Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -184,5 +184,14 @@
android:resource="@xml/filepaths"
/>
</provider>
<!-- EAS Update configuration -->
<meta-data android:name="expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY" android:value="{&quot;expo-channel-name&quot;:&quot;rc&quot;}"/>
<meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION" android:value="7.62.0" />
<meta-data android:name="expo.modules.updates.EXPO_UPDATE_URL" android:value="https://u.expo.dev/" />
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EAS Update URL missing Expo project ID

High Severity

The EAS Update URL https://u.expo.dev/ is incomplete - it's missing the required Expo project UUID. According to ota.config.js, the URL format is https://u.expo.dev/${PROJECT_ID} where PROJECT_ID comes from the EXPO_PROJECT_ID environment variable. This appears to be accidentally committed output from running scripts/update-expo-channel.js without the environment variable set. Without the project ID, OTA updates won't be able to locate the correct Expo project, causing update checks to fail. These configuration changes also appear unrelated to the PR's stated purpose of implementing Perps icon fallback.

🔬 Verification Test

Test code:

// Verify the URL format expected by ota.config.js
const { UPDATE_URL, PROJECT_ID } = require('./ota.config.js');
console.log('PROJECT_ID:', PROJECT_ID || '(empty)');
console.log('UPDATE_URL:', UPDATE_URL);
console.log('URL is incomplete:', UPDATE_URL === 'https://u.expo.dev/');

Command run:

node -e "const { UPDATE_URL, PROJECT_ID } = require('./ota.config.js'); console.log('PROJECT_ID:', PROJECT_ID || '(empty)'); console.log('UPDATE_URL:', UPDATE_URL); console.log('URL is incomplete:', UPDATE_URL === 'https://u.expo.dev/');"

Output:

PROJECT_ID: (empty)
UPDATE_URL: https://u.expo.dev/
URL is incomplete: true

Why this proves the bug: The output shows that without the EXPO_PROJECT_ID environment variable, the URL defaults to https://u.expo.dev/ (incomplete), which matches what was committed to the Android and iOS config files. The Expo Update URL requires a project UUID suffix to function correctly.

Additional Locations (1)

Fix in Cursor Fix in Web

<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="NEVER" />
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0" />
<meta-data android:name="expo.modules.updates.CODE_SIGNING_CERTIFICATE" android:value="-----BEGIN CERTIFICATE-----&#xD;&#xA;MIIC0zCCAbugAwIBAgIJGH+Ulb6OVEeJMA0GCSqGSIb3DQEBCwUAMBMxETAPBgNV&#xD;&#xA;BAMTCE1ldGFNYXNrMB4XDTI1MTIxOTIzMTY1NFoXDTM1MTIxOTIzMTY1NFowEzER&#xD;&#xA;MA8GA1UEAxMITWV0YU1hc2swggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB&#xD;&#xA;AQDWvpseYyys+mFZrxGXMDD4VRGIs0u5mXgjwDf7fEOJWI7uldvVcVDPCO1v+/Ig&#xD;&#xA;0nlz/NgDdVvYNgKFJJq4JMLDJNzxNIcaqSCKlO9IU5qWvnnfEeyWx6Pv0NQssHOD&#xD;&#xA;Sc+WnvfCXub3akM9CE6Noy/KbIHpyUNwypux1eU5KGnZ704kNVsWmU7PeRn5Olnl&#xD;&#xA;6t5Q93sIe2fbESDvHYh0TKC9eQvgkvrCCKlMgyqnZb4fdylXGbWRjivp+AKh/DHz&#xD;&#xA;37bz9KtQO7YF8oZ1QvTiSVOb0hS1rzAE/YMatTbC214FQu5/w3vHCe7ejKcNCjzM&#xD;&#xA;xFm6nSd41ho/SpW4r92dt0xvAgMBAAGjKjAoMA4GA1UdDwEB/wQEAwIHgDAWBgNV&#xD;&#xA;HSUBAf8EDDAKBggrBgEFBQcDAzANBgkqhkiG9w0BAQsFAAOCAQEAn8OKneCOuUcI&#xD;&#xA;wUGRVqWDcej4yYGugWWiIdEgy4as+vXUhMCk47uPwWuKNSILDuPeSxt2lo+AND8U&#xD;&#xA;4N3I87+oYbLktOyph5FtpwUEMC8R/YE8Q5bNmi0LHzzGteenfUhSc+MVhpVVwZ1I&#xD;&#xA;SbHGrj6/oet39FFFqJhWAU+RbMwSnYZKrZTfYELuEppP3WO03P9I8T3XR0d+CmMb&#xD;&#xA;YdeLUvYilZI+3VxKL6tg/UWlZOX8MH6JbtNTm+2YMi1fp/hE8mrd+rR2Re5d87ub&#xD;&#xA;srOY8HZvM0JX4RPddITpfSEwLwrPhdSFRwxyAVmBALYWVHeuiFn2pI3jfNeHF2Lt&#xD;&#xA;SS2hIbf10Q==&#xD;&#xA;-----END CERTIFICATE-----&#xD;&#xA;" />
<meta-data android:name="expo.modules.updates.CODE_SIGNING_METADATA" android:value="{&quot;keyid&quot;:&quot;rc&quot;,&quot;alg&quot;:&quot;rsa-v1_5-sha256&quot;}" />
<meta-data android:name="expo.modules.updates.ENABLED" android:value="true" />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may need to be removed

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sigh sorry about this

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

</application>
</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,14 @@ describe('PerpsTokenLogo', () => {
// Empty symbol results in empty fallback text
});

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

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

it('handles image error by showing text fallback', async () => {
// Arrange
const { UNSAFE_getByType, getByTestId } = render(
it('switches to fallback HyperLiquid URL when primary fails', async () => {
const { UNSAFE_getByType } = render(
<PerpsTokenLogo symbol="BTC" testID="fallback-test" />,
);

const image = UNSAFE_getByType(Image);

// Verify initial URL is primary (MetaMask)
expect(image.props.source.uri).toContain('contract-metadata');

// Simulate primary URL error
await act(async () => {
image.props.onError();
});

// Get updated image after error
const updatedImage = UNSAFE_getByType(Image);

// Verify fallback URL is now used (HyperLiquid)
expect(updatedImage.props.source.uri).toBe(
'https://app.hyperliquid.xyz/coins/BTC.svg',
);
});

it('shows text fallback when both primary and fallback URLs fail', async () => {
const { UNSAFE_getByType, UNSAFE_queryByType, getByTestId } = render(
<PerpsTokenLogo symbol="FAIL" testID="image-error" />,
);

const image = UNSAFE_getByType(Image);

// Act - Simulate image error
// First error - switches to fallback URL
await act(async () => {
image.props.onError();
});

// Assert - Should show text fallback after error
// Get image with fallback URL
const fallbackImage = UNSAFE_getByType(Image);

// Second error - both URLs failed, show text fallback
await act(async () => {
fallbackImage.props.onError();
});

// Verify text fallback is shown
const container = getByTestId('image-error');
expect(container).toBeTruthy();
// Text fallback should show "FA" for "FAIL"
// Image component no longer rendered, text fallback shown instead
expect(UNSAFE_queryByType(Image)).toBeNull();
});

it('correctly applies size prop to container', () => {
Expand Down Expand Up @@ -180,4 +212,30 @@ describe('PerpsTokenLogo', () => {
const image = UNSAFE_getByType(Image);
expect(image.props.source.uri).toContain('BTC.svg');
});

it('resets to primary URL when symbol changes', async () => {
const { UNSAFE_getByType, rerender } = render(
<PerpsTokenLogo symbol="BTC" testID="symbol-change" />,
);

const image = UNSAFE_getByType(Image);

// Trigger error to switch to fallback
await act(async () => {
image.props.onError();
});

// Verify fallback is being used
expect(UNSAFE_getByType(Image).props.source.uri).toContain(
'app.hyperliquid.xyz',
);

// Change symbol
rerender(<PerpsTokenLogo symbol="ETH" testID="symbol-change" />);

// Verify primary URL is used for new symbol
const newImage = UNSAFE_getByType(Image);
expect(newImage.props.source.uri).toContain('contract-metadata');
expect(newImage.props.source.uri).toContain('ETH.svg');
});
});
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Image } from 'expo-image';
import React, { memo, useMemo } from 'react';
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { ActivityIndicator, View } from 'react-native';
import Text, {
TextVariant,
} from '../../../../../component-library/components/Texts/Text';
import { useTokenLogo } from '../../../../hooks/useTokenLogo';
import {
getAssetIconUrl,
getAssetIconUrls,
getPerpsDisplaySymbol,
} from '../../utils/marketUtils';
import {
Expand All @@ -23,12 +23,27 @@ const PerpsTokenLogo: React.FC<PerpsTokenLogoProps> = ({
testID,
recyclingKey,
}) => {
// SVG URL - expo-image handles SVG rendering properly
const imageUri = useMemo(() => {
// Track if we should use fallback URL (after primary fails)
const [useFallbackUrl, setUseFallbackUrl] = useState(false);

// Get both primary (MetaMask) and fallback (HyperLiquid) URLs
const iconUrls = useMemo(() => {
if (!symbol) return null;
return getAssetIconUrl(symbol, K_PREFIX_ASSETS);
return getAssetIconUrls(symbol, K_PREFIX_ASSETS);
}, [symbol]);

// Reset fallback state when symbol changes
useEffect(() => {
setUseFallbackUrl(false);
}, [symbol]);

// Select current image URL based on fallback state
const imageUri = iconUrls
? useFallbackUrl
? iconUrls.fallback
: iconUrls.primary
: null;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale fallback state causes wrong URL on symbol change

Low Severity

When the symbol changes after a primary URL failure, useFallbackUrl retains its true value from the previous symbol during the first render cycle because the reset happens in a useEffect (which runs after render). This causes imageUri to incorrectly use iconUrls.fallback instead of iconUrls.primary for the new symbol. The component then re-renders correctly after the effect runs, but this creates an unnecessary network request to the fallback URL and could cause a brief visual inconsistency if the fallback loads quickly. The state reset could be made synchronous by calculating the initial URL based on symbol change detection rather than relying on a post-render effect.

🔬 Verification Test

Test code:

// This test demonstrates the bug - checking intermediate state
it('should use primary URL immediately when symbol changes (BUG: uses fallback first)', () => {
  // Simulate the render cycle manually
  let useFallbackUrl = true; // State from previous symbol's primary failure
  const newSymbol = 'ETH';
  const iconUrls = {
    primary: 'https://metamask/.../ETH.svg',
    fallback: 'https://hyperliquid/.../ETH.svg'
  };
  
  // First render with new symbol (before useEffect runs)
  const imageUri = iconUrls
    ? useFallbackUrl
      ? iconUrls.fallback  // <-- BUG: This branch is taken
      : iconUrls.primary
    : null;
  
  console.log('First render imageUri:', imageUri);
  console.log('Expected primary URL:', iconUrls.primary);
  console.log('Bug present:', imageUri === iconUrls.fallback);
});

Command run:

node -e "
let useFallbackUrl = true;
const iconUrls = { primary: 'https://metamask/ETH.svg', fallback: 'https://hyperliquid/ETH.svg' };
const imageUri = iconUrls ? (useFallbackUrl ? iconUrls.fallback : iconUrls.primary) : null;
console.log('First render uses:', imageUri.includes('hyperliquid') ? 'FALLBACK (bug)' : 'PRIMARY (correct)');
console.log('Expected: PRIMARY');
"

Output:

First render uses: FALLBACK (bug)
Expected: PRIMARY

Why this proves the bug: The output shows that when useFallbackUrl is true from a previous symbol's failure, the first render with a new symbol incorrectly selects the fallback URL instead of the primary URL. The useEffect that resets useFallbackUrl to false only runs after this initial render, causing one render cycle with the wrong URL.

Fix in Cursor Fix in Web


// Extract display symbol (e.g., "TSLA" from "xyz:TSLA")
const fallbackText = useMemo(() => {
const displaySymbol = getPerpsDisplaySymbol(symbol || '');
Expand All @@ -53,6 +68,22 @@ const PerpsTokenLogo: React.FC<PerpsTokenLogoProps> = ({
assetsRequiringDarkBg: ASSETS_REQUIRING_DARK_BG,
});

// Handle image error with fallback logic:
// 1. If primary URL fails, try fallback URL
// 2. If fallback URL also fails, show text fallback
const handleImageError = useCallback(() => {
if (!useFallbackUrl && iconUrls?.fallback) {
// Primary failed - try fallback URL
setUseFallbackUrl(true);
} else {
// Both URLs failed - show text fallback
handleError();
}
}, [useFallbackUrl, iconUrls?.fallback, handleError]);

// Image key includes fallback state for proper re-render when switching URLs
const imageKey = `${recyclingKey || symbol}-${useFallbackUrl ? 'fallback' : 'primary'}`;

// Show custom two-letter fallback if no symbol or error
if (!symbol || !imageUri || hasError) {
return (
Expand All @@ -72,15 +103,15 @@ const PerpsTokenLogo: React.FC<PerpsTokenLogoProps> = ({
</View>
)}
<Image
key={recyclingKey || symbol} // Use recyclingKey for proper recycling
key={imageKey}
source={{ uri: imageUri }}
style={imageStyle}
onLoadStart={handleLoadStart}
onLoadEnd={handleLoadEnd}
onError={handleError}
onError={handleImageError}
contentFit="contain"
cachePolicy="memory-disk" // Persistent caching across app sessions
recyclingKey={recyclingKey || symbol} // For FlashList optimization
recyclingKey={imageKey} // For FlashList optimization
transition={0} // Disable transition for faster rendering
priority="high" // High priority loading
placeholder={null} // No placeholder for cleaner loading
Expand Down
9 changes: 8 additions & 1 deletion app/components/UI/Perps/constants/hyperLiquidConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,17 @@ export const HYPERLIQUID_ENDPOINTS: HyperLiquidEndpoints = {
testnet: 'wss://api.hyperliquid-testnet.xyz/ws',
};

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

// MetaMask-hosted Perps asset icons (primary source)
// Assets uploaded to: https://github.com/MetaMask/contract-metadata/tree/master/icons/eip155:999
// HIP-3 assets use format: hip3:dex_SYMBOL.svg (e.g., hip3:xyz_AAPL.svg)
// Regular assets use format: SYMBOL.svg (e.g., BTC.svg)
export const METAMASK_PERPS_ICONS_BASE_URL =
'https://raw.githubusercontent.com/MetaMask/contract-metadata/master/icons/eip155:999/';

// Asset configurations for multichain abstraction
export const HYPERLIQUID_ASSET_CONFIGS: HyperLiquidAssetConfigs = {
USDC: {
Expand Down
74 changes: 74 additions & 0 deletions app/components/UI/Perps/utils/marketUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
calculateFundingCountdown,
calculate24hHighLow,
getAssetIconUrl,
getAssetIconUrls,
escapeRegex,
compileMarketPattern,
matchesMarketPattern,
Expand All @@ -16,6 +17,8 @@ import { CandlePeriod } from '../constants/chartConfig';

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

describe('marketUtils', () => {
Expand Down Expand Up @@ -411,6 +414,77 @@ describe('marketUtils', () => {
});
});

describe('getAssetIconUrls', () => {
it('returns primary and fallback URLs for regular asset', () => {
const symbol = 'BTC';

const result = getAssetIconUrls(symbol);

expect(result).toEqual({
primary:
'https://raw.githubusercontent.com/MetaMask/contract-metadata/master/icons/eip155:999/BTC.svg',
fallback: 'https://app.hyperliquid.xyz/coins/BTC.svg',
});
});

it('returns primary and fallback URLs for HIP-3 asset', () => {
const symbol = 'xyz:TSLA';

const result = getAssetIconUrls(symbol);

expect(result).toEqual({
primary:
'https://raw.githubusercontent.com/MetaMask/contract-metadata/master/icons/eip155:999/hip3:xyz_TSLA.svg',
fallback: 'https://app.hyperliquid.xyz/coins/xyz:TSLA.svg',
});
});

it('returns null for empty symbol', () => {
const symbol = '';

const result = getAssetIconUrls(symbol);

expect(result).toBeNull();
});

it('removes k prefix for specified assets', () => {
const symbol = 'kBONK';
const kPrefixAssets = new Set(['KBONK']);

const result = getAssetIconUrls(symbol, kPrefixAssets);

expect(result).toEqual({
primary:
'https://raw.githubusercontent.com/MetaMask/contract-metadata/master/icons/eip155:999/BONK.svg',
fallback: 'https://app.hyperliquid.xyz/coins/BONK.svg',
});
});

it('uppercases lowercase symbols for regular assets', () => {
const symbol = 'eth';

const result = getAssetIconUrls(symbol);

expect(result).toEqual({
primary:
'https://raw.githubusercontent.com/MetaMask/contract-metadata/master/icons/eip155:999/ETH.svg',
fallback: 'https://app.hyperliquid.xyz/coins/ETH.svg',
});
});

it('formats HIP-3 assets with hip3 prefix and underscore separator', () => {
const symbol = 'ABC:xyz100';

const result = getAssetIconUrls(symbol);

expect(result).toEqual({
primary:
'https://raw.githubusercontent.com/MetaMask/contract-metadata/master/icons/eip155:999/hip3:abc_XYZ100.svg',
fallback: 'https://app.hyperliquid.xyz/coins/abc:XYZ100.svg',
});
});
});

describe('Pattern Matching Utilities', () => {
describe('escapeRegex', () => {
it('escapes regex special characters', () => {
Expand Down
Loading
Loading