diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 10dbcce23c98..6a5d6fe93113 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -128,6 +128,12 @@ app/selectors/earnController @MetaMask/metamask-earn **/Earn/** @MetaMask/metamask-earn **/earn/** @MetaMask/metamask-earn +# Perps Team +app/components/UI/Perps/ @MetaMask/perps +app/components/UI/WalletAction/*perps* @MetaMask/perps +**/Perps/** @MetaMask/perps +**/perps/** @MetaMask/perps + # Assets Team app/components/hooks/useIsOriginalNativeTokenSymbol @MetaMask/metamask-assets app/components/hooks/useTokenBalancesController @MetaMask/metamask-assets diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index 2577a637fa05..99f94b73263c 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -97,6 +97,7 @@ import { AssetLoader } from '../../Views/AssetLoader'; import { EarnScreenStack, EarnModalStack } from '../../UI/Earn/routes'; import { BridgeTransactionDetails } from '../../UI/Bridge/components/TransactionDetails/TransactionDetails'; import { BridgeModalStack, BridgeScreenStack } from '../../UI/Bridge/routes'; +import { PerpsScreenStack } from '../../UI/Perps'; import TurnOnBackupAndSync from '../../Views/Identity/TurnOnBackupAndSync/TurnOnBackupAndSync'; import DeFiProtocolPositionDetails from '../../UI/DeFiPositions/DeFiProtocolPositionDetails'; import UnmountOnBlur from '../../Views/UnmountOnBlur'; @@ -863,6 +864,7 @@ const MainNavigator = () => ( component={StakeModalStack} options={clearStackNavigatorOptions} /> + { + const { theme } = params; + const { colors } = theme; + + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.default, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 8, + marginVertical: 16, + }, + headerSpacer: { + width: 24, + }, + headerTitle: { + flex: 1, + textAlign: 'center', + }, + closeButton: { + padding: 4, + }, + listContainer: { + flex: 1, + }, + listHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + marginBottom: 8, + marginTop: 30, + }, + listHeaderLeft: { + flex: 1, + }, + listHeaderRight: { + flex: 1, + alignItems: 'flex-end', + }, + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 24, + }, + errorText: { + textAlign: 'center', + marginBottom: 16, + }, + flashListContent: { + paddingBottom: 16, + }, + skeletonContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 16, + minHeight: 88, + }, + skeletonLeftSection: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + skeletonAvatar: { + width: 40, + height: 40, + borderRadius: 20, + marginRight: 16, + }, + skeletonTokenInfo: { + flex: 1, + }, + skeletonTokenHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 6, + }, + skeletonTokenSymbol: { + width: 60, + height: 16, + borderRadius: 4, + marginRight: 8, + }, + skeletonLeverage: { + width: 30, + height: 14, + borderRadius: 4, + }, + skeletonVolume: { + width: 80, + height: 12, + borderRadius: 4, + }, + skeletonRightSection: { + alignItems: 'flex-end', + flex: 1, + }, + skeletonPrice: { + width: 90, + height: 16, + borderRadius: 4, + marginBottom: 6, + }, + skeletonChange: { + width: 70, + height: 14, + borderRadius: 4, + }, + animatedListContainer: { + flex: 1, + paddingHorizontal: 16, + }, + searchContainer: { + paddingHorizontal: 16, + }, + searchInputContainer: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.background.muted, + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 12, + }, + searchIcon: { + marginRight: 10, + color: colors.icon.muted, + }, + searchInput: { + flex: 1, + fontSize: 16, + color: colors.text.default, + }, + clearButton: { + padding: 4, + marginLeft: 8, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx new file mode 100644 index 000000000000..48e3eb0fa003 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx @@ -0,0 +1,233 @@ +import React, { useEffect, useRef, useState, useMemo } from 'react'; +import { + View, + TouchableOpacity, + SafeAreaView, + Animated, + TextInput, +} from 'react-native'; +import { FlashList } from '@shopify/flash-list'; +import SkeletonPlaceholder from 'react-native-skeleton-placeholder'; +import { useStyles } from '../../../../../component-library/hooks'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; +import Icon, { + IconName, + IconSize, +} from '../../../../../component-library/components/Icons/Icon'; +import { strings } from '../../../../../../locales/i18n'; +import PerpsMarketRowItem from '../../components/PerpsMarketRowItem'; +import { + PerpsMarketListViewProps, + PerpsMarketData, +} from './PerpsMarketListView.types'; +import { usePerpsMarkets } from '../../hooks/usePerpsMarkets'; +import styleSheet from './PerpsMarketListView.styles'; +import { useNavigation } from '@react-navigation/native'; + +const PerpsMarketRowItemSkeleton = () => { + const { styles, theme } = useStyles(styleSheet, {}); + + return ( + + + + + + + + + + + + + + + + + + + ); +}; + +const PerpsMarketListHeader = () => { + const { styles } = useStyles(styleSheet, {}); + + return ( + + + + {strings('perps.token_volume')} + + + + + {strings('perps.last_price_24h_change')} + + + + ); +}; + +const PerpsMarketListView = ({ onMarketSelect }: PerpsMarketListViewProps) => { + const { styles, theme } = useStyles(styleSheet, {}); + const fadeAnimation = useRef(new Animated.Value(0)).current; + const [searchQuery, setSearchQuery] = useState(''); + + const navigation = useNavigation(); + + const { markets, isLoading, error, refresh, isRefreshing } = usePerpsMarkets({ + enablePolling: false, + }); + + useEffect(() => { + if (markets.length > 0) { + Animated.timing(fadeAnimation, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }).start(); + } + }, [markets.length, fadeAnimation]); + + const handleMarketPress = (market: PerpsMarketData) => { + onMarketSelect?.(market); + }; + + const handleRefresh = () => { + if (!isRefreshing) { + refresh(); + } + }; + + const handleClose = () => { + if (navigation.canGoBack()) { + navigation.goBack(); + } + }; + + const filteredMarkets = useMemo(() => { + if (!searchQuery.trim()) { + return markets; + } + const query = searchQuery.toLowerCase().trim(); + return markets.filter( + (market) => + market.symbol.toLowerCase().includes(query) || + market.name.toLowerCase().includes(query), + ); + }, [markets, searchQuery]); + + const renderMarketList = () => { + // Skeleton List + if (filteredMarkets.length === 0 && isLoading) { + return ( + + + {Array.from({ length: 8 }).map((_, index) => ( + //Using index as key is fine here because the list is static + // eslint-disable-next-line react/no-array-index-key + + + + ))} + + ); + } + + // Error (Failed to load markets) + if (error && filteredMarkets.length === 0) { + return ( + + + {strings('perps.failed_to_load_market_data')} + + + + {strings('perps.tap_to_retry')} + + + + ); + } + + return ( + <> + + + ( + + )} + keyExtractor={(item) => item.symbol} + contentContainerStyle={styles.flashListContent} + estimatedItemSize={80} + refreshing={isRefreshing} + onRefresh={handleRefresh} + /> + + + ); + }; + + return ( + + + {/* Header */} + + + + {strings('perps.perpetual_markets')} + + + + + + {/* Search Bar */} + + + + + {searchQuery.length > 0 && ( + setSearchQuery('')} + style={styles.clearButton} + > + + + )} + + + {renderMarketList()} + + + ); +}; + +export default PerpsMarketListView; diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.types.ts b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.types.ts new file mode 100644 index 000000000000..a3eb8228a082 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.types.ts @@ -0,0 +1,59 @@ +// Import HyperLiquid SDK types +import type { + PerpsUniverse, + PerpsAssetCtx, + AllMids, +} from '@deeeed/hyperliquid-node20'; + +/** + * Market data for perps trading (UI-friendly format) + */ +export interface PerpsMarketData { + /** + * Token symbol (e.g., 'BTC', 'ETH') + */ + symbol: string; + /** + * Full token name + */ + name: string; + /** + * Maximum leverage available (e.g., '40x', '25x') + */ + maxLeverage: string; + /** + * Current price as formatted string + */ + price: string; + /** + * 24h price change as formatted string + */ + change24h: string; + /** + * 24h price change percentage + */ + change24hPercent: string; + /** + * Trading volume as formatted string + */ + volume: string; +} + +/** + * Raw market data from HyperLiquid SDK + */ +export interface HyperLiquidMarketData { + universe: PerpsUniverse[]; + assetCtxs: PerpsAssetCtx[]; + allMids: AllMids; +} + +/** + * Props for PerpsMarketListView component + */ +export interface PerpsMarketListViewProps { + /** + * Callback when a market row is selected + */ + onMarketSelect?: (market: PerpsMarketData) => void; +} diff --git a/app/components/UI/Perps/Views/PerpsView.test.tsx b/app/components/UI/Perps/Views/PerpsView.test.tsx new file mode 100644 index 000000000000..a8369a4629f1 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsView.test.tsx @@ -0,0 +1,282 @@ +import React from 'react'; +import { render, fireEvent, waitFor } from '@testing-library/react-native'; +import PerpsView from './PerpsView'; + +// Mock Hyperliquid SDK +const mockAllMids = jest.fn(); +const mockMeta = jest.fn(); +const mockUnsubscribe = jest.fn(); +const mockAllMidsSubscription = jest.fn(); + +jest.mock('@deeeed/hyperliquid-node20', () => ({ + HttpTransport: jest.fn().mockImplementation(() => ({})), + InfoClient: jest.fn().mockImplementation(() => ({ + allMids: mockAllMids, + meta: mockMeta, + })), + WebSocketTransport: jest.fn().mockImplementation(() => ({})), + SubscriptionClient: jest.fn().mockImplementation(() => ({ + allMids: mockAllMidsSubscription, + })), +})); + +describe('PerpsView', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should render correctly', () => { + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('should render header and buttons correctly', () => { + const { getByText } = render(); + + expect(getByText('Perps Trading (Development)')).toBeTruthy(); + expect(getByText('Test @deeeed/hyperliquid-node20 SDK')).toBeTruthy(); + expect(getByText('Test SDK Connection')).toBeTruthy(); + expect(getByText('Test Asset Listing')).toBeTruthy(); + expect(getByText('Test WebSocket Connection')).toBeTruthy(); + }); + + describe('SDK Connection Test', () => { + it('should handle successful SDK connection test', async () => { + const mockMarketData = { BTC: 50000, ETH: 3000 }; + mockAllMids.mockResolvedValueOnce(mockMarketData); + + const { getByText } = render(); + const testButton = getByText('Test SDK Connection'); + + fireEvent.press(testButton); + + await waitFor(() => { + expect(mockAllMids).toHaveBeenCalled(); + expect(getByText(/✅ SDK connection successful!/)).toBeTruthy(); + }); + }); + + it('should handle SDK connection test with no data', async () => { + mockAllMids.mockResolvedValueOnce(null); + + const { getByText } = render(); + const testButton = getByText('Test SDK Connection'); + + fireEvent.press(testButton); + + await waitFor(() => { + expect(mockAllMids).toHaveBeenCalled(); + expect(getByText(/❌ SDK connected but no data received/)).toBeTruthy(); + }); + }); + + it('should handle SDK connection test failure', async () => { + const error = new Error('Network error'); + mockAllMids.mockRejectedValueOnce(error); + + const { getByText } = render(); + const testButton = getByText('Test SDK Connection'); + + fireEvent.press(testButton); + + await waitFor(() => { + expect(mockAllMids).toHaveBeenCalled(); + expect(getByText(/❌ SDK test failed: Network error/)).toBeTruthy(); + }); + }); + }); + + describe('Asset Listing Test', () => { + it('should handle successful asset listing test', async () => { + const mockPerpsMeta = { + universe: [{ name: 'BTC' }, { name: 'ETH' }, { name: 'SOL' }], + }; + mockMeta.mockResolvedValueOnce(mockPerpsMeta); + + const { getByText } = render(); + const testButton = getByText('Test Asset Listing'); + + fireEvent.press(testButton); + + await waitFor(() => { + expect(mockMeta).toHaveBeenCalled(); + expect(getByText(/✅ Found 3 tradeable assets:/)).toBeTruthy(); + expect(getByText(/BTC, ETH, SOL/)).toBeTruthy(); + }); + }); + + it('should handle asset listing test with no assets', async () => { + const mockPerpsMeta = { universe: [] }; + mockMeta.mockResolvedValueOnce(mockPerpsMeta); + + const { getByText } = render(); + const testButton = getByText('Test Asset Listing'); + + fireEvent.press(testButton); + + await waitFor(() => { + expect(mockMeta).toHaveBeenCalled(); + expect(getByText(/❌ No assets found/)).toBeTruthy(); + }); + }); + + it('should handle asset listing test failure', async () => { + const error = new Error('API error'); + mockMeta.mockRejectedValueOnce(error); + + const { getByText } = render(); + const testButton = getByText('Test Asset Listing'); + + fireEvent.press(testButton); + + await waitFor(() => { + expect(mockMeta).toHaveBeenCalled(); + expect(getByText(/❌ Asset listing failed: API error/)).toBeTruthy(); + }); + }); + + it('should truncate asset list when more than 5 assets', async () => { + const mockPerpsMeta = { + universe: [ + { name: 'BTC' }, + { name: 'ETH' }, + { name: 'SOL' }, + { name: 'ADA' }, + { name: 'DOT' }, + { name: 'LINK' }, + { name: 'UNI' }, + ], + }; + mockMeta.mockResolvedValueOnce(mockPerpsMeta); + + const { getByText } = render(); + const testButton = getByText('Test Asset Listing'); + + fireEvent.press(testButton); + + await waitFor(() => { + expect(getByText(/✅ Found 7 tradeable assets:/)).toBeTruthy(); + expect(getByText(/\.\.\./)).toBeTruthy(); + }); + }); + }); + + describe('WebSocket Connection Test', () => { + it('should handle successful WebSocket connection test', async () => { + const mockData = { BTC: 50000, ETH: 3000 }; + const mockSubscription = { unsubscribe: mockUnsubscribe }; + + mockAllMidsSubscription.mockImplementation((callback) => { + setTimeout(() => callback(mockData), 50); + return Promise.resolve(mockSubscription); + }); + mockUnsubscribe.mockResolvedValueOnce(undefined); + + const { getByText } = render(); + const testButton = getByText('Test WebSocket Connection'); + + fireEvent.press(testButton); + + await waitFor(() => { + expect(mockAllMidsSubscription).toHaveBeenCalled(); + }); + + jest.advanceTimersByTime(60); + + await waitFor(() => { + expect(getByText(/✅ WebSocket connection successful!/)).toBeTruthy(); + expect( + getByText(/Received real-time market data with 2 assets/), + ).toBeTruthy(); + }); + + jest.advanceTimersByTime(150); + + await waitFor(() => { + expect(mockUnsubscribe).toHaveBeenCalled(); + }); + }); + + it('should handle WebSocket connection timeout', async () => { + const mockSubscription = { unsubscribe: mockUnsubscribe }; + + mockAllMidsSubscription.mockImplementation(() => + Promise.resolve(mockSubscription), + ); + mockUnsubscribe.mockResolvedValueOnce(undefined); + + const { getByText } = render(); + const testButton = getByText('Test WebSocket Connection'); + + fireEvent.press(testButton); + + await waitFor(() => { + expect(mockAllMidsSubscription).toHaveBeenCalled(); + }); + + jest.advanceTimersByTime(5000); + + await waitFor(() => { + expect(getByText(/⚠️ WebSocket connection timeout/)).toBeTruthy(); + expect(mockUnsubscribe).toHaveBeenCalled(); + }); + }); + + it('should handle WebSocket connection test failure', async () => { + const error = new Error('WebSocket error'); + mockAllMidsSubscription.mockRejectedValueOnce(error); + + const { getByText } = render(); + const testButton = getByText('Test WebSocket Connection'); + + fireEvent.press(testButton); + + await waitFor(() => { + expect(mockAllMidsSubscription).toHaveBeenCalled(); + expect( + getByText(/❌ WebSocket test failed: WebSocket error/), + ).toBeTruthy(); + }); + }); + }); + + it('should show loading state during tests', async () => { + mockAllMids.mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 1000)), + ); + + const { getByText } = render(); + const testButton = getByText('Test SDK Connection'); + + fireEvent.press(testButton); + + // Note: In a real test environment, you would check for loading indicators + // For this component, the loading state is handled by the Button component itself + expect(mockAllMids).toHaveBeenCalled(); + }); + + it('should not display result container when no test result', () => { + const { queryByText } = render(); + + expect(queryByText('Test Result:')).toBeNull(); + }); + + it('should handle unknown errors gracefully', async () => { + mockAllMids.mockRejectedValueOnce('Unknown error string'); + + const { getByText } = render(); + const testButton = getByText('Test SDK Connection'); + + fireEvent.press(testButton); + + await waitFor(() => { + expect(getByText(/❌ SDK test failed: Unknown error/)).toBeTruthy(); + }); + }); +}); diff --git a/app/components/UI/Perps/Views/PerpsView.tsx b/app/components/UI/Perps/Views/PerpsView.tsx new file mode 100644 index 000000000000..87cc1e397d54 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsView.tsx @@ -0,0 +1,272 @@ +import React, { useState } from 'react'; +import { View } from 'react-native'; +import { useStyles } from '../../../../component-library/hooks'; +import Button, { + ButtonVariants, + ButtonSize, + ButtonWidthTypes, +} from '../../../../component-library/components/Buttons/Button'; +import Text, { + TextVariant, + TextColor, +} from '../../../../component-library/components/Texts/Text'; +import ScreenView from '../../../Base/ScreenView'; +import Logger from '../../../../util/Logger'; + +// Import Hyperliquid SDK components +import { + HttpTransport, + InfoClient, + WebSocketTransport, + SubscriptionClient, +} from '@deeeed/hyperliquid-node20'; +import { PERPS_CONSTANTS } from '../constants'; +import Routes from '../../../../constants/navigation/Routes'; +import { useNavigation } from '@react-navigation/native'; + +interface PerpsViewProps {} + +const styleSheet = () => ({ + content: { + flex: 1, + paddingHorizontal: 24, + paddingTop: 24, + paddingBottom: 32, + }, + headerContainer: { + alignItems: 'center' as const, + marginBottom: 32, + }, + buttonContainer: { + marginBottom: 32, + gap: 16, + }, + resultContainer: { + padding: 16, + borderRadius: 8, + marginTop: 16, + }, + resultText: { + marginTop: 8, + lineHeight: 20, + }, +}); + +const PerpsView: React.FC = () => { + const { styles } = useStyles(styleSheet, {}); + const [isLoading, setIsLoading] = useState(false); + const [testResult, setTestResult] = useState(''); + + const navigation = useNavigation(); + + const testSDKConnection = async () => { + setIsLoading(true); + setTestResult(''); + + try { + Logger.log('Perps: Testing SDK connection...'); + // Create HTTP transport and InfoClient + const transport = new HttpTransport(); + const infoClient = new InfoClient({ transport }); + + // Test basic functionality - get all mids (prices) + const allMids = await infoClient.allMids(); + + if (allMids) { + const successMessage = + '✅ SDK connection successful!\nRetrieved market data from Hyperliquid'; + setTestResult(successMessage); + Logger.log('Perps: SDK test successful', { + dataCount: Object.keys(allMids).length, + }); + } else { + const warningMessage = '❌ SDK connected but no data received'; + setTestResult(warningMessage); + Logger.log('Perps: SDK connected but no data received'); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + const fullErrorMessage = `❌ SDK test failed: ${errorMessage}`; + setTestResult(fullErrorMessage); + Logger.log('Perps: SDK test failed', error); + } finally { + setIsLoading(false); + } + }; + + const testAssetListing = async () => { + setIsLoading(true); + setTestResult(''); + + try { + Logger.log('Perps: Testing asset listing...'); + const transport = new HttpTransport(); + const infoClient = new InfoClient({ transport }); + + // Test asset listing functionality - get perps meta data + const perpsMeta = await infoClient.meta(); + + if (perpsMeta?.universe && perpsMeta.universe.length > 0) { + const assets = perpsMeta.universe; + const successMessage = `✅ Found ${ + assets.length + } tradeable assets:\n${assets + .slice(0, 5) + .map((asset: { name: string }) => asset.name) + .join(', ')}${assets.length > 5 ? '...' : ''}`; + setTestResult(successMessage); + Logger.log('Perps: Asset listing successful', { count: assets.length }); + } else { + const warningMessage = '❌ No assets found'; + setTestResult(warningMessage); + Logger.log('Perps: No assets found'); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + const fullErrorMessage = `❌ Asset listing failed: ${errorMessage}`; + setTestResult(fullErrorMessage); + Logger.log('Perps: Asset listing failed', error); + } finally { + setIsLoading(false); + } + }; + + const testWebSocketConnection = async () => { + setIsLoading(true); + setTestResult(''); + let hasReceivedData = false; + + try { + Logger.log('Perps: Testing WebSocket connection...'); + const transport = new WebSocketTransport(); + const subsClient = new SubscriptionClient({ transport }); + + // Test WebSocket connection with a simple subscription + const subscription = await subsClient.allMids((data) => { + if (!hasReceivedData) { + hasReceivedData = true; + Logger.log('Perps: WebSocket data received', { + dataKeys: Object.keys(data).length, + }); + + const successMessage = `✅ WebSocket connection successful!\nReceived real-time market data with ${ + Object.keys(data).length + } assets`; + setTestResult(successMessage); + + // Unsubscribe after receiving first data + setTimeout(async () => { + try { + await subscription.unsubscribe(); + Logger.log('Perps: WebSocket subscription cleaned up'); + setIsLoading(false); + } catch (error) { + Logger.log('Perps: Error unsubscribing', error); + setIsLoading(false); + } + }, PERPS_CONSTANTS.WEBSOCKET_CLEANUP_DELAY); + } + }); + + // Reduce timeout to 5 seconds and check connection status + setTimeout(async () => { + if (!hasReceivedData) { + try { + await subscription.unsubscribe(); + const timeoutMessage = + '⚠️ WebSocket connection timeout - no data received within 5 seconds\nThis might be normal if the market is closed'; + setTestResult(timeoutMessage); + Logger.log( + 'Perps: WebSocket connection timeout - may be market hours related', + ); + } catch (error) { + Logger.log('Perps: Error during timeout cleanup', error); + } + setIsLoading(false); + } + }, 5000); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + const fullErrorMessage = `❌ WebSocket test failed: ${errorMessage}`; + setTestResult(fullErrorMessage); + Logger.log('Perps: WebSocket test failed', error); + setIsLoading(false); + } + }; + + const openPerpsMarketList = () => { + navigation.navigate(Routes.PERPS.MARKETS_LIST); + }; + + return ( + + + + + Perps Trading (Development) + + + Test @deeeed/hyperliquid-node20 SDK + + + + +