diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index dddc9d2f328c..62db57195574 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -1945,7 +1945,7 @@ export function getPerpsMarketDetailsNavbar(navigation, title) { }, }); // Always navigate back to markets page for consistent navigation - const leftAction = () => navigation.navigate(Routes.PERPS.MARKETS); + const leftAction = () => navigation.navigate(Routes.PERPS.PERPS_HOME); return { headerTitle: () => ( diff --git a/app/components/UI/Perps/Views/PerpsCancelAllOrdersView/PerpsCancelAllOrdersView.styles.ts b/app/components/UI/Perps/Views/PerpsCancelAllOrdersView/PerpsCancelAllOrdersView.styles.ts new file mode 100644 index 000000000000..1bd70533361f --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsCancelAllOrdersView/PerpsCancelAllOrdersView.styles.ts @@ -0,0 +1,27 @@ +import { StyleSheet } from 'react-native'; +import type { Theme } from '../../../../../util/theme/models'; + +export const createStyles = (_theme: Theme) => + StyleSheet.create({ + contentContainer: { + paddingHorizontal: 16, + paddingVertical: 16, + }, + loadingContainer: { + paddingVertical: 32, + alignItems: 'center', + justifyContent: 'center', + }, + loadingText: { + marginTop: 12, + }, + emptyContainer: { + paddingVertical: 32, + paddingHorizontal: 16, + alignItems: 'center', + justifyContent: 'center', + }, + footerContainer: { + paddingHorizontal: 16, + }, + }); diff --git a/app/components/UI/Perps/Views/PerpsCancelAllOrdersView/PerpsCancelAllOrdersView.test.tsx b/app/components/UI/Perps/Views/PerpsCancelAllOrdersView/PerpsCancelAllOrdersView.test.tsx new file mode 100644 index 000000000000..fd26d927a819 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsCancelAllOrdersView/PerpsCancelAllOrdersView.test.tsx @@ -0,0 +1,229 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import PerpsCancelAllOrdersView from './PerpsCancelAllOrdersView'; +import { usePerpsCancelAllOrders, usePerpsLiveOrders } from '../../hooks'; + +// Mock all dependencies +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(() => ({ navigate: jest.fn(), goBack: jest.fn() })), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => key), +})); + +jest.mock('../../hooks', () => ({ + usePerpsLiveOrders: jest.fn(), + usePerpsCancelAllOrders: jest.fn(), +})); + +jest.mock('../../hooks/usePerpsToasts', () => ({ + __esModule: true, + default: jest.fn(() => ({ showToast: jest.fn() })), +})); + +jest.mock('../../../../../util/theme', () => ({ + useTheme: jest.fn(() => ({ + colors: { + accent03: { normal: '#00ff00', dark: '#008800' }, + accent01: { light: '#ffcccc', dark: '#cc0000' }, + primary: { default: '#0000ff' }, + background: { default: '#ffffff' }, + }, + })), +})); + +jest.mock('../../hooks/usePerpsEventTracking', () => ({ + usePerpsEventTracking: jest.fn(), +})); + +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheet', + () => { + const mockReact = jest.requireActual('react'); + return mockReact.forwardRef( + (props: { children: React.ReactNode }, _ref) => <>{props.children}, + ); + }, +); + +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheetHeader', + () => 'BottomSheetHeader', +); + +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheetFooter', + () => { + const { View, TouchableOpacity, Text } = jest.requireActual('react-native'); + + return { + __esModule: true, + default: ({ + buttonPropsArray, + }: { + buttonPropsArray?: { + label: string; + onPress: () => void; + disabled?: boolean; + }[]; + }) => ( + + {buttonPropsArray?.map((buttonProps, index) => ( + + {buttonProps.label} + + ))} + + ), + ButtonsAlignment: { + Horizontal: 'Horizontal', + Vertical: 'Vertical', + }, + }; + }, +); + +const mockUsePerpsLiveOrders = usePerpsLiveOrders as jest.MockedFunction< + typeof usePerpsLiveOrders +>; +const mockUsePerpsCancelAllOrders = + usePerpsCancelAllOrders as jest.MockedFunction< + typeof usePerpsCancelAllOrders + >; + +describe('PerpsCancelAllOrdersView', () => { + const mockOrders = [ + { + orderId: 'order-1', + symbol: 'BTC', + side: 'buy' as const, + orderType: 'limit' as const, + size: '0.1', + originalSize: '0.1', + price: '50000', + filledSize: '0', + remainingSize: '0.1', + status: 'open' as const, + timestamp: Date.now(), + }, + { + orderId: 'order-2', + symbol: 'ETH', + side: 'sell' as const, + orderType: 'limit' as const, + size: '1.0', + originalSize: '1.0', + price: '3000', + filledSize: '0', + remainingSize: '1.0', + status: 'open' as const, + timestamp: Date.now(), + }, + ]; + + const mockCancelAllHook = { + isCanceling: false, + orderCount: 2, + handleCancelAll: jest.fn(), + handleKeepOrders: jest.fn(), + error: null, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: mockOrders, + isInitialLoading: false, + }); + mockUsePerpsCancelAllOrders.mockReturnValue(mockCancelAllHook); + }); + + it('renders cancel all orders view with orders', () => { + // Arrange & Act + const { getByText } = render(); + + // Assert + expect(getByText('perps.cancel_all_modal.title')).toBeTruthy(); + expect(getByText('perps.cancel_all_modal.description')).toBeTruthy(); + }); + + it('renders empty state when no orders', () => { + // Arrange + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [], + isInitialLoading: false, + }); + mockUsePerpsCancelAllOrders.mockReturnValue({ + ...mockCancelAllHook, + orderCount: 0, + }); + + // Act + const { getByText } = render(); + + // Assert + expect(getByText('perps.order.no_orders')).toBeTruthy(); + }); + + it('renders loading state when canceling', () => { + // Arrange + mockUsePerpsCancelAllOrders.mockReturnValue({ + ...mockCancelAllHook, + isCanceling: true, + }); + + // Act + const { getAllByText } = render(); + + // Assert + const cancelingElements = getAllByText('perps.cancel_all_modal.canceling'); + expect(cancelingElements.length).toBeGreaterThan(0); + }); + + it('displays footer buttons with correct labels', () => { + // Arrange & Act + const { getByText } = render(); + + // Assert + expect(getByText('perps.cancel_all_modal.keep_orders')).toBeTruthy(); + expect(getByText('perps.cancel_all_modal.confirm')).toBeTruthy(); + }); + + it('shows canceling label on confirm button when in progress', () => { + // Arrange + mockUsePerpsCancelAllOrders.mockReturnValue({ + ...mockCancelAllHook, + isCanceling: true, + }); + + // Act + const { getAllByText } = render(); + + // Assert + const cancelingElements = getAllByText('perps.cancel_all_modal.canceling'); + expect(cancelingElements.length).toBeGreaterThan(0); + }); + + it('renders with empty orders gracefully', () => { + // Arrange + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [], + isInitialLoading: false, + }); + mockUsePerpsCancelAllOrders.mockReturnValue({ + ...mockCancelAllHook, + orderCount: 0, + }); + + // Act + const { getByText } = render(); + + // Assert + expect(getByText('perps.order.no_orders')).toBeTruthy(); + }); +}); diff --git a/app/components/UI/Perps/Views/PerpsCancelAllOrdersView/PerpsCancelAllOrdersView.tsx b/app/components/UI/Perps/Views/PerpsCancelAllOrdersView/PerpsCancelAllOrdersView.tsx new file mode 100644 index 000000000000..34e24a850b98 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsCancelAllOrdersView/PerpsCancelAllOrdersView.tsx @@ -0,0 +1,232 @@ +import { useNavigation } from '@react-navigation/native'; +import React, { useCallback, useMemo, useRef } from 'react'; +import { View, ActivityIndicator } from 'react-native'; +import { NotificationFeedbackType } from 'expo-haptics'; +import { strings } from '../../../../../../locales/i18n'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../../component-library/components/BottomSheets/BottomSheet'; +import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import BottomSheetFooter, { + ButtonsAlignment, +} from '../../../../../component-library/components/BottomSheets/BottomSheetFooter'; +import Text, { + TextColor, + TextVariant, +} from '../../../../../component-library/components/Texts/Text'; +import { + ButtonSize, + ButtonVariants, +} from '../../../../../component-library/components/Buttons/Button'; +import { IconName } from '../../../../../component-library/components/Icons/Icon'; +import { ToastVariants } from '../../../../../component-library/components/Toast/Toast.types'; +import { usePerpsLiveOrders, usePerpsCancelAllOrders } from '../../hooks'; +import usePerpsToasts, { + type PerpsToastOptions, +} from '../../hooks/usePerpsToasts'; +import { createStyles } from './PerpsCancelAllOrdersView.styles'; +import { useTheme } from '../../../../../util/theme'; +import { MetaMetricsEvents } from '../../../../hooks/useMetrics'; +import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; +import { + PerpsEventProperties, + PerpsEventValues, +} from '../../constants/eventNames'; +import type { CancelOrdersResult } from '../../controllers/types'; + +const PerpsCancelAllOrdersView: React.FC = () => { + const theme = useTheme(); + const styles = createStyles(theme); + const navigation = useNavigation(); + const sheetRef = useRef(null); + const { showToast } = usePerpsToasts(); + + // Fetch orders from live stream (excluding TP/SL orders) + const { orders } = usePerpsLiveOrders({ + throttleMs: 1000, + hideTpSl: true, // Exclude Take Profit and Stop Loss orders + }); + + // Track screen viewed event + usePerpsEventTracking({ + eventName: MetaMetricsEvents.PERPS_SCREEN_VIEWED, + conditions: [true], // Always track when component mounts (WebSocket data loads instantly) + properties: { + [PerpsEventProperties.SCREEN_TYPE]: + PerpsEventValues.SCREEN_TYPE.CANCEL_ALL_ORDERS, + [PerpsEventProperties.OPEN_POSITION]: orders?.length || 0, + }, + }); + + // Toast helper for success + const showSuccessToast = useCallback( + (title: string, message?: string) => { + const toastConfig: PerpsToastOptions = { + variant: ToastVariants.Icon, + iconName: IconName.CheckBold, + backgroundColor: theme.colors.accent03.normal, + iconColor: theme.colors.accent03.dark, + hapticsType: NotificationFeedbackType.Success, + hasNoTimeout: false, + labelOptions: message + ? [ + { label: title, isBold: true }, + { label: '\n', isBold: false }, + { label: message, isBold: false }, + ] + : [{ label: title, isBold: true }], + }; + showToast(toastConfig); + }, + [showToast, theme.colors.accent03], + ); + + // Toast helper for errors + const showErrorToast = useCallback( + (title: string, message?: string) => { + const toastConfig: PerpsToastOptions = { + variant: ToastVariants.Icon, + iconName: IconName.Warning, + backgroundColor: theme.colors.accent01.light, + iconColor: theme.colors.accent01.dark, + hapticsType: NotificationFeedbackType.Error, + hasNoTimeout: false, + labelOptions: message + ? [ + { label: title, isBold: true }, + { label: '\n', isBold: false }, + { label: message, isBold: false }, + ] + : [{ label: title, isBold: true }], + }; + showToast(toastConfig); + }, + [showToast, theme.colors.accent01], + ); + + // Handle success callback from hook + const handleSuccess = useCallback( + (result: CancelOrdersResult) => { + if (result.success && result.successCount > 0) { + showSuccessToast( + strings('perps.cancel_all_modal.success_title'), + strings('perps.cancel_all_modal.success_message', { + count: result.successCount, + }), + ); + } else if (result.successCount > 0 && result.failureCount > 0) { + showSuccessToast( + strings('perps.cancel_all_modal.success_title'), + strings('perps.cancel_all_modal.partial_success', { + successCount: result.successCount, + totalCount: result.successCount + result.failureCount, + }), + ); + } + }, + [showSuccessToast], + ); + + // Handle error callback from hook + const handleError = useCallback( + (error: Error) => { + showErrorToast( + strings('perps.cancel_all_modal.error_title'), + error.message || 'Unknown error', + ); + }, + [showErrorToast], + ); + + // Use cancel all orders hook for business logic + const { isCanceling, handleCancelAll, handleKeepOrders } = + usePerpsCancelAllOrders(orders, { + onSuccess: handleSuccess, + onError: handleError, + }); + + const handleClose = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + const footerButtons = useMemo( + () => [ + { + label: strings('perps.cancel_all_modal.keep_orders'), + onPress: handleKeepOrders, + variant: ButtonVariants.Secondary, + size: ButtonSize.Lg, + disabled: isCanceling, + }, + { + label: isCanceling + ? strings('perps.cancel_all_modal.canceling') + : strings('perps.cancel_all_modal.confirm'), + onPress: handleCancelAll, + variant: ButtonVariants.Primary, + size: ButtonSize.Lg, + disabled: isCanceling, + danger: true, + }, + ], + [handleKeepOrders, handleCancelAll, isCanceling], + ); + + // Show empty state if no orders (WebSocket data loads instantly, no loading state needed) + if (!orders || orders.length === 0) { + return ( + + + + {strings('perps.cancel_all_modal.title')} + + + + + {strings('perps.order.no_orders')} + + + + ); + } + + return ( + + + + {strings('perps.cancel_all_modal.title')} + + + + + {isCanceling ? ( + + + + {strings('perps.cancel_all_modal.canceling')} + + + ) : ( + + {strings('perps.cancel_all_modal.description')} + + )} + + + + + ); +}; + +export default PerpsCancelAllOrdersView; diff --git a/app/components/UI/Perps/Views/PerpsCloseAllPositionsView/PerpsCloseAllPositionsView.styles.ts b/app/components/UI/Perps/Views/PerpsCloseAllPositionsView/PerpsCloseAllPositionsView.styles.ts new file mode 100644 index 000000000000..45ad434ba981 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsCloseAllPositionsView/PerpsCloseAllPositionsView.styles.ts @@ -0,0 +1,38 @@ +import { StyleSheet } from 'react-native'; +import type { Theme } from '../../../../../util/theme/models'; + +export const createStyles = (_theme: Theme) => + StyleSheet.create({ + contentContainer: { + paddingHorizontal: 16, + paddingVertical: 8, + }, + description: { + marginBottom: 16, + }, + breakdownContainer: { + gap: 12, + }, + loadingContainer: { + paddingVertical: 32, + alignItems: 'center', + justifyContent: 'center', + }, + loadingText: { + marginTop: 12, + }, + emptyContainer: { + paddingVertical: 32, + paddingHorizontal: 16, + alignItems: 'center', + justifyContent: 'center', + }, + footerContainer: { + paddingHorizontal: 16, + }, + labelWithTooltip: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + }); diff --git a/app/components/UI/Perps/Views/PerpsCloseAllPositionsView/PerpsCloseAllPositionsView.test.tsx b/app/components/UI/Perps/Views/PerpsCloseAllPositionsView/PerpsCloseAllPositionsView.test.tsx new file mode 100644 index 000000000000..ddfe766bde7d --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsCloseAllPositionsView/PerpsCloseAllPositionsView.test.tsx @@ -0,0 +1,273 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import PerpsCloseAllPositionsView from './PerpsCloseAllPositionsView'; +import { + usePerpsLivePositions, + usePerpsCloseAllCalculations, + usePerpsCloseAllPositions, +} from '../../hooks'; + +// Mock all dependencies +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(() => ({ navigate: jest.fn(), goBack: jest.fn() })), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => key), +})); + +jest.mock('../../hooks', () => ({ + usePerpsLivePositions: jest.fn(), + usePerpsCloseAllCalculations: jest.fn(), + usePerpsCloseAllPositions: jest.fn(), +})); + +jest.mock('../../hooks/stream', () => ({ + usePerpsLivePrices: jest.fn(() => ({})), +})); + +jest.mock('../../hooks/usePerpsToasts', () => ({ + __esModule: true, + default: jest.fn(() => ({ showToast: jest.fn() })), +})); + +jest.mock('../../../../../util/theme', () => ({ + useTheme: jest.fn(() => ({ + colors: { + accent03: { normal: '#00ff00', dark: '#008800' }, + accent01: { light: '#ffcccc', dark: '#cc0000' }, + primary: { default: '#0000ff' }, + background: { default: '#ffffff' }, + }, + })), +})); + +jest.mock('../../hooks/usePerpsEventTracking', () => ({ + usePerpsEventTracking: jest.fn(), +})); + +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheet', + () => { + const mockReact = jest.requireActual('react'); + return mockReact.forwardRef( + (props: { children: React.ReactNode }, _ref) => <>{props.children}, + ); + }, +); + +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheetHeader', + () => 'BottomSheetHeader', +); + +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheetFooter', + () => { + const { View, TouchableOpacity, Text } = jest.requireActual('react-native'); + + return { + __esModule: true, + default: ({ + buttonPropsArray, + }: { + buttonPropsArray?: { + label: string; + onPress: () => void; + disabled?: boolean; + }[]; + }) => ( + + {buttonPropsArray?.map((buttonProps, index) => ( + + {buttonProps.label} + + ))} + + ), + ButtonsAlignment: { + Horizontal: 'Horizontal', + Vertical: 'Vertical', + }, + }; + }, +); + +jest.mock('../../components/PerpsCloseSummary', () => 'PerpsCloseSummary'); + +const mockUsePerpsLivePositions = usePerpsLivePositions as jest.MockedFunction< + typeof usePerpsLivePositions +>; +const mockUsePerpsCloseAllCalculations = + usePerpsCloseAllCalculations as jest.MockedFunction< + typeof usePerpsCloseAllCalculations + >; +const mockUsePerpsCloseAllPositions = + usePerpsCloseAllPositions as jest.MockedFunction< + typeof usePerpsCloseAllPositions + >; + +describe('PerpsCloseAllPositionsView', () => { + const mockPositions = [ + { + coin: 'BTC', + size: '0.5', + entryPrice: '50000', + positionValue: '25000', + unrealizedPnl: '100', + marginUsed: '1000', + leverage: { type: 'cross' as const, value: 25 }, + liquidationPrice: '48000', + maxLeverage: 50, + returnOnEquity: '10', + cumulativeFunding: { + allTime: '0', + sinceOpen: '0', + sinceChange: '0', + }, + roi: '10', + takeProfitPrice: undefined, + stopLossPrice: undefined, + takeProfitCount: 0, + stopLossCount: 0, + marketPrice: '50200', + timestamp: Date.now(), + }, + ]; + + const mockCalculations = { + totalMargin: 1000, + totalPnl: 100, + totalFees: 10, + receiveAmount: 1090, + totalEstimatedPoints: 50, + avgFeeDiscountPercentage: 5, + avgBonusBips: 10, + avgMetamaskFeeRate: 0.01, + avgProtocolFeeRate: 0.00045, + avgOriginalMetamaskFeeRate: 0.015, + isLoading: false, + hasError: false, + shouldShowRewards: true, + }; + + const mockCloseAllHook = { + isClosing: false, + positionCount: 1, + handleCloseAll: jest.fn(), + handleKeepPositions: jest.fn(), + error: null, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUsePerpsLivePositions.mockReturnValue({ + positions: mockPositions, + isInitialLoading: false, + }); + mockUsePerpsCloseAllCalculations.mockReturnValue(mockCalculations); + mockUsePerpsCloseAllPositions.mockReturnValue(mockCloseAllHook); + }); + + it('renders loading state when initially loading positions', () => { + // Arrange + mockUsePerpsLivePositions.mockReturnValue({ + positions: [], + isInitialLoading: true, + }); + + // Act + const { getByText } = render(); + + // Assert + expect(getByText('perps.close_all_modal.title')).toBeTruthy(); + }); + + it('renders empty state when no positions', () => { + // Arrange + mockUsePerpsLivePositions.mockReturnValue({ + positions: [], + isInitialLoading: false, + }); + + // Act + const { getByText } = render(); + + // Assert + expect(getByText('perps.position.no_positions')).toBeTruthy(); + }); + + it('renders close all positions view with positions', () => { + // Arrange & Act + const { getByText } = render(); + + // Assert + expect(getByText('perps.close_all_modal.title')).toBeTruthy(); + expect(getByText('perps.close_all_modal.description')).toBeTruthy(); + }); + + it('renders loading state when closing', () => { + // Arrange + mockUsePerpsCloseAllPositions.mockReturnValue({ + ...mockCloseAllHook, + isClosing: true, + }); + + // Act + const { getAllByText } = render(); + + // Assert + const closingElements = getAllByText('perps.close_all_modal.closing'); + expect(closingElements.length).toBeGreaterThan(0); + }); + + it('displays footer buttons with correct labels', () => { + // Arrange & Act + const { getByText } = render(); + + // Assert + expect(getByText('perps.close_all_modal.keep_positions')).toBeTruthy(); + expect(getByText('perps.close_all_modal.close_all')).toBeTruthy(); + }); + + it('shows closing label on close button when in progress', () => { + // Arrange + mockUsePerpsCloseAllPositions.mockReturnValue({ + ...mockCloseAllHook, + isClosing: true, + }); + + // Act + const { getAllByText } = render(); + + // Assert + const closingElements = getAllByText('perps.close_all_modal.closing'); + expect(closingElements.length).toBeGreaterThan(0); + }); + + it('renders with empty positions gracefully', () => { + // Arrange + mockUsePerpsLivePositions.mockReturnValue({ + positions: [], + isInitialLoading: false, + }); + + // Act + const { getByText } = render(); + + // Assert + expect(getByText('perps.position.no_positions')).toBeTruthy(); + }); + + it('renders PerpsCloseSummary when not closing', () => { + // Arrange & Act + const { getByText } = render(); + + // Assert + expect(getByText('perps.close_all_modal.description')).toBeTruthy(); + }); +}); diff --git a/app/components/UI/Perps/Views/PerpsCloseAllPositionsView/PerpsCloseAllPositionsView.tsx b/app/components/UI/Perps/Views/PerpsCloseAllPositionsView/PerpsCloseAllPositionsView.tsx new file mode 100644 index 000000000000..964e20e717ab --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsCloseAllPositionsView/PerpsCloseAllPositionsView.tsx @@ -0,0 +1,300 @@ +import { useNavigation } from '@react-navigation/native'; +import React, { useCallback, useMemo, useRef } from 'react'; +import { View, ActivityIndicator } from 'react-native'; +import { NotificationFeedbackType } from 'expo-haptics'; +import { strings } from '../../../../../../locales/i18n'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../../component-library/components/BottomSheets/BottomSheet'; +import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import BottomSheetFooter, { + ButtonsAlignment, +} from '../../../../../component-library/components/BottomSheets/BottomSheetFooter'; +import Text, { + TextColor, + TextVariant, +} from '../../../../../component-library/components/Texts/Text'; +import { + ButtonSize, + ButtonVariants, +} from '../../../../../component-library/components/Buttons/Button'; +import { IconName } from '../../../../../component-library/components/Icons/Icon'; +import { ToastVariants } from '../../../../../component-library/components/Toast/Toast.types'; +import { + usePerpsLivePositions, + usePerpsCloseAllCalculations, + usePerpsCloseAllPositions, +} from '../../hooks'; +import { usePerpsLivePrices } from '../../hooks/stream'; +import usePerpsToasts, { + type PerpsToastOptions, +} from '../../hooks/usePerpsToasts'; +import PerpsCloseSummary from '../../components/PerpsCloseSummary'; +import { createStyles } from './PerpsCloseAllPositionsView.styles'; +import { useTheme } from '../../../../../util/theme'; +import { MetaMetricsEvents } from '../../../../hooks/useMetrics'; +import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; +import { + PerpsEventProperties, + PerpsEventValues, +} from '../../constants/eventNames'; +import type { ClosePositionsResult } from '../../controllers/types'; + +const PerpsCloseAllPositionsView: React.FC = () => { + const theme = useTheme(); + const styles = createStyles(theme); + const navigation = useNavigation(); + const sheetRef = useRef(null); + const { showToast } = usePerpsToasts(); + + // Fetch positions from live stream + const { positions, isInitialLoading } = usePerpsLivePositions({ + throttleMs: 1000, + }); + + // Fetch current prices for fee calculations (throttled to avoid excessive updates) + const symbols = useMemo( + () => (positions || []).map((pos) => pos.coin), + [positions], + ); + const priceData = usePerpsLivePrices({ + symbols, + throttleMs: 1000, + }); + + // Use hook for accurate fee and rewards calculations + const calculations = usePerpsCloseAllCalculations({ + positions: positions || [], + priceData, + }); + + // Track screen viewed event + usePerpsEventTracking({ + eventName: MetaMetricsEvents.PERPS_SCREEN_VIEWED, + conditions: [!isInitialLoading], + properties: { + [PerpsEventProperties.SCREEN_TYPE]: + PerpsEventValues.SCREEN_TYPE.CLOSE_ALL_POSITIONS, + [PerpsEventProperties.OPEN_POSITION]: positions?.length || 0, + }, + }); + + // Toast helper for success + const showSuccessToast = useCallback( + (title: string, message?: string) => { + const toastConfig: PerpsToastOptions = { + variant: ToastVariants.Icon, + iconName: IconName.CheckBold, + backgroundColor: theme.colors.accent03.normal, + iconColor: theme.colors.accent03.dark, + hapticsType: NotificationFeedbackType.Success, + hasNoTimeout: false, + labelOptions: message + ? [ + { label: title, isBold: true }, + { label: '\n', isBold: false }, + { label: message, isBold: false }, + ] + : [{ label: title, isBold: true }], + }; + showToast(toastConfig); + }, + [showToast, theme.colors.accent03], + ); + + // Toast helper for errors + const showErrorToast = useCallback( + (title: string, message?: string) => { + const toastConfig: PerpsToastOptions = { + variant: ToastVariants.Icon, + iconName: IconName.Warning, + backgroundColor: theme.colors.accent01.light, + iconColor: theme.colors.accent01.dark, + hapticsType: NotificationFeedbackType.Error, + hasNoTimeout: false, + labelOptions: message + ? [ + { label: title, isBold: true }, + { label: '\n', isBold: false }, + { label: message, isBold: false }, + ] + : [{ label: title, isBold: true }], + }; + showToast(toastConfig); + }, + [showToast, theme.colors.accent01], + ); + + // Handle success callback from hook + const handleSuccess = useCallback( + (result: ClosePositionsResult) => { + if (result.success && result.successCount > 0) { + showSuccessToast( + strings('perps.close_all_modal.success_title'), + strings('perps.close_all_modal.success_message', { + count: result.successCount, + }), + ); + } else if (result.successCount > 0 && result.failureCount > 0) { + showSuccessToast( + strings('perps.close_all_modal.success_title'), + strings('perps.close_all_modal.partial_success', { + successCount: result.successCount, + totalCount: result.successCount + result.failureCount, + }), + ); + } + }, + [showSuccessToast], + ); + + // Handle error callback from hook + const handleError = useCallback( + (error: Error) => { + showErrorToast( + strings('perps.close_all_modal.error_title'), + error.message || 'Unknown error', + ); + }, + [showErrorToast], + ); + + // Use close all positions hook for business logic + const { isClosing, handleCloseAll, handleKeepPositions } = + usePerpsCloseAllPositions(positions, { + onSuccess: handleSuccess, + onError: handleError, + calculations: { + totalMargin: String(calculations.totalMargin), + totalPnl: String(calculations.totalPnl), + totalFees: String(calculations.totalFees), + receiveAmount: String(calculations.receiveAmount), + }, + }); + + const handleClose = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + const footerButtons = useMemo( + () => [ + { + label: strings('perps.close_all_modal.keep_positions'), + onPress: handleKeepPositions, + variant: ButtonVariants.Secondary, + size: ButtonSize.Lg, + disabled: isClosing, + }, + { + label: isClosing + ? strings('perps.close_all_modal.closing') + : strings('perps.close_all_modal.close_all'), + onPress: handleCloseAll, + variant: ButtonVariants.Primary, + size: ButtonSize.Lg, + disabled: isClosing, + danger: true, + }, + ], + [handleKeepPositions, handleCloseAll, isClosing], + ); + + // Show loading state while fetching positions + if (isInitialLoading) { + return ( + + + + {strings('perps.close_all_modal.title')} + + + + + + + ); + } + + // Show empty state if no positions + if (!positions || positions.length === 0) { + return ( + + + + {strings('perps.close_all_modal.title')} + + + + + {strings('perps.position.no_positions')} + + + + ); + } + + return ( + + + + {strings('perps.close_all_modal.title')} + + + + + + {strings('perps.close_all_modal.description')} + + + {isClosing ? ( + + + + {strings('perps.close_all_modal.closing')} + + + ) : ( + + )} + + + + + ); +}; + +export default PerpsCloseAllPositionsView; diff --git a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx index 33ea2b69f9df..4cc9e051f914 100644 --- a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx +++ b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx @@ -13,10 +13,7 @@ import React, { } from 'react'; import { ScrollView, TouchableOpacity, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { - PerpsClosePositionViewSelectorsIDs, - PerpsOrderViewSelectorsIDs, -} from '../../../../../../e2e/selectors/Perps/Perps.selectors'; +import { PerpsClosePositionViewSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; import { strings } from '../../../../../../locales/i18n'; import Button, { ButtonSize, @@ -54,7 +51,6 @@ import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; import { formatPositionSize, formatPerpsFiat, - PRICE_RANGES_MINIMAL_VIEW, PRICE_RANGES_UNIVERSAL, } from '../../utils/formatUtils'; import { @@ -69,16 +65,10 @@ import { import { MetaMetricsEvents } from '../../../../hooks/useMetrics'; import { TraceName } from '../../../../../util/trace'; import PerpsOrderHeader from '../../components/PerpsOrderHeader'; -import PerpsFeesDisplay from '../../components/PerpsFeesDisplay'; import PerpsAmountDisplay from '../../components/PerpsAmountDisplay'; -import PerpsBottomSheetTooltip from '../../components/PerpsBottomSheetTooltip'; -import { PerpsTooltipContentKey } from '../../components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types'; import PerpsLimitPriceBottomSheet from '../../components/PerpsLimitPriceBottomSheet'; import PerpsSlider from '../../components/PerpsSlider/PerpsSlider'; -import useTooltipModal from '../../../../../components/hooks/useTooltipModal'; -import RewardsAnimations, { - RewardAnimationState, -} from '../../../Rewards/components/RewardPointsAnimation'; +import PerpsCloseSummary from '../../components/PerpsCloseSummary'; const PerpsClosePositionView: React.FC = () => { const theme = useTheme(); @@ -91,7 +81,6 @@ const PerpsClosePositionView: React.FC = () => { const inputMethodRef = useRef('default'); const { showToast, PerpsToastOptions } = usePerpsToasts(); - const { openTooltipModal } = useTooltipModal(); // Track screen load performance with unified hook (immediate measurement) usePerpsMeasurement({ @@ -103,8 +92,6 @@ const PerpsClosePositionView: React.FC = () => { const [isLimitPriceVisible, setIsLimitPriceVisible] = useState(false); const [isInputFocused, setIsInputFocused] = useState(false); const [isUserInputActive, setIsUserInputActive] = useState(false); - const [selectedTooltip, setSelectedTooltip] = - useState(null); // State for close amount const [closePercentage, setClosePercentage] = useState(100); // Default to 100% (full close) @@ -433,27 +420,6 @@ const PerpsClosePositionView: React.FC = () => { setClosePercentage(value); }; - // Tooltip handlers - const handleTooltipPress = useCallback( - (contentKey: PerpsTooltipContentKey) => { - setSelectedTooltip(contentKey); - }, - [], - ); - - const handleTooltipClose = useCallback(() => { - setSelectedTooltip(null); - }, []); - - const realizedPnl = useMemo(() => { - const price = Math.abs(effectivePnL * (closePercentage / 100)); - const formattedPrice = formatPerpsFiat(price, { - ranges: PRICE_RANGES_MINIMAL_VIEW, - }); - - return { formattedPrice, price, isNegative: effectivePnL < 0 }; - }, [effectivePnL, closePercentage]); - // Hide provider-level limit price required error on this UI // Only display the minimum amount error (e.g. minimum $10) and suppress others const filteredErrors = useMemo(() => { @@ -470,143 +436,32 @@ const PerpsClosePositionView: React.FC = () => { }, [validationResult.errors]); const Summary = ( - - - - - {strings('perps.close_position.margin')} - - - - - {formatPerpsFiat( - (closePercentage / 100) * initialMargin + - effectivePnL * (closePercentage / 100), - { - ranges: PRICE_RANGES_MINIMAL_VIEW, - }, - )} - - - - {strings('perps.close_position.includes_pnl')} - - - {realizedPnl.isNegative ? '-' : '+'} - {realizedPnl.formattedPrice} - - - - - - - - handleTooltipPress('closing_fees')} - style={styles.labelWithTooltip} - testID={PerpsClosePositionViewSelectorsIDs.FEES_TOOLTIP_BUTTON} - > - - {strings('perps.close_position.fees')} - - - - - - - - - - - - handleTooltipPress('close_position_you_receive')} - style={styles.labelWithTooltip} - testID={ - PerpsClosePositionViewSelectorsIDs.YOU_RECEIVE_TOOLTIP_BUTTON - } - > - - {strings('perps.close_position.you_receive')} - - - - - - - {formatPerpsFiat(receiveAmount, { - ranges: PRICE_RANGES_MINIMAL_VIEW, - })} - - - - - {/* Rewards Points Estimation */} - {rewardsState.shouldShowRewardsRow && ( - - - handleTooltipPress('points')} - style={styles.labelWithTooltip} - testID={PerpsClosePositionViewSelectorsIDs.POINTS_TOOLTIP_BUTTON} - > - - {strings('perps.estimated_points')} - - - - - - - openTooltipModal( - strings('perps.points_error'), - strings('perps.points_error_content'), - ) - } - state={ - rewardsState.isLoading - ? RewardAnimationState.Loading - : rewardsState.hasError - ? RewardAnimationState.ErrorState - : RewardAnimationState.Idle - } - /> - - - )} - + ); return ( @@ -679,19 +534,6 @@ const PerpsClosePositionView: React.FC = () => { > {strings('perps.order.limit_price')} - handleTooltipPress('limit_price')} - style={styles.infoIcon} - > - - @@ -718,8 +560,8 @@ const PerpsClosePositionView: React.FC = () => { {/* Filter the errors and only show minimum $10 error */} {filteredErrors.length > 0 && ( - {filteredErrors.map((error, index) => ( - + {filteredErrors.map((error) => ( + { direction={isLong ? 'short' : 'long'} // Opposite direction for closing isClosingPosition /> - - {/* Tooltip Bottom Sheet */} - {selectedTooltip ? ( - - ) : null} ); }; diff --git a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.styles.ts b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.styles.ts new file mode 100644 index 000000000000..86b3101a5701 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.styles.ts @@ -0,0 +1,116 @@ +import { StyleSheet } from 'react-native'; +import type { Theme } from '../../../../../util/theme/models'; + +const styleSheet = (params: { theme: Theme }) => { + 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: 16, + borderBottomWidth: 1, + borderBottomColor: colors.border.muted, + }, + headerTitle: { + flex: 1, + textAlign: 'center', + }, + headerSpacer: { + width: 40, // Same width as ButtonIcon to center the title + }, + searchButton: { + padding: 4, + }, + searchContainer: { + paddingTop: 16, + paddingHorizontal: 16, + }, + searchInputContainer: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.background.muted, + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 8, + minHeight: 50, + }, + searchIcon: { + marginRight: 10, + color: colors.icon.muted, + }, + searchInput: { + flex: 1, + fontSize: 16, + color: colors.text.default, + includeFontPadding: false, // Android-specific: removes extra font padding + }, + clearButton: { + padding: 4, + marginLeft: 8, + }, + scrollView: { + flex: 1, + }, + scrollViewContent: { + paddingBottom: 16, + }, + tabBarContainer: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + }, + actionButtonsContainer: { + paddingHorizontal: 16, + marginTop: 16, + marginBottom: 16, + }, + actionButton: { + backgroundColor: colors.background.alternative, + borderRadius: 12, + borderWidth: 1, + borderColor: colors.border.muted, + marginBottom: 12, + overflow: 'hidden', + }, + actionButtonContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 16, + }, + actionButtonTextContainer: { + flex: 1, + marginRight: 12, + }, + bottomSpacer: { + height: 80, // Space for tab bar + safe area + }, + section: { + marginBottom: 16, + borderBottomWidth: 1, + borderBottomColor: colors.border.muted, + paddingBottom: 16, + }, + sectionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, + paddingHorizontal: 16, + }, + sectionContent: { + paddingHorizontal: 16, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx new file mode 100644 index 000000000000..cedfaa3aeaea --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx @@ -0,0 +1,638 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import PerpsHomeView from './PerpsHomeView'; +import Routes from '../../../../../constants/navigation/Routes'; + +// Mock navigation +const mockNavigate = jest.fn(); +const mockGoBack = jest.fn(); +const mockCanGoBack = jest.fn(() => true); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + navigate: mockNavigate, + goBack: mockGoBack, + canGoBack: mockCanGoBack, + }), + useRoute: () => ({ + params: { + source: 'main_action_button', + }, + }), +})); + +// Mock Redux +jest.mock('react-redux', () => ({ + useSelector: jest.fn(() => false), // isRewardsEnabled +})); + +// Mock components to prevent complex module initialization chains +jest.mock( + '../../components/PerpsMarketTypeSection', + () => 'PerpsMarketTypeSection', +); +jest.mock( + '../../components/PerpsWatchlistMarkets/PerpsWatchlistMarkets', + () => 'PerpsWatchlistMarkets', +); +jest.mock( + '../../components/PerpsRecentActivityList/PerpsRecentActivityList', + () => 'PerpsRecentActivityList', +); + +// Mock hooks (consolidated to avoid conflicts) +const mockNavigateBack = jest.fn(); +jest.mock('../../hooks', () => ({ + usePerpsHomeData: jest.fn(), + usePerpsMeasurement: jest.fn(), + usePerpsNavigation: jest.fn(() => ({ + navigateTo: jest.fn(), + navigateToMarketDetails: jest.fn(), + navigateToMarketList: jest.fn(), + navigateBack: mockNavigateBack, + goBack: jest.fn(), + })), +})); + +jest.mock('../../hooks/usePerpsEventTracking', () => ({ + usePerpsEventTracking: jest.fn(), +})); + +jest.mock('../../../../hooks/useMetrics', () => ({ + useMetrics: () => ({ + trackEvent: jest.fn(), + createEventBuilder: jest.fn(() => ({ + build: jest.fn(), + })), + }), + MetaMetricsEvents: { + NAVIGATION_TAPS_GET_HELP: 'NAVIGATION_TAPS_GET_HELP', + PERPS_SCREEN_VIEWED: 'PERPS_SCREEN_VIEWED', + }, +})); + +// Mock design system +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ + style: (className: string) => ({ testID: className }), + }), +})); + +jest.mock('react-native-safe-area-context', () => ({ + SafeAreaView: 'SafeAreaView', + useSafeAreaInsets: () => ({ + top: 0, + bottom: 0, + left: 0, + right: 0, + }), +})); + +jest.mock('@metamask/design-system-react-native', () => ({ + Box: 'Box', + BoxFlexDirection: { + Row: 'Row', + }, + BoxAlignItems: { + Center: 'Center', + }, +})); + +// Mock stylesheet +jest.mock('./PerpsHomeView.styles', () => ({})); + +// Mock styles hook +jest.mock('../../../../../component-library/hooks', () => ({ + useStyles: () => ({ + styles: { + container: {}, + header: {}, + headerTitle: {}, + searchButton: {}, + searchContainer: {}, + scrollView: {}, + scrollViewContent: {}, + section: {}, + sectionHeader: {}, + sectionContent: {}, + actionButtonsContainer: {}, + actionButton: {}, + actionButtonContent: {}, + actionButtonTextContainer: {}, + bottomSpacer: {}, + tabBarContainer: {}, + }, + theme: { + colors: { + primary: { default: '#0000ff' }, + icon: { default: '#000000' }, + }, + }, + }), +})); + +// Mock i18n +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => key, +})); + +// Use actual constants - don't mock perpsConfig + +jest.mock('../../../../../util/trace', () => ({ + TraceName: { + PerpsMarketListView: 'PerpsMarketListView', + }, +})); + +jest.mock('../../constants/eventNames', () => ({ + PerpsEventProperties: { + SCREEN_TYPE: 'screen_type', + SOURCE: 'source', + }, + PerpsEventValues: { + SCREEN_TYPE: { + MARKETS: 'markets', + }, + SOURCE: { + MAIN_ACTION_BUTTON: 'main_action_button', + HOMESCREEN_TAB: 'homescreen_tab', + }, + }, +})); + +// Mock child components +jest.mock('../../components/PerpsHomeHeader', () => { + const { View, TouchableOpacity, Text } = jest.requireActual('react-native'); + + interface MockPerpsHomeHeaderProps { + onSearchToggle: () => void; + onBack: () => void; + testID: string; + } + + return function MockPerpsHomeHeader({ + onSearchToggle, + onBack, + testID, + }: MockPerpsHomeHeaderProps) { + return ( + + + Back + + + Search + + + ); + }; +}); +jest.mock('../../components/PerpsHomeSection', () => { + const { View, TouchableOpacity, Text } = jest.requireActual('react-native'); + + interface MockPerpsHomeSectionProps { + title?: string; + children?: React.ReactNode; + isEmpty?: boolean; + showWhenEmpty?: boolean; + onActionPress?: () => void; + actionLabel?: string; + } + + return function MockPerpsHomeSection({ + title, + children, + isEmpty, + showWhenEmpty, + onActionPress, + actionLabel, + }: MockPerpsHomeSectionProps) { + if (isEmpty && !showWhenEmpty) return null; + return ( + + {title && {title}} + {children} + {actionLabel && onActionPress && ( + + {actionLabel} + + )} + + ); + }; +}); +jest.mock( + '../../components/PerpsMarketBalanceActions', + () => 'PerpsMarketBalanceActions', +); +jest.mock( + '../../../../../component-library/components/Form/TextFieldSearch', + () => { + const { TextInput } = jest.requireActual('react-native'); + + interface MockTextFieldSearchProps { + testID?: string; + value?: string; + onChangeText?: (text: string) => void; + placeholder?: string; + } + + return function MockTextFieldSearch({ + testID, + value, + onChangeText, + placeholder, + }: MockTextFieldSearchProps) { + return ( + + ); + }; + }, +); +jest.mock('../../components/PerpsCard', () => 'PerpsCard'); +jest.mock( + '../../components/PerpsWatchlistMarkets/PerpsWatchlistMarkets', + () => 'PerpsWatchlistMarkets', +); +jest.mock( + '../../components/PerpsRecentActivityList/PerpsRecentActivityList', + () => 'PerpsRecentActivityList', +); +jest.mock('../../../../../component-library/components/Texts/Text', () => ({ + __esModule: true, + default: 'Text', + TextVariant: { + HeadingLG: 'HeadingLG', + HeadingSM: 'HeadingSM', + BodyMD: 'BodyMD', + BodyMDMedium: 'BodyMDMedium', + BodySM: 'BodySM', + }, + TextColor: { + Default: 'Default', + Alternative: 'Alternative', + }, +})); +jest.mock('../../../../../component-library/components/Icons/Icon', () => ({ + __esModule: true, + default: 'Icon', + IconName: { + ArrowLeft: 'ArrowLeft', + Search: 'Search', + Close: 'Close', + Arrow2Right: 'Arrow2Right', + Home: 'Home', + Explore: 'Explore', + SwapVertical: 'SwapVertical', + Activity: 'Activity', + Setting: 'Setting', + MetamaskFoxOutline: 'MetamaskFoxOutline', + }, + IconSize: { + Lg: 'Lg', + Md: 'Md', + }, + IconColor: { + Default: 'Default', + }, +})); +jest.mock( + '../../../../../component-library/components/Buttons/ButtonIcon', + () => ({ + __esModule: true, + default: 'ButtonIcon', + ButtonIconSizes: { + Md: 'Md', + }, + }), +); +jest.mock( + '../../../../../component-library/components/Navigation/TabBarItem', + () => 'TabBarItem', +); + +const mockUsePerpsHomeData = jest.requireMock('../../hooks') + .usePerpsHomeData as jest.Mock; + +describe('PerpsHomeView', () => { + const mockDefaultData = { + positions: [], + orders: [], + watchlistMarkets: [], + perpsMarkets: [], + stocksMarkets: [], + commoditiesMarkets: [], + forexMarkets: [], + recentActivity: [], + sortBy: 'name', + isLoading: { + positions: false, + markets: false, + }, + refresh: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockNavigateBack.mockClear(); + mockUsePerpsHomeData.mockReturnValue(mockDefaultData); + }); + + it('renders without crashing', () => { + // Arrange & Act + const { getByTestId } = render(); + + // Assert - Component renders with essential elements + expect(getByTestId('back-button')).toBeTruthy(); + expect(getByTestId('perps-home-search-toggle')).toBeTruthy(); + }); + + it('shows header with navigation controls', () => { + // Arrange & Act + const { getByTestId } = render(); + + // Assert + expect(getByTestId('back-button')).toBeTruthy(); + expect(getByTestId('perps-home-search-toggle')).toBeTruthy(); + }); + + it('shows search toggle button', () => { + // Arrange & Act + const { getByTestId } = render(); + + // Assert + expect(getByTestId('perps-home-search-toggle')).toBeTruthy(); + }); + + it('toggles search bar visibility when search button is pressed', () => { + // Arrange + const { getByTestId, queryByTestId } = render(); + + // Act - Initially search should not be visible + expect(queryByTestId('perps-home-search')).toBeNull(); + + // Press search toggle + fireEvent.press(getByTestId('perps-home-search-toggle')); + + // Assert - Search should now be visible + expect(getByTestId('perps-home-search')).toBeTruthy(); + }); + + it('hides search bar when toggle is pressed again', () => { + // Arrange + const { getByTestId, queryByTestId } = render(); + + // Act - Open search + fireEvent.press(getByTestId('perps-home-search-toggle')); + expect(getByTestId('perps-home-search')).toBeTruthy(); + + // Close search + fireEvent.press(getByTestId('perps-home-search-toggle')); + + // Assert - Search should be hidden + expect(queryByTestId('perps-home-search')).toBeNull(); + }); + + it('shows positions section when positions exist', () => { + // Arrange + mockUsePerpsHomeData.mockReturnValue({ + ...mockDefaultData, + positions: [ + { + coin: 'BTC', + size: '0.5', + entryPrice: '50000', + positionValue: '25000', + unrealizedPnl: '100', + marginUsed: '1000', + leverage: { type: 'cross' as const, value: 25 }, + liquidationPrice: '48000', + maxLeverage: 50, + returnOnEquity: '10', + cumulativeFunding: { + allTime: '0', + sinceOpen: '0', + sinceChange: '0', + }, + roi: '10', + takeProfitPrice: undefined, + stopLossPrice: undefined, + takeProfitCount: 0, + stopLossCount: 0, + marketPrice: '50200', + timestamp: Date.now(), + }, + ], + }); + + // Act + const { getByText } = render(); + + // Assert + expect(getByText('perps.home.positions')).toBeTruthy(); + expect(getByText('perps.home.close_all')).toBeTruthy(); + }); + + it('shows orders section when orders exist', () => { + // Arrange + mockUsePerpsHomeData.mockReturnValue({ + ...mockDefaultData, + orders: [ + { + orderId: '123', + coin: 'ETH', + side: 'buy' as const, + size: '1.0', + limitPrice: '3000', + orderType: 'limit' as const, + timestamp: Date.now(), + }, + ], + }); + + // Act + const { getByText } = render(); + + // Assert + expect(getByText('perps.home.orders')).toBeTruthy(); + expect(getByText('perps.home.cancel_all')).toBeTruthy(); + }); + + it('hides positions section when no positions', () => { + // Arrange + mockUsePerpsHomeData.mockReturnValue({ + ...mockDefaultData, + positions: [], + }); + + // Act + const { queryByText } = render(); + + // Assert + expect(queryByText('perps.home.positions')).toBeNull(); + }); + + it('hides orders section when no orders', () => { + // Arrange + mockUsePerpsHomeData.mockReturnValue({ + ...mockDefaultData, + orders: [], + }); + + // Act + const { queryByText } = render(); + + // Assert + expect(queryByText('perps.home.orders')).toBeNull(); + }); + + it('handles back button press', () => { + // Arrange + const { getByTestId } = render(); + + // Act + fireEvent.press(getByTestId('back-button')); + + // Assert + expect(mockNavigateBack).toHaveBeenCalled(); + }); + + it('navigates to close all modal when close all is pressed', () => { + // Arrange + mockUsePerpsHomeData.mockReturnValue({ + ...mockDefaultData, + positions: [ + { + coin: 'BTC', + size: '0.5', + entryPrice: '50000', + positionValue: '25000', + unrealizedPnl: '100', + marginUsed: '1000', + leverage: { type: 'cross' as const, value: 25 }, + liquidationPrice: '48000', + maxLeverage: 50, + returnOnEquity: '10', + cumulativeFunding: { + allTime: '0', + sinceOpen: '0', + sinceChange: '0', + }, + roi: '10', + takeProfitPrice: undefined, + stopLossPrice: undefined, + takeProfitCount: 0, + stopLossCount: 0, + marketPrice: '50200', + timestamp: Date.now(), + }, + ], + }); + + const { getByText } = render(); + + // Act + fireEvent.press(getByText('perps.home.close_all')); + + // Assert + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.MODALS.ROOT, { + screen: Routes.PERPS.MODALS.CLOSE_ALL_POSITIONS, + }); + }); + + it('navigates to cancel all modal when cancel all is pressed', () => { + // Arrange + mockUsePerpsHomeData.mockReturnValue({ + ...mockDefaultData, + orders: [ + { + orderId: '123', + coin: 'ETH', + side: 'buy' as const, + size: '1.0', + limitPrice: '3000', + orderType: 'limit' as const, + timestamp: Date.now(), + }, + ], + }); + + const { getByText } = render(); + + // Act + fireEvent.press(getByText('perps.home.cancel_all')); + + // Assert + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.MODALS.ROOT, { + screen: Routes.PERPS.MODALS.CANCEL_ALL_ORDERS, + }); + }); + + it('renders bottom tab bar with all tabs', () => { + // Arrange & Act + const { getByTestId } = render(); + + // Assert + expect(getByTestId('tab-bar-item-wallet')).toBeTruthy(); + expect(getByTestId('tab-bar-item-browser')).toBeTruthy(); + expect(getByTestId('tab-bar-item-actions')).toBeTruthy(); + expect(getByTestId('tab-bar-item-activity')).toBeTruthy(); + expect(getByTestId('tab-bar-item-settings')).toBeTruthy(); + }); + + it('renders main sections', () => { + // Arrange & Act + const { UNSAFE_getByType } = render(); + + // Assert + expect(UNSAFE_getByType('PerpsMarketBalanceActions' as never)).toBeTruthy(); + expect(UNSAFE_getByType('PerpsRecentActivityList' as never)).toBeTruthy(); + }); + + it('shows watchlist section when watchlist markets exist', () => { + // Arrange + mockUsePerpsHomeData.mockReturnValue({ + ...mockDefaultData, + watchlistMarkets: [ + { + symbol: 'BTC', + name: 'Bitcoin', + price: '$50000', + change24h: '+$1250', + change24hPercent: '+2.5%', + volume: '$1.2B', + volumeNumber: 1200000000, + maxLeverage: '50', + }, + ], + }); + + // Act + const { UNSAFE_getByType } = render(); + + // Assert + expect(UNSAFE_getByType('PerpsWatchlistMarkets' as never)).toBeTruthy(); + }); + + it('renders watchlist component with empty markets array', () => { + // Arrange + mockUsePerpsHomeData.mockReturnValue({ + ...mockDefaultData, + watchlistMarkets: [], + }); + + // Act + const { UNSAFE_getByType } = render(); + + // Assert - Component is rendered, it handles empty state internally + expect(UNSAFE_getByType('PerpsWatchlistMarkets' as never)).toBeTruthy(); + }); +}); diff --git a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx new file mode 100644 index 000000000000..b83fadd7ae0a --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx @@ -0,0 +1,337 @@ +import React, { useCallback, useState } from 'react'; +import { View, ScrollView, TouchableOpacity } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { + useNavigation, + useRoute, + type NavigationProp, + type RouteProp, +} from '@react-navigation/native'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; +import Icon, { + IconName, + IconSize, +} from '../../../../../component-library/components/Icons/Icon'; +import { useStyles } from '../../../../../component-library/hooks'; +import { strings } from '../../../../../../locales/i18n'; +import Routes from '../../../../../constants/navigation/Routes'; +import { + usePerpsHomeData, + usePerpsNavigation, + usePerpsMeasurement, +} from '../../hooks'; +import PerpsMarketBalanceActions from '../../components/PerpsMarketBalanceActions'; +import TextFieldSearch from '../../../../../component-library/components/Form/TextFieldSearch'; +import PerpsCard from '../../components/PerpsCard'; +import PerpsWatchlistMarkets from '../../components/PerpsWatchlistMarkets/PerpsWatchlistMarkets'; +import PerpsMarketTypeSection from '../../components/PerpsMarketTypeSection'; +import PerpsRecentActivityList from '../../components/PerpsRecentActivityList/PerpsRecentActivityList'; +import PerpsHomeSection from '../../components/PerpsHomeSection'; +import PerpsRowSkeleton from '../../components/PerpsRowSkeleton'; +import PerpsBottomTabBar from '../../components/PerpsBottomTabBar'; +import PerpsHomeHeader from '../../components/PerpsHomeHeader'; +import { LEARN_MORE_CONFIG, SUPPORT_CONFIG } from '../../constants/perpsConfig'; +import type { PerpsNavigationParamList } from '../../types/navigation'; +import { useMetrics, MetaMetricsEvents } from '../../../../hooks/useMetrics'; +import styleSheet from './PerpsHomeView.styles'; +import { TraceName } from '../../../../../util/trace'; +import { + PerpsEventProperties, + PerpsEventValues, +} from '../../constants/eventNames'; +import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; +import { PerpsHomeViewSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; + +const PerpsHomeView = () => { + const { styles, theme } = useStyles(styleSheet, {}); + const navigation = useNavigation>(); + const route = + useRoute>(); + const { trackEvent, createEventBuilder } = useMetrics(); + + // Use centralized navigation hook + const perpsNavigation = usePerpsNavigation(); + + // Search state + const [searchQuery, setSearchQuery] = useState(''); + const [isSearchVisible, setIsSearchVisible] = useState(false); + + // Fetch all home screen data with search filtering + const { + positions, + orders, + watchlistMarkets, + perpsMarkets, // Crypto markets (renamed from trendingMarkets) + stocksMarkets, + commoditiesMarkets, + forexMarkets, + recentActivity, + sortBy, + isLoading, + } = usePerpsHomeData({ + searchQuery: isSearchVisible ? searchQuery : '', + }); + + // Determine if any data is loading for initial load tracking + // Orders and activity load via WebSocket instantly, only track positions and markets + const isAnyLoading = isLoading.positions || isLoading.markets; + + // Performance tracking: Measure screen load time until data is displayed + usePerpsMeasurement({ + traceName: TraceName.PerpsMarketListView, // Keep same trace name for consistency + conditions: [!isAnyLoading], + }); + + // Track home screen viewed event + const source = + route.params?.source || PerpsEventValues.SOURCE.MAIN_ACTION_BUTTON; + usePerpsEventTracking({ + eventName: MetaMetricsEvents.PERPS_SCREEN_VIEWED, + conditions: [!isAnyLoading], + properties: { + [PerpsEventProperties.SCREEN_TYPE]: PerpsEventValues.SCREEN_TYPE.MARKETS, + [PerpsEventProperties.SOURCE]: source, + }, + }); + + const handleSearchToggle = useCallback(() => { + setIsSearchVisible(!isSearchVisible); + if (isSearchVisible) { + // Clear search when hiding search bar + setSearchQuery(''); + } + }, [isSearchVisible]); + + const handleLearnMorePress = useCallback(() => { + navigation.navigate(Routes.PERPS.TUTORIAL, { + source: PerpsEventValues.SOURCE.HOMESCREEN_TAB, + }); + }, [navigation]); + + const handleContactSupportPress = useCallback(() => { + navigation.navigate(Routes.WEBVIEW.MAIN, { + screen: Routes.WEBVIEW.SIMPLE, + params: { + url: SUPPORT_CONFIG.URL, + title: strings(SUPPORT_CONFIG.TITLE_KEY), + }, + }); + trackEvent( + createEventBuilder(MetaMetricsEvents.NAVIGATION_TAPS_GET_HELP).build(), + ); + }, [navigation, trackEvent, createEventBuilder]); + + // Modal handlers - now using navigation to modal stack + const handleCloseAllPress = useCallback(() => { + navigation.navigate(Routes.PERPS.MODALS.ROOT, { + screen: Routes.PERPS.MODALS.CLOSE_ALL_POSITIONS, + }); + }, [navigation]); + + const handleCancelAllPress = useCallback(() => { + navigation.navigate(Routes.PERPS.MODALS.ROOT, { + screen: Routes.PERPS.MODALS.CANCEL_ALL_ORDERS, + }); + }, [navigation]); + + // Back button handler - now uses navigation hook + const handleBackPress = perpsNavigation.navigateBack; + + return ( + + {/* Header - Using extracted component */} + + + {/* Search Bar - Use design system component */} + {isSearchVisible && ( + + 0} + onPressClearButton={() => setSearchQuery('')} + placeholder={strings('perps.search_by_token_symbol')} + testID={PerpsHomeViewSelectorsIDs.SEARCH_INPUT} + /> + + )} + + {/* Main Content - ScrollView with all carousels */} + + {/* Balance Actions Component */} + + + {/* Positions Section */} + } + > + + {positions.map((position, index) => ( + + ))} + + + + {/* Orders Section */} + } + > + + {orders.map((order) => ( + + ))} + + + + {/* Watchlist Section */} + + + {/* Crypto Markets List */} + + + {/* Stocks Markets List */} + + + {/* Commodities Markets List */} + + + {/* Forex Markets List */} + + + {/* Recent Activity List */} + + + {/* Action Buttons */} + + {/* Learn about perps */} + + + + + {strings(LEARN_MORE_CONFIG.TITLE_KEY)} + + + {strings(LEARN_MORE_CONFIG.DESCRIPTION_KEY)} + + + + + + + {/* Contact support */} + + + + + {strings(SUPPORT_CONFIG.TITLE_KEY)} + + + {strings(SUPPORT_CONFIG.DESCRIPTION_KEY)} + + + + + + + + {/* Bottom spacing for tab bar */} + + + + {/* Bottom Tab Bar - Using extracted component */} + + + + + ); +}; + +export default PerpsHomeView; diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx index 3811a4638f0f..1ae3fdabff2d 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx @@ -59,6 +59,13 @@ const mockNavigate = jest.fn(); const mockGoBack = jest.fn(); const mockCanGoBack = jest.fn(); +// usePerpsNavigation mock functions +const mockNavigateToHome = jest.fn(); +const mockNavigateToActivity = jest.fn(); +const mockNavigateToOrder = jest.fn(); +const mockNavigateToTutorial = jest.fn(); +const mockNavigateBack = jest.fn(); + // Mock notification feature flag const mockIsNotificationsFeatureEnabled = jest.fn(); @@ -118,6 +125,7 @@ jest.mock('../../hooks/stream/usePerpsLiveAccount', () => ({ // Mock the selector module first jest.mock('../../selectors/perpsController', () => ({ selectPerpsEligibility: jest.fn(), + createSelectIsWatchlistMarket: jest.fn(() => jest.fn(() => false)), })); // Mock react-redux @@ -250,6 +258,14 @@ jest.mock('../../hooks', () => ({ usePerpsNetworkManagement: jest.fn(() => ({ ensureArbitrumNetworkExists: jest.fn().mockResolvedValue(undefined), })), + usePerpsNavigation: jest.fn(() => ({ + navigateToHome: mockNavigateToHome, + navigateToActivity: mockNavigateToActivity, + navigateToOrder: mockNavigateToOrder, + navigateToTutorial: mockNavigateToTutorial, + navigateBack: mockNavigateBack, + canGoBack: mockCanGoBack(), + })), })); // Mock PerpsMarketStatisticsCard to simplify the test @@ -959,8 +975,8 @@ describe('PerpsMarketDetailsView', () => { fireEvent.press(longButton); }); - expect(mockNavigate).toHaveBeenCalledTimes(1); - expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ORDER, { + expect(mockNavigateToOrder).toHaveBeenCalledTimes(1); + expect(mockNavigateToOrder).toHaveBeenCalledWith({ direction: 'long', asset: 'BTC', }); @@ -994,8 +1010,8 @@ describe('PerpsMarketDetailsView', () => { fireEvent.press(shortButton); }); - expect(mockNavigate).toHaveBeenCalledTimes(1); - expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ORDER, { + expect(mockNavigateToOrder).toHaveBeenCalledTimes(1); + expect(mockNavigateToOrder).toHaveBeenCalledWith({ direction: 'short', asset: 'BTC', }); diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index b6814beb7a59..e25116abd990 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -12,6 +12,7 @@ import React, { useRef, } from 'react'; import { ScrollView, View, RefreshControl, Linking } from 'react-native'; +import { useDispatch, useSelector } from 'react-redux'; import { SafeAreaView } from 'react-native-safe-area-context'; import { strings } from '../../../../../../locales/i18n'; import Button, { @@ -29,6 +30,8 @@ import Routes from '../../../../../constants/navigation/Routes'; import { PerpsMarketDetailsViewSelectorsIDs, PerpsOrderViewSelectorsIDs, + PerpsTutorialSelectorsIDs, + PerpsMarketTabsSelectorsIDs, } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; import PerpsMarketHeader from '../../components/PerpsMarketHeader'; import type { @@ -53,6 +56,7 @@ import { usePerpsConnection, usePerpsTrading, usePerpsNetworkManagement, + usePerpsNavigation, } from '../../hooks'; import { usePerpsDataMonitor, @@ -63,6 +67,9 @@ import { usePerpsLiveOrders, usePerpsLiveAccount } from '../../hooks/stream'; import PerpsMarketTabs from '../../components/PerpsMarketTabs/PerpsMarketTabs'; import type { PerpsTabId } from '../../components/PerpsMarketTabs/PerpsMarketTabs.types'; import PerpsNotificationTooltip from '../../components/PerpsNotificationTooltip'; +import PerpsNavigationCard, { + type NavigationItem, +} from '../../components/PerpsNavigationCard/PerpsNavigationCard'; import { isNotificationsFeatureEnabled } from '../../../../../util/notifications'; import TradingViewChart, { type TradingViewChartRef, @@ -71,12 +78,15 @@ import PerpsCandlePeriodSelector from '../../components/PerpsCandlePeriodSelecto import PerpsCandlePeriodBottomSheet from '../../components/PerpsCandlePeriodBottomSheet'; import { getPerpsMarketDetailsNavbar } from '../../../Navbar'; import PerpsBottomSheetTooltip from '../../components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip'; -import { selectPerpsEligibility } from '../../selectors/perpsController'; -import { useSelector, useDispatch } from 'react-redux'; +import { + selectPerpsEligibility, + createSelectIsWatchlistMarket, +} from '../../selectors/perpsController'; import ButtonSemantic, { ButtonSemanticSeverity, } from '../../../../../component-library/components-temp/Buttons/ButtonSemantic'; import { useConfirmNavigation } from '../../../../Views/confirmations/hooks/useConfirmNavigation'; +import Engine from '../../../../../core/Engine'; import { setPerpsChartPreferredCandlePeriod } from '../../../../../actions/settings'; import { selectPerpsChartPreferredCandlePeriod } from '../../selectors/chartPreferences'; interface MarketDetailsRouteParams { @@ -88,6 +98,17 @@ interface MarketDetailsRouteParams { } const PerpsMarketDetailsView: React.FC = () => { + // Use centralized navigation hook for all Perps navigation + const { + navigateToHome, + navigateToActivity, + navigateToOrder, + navigateToTutorial, + navigateBack, + canGoBack, + } = usePerpsNavigation(); + + // Keep direct navigation for configuration methods (setOptions, setParams) const navigation = useNavigation>(); const route = useRoute>(); @@ -100,6 +121,34 @@ const PerpsMarketDetailsView: React.FC = () => { const isEligible = useSelector(selectPerpsEligibility); + // Check if current market is in watchlist + const selectIsWatchlist = useMemo( + () => createSelectIsWatchlistMarket(market?.symbol || ''), + [market?.symbol], + ); + const isWatchlistFromRedux = useSelector(selectIsWatchlist); + + // Optimistic local state for instant UI feedback + const [optimisticWatchlist, setOptimisticWatchlist] = useState< + boolean | null + >(null); + const isWatchlist = optimisticWatchlist ?? isWatchlistFromRedux; + + // Reset optimistic state when market changes + useEffect(() => { + setOptimisticWatchlist(null); + }, [market?.symbol]); + + // Clear optimistic state once Redux has caught up + useEffect(() => { + if ( + optimisticWatchlist !== null && + optimisticWatchlist === isWatchlistFromRedux + ) { + setOptimisticWatchlist(null); + } + }, [isWatchlistFromRedux, optimisticWatchlist]); + // Set navigation header with proper back button useEffect(() => { if (market) { @@ -168,7 +217,7 @@ const PerpsMarketDetailsView: React.FC = () => { enabled: !!(monitoringIntent && market && monitoringIntent.asset), }); // Get real-time open orders via WebSocket - const ordersData = usePerpsLiveOrders({}); + const { orders: ordersData } = usePerpsLiveOrders({}); // Filter orders for the current market const openOrders = useMemo(() => { if (!ordersData?.length || !market?.symbol) return []; @@ -395,21 +444,46 @@ const PerpsMarketDetailsView: React.FC = () => { const isNotificationsEnabled = isNotificationsFeatureEnabled(); const handleBackPress = () => { - if (navigation.canGoBack()) { - navigation.goBack(); + if (canGoBack) { + navigateBack(); } else { // Fallback to markets list if no previous screen - navigation.navigate(Routes.PERPS.MARKETS, { source }); + navigateToHome(source); } }; + const handleWatchlistPress = useCallback(() => { + if (!market?.symbol) return; + + // Optimistic update - instant UI feedback + const newWatchlistState = !isWatchlist; + setOptimisticWatchlist(newWatchlistState); + + // Actual state update + const controller = Engine.context.PerpsController; + controller.toggleWatchlistMarket(market.symbol); + + // Track watchlist toggle event + const watchlistCount = controller.getWatchlistMarkets().length; + + track(MetaMetricsEvents.PERPS_UI_INTERACTION, { + [PerpsEventProperties.INTERACTION_TYPE]: 'watchlist_toggled', + [PerpsEventProperties.ACTION_TYPE]: newWatchlistState + ? 'add_to_watchlist' + : 'remove_from_watchlist', + [PerpsEventProperties.ASSET]: market.symbol, + [PerpsEventProperties.SOURCE]: 'asset_details', + watchlist_count: watchlistCount, + }); + }, [market, isWatchlist, track]); + const handleLongPress = () => { if (!isEligible) { setIsEligibilityModalVisible(true); return; } - navigation.navigate(Routes.PERPS.ORDER, { + navigateToOrder({ direction: 'long', asset: market.symbol, }); @@ -421,7 +495,7 @@ const PerpsMarketDetailsView: React.FC = () => { return; } - navigation.navigate(Routes.PERPS.ORDER, { + navigateToOrder({ direction: 'short', asset: market.symbol, }); @@ -468,6 +542,23 @@ const PerpsMarketDetailsView: React.FC = () => { [hasZeroBalance, isLoadingPosition], ); + // Define navigation items for the card + const navigationItems: NavigationItem[] = useMemo( + () => [ + { + label: strings('perps.tutorial.card.title'), + onPress: () => navigateToTutorial(), + testID: PerpsTutorialSelectorsIDs.TUTORIAL_CARD, + }, + { + label: strings('perps.market.go_to_activity'), + onPress: () => navigateToActivity(), + testID: PerpsMarketTabsSelectorsIDs.ACTIVITY_LINK, + }, + ], + [navigateToTutorial, navigateToActivity], + ); + // Simplified styles - no complex calculations needed const { styles, theme } = useStyles(createStyles, {}); @@ -496,12 +587,8 @@ const PerpsMarketDetailsView: React.FC = () => { - navigation.navigate(Routes.TRANSACTIONS_VIEW, { - screen: Routes.TRANSACTIONS_VIEW, - params: { redirectToPerpsTransactions: true }, - }) - } + onFavoritePress={handleWatchlistPress} + isFavorite={isWatchlist} testID={PerpsMarketDetailsViewSelectorsIDs.HEADER} /> @@ -577,6 +664,11 @@ const PerpsMarketDetailsView: React.FC = () => { /> + {/* Navigation Card Section */} + + + + {/* Risk Disclaimer Section */} { marginLeft: -12, // Compensate for padding to maintain visual alignment marginRight: -12, }, + backButton: { + padding: 8, + marginRight: 8, + }, headerTitle: { textAlign: 'left', }, @@ -49,6 +53,9 @@ const styleSheet = (params: { theme: Theme }) => { listContainerWithTabBar: { flex: 1, }, + tabsContainer: { + flex: 1, + }, tabBarContainer: { position: 'absolute', bottom: 0, @@ -134,11 +141,8 @@ const styleSheet = (params: { theme: Theme }) => { flex: 1, }, searchContainer: { - marginHorizontal: 16, - marginTop: 16, - borderWidth: 1, - borderColor: colors.border.muted, - borderRadius: 12, + paddingTop: 16, + paddingHorizontal: 16, }, searchInputContainer: { flexDirection: 'row', diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx index 0f359653db59..4fd5ae1e35a5 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx @@ -11,7 +11,6 @@ import type { PerpsMarketData } from '../../controllers/types'; import Routes from '../../../../../constants/navigation/Routes'; import { PerpsMarketListViewSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; -import { IconName } from '../../../../../component-library/components/Icons/Icon'; jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), @@ -21,9 +20,22 @@ jest.mock('@react-navigation/native', () => ({ })); jest.mock('../../hooks/usePerpsMarkets', () => ({ + ...jest.requireActual('../../hooks/usePerpsMarkets'), usePerpsMarkets: jest.fn(), })); +// Don't mock usePerpsMarketListView - test the real implementation +// Instead, mock its dependencies below + +// Mock Engine for PerpsController +jest.mock('../../../../../core/Engine', () => ({ + context: { + PerpsController: { + saveMarketFilterPreferences: jest.fn(), + }, + }, +})); + jest.mock('../../hooks/usePerpsEventTracking', () => ({ usePerpsEventTracking: jest.fn(() => ({ track: jest.fn(), @@ -62,6 +74,26 @@ jest.mock('../../hooks/stream', () => ({ })), })); +// Mock variables to hold state that will be set in beforeEach +const mockMarketDataForHook: PerpsMarketData[] = []; +let mockSearchVisible = false; +let mockSearchQuery = ''; + +// Create persistent mock functions that update the shared state +const mockSetSearchQuery = jest.fn((q: string) => { + mockSearchQuery = q; +}); +const mockSetIsSearchVisible = jest.fn((v: boolean) => { + mockSearchVisible = v; +}); +const mockToggleSearchVisibility = jest.fn(() => { + mockSearchVisible = !mockSearchVisible; +}); +const mockClearSearch = jest.fn(() => { + mockSearchQuery = ''; + mockSearchVisible = false; +}); + jest.mock('../../hooks', () => ({ useColorPulseAnimation: jest.fn(() => ({ startPulseAnimation: jest.fn(), @@ -87,8 +119,76 @@ jest.mock('../../hooks', () => ({ isLoading: false, error: null, })), - formatFeeRate: jest.fn((rate) => `${((rate || 0) * 100).toFixed(3)}%`), + usePerpsNavigation: jest.fn(() => ({ + navigateToWallet: jest.fn(), + navigateToBrowser: jest.fn(), + navigateToActions: jest.fn(), + navigateToActivity: jest.fn(), + navigateToRewardsOrSettings: jest.fn(), + navigateToMarketDetails: jest.fn(), + navigateToHome: jest.fn(), + navigateToMarketList: jest.fn(), + navigateBack: jest.fn(), + canGoBack: true, + })), usePerpsMeasurement: jest.fn(), + usePerpsMarketListView: jest.fn(() => { + // Filter markets based on search query (case-insensitive) + const filteredMarkets = mockSearchQuery.trim() + ? mockMarketDataForHook.filter( + (m) => + m.symbol.toLowerCase().includes(mockSearchQuery.toLowerCase()) || + m.name.toLowerCase().includes(mockSearchQuery.toLowerCase()), + ) + : mockMarketDataForHook; + + return { + markets: filteredMarkets, + searchState: { + searchQuery: mockSearchQuery, + setSearchQuery: mockSetSearchQuery, + isSearchVisible: mockSearchVisible, + setIsSearchVisible: mockSetIsSearchVisible, + toggleSearchVisibility: mockToggleSearchVisibility, + clearSearch: mockClearSearch, + }, + sortState: { + selectedOptionId: 'volume', + sortBy: 'volume', + direction: 'desc', + handleOptionChange: jest.fn(), + }, + favoritesState: { + showFavoritesOnly: false, + setShowFavoritesOnly: jest.fn(), + }, + marketTypeFilterState: { + marketTypeFilter: 'all', + setMarketTypeFilter: jest.fn(), + }, + marketCounts: { + crypto: 0, + equity: 0, + commodity: 0, + forex: 0, + }, + isLoading: false, + error: null, + }; + }), +})); + +jest.mock('../../hooks/usePerpsOrderFees', () => ({ + usePerpsOrderFees: jest.fn(() => ({ + totalFee: 0, + protocolFee: 0, + metamaskFee: 0, + protocolFeeRate: 0, + metamaskFeeRate: 0, + isLoadingMetamaskFee: false, + error: null, + })), + formatFeeRate: jest.fn((rate) => `${((rate || 0) * 100).toFixed(3)}%`), })); jest.mock('../../components/PerpsMarketBalanceActions', () => { @@ -104,6 +204,94 @@ jest.mock('../../components/PerpsMarketBalanceActions', () => { }; }); +jest.mock('./components/PerpsMarketFiltersBar', () => { + const MockReact = jest.requireActual('react'); + const { + View, + Text, + TouchableOpacity: RNTouchableOpacity, + } = jest.requireActual('react-native'); + + return function PerpsMarketFiltersBar({ + selectedOptionId, + onSortPress, + onWatchlistToggle, + testID, + }: { + selectedOptionId: string; + onSortPress: () => void; + showWatchlistOnly: boolean; + onWatchlistToggle: () => void; + testID?: string; + }) { + // Capitalize first letter to match test expectations + const displayText = selectedOptionId + ? selectedOptionId.charAt(0).toUpperCase() + selectedOptionId.slice(1) + : ''; + + return MockReact.createElement( + View, + { testID }, + MockReact.createElement( + RNTouchableOpacity, + { testID: testID ? `${testID}-sort` : undefined, onPress: onSortPress }, + MockReact.createElement(Text, null, displayText), + ), + MockReact.createElement( + RNTouchableOpacity, + { + testID: testID ? `${testID}-watchlist-toggle` : undefined, + onPress: onWatchlistToggle, + }, + MockReact.createElement(Text, null, 'Watchlist'), + ), + ); + }; +}); + +jest.mock( + '../../../../../component-library/components/Form/TextFieldSearch', + () => { + const { + TextInput, + View, + TouchableOpacity: RNTouchableOpacity, + } = jest.requireActual('react-native'); + return function MockTextFieldSearch({ + value, + onChangeText, + placeholder, + testID, + showClearButton, + onPressClearButton, + }: { + value: string; + onChangeText: (text: string) => void; + placeholder: string; + testID: string; + showClearButton?: boolean; + onPressClearButton?: () => void; + }) { + return ( + + + {showClearButton && ( + + )} + + ); + }; + }, +); + jest.mock('../../../../Views/confirmations/hooks/useConfirmNavigation', () => ({ useConfirmNavigation: jest.fn(() => ({ navigateToConfirmation: jest.fn(), @@ -112,6 +300,8 @@ jest.mock('../../../../Views/confirmations/hooks/useConfirmNavigation', () => ({ jest.mock('../../selectors/perpsController', () => ({ selectPerpsEligibility: jest.fn(() => true), + selectPerpsWatchlistMarkets: jest.fn(() => []), + selectPerpsMarketFilterPreferences: jest.fn(() => 'volume'), })); jest.mock('../../utils/formatUtils', () => ({ @@ -129,7 +319,7 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({ })); jest.mock('@metamask/design-system-react-native', () => { - const { View } = jest.requireActual('react-native'); + const { View, Text: RNText } = jest.requireActual('react-native'); return { Box: ({ children, @@ -144,6 +334,14 @@ jest.mock('@metamask/design-system-react-native', () => { {children} ), + Text: RNText, + TextVariant: { + BodySm: 'sBodySM', + BodyMD: 'sBodyMD', + BodyMDMedium: 'sBodyMDMedium', + HeadingSM: 'sHeadingSM', + HeadingLG: 'sHeadingLG', + }, BoxFlexDirection: { Row: 'row', }, @@ -366,11 +564,37 @@ describe('PerpsMarketListView', () => { }, ]; + // Mock Redux state with perpsController + const mockState = { + engine: { + backgroundState: { + PerpsController: { + watchlistMarkets: { + testnet: [], + mainnet: [], + }, + }, + }, + }, + }; + let originalConsoleError: typeof console.error; beforeEach(() => { jest.clearAllMocks(); + // Set mock market data for the hook + mockMarketDataForHook.length = 0; + mockMarketDataForHook.push(...mockMarketData); + + // Reset search state + mockSearchVisible = false; + mockSearchQuery = ''; + mockSetSearchQuery.mockClear(); + mockSetIsSearchVisible.mockClear(); + mockToggleSearchVisibility.mockClear(); + mockClearSearch.mockClear(); + // Suppress console warnings for Animated during tests originalConsoleError = console.error; console.error = (...args) => { @@ -392,6 +616,7 @@ describe('PerpsMarketListView', () => { params: {}, }); + // Mock usePerpsMarkets - this is the data source for the real hook mockUsePerpsMarkets.mockReturnValue({ markets: mockMarketData, isLoading: false, @@ -410,20 +635,19 @@ describe('PerpsMarketListView', () => { describe('Component Rendering', () => { it('renders the component with header and search button', () => { - renderWithProvider(); + renderWithProvider(, { state: mockState }); expect(screen.getByText('Perps')).toBeOnTheScreen(); expect( screen.getByTestId( - PerpsMarketListViewSelectorsIDs.SEARCH_TOGGLE_BUTTON, + `${PerpsMarketListViewSelectorsIDs.CLOSE_BUTTON}-search-toggle`, ), ).toBeOnTheScreen(); expect(screen.getByText('Volume')).toBeOnTheScreen(); - expect(screen.getByText('Price / 24h change')).toBeOnTheScreen(); }); it('renders market list when data is available', () => { - renderWithProvider(); + renderWithProvider(, { state: mockState }); expect(screen.getByTestId('market-row-BTC')).toBeOnTheScreen(); expect(screen.getByTestId('market-row-ETH')).toBeOnTheScreen(); @@ -431,12 +655,12 @@ describe('PerpsMarketListView', () => { }); it('renders interactive elements', () => { - renderWithProvider(); + renderWithProvider(, { state: mockState }); // Should have search toggle button and market rows expect( screen.getByTestId( - PerpsMarketListViewSelectorsIDs.SEARCH_TOGGLE_BUTTON, + `${PerpsMarketListViewSelectorsIDs.CLOSE_BUTTON}-search-toggle`, ), ).toBeOnTheScreen(); expect(screen.getByTestId('market-row-BTC')).toBeOnTheScreen(); @@ -447,7 +671,7 @@ describe('PerpsMarketListView', () => { describe('Search Functionality', () => { it('shows search input when search button is pressed', () => { - renderWithProvider(); + renderWithProvider(, { state: mockState }); // Initially search should not be visible expect( @@ -456,7 +680,7 @@ describe('PerpsMarketListView', () => { // Click search toggle button const searchButton = screen.getByTestId( - PerpsMarketListViewSelectorsIDs.SEARCH_TOGGLE_BUTTON, + `${PerpsMarketListViewSelectorsIDs.CLOSE_BUTTON}-search-toggle`, ); act(() => { fireEvent.press(searchButton); @@ -468,64 +692,31 @@ describe('PerpsMarketListView', () => { ).toBeOnTheScreen(); }); - it('shows no markets when search is visible with empty query', () => { - renderWithProvider(); + it('shows all markets when search is visible with empty query', () => { + renderWithProvider(, { state: mockState }); - // Initially all markets should be visible expect(screen.getByTestId('market-row-BTC')).toBeOnTheScreen(); expect(screen.getByTestId('market-row-ETH')).toBeOnTheScreen(); expect(screen.getByTestId('market-row-SOL')).toBeOnTheScreen(); - // Click search toggle button to show search const searchButton = screen.getByTestId( - PerpsMarketListViewSelectorsIDs.SEARCH_TOGGLE_BUTTON, + `${PerpsMarketListViewSelectorsIDs.CLOSE_BUTTON}-search-toggle`, ); act(() => { fireEvent.press(searchButton); }); - // Search input should be visible expect( screen.getByPlaceholderText('Search by token symbol'), ).toBeOnTheScreen(); - // No markets should be visible when search is open with empty query - expect(screen.queryByTestId('market-row-BTC')).not.toBeOnTheScreen(); - expect(screen.queryByTestId('market-row-ETH')).not.toBeOnTheScreen(); - expect(screen.queryByTestId('market-row-SOL')).not.toBeOnTheScreen(); - }); - - it('shows empty state with search prompt when search is visible with empty query', () => { - renderWithProvider(); - - // Click search toggle button to show search - const searchButton = screen.getByTestId( - PerpsMarketListViewSelectorsIDs.SEARCH_TOGGLE_BUTTON, - ); - act(() => { - fireEvent.press(searchButton); - }); - - // Empty state should be visible with search prompt - expect(screen.getByText('No tokens found')).toBeOnTheScreen(); - expect(screen.getByText('Search by token symbol')).toBeOnTheScreen(); - - // Search icon should be visible in empty state - const icons = screen.root.findAllByProps({ name: IconName.Search }); - expect(icons.length).toBeGreaterThan(0); - - // No markets should be visible - expect(screen.queryByTestId('market-row-BTC')).not.toBeOnTheScreen(); - expect(screen.queryByTestId('market-row-ETH')).not.toBeOnTheScreen(); - expect(screen.queryByTestId('market-row-SOL')).not.toBeOnTheScreen(); - - // Should not show list header when empty state is shown - expect(screen.queryByText('Volume')).not.toBeOnTheScreen(); - expect(screen.queryByText('Price / 24h change')).not.toBeOnTheScreen(); + expect(screen.getByTestId('market-row-BTC')).toBeOnTheScreen(); + expect(screen.getByTestId('market-row-ETH')).toBeOnTheScreen(); + expect(screen.getByTestId('market-row-SOL')).toBeOnTheScreen(); }); it('hides PerpsMarketBalanceActions when search is visible', () => { - renderWithProvider(); + renderWithProvider(, { state: mockState }); // Initially balance actions should be visible expect( @@ -534,7 +725,7 @@ describe('PerpsMarketListView', () => { // Click search toggle button to show search const searchButton = screen.getByTestId( - PerpsMarketListViewSelectorsIDs.SEARCH_TOGGLE_BUTTON, + `${PerpsMarketListViewSelectorsIDs.CLOSE_BUTTON}-search-toggle`, ); act(() => { fireEvent.press(searchButton); @@ -551,137 +742,8 @@ describe('PerpsMarketListView', () => { ).toBeOnTheScreen(); }); - it('filters markets based on symbol search', () => { - renderWithProvider(); - - // First toggle search visibility - const searchButton = screen.getByTestId( - PerpsMarketListViewSelectorsIDs.SEARCH_TOGGLE_BUTTON, - ); - act(() => { - fireEvent.press(searchButton); - }); - - const searchInput = screen.getByPlaceholderText('Search by token symbol'); - act(() => { - fireEvent.changeText(searchInput, 'BTC'); - }); - - expect(screen.getByTestId('market-row-BTC')).toBeOnTheScreen(); - expect(screen.queryByTestId('market-row-ETH')).not.toBeOnTheScreen(); - expect(screen.queryByTestId('market-row-SOL')).not.toBeOnTheScreen(); - }); - - it('filters markets based on name search', () => { - renderWithProvider(); - - // First toggle search visibility - const searchButton = screen.getByTestId( - PerpsMarketListViewSelectorsIDs.SEARCH_TOGGLE_BUTTON, - ); - act(() => { - fireEvent.press(searchButton); - }); - - const searchInput = screen.getByPlaceholderText('Search by token symbol'); - act(() => { - fireEvent.changeText(searchInput, 'bitcoin'); - }); - - expect(screen.getByTestId('market-row-BTC')).toBeOnTheScreen(); - expect(screen.queryByTestId('market-row-ETH')).not.toBeOnTheScreen(); - expect(screen.queryByTestId('market-row-SOL')).not.toBeOnTheScreen(); - }); - - it('shows clear button when search has text', () => { - renderWithProvider(); - - // First toggle search visibility - const searchButton = screen.getByTestId( - PerpsMarketListViewSelectorsIDs.SEARCH_TOGGLE_BUTTON, - ); - act(() => { - fireEvent.press(searchButton); - }); - - const searchInput = screen.getByPlaceholderText('Search by token symbol'); - act(() => { - fireEvent.changeText(searchInput, 'BTC'); - }); - - expect(screen.getByTestId('market-row-BTC')).toBeOnTheScreen(); - - // Should show clear button when there's search text - expect( - screen.getByTestId(PerpsMarketListViewSelectorsIDs.SEARCH_CLEAR_BUTTON), - ).toBeOnTheScreen(); - - // Should only show the filtered market (BTC), not others - expect(screen.queryByTestId('market-row-ETH')).not.toBeOnTheScreen(); - expect(screen.queryByTestId('market-row-SOL')).not.toBeOnTheScreen(); - }); - - it('clears search when clear button is pressed', () => { - renderWithProvider(); - - // First toggle search visibility - const searchButton = screen.getByTestId( - PerpsMarketListViewSelectorsIDs.SEARCH_TOGGLE_BUTTON, - ); - act(() => { - fireEvent.press(searchButton); - }); - - const searchInput = screen.getByPlaceholderText('Search by token symbol'); - act(() => { - fireEvent.changeText(searchInput, 'BTC'); - }); - - expect(screen.getByTestId('market-row-BTC')).toBeOnTheScreen(); - expect(screen.queryByTestId('market-row-ETH')).not.toBeOnTheScreen(); - - // Verify the search input has the typed value - expect(searchInput.props.value).toBe('BTC'); - - // Find and press clear button using testID - const clearButton = screen.getByTestId( - PerpsMarketListViewSelectorsIDs.SEARCH_CLEAR_BUTTON, - ); - act(() => { - fireEvent.press(clearButton); - }); - - // After pressing clear button, the search input should be empty - expect(searchInput.props.value).toBe(''); - - // No markets should be visible when search is open with empty query - expect(screen.queryByTestId('market-row-BTC')).not.toBeOnTheScreen(); - expect(screen.queryByTestId('market-row-ETH')).not.toBeOnTheScreen(); - expect(screen.queryByTestId('market-row-SOL')).not.toBeOnTheScreen(); - }); - - it('handles case-insensitive search', () => { - renderWithProvider(); - - // First toggle search visibility - const searchButton = screen.getByTestId( - PerpsMarketListViewSelectorsIDs.SEARCH_TOGGLE_BUTTON, - ); - act(() => { - fireEvent.press(searchButton); - }); - - const searchInput = screen.getByPlaceholderText('Search by token symbol'); - act(() => { - fireEvent.changeText(searchInput, 'ethereum'); - }); - - expect(screen.getByTestId('market-row-ETH')).toBeOnTheScreen(); - expect(screen.queryByTestId('market-row-BTC')).not.toBeOnTheScreen(); - }); - it('hides tab bar when search is visible', () => { - renderWithProvider(); + renderWithProvider(, { state: mockState }); // Initially tab bar should be visible expect(screen.getByTestId('tab-bar-item-wallet')).toBeOnTheScreen(); @@ -691,7 +753,7 @@ describe('PerpsMarketListView', () => { // Click search toggle button to show search const searchButton = screen.getByTestId( - PerpsMarketListViewSelectorsIDs.SEARCH_TOGGLE_BUTTON, + `${PerpsMarketListViewSelectorsIDs.CLOSE_BUTTON}-search-toggle`, ); act(() => { fireEvent.press(searchButton); @@ -716,14 +778,14 @@ describe('PerpsMarketListView', () => { }); it('shows navbar when close icon is pressed while search is visible', () => { - renderWithProvider(); + renderWithProvider(, { state: mockState }); // Initially tab bar should be visible expect(screen.getByTestId('tab-bar-item-wallet')).toBeOnTheScreen(); // Click search toggle button to show search const searchButton = screen.getByTestId( - PerpsMarketListViewSelectorsIDs.SEARCH_TOGGLE_BUTTON, + `${PerpsMarketListViewSelectorsIDs.CLOSE_BUTTON}-search-toggle`, ); act(() => { fireEvent.press(searchButton); @@ -760,11 +822,11 @@ describe('PerpsMarketListView', () => { const mockDismiss = jest.fn(); jest.spyOn(Keyboard, 'dismiss').mockImplementation(mockDismiss); - renderWithProvider(); + renderWithProvider(, { state: mockState }); // Click search toggle button to show search const searchButton = screen.getByTestId( - PerpsMarketListViewSelectorsIDs.SEARCH_TOGGLE_BUTTON, + `${PerpsMarketListViewSelectorsIDs.CLOSE_BUTTON}-search-toggle`, ); act(() => { fireEvent.press(searchButton); @@ -806,14 +868,14 @@ describe('PerpsMarketListView', () => { const { Keyboard } = jest.requireActual('react-native'); jest.spyOn(Keyboard, 'addListener').mockImplementation(mockAddListener); - renderWithProvider(); + renderWithProvider(, { state: mockState }); // Initially tab bar should be visible expect(screen.getByTestId('tab-bar-item-wallet')).toBeOnTheScreen(); // Click search toggle button to show search const searchButton = screen.getByTestId( - PerpsMarketListViewSelectorsIDs.SEARCH_TOGGLE_BUTTON, + `${PerpsMarketListViewSelectorsIDs.CLOSE_BUTTON}-search-toggle`, ); act(() => { fireEvent.press(searchButton); @@ -851,50 +913,28 @@ describe('PerpsMarketListView', () => { expect(screen.getByTestId('tab-bar-item-actions')).toBeOnTheScreen(); expect(screen.getByTestId('tab-bar-item-activity')).toBeOnTheScreen(); }); + }); - it('hides navbar when search input is pressed again after keyboard dismissal', () => { - // Mock keyboard listener - let keyboardHideCallback: (() => void) | null = null; - const mockAddListener = jest.fn((event, callback) => { - if (event === 'keyboardDidHide') { - keyboardHideCallback = callback; - } - return { remove: jest.fn() }; - }); - const { Keyboard } = jest.requireActual('react-native'); - jest.spyOn(Keyboard, 'addListener').mockImplementation(mockAddListener); - - renderWithProvider(); - - // Open search - const searchButton = screen.getByTestId( - PerpsMarketListViewSelectorsIDs.SEARCH_TOGGLE_BUTTON, + describe('Watchlist Filtering', () => { + it('shows all markets when showWatchlistOnly is false', () => { + // Mock watchlistMarkets to only include BTC + const { selectPerpsWatchlistMarkets } = jest.requireMock( + '../../selectors/perpsController', ); - act(() => { - fireEvent.press(searchButton); - }); + selectPerpsWatchlistMarkets.mockReturnValue(['BTC']); - // Navbar should be hidden - expect(screen.queryByTestId('tab-bar-item-wallet')).not.toBeOnTheScreen(); - - // Simulate keyboard dismissal - act(() => { - if (keyboardHideCallback) { - keyboardHideCallback(); - } + // Mock route params without showWatchlistOnly + mockUseRoute.mockReturnValue({ + name: 'PerpsMarketListView', + params: {}, }); - // Navbar should be visible - expect(screen.getByTestId('tab-bar-item-wallet')).toBeOnTheScreen(); - - // Press the search input again - const searchInput = screen.getByPlaceholderText('Search by token symbol'); - act(() => { - fireEvent(searchInput, 'pressIn'); - }); + renderWithProvider(, { state: mockState }); - // Navbar should be hidden again - expect(screen.queryByTestId('tab-bar-item-wallet')).not.toBeOnTheScreen(); + // Should show all markets + expect(screen.getByTestId('market-row-BTC')).toBeOnTheScreen(); + expect(screen.getByTestId('market-row-ETH')).toBeOnTheScreen(); + expect(screen.getByTestId('market-row-SOL')).toBeOnTheScreen(); }); }); @@ -912,7 +952,7 @@ describe('PerpsMarketListView', () => { }); it('does not throw error when onMarketSelect is not provided', () => { - renderWithProvider(); + renderWithProvider(, { state: mockState }); const btcRow = screen.getByTestId('market-row-BTC'); expect(() => fireEvent.press(btcRow)).not.toThrow(); @@ -920,22 +960,6 @@ describe('PerpsMarketListView', () => { }); describe('Loading States', () => { - it('shows skeleton loading state when data is loading', () => { - mockUsePerpsMarkets.mockReturnValue({ - markets: [], - isLoading: true, - error: null, - refresh: jest.fn(), - isRefreshing: false, - }); - - renderWithProvider(); - - expect( - screen.getAllByTestId('perps-market-list-skeleton-row'), - ).toHaveLength(8); - }); - it('shows header even during loading', () => { mockUsePerpsMarkets.mockReturnValue({ markets: [], @@ -945,47 +969,14 @@ describe('PerpsMarketListView', () => { isRefreshing: false, }); - renderWithProvider(); + renderWithProvider(, { state: mockState }); - expect(screen.getByText('Volume')).toBeOnTheScreen(); - expect(screen.getByText('Price / 24h change')).toBeOnTheScreen(); + // During loading, sort dropdowns are hidden, so don't check for them + expect(screen.getByText('Perps')).toBeOnTheScreen(); }); }); describe('Error Handling', () => { - it('shows error message when there is an error', () => { - mockUsePerpsMarkets.mockReturnValue({ - markets: [], - isLoading: false, - error: 'Network error', - refresh: jest.fn(), - isRefreshing: false, - }); - - renderWithProvider(); - - expect(screen.getByText('Failed to load market data')).toBeOnTheScreen(); - expect(screen.getByText('Tap to retry')).toBeOnTheScreen(); - }); - - it('calls refresh when retry button is pressed', () => { - const mockRefresh = jest.fn().mockResolvedValue(undefined); - mockUsePerpsMarkets.mockReturnValue({ - markets: [], - isLoading: false, - error: 'Network error', - refresh: mockRefresh, - isRefreshing: false, - }); - - renderWithProvider(); - - const retryButton = screen.getByText('Tap to retry'); - fireEvent.press(retryButton); - - expect(mockRefresh).toHaveBeenCalled(); - }); - it('shows error only when no markets are available', () => { mockUsePerpsMarkets.mockReturnValue({ markets: mockMarketData, @@ -995,7 +986,7 @@ describe('PerpsMarketListView', () => { isRefreshing: false, }); - renderWithProvider(); + renderWithProvider(, { state: mockState }); expect( screen.queryByText('Failed to load market data'), @@ -1004,41 +995,10 @@ describe('PerpsMarketListView', () => { }); }); - describe('Pull to Refresh', () => { - it('handles pull to refresh', () => { - const mockRefresh = jest.fn().mockResolvedValue(undefined); - mockUsePerpsMarkets.mockReturnValue({ - markets: mockMarketData, - isLoading: false, - error: null, - refresh: mockRefresh, - isRefreshing: false, - }); - - renderWithProvider(); - - const flashList = screen.getByTestId('flash-list'); - fireEvent(flashList, 'onRefresh'); - - expect(mockRefresh).toHaveBeenCalled(); - }); - }); - describe('Navigation', () => { - it('navigates back when close button is pressed', () => { - renderWithProvider(); - - // Find close button (first TouchableOpacity after the market rows) - const touchableElements = screen.root.findAllByType(TouchableOpacity); - const closeButton = touchableElements[0]; // Close button is the first one - fireEvent.press(closeButton); - - expect(mockNavigation.goBack).toHaveBeenCalled(); - }); - it('does not navigate back when canGoBack returns false', () => { mockNavigation.canGoBack.mockReturnValue(false); - renderWithProvider(); + renderWithProvider(, { state: mockState }); // Find close button (first TouchableOpacity after the market rows) const touchableElements = screen.root.findAllByType(TouchableOpacity); @@ -1051,7 +1011,7 @@ describe('PerpsMarketListView', () => { describe('Market Data Display', () => { it('displays market data correctly', () => { - renderWithProvider(); + renderWithProvider(, { state: mockState }); expect(screen.getByTestId('market-symbol-BTC')).toHaveTextContent('BTC'); expect(screen.getByTestId('market-name-BTC')).toHaveTextContent( @@ -1066,7 +1026,7 @@ describe('PerpsMarketListView', () => { }); it('displays all provided markets', () => { - renderWithProvider(); + renderWithProvider(, { state: mockState }); mockMarketData.forEach((market) => { expect( @@ -1078,7 +1038,7 @@ describe('PerpsMarketListView', () => { describe('TabBar Navigation', () => { it('renders all TabBar items', () => { - renderWithProvider(); + renderWithProvider(, { state: mockState }); expect(screen.getByTestId('tab-bar-item-wallet')).toBeOnTheScreen(); expect(screen.getByTestId('tab-bar-item-browser')).toBeOnTheScreen(); @@ -1088,7 +1048,7 @@ describe('PerpsMarketListView', () => { }); it('navigates to wallet when home tab is pressed', () => { - renderWithProvider(); + renderWithProvider(, { state: mockState }); const walletTab = screen.getByTestId('tab-bar-item-wallet'); fireEvent.press(walletTab); @@ -1102,7 +1062,7 @@ describe('PerpsMarketListView', () => { }); it('navigates to browser when browser tab is pressed', () => { - renderWithProvider(); + renderWithProvider(, { state: mockState }); const browserTab = screen.getByTestId('tab-bar-item-browser'); fireEvent.press(browserTab); @@ -1116,7 +1076,7 @@ describe('PerpsMarketListView', () => { }); it('navigates to wallet actions when actions tab is pressed', () => { - renderWithProvider(); + renderWithProvider(, { state: mockState }); const actionsTab = screen.getByTestId('tab-bar-item-actions'); fireEvent.press(actionsTab); @@ -1130,7 +1090,7 @@ describe('PerpsMarketListView', () => { }); it('navigates to activity when activity tab is pressed', () => { - renderWithProvider(); + renderWithProvider(, { state: mockState }); const activityTab = screen.getByTestId('tab-bar-item-activity'); fireEvent.press(activityTab); @@ -1141,7 +1101,7 @@ describe('PerpsMarketListView', () => { }); it('navigates to rewards when rewards tab is pressed', () => { - renderWithProvider(); + renderWithProvider(, { state: mockState }); const rewardsTab = screen.getByTestId('tab-bar-item-rewards'); fireEvent.press(rewardsTab); @@ -1155,7 +1115,7 @@ describe('PerpsMarketListView', () => { ); selectRewardsEnabledFlag.mockReturnValue(false); - renderWithProvider(); + renderWithProvider(, { state: mockState }); const settingsTab = screen.getByTestId('tab-bar-item-settings'); fireEvent.press(settingsTab); @@ -1173,85 +1133,11 @@ describe('PerpsMarketListView', () => { }); describe('Edge Cases', () => { - it('handles empty market data gracefully', () => { - mockUsePerpsMarkets.mockReturnValue({ - markets: [], - isLoading: false, - error: null, - refresh: jest.fn(), - isRefreshing: false, - }); - - renderWithProvider(); - - expect(screen.getByText('Volume')).toBeOnTheScreen(); - expect(screen.getByText('Price / 24h change')).toBeOnTheScreen(); - expect(screen.queryByTestId('market-row-BTC')).not.toBeOnTheScreen(); - }); - - it('handles search with no results', () => { - renderWithProvider(); - - // First toggle search visibility - const searchButton = screen.getByTestId( - PerpsMarketListViewSelectorsIDs.SEARCH_TOGGLE_BUTTON, - ); - act(() => { - fireEvent.press(searchButton); - }); - - const searchInput = screen.getByPlaceholderText('Search by token symbol'); - - act(() => { - fireEvent.changeText(searchInput, 'NONEXISTENT'); - }); - - expect(screen.queryByTestId('market-row-BTC')).not.toBeOnTheScreen(); - expect(screen.queryByTestId('market-row-ETH')).not.toBeOnTheScreen(); - expect(screen.queryByTestId('market-row-SOL')).not.toBeOnTheScreen(); - }); - - it('shows empty state when search returns no results', () => { - renderWithProvider(); - - // First toggle search visibility - const searchButton = screen.getByTestId( - PerpsMarketListViewSelectorsIDs.SEARCH_TOGGLE_BUTTON, - ); - act(() => { - fireEvent.press(searchButton); - }); - - const searchInput = screen.getByPlaceholderText('Search by token symbol'); - - act(() => { - fireEvent.changeText(searchInput, 'NONEXISTENT'); - }); - - // Should show empty state message - expect(screen.getByText('No tokens found')).toBeOnTheScreen(); - expect( - screen.getByText( - 'We couldn\'t find any tokens with the name "NONEXISTENT". Try a different search.', - ), - ).toBeOnTheScreen(); - - // Should not show any market rows - expect(screen.queryByTestId('market-row-BTC')).not.toBeOnTheScreen(); - expect(screen.queryByTestId('market-row-ETH')).not.toBeOnTheScreen(); - expect(screen.queryByTestId('market-row-SOL')).not.toBeOnTheScreen(); - - // Should not show list header when empty state is shown - expect(screen.queryByText('Volume')).not.toBeOnTheScreen(); - expect(screen.queryByText('Price / 24h change')).not.toBeOnTheScreen(); - }); - it('handles search with whitespace', () => { - renderWithProvider(); + renderWithProvider(, { state: mockState }); - // First toggle search visibility const searchButton = screen.getByTestId( - PerpsMarketListViewSelectorsIDs.SEARCH_TOGGLE_BUTTON, + `${PerpsMarketListViewSelectorsIDs.CLOSE_BUTTON}-search-toggle`, ); act(() => { fireEvent.press(searchButton); @@ -1262,14 +1148,9 @@ describe('PerpsMarketListView', () => { fireEvent.changeText(searchInput, ' '); }); - // Should show no markets when search is visible but query is empty/whitespace - expect(screen.queryByTestId('market-row-BTC')).not.toBeOnTheScreen(); - expect(screen.queryByTestId('market-row-ETH')).not.toBeOnTheScreen(); - expect(screen.queryByTestId('market-row-SOL')).not.toBeOnTheScreen(); - - // Should show empty state with search prompt (not query-specific message) - expect(screen.getByText('No tokens found')).toBeOnTheScreen(); - expect(screen.getByText('Search by token symbol')).toBeOnTheScreen(); + expect(screen.getByTestId('market-row-BTC')).toBeOnTheScreen(); + expect(screen.getByTestId('market-row-ETH')).toBeOnTheScreen(); + expect(screen.getByTestId('market-row-SOL')).toBeOnTheScreen(); }); }); }); diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx index 21a3a5f3c306..43022a561053 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx @@ -1,14 +1,11 @@ -import React, { useEffect, useRef, useState, useMemo } from 'react'; -import { - View, - TouchableOpacity, - Animated, - TextInput, - Pressable, - Keyboard, -} from 'react-native'; -import { FlashList } from '@shopify/flash-list'; -import { Skeleton } from '../../../../../component-library/components/Skeleton'; +import React, { + useEffect, + useRef, + useState, + useMemo, + useCallback, +} from 'react'; +import { View, Animated, Keyboard } from 'react-native'; import { useStyles } from '../../../../../component-library/hooks'; import Icon, { IconName, @@ -19,25 +16,29 @@ import Text, { TextVariant, TextColor, } from '../../../../../component-library/components/Texts/Text'; -import PerpsMarketRowItem from '../../components/PerpsMarketRowItem'; +import { + TabsList, + type TabsListRef, +} from '../../../../../component-library/components-temp/Tabs'; import PerpsMarketBalanceActions from '../../components/PerpsMarketBalanceActions'; -import { usePerpsMarkets } from '../../hooks/usePerpsMarkets'; +import TextFieldSearch from '../../../../../component-library/components/Form/TextFieldSearch'; +import PerpsMarketSortFieldBottomSheet from '../../components/PerpsMarketSortFieldBottomSheet'; +import PerpsMarketFiltersBar from './components/PerpsMarketFiltersBar'; +import PerpsMarketList from '../../components/PerpsMarketList'; +import PerpsBottomTabBar from '../../components/PerpsBottomTabBar'; +import PerpsMarketListHeader from '../../components/PerpsMarketListHeader'; +import { + usePerpsMarketListView, + usePerpsMeasurement, + usePerpsNavigation, +} from '../../hooks'; +import PerpsMarketRowSkeleton from './components/PerpsMarketRowSkeleton'; import styleSheet from './PerpsMarketListView.styles'; import { PerpsMarketListViewProps } from './PerpsMarketListView.types'; import type { PerpsMarketData } from '../../controllers/types'; import { PerpsMarketListViewSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; -import { - NavigationProp, - useNavigation, - useRoute, - RouteProp, -} from '@react-navigation/native'; -import Routes from '../../../../../constants/navigation/Routes'; -import { - SafeAreaView, - useSafeAreaInsets, -} from 'react-native-safe-area-context'; -import { usePerpsMeasurement } from '../../hooks'; +import { useRoute, RouteProp } from '@react-navigation/native'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { TraceName } from '../../../../../util/trace'; import { PerpsEventProperties, @@ -45,186 +46,236 @@ import { } from '../../constants/eventNames'; import { MetaMetricsEvents } from '../../../../hooks/useMetrics'; import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; -import { useSelector } from 'react-redux'; -import { selectRewardsEnabledFlag } from '../../../../../selectors/featureFlagController/rewards'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import TabBarItem from '../../../../../component-library/components/Navigation/TabBarItem'; -import { - Box, - BoxFlexDirection, - BoxAlignItems, -} from '@metamask/design-system-react-native'; import { PerpsNavigationParamList } from '../../types/navigation'; -const PerpsMarketRowItemSkeleton = () => { - const { styles } = useStyles(styleSheet, {}); - - return ( - - - {/* Avatar skeleton */} - - - - {/* Token symbol skeleton */} - - {/* Leverage skeleton */} - - - {/* Volume skeleton */} - - - - - {/* Price skeleton */} - - {/* Change skeleton */} - - - - ); -}; - -const PerpsMarketListHeader = () => { - const { styles } = useStyles(styleSheet, {}); - - return ( - - - - {strings('perps.volume')} - - - - - {strings('perps.price_24h_change')} - - - - ); -}; - const PerpsMarketListView = ({ onMarketSelect, protocolId: _protocolId, + variant: propVariant, + title: propTitle, + showBalanceActions: propShowBalanceActions, + showBottomNav: propShowBottomNav, + defaultSearchVisible: propDefaultSearchVisible, + showWatchlistOnly: propShowWatchlistOnly, }: PerpsMarketListViewProps) => { const { styles, theme } = useStyles(styleSheet, {}); - const navigation = useNavigation>(); + const route = + useRoute>(); + + // Use centralized navigation hook + const perpsNavigation = usePerpsNavigation(); + + // Merge route params with props (route params take precedence) + const variant = route.params?.variant ?? propVariant ?? 'full'; + const title = route.params?.title ?? propTitle; + const showBalanceActions = + route.params?.showBalanceActions ?? propShowBalanceActions ?? true; + const showBottomNav = + route.params?.showBottomNav ?? propShowBottomNav ?? true; + const defaultSearchVisible = + route.params?.defaultSearchVisible ?? propDefaultSearchVisible ?? false; + const showWatchlistOnly = + route.params?.showWatchlistOnly ?? propShowWatchlistOnly ?? false; + const defaultMarketTypeFilter = + route.params?.defaultMarketTypeFilter ?? 'all'; + const fadeAnimation = useRef(new Animated.Value(0)).current; - const hiddenButtonStyle = { - position: 'absolute' as const, - opacity: 0, - pointerEvents: 'box-none' as const, - }; - const [searchQuery, setSearchQuery] = useState(''); - const [isSearchVisible, setIsSearchVisible] = useState(false); + const tabsListRef = useRef(null); + const noPaddingContentStyle = useMemo(() => ({ paddingHorizontal: 0 }), []); const [shouldShowNavbar, setShouldShowNavbar] = useState(true); - const isRewardsEnabled = useSelector(selectRewardsEnabledFlag); + const [isSortFieldSheetVisible, setIsSortFieldSheetVisible] = useState(false); + // Use the combined market list view hook for all business logic const { - markets, + markets: filteredMarkets, + searchState, + sortState, + favoritesState, + marketTypeFilterState, + marketCounts, isLoading: isLoadingMarkets, error, - refresh: refreshMarkets, - isRefreshing: isRefreshingMarkets, - } = usePerpsMarkets({ + } = usePerpsMarketListView({ + defaultSearchVisible, enablePolling: false, + showWatchlistOnly, + defaultMarketTypeFilter, + showZeroVolume: true, // Show $0.00 volume markets in list view }); - useEffect(() => { - if (markets.length > 0) { - Animated.timing(fadeAnimation, { - toValue: 1, - duration: 300, - useNativeDriver: true, - }).start(); + // Destructure search state for easier access + const { + searchQuery, + setSearchQuery, + isSearchVisible, + toggleSearchVisibility, + clearSearch, + } = searchState; + + // Destructure sort state for easier access + const { selectedOptionId, sortBy, handleOptionChange } = sortState; + + // Destructure favorites state for easier access + const { showFavoritesOnly, setShowFavoritesOnly } = favoritesState; + + // Destructure market type filter state + const { marketTypeFilter, setMarketTypeFilter } = marketTypeFilterState; + + // Handler for market press (defined early to avoid use-before-define) + const handleMarketPress = useCallback( + (market: PerpsMarketData) => { + if (onMarketSelect) { + onMarketSelect(market); + } else { + perpsNavigation.navigateToMarketDetails(market, route.params?.source); + } + }, + [onMarketSelect, perpsNavigation, route.params?.source], + ); + + // Market type tab content component (reusable for all types) + const MarketTypeTabContent = useCallback( + ({ tabLabel }: { tabLabel: string }) => ( + + + + ), + [ + filteredMarkets, + handleMarketPress, + sortBy, + fadeAnimation, + styles.animatedListContainer, + noPaddingContentStyle, + ], + ); + + // Build tabs array dynamically based on available markets (hide empty tabs) + const tabsToRender = useMemo(() => { + const tabs = []; + + if (marketCounts.crypto > 0) { + tabs.push( + , + ); } - }, [markets.length, fadeAnimation]); - const { track } = usePerpsEventTracking(); - const route = - useRoute>(); + if (marketCounts.equity > 0) { + tabs.push( + , + ); + } - const handleMarketPress = (market: PerpsMarketData) => { - if (onMarketSelect) { - onMarketSelect(market); - } else { - navigation.navigate(Routes.PERPS.MARKET_DETAILS, { - market, - source: route.params?.source, - }); + if (marketCounts.commodity > 0) { + tabs.push( + , + ); } - }; - const handleRefresh = () => { - refreshMarkets().catch((err) => { - console.error('Failed to refresh markets:', err); - }); - }; - const handleBackPressed = () => { - // Navigate back to the main Perps tab - if (navigation.canGoBack()) { - navigation.goBack(); + if (marketCounts.forex > 0) { + tabs.push( + , + ); } - }; - const filteredMarkets = useMemo(() => { - // First filter out markets with no volume or $0 volume - const marketsWithVolume = markets.filter((market: PerpsMarketData) => { - // Check if volume exists and is not zero - if ( - !market.volume || - market.volume === '$0' || - market.volume === '$0.00' - ) { - return false; - } - // Also filter out fallback display values - if (market.volume === '$---' || market.volume === '---') { - return false; + return tabs; + }, [marketCounts, MarketTypeTabContent]); + + // Calculate active tab index from current marketTypeFilter + const activeTabIndex = useMemo(() => { + if (marketTypeFilter === 'all' || tabsToRender.length === 0) { + return 0; // Default to first tab + } + + // Map filter to index based on available tabs + const filterToKeyMap = { + crypto: 'crypto-tab', + equity: 'equity-tab', + commodity: 'commodity-tab', + forex: 'forex-tab', + }; + + const targetKey = + filterToKeyMap[marketTypeFilter as keyof typeof filterToKeyMap]; + const index = tabsToRender.findIndex((tab) => tab.key === targetKey); + return index >= 0 ? index : 0; + }, [marketTypeFilter, tabsToRender]); + + // Handle tab change (when user swipes) + const handleTabChange = useCallback( + ({ i }: { i: number }) => { + // Map index back to market type filter + const keyToFilterMap: Record< + string, + 'crypto' | 'equity' | 'commodity' | 'forex' + > = { + 'crypto-tab': 'crypto', + 'equity-tab': 'equity', + 'commodity-tab': 'commodity', + 'forex-tab': 'forex', + }; + + const tabKey = tabsToRender[i]?.key; + const filter = tabKey ? keyToFilterMap[tabKey as string] : undefined; + + if (filter) { + setMarketTypeFilter(filter); } - return true; - }); + }, + [tabsToRender, setMarketTypeFilter], + ); - // If search is not visible, show all markets - if (!isSearchVisible) { - return marketsWithVolume; + // Sync TabsList when active tab changes (e.g., from navigation param) + useEffect(() => { + if (tabsListRef.current && activeTabIndex >= 0 && tabsToRender.length > 0) { + tabsListRef.current.goToTabIndex(activeTabIndex); } + }, [activeTabIndex, tabsToRender.length]); - // If search is visible but query is empty, show no markets - if (!searchQuery.trim()) { - return []; + useEffect(() => { + if (filteredMarkets.length > 0) { + Animated.timing(fadeAnimation, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }).start(); } + }, [filteredMarkets.length, fadeAnimation]); - // Filter based on search query - const query = searchQuery.toLowerCase().trim(); - return marketsWithVolume.filter( - (market: PerpsMarketData) => - market.symbol.toLowerCase().includes(query) || - market.name.toLowerCase().includes(query), - ); - }, [markets, searchQuery, isSearchVisible]); + const { track } = usePerpsEventTracking(); + + // Use navigation hook for back button + const handleBackPressed = perpsNavigation.navigateBack; const handleSearchToggle = () => { - setIsSearchVisible(!isSearchVisible); + toggleSearchVisibility(); if (isSearchVisible) { - // Clear search when hiding search bar - setSearchQuery(''); + clearSearch(); setShouldShowNavbar(true); } else { setShouldShowNavbar(false); - // Track search bar clicked event track(MetaMetricsEvents.PERPS_UI_INTERACTION, { [PerpsEventProperties.INTERACTION_TYPE]: PerpsEventValues.INTERACTION_TYPE.SEARCH_CLICKED, @@ -232,6 +283,10 @@ const PerpsMarketListView = ({ } }; + const handleFavoritesToggle = () => { + setShowFavoritesOnly(!showFavoritesOnly); + }; + // Performance tracking: Measure screen load time until market data is displayed usePerpsMeasurement({ traceName: TraceName.PerpsMarketListView, @@ -256,7 +311,7 @@ const PerpsMarketListView = ({ route.params?.source || PerpsEventValues.SOURCE.MAIN_ACTION_BUTTON; usePerpsEventTracking({ eventName: MetaMetricsEvents.PERPS_SCREEN_VIEWED, - conditions: [markets.length > 0], + conditions: [filteredMarkets.length > 0], properties: { [PerpsEventProperties.SCREEN_TYPE]: PerpsEventValues.SCREEN_TYPE.MARKETS, [PerpsEventProperties.SOURCE]: source, @@ -268,13 +323,13 @@ const PerpsMarketListView = ({ if (isLoadingMarkets) { 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 - - - + ))} ); @@ -291,11 +346,37 @@ const PerpsMarketListView = ({ > {strings('perps.failed_to_load_market_data')} - - - {strings('perps.tap_to_retry')} - - + + {strings('perps.data_updates_automatically')} + + + ); + } + + // Empty favorites results - show when favorites filter is active but no favorites found + if (showFavoritesOnly && filteredMarkets.length === 0) { + return ( + + + + {strings('perps.no_favorites_found')} + + + {strings('perps.no_favorites_description')} + ); } @@ -330,218 +411,113 @@ const PerpsMarketListView = ({ ); } + // Use reusable PerpsMarketList component return ( - <> - - - ( - - )} - keyExtractor={(item: PerpsMarketData) => item.symbol} - contentContainerStyle={styles.flashListContent} - refreshing={isRefreshingMarkets} - onRefresh={handleRefresh} - /** - * Fixes "double-tap" UX issue where the first tap dismissed the keyboard - * and the second tap selects a list item. - * - * This allows users to directly select a list item with a single tap. - */ - keyboardShouldPersistTaps="handled" - /> - - - ); - }; - - const tw = useTailwind(); - const insets = useSafeAreaInsets(); - - const renderBottomTabBar = () => { - const handleWalletPress = () => { - navigation.navigate(Routes.WALLET.HOME, { - screen: Routes.WALLET.TAB_STACK_FLOW, - params: { - screen: Routes.WALLET_VIEW, - }, - }); - }; - - const handleBrowserPress = () => { - navigation.navigate(Routes.BROWSER.HOME, { - screen: Routes.BROWSER.VIEW, - }); - }; - - const handleActionsPress = () => { - navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { - screen: Routes.MODAL.WALLET_ACTIONS, - }); - }; - - const handleActivityPress = () => { - navigation.navigate(Routes.TRANSACTIONS_VIEW); - }; - - const handleRewardsOrSettingsPress = () => { - if (isRewardsEnabled) { - navigation.navigate(Routes.REWARDS_VIEW); - } else { - navigation.navigate(Routes.SETTINGS_VIEW, { - screen: 'Settings', - }); - } - }; - - return ( - - - - - - - - - - - - - - - - - - - + + + ); }; return ( - {/* Hidden close button for navigation tests */} - - {/* Header */} - Keyboard.dismiss()}> - - - {strings('perps.title')} - - - - - - - - + {/* Search Bar - Use design system component */} {isSearchVisible && ( - - - setShouldShowNavbar(false)} - autoCapitalize="none" - autoCorrect={false} - autoFocus - /> - {searchQuery.length > 0 && ( - setSearchQuery('')} - style={styles.clearButton} - testID={PerpsMarketListViewSelectorsIDs.SEARCH_CLEAR_BUTTON} - > - - - )} - + 0} + onPressClearButton={() => setSearchQuery('')} + placeholder={strings('perps.search_by_token_symbol')} + testID={PerpsMarketListViewSelectorsIDs.SEARCH_BAR} + /> )} - {/* Balance Actions Component */} - {!isSearchVisible && } + {/* Balance Actions Component - Only show in full variant when search not visible */} + {!isSearchVisible && showBalanceActions && variant === 'full' && ( + + )} - {renderMarketList()} + {/* Filter Bar - Only visible when search is NOT active */} + {!isSearchVisible && + !isLoadingMarkets && + !error && + (filteredMarkets.length > 0 || showFavoritesOnly) && ( + setIsSortFieldSheetVisible(true)} + showWatchlistOnly={showFavoritesOnly} + onWatchlistToggle={handleFavoritesToggle} + testID={PerpsMarketListViewSelectorsIDs.SORT_FILTERS} + /> + )} + + {/* Market Type Tabs - Only visible when search is NOT active and tabs exist */} + {!isSearchVisible && + !isLoadingMarkets && + !error && + tabsToRender.length > 0 && ( + + + {tabsToRender} + + + )} + + {/* Market list hidden when tabs are shown (tabs contain the list) */} + {!isSearchVisible && + !isLoadingMarkets && + !error && + tabsToRender.length === 0 && ( + + {renderMarketList()} + + )} - {/* Bottom navbar - hidden when search is visible */} - {shouldShowNavbar && ( - {renderBottomTabBar()} + {/* Show regular list when searching or loading */} + {(isSearchVisible || isLoadingMarkets || error) && ( + {renderMarketList()} )} + + {/* Bottom navbar - only show in full variant, hidden when search visible */} + {showBottomNav && variant === 'full' && shouldShowNavbar && ( + + + + )} + + {/* Sort Field Bottom Sheet */} + setIsSortFieldSheetVisible(false)} + selectedOptionId={selectedOptionId} + onOptionSelect={handleOptionChange} + testID={`${PerpsMarketListViewSelectorsIDs.SORT_FILTERS}-field-sheet`} + /> ); }; diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.types.ts b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.types.ts index 9ba7fa379095..4ccd79866b3d 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.types.ts +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.types.ts @@ -13,6 +13,7 @@ export interface ProtocolMarketData { /** * Props for PerpsMarketListView component + * Now configurable for different use cases (full view, minimal view) */ export interface PerpsMarketListViewProps { /** @@ -24,4 +25,37 @@ export interface PerpsMarketListViewProps { * If not provided, uses the active protocol from PerpsController */ protocolId?: string; + /** + * View variant + * - 'full': Full market list view with balance actions, tutorial, bottom nav + * - 'minimal': Minimal view for embedded use (e.g., trending markets) + * @default 'full' + */ + variant?: 'full' | 'minimal'; + /** + * Optional custom title + * If not provided, uses default 'perps.title' + */ + title?: string; + /** + * Show balance actions component (deposit/withdraw) + * Only applicable when search is not visible + * @default true + */ + showBalanceActions?: boolean; + /** + * Show bottom navigation bar + * @default true + */ + showBottomNav?: boolean; + /** + * Start with search bar visible + * @default false + */ + defaultSearchVisible?: boolean; + /** + * Start with watchlist filter enabled (show only watchlisted markets) + * @default false + */ + showWatchlistOnly?: boolean; } diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketFiltersBar/PerpsMarketFiltersBar.styles.ts b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketFiltersBar/PerpsMarketFiltersBar.styles.ts new file mode 100644 index 000000000000..7e2fbdca4966 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketFiltersBar/PerpsMarketFiltersBar.styles.ts @@ -0,0 +1,31 @@ +import { StyleSheet } from 'react-native'; +import type { Theme } from '../../../../../../../util/theme/models'; + +/** + * Styles for PerpsMarketFiltersBar component + */ +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + + return StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + width: '100%', + backgroundColor: theme.colors.background.default, + }, + sortContainer: { + flex: 1, + }, + watchlistButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: 8, + paddingVertical: 6, + marginLeft: 8, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketFiltersBar/PerpsMarketFiltersBar.test.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketFiltersBar/PerpsMarketFiltersBar.test.tsx new file mode 100644 index 000000000000..97c3a46e31d7 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketFiltersBar/PerpsMarketFiltersBar.test.tsx @@ -0,0 +1,229 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import PerpsMarketFiltersBar from './PerpsMarketFiltersBar'; + +jest.mock('../../../../components/PerpsMarketSortDropdowns', () => { + const { TouchableOpacity, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + selectedOptionId, + onSortPress, + testID, + }: { + selectedOptionId: string; + onSortPress: () => void; + testID?: string; + }) => ( + + {selectedOptionId} + + ), + }; +}); + +jest.mock( + '../../../../../../../component-library/components/Icons/Icon', + () => { + const { Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ name, testID }: { name: string; testID?: string }) => ( + {name} + ), + IconName: { + Star: 'Star', + StarFilled: 'StarFilled', + }, + IconSize: { Sm: 'sm' }, + }; + }, +); + +jest.mock( + '../../../../../../../component-library/components/Texts/Text', + () => { + const { Text: RNText } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + children, + testID, + }: { + children: React.ReactNode; + testID?: string; + }) => {children}, + TextVariant: { BodyMD: 'BodyMD' }, + }; + }, +); + +describe('PerpsMarketFiltersBar', () => { + const mockOnSortPress = jest.fn(); + const mockOnWatchlistToggle = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Rendering', () => { + it('renders without crashing', () => { + const { toJSON } = render( + , + ); + + expect(toJSON()).toBeTruthy(); + }); + + it('renders sort dropdown with correct props', () => { + const { getByTestId } = render( + , + ); + + const sortDropdown = getByTestId('filters-bar-sort'); + expect(sortDropdown).toBeTruthy(); + }); + + it('renders watchlist button with text and Star icon when inactive', () => { + const { getByTestId, getByText } = render( + , + ); + + const watchlistButton = getByTestId('filters-bar-watchlist-toggle'); + expect(watchlistButton).toBeTruthy(); + expect(getByText('Watchlist')).toBeTruthy(); + expect(getByText('Star')).toBeTruthy(); + }); + + it('renders watchlist button with text and StarFilled icon when active', () => { + const { getByTestId, getByText } = render( + , + ); + + const watchlistButton = getByTestId('filters-bar-watchlist-toggle'); + expect(watchlistButton).toBeTruthy(); + expect(getByText('Watchlist')).toBeTruthy(); + expect(getByText('StarFilled')).toBeTruthy(); + }); + }); + + describe('Interactions', () => { + it('calls onSortPress when sort dropdown is pressed', () => { + const { getByTestId } = render( + , + ); + + const sortDropdown = getByTestId('filters-bar-sort'); + fireEvent.press(sortDropdown); + + expect(mockOnSortPress).toHaveBeenCalledTimes(1); + }); + + it('calls onWatchlistToggle when watchlist button is pressed', () => { + const { getByTestId } = render( + , + ); + + const watchlistButton = getByTestId('filters-bar-watchlist-toggle'); + fireEvent.press(watchlistButton); + + expect(mockOnWatchlistToggle).toHaveBeenCalledTimes(1); + }); + }); + + describe('Test IDs', () => { + it('applies custom testID and derived testIDs', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('custom-filters')).toBeTruthy(); + expect(getByTestId('custom-filters-sort')).toBeTruthy(); + expect(getByTestId('custom-filters-watchlist-toggle')).toBeTruthy(); + }); + + it('handles missing testID gracefully', () => { + const { toJSON } = render( + , + ); + + expect(toJSON()).toBeTruthy(); + }); + }); + + describe('Watchlist State', () => { + it('updates icon when watchlist state changes', () => { + const { getByText, rerender } = render( + , + ); + + expect(getByText('Star')).toBeTruthy(); + expect(getByText('Watchlist')).toBeTruthy(); + + rerender( + , + ); + + expect(getByText('StarFilled')).toBeTruthy(); + expect(getByText('Watchlist')).toBeTruthy(); + }); + }); +}); diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketFiltersBar/PerpsMarketFiltersBar.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketFiltersBar/PerpsMarketFiltersBar.tsx new file mode 100644 index 000000000000..d51856e6710b --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketFiltersBar/PerpsMarketFiltersBar.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { View, TouchableOpacity } from 'react-native'; +import { useStyles } from '../../../../../../../component-library/hooks'; +import Icon, { + IconName, + IconSize, +} from '../../../../../../../component-library/components/Icons/Icon'; +import Text, { + TextVariant, +} from '../../../../../../../component-library/components/Texts/Text'; +import PerpsMarketSortDropdowns from '../../../../components/PerpsMarketSortDropdowns'; +import type { PerpsMarketFiltersBarProps } from './PerpsMarketFiltersBar.types'; +import styleSheet from './PerpsMarketFiltersBar.styles'; + +/** + * PerpsMarketFiltersBar Component + * + * Combines market sort dropdown with watchlist filter toggle + * Provides a unified filter bar for the markets list + * + * Features: + * - Sort dropdown on the left (market, volume, open interest, etc.) + * - Watchlist toggle button on the right (icon + text) + * - Visual feedback for active watchlist filter (filled vs outline star) + * + * @example + * ```tsx + * setSheetVisible(true)} + * showWatchlistOnly={false} + * onWatchlistToggle={() => setShowWatchlist(!showWatchlist)} + * /> + * ``` + */ +const PerpsMarketFiltersBar: React.FC = ({ + selectedOptionId, + onSortPress, + showWatchlistOnly, + onWatchlistToggle, + testID, +}) => { + const { styles } = useStyles(styleSheet, {}); + + return ( + + + + + + + Watchlist + + + ); +}; + +export default PerpsMarketFiltersBar; diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketFiltersBar/PerpsMarketFiltersBar.types.ts b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketFiltersBar/PerpsMarketFiltersBar.types.ts new file mode 100644 index 000000000000..a9c8bca17235 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketFiltersBar/PerpsMarketFiltersBar.types.ts @@ -0,0 +1,31 @@ +import type { SortOptionId } from '../../../../constants/perpsConfig'; + +/** + * Props for PerpsMarketFiltersBar component + */ +export interface PerpsMarketFiltersBarProps { + /** + * Currently selected sort option ID + */ + selectedOptionId: SortOptionId; + + /** + * Callback when sort dropdown is pressed + */ + onSortPress: () => void; + + /** + * Whether watchlist-only filter is active + */ + showWatchlistOnly: boolean; + + /** + * Callback when watchlist filter is toggled + */ + onWatchlistToggle: () => void; + + /** + * Optional test ID for testing + */ + testID?: string; +} diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketFiltersBar/index.ts b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketFiltersBar/index.ts new file mode 100644 index 000000000000..88bf482da4f7 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketFiltersBar/index.ts @@ -0,0 +1,2 @@ +export { default } from './PerpsMarketFiltersBar'; +export type { PerpsMarketFiltersBarProps } from './PerpsMarketFiltersBar.types'; diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/PerpsMarketRowSkeleton.styles.ts b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/PerpsMarketRowSkeleton.styles.ts new file mode 100644 index 000000000000..e4bd0b48a717 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/PerpsMarketRowSkeleton.styles.ts @@ -0,0 +1,56 @@ +import { StyleSheet } from 'react-native'; + +/** + * Styles for PerpsMarketRowSkeleton component + */ +const styleSheet = () => + StyleSheet.create({ + skeletonContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 16, + minHeight: 88, + }, + skeletonLeftSection: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + skeletonAvatar: { + borderRadius: 20, + marginRight: 16, + }, + skeletonTokenInfo: { + flex: 1, + }, + skeletonTokenHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 6, + }, + skeletonTokenSymbol: { + borderRadius: 4, + marginRight: 8, + }, + skeletonLeverage: { + borderRadius: 4, + }, + skeletonVolume: { + borderRadius: 4, + }, + skeletonRightSection: { + alignItems: 'flex-end', + flex: 1, + }, + skeletonPrice: { + borderRadius: 4, + marginBottom: 6, + }, + skeletonChange: { + borderRadius: 4, + }, + }); + +export default styleSheet; diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/PerpsMarketRowSkeleton.test.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/PerpsMarketRowSkeleton.test.tsx new file mode 100644 index 000000000000..85f90d8fdb24 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/PerpsMarketRowSkeleton.test.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import PerpsMarketRowSkeleton from './PerpsMarketRowSkeleton'; + +jest.mock('../../../../../../../component-library/components/Skeleton', () => { + const { View } = jest.requireActual('react-native'); + return { + Skeleton: ({ + testID, + ...props + }: { + testID?: string; + width?: number; + height?: number; + style?: object; + }) => , + }; +}); + +describe('PerpsMarketRowSkeleton', () => { + it('renders without crashing with testID', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('market-skeleton')).toBeTruthy(); + }); + + it('renders without crashing without testID', () => { + const { toJSON } = render(); + + expect(toJSON()).toBeTruthy(); + }); +}); diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/PerpsMarketRowSkeleton.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/PerpsMarketRowSkeleton.tsx new file mode 100644 index 000000000000..555343d84df8 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/PerpsMarketRowSkeleton.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { View } from 'react-native'; +import { Skeleton } from '../../../../../../../component-library/components/Skeleton'; +import { useStyles } from '../../../../../../../component-library/hooks'; +import type { PerpsMarketRowSkeletonProps } from './PerpsMarketRowSkeleton.types'; +import styleSheet from './PerpsMarketRowSkeleton.styles'; + +/** + * PerpsMarketRowSkeleton Component + * + * Loading skeleton component for Perps market list rows + * Displays placeholder content while market data is loading + * + * Features: + * - Avatar skeleton (40x40 circle) + * - Token symbol and leverage skeletons + * - Volume skeleton + * - Price and change skeletons + * - Matches the layout of actual market rows + * + * @example + * ```tsx + * {Array.from({ length: 8 }).map((_, index) => ( + * + * ))} + * ``` + */ +const PerpsMarketRowSkeleton: React.FC = ({ + testID, +}) => { + const { styles } = useStyles(styleSheet, {}); + + return ( + + + {/* Avatar skeleton */} + + + + {/* Token symbol skeleton */} + + {/* Leverage skeleton */} + + + {/* Volume skeleton */} + + + + + {/* Price skeleton */} + + {/* Change skeleton */} + + + + ); +}; + +export default PerpsMarketRowSkeleton; diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/PerpsMarketRowSkeleton.types.ts b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/PerpsMarketRowSkeleton.types.ts new file mode 100644 index 000000000000..bf35b3eeee7e --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/PerpsMarketRowSkeleton.types.ts @@ -0,0 +1,9 @@ +/** + * Props for PerpsMarketRowSkeleton component + */ +export interface PerpsMarketRowSkeletonProps { + /** + * Test ID for the skeleton container + */ + testID?: string; +} diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/index.ts b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/index.ts new file mode 100644 index 000000000000..12dfd5af0053 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton/index.ts @@ -0,0 +1,2 @@ +export { default } from './PerpsMarketRowSkeleton'; +export type { PerpsMarketRowSkeletonProps } from './PerpsMarketRowSkeleton.types'; diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx index 1390446c2020..24fe83a168b5 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx @@ -6,7 +6,7 @@ import { screen, waitFor, } from '@testing-library/react-native'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback } from 'react'; import { useSelector } from 'react-redux'; import { TouchableOpacity } from 'react-native'; import { Text } from '@metamask/design-system-react-native'; @@ -2536,7 +2536,7 @@ describe('PerpsOrderView', () => { callback: (positions: MockPosition[]) => void; } - it('should use existing position leverage as default when no leverage is provided via route params', async () => { + it('should not sync leverage from position when route param leverage is provided', async () => { // Create a custom test wrapper that provides position data const TestWrapperWithPositions = ({ children, @@ -2549,26 +2549,26 @@ describe('PerpsOrderView', () => { mockStreamManager.positions.subscribe = jest.fn( (params: SubscribeParams) => { const { callback } = params; - // Simulate position data for BTC with leverage 15 + // Simulate position data for ETH with leverage 15 callback([ { - coin: 'BTC', - size: '0.1', - entryPrice: '50000', - positionValue: '5000', - unrealizedPnl: '100', - marginUsed: '333.33', + coin: 'ETH', + size: '0.5', + entryPrice: '3000', + positionValue: '1500', + unrealizedPnl: '50', + marginUsed: '100', leverage: { type: 'isolated', value: 15, }, - liquidationPrice: '45000', + liquidationPrice: '2700', maxLeverage: 50, - returnOnEquity: '30', + returnOnEquity: '50', cumulativeFunding: { - allTime: '5', - sinceOpen: '2', - sinceChange: '1', + allTime: '3', + sinceOpen: '1', + sinceChange: '0.5', }, takeProfitCount: 0, stopLossCount: 0, @@ -2589,22 +2589,22 @@ describe('PerpsOrderView', () => { ); }; - // Mock route params with BTC asset but no leverage + // Mock route params with ETH asset and explicit leverage (useRoute as jest.Mock).mockReturnValue({ params: { - asset: 'BTC', + asset: 'ETH', direction: 'long', - // No leverage provided - should use position's leverage + leverage: 5, // Explicit leverage should take precedence }, }); - // Mock the order context to verify leverage is set correctly + // Mock the order context with the explicit leverage from route const mockSetLeverage = jest.fn(); (usePerpsOrderContext as jest.Mock).mockReturnValue({ orderForm: { - asset: 'BTC', + asset: 'ETH', amount: '11', - leverage: 3, // Initially the default leverage + leverage: 5, // Already set from route params direction: 'long', type: 'market', limitPrice: undefined, @@ -2631,45 +2631,52 @@ describe('PerpsOrderView', () => { render(, { wrapper: TestWrapperWithPositions }); - await waitFor(() => { - // Verify setLeverage was called with the position's leverage (15x) - expect(mockSetLeverage).toHaveBeenCalledWith(15); - }); + // Wait a bit to ensure the effect runs + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify setLeverage was NOT called because leverage was already set from route params + expect(mockSetLeverage).not.toHaveBeenCalled(); + + // Verify the leverage displayed is from route params + expect(screen.getByText('5x')).toBeDefined(); }); - it('should not sync leverage from position when route param leverage is provided', async () => { - // Create a custom test wrapper that provides position data - const TestWrapperWithPositions = ({ + it('should prioritize existing position leverage over saved config (bug fix)', async () => { + // This test verifies the fix for the leverage priority chain bug + // Scenario: User has saved config at 5x, but existing position at 10x + // Expected: Form should initialize with 10x (not 5x) + + // Create a custom test wrapper with position data + const TestWrapperWithPositionsAndConfig = ({ children, }: { children: React.ReactNode; }) => { const mockStreamManager = createMockStreamManager(); - // Override positions.subscribe to provide position data + // Override positions.subscribe to provide position data for BTC at 10x mockStreamManager.positions.subscribe = jest.fn( (params: SubscribeParams) => { const { callback } = params; - // Simulate position data for ETH with leverage 15 callback([ { - coin: 'ETH', - size: '0.5', - entryPrice: '3000', - positionValue: '1500', - unrealizedPnl: '50', - marginUsed: '100', + coin: 'BTC', + size: '0.1', + entryPrice: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', leverage: { type: 'isolated', - value: 15, + value: 10, // Existing position at 10x }, - liquidationPrice: '2700', + liquidationPrice: '45000', maxLeverage: 50, - returnOnEquity: '50', + returnOnEquity: '20', cumulativeFunding: { - allTime: '3', - sinceOpen: '1', - sinceChange: '0.5', + allTime: '5', + sinceOpen: '2', + sinceChange: '1', }, takeProfitCount: 0, stopLossCount: 0, @@ -2690,22 +2697,23 @@ describe('PerpsOrderView', () => { ); }; - // Mock route params with ETH asset and explicit leverage + // Mock route params with BTC but no leverage param (useRoute as jest.Mock).mockReturnValue({ params: { - asset: 'ETH', + asset: 'BTC', direction: 'long', - leverage: 5, // Explicit leverage should take precedence + // No leverage param - should use existing position (10x), not saved config (5x) }, }); - // Mock the order context with the explicit leverage from route + // Mock the order context with initial leverage from existing position (10x) + // In the real app, PerpsOrderProvider would initialize with this const mockSetLeverage = jest.fn(); (usePerpsOrderContext as jest.Mock).mockReturnValue({ orderForm: { - asset: 'ETH', + asset: 'BTC', amount: '11', - leverage: 5, // Already set from route params + leverage: 10, // Should be 10x from existing position, not 5x from saved config direction: 'long', type: 'market', limitPrice: undefined, @@ -2726,20 +2734,16 @@ describe('PerpsOrderView', () => { maxPossibleAmount: 1000, calculations: { marginRequired: '11', - positionSize: '0.0037', + positionSize: '0.0002', }, }); - render(, { wrapper: TestWrapperWithPositions }); - - // Wait a bit to ensure the effect runs - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Verify setLeverage was NOT called because leverage was already set from route params - expect(mockSetLeverage).not.toHaveBeenCalled(); + render(, { + wrapper: TestWrapperWithPositionsAndConfig, + }); - // Verify the leverage displayed is from route params - expect(screen.getByText('5x')).toBeDefined(); + // Verify the leverage is 10x from existing position, not 5x from saved config + expect(screen.getByText('10x')).toBeDefined(); }); }); @@ -2750,13 +2754,7 @@ describe('PerpsOrderView', () => { // Create a component that exposes the tooltip close handler const TestComponent = () => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [selectedTooltip, setSelectedTooltip] = useState( - 'points', - ); - const handleTooltipClose = useCallback(() => { - setSelectedTooltip(null); mockSetSelectedTooltip(null); }, []); diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index 6ab289d904c5..34f574889431 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -85,11 +85,7 @@ import { usePerpsToasts, usePerpsTrading, } from '../../hooks'; -import { - usePerpsLiveAccount, - usePerpsLivePrices, - usePerpsLivePositions, -} from '../../hooks/stream'; +import { usePerpsLiveAccount, usePerpsLivePrices } from '../../hooks/stream'; import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; import { @@ -190,40 +186,26 @@ const PerpsOrderViewContentBase: React.FC = () => { maxPossibleAmount, } = usePerpsOrderContext(); - // Get live positions to sync leverage from existing position - const { positions, isInitialLoading: isLoadingPositions } = - usePerpsLivePositions(); - - // Track if we've already synced leverage from existing position - const hasSyncedLeverageRef = useRef(false); - - // Effect to update leverage from existing position after positions load - useEffect(() => { - // Only update if: - // 1. Positions have loaded - // 2. We haven't already synced (to avoid overwriting user changes) - // 3. Current leverage is the default (3) - meaning no explicit leverage was provided - if ( - !isLoadingPositions && - !hasSyncedLeverageRef.current && - orderForm.leverage === PERPS_CONSTANTS.DEFAULT_MAX_LEVERAGE - ) { - const existingPosition = positions.find( - (position) => position.coin === orderForm.asset, - ); - - if (existingPosition?.leverage?.value) { - setLeverage(existingPosition.leverage.value); - hasSyncedLeverageRef.current = true; - } - } - }, [ - isLoadingPositions, - positions, - orderForm.asset, - orderForm.leverage, - setLeverage, - ]); + /** + * PROTOCOL CONSTRAINT: Existing position leverage + * + * HyperLiquid protocol requirement: when adding to an existing position, + * the new order's leverage MUST be >= the existing position's leverage. + * If not met, the order will fail. + * + * This is SEPARATE from user preference (saved trade configuration): + * - User preference (saved config): Default for NEW positions (no existing position) + * - Protocol constraint: Required minimum for EXISTING positions + * + * Priority chain for leverage selection (enforced in usePerpsOrderForm): + * 1. Navigation param (explicit user intent via route) + * 2. Existing position leverage (protocol requirement - synced via hook effect) + * 3. Saved trade config (user preference - from controller state) + * 4. Default 3x (fallback) + * + * Note: Positions load asynchronously via WebSocket. usePerpsOrderForm handles + * updating leverage after positions load to prevent protocol violations. + */ // Market data hook - now uses orderForm.asset from context const { @@ -546,6 +528,11 @@ const PerpsOrderViewContentBase: React.FC = () => { orderForm.limitPrice, ]); + // Get existing position leverage for validation (protocol constraint) + // Note: This is the same value used for initial form state, but needed here for validation + const existingPositionLeverageForValidation = + existingPosition?.leverage?.value; + // Order validation using new hook const orderValidation = usePerpsOrderValidation({ orderForm, @@ -553,6 +540,7 @@ const PerpsOrderViewContentBase: React.FC = () => { assetPrice: assetData.price, availableBalance, marginRequired: marginRequired || '0', + existingPositionLeverage: existingPositionLeverageForValidation, }); // Filter out specific validation error(s) from display (similar to ClosePositionView pattern) diff --git a/app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.tsx b/app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.tsx index 779fb01c7734..61758bcebf4c 100644 --- a/app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.tsx +++ b/app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.tsx @@ -138,7 +138,7 @@ const PerpsPositionsView: React.FC = () => { iconColor={IconColor.Default} size={ButtonIconSizes.Md} onPress={handleBackPress} - testID="back-button" + testID={PerpsPositionsViewSelectorsIDs.BACK_BUTTON} /> {strings('perps.position.title')} diff --git a/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx b/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx index 9a00099fb737..57898d09d618 100644 --- a/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx +++ b/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx @@ -381,7 +381,7 @@ const PerpsTPSLView: React.FC = () => { iconColor={IconColor.Default} size={ButtonIconSizes.Md} onPress={handleBack} - testID="back-button" + testID={PerpsTPSLViewSelectorsIDs.BACK_BUTTON} /> diff --git a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.styles.ts b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.styles.ts index c44b6c77e585..1468372c3b55 100644 --- a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.styles.ts +++ b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.styles.ts @@ -24,9 +24,10 @@ const styleSheet = (params: { theme: Theme }) => { justifyContent: 'space-between', alignItems: 'center', marginBottom: 8, + paddingTop: 16, }, sectionTitle: { - paddingTop: 16, + // Removed paddingTop - now on parent sectionHeader for consistent alignment }, emptyContainer: { padding: 24, diff --git a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx index adb464b85f79..93e0b875222e 100644 --- a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx @@ -90,7 +90,7 @@ jest.mock('../../hooks', () => ({ // Mock stream hooks separately since they're imported from different path jest.mock('../../hooks/stream', () => ({ - usePerpsLiveOrders: jest.fn(() => []), + usePerpsLiveOrders: jest.fn(() => ({ orders: [] })), usePerpsLiveAccount: jest.fn(() => ({ account: { availableBalance: '1000.00', @@ -246,7 +246,7 @@ describe('PerpsTabView', () => { isInitialLoading: false, }); - mockUsePerpsLiveOrders.mockReturnValue([]); + mockUsePerpsLiveOrders.mockReturnValue({ orders: [] }); mockUsePerpsTrading.mockReturnValue({ getAccountState: jest.fn(), @@ -414,7 +414,7 @@ describe('PerpsTabView', () => { }); expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKETS, + screen: Routes.PERPS.PERPS_HOME, params: { source: 'position_tab' }, }); }); @@ -426,10 +426,12 @@ describe('PerpsTabView', () => { isInitialLoading: false, }); - mockUsePerpsLiveOrders.mockReturnValue([ - { orderId: '123', symbol: 'ETH', size: '1.0', orderType: 'limit' }, - { orderId: '456', symbol: 'BTC', size: '0.5', orderType: 'market' }, - ]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [ + { orderId: '123', symbol: 'ETH', size: '1.0', orderType: 'limit' }, + { orderId: '456', symbol: 'BTC', size: '0.5', orderType: 'market' }, + ], + }); // When the view is rendered render(); @@ -454,7 +456,7 @@ describe('PerpsTabView', () => { isInitialLoading: false, }); - mockUsePerpsLiveOrders.mockReturnValue([]); + mockUsePerpsLiveOrders.mockReturnValue({ orders: [] }); // When the view is rendered render(); @@ -480,7 +482,7 @@ describe('PerpsTabView', () => { isInitialLoading: false, }); - mockUsePerpsLiveOrders.mockReturnValue([]); + mockUsePerpsLiveOrders.mockReturnValue({ orders: [] }); // When the view is rendered render(); @@ -502,9 +504,11 @@ describe('PerpsTabView', () => { isInitialLoading: false, }); - mockUsePerpsLiveOrders.mockReturnValue([ - { orderId: '123', symbol: 'ETH', size: '1.0', orderType: 'limit' }, - ]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [ + { orderId: '123', symbol: 'ETH', size: '1.0', orderType: 'limit' }, + ], + }); // When the view is rendered render(); @@ -558,7 +562,7 @@ describe('PerpsTabView', () => { }); expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKETS, + screen: Routes.PERPS.PERPS_HOME, params: { source: PerpsEventValues.SOURCE.HOMESCREEN_TAB }, }); }); @@ -574,7 +578,7 @@ describe('PerpsTabView', () => { loadPositions: jest.fn(), }); - mockUsePerpsLiveOrders.mockReturnValue([]); + mockUsePerpsLiveOrders.mockReturnValue({ orders: [] }); // Act - Render component render(); @@ -590,7 +594,7 @@ describe('PerpsTabView', () => { isInitialLoading: false, }); - mockUsePerpsLiveOrders.mockReturnValue([]); + mockUsePerpsLiveOrders.mockReturnValue({ orders: [] }); render(); @@ -604,9 +608,9 @@ describe('PerpsTabView', () => { isInitialLoading: false, }); - mockUsePerpsLiveOrders.mockReturnValue([ - { orderId: '123', symbol: 'ETH', size: '1.0' }, - ]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [{ orderId: '123', symbol: 'ETH', size: '1.0' }], + }); render(); @@ -620,7 +624,7 @@ describe('PerpsTabView', () => { isInitialLoading: false, }); - mockUsePerpsLiveOrders.mockReturnValue([]); + mockUsePerpsLiveOrders.mockReturnValue({ orders: [] }); render(); diff --git a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx index 6764878cbaa7..2bff1bcc2da1 100644 --- a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx +++ b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx @@ -14,6 +14,7 @@ import Icon, { } from '../../../../../component-library/components/Icons/Icon'; import Text, { TextVariant, + TextColor, } from '../../../../../component-library/components/Texts/Text'; import { useStyles } from '../../../../../component-library/hooks'; import Routes from '../../../../../constants/navigation/Routes'; @@ -63,7 +64,7 @@ const PerpsTabView: React.FC = () => { ], }); - const orders = usePerpsLiveOrders({ + const { orders } = usePerpsLiveOrders({ hideTpSl: true, // Filter out TP/SL orders throttleMs: 1000, // Update orders every second }); @@ -91,7 +92,7 @@ const PerpsTabView: React.FC = () => { const handleManageBalancePress = useCallback(() => { navigation.navigate(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKETS, + screen: Routes.PERPS.PERPS_HOME, params: { source: PerpsEventValues.SOURCE.HOMESCREEN_TAB }, }); }, [navigation]); @@ -103,12 +104,25 @@ const PerpsTabView: React.FC = () => { } else { // Navigate to trading view for returning users navigation.navigate(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKETS, + screen: Routes.PERPS.PERPS_HOME, params: { source: PerpsEventValues.SOURCE.POSITION_TAB }, }); } }, [navigation, isFirstTimeUser]); + // Modal handlers - now using navigation to modal stack + const handleCloseAllPress = useCallback(() => { + navigation.navigate(Routes.PERPS.MODALS.ROOT, { + screen: Routes.PERPS.MODALS.CLOSE_ALL_POSITIONS, + }); + }, [navigation]); + + const handleCancelAllPress = useCallback(() => { + navigation.navigate(Routes.PERPS.MODALS.ROOT, { + screen: Routes.PERPS.MODALS.CANCEL_ALL_ORDERS, + }); + }, [navigation]); + const renderStartTradeCTA = () => ( = () => { {strings('perps.order.open_orders')} + + + {strings('perps.home.cancel_all')} + + {orders.map((order) => ( @@ -179,6 +198,11 @@ const PerpsTabView: React.FC = () => { > {strings('perps.position.title')} + + + {strings('perps.home.close_all')} + + {positions.map((position, index) => { diff --git a/app/components/UI/Perps/__mocks__/engineMocks.ts b/app/components/UI/Perps/__mocks__/engineMocks.ts index 9d5c012e0391..2e0c12eda935 100644 --- a/app/components/UI/Perps/__mocks__/engineMocks.ts +++ b/app/components/UI/Perps/__mocks__/engineMocks.ts @@ -46,10 +46,12 @@ export const createMockEngineContext = () => ({ clearDepositResult: jest.fn(), }, RewardsController: { - getPerpsDiscountForAccount: jest.fn().mockResolvedValue(0), + getPerpsDiscountForAccount: jest.fn().mockReturnValue(Promise.resolve(0)), estimatePoints: jest .fn() - .mockResolvedValue({ pointsEstimate: 100, bonusBips: 200 }), + .mockReturnValue( + Promise.resolve({ pointsEstimate: 100, bonusBips: 200 }), + ), }, }); diff --git a/app/components/UI/Perps/__mocks__/providerMocks.ts b/app/components/UI/Perps/__mocks__/providerMocks.ts index 46637bce73fe..838925948487 100644 --- a/app/components/UI/Perps/__mocks__/providerMocks.ts +++ b/app/components/UI/Perps/__mocks__/providerMocks.ts @@ -66,3 +66,44 @@ export const createMockOrderParams = () => ({ amount: '0.1', price: '50000', }); + +export const createMockOrder = (overrides = {}) => ({ + orderId: 'order-1', + symbol: 'BTC', + side: 'buy' as const, + orderType: 'limit' as const, + size: '0.1', + originalSize: '0.1', + price: '50000', + filledSize: '0', + remainingSize: '0.1', + status: 'open' as const, + timestamp: Date.now(), + ...overrides, +}); + +export const createMockPosition = (overrides = {}) => ({ + coin: 'BTC', + size: '0.5', + entryPrice: '50000', + positionValue: '25000', + unrealizedPnl: '100', + marginUsed: '1000', + leverage: { type: 'cross' as const, value: 25 }, + liquidationPrice: '48000', + maxLeverage: 50, + returnOnEquity: '10', + cumulativeFunding: { + allTime: '0', + sinceOpen: '0', + sinceChange: '0', + }, + roi: '10', + takeProfitPrice: undefined, + stopLossPrice: undefined, + takeProfitCount: 0, + stopLossCount: 0, + marketPrice: '50200', + timestamp: Date.now(), + ...overrides, +}); diff --git a/app/components/UI/Perps/components/PerpsBottomTabBar/PerpsBottomTabBar.styles.ts b/app/components/UI/Perps/components/PerpsBottomTabBar/PerpsBottomTabBar.styles.ts new file mode 100644 index 000000000000..97570ac8e650 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsBottomTabBar/PerpsBottomTabBar.styles.ts @@ -0,0 +1,29 @@ +import { StyleSheet } from 'react-native'; +import type { Theme } from '../../../../../util/theme/models'; + +/** + * Styles for PerpsBottomTabBar component + */ +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + + return StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'flex-end', + width: '100%', + paddingTop: 12, + marginBottom: 4, + paddingHorizontal: 8, + backgroundColor: theme.colors.background.default, + borderTopWidth: 1, + borderTopColor: theme.colors.border.muted, + }, + tabItem: { + flex: 1, + width: '100%', + }, + }); +}; + +export default styleSheet; diff --git a/app/components/UI/Perps/components/PerpsBottomTabBar/PerpsBottomTabBar.test.tsx b/app/components/UI/Perps/components/PerpsBottomTabBar/PerpsBottomTabBar.test.tsx new file mode 100644 index 000000000000..430136ffb211 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsBottomTabBar/PerpsBottomTabBar.test.tsx @@ -0,0 +1,288 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import PerpsBottomTabBar from './PerpsBottomTabBar'; +import Routes from '../../../../../constants/navigation/Routes'; +import { PerpsHomeViewSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(), +})); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key) => { + const translations: Record = { + 'bottom_nav.home': 'Home', + 'bottom_nav.browser': 'Browser', + 'bottom_nav.activity': 'Activity', + 'bottom_nav.rewards': 'Rewards', + 'bottom_nav.settings': 'Settings', + }; + return translations[key] || key; + }), +})); + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: jest.fn(() => ({ bottom: 20, top: 0, left: 0, right: 0 })), +})); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: jest.fn(() => ({ + style: jest.fn((className) => ({ className })), + })), +})); + +jest.mock('@metamask/design-system-react-native', () => { + const { View } = jest.requireActual('react-native'); + return { + Box: ({ + children, + testID, + ...props + }: { + children?: React.ReactNode; + testID?: string; + style?: object; + }) => ( + + {children} + + ), + BoxFlexDirection: { + Row: 'row', + }, + BoxAlignItems: { + Center: 'center', + }, + }; +}); + +jest.mock( + '../../../../../component-library/components/Navigation/TabBarItem', + () => { + const { TouchableOpacity, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + onPress, + testID, + label, + }: { + onPress: () => void; + testID: string; + label: string; + }) => ( + + {label} + + ), + }; + }, +); + +describe('PerpsBottomTabBar', () => { + const mockNavigate = jest.fn(); + const mockUseNavigation = useNavigation as jest.MockedFunction< + typeof useNavigation + >; + const mockUseSelector = useSelector as jest.MockedFunction< + typeof useSelector + >; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseNavigation.mockReturnValue({ + navigate: mockNavigate, + } as Partial> as ReturnType); + mockUseSelector.mockReturnValue(false); // isRewardsEnabled = false + }); + + describe('Rendering', () => { + it('renders all tab items', () => { + const { getByText } = render(); + + expect(getByText('Home')).toBeTruthy(); + expect(getByText('Browser')).toBeTruthy(); + expect(getByText('Trade')).toBeTruthy(); + expect(getByText('Activity')).toBeTruthy(); + expect(getByText('Settings')).toBeTruthy(); // When rewards disabled + }); + + it('renders Rewards tab when rewards feature is enabled', () => { + mockUseSelector.mockReturnValue(true); // isRewardsEnabled = true + const { getByText } = render(); + + expect(getByText('Rewards')).toBeTruthy(); + }); + + it('renders Settings tab when rewards feature is disabled', () => { + mockUseSelector.mockReturnValue(false); // isRewardsEnabled = false + const { getByText } = render(); + + expect(getByText('Settings')).toBeTruthy(); + }); + + it('highlights active tab when activeTab prop is provided', () => { + const { getByTestId } = render(); + + const walletTab = getByTestId(PerpsHomeViewSelectorsIDs.TAB_BAR_WALLET); + expect(walletTab).toBeTruthy(); + // TabBarItem handles isActive internally, we just verify it's rendered + }); + }); + + describe('Default Navigation', () => { + it('navigates to wallet view when Home tab is pressed', () => { + const { getByTestId } = render(); + + const homeTab = getByTestId(PerpsHomeViewSelectorsIDs.TAB_BAR_WALLET); + fireEvent.press(homeTab); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET.HOME, { + screen: Routes.WALLET.TAB_STACK_FLOW, + params: { + screen: Routes.WALLET_VIEW, + }, + }); + }); + + it('navigates to browser view when Browser tab is pressed', () => { + const { getByTestId } = render(); + + const browserTab = getByTestId(PerpsHomeViewSelectorsIDs.TAB_BAR_BROWSER); + fireEvent.press(browserTab); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.BROWSER.HOME, { + screen: Routes.BROWSER.VIEW, + }); + }); + + it('navigates to actions modal when Trade tab is pressed', () => { + const { getByTestId } = render(); + + const tradeTab = getByTestId(PerpsHomeViewSelectorsIDs.TAB_BAR_ACTIONS); + fireEvent.press(tradeTab); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.MODAL.WALLET_ACTIONS, + }); + }); + + it('navigates to transactions view when Activity tab is pressed', () => { + const { getByTestId } = render(); + + const activityTab = getByTestId( + PerpsHomeViewSelectorsIDs.TAB_BAR_ACTIVITY, + ); + fireEvent.press(activityTab); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW); + }); + + it('navigates to settings view when Settings tab is pressed (rewards disabled)', () => { + mockUseSelector.mockReturnValue(false); + const { getByTestId } = render(); + + const settingsTab = getByTestId('tab-bar-item-settings'); + fireEvent.press(settingsTab); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.SETTINGS_VIEW, { + screen: 'Settings', + }); + }); + + it('navigates to rewards view when Rewards tab is pressed (rewards enabled)', () => { + mockUseSelector.mockReturnValue(true); + const { getByTestId } = render(); + + const rewardsTab = getByTestId('tab-bar-item-rewards'); + fireEvent.press(rewardsTab); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.REWARDS_VIEW); + }); + }); + + describe('Custom Navigation Handlers', () => { + it('uses custom onWalletPress handler when provided', () => { + const customHandler = jest.fn(); + const { getByTestId } = render( + , + ); + + const homeTab = getByTestId(PerpsHomeViewSelectorsIDs.TAB_BAR_WALLET); + fireEvent.press(homeTab); + + expect(customHandler).toHaveBeenCalledTimes(1); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('uses custom onBrowserPress handler when provided', () => { + const customHandler = jest.fn(); + const { getByTestId } = render( + , + ); + + const browserTab = getByTestId(PerpsHomeViewSelectorsIDs.TAB_BAR_BROWSER); + fireEvent.press(browserTab); + + expect(customHandler).toHaveBeenCalledTimes(1); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('uses custom onActionsPress handler when provided', () => { + const customHandler = jest.fn(); + const { getByTestId } = render( + , + ); + + const tradeTab = getByTestId(PerpsHomeViewSelectorsIDs.TAB_BAR_ACTIONS); + fireEvent.press(tradeTab); + + expect(customHandler).toHaveBeenCalledTimes(1); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('uses custom onActivityPress handler when provided', () => { + const customHandler = jest.fn(); + const { getByTestId } = render( + , + ); + + const activityTab = getByTestId( + PerpsHomeViewSelectorsIDs.TAB_BAR_ACTIVITY, + ); + fireEvent.press(activityTab); + + expect(customHandler).toHaveBeenCalledTimes(1); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('uses custom onRewardsOrSettingsPress handler when provided', () => { + const customHandler = jest.fn(); + const { getByTestId } = render( + , + ); + + const settingsTab = getByTestId('tab-bar-item-settings'); + fireEvent.press(settingsTab); + + expect(customHandler).toHaveBeenCalledTimes(1); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + }); + + describe('Test ID', () => { + it('applies custom testID to container when provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('custom-tab-bar')).toBeTruthy(); + }); + }); +}); diff --git a/app/components/UI/Perps/components/PerpsBottomTabBar/PerpsBottomTabBar.tsx b/app/components/UI/Perps/components/PerpsBottomTabBar/PerpsBottomTabBar.tsx new file mode 100644 index 000000000000..3ac412f14241 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsBottomTabBar/PerpsBottomTabBar.tsx @@ -0,0 +1,169 @@ +import React, { useCallback } from 'react'; +import { View, StyleSheet } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useNavigation, NavigationProp } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { useStyles } from '../../../../../component-library/hooks'; +import TabBarItem from '../../../../../component-library/components/Navigation/TabBarItem'; +import { IconName } from '../../../../../component-library/components/Icons/Icon'; +import { strings } from '../../../../../../locales/i18n'; +import Routes from '../../../../../constants/navigation/Routes'; +import { selectRewardsEnabledFlag } from '../../../../../selectors/featureFlagController/rewards'; +import { PerpsHomeViewSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; +import type { PerpsNavigationParamList } from '../../types/navigation'; +import type { PerpsBottomTabBarProps } from './PerpsBottomTabBar.types'; +import styleSheet from './PerpsBottomTabBar.styles'; + +/** + * PerpsBottomTabBar Component + * + * Reusable bottom navigation tab bar for Perps standalone views + * Shared by PerpsHomeView and PerpsMarketListView + * + * Features: + * - Default navigation handlers for all tabs + * - Optional custom handlers via props + * - Rewards/Settings toggle based on feature flag + * - Safe area inset handling for bottom padding + * - Active tab highlighting + * + * @example + * ```tsx + * + * ``` + * + * @example Custom handlers + * ```tsx + * + * ``` + */ +const PerpsBottomTabBar: React.FC = ({ + activeTab, + onWalletPress, + onBrowserPress, + onActionsPress, + onActivityPress, + onRewardsOrSettingsPress, + testID, +}) => { + const { styles } = useStyles(styleSheet, {}); + const insets = useSafeAreaInsets(); + const navigation = useNavigation>(); + const isRewardsEnabled = useSelector(selectRewardsEnabledFlag); + + // Default navigation handlers + const defaultHandleWalletPress = useCallback(() => { + navigation.navigate(Routes.WALLET.HOME, { + screen: Routes.WALLET.TAB_STACK_FLOW, + params: { + screen: Routes.WALLET_VIEW, + }, + }); + }, [navigation]); + + const defaultHandleBrowserPress = useCallback(() => { + navigation.navigate(Routes.BROWSER.HOME, { + screen: Routes.BROWSER.VIEW, + }); + }, [navigation]); + + const defaultHandleActionsPress = useCallback(() => { + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.MODAL.WALLET_ACTIONS, + }); + }, [navigation]); + + const defaultHandleActivityPress = useCallback(() => { + navigation.navigate(Routes.TRANSACTIONS_VIEW); + }, [navigation]); + + const defaultHandleRewardsOrSettingsPress = useCallback(() => { + if (isRewardsEnabled) { + navigation.navigate(Routes.REWARDS_VIEW); + } else { + navigation.navigate(Routes.SETTINGS_VIEW, { + screen: 'Settings', + }); + } + }, [navigation, isRewardsEnabled]); + + // Use custom handlers if provided, otherwise use defaults + const handleWalletPress = onWalletPress || defaultHandleWalletPress; + const handleBrowserPress = onBrowserPress || defaultHandleBrowserPress; + const handleActionsPress = onActionsPress || defaultHandleActionsPress; + const handleActivityPress = onActivityPress || defaultHandleActivityPress; + const handleRewardsOrSettingsPress = + onRewardsOrSettingsPress || defaultHandleRewardsOrSettingsPress; + + const containerStyle = StyleSheet.flatten([ + styles.container, + { paddingBottom: insets.bottom }, + ]); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export default PerpsBottomTabBar; diff --git a/app/components/UI/Perps/components/PerpsBottomTabBar/PerpsBottomTabBar.types.ts b/app/components/UI/Perps/components/PerpsBottomTabBar/PerpsBottomTabBar.types.ts new file mode 100644 index 000000000000..74cb588fd8e6 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsBottomTabBar/PerpsBottomTabBar.types.ts @@ -0,0 +1,31 @@ +/** + * Props for PerpsBottomTabBar component + */ +export interface PerpsBottomTabBarProps { + /** + * Currently active tab identifier + * @default undefined (no tab is marked as active) + */ + activeTab?: + | 'wallet' + | 'browser' + | 'trade' + | 'activity' + | 'rewards' + | 'settings'; + + /** + * Optional custom navigation handlers + * If not provided, uses default navigation behavior + */ + onWalletPress?: () => void; + onBrowserPress?: () => void; + onActionsPress?: () => void; + onActivityPress?: () => void; + onRewardsOrSettingsPress?: () => void; + + /** + * Test ID for the tab bar container + */ + testID?: string; +} diff --git a/app/components/UI/Perps/components/PerpsBottomTabBar/index.ts b/app/components/UI/Perps/components/PerpsBottomTabBar/index.ts new file mode 100644 index 000000000000..fe5af9e625c9 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsBottomTabBar/index.ts @@ -0,0 +1,2 @@ +export { default } from './PerpsBottomTabBar'; +export type { PerpsBottomTabBarProps } from './PerpsBottomTabBar.types'; diff --git a/app/components/UI/Perps/components/PerpsCancelAllOrdersModal/PerpsCancelAllOrdersModal.styles.ts b/app/components/UI/Perps/components/PerpsCancelAllOrdersModal/PerpsCancelAllOrdersModal.styles.ts new file mode 100644 index 000000000000..f156008e8bc9 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsCancelAllOrdersModal/PerpsCancelAllOrdersModal.styles.ts @@ -0,0 +1,24 @@ +import { StyleSheet } from 'react-native'; +import type { Theme } from '../../../../../util/theme/models'; + +const styleSheet = (_params: { theme: Theme }) => + StyleSheet.create({ + contentContainer: { + paddingHorizontal: 16, + paddingVertical: 24, + minHeight: 100, + }, + loadingContainer: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 16, + }, + loadingText: { + marginTop: 16, + }, + footerContainer: { + paddingTop: 16, + }, + }); + +export default styleSheet; diff --git a/app/components/UI/Perps/components/PerpsCancelAllOrdersModal/PerpsCancelAllOrdersModal.test.tsx b/app/components/UI/Perps/components/PerpsCancelAllOrdersModal/PerpsCancelAllOrdersModal.test.tsx new file mode 100644 index 000000000000..45b9a75034b3 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsCancelAllOrdersModal/PerpsCancelAllOrdersModal.test.tsx @@ -0,0 +1,401 @@ +import React from 'react'; +import { + render, + screen, + fireEvent, + waitFor, +} from '@testing-library/react-native'; +import PerpsCancelAllOrdersModal from './PerpsCancelAllOrdersModal'; +import Engine from '../../../../../core/Engine'; + +// Mock dependencies +jest.mock('../../../../../core/Engine', () => ({ + context: { + PerpsController: { + cancelOrders: jest.fn(), + }, + }, +})); + +jest.mock('../../hooks/usePerpsToasts', () => ({ + __esModule: true, + default: () => ({ + showToast: jest.fn(), + }), +})); + +jest.mock('expo-haptics', () => ({ + NotificationFeedbackType: { + Success: 'success', + Error: 'error', + }, +})); + +jest.mock('../../../../../component-library/hooks', () => ({ + useStyles: () => ({ + styles: { + contentContainer: {}, + loadingContainer: {}, + loadingText: {}, + footerContainer: {}, + }, + theme: { + colors: { + primary: { default: '#000000' }, + accent03: { normal: '#00FF00', dark: '#008800' }, + accent01: { light: '#FF0000', dark: '#880000' }, + }, + }, + }), +})); + +jest.mock('../../../../hooks/useStyles', () => ({ + useStyles: () => ({ + styles: { + contentContainer: {}, + loadingContainer: {}, + loadingText: {}, + footerContainer: {}, + }, + theme: { + colors: { + primary: { default: '#000000' }, + accent03: { normal: '#00FF00', dark: '#008800' }, + accent01: { light: '#FF0000', dark: '#880000' }, + }, + }, + }), +})); + +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheet', + () => { + const MockReact = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: MockReact.forwardRef( + ( + { + children, + }: { + children: React.ReactNode; + }, + ref: React.Ref<{ + onOpenBottomSheet: () => void; + onCloseBottomSheet: () => void; + }>, + ) => { + MockReact.useImperativeHandle(ref, () => ({ + onOpenBottomSheet: jest.fn(), + onCloseBottomSheet: jest.fn(), + })); + + return {children}; + }, + ), + }; + }, +); + +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheetHeader', + () => { + const { View } = jest.requireActual('react-native'); + return function MockBottomSheetHeader({ + children, + }: { + children: React.ReactNode; + }) { + return {children}; + }; + }, +); + +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheetFooter', + () => { + const { View, TouchableOpacity, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: function MockBottomSheetFooter({ + buttonPropsArray, + }: { + buttonPropsArray: { + label: string; + onPress: () => void; + disabled?: boolean; + }[]; + }) { + return ( + + {buttonPropsArray.map((button, index) => ( + + {button.label} + + ))} + + ); + }, + ButtonsAlignment: { + Horizontal: 'horizontal', + Vertical: 'vertical', + }, + }; + }, +); + +jest.mock('./PerpsCancelAllOrdersModal.styles', () => () => ({})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string, options?: Record) => { + const translations: Record = { + 'perps.cancel_all_modal.title': 'Cancel All Orders', + 'perps.cancel_all_modal.description': + 'Are you sure you want to cancel all open orders?', + 'perps.cancel_all_modal.keep_orders': 'Keep Orders', + 'perps.cancel_all_modal.confirm': 'Cancel All', + 'perps.cancel_all_modal.canceling': 'Canceling...', + 'perps.cancel_all_modal.success_title': 'Orders Canceled', + 'perps.cancel_all_modal.success_message': `${options?.count} orders canceled successfully`, + 'perps.cancel_all_modal.partial_success': `${options?.successCount} of ${options?.totalCount} orders canceled`, + 'perps.cancel_all_modal.error_title': 'Cancellation Failed', + 'perps.cancel_all_modal.error_message': `Failed to cancel ${options?.count} orders`, + }; + return translations[key] || key; + }), +})); + +const mockOrders = [ + { + orderId: '1', + symbol: 'BTC', + side: 'buy' as const, + orderType: 'limit' as const, + size: '0.1', + originalSize: '0.1', + price: '50000', + filledSize: '0', + remainingSize: '0.1', + status: 'open' as const, + timestamp: Date.now(), + }, + { + orderId: '2', + symbol: 'ETH', + side: 'sell' as const, + orderType: 'limit' as const, + size: '1.0', + originalSize: '1.0', + price: '3000', + filledSize: '0', + remainingSize: '1.0', + status: 'open' as const, + timestamp: Date.now(), + }, +]; + +describe('PerpsCancelAllOrdersModal', () => { + const mockOnClose = jest.fn(); + const mockOnSuccess = jest.fn(); + const mockCancelOrders = Engine.context.PerpsController + .cancelOrders as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('Visibility', () => { + it('returns null when isVisible is false', () => { + const { toJSON } = render( + , + ); + + expect(toJSON()).toBeNull(); + }); + + it('renders when isVisible is true', () => { + render( + , + ); + + expect(screen.getByTestId('bottom-sheet')).toBeOnTheScreen(); + expect(screen.getByTestId('bottom-sheet-header')).toBeOnTheScreen(); + expect(screen.getByTestId('bottom-sheet-footer')).toBeOnTheScreen(); + }); + }); + + describe('Button Interactions', () => { + it('renders Keep Orders button', () => { + render( + , + ); + + expect(screen.getByTestId('footer-button-0')).toBeOnTheScreen(); + }); + + it('renders Cancel All button', () => { + render( + , + ); + + expect(screen.getByTestId('footer-button-1')).toBeOnTheScreen(); + }); + + it('calls handleKeepOrders when Keep Orders button is pressed', () => { + render( + , + ); + + fireEvent.press(screen.getByTestId('footer-button-0')); + + // Component should attempt to close the bottom sheet + expect(screen.getByTestId('bottom-sheet')).toBeOnTheScreen(); + }); + }); + + describe('Cancel Orders', () => { + it('calls cancelOrders with cancelAll true when Cancel All button is pressed', async () => { + mockCancelOrders.mockResolvedValue({ + success: true, + successCount: 2, + failureCount: 0, + }); + + render( + , + ); + + fireEvent.press(screen.getByTestId('footer-button-1')); + + await waitFor(() => { + expect(mockCancelOrders).toHaveBeenCalledWith({ cancelAll: true }); + }); + }); + + it('calls onSuccess when all orders are canceled successfully', async () => { + mockCancelOrders.mockResolvedValue({ + success: true, + successCount: 2, + failureCount: 0, + }); + + render( + , + ); + + fireEvent.press(screen.getByTestId('footer-button-1')); + + await waitFor(() => { + expect(mockOnSuccess).toHaveBeenCalled(); + }); + }); + + it('calls onSuccess on partial success', async () => { + mockCancelOrders.mockResolvedValue({ + success: false, + successCount: 1, + failureCount: 1, + }); + + render( + , + ); + + fireEvent.press(screen.getByTestId('footer-button-1')); + + await waitFor(() => { + expect(mockOnSuccess).toHaveBeenCalled(); + }); + }); + + it('does not call onSuccess when all orders fail to cancel', async () => { + mockCancelOrders.mockResolvedValue({ + success: false, + successCount: 0, + failureCount: 2, + }); + + render( + , + ); + + fireEvent.press(screen.getByTestId('footer-button-1')); + + await waitFor(() => { + expect(mockCancelOrders).toHaveBeenCalled(); + }); + + expect(mockOnSuccess).not.toHaveBeenCalled(); + }); + + it('handles error when cancelOrders throws', async () => { + mockCancelOrders.mockRejectedValue(new Error('Network error')); + + render( + , + ); + + fireEvent.press(screen.getByTestId('footer-button-1')); + + await waitFor(() => { + expect(mockCancelOrders).toHaveBeenCalled(); + }); + + expect(mockOnSuccess).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/UI/Perps/components/PerpsCancelAllOrdersModal/PerpsCancelAllOrdersModal.tsx b/app/components/UI/Perps/components/PerpsCancelAllOrdersModal/PerpsCancelAllOrdersModal.tsx new file mode 100644 index 000000000000..cf79844d8b13 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsCancelAllOrdersModal/PerpsCancelAllOrdersModal.tsx @@ -0,0 +1,208 @@ +import React, { useCallback, useState, useMemo } from 'react'; +import { View, ActivityIndicator } from 'react-native'; +import { NotificationFeedbackType } from 'expo-haptics'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../../component-library/components/BottomSheets/BottomSheet'; +import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import BottomSheetFooter, { + ButtonsAlignment, +} from '../../../../../component-library/components/BottomSheets/BottomSheetFooter'; +import Text, { + TextColor, + TextVariant, +} from '../../../../../component-library/components/Texts/Text'; +import { + ButtonSize, + ButtonVariants, +} from '../../../../../component-library/components/Buttons/Button'; +import { IconName } from '../../../../../component-library/components/Icons/Icon'; +import { ToastVariants } from '../../../../../component-library/components/Toast/Toast.types'; +import { useStyles } from '../../../../hooks/useStyles'; +import { strings } from '../../../../../../locales/i18n'; +import Engine from '../../../../../core/Engine'; +import createStyles from './PerpsCancelAllOrdersModal.styles'; +import usePerpsToasts, { + type PerpsToastOptions, +} from '../../hooks/usePerpsToasts'; +import type { Order } from '../../controllers/types'; + +interface PerpsCancelAllOrdersModalProps { + isVisible: boolean; + onClose: () => void; + orders: Order[]; + onSuccess?: () => void; +} + +const PerpsCancelAllOrdersModal: React.FC = ({ + isVisible, + onClose, + orders: _orders, + onSuccess, +}) => { + const { styles, theme } = useStyles(createStyles, {}); + const bottomSheetRef = React.useRef(null); + const [isCanceling, setIsCanceling] = useState(false); + const { showToast } = usePerpsToasts(); + + const showSuccessToast = useCallback( + (title: string, message?: string) => { + const toastConfig: PerpsToastOptions = { + variant: ToastVariants.Icon, + iconName: IconName.CheckBold, + backgroundColor: theme.colors.accent03.normal, + iconColor: theme.colors.accent03.dark, + hapticsType: NotificationFeedbackType.Success, + hasNoTimeout: false, + labelOptions: message + ? [ + { label: title, isBold: true }, + { label: '\n', isBold: false }, + { label: message, isBold: false }, + ] + : [{ label: title, isBold: true }], + }; + showToast(toastConfig); + }, + [showToast, theme.colors.accent03], + ); + + const showErrorToast = useCallback( + (title: string, message?: string) => { + const toastConfig: PerpsToastOptions = { + variant: ToastVariants.Icon, + iconName: IconName.Warning, + backgroundColor: theme.colors.accent01.light, + iconColor: theme.colors.accent01.dark, + hapticsType: NotificationFeedbackType.Error, + hasNoTimeout: false, + labelOptions: message + ? [ + { label: title, isBold: true }, + { label: '\n', isBold: false }, + { label: message, isBold: false }, + ] + : [{ label: title, isBold: true }], + }; + showToast(toastConfig); + }, + [showToast, theme.colors.accent01], + ); + + const handleConfirm = useCallback(async () => { + setIsCanceling(true); + try { + const result = await Engine.context.PerpsController.cancelOrders({ + cancelAll: true, + }); + + if (result.success && result.successCount > 0) { + showSuccessToast( + strings('perps.cancel_all_modal.success_title'), + strings('perps.cancel_all_modal.success_message', { + count: result.successCount, + }), + ); + onSuccess?.(); + bottomSheetRef.current?.onCloseBottomSheet(); + } else if (result.successCount > 0 && result.failureCount > 0) { + // Partial success + showSuccessToast( + strings('perps.cancel_all_modal.success_title'), + strings('perps.cancel_all_modal.partial_success', { + successCount: result.successCount, + totalCount: result.successCount + result.failureCount, + }), + ); + onSuccess?.(); + bottomSheetRef.current?.onCloseBottomSheet(); + } else { + showErrorToast( + strings('perps.cancel_all_modal.error_title'), + strings('perps.cancel_all_modal.error_message', { + count: result.failureCount, + }), + ); + } + } catch (error) { + showErrorToast( + strings('perps.cancel_all_modal.error_title'), + error instanceof Error ? error.message : 'Unknown error', + ); + } finally { + setIsCanceling(false); + } + }, [showSuccessToast, showErrorToast, onSuccess]); + + const handleKeepOrders = useCallback(() => { + bottomSheetRef.current?.onCloseBottomSheet(); + }, []); + + const footerButtons = useMemo( + () => [ + { + label: strings('perps.cancel_all_modal.keep_orders'), + onPress: handleKeepOrders, + variant: ButtonVariants.Secondary, + size: ButtonSize.Lg, + disabled: isCanceling, + }, + { + label: isCanceling + ? strings('perps.cancel_all_modal.canceling') + : strings('perps.cancel_all_modal.confirm'), + onPress: handleConfirm, + variant: ButtonVariants.Primary, + size: ButtonSize.Lg, + disabled: isCanceling, + }, + ], + [handleKeepOrders, handleConfirm, isCanceling], + ); + + if (!isVisible) return null; + + return ( + + + + {strings('perps.cancel_all_modal.title')} + + + + + {isCanceling ? ( + + + + {strings('perps.cancel_all_modal.canceling')} + + + ) : ( + + {strings('perps.cancel_all_modal.description')} + + )} + + + + + ); +}; + +export default PerpsCancelAllOrdersModal; diff --git a/app/components/UI/Perps/components/PerpsCard/PerpsCard.styles.ts b/app/components/UI/Perps/components/PerpsCard/PerpsCard.styles.ts index 9e0c8c72b801..bff060fe7809 100644 --- a/app/components/UI/Perps/components/PerpsCard/PerpsCard.styles.ts +++ b/app/components/UI/Perps/components/PerpsCard/PerpsCard.styles.ts @@ -1,8 +1,10 @@ import { StyleSheet } from 'react-native'; import type { Theme } from '../../../../../util/theme/models'; -const styleSheet = (_params: { theme: Theme }) => - StyleSheet.create({ +const styleSheet = (params: { theme: Theme; vars: { iconSize: number } }) => { + const { iconSize } = params.vars; + + return StyleSheet.create({ card: { paddingVertical: 12, marginVertical: 2, @@ -18,9 +20,9 @@ const styleSheet = (_params: { theme: Theme }) => flex: 1, }, assetIcon: { - width: 40, - height: 40, - borderRadius: 20, + width: iconSize, + height: iconSize, + borderRadius: iconSize / 2, marginRight: 12, }, cardInfo: { @@ -30,5 +32,6 @@ const styleSheet = (_params: { theme: Theme }) => alignItems: 'flex-end', }, }); +}; export default styleSheet; diff --git a/app/components/UI/Perps/components/PerpsCard/PerpsCard.tsx b/app/components/UI/Perps/components/PerpsCard/PerpsCard.tsx index fa596218ef28..35d2f10bfcab 100644 --- a/app/components/UI/Perps/components/PerpsCard/PerpsCard.tsx +++ b/app/components/UI/Perps/components/PerpsCard/PerpsCard.tsx @@ -20,6 +20,7 @@ import { usePerpsMarkets } from '../../hooks/usePerpsMarkets'; import PerpsTokenLogo from '../PerpsTokenLogo'; import styleSheet from './PerpsCard.styles'; import type { PerpsCardProps } from './PerpsCard.types'; +import { HOME_SCREEN_CONFIG } from '../../constants/perpsConfig'; /** * PerpsCard Component @@ -33,8 +34,9 @@ const PerpsCard: React.FC = ({ onPress, testID, source, + iconSize = HOME_SCREEN_CONFIG.DEFAULT_ICON_SIZE, }) => { - const { styles } = useStyles(styleSheet, {}); + const { styles } = useStyles(styleSheet, { iconSize }); const navigation = useNavigation>(); // Determine which type of data we have @@ -120,7 +122,7 @@ const PerpsCard: React.FC = ({ {symbol && ( )} diff --git a/app/components/UI/Perps/components/PerpsCard/PerpsCard.types.ts b/app/components/UI/Perps/components/PerpsCard/PerpsCard.types.ts index b76d1535f41f..a97352fa254e 100644 --- a/app/components/UI/Perps/components/PerpsCard/PerpsCard.types.ts +++ b/app/components/UI/Perps/components/PerpsCard/PerpsCard.types.ts @@ -6,4 +6,5 @@ export interface PerpsCardProps { onPress?: () => void; testID?: string; source?: string; + iconSize?: number; } diff --git a/app/components/UI/Perps/components/PerpsCloseAllPositionsModal/PerpsCloseAllPositionsModal.styles.ts b/app/components/UI/Perps/components/PerpsCloseAllPositionsModal/PerpsCloseAllPositionsModal.styles.ts new file mode 100644 index 000000000000..38a8a44e121b --- /dev/null +++ b/app/components/UI/Perps/components/PerpsCloseAllPositionsModal/PerpsCloseAllPositionsModal.styles.ts @@ -0,0 +1,26 @@ +import { StyleSheet } from 'react-native'; +import type { Theme } from '../../../../../util/theme/models'; + +const styleSheet = (_params: { theme: Theme }) => + StyleSheet.create({ + contentContainer: { + paddingHorizontal: 16, + paddingVertical: 16, + }, + description: { + marginBottom: 24, + }, + loadingContainer: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 32, + }, + loadingText: { + marginTop: 16, + }, + footerContainer: { + paddingTop: 16, + }, + }); + +export default styleSheet; diff --git a/app/components/UI/Perps/components/PerpsCloseAllPositionsModal/PerpsCloseAllPositionsModal.test.tsx b/app/components/UI/Perps/components/PerpsCloseAllPositionsModal/PerpsCloseAllPositionsModal.test.tsx new file mode 100644 index 000000000000..91f19000b7cb --- /dev/null +++ b/app/components/UI/Perps/components/PerpsCloseAllPositionsModal/PerpsCloseAllPositionsModal.test.tsx @@ -0,0 +1,445 @@ +import React from 'react'; +import { render, fireEvent, waitFor } from '@testing-library/react-native'; +import PerpsCloseAllPositionsModal from './PerpsCloseAllPositionsModal'; +import Engine from '../../../../../core/Engine'; +import type { Position } from '../../controllers/types'; + +// Mock Engine +jest.mock('../../../../../core/Engine', () => ({ + context: { + PerpsController: { + closePositions: jest.fn(), + }, + }, +})); + +// Mock hooks +jest.mock('../../hooks', () => ({ + usePerpsCloseAllCalculations: jest.fn(), +})); + +jest.mock('../../hooks/stream', () => ({ + usePerpsLivePrices: jest.fn(), +})); + +jest.mock('../../hooks/usePerpsToasts', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('../../../../hooks/useStyles', () => ({ + useStyles: () => ({ + styles: { + contentContainer: {}, + description: {}, + loadingContainer: {}, + loadingText: {}, + footerContainer: {}, + }, + theme: { + colors: { + accent03: { normal: '#00ff00', dark: '#008800' }, + accent01: { light: '#ffcccc', dark: '#cc0000' }, + primary: { default: '#0000ff' }, + }, + }, + }), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string, params?: Record) => { + if (key === 'perps.close_all_modal.success_message' && params) { + return `Successfully closed ${params.count} position(s)`; + } + if (key === 'perps.close_all_modal.partial_success' && params) { + return `Closed ${params.successCount} of ${params.totalCount} positions`; + } + if (key === 'perps.close_all_modal.error_message' && params) { + return `Failed to close ${params.count} position(s)`; + } + return key; + }, +})); + +// Mock BottomSheet components +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheet', + () => { + const mockReact = jest.requireActual('react'); + return mockReact.forwardRef( + (props: { children: React.ReactNode; onClose?: () => void }, _ref) => ( + <>{props.children} + ), + ); + }, +); + +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheetHeader', + () => 'BottomSheetHeader', +); + +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheetFooter', + () => { + const { View, TouchableOpacity, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + buttonPropsArray, + }: { + buttonPropsArray?: { + label: string; + onPress: () => void; + disabled?: boolean; + }[]; + }) => ( + + {buttonPropsArray?.map((buttonProps, index) => ( + + {buttonProps.label} + + ))} + + ), + ButtonsAlignment: { + Horizontal: 'Horizontal', + }, + }; + }, +); + +jest.mock('../PerpsCloseSummary', () => 'PerpsCloseSummary'); + +const mockUsePerpsCloseAllCalculations = jest.requireMock('../../hooks') + .usePerpsCloseAllCalculations as jest.Mock; +const mockUsePerpsLivePrices = jest.requireMock('../../hooks/stream') + .usePerpsLivePrices as jest.Mock; +const mockUsePerpsToasts = jest.requireMock('../../hooks/usePerpsToasts') + .default as jest.Mock; + +describe('PerpsCloseAllPositionsModal', () => { + const mockPositions: Position[] = [ + { + coin: 'BTC', + size: '0.5', + entryPrice: '50000', + positionValue: '25000', + unrealizedPnl: '100', + marginUsed: '1000', + leverage: { type: 'cross' as const, value: 25 }, + liquidationPrice: '48000', + maxLeverage: 50, + returnOnEquity: '10', + cumulativeFunding: { + allTime: '0', + sinceOpen: '0', + sinceChange: '0', + }, + takeProfitPrice: undefined, + stopLossPrice: undefined, + takeProfitCount: 0, + stopLossCount: 0, + }, + ]; + + const mockCalculations = { + totalMargin: 1000, + totalPnl: 100, + totalFees: 10, + receiveAmount: 1090, + totalEstimatedPoints: 50, + avgFeeDiscountPercentage: 5, + avgBonusBips: 10, + avgMetamaskFeeRate: 0.01, + avgProtocolFeeRate: 0.00045, + avgOriginalMetamaskFeeRate: 0.015, + isLoading: false, + hasError: false, + shouldShowRewards: true, + }; + + const mockShowToast = jest.fn(); + const mockOnClose = jest.fn(); + const mockOnSuccess = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUsePerpsCloseAllCalculations.mockReturnValue(mockCalculations); + mockUsePerpsLivePrices.mockReturnValue({}); + mockUsePerpsToasts.mockReturnValue({ + showToast: mockShowToast, + }); + }); + + it('returns null when not visible', () => { + // Arrange & Act + const { queryByText } = render( + , + ); + + // Assert + expect(queryByText('perps.close_all_modal.title')).toBeNull(); + }); + + it('renders when visible with positions', () => { + // Arrange & Act + const { getByText } = render( + , + ); + + // Assert + expect(getByText('perps.close_all_modal.title')).toBeTruthy(); + expect(getByText('perps.close_all_modal.description')).toBeTruthy(); + }); + + it('renders footer buttons with correct labels', () => { + // Arrange & Act + const { getByText } = render( + , + ); + + // Assert + expect(getByText('perps.close_all_modal.keep_positions')).toBeTruthy(); + expect(getByText('perps.close_all_modal.close_all')).toBeTruthy(); + }); + + it('closes modal when keep positions button is pressed', () => { + // Arrange + const { getByTestId } = render( + , + ); + + // Act + const keepButton = getByTestId('footer-button-0'); + fireEvent.press(keepButton); + + // Assert - Button should be pressable (bottomSheetRef.current?.onCloseBottomSheet is called internally) + expect(keepButton).toBeTruthy(); + }); + + it('handles successful close all operation', async () => { + // Arrange + const mockClosePositions = Engine.context.PerpsController + .closePositions as jest.Mock; + mockClosePositions.mockResolvedValue({ + success: true, + successCount: 1, + failureCount: 0, + }); + + const { getByTestId } = render( + , + ); + + // Act + const closeButton = getByTestId('footer-button-1'); + fireEvent.press(closeButton); + + // Assert + await waitFor(() => { + expect(mockClosePositions).toHaveBeenCalledWith({ closeAll: true }); + expect(mockShowToast).toHaveBeenCalled(); + expect(mockOnSuccess).toHaveBeenCalled(); + }); + }); + + it('handles partial success close all operation', async () => { + // Arrange + const mockClosePositions = Engine.context.PerpsController + .closePositions as jest.Mock; + mockClosePositions.mockResolvedValue({ + success: false, + successCount: 1, + failureCount: 1, + }); + + const { getByTestId } = render( + , + ); + + // Act + const closeButton = getByTestId('footer-button-1'); + fireEvent.press(closeButton); + + // Assert + await waitFor(() => { + expect(mockClosePositions).toHaveBeenCalledWith({ closeAll: true }); + expect(mockShowToast).toHaveBeenCalled(); + expect(mockOnSuccess).toHaveBeenCalled(); + }); + }); + + it('handles failed close all operation', async () => { + // Arrange + const mockClosePositions = Engine.context.PerpsController + .closePositions as jest.Mock; + mockClosePositions.mockResolvedValue({ + success: false, + successCount: 0, + failureCount: 1, + }); + + const { getByTestId } = render( + , + ); + + // Act + const closeButton = getByTestId('footer-button-1'); + fireEvent.press(closeButton); + + // Assert + await waitFor(() => { + expect(mockClosePositions).toHaveBeenCalledWith({ closeAll: true }); + expect(mockShowToast).toHaveBeenCalled(); + }); + }); + + it('handles error during close all operation', async () => { + // Arrange + const mockClosePositions = Engine.context.PerpsController + .closePositions as jest.Mock; + mockClosePositions.mockRejectedValue(new Error('Network error')); + + const { getByTestId } = render( + , + ); + + // Act + const closeButton = getByTestId('footer-button-1'); + fireEvent.press(closeButton); + + // Assert + await waitFor(() => { + expect(mockClosePositions).toHaveBeenCalledWith({ closeAll: true }); + expect(mockShowToast).toHaveBeenCalled(); + }); + }); + + it('shows loading state when closing', () => { + // Arrange + const mockClosePositions = Engine.context.PerpsController + .closePositions as jest.Mock; + mockClosePositions.mockImplementation( + () => + new Promise((resolve) => { + setTimeout( + () => + resolve({ + success: true, + successCount: 1, + failureCount: 0, + }), + 100, + ); + }), + ); + + const { getByTestId, getAllByText } = render( + , + ); + + // Act + const closeButton = getByTestId('footer-button-1'); + fireEvent.press(closeButton); + + // Assert - Should show closing text (appears in both button and loading message) + const closingElements = getAllByText('perps.close_all_modal.closing'); + expect(closingElements.length).toBeGreaterThan(0); + }); + + it('disables buttons when closing', async () => { + // Arrange + const mockClosePositions = Engine.context.PerpsController + .closePositions as jest.Mock; + mockClosePositions.mockImplementation( + () => + new Promise((resolve) => { + setTimeout( + () => + resolve({ + success: true, + successCount: 1, + failureCount: 0, + }), + 100, + ); + }), + ); + + const { getByTestId } = render( + , + ); + + // Act + const closeButton = getByTestId('footer-button-1'); + fireEvent.press(closeButton); + + // Assert - Buttons should be disabled during closing + await waitFor(() => { + const keepButton = getByTestId('footer-button-0'); + expect(keepButton.props.disabled).toBe(true); + }); + }); + + it('renders PerpsCloseSummary when not closing', () => { + // Arrange & Act + const { UNSAFE_getByType } = render( + , + ); + + // Assert + expect(UNSAFE_getByType('PerpsCloseSummary' as never)).toBeTruthy(); + }); +}); diff --git a/app/components/UI/Perps/components/PerpsCloseAllPositionsModal/PerpsCloseAllPositionsModal.tsx b/app/components/UI/Perps/components/PerpsCloseAllPositionsModal/PerpsCloseAllPositionsModal.tsx new file mode 100644 index 000000000000..d548c4d6b90f --- /dev/null +++ b/app/components/UI/Perps/components/PerpsCloseAllPositionsModal/PerpsCloseAllPositionsModal.tsx @@ -0,0 +1,297 @@ +import React, { useCallback, useState, useMemo } from 'react'; +import { View, ActivityIndicator } from 'react-native'; +import { NotificationFeedbackType } from 'expo-haptics'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../../component-library/components/BottomSheets/BottomSheet'; +import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import BottomSheetFooter, { + ButtonsAlignment, +} from '../../../../../component-library/components/BottomSheets/BottomSheetFooter'; +import Text, { + TextColor, + TextVariant, +} from '../../../../../component-library/components/Texts/Text'; +import { + ButtonSize, + ButtonVariants, +} from '../../../../../component-library/components/Buttons/Button'; +import { IconName } from '../../../../../component-library/components/Icons/Icon'; +import { ToastVariants } from '../../../../../component-library/components/Toast/Toast.types'; +import { useStyles } from '../../../../hooks/useStyles'; +import { strings } from '../../../../../../locales/i18n'; +import Engine from '../../../../../core/Engine'; +import createStyles from './PerpsCloseAllPositionsModal.styles'; +import usePerpsToasts, { + type PerpsToastOptions, +} from '../../hooks/usePerpsToasts'; +import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger'; +import type { Position } from '../../controllers/types'; +import { usePerpsCloseAllCalculations } from '../../hooks'; +import { usePerpsLivePrices } from '../../hooks/stream'; +import PerpsCloseSummary from '../PerpsCloseSummary'; + +interface PerpsCloseAllPositionsModalProps { + isVisible: boolean; + onClose: () => void; + positions: Position[]; + onSuccess?: () => void; +} + +const PerpsCloseAllPositionsModal: React.FC< + PerpsCloseAllPositionsModalProps +> = ({ isVisible, onClose, positions, onSuccess }) => { + const { styles, theme } = useStyles(createStyles, {}); + const bottomSheetRef = React.useRef(null); + const [isClosing, setIsClosing] = useState(false); + const { showToast } = usePerpsToasts(); + + // Fetch current prices for fee calculations (throttled to avoid excessive updates) + const symbols = useMemo(() => positions.map((pos) => pos.coin), [positions]); + const priceData = usePerpsLivePrices({ + symbols, + throttleMs: 1000, + }); + + const showSuccessToast = useCallback( + (title: string, message?: string) => { + const toastConfig: PerpsToastOptions = { + variant: ToastVariants.Icon, + iconName: IconName.CheckBold, + backgroundColor: theme.colors.accent03.normal, + iconColor: theme.colors.accent03.dark, + hapticsType: NotificationFeedbackType.Success, + hasNoTimeout: false, + labelOptions: message + ? [ + { label: title, isBold: true }, + { label: '\n', isBold: false }, + { label: message, isBold: false }, + ] + : [{ label: title, isBold: true }], + }; + showToast(toastConfig); + }, + [showToast, theme.colors.accent03], + ); + + const showErrorToast = useCallback( + (title: string, message?: string) => { + const toastConfig: PerpsToastOptions = { + variant: ToastVariants.Icon, + iconName: IconName.Warning, + backgroundColor: theme.colors.accent01.light, + iconColor: theme.colors.accent01.dark, + hapticsType: NotificationFeedbackType.Error, + hasNoTimeout: false, + labelOptions: message + ? [ + { label: title, isBold: true }, + { label: '\n', isBold: false }, + { label: message, isBold: false }, + ] + : [{ label: title, isBold: true }], + }; + showToast(toastConfig); + }, + [showToast, theme.colors.accent01], + ); + + // Use the fixed hook for accurate fee and rewards calculations + const calculations = usePerpsCloseAllCalculations({ + positions, + priceData, + }); + + const handleCloseAll = useCallback(async () => { + const startTime = Date.now(); + setIsClosing(true); + + DevLogger.log( + '[PerpsCloseAllPositionsModal] Starting close all positions', + { + positionCount: positions.length, + totalMargin: calculations.totalMargin, + totalPnl: calculations.totalPnl, + estimatedTotalFees: calculations.totalFees, + estimatedReceiveAmount: calculations.receiveAmount, + }, + ); + + try { + const result = await Engine.context.PerpsController.closePositions({ + closeAll: true, + }); + + const executionTime = Date.now() - startTime; + + if (result.success && result.successCount > 0) { + DevLogger.log( + '[PerpsCloseAllPositionsModal] Close all positions succeeded', + { + successCount: result.successCount, + failureCount: result.failureCount, + executionTimeMs: executionTime, + }, + ); + + showSuccessToast( + strings('perps.close_all_modal.success_title'), + strings('perps.close_all_modal.success_message', { + count: result.successCount, + }), + ); + onSuccess?.(); + bottomSheetRef.current?.onCloseBottomSheet(); + } else if (result.successCount > 0 && result.failureCount > 0) { + DevLogger.log( + '[PerpsCloseAllPositionsModal] Close all positions partially succeeded', + { + successCount: result.successCount, + failureCount: result.failureCount, + totalCount: result.successCount + result.failureCount, + executionTimeMs: executionTime, + }, + ); + + showSuccessToast( + strings('perps.close_all_modal.success_title'), + strings('perps.close_all_modal.partial_success', { + successCount: result.successCount, + totalCount: result.successCount + result.failureCount, + }), + ); + onSuccess?.(); + bottomSheetRef.current?.onCloseBottomSheet(); + } else { + DevLogger.log( + '[PerpsCloseAllPositionsModal] Close all positions failed', + { + failureCount: result.failureCount, + executionTimeMs: executionTime, + }, + ); + + showErrorToast( + strings('perps.close_all_modal.error_title'), + strings('perps.close_all_modal.error_message', { + count: result.failureCount, + }), + ); + } + } catch (error) { + const executionTime = Date.now() - startTime; + DevLogger.log('[PerpsCloseAllPositionsModal] Close all positions error', { + error: error instanceof Error ? error.message : 'Unknown error', + errorStack: error instanceof Error ? error.stack : undefined, + executionTimeMs: executionTime, + }); + + showErrorToast( + strings('perps.close_all_modal.error_title'), + error instanceof Error ? error.message : 'Unknown error', + ); + } finally { + setIsClosing(false); + } + }, [ + showSuccessToast, + showErrorToast, + onSuccess, + positions.length, + calculations, + ]); + + const handleKeepPositions = useCallback(() => { + bottomSheetRef.current?.onCloseBottomSheet(); + }, []); + + const footerButtons = useMemo( + () => [ + { + label: strings('perps.close_all_modal.keep_positions'), + onPress: handleKeepPositions, + variant: ButtonVariants.Secondary, + size: ButtonSize.Lg, + disabled: isClosing, + }, + { + label: isClosing + ? strings('perps.close_all_modal.closing') + : strings('perps.close_all_modal.close_all'), + onPress: handleCloseAll, + variant: ButtonVariants.Primary, + size: ButtonSize.Lg, + disabled: isClosing, + danger: true, + }, + ], + [handleKeepPositions, handleCloseAll, isClosing], + ); + + if (!isVisible) return null; + + return ( + + + + {strings('perps.close_all_modal.title')} + + + + + + {strings('perps.close_all_modal.description')} + + + {isClosing ? ( + + + + {strings('perps.close_all_modal.closing')} + + + ) : ( + + )} + + + + + ); +}; + +export default PerpsCloseAllPositionsModal; diff --git a/app/components/UI/Perps/components/PerpsCloseSummary/PerpsCloseSummary.styles.ts b/app/components/UI/Perps/components/PerpsCloseSummary/PerpsCloseSummary.styles.ts new file mode 100644 index 000000000000..e339585e69d7 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsCloseSummary/PerpsCloseSummary.styles.ts @@ -0,0 +1,52 @@ +import { StyleSheet } from 'react-native'; +import type { Theme } from '../../../../../util/theme/models'; + +const styleSheet = (_params: { theme: Theme }) => { + const { colors } = _params.theme; + + return StyleSheet.create({ + summaryContainer: { + paddingTop: 16, + paddingBottom: 16, + gap: 4, + }, + paddingHorizontal: { + paddingHorizontal: 16, + }, + summaryRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + paddingVertical: 4, + }, + summaryLabel: { + flex: 1, + }, + summaryValue: { + flexShrink: 0, + alignItems: 'flex-end', + }, + summaryTotalRow: { + marginTop: 4, + paddingTop: 16, + borderTopWidth: 1, + borderTopColor: colors.border.muted, + }, + labelWithTooltip: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + inclusiveFeeRow: { + flexDirection: 'row', + gap: 4, + }, + loadingContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/UI/Perps/components/PerpsCloseSummary/PerpsCloseSummary.test.tsx b/app/components/UI/Perps/components/PerpsCloseSummary/PerpsCloseSummary.test.tsx new file mode 100644 index 000000000000..17c6a8d8722a --- /dev/null +++ b/app/components/UI/Perps/components/PerpsCloseSummary/PerpsCloseSummary.test.tsx @@ -0,0 +1,208 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import PerpsCloseSummary from './PerpsCloseSummary'; +import { strings } from '../../../../../../locales/i18n'; + +// Mock dependencies +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => key), +})); + +jest.mock('../../../../hooks/useStyles', () => ({ + useStyles: jest.fn(() => ({ + styles: { + summaryContainer: {}, + paddingHorizontal: {}, + summaryRow: {}, + summaryLabel: {}, + summaryValue: {}, + inclusiveFeeRow: {}, + labelWithTooltip: {}, + summaryTotalRow: {}, + rewardsRow: {}, + rewardsContent: {}, + loadingContainer: {}, + }, + theme: { + colors: { + icon: { + alternative: '#CCCCCC', + }, + }, + }, + })), +})); + +jest.mock('../PerpsFeesDisplay', () => 'PerpsFeesDisplay'); +jest.mock('../PerpsBottomSheetTooltip', () => 'PerpsBottomSheetTooltip'); +jest.mock('../../../Rewards/components/RewardPointsAnimation', () => ({ + __esModule: true, + default: 'RewardsAnimations', + RewardAnimationState: { + Idle: 'idle', + Loading: 'loading', + ErrorState: 'error', + Animating: 'animating', + }, +})); + +describe('PerpsCloseSummary', () => { + const defaultProps = { + totalMargin: 1000, + totalPnl: 150, + totalFees: 5.5, + feeDiscountPercentage: 10, + metamaskFeeRate: 0.01, + protocolFeeRate: 0.00045, + receiveAmount: 1144.5, + shouldShowRewards: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders margin with positive P&L', () => { + // Arrange + const props = { ...defaultProps, totalPnl: 150 }; + + // Act + const { getByText } = render(); + + // Assert + expect(strings).toHaveBeenCalledWith('perps.close_position.margin'); + expect(strings).toHaveBeenCalledWith('perps.close_position.includes_pnl'); + expect(getByText('perps.close_position.margin')).toBeTruthy(); + }); + + it('renders margin with negative P&L', () => { + // Arrange + const props = { ...defaultProps, totalPnl: -50 }; + + // Act + const { getByText } = render(); + + // Assert + expect(getByText('perps.close_position.margin')).toBeTruthy(); + expect(strings).toHaveBeenCalledWith('perps.close_position.includes_pnl'); + }); + + it('renders fees section with tooltip', () => { + // Arrange + const testIDs = { feesTooltip: 'fees-tooltip-button' }; + const props = { ...defaultProps, testIDs }; + + // Act + const { getByTestId, getByText } = render(); + + // Assert + expect(getByText('perps.close_position.fees')).toBeTruthy(); + expect(getByTestId('fees-tooltip-button')).toBeTruthy(); + }); + + it('renders receive amount with tooltip', () => { + // Arrange + const testIDs = { receiveTooltip: 'receive-tooltip-button' }; + const props = { ...defaultProps, testIDs }; + + // Act + const { getByTestId, getByText } = render(); + + // Assert + expect(getByText('perps.close_position.you_receive')).toBeTruthy(); + expect(getByTestId('receive-tooltip-button')).toBeTruthy(); + }); + + it('renders rewards section when enabled', () => { + // Arrange + const props = { + ...defaultProps, + shouldShowRewards: true, + estimatedPoints: 100, + bonusBips: 500, + }; + + // Act + const { getByText } = render(); + + // Assert + expect(getByText('perps.estimated_points')).toBeTruthy(); + }); + + it('renders rewards with loading state', () => { + // Arrange + const props = { + ...defaultProps, + shouldShowRewards: true, + isLoadingRewards: true, + estimatedPoints: 0, + }; + + // Act + const { getByText } = render(); + + // Assert + expect(getByText('perps.estimated_points')).toBeTruthy(); + }); + + it('applies custom style prop', () => { + // Arrange + const customStyle = { backgroundColor: 'red' }; + const props = { ...defaultProps, style: customStyle }; + + // Act + const { getByTestId } = render(); + + // Assert - component renders without crashing with custom style + expect(getByTestId).toBeDefined(); + }); + + it('applies padding when input focused', () => { + // Arrange + const props = { ...defaultProps, isInputFocused: true }; + + // Act + const { getByTestId } = render(); + + // Assert - component renders without crashing with focused state + expect(getByTestId).toBeDefined(); + }); + + it('disables tooltip interactions when enableTooltips is false', () => { + // Arrange + const props = { ...defaultProps, enableTooltips: false }; + + // Act + const { queryByTestId } = render(); + + // Assert - tooltip buttons not rendered when tooltips disabled + expect(queryByTestId('fees-tooltip-button')).toBeNull(); + }); + + it('renders loading indicator when fees are calculating', () => { + // Arrange + const props = { ...defaultProps, isLoadingFees: true }; + + // Act + const { queryByText } = render(); + + // Assert - PerpsFeesDisplay not shown when loading + expect(queryByText('PerpsFeesDisplay')).toBeNull(); + }); + + it('displays error state when rewards calculation fails', () => { + // Arrange + const props = { + ...defaultProps, + shouldShowRewards: true, + hasRewardsError: true, + estimatedPoints: 0, + }; + + // Act + const { getByText } = render(); + + // Assert - rewards section still renders with error state + expect(getByText('perps.estimated_points')).toBeTruthy(); + }); +}); diff --git a/app/components/UI/Perps/components/PerpsCloseSummary/PerpsCloseSummary.tsx b/app/components/UI/Perps/components/PerpsCloseSummary/PerpsCloseSummary.tsx new file mode 100644 index 000000000000..d3a7acee5218 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsCloseSummary/PerpsCloseSummary.tsx @@ -0,0 +1,332 @@ +import React, { useCallback, useState } from 'react'; +import { + View, + TouchableOpacity, + ActivityIndicator, + type ViewStyle, +} from 'react-native'; +import Text, { + TextColor, + TextVariant, +} from '../../../../../component-library/components/Texts/Text'; +import Icon, { + IconName, + IconSize, + IconColor, +} from '../../../../../component-library/components/Icons/Icon'; +import { strings } from '../../../../../../locales/i18n'; +import { + formatPerpsFiat, + PRICE_RANGES_MINIMAL_VIEW, +} from '../../utils/formatUtils'; +import PerpsFeesDisplay from '../PerpsFeesDisplay'; +import PerpsBottomSheetTooltip from '../PerpsBottomSheetTooltip'; +import { type PerpsTooltipContentKey } from '../PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types'; +import RewardsAnimations, { + RewardAnimationState, +} from '../../../Rewards/components/RewardPointsAnimation'; +import { useStyles } from '../../../../hooks/useStyles'; +import createStyles from './PerpsCloseSummary.styles'; + +export interface PerpsCloseSummaryProps { + /** Total margin including P&L */ + totalMargin: number; + /** Total unrealized P&L (for "includes P&L" breakdown) */ + totalPnl: number; + + /** Total fees for closing */ + totalFees: number; + /** Fee discount percentage (0-100) */ + feeDiscountPercentage?: number; + /** MetaMask fee rate (as decimal, e.g. 0.01 for 1%) */ + metamaskFeeRate: number; + /** Protocol fee rate (as decimal, e.g. 0.00045 for 0.045%) */ + protocolFeeRate: number; + /** Original MetaMask fee rate before discounts */ + originalMetamaskFeeRate?: number; + + /** Amount user will receive after closing */ + receiveAmount: number; + + /** Whether to show rewards section */ + shouldShowRewards: boolean; + /** Estimated points to be earned */ + estimatedPoints?: number; + /** Bonus multiplier in basis points */ + bonusBips?: number; + /** Whether fees calculation is loading */ + isLoadingFees?: boolean; + /** Whether rewards calculation is loading */ + isLoadingRewards?: boolean; + /** Whether there was an error calculating rewards */ + hasRewardsError?: boolean; + + /** Optional styling for container */ + style?: ViewStyle; + /** Whether input is focused (for padding adjustment) */ + isInputFocused?: boolean; + + /** Whether to enable tooltips (default: true) */ + enableTooltips?: boolean; + + /** Optional test IDs for tooltips */ + testIDs?: { + feesTooltip?: string; + receiveTooltip?: string; + pointsTooltip?: string; + }; +} + +/** + * Shared summary component for closing positions + * + * Displays: + * - Margin (with P&L breakdown) + * - Fees (with discount indicator) + * - Receive amount + * - Estimated points (optional) + * + * Tooltips can be disabled via the `enableTooltips` prop (defaults to true). + * Useful when this component is used within a bottom sheet to avoid nested modals. + */ +const PerpsCloseSummary: React.FC = ({ + totalMargin, + totalPnl, + totalFees, + feeDiscountPercentage, + metamaskFeeRate, + protocolFeeRate, + originalMetamaskFeeRate, + receiveAmount, + shouldShowRewards, + estimatedPoints = 0, + bonusBips = 0, + isLoadingFees = false, + isLoadingRewards = false, + hasRewardsError = false, + style, + isInputFocused = false, + enableTooltips = true, + testIDs, +}) => { + const { styles, theme } = useStyles(createStyles, {}); + const [selectedTooltip, setSelectedTooltip] = + useState(null); + + const handleTooltipPress = useCallback( + (contentKey: PerpsTooltipContentKey) => { + if (enableTooltips) { + setSelectedTooltip(contentKey); + } + }, + [enableTooltips], + ); + + const handleTooltipClose = useCallback(() => { + setSelectedTooltip(null); + }, []); + + // Determine reward animation state based on loading and error states + const getRewardAnimationState = () => { + if (isLoadingRewards) { + return RewardAnimationState.Loading; + } + if (hasRewardsError) { + return RewardAnimationState.ErrorState; + } + return RewardAnimationState.Idle; + }; + + const rewardAnimationState = getRewardAnimationState(); + + return ( + <> + + {/* Margin with P&L breakdown */} + + + + {strings('perps.close_position.margin')} + + + + + {formatPerpsFiat(totalMargin, { + ranges: PRICE_RANGES_MINIMAL_VIEW, + })} + + + + {strings('perps.close_position.includes_pnl')} + + + {totalPnl < 0 ? '-' : '+'} + {formatPerpsFiat(Math.abs(totalPnl), { + ranges: PRICE_RANGES_MINIMAL_VIEW, + })} + + + + + + {/* Fees with discount */} + + + {enableTooltips ? ( + handleTooltipPress('closing_fees')} + style={styles.labelWithTooltip} + testID={testIDs?.feesTooltip} + > + + {strings('perps.close_position.fees')} + + + + ) : ( + + {strings('perps.close_position.fees')} + + )} + + + {isLoadingFees ? ( + + + + ) : ( + + )} + + + + {/* You'll receive */} + + + {enableTooltips ? ( + handleTooltipPress('close_position_you_receive')} + style={styles.labelWithTooltip} + testID={testIDs?.receiveTooltip} + > + + {strings('perps.close_position.you_receive')} + + + + ) : ( + + {strings('perps.close_position.you_receive')} + + )} + + + + {formatPerpsFiat(receiveAmount, { + ranges: PRICE_RANGES_MINIMAL_VIEW, + })} + + + + + {/* Estimated Points */} + {shouldShowRewards && ( + + + {enableTooltips ? ( + handleTooltipPress('points')} + style={styles.labelWithTooltip} + testID={testIDs?.pointsTooltip} + > + + {strings('perps.estimated_points')} + + + + ) : ( + + {strings('perps.estimated_points')} + + )} + + + + + + )} + + + {/* Tooltip Bottom Sheets */} + {enableTooltips && selectedTooltip === 'closing_fees' && ( + + )} + + {enableTooltips && selectedTooltip === 'close_position_you_receive' && ( + + )} + + {enableTooltips && selectedTooltip === 'points' && ( + + )} + + ); +}; + +export default PerpsCloseSummary; diff --git a/app/components/UI/Perps/components/PerpsCloseSummary/index.ts b/app/components/UI/Perps/components/PerpsCloseSummary/index.ts new file mode 100644 index 000000000000..5409e8e9edbf --- /dev/null +++ b/app/components/UI/Perps/components/PerpsCloseSummary/index.ts @@ -0,0 +1,2 @@ +export { default } from './PerpsCloseSummary'; +export type { PerpsCloseSummaryProps } from './PerpsCloseSummary'; diff --git a/app/components/UI/Perps/components/PerpsHomeHeader/PerpsHomeHeader.styles.ts b/app/components/UI/Perps/components/PerpsHomeHeader/PerpsHomeHeader.styles.ts new file mode 100644 index 000000000000..9f19a74a57ca --- /dev/null +++ b/app/components/UI/Perps/components/PerpsHomeHeader/PerpsHomeHeader.styles.ts @@ -0,0 +1,30 @@ +import { StyleSheet } from 'react-native'; +import type { Theme } from '../../../../../util/theme/models'; + +/** + * Styles for PerpsHomeHeader component + */ +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + + return StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 12, + backgroundColor: theme.colors.background.default, + }, + headerTitle: { + flex: 1, + textAlign: 'center', + marginHorizontal: 8, + }, + searchButton: { + padding: 4, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/UI/Perps/components/PerpsHomeHeader/PerpsHomeHeader.test.tsx b/app/components/UI/Perps/components/PerpsHomeHeader/PerpsHomeHeader.test.tsx new file mode 100644 index 000000000000..c8baba320276 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsHomeHeader/PerpsHomeHeader.test.tsx @@ -0,0 +1,182 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { useNavigation } from '@react-navigation/native'; +import PerpsHomeHeader from './PerpsHomeHeader'; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key) => { + const translations: Record = { + 'perps.title': 'Perps', + }; + return translations[key] || key; + }), +})); + +jest.mock( + '../../../../../component-library/components/Buttons/ButtonIcon', + () => { + const { TouchableOpacity } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + onPress, + testID, + }: { + onPress: () => void; + testID?: string; + }) => , + ButtonIconSizes: { Md: 'md' }, + }; + }, +); + +describe('PerpsHomeHeader', () => { + const mockGoBack = jest.fn(); + const mockCanGoBack = jest.fn(); + const mockUseNavigation = useNavigation as jest.MockedFunction< + typeof useNavigation + >; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseNavigation.mockReturnValue({ + goBack: mockGoBack, + canGoBack: mockCanGoBack, + } as Partial> as ReturnType); + mockCanGoBack.mockReturnValue(true); + }); + + describe('Rendering', () => { + it('renders with default title', () => { + const { getByText } = render( + , + ); + + expect(getByText('Perps')).toBeTruthy(); + }); + + it('renders with custom title', () => { + const { getByText } = render( + , + ); + + expect(getByText('My Markets')).toBeTruthy(); + }); + + it('renders search button', () => { + const { getByTestId } = render( + , + ); + + const searchButton = getByTestId('home-header-search-toggle'); + expect(searchButton).toBeTruthy(); + }); + }); + + describe('Default Navigation', () => { + it('calls navigation.goBack() when back button is pressed', () => { + const { getByTestId } = render( + , + ); + + const backButton = getByTestId('home-header-back-button'); + fireEvent.press(backButton); + + expect(mockCanGoBack).toHaveBeenCalled(); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('does not call goBack when canGoBack returns false', () => { + mockCanGoBack.mockReturnValue(false); + const { getByTestId } = render( + , + ); + + const backButton = getByTestId('home-header-back-button'); + fireEvent.press(backButton); + + expect(mockCanGoBack).toHaveBeenCalled(); + expect(mockGoBack).not.toHaveBeenCalled(); + }); + }); + + describe('Custom Handlers', () => { + it('uses custom onBack handler when provided', () => { + const customBackHandler = jest.fn(); + const { getByTestId } = render( + , + ); + + const backButton = getByTestId('home-header-back-button'); + fireEvent.press(backButton); + + expect(customBackHandler).toHaveBeenCalledTimes(1); + expect(mockGoBack).not.toHaveBeenCalled(); + }); + + it('calls onSearchToggle when search button is pressed', () => { + const mockSearchToggle = jest.fn(); + const { getByTestId } = render( + , + ); + + const searchButton = getByTestId('home-header-search-toggle'); + fireEvent.press(searchButton); + + expect(mockSearchToggle).toHaveBeenCalledTimes(1); + }); + }); + + describe('Search Icon State', () => { + it('renders Search icon when isSearchVisible is false', () => { + const { getByTestId } = render( + , + ); + + const searchButton = getByTestId('home-header-search-toggle'); + expect(searchButton).toBeTruthy(); + // Icon component would render IconName.Search + }); + + it('renders Close icon when isSearchVisible is true', () => { + const { getByTestId } = render( + , + ); + + const searchButton = getByTestId('home-header-search-toggle'); + expect(searchButton).toBeTruthy(); + // Icon component would render IconName.Close + }); + }); + + describe('Test ID', () => { + it('applies custom testID and derived testIDs', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('custom-header')).toBeTruthy(); + expect(getByTestId('custom-header-back-button')).toBeTruthy(); + expect(getByTestId('custom-header-search-toggle')).toBeTruthy(); + }); + }); +}); diff --git a/app/components/UI/Perps/components/PerpsHomeHeader/PerpsHomeHeader.tsx b/app/components/UI/Perps/components/PerpsHomeHeader/PerpsHomeHeader.tsx new file mode 100644 index 000000000000..c0d792b99368 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsHomeHeader/PerpsHomeHeader.tsx @@ -0,0 +1,106 @@ +import React, { useCallback } from 'react'; +import { View, TouchableOpacity } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useStyles } from '../../../../../component-library/hooks'; +import ButtonIcon, { + ButtonIconSizes, +} from '../../../../../component-library/components/Buttons/ButtonIcon'; +import Icon, { + IconName, + IconSize, + IconColor, +} from '../../../../../component-library/components/Icons/Icon'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; +import { strings } from '../../../../../../locales/i18n'; +import type { PerpsHomeHeaderProps } from './PerpsHomeHeader.types'; +import styleSheet from './PerpsHomeHeader.styles'; + +/** + * PerpsHomeHeader Component + * + * Header component for Perps Home view with back button, + * title, and search toggle functionality + * + * Features: + * - Back button using ButtonIcon component + * - Centered title with custom text support + * - Search toggle button that changes icon based on visibility + * + * @example + * ```tsx + * + * ``` + * + * @example Custom back handler + * ```tsx + * + * ``` + */ +const PerpsHomeHeader: React.FC = ({ + title, + isSearchVisible = false, + onBack, + onSearchToggle, + testID, +}) => { + const { styles } = useStyles(styleSheet, {}); + const navigation = useNavigation(); + + // Default back handler + const defaultHandleBack = useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack(); + } + }, [navigation]); + + // Use custom handler if provided, otherwise use default + const handleBack = onBack || defaultHandleBack; + + return ( + + {/* Back Button */} + + + {/* Title */} + + {title || strings('perps.title')} + + + {/* Search Toggle Button */} + + + + + ); +}; + +export default PerpsHomeHeader; diff --git a/app/components/UI/Perps/components/PerpsHomeHeader/PerpsHomeHeader.types.ts b/app/components/UI/Perps/components/PerpsHomeHeader/PerpsHomeHeader.types.ts new file mode 100644 index 000000000000..e3ae89ba24a3 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsHomeHeader/PerpsHomeHeader.types.ts @@ -0,0 +1,32 @@ +/** + * Props for PerpsHomeHeader component + */ +export interface PerpsHomeHeaderProps { + /** + * Header title text + * @default strings('perps.title') + */ + title?: string; + + /** + * Whether search bar is currently visible + * @default false + */ + isSearchVisible?: boolean; + + /** + * Callback when back button is pressed + * If not provided, uses default navigation.goBack() + */ + onBack?: () => void; + + /** + * Callback when search toggle button is pressed + */ + onSearchToggle?: () => void; + + /** + * Test ID for the header container + */ + testID?: string; +} diff --git a/app/components/UI/Perps/components/PerpsHomeHeader/index.ts b/app/components/UI/Perps/components/PerpsHomeHeader/index.ts new file mode 100644 index 000000000000..1ca221d1f4fb --- /dev/null +++ b/app/components/UI/Perps/components/PerpsHomeHeader/index.ts @@ -0,0 +1,2 @@ +export { default } from './PerpsHomeHeader'; +export type { PerpsHomeHeaderProps } from './PerpsHomeHeader.types'; diff --git a/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.test.tsx b/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.test.tsx new file mode 100644 index 000000000000..98798ccba594 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.test.tsx @@ -0,0 +1,432 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { View, Text } from 'react-native'; +import PerpsHomeSection from './PerpsHomeSection'; + +describe('PerpsHomeSection', () => { + const mockSkeleton = () => ; + const mockChildren = Content; + + describe('rendering', () => { + it('renders section with title', () => { + const { getByText } = render( + + {mockChildren} + , + ); + + expect(getByText('Test Section')).toBeTruthy(); + }); + + it('renders children when not loading', () => { + const { getByTestId } = render( + + {mockChildren} + , + ); + + expect(getByTestId('section-content')).toBeTruthy(); + }); + + it('renders skeleton when loading', () => { + const { getByTestId, queryByTestId } = render( + + {mockChildren} + , + ); + + expect(getByTestId('skeleton-loader')).toBeTruthy(); + expect(queryByTestId('section-content')).toBeNull(); + }); + + it('applies custom testID', () => { + const { getByTestId } = render( + + {mockChildren} + , + ); + + expect(getByTestId('custom-section')).toBeTruthy(); + }); + }); + + describe('empty state behavior', () => { + it('hides section when empty and showWhenEmpty is false', () => { + const { queryByText } = render( + + {mockChildren} + , + ); + + expect(queryByText('Test Section')).toBeNull(); + }); + + it('shows section when empty and showWhenEmpty is true', () => { + const { getByText } = render( + + {mockChildren} + , + ); + + expect(getByText('Test Section')).toBeTruthy(); + }); + + it('hides section when empty by default with showWhenEmpty undefined', () => { + const { queryByText } = render( + + {mockChildren} + , + ); + + expect(queryByText('Test Section')).toBeNull(); + }); + + it('shows section when not empty regardless of showWhenEmpty', () => { + const { getByText } = render( + + {mockChildren} + , + ); + + expect(getByText('Test Section')).toBeTruthy(); + }); + + it('shows section when loading even if empty', () => { + const { getByText } = render( + + {mockChildren} + , + ); + + expect(getByText('Test Section')).toBeTruthy(); + }); + }); + + describe('action button', () => { + it('renders action button when actionLabel and onActionPress provided', () => { + const { getByText } = render( + + {mockChildren} + , + ); + + expect(getByText('Close All')).toBeTruthy(); + }); + + it('calls onActionPress when action button is pressed', () => { + const mockOnActionPress = jest.fn(); + + const { getByText } = render( + + {mockChildren} + , + ); + + fireEvent.press(getByText('Close All')); + + expect(mockOnActionPress).toHaveBeenCalledTimes(1); + }); + + it('omits action button when actionLabel not provided', () => { + const { queryByText } = render( + + {mockChildren} + , + ); + + expect(queryByText('Close All')).toBeNull(); + }); + + it('omits action button when onActionPress not provided', () => { + const { queryByText } = render( + + {mockChildren} + , + ); + + expect(queryByText('Close All')).toBeNull(); + }); + + it('hides action button when loading', () => { + const { queryByText } = render( + + {mockChildren} + , + ); + + expect(queryByText('Close All')).toBeNull(); + }); + + it('hides action button when empty', () => { + const { queryByText } = render( + + {mockChildren} + , + ); + + expect(queryByText('Close All')).toBeNull(); + }); + }); + + describe('loading states', () => { + it('shows skeleton during loading state', () => { + const { getByTestId } = render( + + {mockChildren} + , + ); + + expect(getByTestId('skeleton-loader')).toBeTruthy(); + }); + + it('transitions from loading to content', () => { + const { getByTestId, queryByTestId, rerender } = render( + + {mockChildren} + , + ); + + expect(getByTestId('skeleton-loader')).toBeTruthy(); + expect(queryByTestId('section-content')).toBeNull(); + + rerender( + + {mockChildren} + , + ); + + expect(queryByTestId('skeleton-loader')).toBeNull(); + expect(getByTestId('section-content')).toBeTruthy(); + }); + + it('calls renderSkeleton function when loading', () => { + const mockRenderSkeleton = jest.fn(() => ( + + )); + + const { getByTestId } = render( + + {mockChildren} + , + ); + + expect(mockRenderSkeleton).toHaveBeenCalledTimes(1); + expect(getByTestId('custom-skeleton')).toBeTruthy(); + }); + }); + + describe('edge cases', () => { + it('handles empty string title', () => { + const { queryByText } = render( + + {mockChildren} + , + ); + + expect(queryByText('')).toBeTruthy(); + }); + + it('handles complex children', () => { + const complexChildren = ( + <> + + + Text + + ); + + const { getByTestId } = render( + + {complexChildren} + , + ); + + expect(getByTestId('child-1')).toBeTruthy(); + expect(getByTestId('child-2')).toBeTruthy(); + expect(getByTestId('child-3')).toBeTruthy(); + }); + + it('handles multiple action button presses', () => { + const mockOnActionPress = jest.fn(); + + const { getByText } = render( + + {mockChildren} + , + ); + + const actionButton = getByText('Close All'); + + fireEvent.press(actionButton); + fireEvent.press(actionButton); + fireEvent.press(actionButton); + + expect(mockOnActionPress).toHaveBeenCalledTimes(3); + }); + }); + + describe('combined states', () => { + it('handles loading and showWhenEmpty together', () => { + const { getByText, getByTestId } = render( + + {mockChildren} + , + ); + + expect(getByText('Test Section')).toBeTruthy(); + expect(getByTestId('skeleton-loader')).toBeTruthy(); + }); + + it('handles loading with action props', () => { + const { getByText, queryByText } = render( + + {mockChildren} + , + ); + + expect(getByText('Test Section')).toBeTruthy(); + expect(queryByText('Close All')).toBeNull(); + }); + }); +}); diff --git a/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.tsx b/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.tsx new file mode 100644 index 000000000000..fb9f2c06f95f --- /dev/null +++ b/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.tsx @@ -0,0 +1,129 @@ +import React, { ReactNode } from 'react'; +import { View, TouchableOpacity, StyleSheet } from 'react-native'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; + +export interface PerpsHomeSectionProps { + /** + * Section title + */ + title: string; + /** + * Whether the section is loading + */ + isLoading: boolean; + /** + * Whether the section has no data + */ + isEmpty: boolean; + /** + * Whether to show the section when empty (default: false) + * - true: Always show section (e.g., Trending Markets) + * - false: Hide section when empty (e.g., Positions, Orders) + */ + showWhenEmpty?: boolean; + /** + * Optional action label (e.g., "Close All", "See All") + */ + actionLabel?: string; + /** + * Optional action handler + */ + onActionPress?: () => void; + /** + * Function to render skeleton loading state + */ + renderSkeleton: () => ReactNode; + /** + * Section content + */ + children: ReactNode; + /** + * Optional test ID + */ + testID?: string; +} + +const styles = StyleSheet.create({ + section: { + marginBottom: 24, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + marginBottom: 12, + }, + content: { + // Content styling handled by children + }, +}); + +/** + * PerpsHomeSection Component + * + * A reusable wrapper for home screen sections that handles: + * - Loading states with skeletons + * - Empty states (hide or show based on showWhenEmpty) + * - Section headers with optional actions + * - Consistent styling and layout + * + * @example + * ```tsx + * } + * > + * {positions.map(pos => )} + * + * ``` + */ +const PerpsHomeSection: React.FC = ({ + title, + isLoading, + isEmpty, + showWhenEmpty = false, + actionLabel, + onActionPress, + renderSkeleton, + children, + testID, +}) => { + // Hide section if empty and showWhenEmpty is false + if (!isLoading && isEmpty && !showWhenEmpty) { + return null; + } + + return ( + + {/* Section Header */} + + + {title} + + {actionLabel && onActionPress && !isLoading && !isEmpty && ( + + + {actionLabel} + + + )} + + + {/* Section Content */} + + {isLoading ? renderSkeleton() : children} + + + ); +}; + +export default PerpsHomeSection; diff --git a/app/components/UI/Perps/components/PerpsHomeSection/index.ts b/app/components/UI/Perps/components/PerpsHomeSection/index.ts new file mode 100644 index 000000000000..f20a52e4dc97 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsHomeSection/index.ts @@ -0,0 +1,2 @@ +export { default } from './PerpsHomeSection'; +export type { PerpsHomeSectionProps } from './PerpsHomeSection'; diff --git a/app/components/UI/Perps/components/PerpsMarketHeader/PerpsMarketHeader.tsx b/app/components/UI/Perps/components/PerpsMarketHeader/PerpsMarketHeader.tsx index 8b4e0d9e6f62..4a90f8179306 100644 --- a/app/components/UI/Perps/components/PerpsMarketHeader/PerpsMarketHeader.tsx +++ b/app/components/UI/Perps/components/PerpsMarketHeader/PerpsMarketHeader.tsx @@ -29,7 +29,8 @@ interface PerpsMarketHeaderProps { market: PerpsMarketData; onBackPress?: () => void; onMorePress?: () => void; - onActivityPress?: () => void; + onFavoritePress?: () => void; + isFavorite?: boolean; testID?: string; } @@ -37,7 +38,8 @@ const PerpsMarketHeader: React.FC = ({ market, onBackPress, onMorePress, - onActivityPress, + onFavoritePress, + isFavorite = false, testID, }) => { const { styles } = useStyles(styleSheet, {}); @@ -97,10 +99,10 @@ const PerpsMarketHeader: React.FC = ({ {/* Right Action Button */} - {onActivityPress ? ( - + {onFavoritePress ? ( + diff --git a/app/components/UI/Perps/components/PerpsMarketList/PerpsMarketList.styles.ts b/app/components/UI/Perps/components/PerpsMarketList/PerpsMarketList.styles.ts new file mode 100644 index 000000000000..d9a7fda17928 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsMarketList/PerpsMarketList.styles.ts @@ -0,0 +1,21 @@ +import { StyleSheet } from 'react-native'; + +const styleSheet = () => + StyleSheet.create({ + container: { + flex: 1, + }, + contentContainer: { + paddingBottom: 16, + paddingHorizontal: 16, + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingVertical: 32, + paddingHorizontal: 16, + }, + }); + +export default styleSheet; diff --git a/app/components/UI/Perps/components/PerpsMarketList/PerpsMarketList.test.tsx b/app/components/UI/Perps/components/PerpsMarketList/PerpsMarketList.test.tsx new file mode 100644 index 000000000000..dd960ab9f2a0 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsMarketList/PerpsMarketList.test.tsx @@ -0,0 +1,595 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react-native'; +import { View, Text as RNText } from 'react-native'; +import PerpsMarketList from './PerpsMarketList'; +import type { PerpsMarketData } from '../../controllers/types'; +import type { SortField } from '../../utils/sortMarkets'; + +// Mock dependencies +jest.mock('@shopify/flash-list', () => { + const { FlatList } = jest.requireActual('react-native'); + return { + FlashList: FlatList, + }; +}); + +jest.mock('../../../../../component-library/hooks', () => ({ + useStyles: () => ({ + styles: { + container: {}, + contentContainer: {}, + emptyContainer: {}, + }, + }), +})); + +jest.mock('../PerpsMarketRowItem', () => { + const { TouchableOpacity, Text } = jest.requireActual('react-native'); + return function MockPerpsMarketRowItem({ + market, + onPress, + iconSize, + displayMetric, + }: { + market: PerpsMarketData; + onPress: () => void; + iconSize?: number; + displayMetric?: SortField; + }) { + return ( + + {market.symbol} + {market.name} + {iconSize && {iconSize}} + {displayMetric && {displayMetric}} + + ); + }; +}); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => { + const translations: Record = { + 'perps.home.no_markets': 'No markets available', + }; + return translations[key] || key; + }), +})); + +describe('PerpsMarketList', () => { + const mockOnMarketPress = jest.fn(); + const mockMarkets: PerpsMarketData[] = [ + { + symbol: 'BTC', + name: 'Bitcoin', + maxLeverage: '50x', + price: '$52,000', + change24h: '+$2,000', + change24hPercent: '+4.00%', + volume: '$2.5B', + }, + { + symbol: 'ETH', + name: 'Ethereum', + maxLeverage: '25x', + price: '$3,000', + change24h: '-$50', + change24hPercent: '-1.64%', + volume: '$1.2B', + }, + { + symbol: 'SOL', + name: 'Solana', + maxLeverage: '20x', + price: '$150', + change24h: '+$5', + change24hPercent: '+3.45%', + volume: '$800M', + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('Component Rendering', () => { + it('renders list with markets', () => { + render( + , + ); + + expect(screen.getByText('BTC')).toBeOnTheScreen(); + expect(screen.getByText('Bitcoin')).toBeOnTheScreen(); + expect(screen.getByText('ETH')).toBeOnTheScreen(); + expect(screen.getByText('Ethereum')).toBeOnTheScreen(); + expect(screen.getByText('SOL')).toBeOnTheScreen(); + expect(screen.getByText('Solana')).toBeOnTheScreen(); + }); + + it('renders with custom testID', () => { + render( + , + ); + + expect(screen.getByTestId('custom-market-list')).toBeOnTheScreen(); + }); + + it('renders with default testID when not provided', () => { + render( + , + ); + + expect(screen.getByTestId('perps-market-list')).toBeOnTheScreen(); + }); + + it('renders markets in provided order', () => { + render( + , + ); + + const btcRow = screen.getByTestId('perps-market-row-BTC'); + const ethRow = screen.getByTestId('perps-market-row-ETH'); + const solRow = screen.getByTestId('perps-market-row-SOL'); + + expect(btcRow).toBeOnTheScreen(); + expect(ethRow).toBeOnTheScreen(); + expect(solRow).toBeOnTheScreen(); + }); + }); + + describe('Empty State', () => { + it('renders empty state when markets array is empty', () => { + render( + , + ); + + expect(screen.getByTestId('perps-market-list-empty')).toBeOnTheScreen(); + }); + + it('renders empty state with custom testID', () => { + render( + , + ); + + expect(screen.getByTestId('custom-list-empty')).toBeOnTheScreen(); + }); + + it('renders custom empty message', () => { + render( + , + ); + + // Empty state is rendered - verify via testID since Text doesn't render children + expect(screen.getByTestId('perps-market-list-empty')).toBeOnTheScreen(); + }); + + it('does not render FlashList when empty', () => { + render( + , + ); + + expect(screen.queryByTestId('perps-market-list')).not.toBeOnTheScreen(); + }); + }); + + describe('Market Interaction', () => { + it('calls onMarketPress when market row is pressed', () => { + render( + , + ); + + const btcRow = screen.getByTestId('perps-market-row-BTC'); + fireEvent.press(btcRow); + + expect(mockOnMarketPress).toHaveBeenCalledTimes(1); + expect(mockOnMarketPress).toHaveBeenCalledWith(mockMarkets[0]); + }); + + it('calls onMarketPress with correct market for different rows', () => { + render( + , + ); + + const ethRow = screen.getByTestId('perps-market-row-ETH'); + fireEvent.press(ethRow); + + expect(mockOnMarketPress).toHaveBeenCalledTimes(1); + expect(mockOnMarketPress).toHaveBeenCalledWith(mockMarkets[1]); + }); + + it('handles multiple market presses', () => { + render( + , + ); + + const btcRow = screen.getByTestId('perps-market-row-BTC'); + const ethRow = screen.getByTestId('perps-market-row-ETH'); + const solRow = screen.getByTestId('perps-market-row-SOL'); + + fireEvent.press(btcRow); + fireEvent.press(ethRow); + fireEvent.press(solRow); + + expect(mockOnMarketPress).toHaveBeenCalledTimes(3); + expect(mockOnMarketPress).toHaveBeenNthCalledWith(1, mockMarkets[0]); + expect(mockOnMarketPress).toHaveBeenNthCalledWith(2, mockMarkets[1]); + expect(mockOnMarketPress).toHaveBeenNthCalledWith(3, mockMarkets[2]); + }); + }); + + describe('Props Configuration', () => { + it('passes iconSize to market rows', () => { + render( + , + ); + + expect(screen.getByTestId('icon-size')).toBeOnTheScreen(); + expect(screen.getByTestId('icon-size')).toHaveTextContent('48'); + }); + + it('passes sortBy as displayMetric to market rows', () => { + render( + , + ); + + expect(screen.getByTestId('display-metric')).toBeOnTheScreen(); + expect(screen.getByTestId('display-metric')).toHaveTextContent( + 'priceChange', + ); + }); + + it('uses default sortBy value of volume when not provided', () => { + render( + , + ); + + expect(screen.getByTestId('display-metric')).toHaveTextContent('volume'); + }); + + it('renders ListHeaderComponent when provided', () => { + const HeaderComponent = () => ( + + Custom Header + + ); + + render( + , + ); + + expect(screen.getByTestId('custom-header')).toBeOnTheScreen(); + expect(screen.getByText('Custom Header')).toBeOnTheScreen(); + }); + + it('renders without ListHeaderComponent when not provided', () => { + render( + , + ); + + expect(screen.queryByTestId('custom-header')).not.toBeOnTheScreen(); + }); + }); + + describe('Data Updates', () => { + it('updates when markets prop changes', () => { + const { rerender } = render( + , + ); + + expect(screen.getByText('BTC')).toBeOnTheScreen(); + expect(screen.getByText('ETH')).toBeOnTheScreen(); + + const newMarkets: PerpsMarketData[] = [ + { + symbol: 'AVAX', + name: 'Avalanche', + maxLeverage: '30x', + price: '$40', + change24h: '+$2', + change24hPercent: '+5.26%', + volume: '$500M', + }, + ]; + + rerender( + , + ); + + expect(screen.queryByText('BTC')).not.toBeOnTheScreen(); + expect(screen.queryByText('ETH')).not.toBeOnTheScreen(); + expect(screen.getByText('AVAX')).toBeOnTheScreen(); + expect(screen.getByText('Avalanche')).toBeOnTheScreen(); + }); + + it('transitions from markets to empty state', () => { + const { rerender } = render( + , + ); + + expect(screen.getByText('BTC')).toBeOnTheScreen(); + expect( + screen.queryByTestId('perps-market-list-empty'), + ).not.toBeOnTheScreen(); + + rerender( + , + ); + + expect(screen.queryByText('BTC')).not.toBeOnTheScreen(); + expect(screen.getByTestId('perps-market-list-empty')).toBeOnTheScreen(); + }); + + it('transitions from empty state to markets', () => { + const { rerender } = render( + , + ); + + expect(screen.getByTestId('perps-market-list-empty')).toBeOnTheScreen(); + expect(screen.queryByText('BTC')).not.toBeOnTheScreen(); + + rerender( + , + ); + + expect( + screen.queryByTestId('perps-market-list-empty'), + ).not.toBeOnTheScreen(); + expect(screen.getByText('BTC')).toBeOnTheScreen(); + }); + + it('updates iconSize prop correctly', () => { + const { rerender } = render( + , + ); + + expect(screen.getByTestId('icon-size')).toHaveTextContent('32'); + + rerender( + , + ); + + expect(screen.getByTestId('icon-size')).toHaveTextContent('48'); + }); + + it('updates sortBy prop correctly', () => { + const { rerender } = render( + , + ); + + expect(screen.getByTestId('display-metric')).toHaveTextContent('volume'); + + rerender( + , + ); + + expect(screen.getByTestId('display-metric')).toHaveTextContent( + 'priceChange', + ); + }); + }); + + describe('Edge Cases', () => { + it('handles single market', () => { + render( + , + ); + + expect(screen.getByText('BTC')).toBeOnTheScreen(); + expect(screen.queryByText('ETH')).not.toBeOnTheScreen(); + }); + + it('handles very long market list', () => { + const longMarketList: PerpsMarketData[] = Array.from( + { length: 100 }, + (_, i) => ({ + symbol: `TOKEN${i}`, + name: `Token ${i}`, + maxLeverage: '20x', + price: `$${100 * (i + 1)}`, + change24h: `+$${10 * (i + 1)}`, + change24hPercent: '+5.00%', + volume: '$1M', + }), + ); + + render( + , + ); + + expect(screen.getByTestId('perps-market-list')).toBeOnTheScreen(); + }); + + it('handles markets with special characters in symbols', () => { + const specialMarkets: PerpsMarketData[] = [ + { + symbol: 'BTC-USD', + name: 'Bitcoin USD', + maxLeverage: '50x', + price: '$52,000', + change24h: '+$2,000', + change24hPercent: '+4.00%', + volume: '$2.5B', + }, + ]; + + render( + , + ); + + expect(screen.getByText('BTC-USD')).toBeOnTheScreen(); + }); + + it('handles empty string symbols gracefully', () => { + const marketsWithEmptySymbol: PerpsMarketData[] = [ + { + symbol: '', + name: 'Unknown', + maxLeverage: '10x', + price: '$0', + change24h: '$0', + change24hPercent: '0.00%', + volume: '$0', + }, + ]; + + render( + , + ); + + expect(screen.getByText('Unknown')).toBeOnTheScreen(); + }); + }); + + describe('Component Lifecycle', () => { + it('does not throw error on unmount', () => { + const { unmount } = render( + , + ); + + expect(() => unmount()).not.toThrow(); + }); + + it('cleans up properly when remounted with different props', () => { + const { root, rerender } = render( + , + ); + + expect(root).toBeTruthy(); + + rerender( + , + ); + + expect(screen.getByTestId('perps-market-list-empty')).toBeOnTheScreen(); + + rerender( + , + ); + + expect( + screen.queryByTestId('perps-market-list-empty'), + ).not.toBeOnTheScreen(); + expect(screen.getByText('BTC')).toBeOnTheScreen(); + }); + }); + + describe('FlashList Configuration', () => { + it('uses symbol as keyExtractor', () => { + render( + , + ); + + // Verify each market row has testID based on symbol + expect(screen.getByTestId('perps-market-row-BTC')).toBeOnTheScreen(); + expect(screen.getByTestId('perps-market-row-ETH')).toBeOnTheScreen(); + expect(screen.getByTestId('perps-market-row-SOL')).toBeOnTheScreen(); + }); + }); +}); diff --git a/app/components/UI/Perps/components/PerpsMarketList/PerpsMarketList.tsx b/app/components/UI/Perps/components/PerpsMarketList/PerpsMarketList.tsx new file mode 100644 index 000000000000..4e6f22192602 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsMarketList/PerpsMarketList.tsx @@ -0,0 +1,92 @@ +import React, { useCallback } from 'react'; +import { View } from 'react-native'; +import { FlashList } from '@shopify/flash-list'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; +import { useStyles } from '../../../../../component-library/hooks'; +import { strings } from '../../../../../../locales/i18n'; +import PerpsMarketRowItem from '../PerpsMarketRowItem'; +import { HOME_SCREEN_CONFIG } from '../../constants/perpsConfig'; +import styleSheet from './PerpsMarketList.styles'; +import type { PerpsMarketListProps } from './PerpsMarketList.types'; +import type { PerpsMarketData } from '../../controllers/types'; + +/** + * PerpsMarketList Component + * + * Reusable FlashList wrapper with consistent configuration. + * Handles market list rendering with optimal performance. + * + * Features: + * - FlashList for optimal performance + * - Consistent configuration (estimatedItemSize, keyboardShouldPersistTaps) + * - Empty state handling + * - Auto-updating via WebSocket (no manual refresh needed) + * - Optional header component + * + * @example + * ```tsx + * + * ``` + */ +const PerpsMarketList: React.FC = ({ + markets, + onMarketPress, + emptyMessage = strings('perps.home.no_markets'), + ListHeaderComponent, + iconSize = HOME_SCREEN_CONFIG.DEFAULT_ICON_SIZE, + sortBy = 'volume', + showBadge = true, + contentContainerStyle, + testID = 'perps-market-list', +}) => { + const { styles } = useStyles(styleSheet, {}); + + const renderItem = useCallback( + ({ item }: { item: PerpsMarketData }) => ( + onMarketPress(item)} + iconSize={iconSize} + displayMetric={sortBy} + showBadge={showBadge} + /> + ), + [onMarketPress, iconSize, sortBy, showBadge], + ); + + const renderEmpty = useCallback( + () => ( + + + {emptyMessage} + + + ), + [styles.emptyContainer, emptyMessage, testID], + ); + + if (markets.length === 0) { + return renderEmpty(); + } + + return ( + item.symbol} + contentContainerStyle={[styles.contentContainer, contentContainerStyle]} + keyboardShouldPersistTaps="handled" + ListHeaderComponent={ListHeaderComponent} + testID={testID} + /> + ); +}; + +export default PerpsMarketList; diff --git a/app/components/UI/Perps/components/PerpsMarketList/PerpsMarketList.types.ts b/app/components/UI/Perps/components/PerpsMarketList/PerpsMarketList.types.ts new file mode 100644 index 000000000000..3d77305a166e --- /dev/null +++ b/app/components/UI/Perps/components/PerpsMarketList/PerpsMarketList.types.ts @@ -0,0 +1,53 @@ +import type { PerpsMarketData } from '../../controllers/types'; +import type { SortField } from '../../utils/sortMarkets'; +import type { StyleProp, ViewStyle } from 'react-native'; + +/** + * Props for PerpsMarketList component + * Reusable FlashList wrapper with consistent configuration + */ +export interface PerpsMarketListProps { + /** + * Markets to display + */ + markets: PerpsMarketData[]; + /** + * Callback when a market is pressed + */ + onMarketPress: (market: PerpsMarketData) => void; + /** + * Message to display when list is empty + * @default 'perps.home.no_markets' + */ + emptyMessage?: string; + /** + * Optional header component to render above the list + */ + ListHeaderComponent?: + | React.ComponentType + | React.ReactElement + | null; + /** + * Optional icon size for market row items + * @default HOME_SCREEN_CONFIG.DEFAULT_ICON_SIZE + */ + iconSize?: number; + /** + * Current sort field to determine what metric to display in rows + * @default 'volume' + */ + sortBy?: SortField; + /** + * Whether to show market type badges (STOCK, COMMODITY, FOREX) on row items + * @default true + */ + showBadge?: boolean; + /** + * Optional style for the FlashList content container + */ + contentContainerStyle?: StyleProp; + /** + * Test ID for E2E testing + */ + testID?: string; +} diff --git a/app/components/UI/Perps/components/PerpsMarketList/index.tsx b/app/components/UI/Perps/components/PerpsMarketList/index.tsx new file mode 100644 index 000000000000..fa0690237350 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsMarketList/index.tsx @@ -0,0 +1,2 @@ +export { default } from './PerpsMarketList'; +export type { PerpsMarketListProps } from './PerpsMarketList.types'; diff --git a/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.styles.ts b/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.styles.ts new file mode 100644 index 000000000000..a9f0b8af9542 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.styles.ts @@ -0,0 +1,41 @@ +import { StyleSheet } from 'react-native'; +import type { Theme } from '../../../../../util/theme/models'; + +/** + * Styles for PerpsMarketListHeader component + */ +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + + return StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 12, + backgroundColor: theme.colors.background.default, + borderBottomWidth: 1, + borderBottomColor: theme.colors.border.muted, + }, + backButton: { + padding: 4, + }, + headerTitleContainer: { + flex: 1, + paddingHorizontal: 12, + }, + headerTitle: { + textAlign: 'center', + }, + titleButtonsRightContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + searchButton: { + padding: 4, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.test.tsx b/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.test.tsx new file mode 100644 index 000000000000..923df39d463e --- /dev/null +++ b/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.test.tsx @@ -0,0 +1,181 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { useNavigation } from '@react-navigation/native'; +import { Keyboard } from 'react-native'; +import PerpsMarketListHeader from './PerpsMarketListHeader'; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key) => { + const translations: Record = { + 'perps.title': 'Perps', + }; + return translations[key] || key; + }), +})); + +describe('PerpsMarketListHeader', () => { + const mockGoBack = jest.fn(); + const mockCanGoBack = jest.fn(); + const mockUseNavigation = useNavigation as jest.MockedFunction< + typeof useNavigation + >; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseNavigation.mockReturnValue({ + goBack: mockGoBack, + canGoBack: mockCanGoBack, + } as Partial> as ReturnType); + mockCanGoBack.mockReturnValue(true); + }); + + describe('Rendering', () => { + it('renders with default title', () => { + const { getByText } = render( + , + ); + + expect(getByText('Perps')).toBeTruthy(); + }); + + it('renders with custom title', () => { + const { getByText } = render( + , + ); + + expect(getByText('Custom Markets')).toBeTruthy(); + }); + + it('renders search icon when search is not visible', () => { + const { getByTestId } = render( + , + ); + + const searchButton = getByTestId('market-list-header-search-toggle'); + expect(searchButton).toBeTruthy(); + }); + + it('renders close icon when search is visible', () => { + const { getByTestId } = render( + , + ); + + const searchButton = getByTestId('market-list-header-search-toggle'); + expect(searchButton).toBeTruthy(); + }); + }); + + describe('Default Navigation', () => { + it('calls navigation.goBack() when back button is pressed', () => { + const { getByTestId } = render( + , + ); + + const backButton = getByTestId('market-list-header-back-button'); + fireEvent.press(backButton); + + expect(mockCanGoBack).toHaveBeenCalled(); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('does not call goBack when canGoBack returns false', () => { + mockCanGoBack.mockReturnValue(false); + const { getByTestId } = render( + , + ); + + const backButton = getByTestId('market-list-header-back-button'); + fireEvent.press(backButton); + + expect(mockCanGoBack).toHaveBeenCalled(); + expect(mockGoBack).not.toHaveBeenCalled(); + }); + }); + + describe('Custom Handlers', () => { + it('uses custom onBack handler when provided', () => { + const customBackHandler = jest.fn(); + const { getByTestId } = render( + , + ); + + const backButton = getByTestId('market-list-header-back-button'); + fireEvent.press(backButton); + + expect(customBackHandler).toHaveBeenCalledTimes(1); + expect(mockGoBack).not.toHaveBeenCalled(); + }); + + it('calls onSearchToggle when search button is pressed', () => { + const mockSearchToggle = jest.fn(); + const { getByTestId } = render( + , + ); + + const searchButton = getByTestId('market-list-header-search-toggle'); + fireEvent.press(searchButton); + + expect(mockSearchToggle).toHaveBeenCalledTimes(1); + }); + }); + + describe('Keyboard Behavior', () => { + it('dismisses keyboard when header is pressed', () => { + const dismissSpy = jest.spyOn(Keyboard, 'dismiss'); + const { getByTestId } = render( + , + ); + + const header = getByTestId('market-list-header'); + fireEvent.press(header); + + expect(dismissSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('Test ID', () => { + it('applies custom testID and derived testIDs', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('custom-header')).toBeTruthy(); + expect(getByTestId('custom-header-back-button')).toBeTruthy(); + expect(getByTestId('custom-header-search-toggle')).toBeTruthy(); + }); + }); +}); diff --git a/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.tsx b/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.tsx new file mode 100644 index 000000000000..49ba0b588263 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.tsx @@ -0,0 +1,110 @@ +import React, { useCallback } from 'react'; +import { View, TouchableOpacity, Pressable, Keyboard } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useStyles } from '../../../../../component-library/hooks'; +import Icon, { + IconName, + IconSize, +} from '../../../../../component-library/components/Icons/Icon'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; +import { strings } from '../../../../../../locales/i18n'; +import type { PerpsMarketListHeaderProps } from './PerpsMarketListHeader.types'; +import styleSheet from './PerpsMarketListHeader.styles'; + +/** + * PerpsMarketListHeader Component + * + * Header component for Perps Market List view with back button, + * title, and search toggle functionality + * + * Features: + * - Back button with default or custom navigation handler + * - Centered title with custom text support + * - Search toggle button that changes icon based on visibility + * - Keyboard dismiss on header press + * + * @example + * ```tsx + * + * ``` + * + * @example Custom back handler + * ```tsx + * + * ``` + */ +const PerpsMarketListHeader: React.FC = ({ + title, + isSearchVisible = false, + onBack, + onSearchToggle, + testID, +}) => { + const { styles } = useStyles(styleSheet, {}); + const navigation = useNavigation(); + + // Default back handler + const defaultHandleBack = useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack(); + } + }, [navigation]); + + // Use custom handler if provided, otherwise use default + const handleBack = onBack || defaultHandleBack; + + return ( + Keyboard.dismiss()} + testID={testID} + > + {/* Back Button */} + + + + + {/* Title */} + + + {title || strings('perps.title')} + + + + {/* Search Toggle Button */} + + + + + + + ); +}; + +export default PerpsMarketListHeader; diff --git a/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.types.ts b/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.types.ts new file mode 100644 index 000000000000..da68d7e5c138 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.types.ts @@ -0,0 +1,32 @@ +/** + * Props for PerpsMarketListHeader component + */ +export interface PerpsMarketListHeaderProps { + /** + * Header title text + * @default strings('perps.title') + */ + title?: string; + + /** + * Whether search bar is currently visible + * @default false + */ + isSearchVisible?: boolean; + + /** + * Callback when back button is pressed + * If not provided, uses default navigation.goBack() + */ + onBack?: () => void; + + /** + * Callback when search toggle button is pressed + */ + onSearchToggle?: () => void; + + /** + * Test ID for the header container + */ + testID?: string; +} diff --git a/app/components/UI/Perps/components/PerpsMarketListHeader/index.ts b/app/components/UI/Perps/components/PerpsMarketListHeader/index.ts new file mode 100644 index 000000000000..63608dbec631 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsMarketListHeader/index.ts @@ -0,0 +1,2 @@ +export { default } from './PerpsMarketListHeader'; +export type { PerpsMarketListHeaderProps } from './PerpsMarketListHeader.types'; diff --git a/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.styles.ts b/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.styles.ts index d383846a118c..ad28dbf5225e 100644 --- a/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.styles.ts +++ b/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.styles.ts @@ -10,8 +10,7 @@ const styleSheet = (params: { theme: Theme }) => { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - paddingVertical: 12, - paddingHorizontal: 16, + paddingVertical: 6, backgroundColor: colors.background.default, }, perpIcon: { diff --git a/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.tsx b/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.tsx index 365695996b74..5dfbc3a8ea5f 100644 --- a/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.tsx +++ b/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.tsx @@ -6,7 +6,10 @@ import Text, { TextVariant, } from '../../../../../component-library/components/Texts/Text'; import { useStyles } from '../../../../../component-library/hooks'; -import { PERPS_CONSTANTS } from '../../constants/perpsConfig'; +import { + PERPS_CONSTANTS, + HOME_SCREEN_CONFIG, +} from '../../constants/perpsConfig'; import type { PerpsMarketData } from '../../controllers/types'; import { usePerpsLivePrices } from '../../hooks/stream'; import { @@ -26,7 +29,13 @@ import PerpsTokenLogo from '../PerpsTokenLogo'; import styleSheet from './PerpsMarketRowItem.styles'; import { PerpsMarketRowItemProps } from './PerpsMarketRowItem.types'; -const PerpsMarketRowItem = ({ market, onPress }: PerpsMarketRowItemProps) => { +const PerpsMarketRowItem = ({ + market, + onPress, + iconSize = HOME_SCREEN_CONFIG.DEFAULT_ICON_SIZE, + displayMetric = 'volume', + showBadge = true, +}: PerpsMarketRowItemProps) => { const { styles } = useStyles(styleSheet, {}); // Subscribe to live prices for just this symbol @@ -89,9 +98,12 @@ const PerpsMarketRowItem = ({ market, onPress }: PerpsMarketRowItemProps) => { updatedMarket.volume = formatVolume(volume); } else { // Only show $0 if volume is truly 0 - updatedMarket.volume = '$0.00'; + updatedMarket.volume = PERPS_CONSTANTS.ZERO_AMOUNT_DETAILED_DISPLAY; } - } else if (!market.volume || market.volume === '$0') { + } else if ( + !market.volume || + market.volume === PERPS_CONSTANTS.ZERO_AMOUNT_DISPLAY + ) { // Fallback: ensure volume field always has a value updatedMarket.volume = PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY; } @@ -103,6 +115,30 @@ const PerpsMarketRowItem = ({ market, onPress }: PerpsMarketRowItemProps) => { onPress?.(displayMarket); }; + // Helper to get display value based on selected metric + const getDisplayValue = useMemo(() => { + switch (displayMetric) { + case 'priceChange': + return displayMarket.change24hPercent; + case 'openInterest': + return ( + displayMarket.openInterest || PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY + ); + case 'fundingRate': + // Format funding rate as percentage (e.g., 0.0001 → 0.0100%) + if ( + displayMarket.fundingRate !== undefined && + displayMarket.fundingRate !== null + ) { + return `${(displayMarket.fundingRate * 100).toFixed(4)}%`; + } + return '0.0000%'; + case 'volume': + default: + return displayMarket.volume; + } + }, [displayMetric, displayMarket]); + const isPositiveChange = !displayMarket.change24h.startsWith('-'); const badgeType = getMarketBadgeType(displayMarket); @@ -117,7 +153,7 @@ const PerpsMarketRowItem = ({ market, onPress }: PerpsMarketRowItemProps) => { { - {displayMarket.volume} + {getDisplayValue} - {badgeType && ( + {showBadge && badgeType && ( void; + /** + * Size of the token icon (defaults to HOME_SCREEN_CONFIG.DEFAULT_ICON_SIZE) + */ + iconSize?: number; + /** + * Metric to display in the subtitle area + * - 'volume': Shows 24h trading volume (default) + * - 'priceChange': Shows 24h price change percentage + * - 'openInterest': Shows open interest value + * - 'fundingRate': Shows current funding rate + * @default 'volume' + */ + displayMetric?: SortField; + /** + * Whether to show the market type badge (STOCK, COMMODITY, FOREX) + * Should be true in watchlist (mixed types) but false in type-specific sections/tabs + * @default true + */ + showBadge?: boolean; } diff --git a/app/components/UI/Perps/components/PerpsMarketSortDropdowns/PerpsMarketSortDropdowns.styles.ts b/app/components/UI/Perps/components/PerpsMarketSortDropdowns/PerpsMarketSortDropdowns.styles.ts new file mode 100644 index 000000000000..ff26788fcaf5 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsMarketSortDropdowns/PerpsMarketSortDropdowns.styles.ts @@ -0,0 +1,42 @@ +import { Theme } from '../../../../../util/theme/models'; +import { StyleSheet } from 'react-native'; + +export const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + + return StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-start', + paddingHorizontal: 16, + paddingVertical: 12, + gap: 8, + }, + dropdownButton: { + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 8, + backgroundColor: theme.colors.background.muted, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + gap: 4, + }, + dropdownButtonActive: { + backgroundColor: theme.colors.primary.muted, + }, + dropdownButtonPressed: { + opacity: 0.7, + }, + dropdownText: { + fontSize: 14, + fontWeight: '400', + color: theme.colors.text.alternative, + }, + dropdownTextActive: { + color: theme.colors.primary.default, + fontWeight: '500', + }, + }); +}; diff --git a/app/components/UI/Perps/components/PerpsMarketSortDropdowns/PerpsMarketSortDropdowns.test.tsx b/app/components/UI/Perps/components/PerpsMarketSortDropdowns/PerpsMarketSortDropdowns.test.tsx new file mode 100644 index 000000000000..c194bfdb8af3 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsMarketSortDropdowns/PerpsMarketSortDropdowns.test.tsx @@ -0,0 +1,261 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react-native'; +import PerpsMarketSortDropdowns from './PerpsMarketSortDropdowns'; +import type { SortOptionId } from '../../constants/perpsConfig'; + +// Mock dependencies +jest.mock('../../../../../component-library/hooks', () => ({ + useStyles: () => ({ + styles: { + container: {}, + dropdownButton: {}, + dropdownButtonPressed: {}, + dropdownButtonActive: {}, + dropdownText: {}, + dropdownTextActive: {}, + }, + }), +})); + +// Mock the design system components +jest.mock('@metamask/design-system-react-native', () => { + const { View, Text } = jest.requireActual('react-native'); + return { + Box: View, + Text: ({ children, ...props }: { children?: React.ReactNode }) => ( + {children} + ), + TextVariant: {}, + }; +}); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const translations: Record = { + 'perps.sort.volume': 'Volume', + 'perps.sort.price_change_high_to_low': 'Price Change (High to Low)', + 'perps.sort.price_change_low_to_high': 'Price Change (Low to High)', + 'perps.sort.funding_rate': 'Funding Rate', + 'perps.sort.open_interest': 'Open Interest', + }; + return translations[key] || key; + }, +})); + +describe('PerpsMarketSortDropdowns', () => { + const mockOnSortPress = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('Component Rendering', () => { + it('renders with default props', () => { + render( + , + ); + + expect( + screen.getByTestId('perps-market-sort-dropdowns'), + ).toBeOnTheScreen(); + }); + + it('renders with custom testID', () => { + render( + , + ); + + expect(screen.getByTestId('custom-sort-dropdowns')).toBeOnTheScreen(); + }); + + it('renders sort field button', () => { + render( + , + ); + + expect( + screen.getByTestId('perps-market-sort-dropdowns-sort-field'), + ).toBeOnTheScreen(); + }); + }); + + describe('Sort Field Display', () => { + it('displays volume label when selectedOptionId is volume', () => { + render( + , + ); + + expect(screen.getByText('Volume')).toBeOnTheScreen(); + expect( + screen.getByTestId('perps-market-sort-dropdowns-sort-field'), + ).toBeOnTheScreen(); + }); + + it('displays price change label when selectedOptionId is priceChange-desc', () => { + render( + , + ); + + expect(screen.getByText('Price Change (High to Low)')).toBeOnTheScreen(); + }); + + it('displays funding rate label when selectedOptionId is fundingRate', () => { + render( + , + ); + + expect(screen.getByText('Funding Rate')).toBeOnTheScreen(); + }); + }); + + describe('User Interactions', () => { + it('calls onSortPress when sort field button is pressed', () => { + render( + , + ); + + const sortButton = screen.getByTestId( + 'perps-market-sort-dropdowns-sort-field', + ); + fireEvent.press(sortButton); + + expect(mockOnSortPress).toHaveBeenCalledTimes(1); + }); + + it('handles multiple rapid presses on sort field button', () => { + render( + , + ); + + const sortButton = screen.getByTestId( + 'perps-market-sort-dropdowns-sort-field', + ); + fireEvent.press(sortButton); + fireEvent.press(sortButton); + fireEvent.press(sortButton); + + expect(mockOnSortPress).toHaveBeenCalledTimes(3); + }); + }); + + describe('Props Updates', () => { + it('updates selectedOptionId and callback correctly', () => { + const { rerender } = render( + , + ); + + expect(screen.getByText('Volume')).toBeOnTheScreen(); + + const newOnSortPress = jest.fn(); + + rerender( + , + ); + + expect(screen.getByText('Price Change (High to Low)')).toBeOnTheScreen(); + + const sortButton = screen.getByTestId( + 'perps-market-sort-dropdowns-sort-field', + ); + fireEvent.press(sortButton); + + expect(newOnSortPress).toHaveBeenCalledTimes(1); + expect(mockOnSortPress).not.toHaveBeenCalled(); + }); + }); + + describe('Edge Cases', () => { + it('handles all sort option values', () => { + const sortOptions: SortOptionId[] = [ + 'volume', + 'priceChange-desc', + 'fundingRate', + ]; + + sortOptions.forEach((optionId) => { + const { unmount } = render( + , + ); + + expect( + screen.getByTestId('perps-market-sort-dropdowns-sort-field'), + ).toBeOnTheScreen(); + + unmount(); + }); + }); + }); + + describe('Component Lifecycle', () => { + it('does not throw error on unmount', () => { + const { unmount } = render( + , + ); + + expect(() => unmount()).not.toThrow(); + }); + + it('cleans up properly when remounted with different props', () => { + const { root, rerender, unmount } = render( + , + ); + + expect(root).toBeTruthy(); + + rerender( + , + ); + + expect(root).toBeTruthy(); + + expect(() => unmount()).not.toThrow(); + }); + }); +}); diff --git a/app/components/UI/Perps/components/PerpsMarketSortDropdowns/PerpsMarketSortDropdowns.tsx b/app/components/UI/Perps/components/PerpsMarketSortDropdowns/PerpsMarketSortDropdowns.tsx new file mode 100644 index 000000000000..b70936e24dec --- /dev/null +++ b/app/components/UI/Perps/components/PerpsMarketSortDropdowns/PerpsMarketSortDropdowns.tsx @@ -0,0 +1,73 @@ +import React, { useMemo } from 'react'; +import { Pressable } from 'react-native'; +import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; +import { useStyles } from '../../../../../component-library/hooks'; +import { strings } from '../../../../../../locales/i18n'; +import Icon, { + IconColor, + IconName, + IconSize, +} from '../../../../../component-library/components/Icons/Icon'; +import { styleSheet } from './PerpsMarketSortDropdowns.styles'; +import type { PerpsMarketSortDropdownsProps } from './PerpsMarketSortDropdowns.types'; +import { MARKET_SORTING_CONFIG } from '../../constants/perpsConfig'; + +/** + * PerpsMarketSortDropdowns Component + * + * Compact dropdown button for market sorting. + * Opens bottom sheet for sort options selection. + * + * Features: + * - Dropdown button showing current sort selection + * - Chevron indicator + * - Opens bottom sheet for sort option selection + * + * @example + * ```tsx + * setShowSortSheet(true)} + * /> + * ``` + */ +const PerpsMarketSortDropdowns: React.FC = ({ + selectedOptionId, + onSortPress, + testID = 'perps-market-sort-dropdowns', +}) => { + const { styles } = useStyles(styleSheet, {}); + + // Get display label for current sort option + const sortLabel = useMemo(() => { + const option = MARKET_SORTING_CONFIG.SORT_OPTIONS.find( + (opt) => opt.id === selectedOptionId, + ); + return option ? strings(option.labelKey) : strings('perps.sort.volume'); + }, [selectedOptionId]); + + return ( + + {/* Sort Field Dropdown */} + [ + styles.dropdownButton, + pressed && styles.dropdownButtonPressed, + ]} + onPress={onSortPress} + testID={`${testID}-sort-field`} + > + + {sortLabel} + + + + + ); +}; + +export default PerpsMarketSortDropdowns; diff --git a/app/components/UI/Perps/components/PerpsMarketSortDropdowns/PerpsMarketSortDropdowns.types.ts b/app/components/UI/Perps/components/PerpsMarketSortDropdowns/PerpsMarketSortDropdowns.types.ts new file mode 100644 index 000000000000..187ef86dd5d3 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsMarketSortDropdowns/PerpsMarketSortDropdowns.types.ts @@ -0,0 +1,19 @@ +import type { SortOptionId } from '../../constants/perpsConfig'; + +/** + * Props for PerpsMarketSortDropdowns component + */ +export interface PerpsMarketSortDropdownsProps { + /** + * Currently selected sort option ID + */ + selectedOptionId: SortOptionId; + /** + * Callback when sort field button is pressed + */ + onSortPress: () => void; + /** + * Test ID for E2E testing + */ + testID?: string; +} diff --git a/app/components/UI/Perps/components/PerpsMarketSortDropdowns/index.ts b/app/components/UI/Perps/components/PerpsMarketSortDropdowns/index.ts new file mode 100644 index 000000000000..2530c18e6983 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsMarketSortDropdowns/index.ts @@ -0,0 +1,2 @@ +export { default } from './PerpsMarketSortDropdowns'; +export * from './PerpsMarketSortDropdowns.types'; diff --git a/app/components/UI/Perps/components/PerpsMarketSortFieldBottomSheet/PerpsMarketSortFieldBottomSheet.styles.ts b/app/components/UI/Perps/components/PerpsMarketSortFieldBottomSheet/PerpsMarketSortFieldBottomSheet.styles.ts new file mode 100644 index 000000000000..bdd3871e8b63 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsMarketSortFieldBottomSheet/PerpsMarketSortFieldBottomSheet.styles.ts @@ -0,0 +1,23 @@ +import { Theme } from '../../../../../util/theme/models'; +import { StyleSheet } from 'react-native'; + +export const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + + return StyleSheet.create({ + optionsList: { + paddingBottom: 32, + }, + optionRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 16, + paddingHorizontal: 16, + minHeight: 56, + }, + optionRowSelected: { + backgroundColor: theme.colors.background.muted, + }, + }); +}; diff --git a/app/components/UI/Perps/components/PerpsMarketSortFieldBottomSheet/PerpsMarketSortFieldBottomSheet.test.tsx b/app/components/UI/Perps/components/PerpsMarketSortFieldBottomSheet/PerpsMarketSortFieldBottomSheet.test.tsx new file mode 100644 index 000000000000..9bfc85332320 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsMarketSortFieldBottomSheet/PerpsMarketSortFieldBottomSheet.test.tsx @@ -0,0 +1,280 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react-native'; +import PerpsMarketSortFieldBottomSheet from './PerpsMarketSortFieldBottomSheet'; + +// Mock dependencies +jest.mock('../../../../../component-library/hooks', () => ({ + useStyles: () => ({ + styles: { + filterList: {}, + filterRow: {}, + filterRowActive: {}, + filterRowContent: {}, + toggleContainer: {}, + }, + theme: { + colors: { + background: { + alternative: '#E5E5E5', + }, + icon: { + default: '#000000', + }, + border: { + muted: '#D6D6D6', + }, + }, + }, + }), +})); + +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheet', + () => { + const MockReact = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: MockReact.forwardRef( + ( + { + children, + testID, + }: { + children: React.ReactNode; + testID?: string; + }, + ref: React.Ref<{ + onOpenBottomSheet: () => void; + onCloseBottomSheet: () => void; + }>, + ) => { + // Expose mock ref methods + MockReact.useImperativeHandle(ref, () => ({ + onOpenBottomSheet: jest.fn(), + onCloseBottomSheet: jest.fn(), + })); + + return {children}; + }, + ), + }; + }, +); + +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheetHeader', + () => { + const { View } = jest.requireActual('react-native'); + return function MockBottomSheetHeader({ + children, + }: { + children: React.ReactNode; + }) { + return {children}; + }; + }, +); + +jest.mock( + '../../../../../component-library/components-temp/SegmentedControl', + () => { + const { View, TouchableOpacity, Text } = jest.requireActual('react-native'); + return function MockSegmentedControl({ + options, + selectedValue, + onValueChange, + testID, + }: { + options: { value: string; label: string }[]; + selectedValue: string; + onValueChange: (value: string) => void; + testID?: string; + }) { + return ( + + {options.map((option) => ( + onValueChange(option.value)} + > + + {option.label}{' '} + {selectedValue === option.value ? '(selected)' : ''} + + + ))} + + ); + }; + }, +); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => { + const translations: Record = { + 'perps.sort.sort_by': 'Sort By', + 'perps.sort.volume': 'Volume', + 'perps.sort.price_change': '24h Change', + 'perps.sort.funding_rate': 'Funding Rate', + 'perps.sort.open_interest': 'Open Interest', + 'perps.sort.high': 'High', + 'perps.sort.low': 'Low', + }; + return translations[key] || key; + }), +})); + +describe('PerpsMarketSortFieldBottomSheet', () => { + const mockOnClose = jest.fn(); + const mockOnOptionSelect = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('Visibility', () => { + it('returns null when isVisible is false', () => { + const { toJSON } = render( + , + ); + + expect(toJSON()).toBeNull(); + }); + + it('renders when isVisible is true', () => { + render( + , + ); + + expect(screen.getByTestId('bottom-sheet-header')).toBeOnTheScreen(); + }); + }); + + describe('Sort Options', () => { + it('renders all sort options with testIDs', () => { + render( + , + ); + + expect( + screen.getByTestId('sort-field-sheet-option-volume'), + ).toBeOnTheScreen(); + expect( + screen.getByTestId('sort-field-sheet-option-priceChange-desc'), + ).toBeOnTheScreen(); + expect( + screen.getByTestId('sort-field-sheet-option-priceChange-asc'), + ).toBeOnTheScreen(); + expect( + screen.getByTestId('sort-field-sheet-option-openInterest'), + ).toBeOnTheScreen(); + expect( + screen.getByTestId('sort-field-sheet-option-fundingRate'), + ).toBeOnTheScreen(); + }); + + it('shows checkmark on selected option', () => { + render( + , + ); + + expect( + screen.getByTestId('sort-field-sheet-checkmark-priceChange-desc'), + ).toBeOnTheScreen(); + }); + }); + + describe('Option Selection', () => { + it('calls onOptionSelect with option ID, field, and direction', () => { + render( + , + ); + + fireEvent.press( + screen.getByTestId('sort-field-sheet-option-priceChange-desc'), + ); + + expect(mockOnOptionSelect).toHaveBeenCalledTimes(1); + expect(mockOnOptionSelect).toHaveBeenCalledWith( + 'priceChange-desc', + 'priceChange', + 'desc', + ); + }); + + it('auto-closes when option is selected', () => { + render( + , + ); + + fireEvent.press( + screen.getByTestId('sort-field-sheet-option-priceChange-desc'), + ); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('calls onOptionSelect for ascending price change option', () => { + render( + , + ); + + fireEvent.press( + screen.getByTestId('sort-field-sheet-option-priceChange-asc'), + ); + + expect(mockOnOptionSelect).toHaveBeenCalledTimes(1); + expect(mockOnOptionSelect).toHaveBeenCalledWith( + 'priceChange-asc', + 'priceChange', + 'asc', + ); + }); + }); +}); diff --git a/app/components/UI/Perps/components/PerpsMarketSortFieldBottomSheet/PerpsMarketSortFieldBottomSheet.tsx b/app/components/UI/Perps/components/PerpsMarketSortFieldBottomSheet/PerpsMarketSortFieldBottomSheet.tsx new file mode 100644 index 000000000000..2b5ec6f2358e --- /dev/null +++ b/app/components/UI/Perps/components/PerpsMarketSortFieldBottomSheet/PerpsMarketSortFieldBottomSheet.tsx @@ -0,0 +1,113 @@ +import React, { useRef, useEffect } from 'react'; +import { TouchableOpacity } from 'react-native'; +import { useStyles } from '../../../../../component-library/hooks'; +import Text, { + TextVariant, +} from '../../../../../component-library/components/Texts/Text'; +import Icon, { + IconName, + IconSize, +} from '../../../../../component-library/components/Icons/Icon'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../../component-library/components/BottomSheets/BottomSheet'; +import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import { Box } from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../locales/i18n'; +import { styleSheet } from './PerpsMarketSortFieldBottomSheet.styles'; +import type { PerpsMarketSortFieldBottomSheetProps } from './PerpsMarketSortFieldBottomSheet.types'; +import { MARKET_SORTING_CONFIG } from '../../constants/perpsConfig'; + +/** + * PerpsMarketSortFieldBottomSheet Component + * + * Simple list-based bottom sheet for selecting market sort options. + * Each option combines field + direction into a single selectable item. + * + * Features: + * - Flat list of sort options + * - Checkmark icon on selected option + * - Auto-closes on selection + * + * @example + * ```tsx + * setShowSortSheet(false)} + * selectedOptionId="priceChange-desc" + * onOptionSelect={handleSortChange} + * /> + * ``` + */ +const PerpsMarketSortFieldBottomSheet: React.FC< + PerpsMarketSortFieldBottomSheetProps +> = ({ isVisible, onClose, selectedOptionId, onOptionSelect, testID }) => { + const { styles } = useStyles(styleSheet, {}); + const bottomSheetRef = useRef(null); + + useEffect(() => { + if (isVisible) { + bottomSheetRef.current?.onOpenBottomSheet(); + } + }, [isVisible]); + + /** + * Handle option selection - selects the option and closes the sheet + */ + const handleOptionSelect = (optionId: string) => { + const option = MARKET_SORTING_CONFIG.SORT_OPTIONS.find( + (opt) => opt.id === optionId, + ); + if (option) { + onOptionSelect(option.id, option.field, option.direction); + onClose(); + } + }; + + if (!isVisible) return null; + + return ( + + + + {strings('perps.sort.sort_by')} + + + + {/* Render sort options */} + {MARKET_SORTING_CONFIG.SORT_OPTIONS.map((option) => { + const isSelected = selectedOptionId === option.id; + return ( + handleOptionSelect(option.id)} + testID={testID ? `${testID}-option-${option.id}` : undefined} + > + + {strings(option.labelKey)} + + {isSelected && ( + + )} + + ); + })} + + + ); +}; + +export default PerpsMarketSortFieldBottomSheet; diff --git a/app/components/UI/Perps/components/PerpsMarketSortFieldBottomSheet/PerpsMarketSortFieldBottomSheet.types.ts b/app/components/UI/Perps/components/PerpsMarketSortFieldBottomSheet/PerpsMarketSortFieldBottomSheet.types.ts new file mode 100644 index 000000000000..688ac4989f90 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsMarketSortFieldBottomSheet/PerpsMarketSortFieldBottomSheet.types.ts @@ -0,0 +1,35 @@ +import type { SortField, SortDirection } from '../../utils/sortMarkets'; +import type { SortOptionId } from '../../constants/perpsConfig'; + +/** + * Props for PerpsMarketSortFieldBottomSheet component + */ +export interface PerpsMarketSortFieldBottomSheetProps { + /** + * Whether the bottom sheet is visible + */ + isVisible: boolean; + /** + * Callback when bottom sheet should close + */ + onClose: () => void; + /** + * Currently selected option ID + */ + selectedOptionId: SortOptionId; + /** + * Callback when an option is selected + * @param optionId - The ID of the selected option + * @param field - The sort field + * @param direction - The sort direction + */ + onOptionSelect: ( + optionId: SortOptionId, + field: SortField, + direction: SortDirection, + ) => void; + /** + * Test ID for E2E testing + */ + testID?: string; +} diff --git a/app/components/UI/Perps/components/PerpsMarketSortFieldBottomSheet/index.ts b/app/components/UI/Perps/components/PerpsMarketSortFieldBottomSheet/index.ts new file mode 100644 index 000000000000..1a02e66707db --- /dev/null +++ b/app/components/UI/Perps/components/PerpsMarketSortFieldBottomSheet/index.ts @@ -0,0 +1,2 @@ +export { default } from './PerpsMarketSortFieldBottomSheet'; +export * from './PerpsMarketSortFieldBottomSheet.types'; diff --git a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.styles.ts b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.styles.ts index 3a705d14cc39..79891385bac3 100644 --- a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.styles.ts +++ b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.styles.ts @@ -26,9 +26,6 @@ const styleSheet = () => fundingCountdown: { marginLeft: 2, }, - tutorialCardContainer: { - marginTop: 24, - }, }); export default styleSheet; diff --git a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.test.tsx b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.test.tsx index 98a82b850d9c..5d39a414dbd2 100644 --- a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.test.tsx +++ b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.test.tsx @@ -93,7 +93,7 @@ describe('PerpsMarketStatisticsCard', () => { }); it('renders all statistics rows correctly', () => { - const { getByText, getByTestId } = render( + const { getByText } = render( , ); @@ -112,9 +112,6 @@ describe('PerpsMarketStatisticsCard', () => { // Check funding rate row expect(getByText('perps.market.funding_rate')).toBeOnTheScreen(); expect(getByText('0.0125%')).toBeOnTheScreen(); - - // Check tutorial card - expect(getByTestId('perps-tutorial-card')).toBeOnTheScreen(); }); it('displays positive funding rate in success color', () => { diff --git a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.tsx b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.tsx index a088dc064ab0..445173a3a94d 100644 --- a/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.tsx +++ b/app/components/UI/Perps/components/PerpsMarketStatisticsCard/PerpsMarketStatisticsCard.tsx @@ -18,7 +18,6 @@ import FundingCountdown from '../FundingCountdown'; import { usePerpsLivePrices } from '../../hooks/stream'; import { formatFundingRate } from '../../utils/formatUtils'; import { FUNDING_RATE_CONFIG } from '../../constants/perpsConfig'; -import PerpsTutorialCard from '../PerpsTutorialCard/PerpsTutorialCard'; const PerpsMarketStatisticsCard: React.FC = ({ symbol, @@ -180,9 +179,6 @@ const PerpsMarketStatisticsCard: React.FC = ({ - - - ); }; diff --git a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.styles.ts b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.styles.ts index f1cd7adaced4..d35c62f60b8d 100644 --- a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.styles.ts +++ b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.styles.ts @@ -16,6 +16,10 @@ const styleSheet = ({ theme }: { theme: Theme }) => fullWidthTabWrapper: { flex: 1, }, + // Navigation panel styles + navigationPanel: { + paddingTop: 16, + }, // ... existing styles ... tabContainer: { paddingTop: 18, diff --git a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx index 21d3cb73474e..7abff585307c 100644 --- a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx +++ b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx @@ -185,7 +185,7 @@ describe('PerpsMarketTabs', () => { // Setup default mock data for internal hooks mockUsePerpsMarketStats.mockReturnValue(mockMarketStats); mockUsePerpsLivePositions.mockReturnValue({ positions: [] }); - mockUsePerpsLiveOrders.mockReturnValue([]); + mockUsePerpsLiveOrders.mockReturnValue({ orders: [] }); }); describe('Rendering', () => { @@ -227,7 +227,9 @@ describe('PerpsMarketTabs', () => { it('displays orders tab when unfilled orders exist', () => { const onActiveTabChange = jest.fn(); - mockUsePerpsLiveOrders.mockReturnValue([{ ...mockOrder, symbol: 'BTC' }]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [{ ...mockOrder, symbol: 'BTC' }], + }); const { getAllByText } = render( { mockUsePerpsLivePositions.mockReturnValue({ positions: [{ ...mockPosition, coin: 'BTC' }], }); - mockUsePerpsLiveOrders.mockReturnValue([{ ...mockOrder, symbol: 'BTC' }]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [{ ...mockOrder, symbol: 'BTC' }], + }); const { getAllByText } = render( { mockUsePerpsLivePositions.mockReturnValue({ positions: [{ ...mockPosition, coin: 'BTC' }], }); - mockUsePerpsLiveOrders.mockReturnValue([{ ...mockOrder, symbol: 'BTC' }]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [{ ...mockOrder, symbol: 'BTC' }], + }); const { getAllByText } = render( { }); it('sets initial tab to orders when initialTab is orders', async () => { - mockUsePerpsLiveOrders.mockReturnValue([{ ...mockOrder, symbol: 'BTC' }]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [{ ...mockOrder, symbol: 'BTC' }], + }); const onActiveTabChange = jest.fn(); const { getByTestId } = render( @@ -449,7 +457,9 @@ describe('PerpsMarketTabs', () => { mockUsePerpsLivePositions.mockReturnValue({ positions: [{ ...mockPosition, coin: 'BTC' }], }); - mockUsePerpsLiveOrders.mockReturnValue([{ ...mockOrder, symbol: 'BTC' }]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [{ ...mockOrder, symbol: 'BTC' }], + }); const onActiveTabChange = jest.fn(); const { getByTestId } = render( @@ -474,7 +484,9 @@ describe('PerpsMarketTabs', () => { mockUsePerpsLivePositions.mockReturnValue({ positions: [{ ...mockPosition, coin: 'BTC' }], }); - mockUsePerpsLiveOrders.mockReturnValue([{ ...mockOrder, symbol: 'BTC' }]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [{ ...mockOrder, symbol: 'BTC' }], + }); const onActiveTabChange = jest.fn(); const { getAllByText, getByTestId } = render( { }, ], }); - mockUsePerpsLiveOrders.mockReturnValue([orderWithBothTPSL]); + mockUsePerpsLiveOrders.mockReturnValue({ orders: [orderWithBothTPSL] }); const { getAllByText, getByTestId } = render( { }, ], }); - mockUsePerpsLiveOrders.mockReturnValue([orderWithTP]); + mockUsePerpsLiveOrders.mockReturnValue({ orders: [orderWithTP] }); const { getAllByText, getByTestId } = render( { }, ], }); - mockUsePerpsLiveOrders.mockReturnValue([orderWithSL]); + mockUsePerpsLiveOrders.mockReturnValue({ orders: [orderWithSL] }); const { getAllByText, getByTestId } = render( { orderType: 'stop', }; - mockUsePerpsLiveOrders.mockReturnValue([ - stopLossOrder, - limitOrder, - marketOrder, - ]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [stopLossOrder, limitOrder, marketOrder], + }); const { getAllByTestId } = render( { orderType: 'limit', }; - mockUsePerpsLiveOrders.mockReturnValue([orderWithoutDetailedType]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [orderWithoutDetailedType], + }); const { getByTestId } = render( { mockUsePerpsLivePositions.mockReturnValue({ positions: [{ ...mockPosition, coin: 'BTC' }], }); - mockUsePerpsLiveOrders.mockReturnValue([{ ...mockOrder, symbol: 'BTC' }]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [{ ...mockOrder, symbol: 'BTC' }], + }); const onActiveTabChange = jest.fn(); const { rerender, getByTestId } = render( @@ -735,7 +749,9 @@ describe('PerpsMarketTabs', () => { }); it('preserves current tab when new position appears without external tab change', async () => { - mockUsePerpsLiveOrders.mockReturnValue([{ ...mockOrder, symbol: 'BTC' }]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [{ ...mockOrder, symbol: 'BTC' }], + }); mockUsePerpsLivePositions.mockReturnValue({ positions: [] }); const onActiveTabChange = jest.fn(); @@ -822,7 +838,7 @@ describe('PerpsMarketTabs', () => { const order1 = { ...mockOrder, symbol: 'BTC', orderId: 'order-1' }; const order2 = { ...mockOrder, symbol: 'BTC', orderId: 'order-2' }; - mockUsePerpsLiveOrders.mockReturnValue([order1, order2]); + mockUsePerpsLiveOrders.mockReturnValue({ orders: [order1, order2] }); mockCancelOrder.mockResolvedValue({ success: true }); const { getAllByText, getAllByTestId, rerender } = render( @@ -848,7 +864,7 @@ describe('PerpsMarketTabs', () => { expect(mockCancelOrder).toHaveBeenCalled(); }); - mockUsePerpsLiveOrders.mockReturnValue([order2]); + mockUsePerpsLiveOrders.mockReturnValue({ orders: [order2] }); rerender( { it('displays in-progress toast then success toast when order cancellation succeeds', async () => { mockCancelOrder.mockResolvedValue({ success: true }); const btcOrder = { ...mockOrder, symbol: 'BTC' }; - mockUsePerpsLiveOrders.mockReturnValue([btcOrder]); + mockUsePerpsLiveOrders.mockReturnValue({ orders: [btcOrder] }); const { getAllByText, getByTestId } = render( { error: 'Order not found', }); const btcOrder = { ...mockOrder, symbol: 'BTC' }; - mockUsePerpsLiveOrders.mockReturnValue([btcOrder]); + mockUsePerpsLiveOrders.mockReturnValue({ orders: [btcOrder] }); const { getAllByText, getByTestId } = render( { it('displays in-progress toast then error toast when order cancellation throws exception', async () => { mockCancelOrder.mockRejectedValue(new Error('Network error')); const btcOrder = { ...mockOrder, symbol: 'BTC' }; - mockUsePerpsLiveOrders.mockReturnValue([btcOrder]); + mockUsePerpsLiveOrders.mockReturnValue({ orders: [btcOrder] }); const { getAllByText, getByTestId } = render( { expect(mockOnOrderCancelled).not.toHaveBeenCalled(); }); }); + + // Note: Navigation tests (tutorial card, activity link) removed as these elements + // have been relocated to other components as part of the home screen refactor. + // See PR description: "Activity Link Relocation" and "Learn More Component" sections. }); diff --git a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx index 4aedce32ee52..cda60b790f4c 100644 --- a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx +++ b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx @@ -219,7 +219,7 @@ const PerpsMarketTabs: React.FC = ({ // Subscribe to data internally (marketStats moved to StatisticsTabContent to isolate price updates) const { positions } = usePerpsLivePositions({ throttleMs: 0 }); - const allOrders = usePerpsLiveOrders({ throttleMs: 0 }); + const { orders: allOrders } = usePerpsLiveOrders({ throttleMs: 0 }); const position = useMemo( () => positions.find((p) => p.coin === symbol) || null, diff --git a/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.styles.ts b/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.styles.ts new file mode 100644 index 000000000000..edb34ffbd54d --- /dev/null +++ b/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.styles.ts @@ -0,0 +1,16 @@ +import { StyleSheet } from 'react-native'; + +const styleSheet = () => + StyleSheet.create({ + container: { + marginBottom: 16, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 12, + }, + }); + +export default styleSheet; diff --git a/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.test.tsx b/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.test.tsx new file mode 100644 index 000000000000..0c594c770ca0 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.test.tsx @@ -0,0 +1,471 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { useNavigation } from '@react-navigation/native'; +import PerpsMarketTypeSection from './PerpsMarketTypeSection'; +import Routes from '../../../../../constants/navigation/Routes'; +import type { PerpsMarketData } from '../../controllers/types'; + +// Mock dependencies +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(), +})); + +jest.mock('../PerpsMarketList', () => { + const ReactNative = jest.requireActual('react-native'); + return { + __esModule: true, + default: jest.fn(({ markets, ListHeaderComponent, onMarketPress }) => ( + + {ListHeaderComponent && } + {markets.map((market: PerpsMarketData, index: number) => ( + onMarketPress(market)} + > + {market.symbol} + + ))} + + )), + }; +}); + +jest.mock('../PerpsRowSkeleton', () => { + const ReactNative = jest.requireActual('react-native'); + return { + __esModule: true, + default: jest.fn(({ count }) => ( + + )), + }; +}); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => { + if (key === 'perps.home.see_all') { + return 'See All'; + } + return key; + }), +})); + +const mockNavigate = jest.fn(); +const mockUseNavigation = useNavigation as jest.MockedFunction< + typeof useNavigation +>; + +describe('PerpsMarketTypeSection', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseNavigation.mockReturnValue({ + navigate: mockNavigate, + } as unknown as ReturnType); + }); + + const createMockMarket = (symbol: string): PerpsMarketData => ({ + symbol, + name: `${symbol} Market`, + maxLeverage: '50x', + price: '$50,000.00', + change24h: '+$2,600.00', + change24hPercent: '+5.2%', + volume: '$1,000,000', + fundingRate: 0.01, + }); + + const mockMarkets: PerpsMarketData[] = [ + createMockMarket('BTC'), + createMockMarket('ETH'), + createMockMarket('SOL'), + ]; + + describe('rendering', () => { + it('renders section with title and markets', () => { + const { getByText } = render( + , + ); + + expect(getByText('Crypto Markets')).toBeTruthy(); + }); + + it('renders "See All" link', () => { + const { getByText } = render( + , + ); + + expect(getByText('See All')).toBeTruthy(); + }); + + it('renders market list when markets are available', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('perps-market-list')).toBeTruthy(); + }); + + it('applies custom testID', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('custom-section')).toBeTruthy(); + }); + }); + + describe('loading state', () => { + it('renders skeleton when loading', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('skeleton-count-5')).toBeTruthy(); + }); + + it('renders section header during loading', () => { + const { getByText } = render( + , + ); + + expect(getByText('Crypto Markets')).toBeTruthy(); + expect(getByText('See All')).toBeTruthy(); + }); + + it('does not render market list when loading', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('perps-market-list')).toBeNull(); + }); + + it('transitions from loading to loaded state', () => { + const { getByTestId, queryByTestId, rerender } = render( + , + ); + + expect(getByTestId('skeleton-count-5')).toBeTruthy(); + expect(queryByTestId('perps-market-list')).toBeNull(); + + rerender( + , + ); + + expect(queryByTestId('skeleton-count-5')).toBeNull(); + expect(getByTestId('perps-market-list')).toBeTruthy(); + }); + }); + + describe('empty state', () => { + it('returns null when markets array is empty', () => { + const { queryByText } = render( + , + ); + + expect(queryByText('Crypto Markets')).toBeNull(); + }); + + it('does not render when markets are empty and not loading', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('section')).toBeNull(); + }); + }); + + describe('navigation', () => { + it('navigates to market list when "See All" is pressed', () => { + const { getByText } = render( + , + ); + + fireEvent.press(getByText('See All')); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_LIST, + params: { + defaultMarketTypeFilter: 'crypto', + }, + }); + }); + + it('passes correct market type for equity markets', () => { + const { getByText } = render( + , + ); + + fireEvent.press(getByText('See All')); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_LIST, + params: { + defaultMarketTypeFilter: 'equity', + }, + }); + }); + + it('passes correct market type for commodity markets', () => { + const { getByText } = render( + , + ); + + fireEvent.press(getByText('See All')); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_LIST, + params: { + defaultMarketTypeFilter: 'commodity', + }, + }); + }); + + it('navigates to market details when market is pressed', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('market-item-BTC')); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: { + market: mockMarkets[0], + }, + }); + }); + + it('handles multiple "See All" presses', () => { + const { getByText } = render( + , + ); + + const seeAllButton = getByText('See All'); + + fireEvent.press(seeAllButton); + fireEvent.press(seeAllButton); + + expect(mockNavigate).toHaveBeenCalledTimes(2); + }); + }); + + describe('sort configuration', () => { + it('uses default sort by volume', () => { + const { getByTestId } = render( + , + ); + + const marketList = getByTestId('perps-market-list'); + + expect(marketList).toBeTruthy(); + }); + + it('passes custom sort field to market list', () => { + const { getByTestId } = render( + , + ); + + const marketList = getByTestId('perps-market-list'); + + expect(marketList).toBeTruthy(); + }); + }); + + describe('market types', () => { + it('handles crypto market type', () => { + const { getByText } = render( + , + ); + + expect(getByText('Crypto')).toBeTruthy(); + }); + + it('handles equity market type', () => { + const { getByText } = render( + , + ); + + expect(getByText('Stocks')).toBeTruthy(); + }); + + it('handles commodity market type', () => { + const { getByText } = render( + , + ); + + expect(getByText('Commodities')).toBeTruthy(); + }); + + it('handles forex market type', () => { + const { getByText } = render( + , + ); + + expect(getByText('Forex')).toBeTruthy(); + }); + + it('handles all market type', () => { + const { getByText } = render( + , + ); + + expect(getByText('All Markets')).toBeTruthy(); + }); + }); + + describe('edge cases', () => { + it('handles single market', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('market-item-BTC')).toBeTruthy(); + }); + + it('handles large number of markets', () => { + const largeMarketList = Array.from({ length: 50 }, (_, i) => + createMockMarket(`COIN${i}`), + ); + + const { getByTestId } = render( + , + ); + + expect(getByTestId('perps-market-list')).toBeTruthy(); + }); + + it('maintains header visibility during loading', () => { + const { getByText, rerender } = render( + , + ); + + expect(getByText('Crypto Markets')).toBeTruthy(); + + rerender( + , + ); + + expect(getByText('Crypto Markets')).toBeTruthy(); + }); + }); +}); diff --git a/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.tsx b/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.tsx new file mode 100644 index 000000000000..3a31b9698944 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsMarketTypeSection/PerpsMarketTypeSection.tsx @@ -0,0 +1,134 @@ +import React, { useCallback } from 'react'; +import { View, TouchableOpacity } from 'react-native'; +import { useNavigation, type NavigationProp } from '@react-navigation/native'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; +import { strings } from '../../../../../../locales/i18n'; +import Routes from '../../../../../constants/navigation/Routes'; +import type { + PerpsMarketData, + PerpsNavigationParamList, +} from '../../controllers/types'; +import { useStyles } from '../../../../../component-library/hooks'; +import type { SortField } from '../../utils/sortMarkets'; +import PerpsMarketList from '../PerpsMarketList'; +import styleSheet from './PerpsMarketTypeSection.styles'; +import PerpsRowSkeleton from '../PerpsRowSkeleton'; + +export interface PerpsMarketTypeSectionProps { + /** Section title (e.g., "Perps", "Stocks", "Commodities") */ + title: string; + /** Markets to display */ + markets: PerpsMarketData[]; + /** Market type for filtering when "See All" is pressed */ + marketType: 'crypto' | 'equity' | 'commodity' | 'forex' | 'all'; + /** Sort field for market list */ + sortBy?: SortField; + /** Whether markets are loading */ + isLoading?: boolean; + /** Test ID for component */ + testID?: string; +} + +/** + * PerpsMarketTypeSection Component + * + * Generic reusable section for displaying markets grouped by type. + * Used for Perps (crypto), Stocks, Commodities, Forex sections on home screen. + * + * Features: + * - Shows section header with title and "See All" link + * - Displays market list with sorting + * - Skeleton loading state + * - Hides section entirely when no markets available + * - Navigates to full market list view on "See All" + * + * @example + * ```tsx + * + * ``` + */ +const PerpsMarketTypeSection: React.FC = ({ + title, + markets, + marketType, + sortBy = 'volume', + isLoading, + testID, +}) => { + const { styles } = useStyles(styleSheet, {}); + const navigation = useNavigation>(); + + const handleViewAll = useCallback(() => { + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_LIST, + params: { + defaultMarketTypeFilter: marketType, + }, + }); + }, [navigation, marketType]); + + const handleMarketPress = useCallback( + (market: PerpsMarketData) => { + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: { market }, + }); + }, + [navigation], + ); + + // Header component + const SectionHeader = useCallback( + () => ( + + + {title} + + + + {strings('perps.home.see_all')} + + + + ), + [styles.header, title, handleViewAll], + ); + + // Show skeleton during initial load + if (isLoading) { + return ( + + + + + ); + } + + // Hide section entirely when no markets (feature flag controlled) + if (markets.length === 0) { + return null; + } + + // Render market list + return ( + + + + ); +}; + +export default PerpsMarketTypeSection; diff --git a/app/components/UI/Perps/components/PerpsMarketTypeSection/index.ts b/app/components/UI/Perps/components/PerpsMarketTypeSection/index.ts new file mode 100644 index 000000000000..d1adc0c81b4a --- /dev/null +++ b/app/components/UI/Perps/components/PerpsMarketTypeSection/index.ts @@ -0,0 +1,2 @@ +export { default } from './PerpsMarketTypeSection'; +export type { PerpsMarketTypeSectionProps } from './PerpsMarketTypeSection'; diff --git a/app/components/UI/Perps/components/PerpsNavigationCard/PerpsNavigationCard.styles.ts b/app/components/UI/Perps/components/PerpsNavigationCard/PerpsNavigationCard.styles.ts new file mode 100644 index 000000000000..0af308556a48 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsNavigationCard/PerpsNavigationCard.styles.ts @@ -0,0 +1,31 @@ +import { StyleSheet } from 'react-native'; +import { Theme } from '../../../../../util/theme/models'; + +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + const { colors } = theme; + + return StyleSheet.create({ + container: { + marginTop: 24, + gap: 1, // Small gap creates separator line between grouped items + }, + itemWrapper: { + backgroundColor: colors.background.section, + overflow: 'hidden', + }, + itemFirst: { + borderTopLeftRadius: 12, + borderTopRightRadius: 12, + }, + itemLast: { + borderBottomLeftRadius: 12, + borderBottomRightRadius: 12, + }, + listItem: { + paddingVertical: 12, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/UI/Perps/components/PerpsNavigationCard/PerpsNavigationCard.test.tsx b/app/components/UI/Perps/components/PerpsNavigationCard/PerpsNavigationCard.test.tsx new file mode 100644 index 000000000000..90c4bbd1fd49 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsNavigationCard/PerpsNavigationCard.test.tsx @@ -0,0 +1,265 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { IconName } from '../../../../../component-library/components/Icons/Icon'; +import PerpsNavigationCard, { + type NavigationItem, +} from './PerpsNavigationCard'; + +jest.mock('../../../../hooks/useStyles', () => ({ + useStyles: () => ({ + styles: { + container: {}, + itemWrapper: {}, + itemFirst: {}, + itemLast: {}, + listItem: {}, + }, + }), +})); + +jest.mock('./PerpsNavigationCard.styles', () => ({})); + +jest.mock( + '../../../../../component-library/components/List/ListItem', + () => 'ListItem', +); +jest.mock( + '../../../../../component-library/components/List/ListItemColumn', + () => ({ + __esModule: true, + default: 'ListItemColumn', + WidthType: { + Fill: 'Fill', + Auto: 'Auto', + }, + }), +); +jest.mock('../../../../../component-library/components/Icons/Icon', () => ({ + __esModule: true, + default: 'Icon', + IconName: { + Setting: 'Setting', + Notification: 'Notification', + ArrowRight: 'ArrowRight', + }, + IconSize: { + Md: 'Md', + }, + IconColor: { + Default: 'Default', + Alternative: 'Alternative', + Primary: 'Primary', + }, +})); +jest.mock('../../../../../component-library/components/Texts/Text', () => ({ + __esModule: true, + default: 'Text', + TextVariant: { + BodyMD: 'BodyMD', + }, + TextColor: { + Default: 'Default', + }, +})); + +describe('PerpsNavigationCard', () => { + const mockOnPress = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders with single item', () => { + // Arrange + const items: NavigationItem[] = [ + { + label: 'Settings', + onPress: mockOnPress, + }, + ]; + + // Act + const { getByText } = render(); + + // Assert + expect(getByText('Settings')).toBeTruthy(); + }); + + it('renders with multiple items', () => { + // Arrange + const items: NavigationItem[] = [ + { + label: 'Settings', + onPress: mockOnPress, + }, + { + label: 'Notifications', + onPress: mockOnPress, + }, + { + label: 'Help', + onPress: mockOnPress, + }, + ]; + + // Act + const { getByText } = render(); + + // Assert + expect(getByText('Settings')).toBeTruthy(); + expect(getByText('Notifications')).toBeTruthy(); + expect(getByText('Help')).toBeTruthy(); + }); + + it('handles onPress callback when item is pressed', () => { + // Arrange + const items: NavigationItem[] = [ + { + label: 'Settings', + onPress: mockOnPress, + testID: 'settings-item', + }, + ]; + + // Act + const { getByTestId } = render(); + const item = getByTestId('settings-item'); + fireEvent.press(item); + + // Assert + expect(mockOnPress).toHaveBeenCalledTimes(1); + }); + + it('calls correct onPress for each item', () => { + // Arrange + const onPress1 = jest.fn(); + const onPress2 = jest.fn(); + const items: NavigationItem[] = [ + { + label: 'Settings', + onPress: onPress1, + testID: 'settings-item', + }, + { + label: 'Notifications', + onPress: onPress2, + testID: 'notifications-item', + }, + ]; + + // Act + const { getByTestId } = render(); + fireEvent.press(getByTestId('settings-item')); + fireEvent.press(getByTestId('notifications-item')); + + // Assert + expect(onPress1).toHaveBeenCalledTimes(1); + expect(onPress2).toHaveBeenCalledTimes(1); + }); + + it('renders with icon when iconName is provided', () => { + // Arrange + const items: NavigationItem[] = [ + { + label: 'Settings', + iconName: IconName.Setting, + onPress: mockOnPress, + }, + ]; + + // Act + const { UNSAFE_getAllByType } = render( + , + ); + const icons = UNSAFE_getAllByType('Icon' as never); + + // Assert - Should have both the left icon and the right arrow icon + expect(icons.length).toBeGreaterThanOrEqual(2); + }); + + it('renders without left icon when iconName is not provided', () => { + // Arrange + const items: NavigationItem[] = [ + { + label: 'Settings', + onPress: mockOnPress, + }, + ]; + + // Act + const { UNSAFE_getAllByType } = render( + , + ); + const icons = UNSAFE_getAllByType('Icon' as never); + + // Assert - Should have only the right arrow icon + expect(icons.length).toBe(1); + }); + + it('shows arrow icon by default', () => { + // Arrange + const items: NavigationItem[] = [ + { + label: 'Settings', + onPress: mockOnPress, + }, + ]; + + // Act + const { UNSAFE_getAllByType } = render( + , + ); + const icons = UNSAFE_getAllByType('Icon' as never); + + // Assert - Should render arrow icon + expect(icons.length).toBeGreaterThan(0); + }); + + it('hides arrow icon when showArrow is false', () => { + // Arrange + const items: NavigationItem[] = [ + { + label: 'Settings', + onPress: mockOnPress, + showArrow: false, + }, + ]; + + // Act + const { UNSAFE_queryAllByType } = render( + , + ); + const icons = UNSAFE_queryAllByType('Icon' as never); + + // Assert - Should not render any icons (no arrow, no left icon) + expect(icons.length).toBe(0); + }); + + it('renders empty list when items array is empty', () => { + // Arrange + const items: NavigationItem[] = []; + + // Act + const { queryByText } = render(); + + // Assert + expect(queryByText('Settings')).toBeNull(); + }); + + it('renders items with testID when provided', () => { + // Arrange + const items: NavigationItem[] = [ + { + label: 'Settings', + onPress: mockOnPress, + testID: 'custom-test-id', + }, + ]; + + // Act + const { getByTestId } = render(); + + // Assert + expect(getByTestId('custom-test-id')).toBeTruthy(); + }); +}); diff --git a/app/components/UI/Perps/components/PerpsNavigationCard/PerpsNavigationCard.tsx b/app/components/UI/Perps/components/PerpsNavigationCard/PerpsNavigationCard.tsx new file mode 100644 index 000000000000..2e4b79233e19 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsNavigationCard/PerpsNavigationCard.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { TouchableOpacity, View } from 'react-native'; +import ListItem from '../../../../../component-library/components/List/ListItem'; +import ListItemColumn, { + WidthType, +} from '../../../../../component-library/components/List/ListItemColumn'; +import Icon, { + IconColor, + IconName, + IconSize, +} from '../../../../../component-library/components/Icons/Icon'; +import Text, { + TextColor, + TextVariant, +} from '../../../../../component-library/components/Texts/Text'; +import { useStyles } from '../../../../hooks/useStyles'; +import styleSheet from './PerpsNavigationCard.styles'; + +/** + * Represents a single navigation item in the card + */ +export interface NavigationItem { + /** + * The label text to display + */ + label: string; + /** + * Optional icon to display on the left + */ + iconName?: IconName; + /** + * Optional flag to show/hide the right arrow icon (defaults to true) + */ + showArrow?: boolean; + /** + * Optional color for the right arrow icon (defaults to IconColor.Alternative) + */ + arrowColor?: IconColor; + /** + * Callback function when the item is pressed + */ + onPress: () => void; + /** + * Optional test ID for E2E testing + */ + testID?: string; +} + +interface PerpsNavigationCardProps { + /** + * Array of navigation items to display in the grouped card + */ + items: NavigationItem[]; +} + +/** + * A grouped navigation card component for Perps with iOS-style list appearance. + * Displays multiple navigation items in a single card with: + * - Rounded corners on first and last items + * - Thin separator lines between items (1px gap) + * - Consistent background color and styling + * + * Follows the ListItem design pattern: [Icon] Text [Arrow] + */ +const PerpsNavigationCard: React.FC = ({ items }) => { + const { styles } = useStyles(styleSheet, {}); + + return ( + + {items.map((item, index) => { + const isFirst = index === 0; + const isLast = index === items.length - 1; + const itemStyle = [ + styles.itemWrapper, + isFirst && styles.itemFirst, + isLast && styles.itemLast, + ]; + + return ( + + + + {item.iconName && ( + + )} + + + {item.label} + + + {(item.showArrow ?? true) && ( + + + + )} + + + + ); + })} + + ); +}; + +export default PerpsNavigationCard; diff --git a/app/components/UI/Perps/components/PerpsNavigationCard/index.ts b/app/components/UI/Perps/components/PerpsNavigationCard/index.ts new file mode 100644 index 000000000000..894e9fa2cebb --- /dev/null +++ b/app/components/UI/Perps/components/PerpsNavigationCard/index.ts @@ -0,0 +1 @@ +export { default } from './PerpsNavigationCard'; diff --git a/app/components/UI/Perps/components/PerpsOrderHeader/PerpsOrderHeader.test.tsx b/app/components/UI/Perps/components/PerpsOrderHeader/PerpsOrderHeader.test.tsx index 65b78385bb16..9c5134c1a053 100644 --- a/app/components/UI/Perps/components/PerpsOrderHeader/PerpsOrderHeader.test.tsx +++ b/app/components/UI/Perps/components/PerpsOrderHeader/PerpsOrderHeader.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; import { useNavigation } from '@react-navigation/native'; import PerpsOrderHeader from './PerpsOrderHeader'; +import { PerpsHomeViewSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; jest.mock('@react-navigation/native', () => ({ useNavigation: jest.fn(), @@ -74,7 +75,7 @@ describe('PerpsOrderHeader', () => { it('should handle navigation back', () => { const { getByTestId } = render(); - const backButton = getByTestId('back-button'); + const backButton = getByTestId(PerpsHomeViewSelectorsIDs.BACK_BUTTON); fireEvent.press(backButton); expect(mockGoBack).toHaveBeenCalled(); }); @@ -84,7 +85,7 @@ describe('PerpsOrderHeader', () => { const { getByTestId } = render( , ); - const backButton = getByTestId('back-button'); + const backButton = getByTestId(PerpsHomeViewSelectorsIDs.BACK_BUTTON); fireEvent.press(backButton); expect(mockOnBack).toHaveBeenCalled(); expect(mockGoBack).not.toHaveBeenCalled(); diff --git a/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.styles.ts b/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.styles.ts new file mode 100644 index 000000000000..9396c23f5ff7 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.styles.ts @@ -0,0 +1,54 @@ +import { StyleSheet } from 'react-native'; +import type { Theme } from '../../../../../util/theme/models'; + +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + const { colors } = theme; + + return StyleSheet.create({ + container: { + marginBottom: 16, + borderBottomWidth: 1, + borderBottomColor: colors.border.muted, + paddingBottom: 16, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, + paddingHorizontal: 16, + }, + activityItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 16, + }, + leftSection: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + iconContainer: { + marginRight: 12, + }, + activityInfo: { + flex: 1, + }, + activityType: { + marginBottom: 4, + }, + activityAmount: { + color: colors.text.alternative, + }, + rightSection: { + alignItems: 'flex-end', + }, + emptyText: { + paddingHorizontal: 16, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.test.tsx b/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.test.tsx new file mode 100644 index 000000000000..00a5bb8532d3 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.test.tsx @@ -0,0 +1,783 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react-native'; +import PerpsRecentActivityList from './PerpsRecentActivityList'; +import type { OrderFill } from '../../controllers/types'; +import Routes from '../../../../../constants/navigation/Routes'; +import { transformFillsToTransactions } from '../../utils/transactionTransforms'; +import { FillType } from '../../types/transactionHistory'; + +// Mock dependencies +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(() => ({ + navigate: jest.fn(), + })), +})); + +jest.mock('../../../../../component-library/hooks', () => ({ + useStyles: () => ({ + styles: { + container: {}, + header: {}, + activityItem: {}, + leftSection: {}, + iconContainer: {}, + activityInfo: {}, + activityType: {}, + activityAmount: {}, + rightSection: {}, + emptyText: {}, + }, + }), +})); + +jest.mock('../../../../../component-library/components/Texts/Text', () => { + const ReactLib = jest.requireActual('react'); + const { Text: ReactNativeText } = jest.requireActual('react-native'); + + const MockText = ({ + children, + ...props + }: { + children?: React.ReactNode; + [key: string]: unknown; + }) => ReactLib.createElement(ReactNativeText, props, children); + + return { + __esModule: true, + default: MockText, + TextVariant: { + HeadingSM: 'HeadingSM', + BodyMD: 'BodyMD', + BodyMDMedium: 'BodyMDMedium', + BodySM: 'BodySM', + }, + TextColor: { + Default: 'Default', + Alternative: 'Alternative', + Success: 'Success', + Error: 'Error', + }, + }; +}); + +jest.mock('../PerpsTokenLogo', () => { + const { View: RNView, Text: RNText } = jest.requireActual('react-native'); + return function MockPerpsTokenLogo({ + symbol, + size, + recyclingKey, + }: { + symbol: string; + size: number; + recyclingKey: string; + }) { + return ( + + {size} + {recyclingKey} + + ); + }; +}); + +jest.mock('../PerpsRowSkeleton', () => { + const { View: RNView } = jest.requireActual('react-native'); + return function MockPerpsRowSkeleton({ count }: { count: number }) { + return ; + }; +}); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const translations: Record = { + 'perps.home.recent_activity': 'Recent Activity', + 'perps.home.see_all': 'See All', + 'perps.home.loading': 'Loading...', + 'perps.home.no_activity': 'No recent activity', + }; + return translations[key] || key; + }, +})); + +jest.mock('../../utils/transactionTransforms', () => ({ + transformFillsToTransactions: jest.fn(), +})); + +describe('PerpsRecentActivityList', () => { + const mockNavigate = jest.fn(); + const mockTransformFillsToTransactions = jest.mocked( + transformFillsToTransactions, + ); + + const mockFills: OrderFill[] = [ + { + direction: 'Open Long', + orderId: 'order-1', + symbol: 'BTC', + side: 'buy', + size: '1.5', + price: '52000', + fee: '10.5', + timestamp: 1698700000000, + feeToken: 'USDC', + pnl: '0', + }, + { + direction: 'Close Long', + orderId: 'order-2', + symbol: 'ETH', + side: 'sell', + size: '2.0', + price: '3000', + fee: '5.0', + timestamp: 1698690000000, + feeToken: 'USDC', + pnl: '150', + }, + ]; + + const mockTransactions = [ + { + id: 'order-1', + type: 'trade' as const, + category: 'position_open' as const, + title: 'Opened long', + subtitle: '1.5 BTC', + timestamp: 1698700000000, + asset: 'BTC', + fill: { + shortTitle: 'Opened long', + amount: '-$10.50', + amountNumber: -10.5, + isPositive: false, + size: '1.5', + entryPrice: '52000', + pnl: '0', + fee: '10.5', + points: '0', + feeToken: 'USDC', + action: 'Opened', + fillType: FillType.Standard, + }, + }, + { + id: 'order-2', + type: 'trade' as const, + category: 'position_close' as const, + title: 'Closed long', + subtitle: '2.0 ETH', + timestamp: 1698690000000, + asset: 'ETH', + fill: { + shortTitle: 'Closed long', + amount: '+$145.00', + amountNumber: 145, + isPositive: true, + size: '2.0', + entryPrice: '3000', + pnl: '150', + fee: '5.0', + points: '0', + feeToken: 'USDC', + action: 'Closed', + fillType: FillType.Standard, + }, + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + const { useNavigation } = jest.requireMock('@react-navigation/native'); + useNavigation.mockReturnValue({ + navigate: mockNavigate, + }); + mockTransformFillsToTransactions.mockReturnValue(mockTransactions); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('Loading State', () => { + it('renders loading skeleton when isLoading is true', () => { + render(); + + expect(screen.getByTestId('perps-row-skeleton-3')).toBeOnTheScreen(); + }); + + it('renders header with title when loading', () => { + render(); + + expect(screen.getByText('Recent Activity')).toBeOnTheScreen(); + }); + + it('does not render See All button when loading', () => { + render(); + + expect(screen.queryByText('See All')).not.toBeOnTheScreen(); + }); + + it('does not render activity list when loading', () => { + render(); + + expect( + screen.queryByTestId('perps-token-logo-BTC'), + ).not.toBeOnTheScreen(); + expect( + screen.queryByTestId('perps-token-logo-ETH'), + ).not.toBeOnTheScreen(); + }); + }); + + describe('Empty State', () => { + it('renders empty message when fills array is empty', () => { + render(); + + expect(screen.getByText('No recent activity')).toBeOnTheScreen(); + }); + + it('renders header with title when empty', () => { + render(); + + expect(screen.getByText('Recent Activity')).toBeOnTheScreen(); + }); + + it('does not render See All button when empty', () => { + render(); + + expect(screen.queryByText('See All')).not.toBeOnTheScreen(); + }); + + it('does not call transformFillsToTransactions when empty', () => { + render(); + + expect(mockTransformFillsToTransactions).toHaveBeenCalledWith([]); + }); + }); + + describe('Component Rendering', () => { + it('renders list with fills', () => { + render(); + + expect(screen.getByText('Opened long')).toBeOnTheScreen(); + expect(screen.getByText('Closed long')).toBeOnTheScreen(); + }); + + it('renders header with title and See All button', () => { + render(); + + expect(screen.getByText('Recent Activity')).toBeOnTheScreen(); + expect(screen.getByText('See All')).toBeOnTheScreen(); + }); + + it('renders transaction subtitles correctly', () => { + render(); + + expect(screen.getByText('1.5 BTC')).toBeOnTheScreen(); + expect(screen.getByText('2.0 ETH')).toBeOnTheScreen(); + }); + + it('renders token logos for each transaction', () => { + render(); + + expect(screen.getByTestId('perps-token-logo-BTC')).toBeOnTheScreen(); + expect(screen.getByTestId('perps-token-logo-ETH')).toBeOnTheScreen(); + }); + + it('renders fill amounts correctly', () => { + render(); + + expect(screen.getByText('-$10.50')).toBeOnTheScreen(); + expect(screen.getByText('+$145.00')).toBeOnTheScreen(); + }); + + it('calls transformFillsToTransactions with provided fills', () => { + render(); + + expect(mockTransformFillsToTransactions).toHaveBeenCalledTimes(1); + expect(mockTransformFillsToTransactions).toHaveBeenCalledWith(mockFills); + }); + + it('uses default icon size when not provided', () => { + render(); + + const iconSizes = screen.getAllByTestId('logo-size'); + expect(iconSizes[0]).toHaveTextContent('40'); + }); + + it('uses custom icon size when provided', () => { + render(); + + const iconSizes = screen.getAllByTestId('logo-size'); + expect(iconSizes[0]).toHaveTextContent('40'); + }); + }); + + describe('Navigation Handling', () => { + it('navigates to transactions view when See All is pressed', () => { + render(); + + const seeAllButton = screen.getByText('See All'); + fireEvent.press(seeAllButton); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW, { + screen: Routes.TRANSACTIONS_VIEW, + params: { redirectToPerpsTransactions: true }, + }); + }); + + it('navigates to market details when transaction item is pressed', () => { + render(); + + const transactionItem = screen.getByText('Opened long'); + fireEvent.press(transactionItem.parent?.parent || transactionItem); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: { + market: { symbol: 'BTC', name: 'BTC' }, + }, + }); + }); + + it('navigates with correct market data for different transactions', () => { + render(); + + const ethTransaction = screen.getByText('Closed long'); + fireEvent.press(ethTransaction.parent?.parent || ethTransaction); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: { + market: { symbol: 'ETH', name: 'ETH' }, + }, + }); + }); + + it('handles multiple presses on See All button', () => { + render(); + + const seeAllButton = screen.getByText('See All'); + fireEvent.press(seeAllButton); + fireEvent.press(seeAllButton); + + expect(mockNavigate).toHaveBeenCalledTimes(2); + }); + }); + + describe('Transaction Display', () => { + it('renders transactions without fill data correctly', () => { + const transactionsWithoutFill = [ + { + id: 'order-3', + type: 'trade' as const, + category: 'position_open' as const, + title: 'Opened long', + subtitle: '1.0 SOL', + timestamp: 1698680000000, + asset: 'SOL', + fill: undefined, + }, + ]; + mockTransformFillsToTransactions.mockReturnValueOnce( + transactionsWithoutFill, + ); + + render(); + + expect(screen.getByText('Opened long')).toBeOnTheScreen(); + expect(screen.getByText('1.0 SOL')).toBeOnTheScreen(); + }); + + it('does not render amount when fill is undefined', () => { + const transactionsWithoutFill = [ + { + id: 'order-3', + type: 'trade' as const, + category: 'position_open' as const, + title: 'Opened long', + subtitle: '1.0 SOL', + timestamp: 1698680000000, + asset: 'SOL', + fill: undefined, + }, + ]; + mockTransformFillsToTransactions.mockReturnValueOnce( + transactionsWithoutFill, + ); + + render(); + + expect(screen.queryByText(/\$/)).not.toBeOnTheScreen(); + }); + + it('renders subtitle when provided', () => { + render(); + + expect(screen.getByText('1.5 BTC')).toBeOnTheScreen(); + expect(screen.getByText('2.0 ETH')).toBeOnTheScreen(); + }); + + it('does not render subtitle section when subtitle is undefined', () => { + const transactionsWithoutSubtitle = [ + { + id: 'order-3', + type: 'trade' as const, + category: 'position_open' as const, + title: 'Opened long', + subtitle: '', + timestamp: 1698680000000, + asset: 'SOL', + fill: { + shortTitle: 'Opened long', + amount: '-$10.00', + amountNumber: -10, + isPositive: false, + size: '1.0', + entryPrice: '150', + pnl: '0', + fee: '10.0', + points: '0', + feeToken: 'USDC', + action: 'Opened', + fillType: FillType.Standard, + }, + }, + ]; + mockTransformFillsToTransactions.mockReturnValueOnce( + transactionsWithoutSubtitle, + ); + + render(); + + expect(screen.getByText('Opened long')).toBeOnTheScreen(); + expect(screen.getByText('-$10.00')).toBeOnTheScreen(); + }); + }); + + describe('Edge Cases', () => { + it('handles single fill', () => { + const singleFill = [mockFills[0]]; + const singleTransaction = [mockTransactions[0]]; + mockTransformFillsToTransactions.mockReturnValueOnce(singleTransaction); + + render(); + + expect(screen.getByText('Opened long')).toBeOnTheScreen(); + expect(screen.queryByText('Closed long')).not.toBeOnTheScreen(); + }); + + it('handles very long list of fills', () => { + const longFillList: OrderFill[] = Array.from({ length: 50 }, (_, i) => ({ + direction: 'Open Long', + orderId: `order-${i}`, + symbol: `TOKEN${i}`, + side: 'buy', + size: '1.0', + price: '100', + fee: '1.0', + timestamp: 1698700000000 + i, + feeToken: 'USDC', + pnl: '0', + })); + + const longTransactionList = longFillList.map((_fill, i) => ({ + id: `order-${i}`, + type: 'trade' as const, + category: 'position_open' as const, + title: 'Opened long', + subtitle: `1.0 TOKEN${i}`, + timestamp: 1698700000000 + i, + asset: `TOKEN${i}`, + fill: { + shortTitle: 'Opened long', + amount: '-$1.00', + amountNumber: -1, + isPositive: false, + size: '1.0', + entryPrice: '100', + pnl: '0', + fee: '1.0', + points: '0', + feeToken: 'USDC', + action: 'Opened', + fillType: FillType.Standard, + }, + })); + + mockTransformFillsToTransactions.mockReturnValueOnce(longTransactionList); + + render(); + + expect(screen.getByText('Recent Activity')).toBeOnTheScreen(); + }); + + it('handles fills with empty orderId gracefully', () => { + const fillsWithEmptyId: OrderFill[] = [ + { + direction: 'Open Long', + orderId: '', + symbol: 'BTC', + side: 'buy', + size: '1.0', + price: '52000', + fee: '10.0', + timestamp: 1698700000000, + feeToken: 'USDC', + pnl: '0', + }, + ]; + + const transactionsWithGeneratedId = [ + { + id: 'fill-1698700000000', + type: 'trade' as const, + category: 'position_open' as const, + title: 'Opened long', + subtitle: '1.0 BTC', + timestamp: 1698700000000, + asset: 'BTC', + fill: { + shortTitle: 'Opened long', + amount: '-$10.00', + amountNumber: -10, + isPositive: false, + size: '1.0', + entryPrice: '52000', + pnl: '0', + fee: '10.0', + points: '0', + feeToken: 'USDC', + action: 'Opened', + fillType: FillType.Standard, + }, + }, + ]; + + mockTransformFillsToTransactions.mockReturnValueOnce( + transactionsWithGeneratedId, + ); + + render(); + + expect(screen.getByText('Opened long')).toBeOnTheScreen(); + }); + + it('renders recycling key correctly for token logos', () => { + render(); + + const logoKeys = screen.getAllByTestId('logo-key'); + expect(logoKeys[0]).toHaveTextContent('BTC-order-1'); + expect(logoKeys[1]).toHaveTextContent('ETH-order-2'); + }); + + it('handles fills with special characters in symbols', () => { + const specialFills: OrderFill[] = [ + { + direction: 'Open Long', + orderId: 'order-special', + symbol: 'BTC-USD', + side: 'buy', + size: '1.0', + price: '52000', + fee: '10.0', + timestamp: 1698700000000, + feeToken: 'USDC', + pnl: '0', + }, + ]; + + const specialTransactions = [ + { + id: 'order-special', + type: 'trade' as const, + category: 'position_open' as const, + title: 'Opened long', + subtitle: '1.0 BTC-USD', + timestamp: 1698700000000, + asset: 'BTC-USD', + fill: { + shortTitle: 'Opened long', + amount: '-$10.00', + amountNumber: -10, + isPositive: false, + size: '1.0', + entryPrice: '52000', + pnl: '0', + fee: '10.0', + points: '0', + feeToken: 'USDC', + action: 'Opened', + fillType: FillType.Standard, + }, + }, + ]; + + mockTransformFillsToTransactions.mockReturnValueOnce(specialTransactions); + + render(); + + expect(screen.getByTestId('perps-token-logo-BTC-USD')).toBeOnTheScreen(); + }); + }); + + describe('Data Updates', () => { + it('updates when fills prop changes', () => { + const { rerender } = render( + , + ); + + expect(screen.getByText('Opened long')).toBeOnTheScreen(); + expect(screen.getByText('Closed long')).toBeOnTheScreen(); + + const newFills: OrderFill[] = [ + { + direction: 'Open Short', + orderId: 'order-3', + symbol: 'SOL', + side: 'sell', + size: '10.0', + price: '150', + fee: '2.0', + timestamp: 1698710000000, + feeToken: 'USDC', + pnl: '0', + }, + ]; + + const newTransactions = [ + { + id: 'order-3', + type: 'trade' as const, + category: 'position_open' as const, + title: 'Opened short', + subtitle: '10.0 SOL', + timestamp: 1698710000000, + asset: 'SOL', + fill: { + shortTitle: 'Opened short', + amount: '-$2.00', + amountNumber: -2, + isPositive: false, + size: '10.0', + entryPrice: '150', + pnl: '0', + fee: '2.0', + points: '0', + feeToken: 'USDC', + action: 'Opened', + fillType: FillType.Standard, + }, + }, + ]; + + mockTransformFillsToTransactions.mockReturnValueOnce(newTransactions); + + rerender(); + + expect(screen.queryByText('Opened long')).not.toBeOnTheScreen(); + expect(screen.queryByText('Closed long')).not.toBeOnTheScreen(); + expect(screen.getByText('Opened short')).toBeOnTheScreen(); + }); + + it('transitions from empty state to fills', () => { + const { rerender } = render(); + + expect(screen.getByText('No recent activity')).toBeOnTheScreen(); + + rerender(); + + expect(screen.queryByText('No recent activity')).not.toBeOnTheScreen(); + expect(screen.getByText('Opened long')).toBeOnTheScreen(); + }); + + it('transitions from fills to empty state', () => { + const { rerender } = render( + , + ); + + expect(screen.getByText('Opened long')).toBeOnTheScreen(); + + mockTransformFillsToTransactions.mockReturnValueOnce([]); + + rerender(); + + expect(screen.queryByText('Opened long')).not.toBeOnTheScreen(); + expect(screen.getByText('No recent activity')).toBeOnTheScreen(); + }); + + it('transitions from loading to fills', () => { + const { rerender } = render( + , + ); + + expect(screen.getByTestId('perps-row-skeleton-3')).toBeOnTheScreen(); + + rerender(); + + expect( + screen.queryByTestId('perps-row-skeleton-3'), + ).not.toBeOnTheScreen(); + expect(screen.getByText('Opened long')).toBeOnTheScreen(); + }); + + it('updates icon size correctly', () => { + const { rerender } = render( + , + ); + + let iconSizes = screen.getAllByTestId('logo-size'); + expect(iconSizes[0]).toHaveTextContent('32'); + + rerender(); + + iconSizes = screen.getAllByTestId('logo-size'); + expect(iconSizes[0]).toHaveTextContent('48'); + }); + }); + + describe('Component Lifecycle', () => { + it('does not throw error on unmount', () => { + const { unmount } = render(); + + expect(() => unmount()).not.toThrow(); + }); + + it('cleans up properly when remounted with different props', () => { + const { rerender } = render( + , + ); + + expect(screen.getByText('Opened long')).toBeOnTheScreen(); + + mockTransformFillsToTransactions.mockReturnValueOnce([]); + + rerender(); + + expect(screen.getByText('No recent activity')).toBeOnTheScreen(); + + rerender(); + + expect(screen.getByText('Opened long')).toBeOnTheScreen(); + }); + }); + + describe('FlatList Configuration', () => { + it('uses transaction id as key extractor', () => { + render(); + + // Verify transactions are rendered with correct data + expect(screen.getByText('Opened long')).toBeOnTheScreen(); + expect(screen.getByText('Closed long')).toBeOnTheScreen(); + }); + + it('disables scroll on FlatList', () => { + const { root } = render(); + + // FlatList is rendered with scrollEnabled={false} + expect(root).toBeTruthy(); + }); + }); +}); diff --git a/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.tsx b/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.tsx new file mode 100644 index 000000000000..2bdec8e439f8 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsRecentActivityList/PerpsRecentActivityList.tsx @@ -0,0 +1,170 @@ +import React, { useCallback } from 'react'; +import { View, TouchableOpacity, FlatList } from 'react-native'; +import { useNavigation, type NavigationProp } from '@react-navigation/native'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; +import { strings } from '../../../../../../locales/i18n'; +import Routes from '../../../../../constants/navigation/Routes'; +import type { + OrderFill, + PerpsNavigationParamList, +} from '../../controllers/types'; +import type { PerpsTransaction } from '../../types/transactionHistory'; +import PerpsTokenLogo from '../PerpsTokenLogo'; +import { useStyles } from '../../../../../component-library/hooks'; +import styleSheet from './PerpsRecentActivityList.styles'; +import { transformFillsToTransactions } from '../../utils/transactionTransforms'; +import { HOME_SCREEN_CONFIG } from '../../constants/perpsConfig'; +import PerpsRowSkeleton from '../PerpsRowSkeleton'; + +interface PerpsRecentActivityListProps { + fills: OrderFill[]; + isLoading?: boolean; + iconSize?: number; +} + +const PerpsRecentActivityList: React.FC = ({ + fills, + isLoading, + iconSize = HOME_SCREEN_CONFIG.DEFAULT_ICON_SIZE, +}) => { + const { styles } = useStyles(styleSheet, {}); + const navigation = useNavigation>(); + + const handleSeeAll = useCallback(() => { + navigation.navigate(Routes.TRANSACTIONS_VIEW, { + screen: Routes.TRANSACTIONS_VIEW, + params: { redirectToPerpsTransactions: true }, + }); + }, [navigation]); + + const handleTransactionPress = useCallback( + (transaction: PerpsTransaction) => { + // Navigate to appropriate transaction detail view + if (transaction.fill) { + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: { + market: { symbol: transaction.asset, name: transaction.asset }, + }, + }); + } + }, + [navigation], + ); + + // Transform fills to transactions for display + const transactions = transformFillsToTransactions(fills); + + const renderItem = useCallback( + ({ + item, + }: { + item: ReturnType[0]; + }) => { + const isPositive = item.fill?.isPositive ?? false; + const pnlColor = isPositive ? TextColor.Success : TextColor.Error; + + return ( + handleTransactionPress(item)} + activeOpacity={0.7} + > + + + + + + + {item.title} + + {!!item.subtitle && ( + + {item.subtitle} + + )} + + + + {item.fill && ( + + {item.fill.amount} + + )} + + + ); + }, + [styles, handleTransactionPress, iconSize], + ); + + if (isLoading) { + return ( + + + + {strings('perps.home.recent_activity')} + + + + + ); + } + + if (fills.length === 0) { + return ( + + + + {strings('perps.home.recent_activity')} + + + + {strings('perps.home.no_activity')} + + + ); + } + + return ( + + + + {strings('perps.home.recent_activity')} + + + + {strings('perps.home.see_all')} + + + + + `${item.id || index}`} + scrollEnabled={false} + /> + + ); +}; + +export default PerpsRecentActivityList; diff --git a/app/components/UI/Perps/components/PerpsRowSkeleton/PerpsRowSkeleton.test.tsx b/app/components/UI/Perps/components/PerpsRowSkeleton/PerpsRowSkeleton.test.tsx new file mode 100644 index 000000000000..f613cf2ce21b --- /dev/null +++ b/app/components/UI/Perps/components/PerpsRowSkeleton/PerpsRowSkeleton.test.tsx @@ -0,0 +1,247 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import PerpsRowSkeleton from './PerpsRowSkeleton'; +import { HOME_SCREEN_CONFIG } from '../../constants/perpsConfig'; + +// Mock Skeleton component +jest.mock( + '../../../../../component-library/components/Skeleton/Skeleton', + () => { + const ReactNative = jest.requireActual('react-native'); + return { + __esModule: true, + default: jest.fn(({ height, width, style, testID }) => ( + + )), + }; + }, +); + +describe('PerpsRowSkeleton', () => { + describe('rendering', () => { + it('renders single skeleton row by default', () => { + const { getAllByLabelText } = render(); + + const skeletons = getAllByLabelText(/skeleton-/); + + // 4 skeletons per row: icon, primary text, secondary text, value, label + expect(skeletons).toHaveLength(5); + }); + + it('renders correct icon skeleton with default size', () => { + const { getByLabelText } = render(); + + const iconSkeleton = getByLabelText( + `skeleton-${HOME_SCREEN_CONFIG.DEFAULT_ICON_SIZE}x${HOME_SCREEN_CONFIG.DEFAULT_ICON_SIZE}`, + ); + + expect(iconSkeleton).toBeTruthy(); + }); + + it('renders primary text skeleton', () => { + const { getByLabelText } = render(); + + const primaryTextSkeleton = getByLabelText('skeleton-16x70%'); + + expect(primaryTextSkeleton).toBeTruthy(); + }); + + it('renders secondary text skeleton', () => { + const { getByLabelText } = render(); + + const secondaryTextSkeleton = getByLabelText('skeleton-14x50%'); + + expect(secondaryTextSkeleton).toBeTruthy(); + }); + + it('renders value text skeleton', () => { + const { getByLabelText } = render(); + + const valueTextSkeleton = getByLabelText('skeleton-16x80'); + + expect(valueTextSkeleton).toBeTruthy(); + }); + + it('renders label text skeleton', () => { + const { getByLabelText } = render(); + + const labelTextSkeleton = getByLabelText('skeleton-14x60'); + + expect(labelTextSkeleton).toBeTruthy(); + }); + }); + + describe('count prop', () => { + it('renders multiple skeleton rows when count is specified', () => { + const { getAllByLabelText } = render(); + + const skeletons = getAllByLabelText(/skeleton-/); + + // 5 skeletons per row * 3 rows = 15 + expect(skeletons).toHaveLength(15); + }); + + it('renders no rows when count is zero', () => { + const { queryAllByLabelText } = render(); + + const skeletons = queryAllByLabelText(/skeleton-/); + + expect(skeletons).toHaveLength(0); + }); + + it('renders five skeleton rows', () => { + const { getAllByLabelText } = render(); + + const skeletons = getAllByLabelText(/skeleton-/); + + // 5 skeletons per row * 5 rows = 25 + expect(skeletons).toHaveLength(25); + }); + + it('renders ten skeleton rows', () => { + const { getAllByLabelText } = render(); + + const skeletons = getAllByLabelText(/skeleton-/); + + // 5 skeletons per row * 10 rows = 50 + expect(skeletons).toHaveLength(50); + }); + }); + + describe('iconSize prop', () => { + it('renders icon skeleton with custom size', () => { + const customSize = 48; + + const { getByLabelText } = render( + , + ); + + const iconSkeleton = getByLabelText( + `skeleton-${customSize}x${customSize}`, + ); + + expect(iconSkeleton).toBeTruthy(); + }); + + it('renders small icon skeleton', () => { + const { getByLabelText } = render(); + + const iconSkeleton = getByLabelText('skeleton-24x24'); + + expect(iconSkeleton).toBeTruthy(); + }); + + it('renders large icon skeleton', () => { + const { getByLabelText } = render(); + + const iconSkeleton = getByLabelText('skeleton-64x64'); + + expect(iconSkeleton).toBeTruthy(); + }); + }); + + describe('style prop', () => { + it('renders with custom style prop', () => { + const customStyle = { backgroundColor: 'red', padding: 20 }; + + const { getAllByLabelText } = render( + , + ); + + // Verify the component renders successfully with style prop + const skeletons = getAllByLabelText(/skeleton-/); + expect(skeletons).toHaveLength(5); + }); + }); + + describe('edge cases', () => { + it('handles negative count by rendering no rows', () => { + const { queryAllByLabelText } = render(); + + const skeletons = queryAllByLabelText(/skeleton-/); + + expect(skeletons).toHaveLength(0); + }); + + it('handles fractional count by truncating to integer', () => { + const { getAllByLabelText } = render(); + + const skeletons = getAllByLabelText(/skeleton-/); + + // 5 skeletons per row * 2 rows = 10 (truncates to 2) + expect(skeletons).toHaveLength(10); + }); + + it('handles zero icon size', () => { + const { getByLabelText } = render(); + + const iconSkeleton = getByLabelText('skeleton-0x0'); + + expect(iconSkeleton).toBeTruthy(); + }); + + it('combines count and iconSize props', () => { + const { getAllByLabelText } = render( + , + ); + + const allSkeletons = getAllByLabelText(/skeleton-/); + const iconSkeletons = getAllByLabelText('skeleton-50x50'); + + // Total: 5 skeletons per row * 2 rows = 10 + expect(allSkeletons).toHaveLength(10); + // Icons: 1 icon per row * 2 rows = 2 + expect(iconSkeletons).toHaveLength(2); + }); + }); + + describe('structure', () => { + it('renders rows with correct container structure', () => { + const { getAllByTestId } = render(); + + const skeletons = getAllByTestId('skeleton'); + + // 5 skeletons per row * 2 rows = 10 + expect(skeletons.length).toBe(10); + }); + }); + + describe('accessibility', () => { + it('renders skeletons with accessible labels', () => { + const { getAllByLabelText } = render(); + + const skeletons = getAllByLabelText(/skeleton-/); + + skeletons.forEach((skeleton) => { + expect(skeleton.props.accessibilityLabel).toMatch(/skeleton-\d+x/); + }); + }); + }); + + describe('consistent sizing', () => { + it('maintains consistent text skeleton dimensions across multiple rows', () => { + const { getAllByLabelText } = render(); + + const primaryTextSkeletons = getAllByLabelText('skeleton-16x70%'); + const secondaryTextSkeletons = getAllByLabelText('skeleton-14x50%'); + + // Each row has one of each + expect(primaryTextSkeletons).toHaveLength(3); + expect(secondaryTextSkeletons).toHaveLength(3); + }); + + it('maintains consistent right section skeleton dimensions', () => { + const { getAllByLabelText } = render(); + + const valueSkeletons = getAllByLabelText('skeleton-16x80'); + const labelSkeletons = getAllByLabelText('skeleton-14x60'); + + expect(valueSkeletons).toHaveLength(3); + expect(labelSkeletons).toHaveLength(3); + }); + }); +}); diff --git a/app/components/UI/Perps/components/PerpsRowSkeleton/PerpsRowSkeleton.tsx b/app/components/UI/Perps/components/PerpsRowSkeleton/PerpsRowSkeleton.tsx new file mode 100644 index 000000000000..5a0cec3f07f8 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsRowSkeleton/PerpsRowSkeleton.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import { View, StyleSheet, type ViewStyle } from 'react-native'; +import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; +import { HOME_SCREEN_CONFIG } from '../../constants/perpsConfig'; + +export interface PerpsRowSkeletonProps { + /** + * Number of skeleton rows to render + */ + count?: number; + /** + * Size of the icon skeleton (defaults to HOME_SCREEN_CONFIG.DEFAULT_ICON_SIZE) + */ + iconSize?: number; + /** + * Optional style for the container + */ + style?: ViewStyle; +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 16, + }, + leftSection: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + iconSkeleton: { + borderRadius: 100, // Fully circular + marginRight: 12, + }, + textInfo: { + flex: 1, + gap: 6, + }, + primaryText: { + marginBottom: 0, + }, + secondaryText: { + marginBottom: 0, + }, + rightSection: { + alignItems: 'flex-end', + gap: 6, + }, + valueText: { + marginBottom: 0, + }, + labelText: { + marginBottom: 0, + }, +}); + +/** + * PerpsRowSkeleton Component + * + * A flexible skeleton loader for Perps rows (positions, orders, markets, activity). + * Mimics the structure of PerpsCard and PerpsMarketRowItem. + * + * @example + * ```tsx + * + * ``` + */ +const PerpsRowSkeleton: React.FC = ({ + count = 1, + iconSize = HOME_SCREEN_CONFIG.DEFAULT_ICON_SIZE, + style, +}) => { + // Generate array for count + const rows = Array.from({ length: count }, (_, i) => i); + + return ( + <> + {rows.map((index) => ( + + {/* Left section: Icon + Info */} + + {/* Circular icon skeleton */} + + + {/* Text info */} + + {/* Primary text (larger) */} + + + {/* Secondary text (smaller) */} + + + + + {/* Right section: Value + Label */} + + {/* Value text */} + + + {/* Label/change text */} + + + + ))} + + ); +}; + +export default PerpsRowSkeleton; diff --git a/app/components/UI/Perps/components/PerpsRowSkeleton/index.ts b/app/components/UI/Perps/components/PerpsRowSkeleton/index.ts new file mode 100644 index 000000000000..6b529150f01e --- /dev/null +++ b/app/components/UI/Perps/components/PerpsRowSkeleton/index.ts @@ -0,0 +1,2 @@ +export { default } from './PerpsRowSkeleton'; +export type { PerpsRowSkeletonProps } from './PerpsRowSkeleton'; diff --git a/app/components/UI/Perps/components/PerpsTokenLogo/PerpsTokenLogo.test.tsx b/app/components/UI/Perps/components/PerpsTokenLogo/PerpsTokenLogo.test.tsx index 6da07f549a81..72ff2ac229e7 100644 --- a/app/components/UI/Perps/components/PerpsTokenLogo/PerpsTokenLogo.test.tsx +++ b/app/components/UI/Perps/components/PerpsTokenLogo/PerpsTokenLogo.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, waitFor, act } from '@testing-library/react-native'; +import { render, act } from '@testing-library/react-native'; import { Image } from 'expo-image'; import PerpsTokenLogo from './PerpsTokenLogo'; @@ -13,31 +13,8 @@ jest.mock('../../../../../util/theme', () => ({ }), })); -// Store mocked components in variables to avoid require() in tests -let mockAvatar: jest.Mock; - -jest.mock('../../../../../component-library/components/Avatars/Avatar', () => { - /* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */ - const ReactModule = require('react'); - const { View: ViewComponent } = require('react-native'); - /* eslint-enable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */ - mockAvatar = jest.fn(({ name, testID }) => - ReactModule.createElement(ViewComponent, { - testID: testID || `avatar-${name}`, - }), - ); - return { - __esModule: true, - default: mockAvatar, - AvatarSize: { - Md: 'Md', - Lg: 'Lg', - }, - AvatarVariant: { - Token: 'Token', - }, - }; -}); +// Note: Avatar component is no longer used in PerpsTokenLogo +// The component now uses a simple text-based fallback instead describe('PerpsTokenLogo', () => { beforeEach(() => { @@ -92,19 +69,15 @@ describe('PerpsTokenLogo', () => { ); }); - it('shows Avatar fallback when no symbol is provided', async () => { - render(); + it('shows text fallback when no symbol is provided', () => { + const { getByTestId } = render( + , + ); - // Should render Avatar fallback immediately since empty symbol triggers hasError - await waitFor(() => { - expect(mockAvatar).toHaveBeenCalledWith( - expect.objectContaining({ - name: '', - variant: 'Token', - }), - expect.anything(), - ); - }); + // Should render text fallback immediately since empty symbol triggers fallback + const container = getByTestId('no-symbol'); + expect(container).toBeTruthy(); + // Empty symbol results in empty fallback text }); it('renders Image component with correct URI', () => { @@ -124,9 +97,9 @@ describe('PerpsTokenLogo', () => { ); }); - it('handles image error by showing Avatar fallback', async () => { + it('handles image error by showing text fallback', async () => { // Arrange - const { UNSAFE_getByType, rerender } = render( + const { UNSAFE_getByType, getByTestId } = render( , ); @@ -137,54 +110,55 @@ describe('PerpsTokenLogo', () => { image.props.onError(); }); - // Force re-render to see the fallback - rerender(); - - // Assert - await waitFor(() => { - expect(mockAvatar).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'FAIL', - variant: 'Token', - }), - expect.anything(), - ); - }); + // Assert - Should show text fallback after error + const container = getByTestId('image-error'); + expect(container).toBeTruthy(); + // Text fallback should show "FA" for "FAIL" }); - it('correctly determines avatar size based on numeric size prop', () => { - // Test size 32 -> AvatarSize.Md - const { rerender } = render( + it('correctly applies size prop to container', () => { + // Test size 32 + const { rerender, getByTestId } = render( , ); - expect(mockAvatar).toHaveBeenCalledWith( - expect.objectContaining({ - size: 'Md', - }), - expect.anything(), + const container32 = getByTestId('size-32'); + expect(container32.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + width: 32, + height: 32, + borderRadius: 16, + }), + ]), ); - // Test size 40 -> AvatarSize.Lg - mockAvatar.mockClear(); + // Test size 40 rerender(); - expect(mockAvatar).toHaveBeenCalledWith( - expect.objectContaining({ - size: 'Lg', - }), - expect.anything(), + const container40 = getByTestId('size-40'); + expect(container40.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + width: 40, + height: 40, + borderRadius: 20, + }), + ]), ); - // Test other size -> AvatarSize.Md (default) - mockAvatar.mockClear(); + // Test size 24 rerender(); - expect(mockAvatar).toHaveBeenCalledWith( - expect.objectContaining({ - size: 'Md', - }), - expect.anything(), + const container24 = getByTestId('size-24'); + expect(container24.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + width: 24, + height: 24, + borderRadius: 12, + }), + ]), ); }); diff --git a/app/components/UI/Perps/components/PerpsTokenLogo/PerpsTokenLogo.tsx b/app/components/UI/Perps/components/PerpsTokenLogo/PerpsTokenLogo.tsx index 9ff40272d8ad..5d1661dc9dd8 100644 --- a/app/components/UI/Perps/components/PerpsTokenLogo/PerpsTokenLogo.tsx +++ b/app/components/UI/Perps/components/PerpsTokenLogo/PerpsTokenLogo.tsx @@ -1,10 +1,9 @@ import React, { memo, useMemo, useState, useEffect } from 'react'; import { View, ActivityIndicator, ViewStyle, ImageStyle } from 'react-native'; -import Avatar, { - AvatarSize, - AvatarVariant, -} from '../../../../../component-library/components/Avatars/Avatar'; import { useTheme } from '../../../../../util/theme'; +import Text, { + TextVariant, +} from '../../../../../component-library/components/Texts/Text'; import { PerpsTokenLogoProps } from './PerpsTokenLogo.types'; import { Image } from 'expo-image'; import { HYPERLIQUID_ASSET_ICONS_BASE_URL } from '../../constants/hyperLiquidConfig'; @@ -13,6 +12,7 @@ import { ASSETS_REQUIRING_DARK_BG, K_PREFIX_ASSETS, } from './PerpsAssetBgConfig'; +import { getPerpsDisplaySymbol } from '../../utils/marketUtils'; const PerpsTokenLogo: React.FC = ({ symbol, @@ -82,6 +82,15 @@ const PerpsTokenLogo: React.FC = ({ [size], ); + const fallbackTextStyle = useMemo( + () => ({ + fontSize: Math.round(size * 0.4), + fontWeight: '600' as const, + color: colors.text.default, + }), + [size, colors.text.default], + ); + // SVG URL - expo-image handles SVG rendering properly const imageUri = useMemo(() => { if (!symbol) return null; @@ -109,22 +118,19 @@ const PerpsTokenLogo: React.FC = ({ setHasError(true); }; - // Show Avatar fallback if no symbol or error + // Show custom two-letter fallback if no symbol or error if (!symbol || !imageUri || hasError) { + // Extract display symbol (e.g., "TSLA" from "xyz:TSLA") + const displaySymbol = getPerpsDisplaySymbol(symbol || ''); + // Get first 2 letters, uppercase + const fallbackText = displaySymbol.substring(0, 2).toUpperCase(); + return ( - + + + {fallbackText} + + ); } diff --git a/app/components/UI/Perps/components/PerpsTransactionItem/PerpsTransactionItem.tsx b/app/components/UI/Perps/components/PerpsTransactionItem/PerpsTransactionItem.tsx index b91ac29f772b..77519d3ceccc 100644 --- a/app/components/UI/Perps/components/PerpsTransactionItem/PerpsTransactionItem.tsx +++ b/app/components/UI/Perps/components/PerpsTransactionItem/PerpsTransactionItem.tsx @@ -154,7 +154,7 @@ const PerpsTransactionItem: React.FC = ({ {fillTag} - {item.subtitle && ( + {!!item.subtitle && ( {item.subtitle} )} diff --git a/app/components/UI/Perps/components/PerpsTutorialCard/PerpsTutorialCard.styles.ts b/app/components/UI/Perps/components/PerpsTutorialCard/PerpsTutorialCard.styles.ts deleted file mode 100644 index e96c22266748..000000000000 --- a/app/components/UI/Perps/components/PerpsTutorialCard/PerpsTutorialCard.styles.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Theme } from '@metamask/design-tokens'; -import { StyleSheet } from 'react-native'; - -const styleSheet = (params: { theme: Theme }) => { - const { theme } = params; - const { colors } = theme; - - return StyleSheet.create({ - container: { - flexDirection: 'row', - padding: 12, - alignItems: 'center', - gap: 70, - borderRadius: 12, - backgroundColor: colors.background.muted, - justifyContent: 'space-between', - }, - }); -}; - -export default styleSheet; diff --git a/app/components/UI/Perps/components/PerpsTutorialCard/PerpsTutorialCard.test.tsx b/app/components/UI/Perps/components/PerpsTutorialCard/PerpsTutorialCard.test.tsx deleted file mode 100644 index b3b407ab9a48..000000000000 --- a/app/components/UI/Perps/components/PerpsTutorialCard/PerpsTutorialCard.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; -import PerpsTutorialCard from './PerpsTutorialCard'; -import Routes from '../../../../../constants/navigation/Routes'; -import { PerpsTutorialSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; - -// Navigation mock functions -const mockNavigate = jest.fn(); - -jest.mock('@react-navigation/native', () => { - const actualNav = jest.requireActual('@react-navigation/native'); - return { - ...actualNav, - useNavigation: () => ({ - navigate: mockNavigate, - }), - }; -}); - -// Mock the useStyles hook -jest.mock('../../../../hooks/useStyles', () => ({ - useStyles: jest.fn(() => ({ - styles: { - container: { - flexDirection: 'row', - padding: 12, - alignItems: 'center', - gap: 70, - borderRadius: 12, - backgroundColor: '#f0f0f0', - }, - }, - })), -})); - -describe('PerpsTutorialCard', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('displays the tutorial text', () => { - const { getByText } = render(); - expect(getByText('Learn the basics of perps')).toBeOnTheScreen(); - }); - - it('has the correct testID', () => { - const { getByTestId } = render(); - expect( - getByTestId(PerpsTutorialSelectorsIDs.TUTORIAL_CARD), - ).toBeOnTheScreen(); - }); - - it('navigates to perps tutorial when pressed', () => { - // Arrange - const { getByTestId } = render(); - const tutorialCard = getByTestId(PerpsTutorialSelectorsIDs.TUTORIAL_CARD); - - // Act - fireEvent.press(tutorialCard); - - // Assert - expect(mockNavigate).toHaveBeenCalledTimes(1); - expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.TUTORIAL); - }); -}); diff --git a/app/components/UI/Perps/components/PerpsTutorialCard/PerpsTutorialCard.tsx b/app/components/UI/Perps/components/PerpsTutorialCard/PerpsTutorialCard.tsx deleted file mode 100644 index c08978aa340e..000000000000 --- a/app/components/UI/Perps/components/PerpsTutorialCard/PerpsTutorialCard.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import { TouchableOpacity } from 'react-native'; -import { useStyles } from '../../../../hooks/useStyles'; -import styleSheet from './PerpsTutorialCard.styles'; -import { - Icon, - IconSize, - IconColor, - IconName, - Text, - TextVariant, -} from '@metamask/design-system-react-native'; -import { useNavigation } from '@react-navigation/native'; -import Routes from '../../../../../constants/navigation/Routes'; -import { PerpsTutorialSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; -import { strings } from '../../../../../../locales/i18n'; - -const PerpsTutorialCard = () => { - const { styles } = useStyles(styleSheet, {}); - - const { navigate } = useNavigation(); - - const handlePress = () => { - navigate(Routes.PERPS.TUTORIAL); - }; - - return ( - - - {strings('perps.tutorial.card.title')} - - - - ); -}; - -export default PerpsTutorialCard; diff --git a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.test.tsx b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.test.tsx index 780d1c33d58d..4030fe4f9044 100644 --- a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.test.tsx +++ b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.test.tsx @@ -270,7 +270,7 @@ describe('PerpsTutorialCarousel', () => { expect(mockNavigationServiceMethods.navigate).toHaveBeenCalledWith( Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKETS, + screen: Routes.PERPS.PERPS_HOME, }, ); expect(mockMarkTutorialCompleted).toHaveBeenCalled(); @@ -303,7 +303,7 @@ describe('PerpsTutorialCarousel', () => { expect(mockNavigationServiceMethods.navigate).toHaveBeenCalledWith( Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKETS, + screen: Routes.PERPS.PERPS_HOME, }, ); expect(mockDepositWithConfirmation).not.toHaveBeenCalled(); @@ -494,7 +494,7 @@ describe('PerpsTutorialCarousel', () => { expect(mockNavigationServiceMethods.navigate).toHaveBeenCalledWith( Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKETS, + screen: Routes.PERPS.PERPS_HOME, }, ); expect(mockDepositWithConfirmation).not.toHaveBeenCalled(); @@ -632,7 +632,7 @@ describe('PerpsTutorialCarousel', () => { expect(mockNavigationServiceMethods.navigate).toHaveBeenCalledWith( Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKETS, + screen: Routes.PERPS.PERPS_HOME, }, ); // Should NOT navigate to deposit screen or call deposit diff --git a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx index c39aa7644a10..814ede6663bd 100644 --- a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx +++ b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx @@ -266,7 +266,7 @@ const PerpsTutorialCarousel: React.FC = () => { const navigateToMarketsList = useCallback(() => { NavigationService.navigation.navigate(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKETS, + screen: Routes.PERPS.PERPS_HOME, }); }, []); diff --git a/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.styles.ts b/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.styles.ts new file mode 100644 index 000000000000..a0b76ba6598f --- /dev/null +++ b/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.styles.ts @@ -0,0 +1,9 @@ +import type { Theme } from '../../../../../util/theme/models'; +import { createMarketListStyles } from '../../styles/sharedStyles'; + +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + return createMarketListStyles(theme); +}; + +export default styleSheet; diff --git a/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.test.tsx b/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.test.tsx new file mode 100644 index 000000000000..2efa8b52d36a --- /dev/null +++ b/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.test.tsx @@ -0,0 +1,431 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react-native'; +import PerpsWatchlistMarkets from './PerpsWatchlistMarkets'; +import type { PerpsMarketData } from '../../controllers/types'; +import Routes from '../../../../../constants/navigation/Routes'; + +// Mock dependencies +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(() => ({ + navigate: jest.fn(), + })), +})); + +jest.mock('../../../../../component-library/hooks', () => ({ + useStyles: () => ({ + styles: { + container: {}, + header: {}, + listContent: {}, + emptyText: {}, + }, + }), +})); + +jest.mock('../PerpsMarketRowItem', () => { + const { TouchableOpacity, Text } = jest.requireActual('react-native'); + return function MockPerpsMarketRowItem({ + market, + onPress, + }: { + market: PerpsMarketData; + onPress: () => void; + }) { + return ( + + {market.symbol} + {market.name} + + ); + }; +}); + +jest.mock('../PerpsRowSkeleton', () => { + const { View } = jest.requireActual('react-native'); + return function MockPerpsRowSkeleton({ count }: { count: number }) { + return ; + }; +}); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const translations: Record = { + 'perps.home.watchlist': 'Watchlist', + 'perps.home.see_all': 'See all', + }; + return translations[key] || key; + }, +})); + +describe('PerpsWatchlistMarkets', () => { + const mockNavigate = jest.fn(); + const mockMarkets: PerpsMarketData[] = [ + { + symbol: 'BTC', + name: 'Bitcoin', + maxLeverage: '50x', + price: '$52,000', + change24h: '+$2,000', + change24hPercent: '+4.00%', + volume: '$2.5B', + }, + { + symbol: 'ETH', + name: 'Ethereum', + maxLeverage: '25x', + price: '$3,000', + change24h: '-$50', + change24hPercent: '-1.64%', + volume: '$1.2B', + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + const { useNavigation } = jest.requireMock('@react-navigation/native'); + useNavigation.mockReturnValue({ + navigate: mockNavigate, + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('Component Rendering', () => { + it('renders watchlist header with title', () => { + render(); + + expect(screen.getByText('Watchlist')).toBeOnTheScreen(); + }); + + it('renders component with markets', () => { + const { root } = render(); + + expect(root).toBeTruthy(); + }); + + it('renders all watchlisted markets', () => { + render(); + + expect(screen.getByText('BTC')).toBeOnTheScreen(); + expect(screen.getByText('Bitcoin')).toBeOnTheScreen(); + expect(screen.getByText('ETH')).toBeOnTheScreen(); + expect(screen.getByText('Ethereum')).toBeOnTheScreen(); + }); + + it('returns null when markets array is empty', () => { + const { toJSON } = render(); + + // Component returns null for empty markets + expect(toJSON()).toBeNull(); + }); + + it('shows loading skeleton when isLoading is true', () => { + render(); + + // Component shows skeleton while loading + expect(screen.getByTestId('perps-row-skeleton-3')).toBeOnTheScreen(); + expect(screen.getByText('Watchlist')).toBeOnTheScreen(); + }); + }); + + describe('Navigation Handling', () => { + it('navigates to market list with showWatchlistOnly parameter', () => { + const { root } = render(); + + // Find TouchableOpacity that navigates to See all (second one in header) + const touchables = root.findAllByType( + jest.requireActual('react-native').TouchableOpacity, + ); + // Header has one TouchableOpacity for "See all" + const seeAllButton = touchables.find( + (t) => t.props.onPress !== undefined && t.parent?.parent !== null, + ); + + if (seeAllButton) { + fireEvent.press(seeAllButton); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_LIST, + params: { + showWatchlistOnly: true, + }, + }); + } + }); + + it('navigates to market details when market row is pressed', () => { + render(); + + const btcRow = screen.getByTestId('perps-market-row-BTC'); + fireEvent.press(btcRow); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: { market: mockMarkets[0] }, + }); + }); + + it('passes correct market data to navigation for different markets', () => { + render(); + + const ethRow = screen.getByTestId('perps-market-row-ETH'); + fireEvent.press(ethRow); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: { market: mockMarkets[1] }, + }); + }); + }); + + describe('Market Data Display', () => { + it('displays markets in provided order', () => { + const { rerender } = render( + , + ); + + const reversedMarkets = [...mockMarkets].reverse(); + rerender(); + + expect(screen.getByText('ETH')).toBeOnTheScreen(); + expect(screen.getByText('BTC')).toBeOnTheScreen(); + }); + + it('handles single market', () => { + const singleMarket = [mockMarkets[0]]; + + render(); + + expect(screen.getByText('BTC')).toBeOnTheScreen(); + expect(screen.queryByText('ETH')).not.toBeOnTheScreen(); + }); + + it('handles multiple markets', () => { + const manyMarkets: PerpsMarketData[] = [ + ...mockMarkets, + { + symbol: 'SOL', + name: 'Solana', + maxLeverage: '20x', + price: '$150', + change24h: '+$5', + change24hPercent: '+3.45%', + volume: '$800M', + }, + ]; + + render(); + + expect(screen.getByText('BTC')).toBeOnTheScreen(); + expect(screen.getByText('ETH')).toBeOnTheScreen(); + expect(screen.getByText('SOL')).toBeOnTheScreen(); + }); + + it('updates when markets prop changes', () => { + const { rerender } = render( + , + ); + + expect(screen.getByText('BTC')).toBeOnTheScreen(); + + const newMarkets: PerpsMarketData[] = [ + { + symbol: 'AVAX', + name: 'Avalanche', + maxLeverage: '30x', + price: '$40', + change24h: '+$2', + change24hPercent: '+5.26%', + volume: '$500M', + }, + ]; + + rerender(); + + expect(screen.queryByText('BTC')).not.toBeOnTheScreen(); + expect(screen.getByText('AVAX')).toBeOnTheScreen(); + }); + }); + + describe('Edge Cases', () => { + it('handles empty string symbols gracefully', () => { + const marketsWithEmptySymbol: PerpsMarketData[] = [ + { + symbol: '', + name: 'Unknown', + maxLeverage: '10x', + price: '$0', + change24h: '$0', + change24hPercent: '0.00%', + volume: '$0', + }, + ]; + + render(); + + expect(screen.getByText('Unknown')).toBeOnTheScreen(); + }); + + it('handles markets with special characters in symbols', () => { + const specialMarkets: PerpsMarketData[] = [ + { + symbol: 'BTC-USD', + name: 'Bitcoin USD', + maxLeverage: '50x', + price: '$52,000', + change24h: '+$2,000', + change24hPercent: '+4.00%', + volume: '$2.5B', + }, + ]; + + render(); + + expect(screen.getByText('BTC-USD')).toBeOnTheScreen(); + }); + + it('handles very long market lists', () => { + const longMarketList: PerpsMarketData[] = Array.from( + { length: 50 }, + (_, i) => ({ + symbol: `TOKEN${i}`, + name: `Token ${i}`, + maxLeverage: '20x', + price: `$${100 * (i + 1)}`, + change24h: `+$${10 * (i + 1)}`, + change24hPercent: '+5.00%', + volume: '$1M', + }), + ); + + const { root } = render( + , + ); + + // Component renders with long list + expect(root).toBeTruthy(); + }); + + it('handles markets with undefined optional fields', () => { + const incompleteMarkets: PerpsMarketData[] = [ + { + symbol: 'BTC', + name: 'Bitcoin', + maxLeverage: '50x', + price: '$52,000', + change24h: '+$2,000', + change24hPercent: '+4.00%', + volume: '$2.5B', + }, + ]; + + render(); + + expect(screen.getByText('BTC')).toBeOnTheScreen(); + }); + }); + + describe('Loading State Handling', () => { + it('shows skeleton when isLoading transitions from false to true', () => { + const { rerender } = render( + , + ); + + // Component renders markets initially + expect(screen.getByText('BTC')).toBeOnTheScreen(); + + rerender(); + + // Component shows skeleton during loading + expect(screen.getByTestId('perps-row-skeleton-3')).toBeOnTheScreen(); + }); + + it('shows markets when isLoading transitions from true to false', () => { + const { rerender } = render( + , + ); + + // Component shows skeleton during loading + expect(screen.getByTestId('perps-row-skeleton-3')).toBeOnTheScreen(); + + rerender( + , + ); + + // Component renders markets after loading completes + expect(screen.getByText('BTC')).toBeOnTheScreen(); + expect(screen.getByText('ETH')).toBeOnTheScreen(); + }); + }); + + describe('Interaction Edge Cases', () => { + it('handles multiple rapid presses on market rows', () => { + render(); + + const btcRow = screen.getByTestId('perps-market-row-BTC'); + fireEvent.press(btcRow); + fireEvent.press(btcRow); + + expect(mockNavigate).toHaveBeenCalledTimes(2); + }); + + it('handles pressing different market rows in sequence', () => { + render(); + + const btcRow = screen.getByTestId('perps-market-row-BTC'); + const ethRow = screen.getByTestId('perps-market-row-ETH'); + + fireEvent.press(btcRow); + fireEvent.press(ethRow); + + expect(mockNavigate).toHaveBeenCalledTimes(2); + expect(mockNavigate).toHaveBeenNthCalledWith(1, Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: { market: mockMarkets[0] }, + }); + expect(mockNavigate).toHaveBeenNthCalledWith(2, Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: { market: mockMarkets[1] }, + }); + }); + }); + + describe('Component Lifecycle', () => { + it('does not throw error on unmount', () => { + const { unmount } = render( + , + ); + + expect(() => unmount()).not.toThrow(); + }); + + it('cleans up properly when remounted with different props', () => { + const { toJSON, rerender } = render( + , + ); + + // Component renders with markets + expect(toJSON()).not.toBeNull(); + expect(screen.getByText('BTC')).toBeOnTheScreen(); + + // Hides when empty + rerender(); + expect(toJSON()).toBeNull(); + + // Shows skeleton when loading + rerender(); + expect(screen.getByTestId('perps-row-skeleton-3')).toBeOnTheScreen(); + + // Shows markets again when ready + rerender(); + expect(screen.getByText('BTC')).toBeOnTheScreen(); + }); + }); +}); diff --git a/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.tsx b/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.tsx new file mode 100644 index 000000000000..7b8dded9bbf5 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.tsx @@ -0,0 +1,96 @@ +import React, { useCallback } from 'react'; +import { FlatList, View, TouchableOpacity } from 'react-native'; +import { useNavigation, type NavigationProp } from '@react-navigation/native'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; +import { strings } from '../../../../../../locales/i18n'; +import Routes from '../../../../../constants/navigation/Routes'; +import type { + PerpsMarketData, + PerpsNavigationParamList, +} from '../../controllers/types'; +import PerpsMarketRowItem from '../PerpsMarketRowItem'; +import { useStyles } from '../../../../../component-library/hooks'; +import styleSheet from './PerpsWatchlistMarkets.styles'; +import PerpsRowSkeleton from '../PerpsRowSkeleton'; + +interface PerpsWatchlistMarketsProps { + markets: PerpsMarketData[]; + isLoading?: boolean; +} + +const PerpsWatchlistMarkets: React.FC = ({ + markets, + isLoading, +}) => { + const { styles } = useStyles(styleSheet, {}); + const navigation = useNavigation>(); + + const handleViewAll = useCallback(() => { + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_LIST, + params: { + showWatchlistOnly: true, + }, + }); + }, [navigation]); + + const handleMarketPress = useCallback( + (market: PerpsMarketData) => { + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: { market }, + }); + }, + [navigation], + ); + + const renderMarket = useCallback( + ({ item }: { item: PerpsMarketData }) => ( + handleMarketPress(item)} + /> + ), + [handleMarketPress], + ); + + // Don't show section if no watchlist markets and not loading + if (!isLoading && markets.length === 0) { + return null; + } + + return ( + + + + {strings('perps.home.watchlist')} + + {!isLoading && markets.length > 0 && ( + + + {strings('perps.home.see_all')} + + + )} + + + {isLoading ? ( + + ) : ( + item.symbol} + showsVerticalScrollIndicator={false} + scrollEnabled={false} + contentContainerStyle={styles.listContent} + /> + )} + + ); +}; + +export default PerpsWatchlistMarkets; diff --git a/app/components/UI/Perps/constants/eventNames.ts b/app/components/UI/Perps/constants/eventNames.ts index 9b8870db8ac0..addca846ac01 100644 --- a/app/components/UI/Perps/constants/eventNames.ts +++ b/app/components/UI/Perps/constants/eventNames.ts @@ -206,6 +206,8 @@ export const PerpsEventValues = { TP_SL: 'tp_sl', DEPOSIT_INPUT: 'deposit_input', DEPOSIT_REVIEW: 'deposit_review', + CLOSE_ALL_POSITIONS: 'close_all_positions', + CANCEL_ALL_ORDERS: 'cancel_all_orders', }, SETTING_TYPE: { LEVERAGE: 'leverage', diff --git a/app/components/UI/Perps/constants/hyperLiquidConfig.ts b/app/components/UI/Perps/constants/hyperLiquidConfig.ts index bea3948e97d5..238038524478 100644 --- a/app/components/UI/Perps/constants/hyperLiquidConfig.ts +++ b/app/components/UI/Perps/constants/hyperLiquidConfig.ts @@ -246,24 +246,36 @@ export const HIP3_ASSET_ID_CONFIG = { export const BASIS_POINTS_DIVISOR = 10000; /** - * HIP-3 DEX market type classifications - * Maps DEX identifiers to their asset category for badge display + * HIP-3 asset market type classifications (PRODUCTION DEFAULT) + * + * This is the production default configuration, can be overridden via feature flag + * (remoteFeatureFlags.perpsAssetMarketTypes) for dynamic control. + * + * Maps asset symbols (e.g., "xyz:TSLA") to their market type for badge display. * * Market type determines the badge shown in the UI: - * - 'equity': STOCK badge (for stock markets like xyz) - * - 'forex': FOREX badge (for forex markets) - * - 'commodity': COMMODITY badge (for commodity markets) - * - 'crypto': CRYPTO badge (for crypto-only DEXs) - * - undefined: Falls back to 'experimental' badge for HIP-3 DEXs + * - 'equity': STOCK badge (stocks like TSLA, NVDA) + * - 'commodity': COMMODITY badge (commodities like GOLD) + * - 'forex': FOREX badge (forex pairs) + * - undefined: No badge for crypto or unmapped assets * - * DEXs not listed here will show the 'experimental' badge by default. - * Main DEX (no prefix) shows no badge. + * Format: 'dex:SYMBOL' → MarketType + * This allows flexible per-asset classification. + * Assets not listed here will have no market type (undefined). */ -export const HIP3_DEX_MARKET_TYPES = { - xyz: 'equity' as const, // xyz DEX offers stock trading - // Future DEX classifications: - // abc: 'forex' as const, - // commodity_dex: 'commodity' as const, +export const HIP3_ASSET_MARKET_TYPES: Record< + string, + 'equity' | 'commodity' | 'forex' | 'crypto' +> = { + // xyz DEX - Equities + 'xyz:TSLA': 'equity', + 'xyz:NVDA': 'equity', + 'xyz:XYZ100': 'equity', + + // xyz DEX - Commodities + 'xyz:GOLD': 'commodity', + + // Future asset mappings as xyz adds more markets } as const; /** diff --git a/app/components/UI/Perps/constants/perpsConfig.ts b/app/components/UI/Perps/constants/perpsConfig.ts index e8d34a234ad6..2ea9201c8c96 100644 --- a/app/components/UI/Perps/constants/perpsConfig.ts +++ b/app/components/UI/Perps/constants/perpsConfig.ts @@ -27,6 +27,8 @@ export const PERPS_CONSTANTS = { FALLBACK_PRICE_DISPLAY: '$---', // Display when price data is unavailable FALLBACK_PERCENTAGE_DISPLAY: '--%', // Display when change data is unavailable FALLBACK_DATA_DISPLAY: '--', // Display when non-price data is unavailable + ZERO_AMOUNT_DISPLAY: '$0', // Display for zero dollar amounts (e.g., no volume) + ZERO_AMOUNT_DETAILED_DISPLAY: '$0.00', // Display for zero dollar amounts with decimals } as const; /** @@ -293,3 +295,119 @@ export const DEVELOPMENT_CONFIG = { // Future: Add other development helpers as needed } as const; + +/** + * Home screen configuration + * Controls carousel limits and display settings for the main Perps home screen + */ +export const HOME_SCREEN_CONFIG = { + // Maximum number of items to show in each carousel + POSITIONS_CAROUSEL_LIMIT: 10, + ORDERS_CAROUSEL_LIMIT: 10, + TRENDING_MARKETS_LIMIT: 5, + RECENT_ACTIVITY_LIMIT: 3, + + // Carousel display behavior + CAROUSEL_SNAP_ALIGNMENT: 'start' as const, + CAROUSEL_VISIBLE_ITEMS: 1.2, // Show 1 full item + 20% of next + + // Icon sizes for consistent display across sections + DEFAULT_ICON_SIZE: 40, // Default token icon size for cards and rows +} as const; + +/** + * Market sorting configuration + * Controls sorting behavior and presets for the trending markets view + */ +export const MARKET_SORTING_CONFIG = { + // Default sort settings + DEFAULT_SORT_OPTION_ID: 'volume' as const, + DEFAULT_DIRECTION: 'desc' as const, + + // Available sort fields (only includes fields supported by PerpsMarketData) + SORT_FIELDS: { + VOLUME: 'volume', + PRICE_CHANGE: 'priceChange', + OPEN_INTEREST: 'openInterest', + FUNDING_RATE: 'fundingRate', + } as const, + + // Sort button presets for filter chips (simplified buttons without direction) + SORT_BUTTON_PRESETS: [ + { field: 'volume', labelKey: 'perps.sort.volume' }, + { field: 'priceChange', labelKey: 'perps.sort.price_change' }, + { field: 'fundingRate', labelKey: 'perps.sort.funding_rate' }, + ] as const, + + // Sort options for the bottom sheet + // Each option combines field + direction into a single selectable item + // Only Price Change has both directions as separate options + SORT_OPTIONS: [ + { + id: 'volume', + labelKey: 'perps.sort.volume', + field: 'volume', + direction: 'desc', + }, + { + id: 'priceChange-desc', + labelKey: 'perps.sort.price_change_high_to_low', + field: 'priceChange', + direction: 'desc', + }, + { + id: 'priceChange-asc', + labelKey: 'perps.sort.price_change_low_to_high', + field: 'priceChange', + direction: 'asc', + }, + { + id: 'openInterest', + labelKey: 'perps.sort.open_interest', + field: 'openInterest', + direction: 'desc', + }, + { + id: 'fundingRate', + labelKey: 'perps.sort.funding_rate', + field: 'fundingRate', + direction: 'desc', + }, + ] as const, +} as const; + +/** + * Type for valid sort option IDs + * Derived from SORT_OPTIONS to ensure type safety + * Valid values: 'volume' | 'priceChange-desc' | 'priceChange-asc' | 'openInterest' | 'fundingRate' + */ +export type SortOptionId = + (typeof MARKET_SORTING_CONFIG.SORT_OPTIONS)[number]['id']; + +/** + * Type for sort button presets (filter chips) + * Derived from SORT_BUTTON_PRESETS to ensure type safety + */ +export type SortButtonPreset = + (typeof MARKET_SORTING_CONFIG.SORT_BUTTON_PRESETS)[number]; + +/** + * Learn more card configuration + * External resources and content for Perps education + */ +export const LEARN_MORE_CONFIG = { + EXTERNAL_URL: 'https://metamask.io/perps', + TITLE_KEY: 'perps.learn_more.title', + DESCRIPTION_KEY: 'perps.learn_more.description', + CTA_KEY: 'perps.learn_more.cta', +} as const; + +/** + * Support configuration + * Contact support button configuration (matches Settings behavior) + */ +export const SUPPORT_CONFIG = { + URL: 'https://support.metamask.io', + TITLE_KEY: 'perps.support.title', + DESCRIPTION_KEY: 'perps.support.description', +} as const; diff --git a/app/components/UI/Perps/controllers/PerpsController.test.ts b/app/components/UI/Perps/controllers/PerpsController.test.ts index 3099ed941510..188105109a1f 100644 --- a/app/components/UI/Perps/controllers/PerpsController.test.ts +++ b/app/components/UI/Perps/controllers/PerpsController.test.ts @@ -10,13 +10,30 @@ import { getDefaultPerpsControllerState, } from './PerpsController'; import { HyperLiquidProvider } from './providers/HyperLiquidProvider'; -import { createMockHyperLiquidProvider } from '../__mocks__/providerMocks'; +import { + createMockHyperLiquidProvider, + createMockOrder, + createMockPosition, +} from '../__mocks__/providerMocks'; import { MetaMetrics } from '../../../../core/Analytics'; import Logger from '../../../../util/Logger'; // Mock the HyperLiquidProvider jest.mock('./providers/HyperLiquidProvider'); +// Mock stream manager +const mockStreamManager = { + positions: { pause: jest.fn(), resume: jest.fn() }, + account: { pause: jest.fn(), resume: jest.fn() }, + orders: { pause: jest.fn(), resume: jest.fn() }, + prices: { pause: jest.fn(), resume: jest.fn() }, + orderFills: { pause: jest.fn(), resume: jest.fn() }, +}; + +jest.mock('../providers/PerpsStreamManager', () => ({ + getStreamManagerInstance: jest.fn(() => mockStreamManager), +})); + // Mock Logger jest.mock('../../../../util/Logger', () => ({ __esModule: true, @@ -709,6 +726,130 @@ describe('PerpsController', () => { }); }); + describe('cancelOrders', () => { + beforeEach(() => { + (controller as any).isInitialized = true; + (controller as any).providers = new Map([['hyperliquid', mockProvider]]); + (controller as any).isCancelingOrders = false; + jest.clearAllMocks(); + }); + + it('cancels all orders when cancelAll is true', async () => { + const mockOrders = [ + createMockOrder({ orderId: 'order-1', symbol: 'BTC' }), + createMockOrder({ orderId: 'order-2', symbol: 'ETH' }), + ]; + mockProvider.getOpenOrders.mockResolvedValue(mockOrders); + mockProvider.cancelOrder.mockResolvedValue({ success: true }); + + const result = await controller.cancelOrders({ cancelAll: true }); + + expect(mockProvider.getOpenOrders).toHaveBeenCalled(); + expect(mockProvider.cancelOrder).toHaveBeenCalledTimes(2); + expect(result.successCount).toBe(2); + expect(result.failureCount).toBe(0); + expect(result.success).toBe(true); + }); + + it('cancels specific order IDs when provided', async () => { + const mockOrders = [ + createMockOrder({ orderId: 'order-1', symbol: 'BTC' }), + createMockOrder({ orderId: 'order-2', symbol: 'ETH' }), + createMockOrder({ orderId: 'order-3', symbol: 'SOL' }), + ]; + mockProvider.getOpenOrders.mockResolvedValue(mockOrders); + mockProvider.cancelOrder.mockResolvedValue({ success: true }); + + const result = await controller.cancelOrders({ + orderIds: ['order-1', 'order-3'], + }); + + expect(mockProvider.cancelOrder).toHaveBeenCalledTimes(2); + expect(mockProvider.cancelOrder).toHaveBeenCalledWith({ + coin: 'BTC', + orderId: 'order-1', + }); + expect(mockProvider.cancelOrder).toHaveBeenCalledWith({ + coin: 'SOL', + orderId: 'order-3', + }); + expect(result.successCount).toBe(2); + }); + + it('cancels orders for specific coins when provided', async () => { + const mockOrders = [ + createMockOrder({ orderId: 'order-1', symbol: 'BTC' }), + createMockOrder({ orderId: 'order-2', symbol: 'ETH' }), + createMockOrder({ orderId: 'order-3', symbol: 'BTC' }), + ]; + mockProvider.getOpenOrders.mockResolvedValue(mockOrders); + mockProvider.cancelOrder.mockResolvedValue({ success: true }); + + const result = await controller.cancelOrders({ coins: ['BTC'] }); + + expect(mockProvider.cancelOrder).toHaveBeenCalledTimes(2); + expect(result.successCount).toBe(2); + }); + + it('returns empty results when no orders match filters', async () => { + const mockOrders = [ + createMockOrder({ orderId: 'order-1', symbol: 'BTC' }), + ]; + mockProvider.getOpenOrders.mockResolvedValue(mockOrders); + + const result = await controller.cancelOrders({ coins: ['ETH'] }); + + expect(mockProvider.cancelOrder).not.toHaveBeenCalled(); + expect(result).toEqual({ + success: false, + successCount: 0, + failureCount: 0, + results: [], + }); + }); + + it('handles partial failures gracefully', async () => { + const mockOrders = [ + createMockOrder({ orderId: 'order-1', symbol: 'BTC' }), + createMockOrder({ orderId: 'order-2', symbol: 'ETH' }), + ]; + mockProvider.getOpenOrders.mockResolvedValue(mockOrders); + mockProvider.cancelOrder + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ success: false, error: 'Network error' }); + + const result = await controller.cancelOrders({ cancelAll: true }); + + expect(result.successCount).toBe(1); + expect(result.failureCount).toBe(1); + expect(result.success).toBe(true); + }); + + it('pauses and resumes streams during batch cancellation', async () => { + const mockOrders = [ + createMockOrder({ orderId: 'order-1', symbol: 'BTC' }), + ]; + mockProvider.getOpenOrders.mockResolvedValue(mockOrders); + mockProvider.cancelOrder.mockResolvedValue({ success: true }); + + await controller.cancelOrders({ cancelAll: true }); + + expect(mockStreamManager.orders.pause).toHaveBeenCalled(); + expect(mockStreamManager.orders.resume).toHaveBeenCalled(); + }); + + it('resumes streams even when operation throws error', async () => { + mockProvider.getOpenOrders.mockRejectedValue(new Error('Network error')); + + await expect( + controller.cancelOrders({ cancelAll: true }), + ).rejects.toThrow('Network error'); + + expect(mockStreamManager.orders.pause).toHaveBeenCalled(); + expect(mockStreamManager.orders.resume).toHaveBeenCalled(); + }); + }); + describe('closePosition', () => { it('should close position successfully', async () => { const closeParams = { @@ -834,6 +975,82 @@ describe('PerpsController', () => { }); }); + describe('closePositions', () => { + beforeEach(() => { + (controller as any).isInitialized = true; + (controller as any).providers = new Map([['hyperliquid', mockProvider]]); + }); + + it('closes all positions when closeAll is true', async () => { + const mockPositions = [ + createMockPosition({ coin: 'BTC' }), + createMockPosition({ coin: 'ETH' }), + ]; + mockProvider.getPositions.mockResolvedValue(mockPositions); + mockProvider.closePosition.mockResolvedValue({ success: true }); + + const result = await controller.closePositions({ closeAll: true }); + + expect(mockProvider.getPositions).toHaveBeenCalled(); + expect(mockProvider.closePosition).toHaveBeenCalledTimes(2); + expect(result.successCount).toBe(2); + expect(result.failureCount).toBe(0); + expect(result.success).toBe(true); + }); + + it('closes specific coins when provided', async () => { + const mockPositions = [ + createMockPosition({ coin: 'BTC' }), + createMockPosition({ coin: 'ETH' }), + createMockPosition({ coin: 'SOL' }), + ]; + mockProvider.getPositions.mockResolvedValue(mockPositions); + mockProvider.closePosition.mockResolvedValue({ success: true }); + + const result = await controller.closePositions({ coins: ['BTC', 'SOL'] }); + + expect(mockProvider.closePosition).toHaveBeenCalledTimes(2); + expect(mockProvider.closePosition).toHaveBeenCalledWith({ coin: 'BTC' }); + expect(mockProvider.closePosition).toHaveBeenCalledWith({ coin: 'SOL' }); + expect(result.successCount).toBe(2); + }); + + it('returns empty results when no positions match', async () => { + const mockPositions = [createMockPosition({ coin: 'BTC' })]; + mockProvider.getPositions.mockResolvedValue(mockPositions); + + const result = await controller.closePositions({ coins: ['ETH'] }); + + expect(mockProvider.closePosition).not.toHaveBeenCalled(); + expect(result).toEqual({ + success: false, + successCount: 0, + failureCount: 0, + results: [], + }); + }); + + it('handles partial failures gracefully', async () => { + const mockPositions = [ + createMockPosition({ coin: 'BTC' }), + createMockPosition({ coin: 'ETH' }), + ]; + mockProvider.getPositions.mockResolvedValue(mockPositions); + mockProvider.closePosition + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ + success: false, + error: 'Insufficient margin', + }); + + const result = await controller.closePositions({ closeAll: true }); + + expect(result.successCount).toBe(1); + expect(result.failureCount).toBe(1); + expect(result.success).toBe(true); + }); + }); + describe('validateOrder', () => { it('should validate order successfully', async () => { const orderParams = { @@ -1389,6 +1606,72 @@ describe('PerpsController', () => { }); }); + describe('watchlist markets', () => { + it('should return empty array by default', () => { + const watchlist = controller.getWatchlistMarkets(); + expect(watchlist).toEqual([]); + }); + + it('should toggle watchlist market (add)', () => { + controller.toggleWatchlistMarket('BTC'); + + const watchlist = controller.getWatchlistMarkets(); + expect(watchlist).toContain('BTC'); + expect(controller.isWatchlistMarket('BTC')).toBe(true); + }); + + it('should toggle watchlist market (remove)', () => { + controller.toggleWatchlistMarket('BTC'); + controller.toggleWatchlistMarket('BTC'); + + const watchlist = controller.getWatchlistMarkets(); + expect(watchlist).not.toContain('BTC'); + expect(controller.isWatchlistMarket('BTC')).toBe(false); + }); + + it('should handle multiple watchlist markets', () => { + controller.toggleWatchlistMarket('BTC'); + controller.toggleWatchlistMarket('ETH'); + controller.toggleWatchlistMarket('SOL'); + + const watchlist = controller.getWatchlistMarkets(); + expect(watchlist).toHaveLength(3); + expect(watchlist).toContain('BTC'); + expect(watchlist).toContain('ETH'); + expect(watchlist).toContain('SOL'); + }); + + it('should persist watchlist per network', () => { + // Add to watchlist on mainnet (default is testnet in dev, so set to false) + (controller as any).update((state: any) => { + state.isTestnet = false; + }); + controller.toggleWatchlistMarket('BTC'); + + const mainnetWatchlist = controller.getWatchlistMarkets(); + expect(mainnetWatchlist).toContain('BTC'); + + // Switch to testnet + (controller as any).update((state: any) => { + state.isTestnet = true; + }); + const testnetWatchlist = controller.getWatchlistMarkets(); + expect(testnetWatchlist).toEqual([]); + + // Add to watchlist on testnet + controller.toggleWatchlistMarket('ETH'); + expect(controller.getWatchlistMarkets()).toContain('ETH'); + expect(controller.isWatchlistMarket('ETH')).toBe(true); + + // Switch back to mainnet + (controller as any).update((state: any) => { + state.isTestnet = false; + }); + expect(controller.getWatchlistMarkets()).toContain('BTC'); + expect(controller.getWatchlistMarkets()).not.toContain('ETH'); + }); + }); + describe('additional subscriptions', () => { it('should subscribe to orders', () => { const mockUnsubscribe = jest.fn(); @@ -1625,9 +1908,10 @@ describe('PerpsController', () => { describe('fee calculations', () => { it('should calculate fees', async () => { const feeParams = { - coin: 'BTC', - size: '0.1', orderType: 'market' as const, + isMaker: false, + amount: '1000', + coin: 'BTC', }; const mockFees = { diff --git a/app/components/UI/Perps/controllers/PerpsController.ts b/app/components/UI/Perps/controllers/PerpsController.ts index 891a32df89d3..a016c401140e 100644 --- a/app/components/UI/Perps/controllers/PerpsController.ts +++ b/app/components/UI/Perps/controllers/PerpsController.ts @@ -48,16 +48,27 @@ import { PerpsMeasurementName } from '../constants/performanceMetrics'; import { DATA_LAKE_API_CONFIG, PERPS_CONSTANTS, + MARKET_SORTING_CONFIG, + type SortOptionId, } from '../constants/perpsConfig'; import { PERPS_ERROR_CODES } from './perpsErrorCodes'; import { HyperLiquidProvider } from './providers/HyperLiquidProvider'; +import { + getStreamManagerInstance, + type PerpsStreamManager, +} from '../providers/PerpsStreamManager'; import type { AccountState, AssetRoute, CancelOrderParams, CancelOrderResult, + CancelOrdersParams, + CancelOrdersResult, ClosePositionParams, + ClosePositionsParams, + ClosePositionsResult, EditOrderParams, + FeeCalculationParams, FeeCalculationResult, Funding, GetAccountStateParams, @@ -207,6 +218,29 @@ export type PerpsControllerState = { mainnet: boolean; }; + // Watchlist markets tracking (per network) + watchlistMarkets: { + testnet: string[]; // Array of watchlist market symbols for testnet + mainnet: string[]; // Array of watchlist market symbols for mainnet + }; + + // Trade configurations per market (per network) + tradeConfigurations: { + testnet: { + [marketSymbol: string]: { + leverage?: number; // Last used leverage for this market + }; + }; + mainnet: { + [marketSymbol: string]: { + leverage?: number; + }; + }; + }; + + // Market filter preferences (network-independent) - includes both sorting and filtering options + marketFilterPreferences: SortOptionId; + // Error handling lastError: string | null; lastUpdateTimestamp: number; @@ -246,6 +280,15 @@ export const getDefaultPerpsControllerState = (): PerpsControllerState => ({ testnet: false, mainnet: false, }, + watchlistMarkets: { + testnet: [], + mainnet: [], + }, + tradeConfigurations: { + testnet: {}, + mainnet: {}, + }, + marketFilterPreferences: MARKET_SORTING_CONFIG.DEFAULT_SORT_OPTION_ID, }); /** @@ -372,6 +415,24 @@ const metadata = { anonymous: false, usedInUi: true, }, + watchlistMarkets: { + includeInStateLogs: true, + persist: true, + anonymous: false, + usedInUi: true, + }, + tradeConfigurations: { + includeInStateLogs: true, + persist: true, + anonymous: false, + usedInUi: true, + }, + marketFilterPreferences: { + includeInStateLogs: true, + persist: true, + anonymous: false, + usedInUi: true, + }, }; /** @@ -402,10 +463,18 @@ export type PerpsControllerActions = type: 'PerpsController:cancelOrder'; handler: PerpsController['cancelOrder']; } + | { + type: 'PerpsController:cancelOrders'; + handler: PerpsController['cancelOrders']; + } | { type: 'PerpsController:closePosition'; handler: PerpsController['closePosition']; } + | { + type: 'PerpsController:closePositions'; + handler: PerpsController['closePositions']; + } | { type: 'PerpsController:withdraw'; handler: PerpsController['withdraw']; @@ -469,6 +538,22 @@ export type PerpsControllerActions = | { type: 'PerpsController:resetFirstTimeUserState'; handler: PerpsController['resetFirstTimeUserState']; + } + | { + type: 'PerpsController:saveTradeConfiguration'; + handler: PerpsController['saveTradeConfiguration']; + } + | { + type: 'PerpsController:getTradeConfiguration'; + handler: PerpsController['getTradeConfiguration']; + } + | { + type: 'PerpsController:saveMarketFilterPreferences'; + handler: PerpsController['saveMarketFilterPreferences']; + } + | { + type: 'PerpsController:getMarketFilterPreferences'; + handler: PerpsController['getMarketFilterPreferences']; }; /** @@ -559,8 +644,9 @@ export class PerpsController extends BaseController< }); // Store HIP-3 configuration from client (immutable after construction) + // Clone array to prevent external mutations this.equityEnabled = clientConfig.equityEnabled ?? false; - this.enabledDexs = clientConfig.enabledDexs ?? []; + this.enabledDexs = [...(clientConfig.enabledDexs ?? [])]; // Immediately set the fallback region list since RemoteFeatureFlagController is empty by default and takes a moment to populate. this.setBlockedRegionList( @@ -648,6 +734,81 @@ export class PerpsController extends BaseController< } } + /** + * Execute an operation while temporarily pausing specified stream channels + * to prevent WebSocket updates from triggering UI re-renders during operations. + * + * WebSocket connections remain alive but updates are not emitted to subscribers. + * This prevents race conditions where UI re-renders fetch stale data during operations. + * + * @param operation - The async operation to execute + * @param channels - Array of stream channel names to pause + * @returns The result of the operation + * + * @example + * ```typescript + * // Cancel orders without stream interference + * await this.withStreamPause( + * async () => this.provider.cancelOrders({ cancelAll: true }), + * ['orders'] + * ); + * + * // Close positions and pause multiple streams + * await this.withStreamPause( + * async () => this.provider.closePositions(positions), + * ['positions', 'account', 'orders'] + * ); + * ``` + */ + private async withStreamPause( + operation: () => Promise, + channels: (keyof PerpsStreamManager)[], + ): Promise { + const streamManager = getStreamManagerInstance(); + const pausedChannels: (keyof PerpsStreamManager)[] = []; + + // Pause emission on specified channels (WebSocket stays connected) + // Track which channels successfully paused to ensure proper cleanup + for (const channel of channels) { + try { + streamManager[channel].pause(); + pausedChannels.push(channel); + } catch (err) { + // Log error to Sentry but continue pausing remaining channels + Logger.error( + ensureError(err), + this.getErrorContext('withStreamPause', { + operation: 'pause', + channel: String(channel), + pausedChannels: pausedChannels.join(','), + }), + ); + } + } + + try { + // Execute operation without stream interference + return await operation(); + } finally { + // Resume only channels that were successfully paused + for (const channel of pausedChannels) { + try { + streamManager[channel].resume(); + } catch (err) { + // Log error to Sentry but continue resuming remaining channels + Logger.error( + ensureError(err), + this.getErrorContext('withStreamPause', { + operation: 'resume', + channel: String(channel), + pausedChannels: pausedChannels.join(','), + }), + ); + } + } + } + } + /** * Calculate user fee discount from RewardsController * Used to apply MetaMask reward discounts to trading fees @@ -1006,6 +1167,11 @@ export class PerpsController extends BaseController< state.lastUpdateTimestamp = Date.now(); }); + // Save executed trade configuration for this market + if (params.leverage) { + this.saveTradeConfiguration(params.coin, params.leverage); + } + // Track trade transaction executed const completionDuration = performance.now() - startTime; @@ -1393,6 +1559,151 @@ export class PerpsController extends BaseController< } } + /** + * Cancel multiple orders in parallel + * Batch version of cancelOrder() that cancels multiple orders simultaneously + */ + async cancelOrders(params: CancelOrdersParams): Promise { + const traceId = uuidv4(); + const startTime = performance.now(); + let operationResult: CancelOrdersResult | null = null; + let operationError: Error | null = null; + + try { + trace({ + name: TraceName.PerpsCancelOrder, + id: traceId, + op: TraceOperation.PerpsOrderSubmission, + tags: { + provider: this.state.activeProvider, + isBatch: 'true', + isTestnet: this.state.isTestnet, + }, + data: { + cancelAll: params.cancelAll ? 'true' : 'false', + coinCount: params.coins?.length || 0, + orderIdCount: params.orderIds?.length || 0, + }, + }); + + // Pause orders stream to prevent WebSocket updates during cancellation + operationResult = await this.withStreamPause(async () => { + // Get all open orders (using getOpenOrders to avoid duplicates from historicalOrders) + const orders = await this.getOpenOrders(); + + // Filter orders based on params + let ordersToCancel = orders; + if (params.cancelAll || (!params.coins && !params.orderIds)) { + // Cancel all orders + ordersToCancel = orders; + } else if (params.orderIds && params.orderIds.length > 0) { + // Cancel specific order IDs + ordersToCancel = orders.filter((o) => + params.orderIds?.includes(o.orderId), + ); + } else if (params.coins && params.coins.length > 0) { + // Cancel orders for specific coins + ordersToCancel = orders.filter((o) => + params.coins?.includes(o.symbol), + ); + } + + if (ordersToCancel.length === 0) { + return { + success: false, + successCount: 0, + failureCount: 0, + results: [], + }; + } + + const provider = this.getActiveProvider(); + + // Use batch cancel if provider supports it + if (provider.cancelOrders) { + return await provider.cancelOrders( + ordersToCancel.map((order) => ({ + coin: order.symbol, + orderId: order.orderId, + })), + ); + } + + // Fallback: Cancel orders in parallel (for providers without batch support) + const results = await Promise.allSettled( + ordersToCancel.map((order) => + this.cancelOrder({ coin: order.symbol, orderId: order.orderId }), + ), + ); + + // Aggregate results + const successCount = results.filter( + (r) => r.status === 'fulfilled' && r.value.success, + ).length; + const failureCount = results.length - successCount; + + return { + success: successCount > 0, + successCount, + failureCount, + results: results.map((result, index) => { + let error: string | undefined; + if (result.status === 'rejected') { + error = + result.reason instanceof Error + ? result.reason.message + : 'Unknown error'; + } else if (result.status === 'fulfilled' && !result.value.success) { + error = result.value.error; + } + + return { + orderId: ordersToCancel[index].orderId, + coin: ordersToCancel[index].symbol, + success: !!( + result.status === 'fulfilled' && result.value.success + ), + error, + }; + }), + }; + }, ['orders']); // Disconnect orders stream during operation + + return operationResult; + } catch (error) { + operationError = + error instanceof Error ? error : new Error(String(error)); + throw error; + } finally { + const completionDuration = performance.now() - startTime; + + // Track batch cancel event (success or failure) + MetaMetrics.getInstance().trackEvent( + MetricsEventBuilder.createEventBuilder( + MetaMetricsEvents.PERPS_ORDER_CANCEL_TRANSACTION, + ) + .addProperties({ + [PerpsEventProperties.STATUS]: + operationResult?.success && operationResult.successCount > 0 + ? PerpsEventValues.STATUS.EXECUTED + : PerpsEventValues.STATUS.FAILED, + [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, + ...(operationError && { + [PerpsEventProperties.ERROR_MESSAGE]: operationError.message, + }), + // Note: Custom properties for batch tracking (totalCount, successCount, failureCount) + // can be added to PerpsEventProperties if needed for analytics + }) + .build(), + ); + + endTrace({ + name: TraceName.PerpsCancelOrder, + id: traceId, + }); + } + } + /** * Close a position (partial or full) */ @@ -1702,6 +2013,138 @@ export class PerpsController extends BaseController< } } + /** + * Close multiple positions in parallel + * Batch version of closePosition() that closes multiple positions simultaneously + */ + async closePositions( + params: ClosePositionsParams, + ): Promise { + const traceId = uuidv4(); + const startTime = performance.now(); + let operationResult: ClosePositionsResult | null = null; + let operationError: Error | null = null; + + try { + trace({ + name: TraceName.PerpsClosePosition, + id: traceId, + op: TraceOperation.PerpsPositionManagement, + tags: { + provider: this.state.activeProvider, + isBatch: 'true', + isTestnet: this.state.isTestnet, + }, + data: { + closeAll: params.closeAll ? 'true' : 'false', + coinCount: params.coins?.length || 0, + }, + }); + + const provider = this.getActiveProvider(); + + DevLogger.log('[closePositions] Batch method check', { + providerType: provider.protocolId, + hasBatchMethod: !!provider.closePositions, + methodType: typeof provider.closePositions, + providerKeys: Object.keys(provider).filter((k) => k.includes('close')), + }); + + // Use batch close if provider supports it (provider handles filtering) + if (provider.closePositions) { + operationResult = await provider.closePositions(params); + } else { + // Fallback: Get positions, filter, and close in parallel + const positions = await this.getPositions(); + + const positionsToClose = + params.closeAll || !params.coins || params.coins.length === 0 + ? positions + : positions.filter((p) => params.coins?.includes(p.coin)); + + if (positionsToClose.length === 0) { + operationResult = { + success: false, + successCount: 0, + failureCount: 0, + results: [], + }; + return operationResult; + } + + const results = await Promise.allSettled( + positionsToClose.map((position) => + this.closePosition({ coin: position.coin }), + ), + ); + + // Aggregate results + const successCount = results.filter( + (r) => r.status === 'fulfilled' && r.value.success, + ).length; + const failureCount = results.length - successCount; + + operationResult = { + success: successCount > 0, + successCount, + failureCount, + results: results.map((result, index) => { + let error: string | undefined; + if (result.status === 'rejected') { + error = + result.reason instanceof Error + ? result.reason.message + : 'Unknown error'; + } else if (result.status === 'fulfilled' && !result.value.success) { + error = result.value.error; + } + + return { + coin: positionsToClose[index].coin, + success: !!( + result.status === 'fulfilled' && result.value.success + ), + error, + }; + }), + }; + } + + return operationResult; + } catch (error) { + operationError = + error instanceof Error ? error : new Error(String(error)); + throw error; + } finally { + const completionDuration = performance.now() - startTime; + + // Track batch close event (success or failure) + MetaMetrics.getInstance().trackEvent( + MetricsEventBuilder.createEventBuilder( + MetaMetricsEvents.PERPS_POSITION_CLOSE_TRANSACTION, + ) + .addProperties({ + [PerpsEventProperties.STATUS]: + operationResult?.success && operationResult.successCount > 0 + ? PerpsEventValues.STATUS.EXECUTED + : PerpsEventValues.STATUS.FAILED, + [PerpsEventProperties.COMPLETION_DURATION]: completionDuration, + ...(operationError && { + [PerpsEventProperties.ERROR_MESSAGE]: operationError.message, + }), + // Note: Custom properties for batch tracking (totalCount, successCount, failureCount) + // can be added to PerpsEventProperties if needed for analytics + }) + .build(), + ); + + endTrace({ + name: TraceName.PerpsClosePosition, + id: traceId, + }); + } + } + /** * Update TP/SL for an existing position */ @@ -2158,7 +2601,7 @@ export class PerpsController extends BaseController< // Generate withdrawal request ID for tracking (outside try block for catch access) const currentWithdrawalId = `withdraw-${Date.now()}-${Math.random() .toString(36) - .substr(2, 9)}`; + .substring(2, 11)}`; try { trace({ @@ -3275,11 +3718,9 @@ export class PerpsController extends BaseController< * Calculate trading fees for the active provider * Each provider implements its own fee structure */ - async calculateFees(params: { - orderType: 'market' | 'limit'; - isMaker?: boolean; - amount?: string; - }): Promise { + async calculateFees( + params: FeeCalculationParams, + ): Promise { const provider = this.getActiveProvider(); return provider.calculateFees(params); } @@ -3502,6 +3943,120 @@ export class PerpsController extends BaseController< }); } + /** + * Get saved trade configuration for a market + */ + getTradeConfiguration(coin: string): { leverage?: number } | undefined { + const network = this.state.isTestnet ? 'testnet' : 'mainnet'; + const config = this.state.tradeConfigurations[network]?.[coin]; + + if (!config?.leverage) return undefined; + + DevLogger.log('PerpsController: Retrieved trade config', { + coin, + network, + leverage: config.leverage, + }); + + return { leverage: config.leverage }; + } + + /** + * Save trade configuration for a market + * @param coin - Market symbol + * @param leverage - Leverage value + */ + saveTradeConfiguration(coin: string, leverage: number): void { + const network = this.state.isTestnet ? 'testnet' : 'mainnet'; + + DevLogger.log('PerpsController: Saving trade configuration', { + coin, + network, + leverage, + timestamp: new Date().toISOString(), + }); + + this.update((state) => { + if (!state.tradeConfigurations[network]) { + state.tradeConfigurations[network] = {}; + } + + state.tradeConfigurations[network][coin] = { + leverage, + }; + }); + } + + /** + * Get saved market filter preferences + */ + getMarketFilterPreferences(): SortOptionId { + return ( + this.state.marketFilterPreferences ?? + MARKET_SORTING_CONFIG.DEFAULT_SORT_OPTION_ID + ); + } + + /** + * Save market filter preferences + * @param optionId - Sort/filter option ID + */ + saveMarketFilterPreferences(optionId: SortOptionId): void { + DevLogger.log('PerpsController: Saving market filter preferences', { + optionId, + timestamp: new Date().toISOString(), + }); + + this.update((state) => { + state.marketFilterPreferences = optionId; + }); + } + + /** + * Toggle watchlist status for a market + * Watchlist markets are stored per network (testnet/mainnet) + */ + toggleWatchlistMarket(symbol: string): void { + const currentNetwork = this.state.isTestnet ? 'testnet' : 'mainnet'; + const currentWatchlist = this.state.watchlistMarkets[currentNetwork]; + const isWatchlisted = currentWatchlist.includes(symbol); + + DevLogger.log('PerpsController: Toggling watchlist market', { + timestamp: new Date().toISOString(), + network: currentNetwork, + symbol, + action: isWatchlisted ? 'remove' : 'add', + }); + + this.update((state) => { + if (isWatchlisted) { + // Remove from watchlist + state.watchlistMarkets[currentNetwork] = currentWatchlist.filter( + (s) => s !== symbol, + ); + } else { + // Add to watchlist + state.watchlistMarkets[currentNetwork] = [...currentWatchlist, symbol]; + } + }); + } + + /** + * Check if a market is in the watchlist on the current network + */ + isWatchlistMarket(symbol: string): boolean { + const currentNetwork = this.state.isTestnet ? 'testnet' : 'mainnet'; + return this.state.watchlistMarkets[currentNetwork].includes(symbol); + } + + /** + * Get all watchlist markets for the current network + */ + getWatchlistMarkets(): string[] { + const currentNetwork = this.state.isTestnet ? 'testnet' : 'mainnet'; + return this.state.watchlistMarkets[currentNetwork]; + } + /** * Report order events to data lake API with retry (non-blocking) */ diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts index e37dc6e95c6c..1f9c53ed73f4 100644 --- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts +++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts @@ -77,6 +77,26 @@ jest.mock('../../utils/hyperLiquidValidation', () => ({ })), })); +// Mock adapter functions +jest.mock('../../utils/hyperLiquidAdapter', () => { + const actual = jest.requireActual('../../utils/hyperLiquidAdapter'); + return { + ...actual, + adaptHyperLiquidLedgerUpdateToUserHistoryItem: jest.fn((updates) => { + // Return mock history items based on input + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map((_update: unknown) => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }), + }; +}); + const MockedHyperLiquidClientService = HyperLiquidClientService as jest.MockedClass; const MockedHyperLiquidWalletService = @@ -181,6 +201,37 @@ const createMockInfoClient = (overrides: Record = {}) => ({ }, dailyUserVlm: [], }), + userNonFundingLedgerUpdates: jest.fn().mockResolvedValue([ + { + delta: { type: 'deposit', usdc: '100' }, + time: Date.now(), + hash: '0x123abc', + }, + { + delta: { type: 'withdraw', usdc: '50' }, + time: Date.now() - 3600000, + hash: '0x456def', + }, + ]), + portfolio: jest.fn().mockResolvedValue([ + null, + [ + null, + { + accountValueHistory: [ + [Date.now() - 86400000, '10000'], // 24h ago + [Date.now() - 172800000, '9500'], // 48h ago + [Date.now() - 259200000, '9000'], // 72h ago + ], + }, + ], + ]), + spotMeta: jest.fn().mockResolvedValue({ + tokens: [ + { name: 'USDC', tokenId: '0xdef456' }, + { name: 'USDT', tokenId: '0x789abc' }, + ], + }), ...overrides, }); @@ -209,6 +260,9 @@ const createMockExchangeClient = (overrides: Record = {}) => ({ setReferrer: jest.fn().mockResolvedValue({ status: 'ok', }), + sendAsset: jest.fn().mockResolvedValue({ + status: 'ok', + }), ...overrides, }); @@ -263,6 +317,8 @@ describe('HyperLiquidProvider', () => { subscribeToPositions: jest.fn().mockReturnValue(jest.fn()), // Returns function directly subscribeToOrderFills: jest.fn().mockReturnValue(jest.fn()), // Returns function directly clearAll: jest.fn(), + isPositionsCacheInitialized: jest.fn().mockReturnValue(false), + getCachedPositions: jest.fn().mockReturnValue([]), } as Partial as jest.Mocked; // Mock constructors @@ -1207,6 +1263,207 @@ describe('HyperLiquidProvider', () => { }); }); + describe('Batch Operations', () => { + describe('cancelOrders', () => { + it('returns failure when no orders provided', async () => { + const result = await provider.cancelOrders([]); + + expect(result.success).toBe(false); + expect(result.successCount).toBe(0); + expect(result.failureCount).toBe(0); + expect(result.results).toEqual([]); + }); + + it('cancels multiple orders successfully', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + cancel: jest.fn().mockResolvedValue({ + response: { + data: { + statuses: ['success', 'success'], + }, + }, + }), + }), + ); + + const params = [ + { orderId: '123', coin: 'BTC' }, + { orderId: '456', coin: 'ETH' }, + ]; + + const result = await provider.cancelOrders(params); + + expect(result.success).toBe(true); + expect(result.successCount).toBe(2); + expect(result.failureCount).toBe(0); + expect(result.results).toHaveLength(2); + expect(result.results[0].success).toBe(true); + }); + + it('handles batch cancel errors', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + cancel: jest.fn().mockRejectedValue(new Error('API error')), + }), + ); + + const params = [{ orderId: '123', coin: 'BTC' }]; + + const result = await provider.cancelOrders(params); + + expect(result.success).toBe(false); + expect(result.successCount).toBe(0); + expect(result.failureCount).toBe(1); + expect(result.results[0].success).toBe(false); + expect(result.results[0].error).toBe('API error'); + }); + }); + + describe('closePositions', () => { + it('returns failure when no positions to close', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '0', accountValue: '10000' }, + withdrawable: '10000', + assetPositions: [], + crossMarginSummary: { + accountValue: '10000', + totalMarginUsed: '0', + }, + }), + }), + ); + + const result = await provider.closePositions({ closeAll: true }); + + expect(result.success).toBe(false); + expect(result.successCount).toBe(0); + expect(result.failureCount).toBe(0); + expect(result.results).toEqual([]); + }); + + it('closes multiple positions successfully', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '1500', accountValue: '11500' }, + withdrawable: '10000', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '1.5', + entryPx: '50000', + positionValue: '75000', + unrealizedPnl: '100', + marginUsed: '1000', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + }, + type: 'oneWay', + }, + { + position: { + coin: 'ETH', + szi: '-2.0', + entryPx: '3000', + positionValue: '6000', + unrealizedPnl: '50', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '3300', + }, + type: 'oneWay', + }, + ], + crossMarginSummary: { + accountValue: '11500', + totalMarginUsed: '1500', + }, + }), + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }), + allMids: jest.fn().mockResolvedValue({ + BTC: '50000', + ETH: '3000', + }), + }), + ); + + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + order: jest.fn().mockResolvedValue({ + response: { + data: { + statuses: [{ filled: {} }, { filled: {} }], + }, + }, + }), + }), + ); + + const result = await provider.closePositions({ closeAll: true }); + + expect(result.success).toBe(true); + expect(result.successCount).toBe(2); + expect(result.failureCount).toBe(0); + expect(result.results).toHaveLength(2); + expect(result.results[0].coin).toBe('BTC'); + expect(result.results[1].coin).toBe('ETH'); + }); + + it('handles batch close errors', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '1000', accountValue: '11000' }, + withdrawable: '10000', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '1.0', + entryPx: '50000', + positionValue: '50000', + unrealizedPnl: '100', + marginUsed: '1000', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + }, + type: 'oneWay', + }, + ], + crossMarginSummary: { + accountValue: '11000', + totalMarginUsed: '1000', + }, + }), + }), + ); + + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + order: jest.fn().mockRejectedValue(new Error('Order failed')), + }), + ); + + const result = await provider.closePositions({ closeAll: true }); + + expect(result.success).toBe(false); + expect(result.successCount).toBe(0); + expect(result.failureCount).toBe(1); + expect(result.results[0].success).toBe(false); + expect(result.results[0].error).toBe('Order failed'); + }); + }); + }); + describe('updatePositionTPSL', () => { it('should update position TP/SL successfully', async () => { const updateParams = { @@ -2999,11 +3256,40 @@ describe('HyperLiquidProvider', () => { ); }); + it('should apply 2× fee multiplier for HIP-3 assets', async () => { + // HIP-3 asset (dex:SYMBOL format) + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + coin: 'xyz:TSLA', // HIP-3 asset + }); + + // HIP-3 should have 2× base fees: 0.045% * 2 = 0.09% + 0.1% MetaMask = 0.19% + expect(result.feeRate).toBe(0.0019); // 0.09% taker + 0.1% MetaMask fee + expect(result.feeAmount).toBe(190); // 100000 * 0.0019 + }); + + it('should apply 2× fee multiplier for HIP-3 maker orders', async () => { + // HIP-3 asset (dex:SYMBOL format) + const result = await provider.calculateFees({ + orderType: 'limit', + isMaker: true, + amount: '100000', + coin: 'abc:SPX', // HIP-3 asset + }); + + // HIP-3 should have 2× base fees: 0.015% * 2 = 0.03% + 0.1% MetaMask = 0.13% + expect(result.feeRate).toBe(0.0013); // 0.03% maker + 0.1% MetaMask fee + expect(result.feeAmount).toBe(130); // 100000 * 0.0013 + }); + it('should calculate fees for market orders', async () => { const result = await provider.calculateFees({ orderType: 'market', isMaker: false, amount: '100000', + coin: 'BTC', }); expect(result.feeRate).toBe(0.00145); // 0.045% taker + 0.1% MetaMask fee @@ -3013,8 +3299,10 @@ describe('HyperLiquidProvider', () => { it('should calculate fees for limit orders as taker', async () => { const result = await provider.calculateFees({ orderType: 'limit', + coin: 'BTC', isMaker: false, amount: '100000', + coin: 'ETH', }); expect(result.feeRate).toBe(0.00145); // 0.045% taker + 0.1% MetaMask fee @@ -3024,8 +3312,10 @@ describe('HyperLiquidProvider', () => { it('should calculate fees for limit orders as maker', async () => { const result = await provider.calculateFees({ orderType: 'limit', + coin: 'BTC', isMaker: true, amount: '100000', + coin: 'SOL', }); expect(result.feeRate).toBe(0.00115); // 0.015% maker + 0.1% MetaMask fee @@ -3035,8 +3325,10 @@ describe('HyperLiquidProvider', () => { it('should handle zero amount', async () => { const result = await provider.calculateFees({ orderType: 'market', + coin: 'BTC', isMaker: false, amount: '0', + coin: 'BTC', }); expect(result.feeRate).toBe(0.00145); // Includes 0.1% MetaMask fee @@ -3046,7 +3338,9 @@ describe('HyperLiquidProvider', () => { it('should handle undefined amount', async () => { const result = await provider.calculateFees({ orderType: 'market', + coin: 'BTC', isMaker: false, + coin: 'BTC', }); expect(result.feeRate).toBe(0.00145); // Includes 0.1% MetaMask fee @@ -3072,8 +3366,10 @@ describe('HyperLiquidProvider', () => { // First call should fetch from API const result1 = await provider.calculateFees({ orderType: 'market', + coin: 'BTC', isMaker: false, amount: '100000', + coin: 'BTC', }); // Should use dynamically calculated rate: 0.045% * (1 - 0.04 - 0.05) = 0.045% * 0.91 = 0.04095% @@ -3086,8 +3382,10 @@ describe('HyperLiquidProvider', () => { // Second call should use cache const result2 = await provider.calculateFees({ orderType: 'market', + coin: 'BTC', isMaker: false, amount: '100000', + coin: 'BTC', }); expect(result2.feeRate).toBeCloseTo(0.0014095, 6); // Includes MetaMask fee @@ -3110,8 +3408,10 @@ describe('HyperLiquidProvider', () => { const result = await provider.calculateFees({ orderType: 'market', + coin: 'BTC', isMaker: false, amount: '100000', + coin: 'BTC', }); // Should use base rates on failure @@ -3122,8 +3422,10 @@ describe('HyperLiquidProvider', () => { it('should handle non-numeric amount gracefully', async () => { const result = await provider.calculateFees({ orderType: 'market', + coin: 'BTC', isMaker: false, amount: 'invalid', + coin: 'BTC', }); expect(result.feeRate).toBe(0.00145); // Includes 0.1% MetaMask fee @@ -3135,6 +3437,7 @@ describe('HyperLiquidProvider', () => { orderType: 'market', isMaker: false, amount: '100000', + coin: 'BTC', }); expect(result).toHaveProperty('feeRate'); @@ -3146,6 +3449,7 @@ describe('HyperLiquidProvider', () => { it('should be async and return a Promise', () => { const result = provider.calculateFees({ orderType: 'market', + coin: 'BTC', isMaker: false, }); @@ -3172,6 +3476,7 @@ describe('HyperLiquidProvider', () => { const result = await provider.calculateFees({ orderType: 'market', + coin: 'BTC', isMaker: false, amount: '100000', }); @@ -3198,6 +3503,7 @@ describe('HyperLiquidProvider', () => { const result = await provider.calculateFees({ orderType: 'market', + coin: 'BTC', isMaker: false, amount: '100000', }); @@ -3225,6 +3531,7 @@ describe('HyperLiquidProvider', () => { const result = await provider.calculateFees({ orderType: 'market', + coin: 'BTC', isMaker: false, amount: '100000', }); @@ -3254,6 +3561,7 @@ describe('HyperLiquidProvider', () => { // Test market order with isMaker=true (should still use taker rate) const result = await provider.calculateFees({ orderType: 'market', + coin: 'BTC', isMaker: true, // This should be ignored for market orders amount: '100000', }); @@ -3282,6 +3590,7 @@ describe('HyperLiquidProvider', () => { const result = await provider.calculateFees({ orderType: 'market', + coin: 'BTC', isMaker: false, amount: '100000', }); @@ -3310,6 +3619,7 @@ describe('HyperLiquidProvider', () => { const result = await provider.calculateFees({ orderType: 'market', + coin: 'BTC', isMaker: false, amount: '100000', }); @@ -3338,6 +3648,7 @@ describe('HyperLiquidProvider', () => { const result = await provider.calculateFees({ orderType: 'market', + coin: 'BTC', isMaker: false, amount: '100000', }); @@ -3367,6 +3678,7 @@ describe('HyperLiquidProvider', () => { const result = await provider.calculateFees({ orderType: 'limit', + coin: 'BTC', isMaker: true, amount: '100000', }); @@ -3395,6 +3707,7 @@ describe('HyperLiquidProvider', () => { const result = await provider.calculateFees({ orderType: 'market', + coin: 'BTC', isMaker: false, amount: '100000', }); @@ -3541,6 +3854,7 @@ describe('HyperLiquidProvider', () => { // Act const result = await provider.calculateFees({ orderType: 'market', + coin: 'BTC', isMaker: false, amount: '100000', }); @@ -3560,6 +3874,7 @@ describe('HyperLiquidProvider', () => { // Act const result = await provider.calculateFees({ orderType: 'limit', + coin: 'BTC', isMaker: true, amount: '100000', }); @@ -3579,6 +3894,7 @@ describe('HyperLiquidProvider', () => { // Act const result = await provider.calculateFees({ orderType: 'market', + coin: 'BTC', isMaker: false, amount: '100000', }); @@ -3597,6 +3913,7 @@ describe('HyperLiquidProvider', () => { // Act const result = await provider.calculateFees({ orderType: 'market', + coin: 'BTC', isMaker: false, amount: '100000', }); @@ -3614,6 +3931,7 @@ describe('HyperLiquidProvider', () => { // Act const result = await provider.calculateFees({ orderType: 'limit', + coin: 'BTC', isMaker: true, amount: '100000', }); @@ -3649,6 +3967,7 @@ describe('HyperLiquidProvider', () => { // Act const result = await provider.calculateFees({ orderType: 'market', + coin: 'BTC', isMaker: false, amount: '100000', }); @@ -3668,6 +3987,7 @@ describe('HyperLiquidProvider', () => { // Verify discount is applied let result = await provider.calculateFees({ orderType: 'market', + coin: 'BTC', isMaker: false, amount: '100000', }); @@ -3679,6 +3999,7 @@ describe('HyperLiquidProvider', () => { // Assert - should return to full fees result = await provider.calculateFees({ orderType: 'market', + coin: 'BTC', isMaker: false, amount: '100000', }); @@ -3851,6 +4172,77 @@ describe('HyperLiquidProvider', () => { expect(result.isValid).toBe(false); expect(result.error).toBe('Unexpected error'); }); + + describe('existing position leverage validation', () => { + it('allows order when leverage equals existing position leverage', async () => { + const params: OrderParams = { + coin: 'BTC', + size: '0.1', + isBuy: true, + orderType: 'market', + currentPrice: 50000, + leverage: 10, + existingPositionLeverage: 10, + }; + + const result = await provider.validateOrder(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('allows order when leverage exceeds existing position leverage', async () => { + const params: OrderParams = { + coin: 'BTC', + size: '0.1', + isBuy: true, + orderType: 'market', + currentPrice: 50000, + leverage: 15, + existingPositionLeverage: 10, + }; + + const result = await provider.validateOrder(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('rejects order when leverage below existing position leverage', async () => { + const params: OrderParams = { + coin: 'BTC', + size: '0.1', + isBuy: true, + orderType: 'market', + currentPrice: 50000, + leverage: 5, + existingPositionLeverage: 10, + }; + + const result = await provider.validateOrder(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe( + 'perps.order.validation.leverage_below_position', + ); + }); + + it('allows any leverage when no existing position', async () => { + const params: OrderParams = { + coin: 'BTC', + size: '0.1', + isBuy: true, + orderType: 'market', + currentPrice: 50000, + leverage: 3, + }; + + const result = await provider.validateOrder(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + }); }); describe('Builder Fee and Referral Integration', () => { @@ -4461,7 +4853,11 @@ describe('HyperLiquidProvider', () => { perpDexs: jest.fn().mockResolvedValue([null]), }); - const result = await provider.getOpenOrders(); + // Mock getValidatedDexs to return main DEX + // eslint-disable-next-line @typescript-eslint/no-explicit-any + jest.spyOn(provider as any, 'getValidatedDexs').mockResolvedValue([null]); + + const result = await provider.getOpenOrders({ skipCache: true }); expect(result).toHaveLength(3); @@ -4607,4 +5003,744 @@ describe('HyperLiquidProvider', () => { expect(fills[0].liquidation).toBeUndefined(); }); }); + + describe('getOpenOrders additional coverage', () => { + it('returns empty array when frontendOpenOrders throws error', async () => { + // Arrange + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + frontendOpenOrders: jest.fn().mockRejectedValue(new Error('API Error')), + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + perpDexs: jest.fn().mockResolvedValue([null]), + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + jest.spyOn(provider as any, 'getValidatedDexs').mockResolvedValue([null]); + + // Act + const result = await provider.getOpenOrders({ skipCache: true }); + + // Assert + expect(result).toEqual([]); + }); + + it('returns cached orders when cache is initialized', async () => { + // Arrange + const cachedOrders = [ + { + orderId: '101', + symbol: 'ETH', + side: 'buy' as const, + orderType: 'limit' as const, + size: '1.0', + originalSize: '1.0', + filledSize: '0', + remainingSize: '1.0', + price: '2900', + status: 'open' as const, + timestamp: Date.now(), + detailedOrderType: 'Limit', + reduceOnly: false, + isTrigger: false, + }, + ]; + // Add cache methods to mock + mockSubscriptionService.isOrdersCacheInitialized = jest + .fn() + .mockReturnValue(true); + mockSubscriptionService.getCachedOrders = jest + .fn() + .mockReturnValue(cachedOrders); + + // Act + const result = await provider.getOpenOrders(); + + // Assert + expect(result).toEqual(cachedOrders); + expect(mockClientService.getInfoClient).not.toHaveBeenCalled(); + }); + + it('queries only main DEX when no additional DEXs enabled', async () => { + // Arrange + const mockFrontendOpenOrders = jest.fn().mockResolvedValue([ + { + coin: 'ETH', + side: 'B', + limitPx: '3000', + sz: '1.0', + oid: 301, + timestamp: Date.now(), + origSz: '1.0', + triggerCondition: '', + isTrigger: false, + triggerPx: '', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + tif: 'Gtc', + cloid: null, + }, + ]); + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + frontendOpenOrders: mockFrontendOpenOrders, + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '0', accountValue: '1000' }, + withdrawable: '1000', + assetPositions: [], + crossMarginSummary: { accountValue: '1000', totalMarginUsed: '0' }, + }), + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'ETH', szDecimals: 4, maxLeverage: 25 }], + }), + perpDexs: jest.fn().mockResolvedValue([null]), + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + jest.spyOn(provider as any, 'getValidatedDexs').mockResolvedValue([null]); + + // Act + const result = await provider.getOpenOrders({ skipCache: true }); + + // Assert + expect(result).toHaveLength(1); + expect(result[0].symbol).toBe('ETH'); + // Note: frontendOpenOrders is called twice - once for getOpenOrders and once for getPositions + expect(mockFrontendOpenOrders).toHaveBeenCalled(); + }); + + it('queries multiple DEXs when HIP-3 enabled', async () => { + // Arrange + // Ensure cache is disabled for this test + mockSubscriptionService.isOrdersCacheInitialized = jest + .fn() + .mockReturnValue(false); + + const mockFrontendOpenOrders = jest + .fn() + .mockImplementation((params: { user: string; dex?: string }) => { + if (params.dex === 'xyz') { + return Promise.resolve([ + { + coin: 'xyz:STOCK1', + side: 'B', + limitPx: '100', + sz: '10', + oid: 401, + timestamp: Date.now(), + origSz: '10', + triggerCondition: '', + isTrigger: false, + triggerPx: '', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + tif: 'Gtc', + cloid: null, + }, + ]); + } + // Main DEX + return Promise.resolve([ + { + coin: 'BTC', + side: 'A', + limitPx: '51000', + sz: '0.5', + oid: 402, + timestamp: Date.now(), + origSz: '0.5', + triggerCondition: '', + isTrigger: false, + triggerPx: '', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + tif: 'Gtc', + cloid: null, + }, + ]); + }); + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + frontendOpenOrders: mockFrontendOpenOrders, + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '0', accountValue: '1000' }, + withdrawable: '1000', + assetPositions: [], + crossMarginSummary: { accountValue: '1000', totalMarginUsed: '0' }, + }), + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'xyz:STOCK1', szDecimals: 2, maxLeverage: 20 }, + ], + }), + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + }); + const getValidatedDexsSpy = jest.spyOn( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + provider as any, + 'getValidatedDexs', + ); + getValidatedDexsSpy.mockResolvedValue([null, 'xyz']); + + // Act + const result = await provider.getOpenOrders({ skipCache: true }); + + // Assert + expect(result).toHaveLength(2); + // Verify both orders are present (order may vary due to Promise.all) + const symbols = result.map((r) => r.symbol); + expect(symbols).toContain('xyz:STOCK1'); + expect(symbols).toContain('BTC'); + // Verify both DEXs were queried + expect(mockFrontendOpenOrders).toHaveBeenCalled(); + expect( + mockFrontendOpenOrders.mock.calls.some((call) => call[0].dex === 'xyz'), + ).toBe(true); + }); + }); + + describe('getUserHistory', () => { + it('returns user history items successfully', async () => { + // Arrange + const mockLedgerUpdates = [ + { + delta: { type: 'deposit', usdc: '100' }, + time: Date.now(), + hash: '0x123', + }, + { + delta: { type: 'withdraw', usdc: '50' }, + time: Date.now() - 3600000, + hash: '0x456', + }, + ]; + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userNonFundingLedgerUpdates: jest + .fn() + .mockResolvedValue(mockLedgerUpdates), + }), + ); + + // Act + const result = await provider.getUserHistory(); + + // Assert + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + expect(mockClientService.getInfoClient).toHaveBeenCalled(); + }); + + it('returns empty array on API error', async () => { + // Arrange + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userNonFundingLedgerUpdates: jest + .fn() + .mockRejectedValue(new Error('API Error')), + }), + ); + + // Act + const result = await provider.getUserHistory(); + + // Assert + expect(result).toEqual([]); + }); + + it('handles custom time range parameters', async () => { + // Arrange + const startTime = Date.now() - 86400000; // 24h ago + const endTime = Date.now(); + const mockInfoClient = createMockInfoClient(); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + // Act + await provider.getUserHistory({ startTime, endTime }); + + // Assert + expect(mockInfoClient.userNonFundingLedgerUpdates).toHaveBeenCalledWith( + expect.objectContaining({ + startTime, + endTime, + }), + ); + }); + + it('uses default account when no accountId provided', async () => { + // Arrange + const mockInfoClient = createMockInfoClient(); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + // Act + await provider.getUserHistory(); + + // Assert + expect(mockWalletService.getUserAddressWithDefault).toHaveBeenCalledWith( + undefined, + ); + expect(mockInfoClient.userNonFundingLedgerUpdates).toHaveBeenCalled(); + }); + }); + + describe('getHistoricalPortfolio', () => { + it('returns historical portfolio value from 24h ago', async () => { + // Arrange + const yesterday = Date.now() - 86400000; + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + portfolio: jest.fn().mockResolvedValue([ + null, + [ + null, + { + accountValueHistory: [ + [yesterday, '10000'], + [yesterday - 86400000, '9500'], + ], + }, + ], + ]), + }), + ); + + // Act + const result = await provider.getHistoricalPortfolio(); + + // Assert + expect(result.accountValue1dAgo).toBeDefined(); + expect(result.timestamp).toBeDefined(); + }); + + it('finds closest entry before target timestamp', async () => { + // Arrange + const now = Date.now(); + const closestTime = now - 87000000; // Slightly older than 24h + + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + portfolio: jest.fn().mockResolvedValue([ + null, + [ + null, + { + accountValueHistory: [ + [closestTime, '10000'], // This should be selected + [now - 172800000, '9500'], // Too old + ], + }, + ], + ]), + }), + ); + + // Act + const result = await provider.getHistoricalPortfolio(); + + // Assert + expect(result.accountValue1dAgo).toBe('10000'); + expect(result.timestamp).toBe(closestTime); + }); + + it('returns fallback when no historical data exists', async () => { + // Arrange + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + portfolio: jest.fn().mockResolvedValue([ + null, + [ + null, + { + accountValueHistory: [], + }, + ], + ]), + }), + ); + + // Act + const result = await provider.getHistoricalPortfolio(); + + // Assert + expect(result.accountValue1dAgo).toBe('0'); + expect(result.timestamp).toBe(0); + }); + + it('handles empty portfolio data gracefully', async () => { + // Arrange + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + portfolio: jest.fn().mockResolvedValue(null), + }), + ); + + // Act + const result = await provider.getHistoricalPortfolio(); + + // Assert + expect(result.accountValue1dAgo).toBe('0'); + expect(result.timestamp).toBe(0); + }); + + it('returns zero values on error', async () => { + // Arrange + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + portfolio: jest + .fn() + .mockRejectedValue(new Error('Portfolio API error')), + }), + ); + + // Act + const result = await provider.getHistoricalPortfolio(); + + // Assert + expect(result.accountValue1dAgo).toBe('0'); + expect(result.timestamp).toBe(0); + }); + }); + + describe('getAvailableHip3Dexs', () => { + it('returns HIP-3 DEX names when equity enabled', async () => { + // Arrange - use existing provider with updated mock + const mockInfoClientWithDexs = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([ + null, + { name: 'dex1', url: 'https://dex1.com' }, + { name: 'dex2', url: 'https://dex2.com' }, + ]), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClientWithDexs); + + // Create a provider instance with equity enabled for this specific test + const testProvider = new HyperLiquidProvider({ equityEnabled: true }); + + // Override the private cachedValidatedDexs to simulate already validated state + // This avoids the complex initialization flow + Object.defineProperty(testProvider, 'cachedValidatedDexs', { + value: null, // Force re-evaluation + writable: true, + configurable: true, + }); + + // Act + const result = await testProvider.getAvailableHip3Dexs(); + + // Assert + expect(Array.isArray(result)).toBe(true); + expect(mockInfoClientWithDexs.perpDexs).toHaveBeenCalled(); + }); + + it('returns empty array when equity disabled', async () => { + // Arrange + const disabledProvider = new HyperLiquidProvider({ + equityEnabled: false, + }); + + // Act + const result = await disabledProvider.getAvailableHip3Dexs(); + + // Assert + expect(result).toEqual([]); + }); + + it('returns empty array when perpDexs returns invalid data', async () => { + // Arrange + const hip3Provider = new HyperLiquidProvider({ equityEnabled: true }); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + perpDexs: jest.fn().mockResolvedValue(null), + }), + ); + + // Act + const result = await hip3Provider.getAvailableHip3Dexs(); + + // Assert + expect(result).toEqual([]); + }); + }); + + describe('transferBetweenDexs', () => { + beforeEach(() => { + // Add spotMeta to mock for getUsdcTokenId + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + spotMeta: jest.fn().mockResolvedValue({ + tokens: [{ name: 'USDC', tokenId: '0xabc123' }], + }), + }), + ); + }); + + it('transfers USDC between DEXs successfully', async () => { + // Arrange + const transferParams = { + sourceDex: 'dex1', + destinationDex: 'dex2', + amount: '100', + }; + + // Act + const result = await provider.transferBetweenDexs(transferParams); + + // Assert + expect(result.success).toBe(true); + expect( + mockClientService.getExchangeClient().sendAsset, + ).toHaveBeenCalledWith( + expect.objectContaining({ + sourceDex: 'dex1', + destinationDex: 'dex2', + amount: '100', + token: expect.any(String), + }), + ); + }); + + it('rejects transfer with zero amount', async () => { + // Arrange + const transferParams = { + sourceDex: 'dex1', + destinationDex: 'dex2', + amount: '0', + }; + + // Act + const result = await provider.transferBetweenDexs(transferParams); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toContain('must be greater than 0'); + }); + + it('rejects transfer when source equals destination', async () => { + // Arrange + const transferParams = { + sourceDex: 'dex1', + destinationDex: 'dex1', + amount: '100', + }; + + // Act + const result = await provider.transferBetweenDexs(transferParams); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toContain('must be different'); + }); + + it('handles sendAsset failure gracefully', async () => { + // Arrange + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + sendAsset: jest.fn().mockResolvedValue({ + status: 'error', + message: 'Insufficient balance', + }), + }), + ); + const transferParams = { + sourceDex: 'dex1', + destinationDex: 'dex2', + amount: '100', + }; + + // Act + const result = await provider.transferBetweenDexs(transferParams); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + + it('calls getUsdcTokenId to get correct token', async () => { + // Arrange + const mockSpotMeta = jest.fn().mockResolvedValue({ + tokens: [{ name: 'USDC', tokenId: '0xspecific' }], + }); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(createMockInfoClient({ spotMeta: mockSpotMeta })); + const transferParams = { + sourceDex: '', + destinationDex: 'dex1', + amount: '100', + }; + + // Act + await provider.transferBetweenDexs(transferParams); + + // Assert + expect(mockSpotMeta).toHaveBeenCalled(); + expect( + mockClientService.getExchangeClient().sendAsset, + ).toHaveBeenCalledWith( + expect.objectContaining({ + token: 'USDC:0xspecific', + }), + ); + }); + }); + + describe('getUserNonFundingLedgerUpdates', () => { + it('returns non-funding ledger updates', async () => { + // Arrange + const mockUpdates = [ + { + delta: { type: 'deposit', usdc: '100' }, + time: Date.now(), + hash: '0x123', + }, + { + delta: { type: 'withdraw', usdc: '50' }, + time: Date.now() - 3600000, + hash: '0x456', + }, + ]; + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userNonFundingLedgerUpdates: jest.fn().mockResolvedValue(mockUpdates), + }), + ); + + // Act + const result = await provider.getUserNonFundingLedgerUpdates(); + + // Assert + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(2); + expect(mockClientService.getInfoClient).toHaveBeenCalled(); + }); + + it('returns empty array on error', async () => { + // Arrange + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userNonFundingLedgerUpdates: jest + .fn() + .mockRejectedValue(new Error('API Error')), + }), + ); + + // Act + const result = await provider.getUserNonFundingLedgerUpdates(); + + // Assert + expect(result).toEqual([]); + }); + }); + + describe('HIP-3 Private Methods', () => { + interface ProviderWithPrivateMethods { + getUsdcTokenId(): Promise; + getBalanceForDex(params: { dex: string | null }): Promise; + findSourceDexWithBalance(params: { + targetDex: string; + requiredAmount: number; + }): Promise<{ sourceDex: string; available: number } | null>; + cachedUsdcTokenId?: string; + enabledDexs: string[]; + } + + let testableProvider: ProviderWithPrivateMethods; + + beforeEach(() => { + testableProvider = provider as unknown as ProviderWithPrivateMethods; + // Reset cache + testableProvider.cachedUsdcTokenId = undefined; + }); + + describe('getUsdcTokenId', () => { + it('returns cached token ID when available', async () => { + // Arrange + testableProvider.cachedUsdcTokenId = 'USDC:0xabc123'; + + // Act + const result = await testableProvider.getUsdcTokenId(); + + // Assert + expect(result).toBe('USDC:0xabc123'); + expect(mockClientService.getInfoClient).not.toHaveBeenCalled(); + }); + + it('fetches and caches token ID on first call', async () => { + // Arrange + const mockSpotMeta = { + tokens: [ + { name: 'USDC', tokenId: '0xdef456' }, + { name: 'USDT', tokenId: '0x789abc' }, + ], + }; + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + spotMeta: jest.fn().mockResolvedValue(mockSpotMeta), + }), + ); + + // Act + const result = await testableProvider.getUsdcTokenId(); + + // Assert + expect(result).toBe('USDC:0xdef456'); + expect(testableProvider.cachedUsdcTokenId).toBe('USDC:0xdef456'); + expect(mockClientService.getInfoClient).toHaveBeenCalledTimes(1); + }); + + it('throws error when USDC token not found in metadata', async () => { + // Arrange + const mockSpotMeta = { + tokens: [{ name: 'USDT', tokenId: '0x789abc' }], + }; + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + spotMeta: jest.fn().mockResolvedValue(mockSpotMeta), + }), + ); + + // Act & Assert + await expect(testableProvider.getUsdcTokenId()).rejects.toThrow( + 'USDC token not found in spot metadata', + ); + }); + }); + + describe('findSourceDexWithBalance', () => { + it('finds main DEX with sufficient balance', async () => { + jest + .spyOn(testableProvider, 'getBalanceForDex') + .mockResolvedValue(1000); + const result = await testableProvider.findSourceDexWithBalance({ + targetDex: 'xyz', + requiredAmount: 500, + }); + expect(result).toEqual({ sourceDex: '', available: 1000 }); + }); + + it('returns null when insufficient balance', async () => { + jest.spyOn(testableProvider, 'getBalanceForDex').mockResolvedValue(100); + const result = await testableProvider.findSourceDexWithBalance({ + targetDex: 'xyz', + requiredAmount: 500, + }); + expect(result).toBeNull(); + }); + }); + }); }); diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts index cd093538f223..7c6da22c42fd 100644 --- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts +++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts @@ -10,6 +10,7 @@ import { FEE_RATES, getBridgeInfo, getChainId, + HIP3_ASSET_MARKET_TYPES, HIP3_MARGIN_CONFIG, HYPERLIQUID_WITHDRAWAL_MINUTES, REFERRAL_CONFIG, @@ -59,9 +60,13 @@ import { transformMarketData } from '../../utils/marketDataTransform'; import type { AccountState, AssetRoute, + BatchCancelOrdersParams, CancelOrderParams, CancelOrderResult, + CancelOrdersResult, ClosePositionParams, + ClosePositionsParams, + ClosePositionsResult, DepositParams, DisconnectResult, EditOrderParams, @@ -199,6 +204,13 @@ export class HyperLiquidProvider implements IPerpsProvider { // Initialize clients this.initializeClients(); + + // Debug: Confirm batch methods exist + DevLogger.log('[HyperLiquidProvider] Constructor complete', { + hasBatchCancel: typeof this.cancelOrders === 'function', + hasBatchClose: typeof this.closePositions === 'function', + protocolId: this.protocolId, + }); } /** @@ -1818,6 +1830,291 @@ export class HyperLiquidProvider implements IPerpsProvider { } } + /** + * Cancel multiple orders in a single batch API call + * Optimized implementation that uses HyperLiquid's batch cancel endpoint + */ + async cancelOrders( + params: BatchCancelOrdersParams, + ): Promise { + try { + DevLogger.log('Batch canceling orders:', { + count: params.length, + }); + + if (params.length === 0) { + return { + success: false, + successCount: 0, + failureCount: 0, + results: [], + }; + } + + await this.ensureReady(); + + const exchangeClient = this.clientService.getExchangeClient(); + + // Map orders to SDK format and validate coins + const cancelRequests = params.map((order) => { + const asset = this.coinToAssetId.get(order.coin); + if (asset === undefined) { + throw new Error(`Asset not found for coin: ${order.coin}`); + } + return { + a: asset, + o: parseInt(order.orderId, 10), + }; + }); + + // Single batch API call + const result = await exchangeClient.cancel({ + cancels: cancelRequests, + }); + + // Parse response statuses (one per order) + const statuses = result.response.data.statuses; + const successCount = statuses.filter((s) => s === 'success').length; + const failureCount = statuses.length - successCount; + + return { + success: successCount > 0, + successCount, + failureCount, + results: statuses.map((status, index) => ({ + orderId: params[index].orderId, + coin: params[index].coin, + success: status === 'success', + error: + status !== 'success' + ? (status as { error: string }).error + : undefined, + })), + }; + } catch (error) { + Logger.error( + ensureError(error), + this.getErrorContext('cancelOrders', { + orderCount: params.length, + }), + ); + // Return all orders as failed + return { + success: false, + successCount: 0, + failureCount: params.length, + results: params.map((order) => ({ + orderId: order.orderId, + coin: order.coin, + success: false, + error: error instanceof Error ? error.message : 'Batch cancel failed', + })), + }; + } + } + + async closePositions( + params: ClosePositionsParams, + ): Promise { + // Declare outside try block so it's accessible in catch block + let positionsToClose: Position[] = []; + + try { + await this.ensureReady(); + + // Get all current positions + const positions = await this.getPositions(); + + // Filter positions based on params + positionsToClose = + params.closeAll || !params.coins || params.coins.length === 0 + ? positions + : positions.filter((p) => params.coins?.includes(p.coin)); + + DevLogger.log('Batch closing positions:', { + count: positionsToClose.length, + closeAll: params.closeAll, + coins: params.coins, + }); + + if (positionsToClose.length === 0) { + return { + success: false, + successCount: 0, + failureCount: 0, + results: [], + }; + } + + // Get exchange client and meta for price/size formatting + const exchangeClient = this.clientService.getExchangeClient(); + const infoClient = this.clientService.getInfoClient(); + + // Track HIP-3 positions and freed margins for post-close transfers + const hip3Transfers: { + sourceDex: string; + freedMargin: number; + }[] = []; + + // Build orders array + const orders: SDKOrderParams[] = []; + + for (const position of positionsToClose) { + // Extract DEX name for HIP-3 positions + const { dex: dexName } = parseAssetName(position.coin); + const isHip3Position = position.coin.includes(':'); + + // Get asset info for formatting + const meta = await infoClient.meta({ dex: dexName ?? '' }); + if (!meta.universe || !Array.isArray(meta.universe)) { + throw new Error(`Invalid universe data for ${position.coin}`); + } + + const assetInfo = meta.universe.find( + (asset) => asset.name === position.coin, + ); + if (!assetInfo) { + throw new Error( + `Asset ${position.coin} not found in ${ + dexName || 'main' + } DEX universe`, + ); + } + + // Get asset ID + const assetId = this.coinToAssetId.get(position.coin); + if (assetId === undefined) { + throw new Error(`Asset ID not found for ${position.coin}`); + } + + // Calculate position details (always full close) + const positionSize = parseFloat(position.size); + const isBuy = positionSize < 0; // Close opposite side + const closeSize = Math.abs(positionSize); + const totalMarginUsed = parseFloat(position.marginUsed); + + // Track HIP-3 transfers (full position close means all margin is freed) + if (isHip3Position && dexName && !this.useDexAbstraction) { + hip3Transfers.push({ + sourceDex: dexName, + freedMargin: totalMarginUsed, + }); + } + + // Get current price for market order slippage + const mids = await infoClient.allMids({ dex: dexName ?? '' }); + const currentPrice = parseFloat(mids[position.coin] || '0'); + if (currentPrice === 0) { + throw new Error(`No price available for ${position.coin}`); + } + + // Calculate order price with slippage + const slippage = TRADING_DEFAULTS.slippage; + const orderPrice = isBuy + ? currentPrice * (1 + slippage) + : currentPrice * (1 - slippage); + + // Format size and price + const formattedSize = formatHyperLiquidSize({ + size: closeSize, + szDecimals: assetInfo.szDecimals, + }); + + const formattedPrice = formatHyperLiquidPrice({ + price: orderPrice, + szDecimals: assetInfo.szDecimals, + }); + + // Build reduce-only order + orders.push({ + a: assetId, + b: isBuy, + p: formattedPrice, + s: formattedSize, + r: true, // reduceOnly + t: { limit: { tif: 'Ioc' } }, // Immediate or cancel for market-like execution + }); + } + + // Calculate discounted builder fee if reward discount is active + let builderFee = BUILDER_FEE_CONFIG.maxFeeTenthsBps; + if (this.userFeeDiscountBips !== undefined) { + builderFee = Math.floor( + builderFee * (1 - this.userFeeDiscountBips / BASIS_POINTS_DIVISOR), + ); + } + + // Single batch API call + const result = await exchangeClient.order({ + orders, + grouping: 'na', + builder: { + b: this.getBuilderAddress(this.clientService.isTestnetMode()), + f: builderFee, + }, + }); + + // Parse response statuses (one per order) + const statuses = result.response.data.statuses; + const successCount = statuses.filter( + (s) => 'filled' in s || 'resting' in s, + ).length; + const failureCount = statuses.length - successCount; + + // Handle HIP-3 margin transfers for successful closes + if (!this.useDexAbstraction) { + for (let i = 0; i < statuses.length; i++) { + const status = statuses[i]; + const isSuccess = 'filled' in status || 'resting' in status; + + if (isSuccess && hip3Transfers[i]) { + const { sourceDex, freedMargin } = hip3Transfers[i]; + DevLogger.log( + 'Position closed successfully, initiating manual auto-transfer back', + { coin: positionsToClose[i].coin, freedMargin }, + ); + + // Non-blocking: Transfer freed margin back to main DEX + await this.autoTransferBackAfterClose({ + sourceDex, + freedMargin, + }); + } + } + } + + return { + success: successCount > 0, + successCount, + failureCount, + results: statuses.map((status, index) => ({ + coin: positionsToClose[index].coin, + success: 'filled' in status || 'resting' in status, + error: + 'error' in status ? (status as { error: string }).error : undefined, + })), + }; + } catch (error) { + Logger.error( + ensureError(error), + this.getErrorContext('closePositions', { + positionCount: positionsToClose.length, + }), + ); + // Return all positions as failed + return { + success: false, + successCount: 0, + failureCount: positionsToClose.length, + results: positionsToClose.map((position) => ({ + coin: position.coin, + success: false, + error: error instanceof Error ? error.message : 'Batch close failed', + })), + }; + } + } + /** * Update TP/SL for an existing position * @@ -2184,7 +2481,24 @@ export class HyperLiquidProvider implements IPerpsProvider { */ async getPositions(params?: GetPositionsParams): Promise { try { - DevLogger.log('Getting positions via HyperLiquid SDK'); + // Try WebSocket cache first (unless explicitly bypassed) + if ( + !params?.skipCache && + this.subscriptionService.isPositionsCacheInitialized() + ) { + const cachedPositions = + this.subscriptionService.getCachedPositions() || []; + DevLogger.log('Using cached positions from WebSocket', { + count: cachedPositions.length, + }); + return cachedPositions; + } + + // Fallback to API call + DevLogger.log( + 'Fetching positions via API', + params?.skipCache ? '(skipCache requested)' : '(cache not initialized)', + ); await this.ensureReady(); @@ -2490,9 +2804,22 @@ export class HyperLiquidProvider implements IPerpsProvider { */ async getOpenOrders(params?: GetOrdersParams): Promise { try { + // Try WebSocket cache first (unless explicitly bypassed) + if ( + !params?.skipCache && + this.subscriptionService.isOrdersCacheInitialized() + ) { + const cachedOrders = this.subscriptionService.getCachedOrders() || []; + DevLogger.log('Using cached open orders from WebSocket', { + count: cachedOrders.length, + }); + return cachedOrders; + } + + // Fallback to API call DevLogger.log( - 'Getting currently open orders via HyperLiquid SDK', - params || '(no params)', + 'Fetching open orders via API', + params?.skipCache ? '(skipCache requested)' : '(cache not initialized)', ); await this.ensureReady(); @@ -3154,11 +3481,14 @@ export class HyperLiquidProvider implements IPerpsProvider { }); // Transform to UI-friendly format using standalone utility - return transformMarketData({ - universe: combinedUniverse, - assetCtxs: combinedAssetCtxs, - allMids: combinedAllMids, - }); + return transformMarketData( + { + universe: combinedUniverse, + assetCtxs: combinedAssetCtxs, + allMids: combinedAllMids, + }, + HIP3_ASSET_MARKET_TYPES, + ); } /** @@ -3250,6 +3580,21 @@ export class HyperLiquidProvider implements IPerpsProvider { } } + // Check if order leverage meets existing position requirement (HyperLiquid protocol constraint) + if ( + params.leverage && + params.existingPositionLeverage && + params.leverage < params.existingPositionLeverage + ) { + return { + isValid: false, + error: strings('perps.order.validation.leverage_below_position', { + required: params.existingPositionLeverage.toString(), + provided: params.leverage.toString(), + }), + }; + } + // Validate order value against max limits if (params.currentPrice && params.leverage) { try { @@ -4033,16 +4378,34 @@ export class HyperLiquidProvider implements IPerpsProvider { async calculateFees( params: FeeCalculationParams, ): Promise { - const { orderType, isMaker = false, amount } = params; + const { orderType, isMaker = false, amount, coin } = params; // Start with base rates from config let feeRate = orderType === 'market' || !isMaker ? FEE_RATES.taker : FEE_RATES.maker; + // HIP-3 assets have 2× base fees (per fees.md line 9) + // Parse coin to detect HIP-3 DEX (e.g., "xyz:TSLA" → dex="xyz") + const { dex } = parseAssetName(coin); + const isHip3Asset = dex !== null; + if (isHip3Asset) { + const originalRate = feeRate; + feeRate *= 2; + DevLogger.log('HIP-3 Fee Multiplier Applied', { + coin, + dex, + originalBaseRate: originalRate, + hip3BaseRate: feeRate, + multiplier: 2, + }); + } + DevLogger.log('HyperLiquid Fee Calculation Started', { orderType, isMaker, amount, + coin, + isHip3Asset, baseFeeRate: feeRate, baseTakerRate: FEE_RATES.taker, baseMakerRate: FEE_RATES.maker, diff --git a/app/components/UI/Perps/controllers/selectors.test.ts b/app/components/UI/Perps/controllers/selectors.test.ts index 08876b63f53f..2f3b2d1ce7b7 100644 --- a/app/components/UI/Perps/controllers/selectors.test.ts +++ b/app/components/UI/Perps/controllers/selectors.test.ts @@ -1,29 +1,251 @@ -import { selectIsFirstTimeUser } from './selectors'; +import { + selectIsFirstTimeUser, + selectTradeConfiguration, + selectWatchlistMarkets, + selectIsWatchlistMarket, + selectHasPlacedFirstOrder, + selectMarketFilterPreferences, +} from './selectors'; import type { PerpsControllerState } from './PerpsController'; +import { MARKET_SORTING_CONFIG } from '../constants/perpsConfig'; describe('PerpsController selectors', () => { describe('selectIsFirstTimeUser', () => { - it('should return true when state is undefined', () => { + it('returns true when state is undefined', () => { expect(selectIsFirstTimeUser(undefined)).toBe(true); }); - it('should return true when isFirstTimeUser is true', () => { + it('returns true when isFirstTimeUser is true', () => { const state = { isFirstTimeUser: { testnet: true, mainnet: true }, } as PerpsControllerState; expect(selectIsFirstTimeUser(state)).toBe(true); }); - it('should return false when isFirstTimeUser is false', () => { + it('returns false when isFirstTimeUser is false', () => { const state = { isFirstTimeUser: { testnet: false, mainnet: false }, } as PerpsControllerState; expect(selectIsFirstTimeUser(state)).toBe(false); }); - it('should return true when isFirstTimeUser is undefined in state', () => { + it('returns true when isFirstTimeUser is undefined in state', () => { const state = {} as PerpsControllerState; expect(selectIsFirstTimeUser(state)).toBe(true); }); }); + + describe('selectTradeConfiguration', () => { + it('returns saved config for mainnet when not testnet', () => { + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: { + BTC: { leverage: 10 }, + }, + testnet: {}, + }, + } as unknown as PerpsControllerState; + + const result = selectTradeConfiguration(state, 'BTC'); + + expect(result).toEqual({ leverage: 10 }); + }); + + it('returns saved config for testnet when testnet', () => { + const state = { + isTestnet: true, + tradeConfigurations: { + mainnet: {}, + testnet: { + ETH: { leverage: 5 }, + }, + }, + } as unknown as PerpsControllerState; + + const result = selectTradeConfiguration(state, 'ETH'); + + expect(result).toEqual({ leverage: 5 }); + }); + + it('returns undefined when no config exists for asset', () => { + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: {}, + testnet: {}, + }, + } as PerpsControllerState; + + const result = selectTradeConfiguration(state, 'BTC'); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when config exists but has no leverage', () => { + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: { + BTC: {}, + }, + testnet: {}, + }, + } as unknown as PerpsControllerState; + + const result = selectTradeConfiguration(state, 'BTC'); + + expect(result).toBeUndefined(); + }); + + it('returns config for specific asset when multiple assets configured', () => { + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: { + BTC: { leverage: 10 }, + ETH: { leverage: 5 }, + SOL: { leverage: 3 }, + }, + testnet: {}, + }, + } as unknown as PerpsControllerState; + + const ethResult = selectTradeConfiguration(state, 'ETH'); + const btcResult = selectTradeConfiguration(state, 'BTC'); + + expect(ethResult).toEqual({ leverage: 5 }); + expect(btcResult).toEqual({ leverage: 10 }); + }); + }); + + describe('selectWatchlistMarkets', () => { + it('returns mainnet watchlist when not on testnet', () => { + const state = { + isTestnet: false, + watchlistMarkets: { + mainnet: ['BTC', 'ETH', 'SOL'], + testnet: ['DOGE'], + }, + } as unknown as PerpsControllerState; + + const result = selectWatchlistMarkets(state); + + expect(result).toEqual(['BTC', 'ETH', 'SOL']); + }); + + it('returns testnet watchlist when on testnet', () => { + const state = { + isTestnet: true, + watchlistMarkets: { + mainnet: ['BTC', 'ETH'], + testnet: ['DOGE', 'PEPE'], + }, + } as unknown as PerpsControllerState; + + const result = selectWatchlistMarkets(state); + + expect(result).toEqual(['DOGE', 'PEPE']); + }); + + it('returns empty array when watchlist is undefined', () => { + const state = { + isTestnet: false, + } as unknown as PerpsControllerState; + + const result = selectWatchlistMarkets(state); + + expect(result).toEqual([]); + }); + }); + + describe('selectIsWatchlistMarket', () => { + it('returns true when market is in watchlist', () => { + const state = { + isTestnet: false, + watchlistMarkets: { + mainnet: ['BTC', 'ETH', 'SOL'], + testnet: [], + }, + } as unknown as PerpsControllerState; + + const result = selectIsWatchlistMarket(state, 'ETH'); + + expect(result).toBe(true); + }); + + it('returns false when market is not in watchlist', () => { + const state = { + isTestnet: false, + watchlistMarkets: { + mainnet: ['BTC', 'ETH'], + testnet: [], + }, + } as unknown as PerpsControllerState; + + const result = selectIsWatchlistMarket(state, 'SOL'); + + expect(result).toBe(false); + }); + }); + + describe('selectHasPlacedFirstOrder', () => { + it('returns mainnet value when not on testnet', () => { + const state = { + isTestnet: false, + hasPlacedFirstOrder: { + mainnet: true, + testnet: false, + }, + } as unknown as PerpsControllerState; + + const result = selectHasPlacedFirstOrder(state); + + expect(result).toBe(true); + }); + + it('returns testnet value when on testnet', () => { + const state = { + isTestnet: true, + hasPlacedFirstOrder: { + mainnet: true, + testnet: false, + }, + } as unknown as PerpsControllerState; + + const result = selectHasPlacedFirstOrder(state); + + expect(result).toBe(false); + }); + + it('returns false when hasPlacedFirstOrder is undefined', () => { + const state = { + isTestnet: false, + } as unknown as PerpsControllerState; + + const result = selectHasPlacedFirstOrder(state); + + expect(result).toBe(false); + }); + }); + + describe('selectMarketFilterPreferences', () => { + it('returns saved filter preferences when defined', () => { + const state = { + marketFilterPreferences: 'price', + } as unknown as PerpsControllerState; + + const result = selectMarketFilterPreferences(state); + + expect(result).toBe('price'); + }); + + it('returns default volume when preference is undefined', () => { + const state = {} as unknown as PerpsControllerState; + + const result = selectMarketFilterPreferences(state); + + expect(result).toBe(MARKET_SORTING_CONFIG.DEFAULT_SORT_OPTION_ID); + }); + }); }); diff --git a/app/components/UI/Perps/controllers/selectors.ts b/app/components/UI/Perps/controllers/selectors.ts index 608710662d8c..8510a868b7d2 100644 --- a/app/components/UI/Perps/controllers/selectors.ts +++ b/app/components/UI/Perps/controllers/selectors.ts @@ -1,4 +1,8 @@ import type { PerpsControllerState } from './PerpsController'; +import { + MARKET_SORTING_CONFIG, + type SortOptionId, +} from '../constants/perpsConfig'; /** * Select whether the user is a first-time perps user @@ -27,3 +31,60 @@ export const selectHasPlacedFirstOrder = ( } return state?.hasPlacedFirstOrder?.mainnet ?? false; }; + +/** + * Select watchlist markets for the current network + * @param state - PerpsController state + * @returns Array of watchlist market symbols for current network + */ +export const selectWatchlistMarkets = ( + state: PerpsControllerState, +): string[] => { + if (state?.isTestnet) { + return state?.watchlistMarkets?.testnet ?? []; + } + return state?.watchlistMarkets?.mainnet ?? []; +}; + +/** + * Check if a specific market is in the watchlist on the current network + * @param state - PerpsController state + * @param symbol - Market symbol to check (e.g., 'BTC', 'ETH') + * @returns boolean indicating if market is in watchlist + */ +export const selectIsWatchlistMarket = ( + state: PerpsControllerState, + symbol: string, +): boolean => { + const watchlist = selectWatchlistMarkets(state); + return watchlist.includes(symbol); +}; + +/** + * Select trade configuration for a specific market on the current network + * @param state - PerpsController state + * @param coin - Market symbol (e.g., 'BTC', 'ETH') + * @returns Trade configuration object or undefined + */ +export const selectTradeConfiguration = ( + state: PerpsControllerState, + coin: string, +): { leverage?: number } | undefined => { + const network = state?.isTestnet ? 'testnet' : 'mainnet'; + const config = state?.tradeConfigurations?.[network]?.[coin]; + + if (!config?.leverage) return undefined; + + return { leverage: config.leverage }; +}; + +/** + * Select market filter preferences (network-independent) + * @param state - PerpsController state + * @returns Sort/filter option ID + */ +export const selectMarketFilterPreferences = ( + state: PerpsControllerState, +): SortOptionId => + state?.marketFilterPreferences ?? + MARKET_SORTING_CONFIG.DEFAULT_SORT_OPTION_ID; diff --git a/app/components/UI/Perps/controllers/types/index.ts b/app/components/UI/Perps/controllers/types/index.ts index b4d2228d65e1..0419411a7738 100644 --- a/app/components/UI/Perps/controllers/types/index.ts +++ b/app/components/UI/Perps/controllers/types/index.ts @@ -40,12 +40,20 @@ export interface GetUserHistoryParams { accountId?: CaipAccountId; } +// Trade configuration saved per market per network +export interface TradeConfiguration { + leverage?: number; // Last used leverage for this market +} + // Order type enumeration export type OrderType = 'market' | 'limit'; // Market asset type classification (reusable across components) export type MarketType = 'crypto' | 'equity' | 'commodity' | 'forex'; +// Market type filter including 'all' option for UI filtering +export type MarketTypeFilter = MarketType | 'all'; + // Input method for amount entry tracking export type InputMethod = | 'default' @@ -91,6 +99,7 @@ export type OrderParams = { grouping?: 'na' | 'normalTpsl' | 'positionTpsl'; // Override grouping (defaults: 'na' without TP/SL, 'normalTpsl' with TP/SL) currentPrice?: number; // Current market price (avoids extra API call if provided) leverage?: number; // Leverage to apply for the order (e.g., 10 for 10x leverage) + existingPositionLeverage?: number; // Existing position leverage for validation (protocol constraint) // Optional tracking data for MetaMetrics events trackingData?: TrackingData; @@ -169,6 +178,22 @@ export type ClosePositionParams = { trackingData?: TrackingData; }; +export type ClosePositionsParams = { + coins?: string[]; // Optional: specific coins to close (omit or empty array to close all) + closeAll?: boolean; // Explicitly close all positions +}; + +export type ClosePositionsResult = { + success: boolean; // Overall success (true if at least one position closed) + successCount: number; // Number of positions closed successfully + failureCount: number; // Number of positions that failed to close + results: { + coin: string; + success: boolean; + error?: string; + }[]; +}; + export interface InitializeResult { success: boolean; error?: string; @@ -230,6 +255,10 @@ export interface PerpsMarketData { * Trading volume as formatted string (e.g., '$1.2B', '$850M') */ volume: string; + /** + * Open interest as formatted string (e.g., '$24.5M', '$1.2B') + */ + openInterest?: string; /** * Next funding time in milliseconds since epoch (optional, market-specific) */ @@ -297,6 +326,29 @@ export interface CancelOrderResult { error?: string; } +export type BatchCancelOrdersParams = { + orderId: string; + coin: string; +}[]; + +export type CancelOrdersParams = { + coins?: string[]; // Optional: specific coins (omit to cancel all orders) + orderIds?: string[]; // Optional: specific order IDs (omit to cancel all orders for specified coins) + cancelAll?: boolean; // Explicitly cancel all orders +}; + +export type CancelOrdersResult = { + success: boolean; // Overall success (true if at least one order cancelled) + successCount: number; // Number of orders cancelled successfully + failureCount: number; // Number of orders that failed to cancel + results: { + orderId: string; + coin: string; + success: boolean; + error?: string; + }[]; +}; + export interface EditOrderParams { orderId: string | number; // Order ID or client order ID to modify newOrder: OrderParams; // New order parameters @@ -442,6 +494,7 @@ export interface OrderFill { export interface GetPositionsParams { accountId?: CaipAccountId; // Optional: defaults to selected account includeHistory?: boolean; // Optional: include historical positions + skipCache?: boolean; // Optional: bypass WebSocket cache and force API call (default: false) } export interface GetAccountStateParams { @@ -464,6 +517,7 @@ export interface GetOrdersParams { endTime?: number; // Optional: end timestamp (Unix milliseconds) limit?: number; // Optional: max number of results for pagination offset?: number; // Optional: offset for pagination + skipCache?: boolean; // Optional: bypass WebSocket cache and force API call (default: false) } export interface GetFundingParams { @@ -538,6 +592,7 @@ export interface FeeCalculationParams { orderType: 'market' | 'limit'; isMaker?: boolean; amount?: string; + coin: string; // Required: Asset symbol for HIP-3 fee calculation (e.g., 'BTC', 'xyz:TSLA') } export interface FeeCalculationResult { @@ -608,7 +663,9 @@ export interface IPerpsProvider { placeOrder(params: OrderParams): Promise; editOrder(params: EditOrderParams): Promise; cancelOrder(params: CancelOrderParams): Promise; + cancelOrders?(params: BatchCancelOrdersParams): Promise; // Optional: batch cancel for protocols that support it closePosition(params: ClosePositionParams): Promise; + closePositions?(params: ClosePositionsParams): Promise; // Optional: batch close for protocols that support it updatePositionTPSL(params: UpdatePositionTPSLParams): Promise; getPositions(params?: GetPositionsParams): Promise; getAccountState(params?: GetAccountStateParams): Promise; diff --git a/app/components/UI/Perps/hooks/index.ts b/app/components/UI/Perps/hooks/index.ts index e5caf8e92baa..191a4ef87f0a 100644 --- a/app/components/UI/Perps/hooks/index.ts +++ b/app/components/UI/Perps/hooks/index.ts @@ -8,6 +8,13 @@ export { usePerpsWithdrawQuote } from './usePerpsWithdrawQuote'; export { usePerpsWithdrawStatus } from './usePerpsWithdrawStatus'; export { usePerpsWithdrawProgress } from './usePerpsWithdrawProgress'; +// View-level composite hooks (combining multiple hooks for specific views) +export { usePerpsHomeData } from './usePerpsHomeData'; +export { usePerpsMarketListView } from './usePerpsMarketListView'; +export { usePerpsSearch } from './usePerpsSearch'; +export { usePerpsSorting } from './usePerpsSorting'; +export { usePerpsNavigation } from './usePerpsNavigation'; + // Connection management hooks export { usePerpsConnectionLifecycle } from './usePerpsConnectionLifecycle'; export { usePerpsConnection } from './usePerpsConnection'; @@ -41,6 +48,9 @@ export { usePerpsTPSLUpdate } from './usePerpsTPSLUpdate'; export { usePerpsClosePosition } from './usePerpsClosePosition'; export { usePerpsOrderFees, formatFeeRate } from './usePerpsOrderFees'; export { usePerpsRewards } from './usePerpsRewards'; +export { usePerpsCloseAllCalculations } from './usePerpsCloseAllCalculations'; +export { usePerpsCancelAllOrders } from './usePerpsCancelAllOrders'; +export { usePerpsCloseAllPositions } from './usePerpsCloseAllPositions'; export { useHasExistingPosition } from './useHasExistingPosition'; export { useMinimumOrderAmount } from './useMinimumOrderAmount'; export { usePerpsOrderForm } from './usePerpsOrderForm'; diff --git a/app/components/UI/Perps/hooks/stream/useLiveFills.test.ts b/app/components/UI/Perps/hooks/stream/usePerpsLiveFills.test.ts similarity index 90% rename from app/components/UI/Perps/hooks/stream/useLiveFills.test.ts rename to app/components/UI/Perps/hooks/stream/usePerpsLiveFills.test.ts index cc4f147302a2..f354ae947f46 100644 --- a/app/components/UI/Perps/hooks/stream/useLiveFills.test.ts +++ b/app/components/UI/Perps/hooks/stream/usePerpsLiveFills.test.ts @@ -72,7 +72,7 @@ describe('usePerpsLiveFills', () => { const { result } = renderHook(() => usePerpsLiveFills()); // Initially empty - expect(result.current).toEqual([]); + expect(result.current).toEqual({ fills: [], isInitialLoading: true }); // Simulate fills update const fills: OrderFill[] = [ @@ -85,7 +85,7 @@ describe('usePerpsLiveFills', () => { }); await waitFor(() => { - expect(result.current).toEqual(fills); + expect(result.current.fills).toEqual(fills); }); }); @@ -145,7 +145,7 @@ describe('usePerpsLiveFills', () => { }); await waitFor(() => { - expect(result.current).toEqual([]); + expect(result.current.fills).toEqual([]); }); }); @@ -164,7 +164,7 @@ describe('usePerpsLiveFills', () => { }); // Should not crash and fills should remain empty - expect(result.current).toEqual([]); + expect(result.current.fills).toEqual([]); // Send undefined update act(() => { @@ -172,7 +172,7 @@ describe('usePerpsLiveFills', () => { }); // Should still not crash - expect(result.current).toEqual([]); + expect(result.current.fills).toEqual([]); // Send valid update to ensure it still works const validFills: OrderFill[] = [mockFill]; @@ -182,7 +182,7 @@ describe('usePerpsLiveFills', () => { }); await waitFor(() => { - expect(result.current).toEqual(validFills); + expect(result.current.fills).toEqual(validFills); }); }); @@ -202,7 +202,7 @@ describe('usePerpsLiveFills', () => { }); await waitFor(() => { - expect(result.current).toEqual(firstFills); + expect(result.current.fills).toEqual(firstFills); }); // Second update with different fills @@ -216,8 +216,8 @@ describe('usePerpsLiveFills', () => { }); await waitFor(() => { - expect(result.current).toEqual(secondFills); - expect(result.current).not.toContain(mockFill); + expect(result.current.fills).toEqual(secondFills); + expect(result.current.fills).not.toContain(mockFill); }); }); @@ -242,8 +242,8 @@ describe('usePerpsLiveFills', () => { }); await waitFor(() => { - expect(result.current).toEqual(fills); - expect(result.current).toHaveLength(3); + expect(result.current.fills).toEqual(fills); + expect(result.current.fills).toHaveLength(3); }); }); @@ -267,8 +267,8 @@ describe('usePerpsLiveFills', () => { }); await waitFor(() => { - expect(result.current).toEqual(fills); - const symbols = result.current.map((f) => f.symbol); + expect(result.current.fills).toEqual(fills); + const symbols = result.current.fills.map((f) => f.symbol); expect(symbols).toContain('BTC-PERP'); expect(symbols).toContain('ETH-PERP'); expect(symbols).toContain('SOL-PERP'); diff --git a/app/components/UI/Perps/hooks/stream/usePerpsLiveFills.ts b/app/components/UI/Perps/hooks/stream/usePerpsLiveFills.ts index 6c248255541d..fd9fde2e46c6 100644 --- a/app/components/UI/Perps/hooks/stream/usePerpsLiveFills.ts +++ b/app/components/UI/Perps/hooks/stream/usePerpsLiveFills.ts @@ -1,26 +1,39 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; import { usePerpsStream } from '../../providers/PerpsStreamManager'; import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger'; import type { OrderFill } from '../../controllers/types'; +// Stable empty array reference to prevent re-renders +const EMPTY_FILLS: OrderFill[] = []; + export interface UsePerpsLiveFillsOptions { /** Throttle delay in milliseconds (default: 0ms for immediate updates) */ throttleMs?: number; } +export interface UsePerpsLiveFillsReturn { + /** Array of order fills */ + fills: OrderFill[]; + /** Whether we're waiting for the first real WebSocket data (not cached) */ + isInitialLoading: boolean; +} + /** * Hook for real-time order fill updates via WebSocket subscription * Provides immediate notification of trade executions * * @param options - Configuration options for the hook - * @returns Array of order fills with real-time updates + * @returns Object containing fills array and loading state */ export function usePerpsLiveFills( options: UsePerpsLiveFillsOptions = {}, -): OrderFill[] { +): UsePerpsLiveFillsReturn { const { throttleMs = 0 } = options; const stream = usePerpsStream(); - const [fills, setFills] = useState([]); + const [fills, setFills] = useState(EMPTY_FILLS); + const [isInitialLoading, setIsInitialLoading] = useState(true); + const lastFillsRef = useRef(EMPTY_FILLS); + const hasReceivedFirstUpdate = useRef(false); useEffect(() => { const logMessage = throttleMs @@ -30,13 +43,37 @@ export function usePerpsLiveFills( const unsubscribe = stream.fills.subscribe({ callback: (newFills) => { - if (!newFills) { + // null/undefined means no cached data yet, keep loading state + if (newFills === null || newFills === undefined) { + // Keep isInitialLoading as true, fills as empty array return; } - DevLogger.log('usePerpsLiveFills: Received fill update', { - count: newFills.length, - }); - setFills(newFills); + + // We have real data now (either empty array or fills) + if (!hasReceivedFirstUpdate.current) { + DevLogger.log('usePerpsLiveFills: Received first WebSocket update', { + count: newFills?.length ?? 0, + }); + hasReceivedFirstUpdate.current = true; + setIsInitialLoading(false); + } + + // Only update if fills actually changed + // For empty arrays, use stable reference + if (newFills.length === 0) { + if (lastFillsRef.current.length === 0) { + // Already empty, don't update + return; + } + lastFillsRef.current = EMPTY_FILLS; + setFills(EMPTY_FILLS); + } else { + DevLogger.log('usePerpsLiveFills: Received fill update', { + count: newFills.length, + }); + lastFillsRef.current = newFills; + setFills(newFills); + } }, throttleMs, }); @@ -47,5 +84,8 @@ export function usePerpsLiveFills( }; }, [stream, throttleMs]); - return fills; + return { + fills, + isInitialLoading, + }; } diff --git a/app/components/UI/Perps/hooks/stream/useLiveOrders.test.ts b/app/components/UI/Perps/hooks/stream/usePerpsLiveOrders.test.ts similarity index 91% rename from app/components/UI/Perps/hooks/stream/useLiveOrders.test.ts rename to app/components/UI/Perps/hooks/stream/usePerpsLiveOrders.test.ts index d1e38d905f94..dcd4549003c5 100644 --- a/app/components/UI/Perps/hooks/stream/useLiveOrders.test.ts +++ b/app/components/UI/Perps/hooks/stream/usePerpsLiveOrders.test.ts @@ -72,7 +72,7 @@ describe('usePerpsLiveOrders', () => { const { result } = renderHook(() => usePerpsLiveOrders()); // Initially empty - expect(result.current).toEqual([]); + expect(result.current).toEqual({ orders: [], isInitialLoading: true }); // Simulate orders update const orders: Order[] = [ @@ -85,7 +85,7 @@ describe('usePerpsLiveOrders', () => { }); await waitFor(() => { - expect(result.current).toEqual(orders); + expect(result.current.orders).toEqual(orders); }); }); @@ -145,7 +145,7 @@ describe('usePerpsLiveOrders', () => { }); await waitFor(() => { - expect(result.current).toEqual([]); + expect(result.current.orders).toEqual([]); }); }); @@ -164,7 +164,7 @@ describe('usePerpsLiveOrders', () => { }); // Should not crash and orders should remain empty - expect(result.current).toEqual([]); + expect(result.current.orders).toEqual([]); // Send undefined update act(() => { @@ -172,7 +172,7 @@ describe('usePerpsLiveOrders', () => { }); // Should still not crash - expect(result.current).toEqual([]); + expect(result.current.orders).toEqual([]); // Send valid update to ensure it still works const validOrders: Order[] = [mockOrder]; @@ -182,7 +182,7 @@ describe('usePerpsLiveOrders', () => { }); await waitFor(() => { - expect(result.current).toEqual(validOrders); + expect(result.current.orders).toEqual(validOrders); }); }); @@ -202,7 +202,7 @@ describe('usePerpsLiveOrders', () => { }); await waitFor(() => { - expect(result.current).toEqual(firstOrders); + expect(result.current.orders).toEqual(firstOrders); }); // Second update with different orders @@ -216,8 +216,8 @@ describe('usePerpsLiveOrders', () => { }); await waitFor(() => { - expect(result.current).toEqual(secondOrders); - expect(result.current).not.toContain(mockOrder); + expect(result.current.orders).toEqual(secondOrders); + expect(result.current.orders).not.toContain(mockOrder); }); }); }); diff --git a/app/components/UI/Perps/hooks/stream/usePerpsLiveOrders.ts b/app/components/UI/Perps/hooks/stream/usePerpsLiveOrders.ts index 98e02acf732e..278337cdf041 100644 --- a/app/components/UI/Perps/hooks/stream/usePerpsLiveOrders.ts +++ b/app/components/UI/Perps/hooks/stream/usePerpsLiveOrders.ts @@ -1,8 +1,11 @@ -import { useEffect, useState, useMemo } from 'react'; +import { useEffect, useState, useMemo, useRef } from 'react'; import { usePerpsStream } from '../../providers/PerpsStreamManager'; import type { Order } from '../../controllers/types'; import { isTPSLOrder } from '../../constants/orderTypes'; +// Stable empty array reference to prevent re-renders +const EMPTY_ORDERS: Order[] = []; + export interface UsePerpsLiveOrdersOptions { /** Throttle delay in milliseconds (default: 0 - no throttling for instant updates) */ throttleMs?: number; @@ -10,6 +13,13 @@ export interface UsePerpsLiveOrdersOptions { hideTpSl?: boolean; } +export interface UsePerpsLiveOrdersReturn { + /** Array of current orders */ + orders: Order[]; + /** Whether we're waiting for the first real WebSocket data (not cached) */ + isInitialLoading: boolean; +} + /** * Hook for real-time order updates via WebSocket subscription * Replaces the old polling-based usePerpsOpenOrders hook @@ -18,22 +28,46 @@ export interface UsePerpsLiveOrdersOptions { * and users expect immediate feedback when placing/cancelling orders. * * @param options - Configuration options for the hook - * @returns Array of current orders with real-time updates + * @returns Object containing orders array and loading state */ export function usePerpsLiveOrders( options: UsePerpsLiveOrdersOptions = {}, -): Order[] { +): UsePerpsLiveOrdersReturn { const { throttleMs = 0, hideTpSl = false } = options; // No throttling by default for instant updates const stream = usePerpsStream(); - const [orders, setOrders] = useState([]); + const [orders, setOrders] = useState(EMPTY_ORDERS); + const [isInitialLoading, setIsInitialLoading] = useState(true); + const lastOrdersRef = useRef(EMPTY_ORDERS); + const hasReceivedFirstUpdate = useRef(false); useEffect(() => { const unsubscribe = stream.orders.subscribe({ callback: (newOrders) => { - if (!newOrders) { + // null/undefined means no cached data yet, keep loading state + if (newOrders === null || newOrders === undefined) { + // Keep isInitialLoading as true, orders as empty array return; } - setOrders(newOrders); + + // We have real data now (either empty array or orders) + if (!hasReceivedFirstUpdate.current) { + hasReceivedFirstUpdate.current = true; + setIsInitialLoading(false); + } + + // Only update if orders actually changed + // For empty arrays, use stable reference + if (newOrders.length === 0) { + if (lastOrdersRef.current.length === 0) { + // Already empty, don't update + return; + } + lastOrdersRef.current = EMPTY_ORDERS; + setOrders(EMPTY_ORDERS); + } else { + lastOrdersRef.current = newOrders; + setOrders(newOrders); + } }, throttleMs, }); @@ -51,5 +85,8 @@ export function usePerpsLiveOrders( return orders.filter((order) => !isTPSLOrder(order.detailedOrderType)); }, [orders, hideTpSl]); - return filteredOrders; + return { + orders: filteredOrders, + isInitialLoading, + }; } diff --git a/app/components/UI/Perps/hooks/usePerpsCancelAllOrders.test.ts b/app/components/UI/Perps/hooks/usePerpsCancelAllOrders.test.ts new file mode 100644 index 000000000000..9c493a34d0ea --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsCancelAllOrders.test.ts @@ -0,0 +1,379 @@ +import { renderHook, act, waitFor } from '@testing-library/react-native'; +import { useNavigation } from '@react-navigation/native'; +import { usePerpsCancelAllOrders } from './usePerpsCancelAllOrders'; +import Engine from '../../../../core/Engine'; +import type { Order } from '../controllers/types'; + +// Mock dependencies +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(() => ({ + goBack: jest.fn(), + navigate: jest.fn(), + })), +})); + +jest.mock('../../../../core/Engine', () => ({ + context: { + PerpsController: { + cancelOrders: jest.fn(), + }, + }, +})); + +jest.mock('../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string, params?: Record) => { + if (key === 'perps.cancel_all_modal.error_message' && params?.count) { + return `Failed to cancel ${params.count} orders`; + } + return key; + }), +})); + +const createMockOrder = (overrides: Partial = {}): Order => ({ + orderId: 'order-1', + symbol: 'BTC', + side: 'buy', + orderType: 'limit', + size: '0.1', + originalSize: '0.1', + price: '50000', + filledSize: '0', + remainingSize: '0.1', + status: 'open', + timestamp: Date.now(), + ...overrides, +}); + +describe('usePerpsCancelAllOrders', () => { + const mockNavigation = { + goBack: jest.fn(), + navigate: jest.fn(), + canGoBack: jest.fn(() => true), + }; + + beforeEach(() => { + jest.clearAllMocks(); + (useNavigation as jest.Mock).mockReturnValue(mockNavigation); + }); + + it('initializes with correct default state', () => { + // Arrange + const orders = [createMockOrder(), createMockOrder({ orderId: 'order-2' })]; + + // Act + const { result } = renderHook(() => usePerpsCancelAllOrders(orders)); + + // Assert + expect(result.current.isCanceling).toBe(false); + expect(result.current.orderCount).toBe(2); + expect(result.current.error).toBeNull(); + expect(typeof result.current.handleCancelAll).toBe('function'); + expect(typeof result.current.handleKeepOrders).toBe('function'); + }); + + it('handles empty orders array', () => { + // Arrange & Act + const { result } = renderHook(() => usePerpsCancelAllOrders([])); + + // Assert + expect(result.current.orderCount).toBe(0); + }); + + it('handles null orders', () => { + // Arrange & Act + const { result } = renderHook(() => usePerpsCancelAllOrders(null)); + + // Assert + expect(result.current.orderCount).toBe(0); + }); + + it('cancels all orders successfully', async () => { + // Arrange + const orders = [createMockOrder(), createMockOrder({ orderId: 'order-2' })]; + const mockResult = { + success: true, + successCount: 2, + failureCount: 0, + results: [], + }; + ( + Engine.context.PerpsController.cancelOrders as jest.Mock + ).mockResolvedValue(mockResult); + const { result } = renderHook(() => usePerpsCancelAllOrders(orders)); + + // Act + await act(async () => { + await result.current.handleCancelAll(); + }); + + // Assert + await waitFor(() => { + expect(result.current.isCanceling).toBe(false); + }); + expect(Engine.context.PerpsController.cancelOrders).toHaveBeenCalledWith({ + cancelAll: true, + }); + expect(mockNavigation.goBack).toHaveBeenCalled(); + expect(result.current.error).toBeNull(); + }); + + it('handles partial success (some orders cancel, some fail)', async () => { + // Arrange + const orders = [createMockOrder(), createMockOrder({ orderId: 'order-2' })]; + const mockResult = { + success: false, + successCount: 1, + failureCount: 1, + results: [], + }; + ( + Engine.context.PerpsController.cancelOrders as jest.Mock + ).mockResolvedValue(mockResult); + const { result } = renderHook(() => usePerpsCancelAllOrders(orders)); + + // Act + await act(async () => { + await result.current.handleCancelAll(); + }); + + // Assert + await waitFor(() => { + expect(result.current.isCanceling).toBe(false); + }); + expect(mockNavigation.goBack).toHaveBeenCalled(); + expect(result.current.error).toBeNull(); + }); + + it('handles complete failure (all orders fail)', async () => { + // Arrange + const orders = [createMockOrder(), createMockOrder({ orderId: 'order-2' })]; + const mockResult = { + success: false, + successCount: 0, + failureCount: 2, + results: [], + }; + ( + Engine.context.PerpsController.cancelOrders as jest.Mock + ).mockResolvedValue(mockResult); + const { result } = renderHook(() => usePerpsCancelAllOrders(orders)); + + // Act + await act(async () => { + await result.current.handleCancelAll(); + }); + + // Assert + await waitFor(() => { + expect(result.current.isCanceling).toBe(false); + }); + expect(mockNavigation.goBack).not.toHaveBeenCalled(); + expect(result.current.error).not.toBeNull(); + expect(result.current.error?.message).toContain( + 'Failed to cancel 2 orders', + ); + }); + + it('handles network errors', async () => { + // Arrange + const orders = [createMockOrder()]; + const networkError = new Error('Network request failed'); + ( + Engine.context.PerpsController.cancelOrders as jest.Mock + ).mockRejectedValue(networkError); + const { result } = renderHook(() => usePerpsCancelAllOrders(orders)); + + // Act + await act(async () => { + await result.current.handleCancelAll(); + }); + + // Assert + await waitFor(() => { + expect(result.current.isCanceling).toBe(false); + }); + expect(result.current.error).toEqual(networkError); + expect(mockNavigation.goBack).not.toHaveBeenCalled(); + }); + + it('manages loading state correctly', async () => { + // Arrange + const orders = [createMockOrder()]; + let resolveCancel: (value: unknown) => void; + const cancelPromise = new Promise((resolve) => { + resolveCancel = resolve; + }); + (Engine.context.PerpsController.cancelOrders as jest.Mock).mockReturnValue( + cancelPromise, + ); + const { result } = renderHook(() => usePerpsCancelAllOrders(orders)); + + // Act - Start canceling + act(() => { + result.current.handleCancelAll(); + }); + + // Assert - Should be canceling + await waitFor(() => { + expect(result.current.isCanceling).toBe(true); + }); + + // Act - Resolve the promise + await act(async () => { + resolveCancel({ + success: true, + successCount: 1, + failureCount: 0, + results: [], + }); + await cancelPromise; + }); + + // Assert - Should no longer be canceling + await waitFor(() => { + expect(result.current.isCanceling).toBe(false); + }); + }); + + it('invokes onSuccess callback when provided', async () => { + // Arrange + const orders = [createMockOrder()]; + const mockResult = { + success: true, + successCount: 1, + failureCount: 0, + results: [], + }; + ( + Engine.context.PerpsController.cancelOrders as jest.Mock + ).mockResolvedValue(mockResult); + const onSuccess = jest.fn(); + const { result } = renderHook(() => + usePerpsCancelAllOrders(orders, { onSuccess }), + ); + + // Act + await act(async () => { + await result.current.handleCancelAll(); + }); + + // Assert + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledWith(mockResult); + }); + }); + + it('invokes onError callback when provided', async () => { + // Arrange + const orders = [createMockOrder()]; + const error = new Error('Cancel failed'); + ( + Engine.context.PerpsController.cancelOrders as jest.Mock + ).mockRejectedValue(error); + const onError = jest.fn(); + const { result } = renderHook(() => + usePerpsCancelAllOrders(orders, { onError }), + ); + + // Act + await act(async () => { + await result.current.handleCancelAll(); + }); + + // Assert + await waitFor(() => { + expect(onError).toHaveBeenCalledWith(error); + }); + }); + + it('does not navigate back when navigateBackOnSuccess is false', async () => { + // Arrange + const orders = [createMockOrder()]; + const mockResult = { + success: true, + successCount: 1, + failureCount: 0, + results: [], + }; + ( + Engine.context.PerpsController.cancelOrders as jest.Mock + ).mockResolvedValue(mockResult); + const { result } = renderHook(() => + usePerpsCancelAllOrders(orders, { navigateBackOnSuccess: false }), + ); + + // Act + await act(async () => { + await result.current.handleCancelAll(); + }); + + // Assert + await waitFor(() => { + expect(result.current.isCanceling).toBe(false); + }); + expect(mockNavigation.goBack).not.toHaveBeenCalled(); + }); + + it('handles keepOrders action', () => { + // Arrange + const orders = [createMockOrder()]; + const { result } = renderHook(() => usePerpsCancelAllOrders(orders)); + + // Act + act(() => { + result.current.handleKeepOrders(); + }); + + // Assert + expect(mockNavigation.goBack).toHaveBeenCalled(); + }); + + it('does nothing when handleCancelAll called with no orders', async () => { + // Arrange + const { result } = renderHook(() => usePerpsCancelAllOrders(null)); + + // Act + await act(async () => { + await result.current.handleCancelAll(); + }); + + // Assert + expect(Engine.context.PerpsController.cancelOrders).not.toHaveBeenCalled(); + expect(result.current.isCanceling).toBe(false); + }); + + it('clears error state on subsequent successful cancel', async () => { + // Arrange + const orders = [createMockOrder()]; + const error = new Error('First cancel failed'); + (Engine.context.PerpsController.cancelOrders as jest.Mock) + .mockRejectedValueOnce(error) + .mockResolvedValueOnce({ + success: true, + successCount: 1, + failureCount: 0, + results: [], + }); + const { result } = renderHook(() => usePerpsCancelAllOrders(orders)); + + // Act - First cancel fails + await act(async () => { + await result.current.handleCancelAll(); + }); + + // Assert - Error is set + await waitFor(() => { + expect(result.current.error).toEqual(error); + }); + + // Act - Second cancel succeeds + await act(async () => { + await result.current.handleCancelAll(); + }); + + // Assert - Error is cleared + await waitFor(() => { + expect(result.current.error).toBeNull(); + }); + }); +}); diff --git a/app/components/UI/Perps/hooks/usePerpsCancelAllOrders.ts b/app/components/UI/Perps/hooks/usePerpsCancelAllOrders.ts new file mode 100644 index 000000000000..9c37801037b4 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsCancelAllOrders.ts @@ -0,0 +1,142 @@ +import { useCallback, useState } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; +import Engine from '../../../../core/Engine'; +import type { Order, CancelOrdersResult } from '../controllers/types'; +import { strings } from '../../../../../locales/i18n'; +import Routes from '../../../../constants/navigation/Routes'; + +export interface UsePerpsCancelAllOrdersOptions { + /** Callback invoked when cancellation succeeds */ + onSuccess?: (result: CancelOrdersResult) => void; + /** Callback invoked when cancellation fails */ + onError?: (error: Error) => void; + /** Whether to navigate back on success (default: true) */ + navigateBackOnSuccess?: boolean; +} + +export interface UsePerpsCancelAllOrdersReturn { + /** Whether cancellation is in progress */ + isCanceling: boolean; + /** Number of orders to cancel */ + orderCount: number; + /** Cancel all orders */ + handleCancelAll: () => Promise; + /** Keep orders and navigate back */ + handleKeepOrders: () => void; + /** Last error that occurred */ + error: Error | null; +} + +/** + * Hook for managing cancel all orders business logic + * + * Handles: + * - Cancellation state management + * - Controller interaction + * - Error handling + * - Success/partial success/failure logic + * - Navigation + * + * @param orders - Array of orders to cancel + * @param options - Configuration options + * @returns Cancel all orders state and handlers + */ +export const usePerpsCancelAllOrders = ( + orders: Order[] | null, + options?: UsePerpsCancelAllOrdersOptions, +): UsePerpsCancelAllOrdersReturn => { + const navigation = useNavigation(); + const [isCanceling, setIsCanceling] = useState(false); + const [error, setError] = useState(null); + + const { onSuccess, onError, navigateBackOnSuccess = true } = options || {}; + + const orderCount = orders?.length || 0; + + const handleCancelAll = useCallback(async () => { + if (!orders || orders.length === 0) { + DevLogger.log('[usePerpsCancelAllOrders] No orders to cancel'); + return; + } + + setIsCanceling(true); + setError(null); + + DevLogger.log('[usePerpsCancelAllOrders] Starting cancel all orders', { + orderCount: orders.length, + }); + + try { + const result = await Engine.context.PerpsController.cancelOrders({ + cancelAll: true, + }); + + DevLogger.log('[usePerpsCancelAllOrders] Cancel result', { + success: result.success, + successCount: result.successCount, + failureCount: result.failureCount, + }); + + // Invoke success callback if provided + if (onSuccess) { + onSuccess(result); + } + + // Navigate back on any success (full or partial) + if (navigateBackOnSuccess && result.successCount > 0) { + if (navigation.canGoBack()) { + navigation.goBack(); + } else { + // Fallback: navigate to Markets view if can't go back + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.PERPS_HOME, + }); + } + } + + // If complete failure, throw error to trigger catch block + if (result.successCount === 0 && result.failureCount > 0) { + throw new Error( + strings('perps.cancel_all_modal.error_message', { + count: result.failureCount, + }), + ); + } + } catch (err) { + const errorObj = err instanceof Error ? err : new Error(String(err)); + setError(errorObj); + + DevLogger.log('[usePerpsCancelAllOrders] Cancel failed', { + error: errorObj.message, + }); + + // Invoke error callback if provided + if (onError) { + onError(errorObj); + } + } finally { + setIsCanceling(false); + } + }, [orders, onSuccess, onError, navigateBackOnSuccess, navigation]); + + const handleKeepOrders = useCallback(() => { + DevLogger.log('[usePerpsCancelAllOrders] User chose to keep orders'); + if (navigation.canGoBack()) { + navigation.goBack(); + } else { + // Fallback: navigate to Markets view if can't go back + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.PERPS_HOME, + }); + } + }, [navigation]); + + return { + isCanceling, + orderCount, + handleCancelAll, + handleKeepOrders, + error, + }; +}; diff --git a/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.test.ts b/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.test.ts new file mode 100644 index 000000000000..834aede294bf --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.test.ts @@ -0,0 +1,863 @@ +import { renderHook, waitFor } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import { usePerpsCloseAllCalculations } from './usePerpsCloseAllCalculations'; +import Engine from '../../../../core/Engine'; +import type { Position, FeeCalculationResult } from '../controllers/types'; +import type { EstimatedPointsDto } from '../../../../core/Engine/controllers/rewards-controller/types'; + +/** + * Note: This test file contains act() warnings from React Testing Library. + * These warnings occur because the hook uses useEffect with async operations + * that trigger state updates. The tests properly use waitFor() to handle + * async behavior, and all assertions pass. This is expected behavior for + * hooks that perform calculations asynchronously in response to prop changes. + */ + +// Mock dependencies +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../../core/Engine', () => ({ + context: { + PerpsController: { + calculateFees: jest.fn(), + }, + RewardsController: { + estimatePoints: jest.fn(), + }, + }, +})); + +const mockUseSelector = useSelector as jest.MockedFunction; + +// Test data helpers +const createMockPosition = (overrides: Partial = {}): Position => ({ + coin: 'BTC', + size: '0.5', + entryPrice: '50000', + positionValue: '25000', + unrealizedPnl: '100', + marginUsed: '1000', + leverage: { type: 'cross', value: 25 }, + liquidationPrice: '48000', + maxLeverage: 50, + returnOnEquity: '10', + cumulativeFunding: { + allTime: '0', + sinceOpen: '0', + sinceChange: '0', + }, + takeProfitPrice: undefined, + stopLossPrice: undefined, + takeProfitCount: 0, + stopLossCount: 0, + ...overrides, +}); + +const createMockFeeResult = ( + overrides: Partial = {}, +): FeeCalculationResult => ({ + feeRate: 0.011, + feeAmount: 275, + protocolFeeRate: 0.001, + protocolFeeAmount: 25, + metamaskFeeRate: 0.01, + metamaskFeeAmount: 250, + ...overrides, +}); + +const createMockPointsResult = ( + overrides: Partial = {}, +): EstimatedPointsDto => ({ + pointsEstimate: 100, + bonusBips: 1000, + ...overrides, +}); + +describe('usePerpsCloseAllCalculations', () => { + const mockCalculateFees = Engine.context.PerpsController + .calculateFees as jest.Mock; + const mockEstimatePoints = Engine.context.RewardsController + .estimatePoints as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + // Setup default selector mocks + let selectorCallCount = 0; + mockUseSelector.mockImplementation(() => { + selectorCallCount++; + if (selectorCallCount % 2 === 1) { + return '0x1234567890123456789012345678901234567890'; // selectedAddress + } + return '0xa4b1'; // chainId + }); + + // Setup default Engine mock responses + mockCalculateFees.mockResolvedValue(createMockFeeResult()); + mockEstimatePoints.mockResolvedValue(createMockPointsResult()); + }); + + describe('Initial State', () => { + it('returns zero values for empty positions array', () => { + // Arrange + const positions: Position[] = []; + const priceData = {}; + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + expect(result.current.totalMargin).toBe(0); + expect(result.current.totalPnl).toBe(0); + expect(result.current.totalFees).toBe(0); + expect(result.current.receiveAmount).toBe(0); + expect(result.current.totalEstimatedPoints).toBe(0); + expect(result.current.avgFeeDiscountPercentage).toBe(0); + expect(result.current.avgBonusBips).toBe(0); + expect(result.current.avgMetamaskFeeRate).toBe(0); + expect(result.current.avgProtocolFeeRate).toBe(0); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + expect(result.current.shouldShowRewards).toBe(false); + }); + + it('initializes loading state correctly', () => { + // Arrange + const positions = [createMockPosition()]; + const priceData = { BTC: { price: '51000' } }; + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert - Should start calculating + expect(result.current.isLoading).toBe(true); + }); + }); + + describe('Total Margin Calculation', () => { + it('calculates total margin including P&L for single position', () => { + // Arrange + const positions = [ + createMockPosition({ + marginUsed: '1000', + unrealizedPnl: '100', + }), + ]; + const priceData = { BTC: { price: '51000' } }; + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + expect(result.current.totalMargin).toBe(1100); // 1000 + 100 + }); + + it('calculates total margin for multiple positions', () => { + // Arrange + const positions = [ + createMockPosition({ + coin: 'BTC', + marginUsed: '1000', + unrealizedPnl: '100', + }), + createMockPosition({ + coin: 'ETH', + marginUsed: '500', + unrealizedPnl: '-50', + }), + ]; + const priceData = { + BTC: { price: '51000' }, + ETH: { price: '3000' }, + }; + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + expect(result.current.totalMargin).toBe(1550); // (1000+100) + (500-50) + }); + + it('handles negative P&L correctly', () => { + // Arrange + const positions = [ + createMockPosition({ + marginUsed: '1000', + unrealizedPnl: '-200', + }), + ]; + const priceData = { BTC: { price: '48000' } }; + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + expect(result.current.totalMargin).toBe(800); // 1000 - 200 + }); + }); + + describe('Total P&L Calculation', () => { + it('calculates total P&L for single position', () => { + // Arrange + const positions = [ + createMockPosition({ + unrealizedPnl: '100', + }), + ]; + const priceData = { BTC: { price: '51000' } }; + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + expect(result.current.totalPnl).toBe(100); + }); + + it('calculates total P&L for multiple positions', () => { + // Arrange + const positions = [ + createMockPosition({ coin: 'BTC', unrealizedPnl: '100' }), + createMockPosition({ coin: 'ETH', unrealizedPnl: '50' }), + createMockPosition({ coin: 'SOL', unrealizedPnl: '-25' }), + ]; + const priceData = { + BTC: { price: '51000' }, + ETH: { price: '3000' }, + SOL: { price: '100' }, + }; + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + expect(result.current.totalPnl).toBe(125); // 100 + 50 - 25 + }); + }); + + describe('Fee Calculation', () => { + it('calculates fees for single position using current market price', async () => { + // Arrange + const positions = [ + createMockPosition({ + coin: 'BTC', + size: '0.5', + entryPrice: '50000', + }), + ]; + const priceData = { BTC: { price: '52000' } }; + mockCalculateFees.mockResolvedValue( + createMockFeeResult({ feeAmount: 286 }), + ); + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(mockCalculateFees).toHaveBeenCalledWith({ + orderType: 'market', + isMaker: false, + amount: (0.5 * 52000).toString(), // Uses current price, not entry price + }); + expect(result.current.totalFees).toBe(286); + }); + + it('falls back to entry price when current price unavailable', async () => { + // Arrange + const positions = [ + createMockPosition({ + coin: 'BTC', + size: '0.5', + entryPrice: '50000', + }), + ]; + const priceData = {}; // No price data + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(mockCalculateFees).toHaveBeenCalledWith({ + orderType: 'market', + isMaker: false, + amount: (0.5 * 50000).toString(), // Uses entry price as fallback + }); + }); + + it('aggregates fees across multiple positions', async () => { + // Arrange + const positions = [ + createMockPosition({ coin: 'BTC', size: '0.5' }), + createMockPosition({ coin: 'ETH', size: '10' }), + ]; + const priceData = { + BTC: { price: '52000' }, + ETH: { price: '3000' }, + }; + mockCalculateFees + .mockResolvedValueOnce(createMockFeeResult({ feeAmount: 286 })) + .mockResolvedValueOnce(createMockFeeResult({ feeAmount: 330 })); + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.totalFees).toBe(616); // 286 + 330 + }); + + it('handles fee calculation errors gracefully', async () => { + // Arrange + const positions = [createMockPosition()]; + const priceData = { BTC: { price: '51000' } }; + mockCalculateFees.mockRejectedValue(new Error('Fee calculation failed')); + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.hasError).toBe(true); + expect(result.current.totalFees).toBe(0); + }); + }); + + describe('Points Estimation', () => { + it('estimates points for single position with correct coin parameter', async () => { + // Arrange + const positions = [createMockPosition({ coin: 'BTC' })]; + const priceData = { BTC: { price: '51000' } }; + mockCalculateFees.mockResolvedValue( + createMockFeeResult({ feeAmount: 275 }), + ); + mockEstimatePoints.mockResolvedValue( + createMockPointsResult({ pointsEstimate: 150, bonusBips: 1000 }), + ); + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(mockEstimatePoints).toHaveBeenCalledWith( + expect.objectContaining({ + activityType: 'PERPS', + activityContext: expect.objectContaining({ + perpsContext: expect.objectContaining({ + type: 'CLOSE_POSITION', + coin: 'BTC', + usdFeeValue: '275', + }), + }), + }), + ); + expect(result.current.totalEstimatedPoints).toBe(150); + expect(result.current.avgBonusBips).toBe(1000); + }); + + it('aggregates points across multiple positions with different coins', async () => { + // Arrange + const positions = [ + createMockPosition({ coin: 'BTC' }), + createMockPosition({ coin: 'ETH' }), + ]; + const priceData = { + BTC: { price: '51000' }, + ETH: { price: '3000' }, + }; + mockCalculateFees.mockResolvedValue( + createMockFeeResult({ feeAmount: 275 }), + ); + mockEstimatePoints + .mockResolvedValueOnce( + createMockPointsResult({ pointsEstimate: 150, bonusBips: 1000 }), + ) + .mockResolvedValueOnce( + createMockPointsResult({ pointsEstimate: 100, bonusBips: 1500 }), + ); + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.totalEstimatedPoints).toBe(250); // 150 + 100 + + // Weighted average bonus: (150*1000 + 100*1500) / 250 = 1200 + expect(result.current.avgBonusBips).toBe(1200); + }); + + it('handles points estimation errors without failing entire calculation', async () => { + // Arrange + const positions = [createMockPosition()]; + const priceData = { BTC: { price: '51000' } }; + mockCalculateFees.mockResolvedValue( + createMockFeeResult({ feeAmount: 275 }), + ); + mockEstimatePoints.mockRejectedValue(new Error('Points API failed')); + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.hasError).toBe(false); // Points error doesn't fail calculation + expect(result.current.totalFees).toBeGreaterThan(0); // Fees still calculated + expect(result.current.totalEstimatedPoints).toBe(0); // No points + expect(result.current.shouldShowRewards).toBe(false); + }); + + it('sets shouldShowRewards to true when at least one position has valid points', async () => { + // Arrange + const positions = [ + createMockPosition({ coin: 'BTC' }), + createMockPosition({ coin: 'ETH' }), + ]; + const priceData = { + BTC: { price: '51000' }, + ETH: { price: '3000' }, + }; + mockCalculateFees.mockResolvedValue(createMockFeeResult()); + mockEstimatePoints + .mockResolvedValueOnce(createMockPointsResult({ pointsEstimate: 150 })) + .mockRejectedValueOnce(new Error('Failed')); // Second one fails + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.shouldShowRewards).toBe(true); // One valid result + }); + + it('sets shouldShowRewards to false when all points are zero', async () => { + // Arrange + const positions = [createMockPosition()]; + const priceData = { BTC: { price: '51000' } }; + mockCalculateFees.mockResolvedValue(createMockFeeResult()); + mockEstimatePoints.mockResolvedValue( + createMockPointsResult({ pointsEstimate: 0 }), + ); + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.shouldShowRewards).toBe(false); + }); + }); + + describe('Average Fee Rates', () => { + it('calculates weighted average fee rates for multiple positions', async () => { + // Arrange + const positions = [ + createMockPosition({ coin: 'BTC' }), + createMockPosition({ coin: 'ETH' }), + ]; + const priceData = { + BTC: { price: '51000' }, + ETH: { price: '3000' }, + }; + mockCalculateFees + .mockResolvedValueOnce( + createMockFeeResult({ + feeAmount: 300, + metamaskFeeRate: 0.01, + protocolFeeRate: 0.001, + }), + ) + .mockResolvedValueOnce( + createMockFeeResult({ + feeAmount: 200, + metamaskFeeRate: 0.008, + protocolFeeRate: 0.0012, + }), + ); + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Weighted average MetaMask fee: (300*0.01 + 200*0.008) / 500 = 0.0092 + expect(result.current.avgMetamaskFeeRate).toBeCloseTo(0.0092, 4); + + // Weighted average protocol fee: (300*0.001 + 200*0.0012) / 500 = 0.00108 + expect(result.current.avgProtocolFeeRate).toBeCloseTo(0.00108, 5); + }); + + it('returns zero average rates for empty positions', () => { + // Arrange + const positions: Position[] = []; + const priceData = {}; + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + expect(result.current.avgMetamaskFeeRate).toBe(0); + expect(result.current.avgProtocolFeeRate).toBe(0); + }); + }); + + describe('Receive Amount Calculation', () => { + it('calculates receive amount as margin minus fees', async () => { + // Arrange + const positions = [ + createMockPosition({ + marginUsed: '1000', + unrealizedPnl: '100', + }), + ]; + const priceData = { BTC: { price: '51000' } }; + mockCalculateFees.mockResolvedValue( + createMockFeeResult({ feeAmount: 50 }), + ); + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.receiveAmount).toBe(1050); // (1000 + 100) - 50 + }); + + it('handles negative receive amount when fees exceed margin', async () => { + // Arrange + const positions = [ + createMockPosition({ + marginUsed: '100', + unrealizedPnl: '-50', + }), + ]; + const priceData = { BTC: { price: '51000' } }; + mockCalculateFees.mockResolvedValue( + createMockFeeResult({ feeAmount: 100 }), + ); + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.receiveAmount).toBe(-50); // (100 - 50) - 100 + }); + }); + + describe('Loading and Error States', () => { + it('sets loading to false after successful calculation', async () => { + // Arrange + const positions = [createMockPosition()]; + const priceData = { BTC: { price: '51000' } }; + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + }); + + it('sets hasError to true when fee calculation fails', async () => { + // Arrange + const positions = [createMockPosition()]; + const priceData = { BTC: { price: '51000' } }; + mockCalculateFees.mockRejectedValue(new Error('Network error')); + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + await waitFor(() => { + expect(result.current.hasError).toBe(true); + }); + }); + + it('sets hasError to true when account information missing', async () => { + // Arrange + mockUseSelector.mockReturnValue(null); // No selected address + const positions = [createMockPosition()]; + const priceData = { BTC: { price: '51000' } }; + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + await waitFor(() => { + expect(result.current.hasError).toBe(true); + }); + }); + + it('sets hasError to true when some position calculations fail', async () => { + // Arrange + const positions = [ + createMockPosition({ coin: 'BTC' }), + createMockPosition({ coin: 'ETH' }), + ]; + const priceData = { + BTC: { price: '51000' }, + ETH: { price: '3000' }, + }; + mockCalculateFees + .mockResolvedValueOnce(createMockFeeResult()) // First succeeds + .mockRejectedValueOnce(new Error('Failed')); // Second fails + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + await waitFor(() => { + expect(result.current.hasError).toBe(true); + }); + }); + }); + + describe('Edge Cases', () => { + it('handles positions with zero size', async () => { + // Arrange + const positions = [ + createMockPosition({ + size: '0', + marginUsed: '0', + unrealizedPnl: '0', + }), + ]; + const priceData = { BTC: { price: '51000' } }; + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(mockCalculateFees).toHaveBeenCalledWith({ + orderType: 'market', + isMaker: false, + amount: '0', + }); + }); + + it('handles positions with negative size (short positions)', async () => { + // Arrange + const positions = [ + createMockPosition({ + size: '-0.5', + entryPrice: '50000', + }), + ]; + const priceData = { BTC: { price: '51000' } }; + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(mockCalculateFees).toHaveBeenCalledWith({ + orderType: 'market', + isMaker: false, + amount: (0.5 * 51000).toString(), // Uses absolute value + }); + }); + + it('handles invalid numeric values gracefully', () => { + // Arrange + const positions = [ + createMockPosition({ + marginUsed: 'invalid', + unrealizedPnl: 'NaN', + }), + ]; + const priceData = { BTC: { price: '51000' } }; + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert - Should not crash + expect(result.current.totalMargin).toBe(0); + expect(result.current.totalPnl).toBe(0); + }); + + it('does not recalculate when positions array changes due to optimization', async () => { + // Arrange - The hook has an optimization to prevent recalculation when + // positions change (to avoid slow points API calls on WebSocket updates) + const initialPositions = [createMockPosition({ coin: 'BTC' })]; + const updatedPositions = [ + createMockPosition({ coin: 'BTC' }), + createMockPosition({ coin: 'ETH' }), + ]; + const priceData = { + BTC: { price: '51000' }, + ETH: { price: '3000' }, + }; + + // Act + const { result, rerender } = renderHook( + ({ positions }) => + usePerpsCloseAllCalculations({ positions, priceData }), + { + initialProps: { positions: initialPositions }, + }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const initialCallCount = mockCalculateFees.mock.calls.length; + + // Act - Change positions + rerender({ positions: updatedPositions }); + + // Small delay to ensure no recalculation triggered + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Assert - Should not recalculate due to hasValidResultsRef optimization + expect(mockCalculateFees.mock.calls.length).toBe(initialCallCount); + }); + + it('does not recalculate when only price data changes', async () => { + // Arrange + const positions = [createMockPosition()]; + const initialPriceData = { BTC: { price: '51000' } }; + const updatedPriceData = { BTC: { price: '52000' } }; + + // Act + const { result, rerender } = renderHook( + ({ priceData }) => + usePerpsCloseAllCalculations({ positions, priceData }), + { + initialProps: { priceData: initialPriceData }, + }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const callCountAfterInitial = mockCalculateFees.mock.calls.length; + + // Act - Change price data + rerender({ priceData: updatedPriceData }); + + // Small delay to ensure no recalculation triggered + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Assert - Should not recalculate + expect(mockCalculateFees.mock.calls.length).toBe(callCountAfterInitial); + }); + }); + + describe('Account and Chain Requirements', () => { + it('handles missing chain ID', async () => { + // Arrange + let selectorCallCount = 0; + mockUseSelector.mockImplementation(() => { + selectorCallCount++; + if (selectorCallCount % 2 === 1) { + return '0x1234567890123456789012345678901234567890'; // Valid address + } + return null; // Missing chainId + }); + const positions = [createMockPosition()]; + const priceData = { BTC: { price: '51000' } }; + + // Act + const { result } = renderHook(() => + usePerpsCloseAllCalculations({ positions, priceData }), + ); + + // Assert + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.hasError).toBe(true); + }); + }); +}); diff --git a/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.ts b/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.ts new file mode 100644 index 000000000000..76b21ab8fa08 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.ts @@ -0,0 +1,368 @@ +import { useMemo, useState, useEffect, useRef } from 'react'; +import { useSelector } from 'react-redux'; +import type { Position, FeeCalculationResult } from '../controllers/types'; +import type { + EstimatePointsDto, + EstimatedPointsDto, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +import Engine from '../../../../core/Engine'; +import { selectSelectedInternalAccountFormattedAddress } from '../../../../selectors/accountsController'; +import { selectChainId } from '../../../../selectors/networkController'; +import { formatAccountToCaipAccountId } from '../utils/rewardsUtils'; + +/** + * Aggregated calculations result for closing all positions + */ +export interface CloseAllCalculationsResult { + /** Total margin across all positions (includes P&L) */ + totalMargin: number; + /** Total unrealized P&L across all positions */ + totalPnl: number; + /** Total fees for closing all positions */ + totalFees: number; + /** Amount user will receive after closing all positions */ + receiveAmount: number; + /** Aggregated estimated points for closing all positions */ + totalEstimatedPoints: number; + /** Average fee discount percentage across all positions */ + avgFeeDiscountPercentage: number; + /** Average bonus multiplier in basis points */ + avgBonusBips: number; + /** Average MetaMask fee rate across all positions (as decimal, e.g. 0.01 for 1%) */ + avgMetamaskFeeRate: number; + /** Average protocol fee rate across all positions (as decimal, e.g. 0.00045 for 0.045%) */ + avgProtocolFeeRate: number; + /** Average original MetaMask fee rate before discounts (as decimal) */ + avgOriginalMetamaskFeeRate: number; + /** Whether any fee calculation is still loading */ + isLoading: boolean; + /** Whether there was an error in any calculation */ + hasError: boolean; + /** Whether rewards should be shown (at least one position has valid rewards) */ + shouldShowRewards: boolean; +} + +interface UsePerpsCloseAllCalculationsParams { + /** Array of positions to close */ + positions: Position[]; + /** Current market prices for fee calculation. Format: { [symbol]: { price: string } } */ + priceData: Record; +} + +/** + * Per-position calculation result + */ +interface PerPositionResult { + position: Position; + fees: FeeCalculationResult; + points: EstimatedPointsDto | null; + error?: string; +} + +/** + * Hook to aggregate fee calculations and points estimation across multiple positions + * + * This hook: + * - Calculates fees PER POSITION for accuracy (coin-specific rewards) + * - Aggregates points estimation per position + * - Handles loading states and errors across all calculations + * + * TODO(rewards-batch-api): Replace per-position loop with single batch call when + * https://github.com/consensys-vertical-apps/va-mmcx-rewards/pull/247 is merged. + * The backend will support `payload | payload[]` for batch estimation. + * + * @example + * ```tsx + * const calculations = usePerpsCloseAllCalculations({ + * positions, + * }); + * + * return ( + * + * Total Fees: {calculations.totalFees} + * Estimated Points: {calculations.totalEstimatedPoints} + * You'll Receive: {calculations.receiveAmount} + * + * ); + * ``` + */ +export function usePerpsCloseAllCalculations({ + positions, + priceData, +}: UsePerpsCloseAllCalculationsParams): CloseAllCalculationsResult { + // Selectors for account and chain + const selectedAddress = useSelector( + selectSelectedInternalAccountFormattedAddress, + ); + const currentChainId = useSelector(selectChainId); + + // Use ref to access latest priceData without triggering re-renders + const priceDataRef = useRef(priceData); + priceDataRef.current = priceData; + + // State for per-position calculations + const [perPositionResults, setPerPositionResults] = useState< + PerPositionResult[] + >([]); + const [isCalculating, setIsCalculating] = useState(false); + const [hasCalculationError, setHasCalculationError] = useState(false); + + // Prevent slow points computation from retriggering on WebSocket position updates + // Once we have valid results, freeze them to avoid recalculation failures showing 0 points + const hasValidResultsRef = useRef(false); + + // Calculate total margin (including P&L) + const totalMargin = useMemo( + () => + positions.reduce((sum, pos) => { + const margin = parseFloat(pos.marginUsed) || 0; + const pnl = parseFloat(pos.unrealizedPnl) || 0; + return sum + margin + pnl; + }, 0), + [positions], + ); + + // Calculate total PnL + const totalPnl = useMemo( + () => + positions.reduce( + (sum, pos) => sum + (parseFloat(pos.unrealizedPnl) || 0), + 0, + ), + [positions], + ); + + // Per-position fee and rewards calculation + // This ensures accurate coin-specific rewards calculation + useEffect(() => { + // Skip recalculation if we already have valid results + // Prevents slow points API calls from retriggering on WebSocket position updates + if (hasValidResultsRef.current) { + return; + } + + async function calculatePerPosition() { + if (positions.length === 0) { + setPerPositionResults([]); + setHasCalculationError(false); + return; + } + + if (!selectedAddress || !currentChainId) { + setHasCalculationError(true); + return; + } + + setIsCalculating(true); + setHasCalculationError(false); + + try { + // Convert address to CAIP format for rewards API + const caipAccountId = formatAccountToCaipAccountId( + selectedAddress, + currentChainId, + ); + if (!caipAccountId) { + throw new Error('Failed to format account to CAIP ID'); + } + + const results = await Promise.all( + positions.map(async (pos): Promise => { + try { + // Calculate position value using current market price for accurate fee estimation + // Fees must reflect the actual USD value being closed at current market conditions + // Use ref to get latest price without triggering re-renders + const currentPrice = priceDataRef.current[pos.coin]?.price + ? parseFloat(priceDataRef.current[pos.coin].price) + : parseFloat(pos.entryPrice); // Fallback to entry price if current price unavailable + const size = Math.abs(parseFloat(pos.size)); + const positionValue = size * currentPrice; + + // Calculate fees via PerpsController + const fees = await Engine.context.PerpsController.calculateFees({ + orderType: 'market', + isMaker: false, // Market close orders are always taker + amount: positionValue.toString(), + coin: pos.coin, // Required for HIP-3 2× fee calculation + }); + + // Calculate rewards points per position with coin-specific parameters + let points: EstimatedPointsDto | null = null; + try { + const estimateBody: EstimatePointsDto = { + activityType: 'PERPS', + account: caipAccountId, + activityContext: { + perpsContext: { + type: 'CLOSE_POSITION', + usdFeeValue: (fees.feeAmount ?? 0).toString(), + coin: pos.coin, // ✅ Accurate per-position coin + }, + }, + }; + + points = await Engine.context.RewardsController.estimatePoints( + estimateBody, + ); + } catch (pointsError) { + // Log but don't fail the entire calculation if rewards estimation fails + console.warn( + `Failed to estimate points for ${pos.coin}:`, + pointsError, + ); + } + + return { + position: pos, + fees, + points, + }; + } catch (error) { + return { + position: pos, + fees: { + feeRate: 0, + feeAmount: 0, + protocolFeeRate: 0, + protocolFeeAmount: 0, + metamaskFeeRate: 0, + metamaskFeeAmount: 0, + }, + points: null, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }), + ); + + setPerPositionResults(results); + + // Check if any calculation had errors + const hasErrors = results.some((r) => r.error); + setHasCalculationError(hasErrors); + + // Mark as valid if calculation succeeded (no errors and has results) + // Freezes results to prevent slow points computation from retriggering + if (!hasErrors && results.length > 0) { + hasValidResultsRef.current = true; + } + } catch (error) { + console.error('Failed to calculate per-position fees:', error); + setHasCalculationError(true); + } finally { + setIsCalculating(false); + } + } + + calculatePerPosition().catch((error) => { + console.error('Unhandled error in calculatePerPosition:', error); + setHasCalculationError(true); + }); + }, [positions, selectedAddress, currentChainId]); + // Note: priceData intentionally excluded from deps to prevent recalculation on every price update + // Calculations use the latest priceData reference but only re-run when positions/account changes + + // Aggregate results from per-position calculations + const aggregatedResults = useMemo(() => { + if (perPositionResults.length === 0) { + return { + totalFees: 0, + totalEstimatedPoints: 0, + avgFeeDiscountPercentage: 0, + avgBonusBips: 0, + avgMetamaskFeeRate: 0, + avgProtocolFeeRate: 0, + avgOriginalMetamaskFeeRate: 0, + shouldShowRewards: false, + }; + } + + // Sum fees and points + const totalFees = perPositionResults.reduce( + (sum, result) => sum + (result.fees.feeAmount ?? 0), + 0, + ); + + const totalEstimatedPoints = perPositionResults.reduce( + (sum, result) => sum + (result.points?.pointsEstimate ?? 0), + 0, + ); + + // Calculate weighted averages based on fee amounts + let weightedMetamaskFeeRate = 0; + let weightedProtocolFeeRate = 0; + let totalWeight = 0; + + perPositionResults.forEach((result) => { + const weight = result.fees.feeAmount ?? 0; + if (weight > 0) { + weightedMetamaskFeeRate += result.fees.metamaskFeeRate * weight; + weightedProtocolFeeRate += result.fees.protocolFeeRate * weight; + totalWeight += weight; + } + }); + + const avgMetamaskFeeRate = + totalWeight > 0 ? weightedMetamaskFeeRate / totalWeight : 0; + const avgProtocolFeeRate = + totalWeight > 0 ? weightedProtocolFeeRate / totalWeight : 0; + + // For fee discount calculation, we need original rates from breakdown if available + // Otherwise avgOriginalMetamaskFeeRate equals avgMetamaskFeeRate (no discount) + const avgOriginalMetamaskFeeRate = avgMetamaskFeeRate; // Simplified for now + + // Calculate average fee discount percentage (currently 0 as we don't have original rates) + const avgFeeDiscountPercentage = 0; + + // Calculate average bonus bips (weighted by points) + let weightedBonusBips = 0; + let totalPointsWeight = 0; + perPositionResults.forEach((result) => { + const pointsWeight = result.points?.pointsEstimate ?? 0; + if (pointsWeight > 0 && result.points) { + weightedBonusBips += result.points.bonusBips * pointsWeight; + totalPointsWeight += pointsWeight; + } + }); + const avgBonusBips = + totalPointsWeight > 0 ? weightedBonusBips / totalPointsWeight : 0; + + // Show rewards if at least one position has valid points + const shouldShowRewards = perPositionResults.some( + (result) => result.points !== null && result.points.pointsEstimate > 0, + ); + + return { + totalFees, + totalEstimatedPoints, + avgFeeDiscountPercentage, + avgBonusBips, + avgMetamaskFeeRate, + avgProtocolFeeRate, + avgOriginalMetamaskFeeRate, + shouldShowRewards, + }; + }, [perPositionResults]); + + // Calculate final receive amount + const receiveAmount = useMemo( + () => totalMargin - aggregatedResults.totalFees, + [totalMargin, aggregatedResults.totalFees], + ); + + return { + totalMargin, + totalPnl, + totalFees: aggregatedResults.totalFees, + receiveAmount, + totalEstimatedPoints: aggregatedResults.totalEstimatedPoints, + avgFeeDiscountPercentage: aggregatedResults.avgFeeDiscountPercentage, + avgBonusBips: aggregatedResults.avgBonusBips, + avgMetamaskFeeRate: aggregatedResults.avgMetamaskFeeRate, + avgProtocolFeeRate: aggregatedResults.avgProtocolFeeRate, + avgOriginalMetamaskFeeRate: aggregatedResults.avgOriginalMetamaskFeeRate, + isLoading: isCalculating, + hasError: hasCalculationError, + shouldShowRewards: aggregatedResults.shouldShowRewards, + }; +} diff --git a/app/components/UI/Perps/hooks/usePerpsCloseAllPositions.test.ts b/app/components/UI/Perps/hooks/usePerpsCloseAllPositions.test.ts new file mode 100644 index 000000000000..3ad8320c68dd --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsCloseAllPositions.test.ts @@ -0,0 +1,432 @@ +import { renderHook, act, waitFor } from '@testing-library/react-native'; +import { useNavigation } from '@react-navigation/native'; +import { usePerpsCloseAllPositions } from './usePerpsCloseAllPositions'; +import Engine from '../../../../core/Engine'; +import type { Position } from '../controllers/types'; + +// Mock dependencies +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(), +})); + +jest.mock('../../../../core/Engine', () => ({ + context: { + PerpsController: { + closePositions: jest.fn(), + }, + }, +})); + +jest.mock('../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string, params?: Record) => { + if (key === 'perps.close_all_modal.error_message' && params?.count) { + return `Failed to close ${params.count} positions`; + } + return key; + }), +})); + +const createMockPosition = (overrides: Partial = {}): Position => ({ + coin: 'BTC', + size: '0.5', + entryPrice: '50000', + positionValue: '25000', + unrealizedPnl: '100', + marginUsed: '1000', + leverage: { type: 'cross', value: 25 }, + liquidationPrice: '48000', + maxLeverage: 50, + returnOnEquity: '10', + cumulativeFunding: { + allTime: '0', + sinceOpen: '0', + sinceChange: '0', + }, + takeProfitPrice: undefined, + stopLossPrice: undefined, + takeProfitCount: 0, + stopLossCount: 0, + ...overrides, +}); + +describe('usePerpsCloseAllPositions', () => { + const mockNavigation = { + goBack: jest.fn(), + navigate: jest.fn(), + canGoBack: jest.fn(() => true), + }; + + beforeEach(() => { + jest.clearAllMocks(); + (useNavigation as jest.Mock).mockReturnValue(mockNavigation); + }); + + it('initializes with correct default state', () => { + // Arrange + const positions = [ + createMockPosition(), + createMockPosition({ coin: 'ETH' }), + ]; + + // Act + const { result } = renderHook(() => usePerpsCloseAllPositions(positions)); + + // Assert + expect(result.current.isClosing).toBe(false); + expect(result.current.positionCount).toBe(2); + expect(result.current.error).toBeNull(); + expect(typeof result.current.handleCloseAll).toBe('function'); + expect(typeof result.current.handleKeepPositions).toBe('function'); + }); + + it('handles empty positions array', () => { + // Arrange & Act + const { result } = renderHook(() => usePerpsCloseAllPositions([])); + + // Assert + expect(result.current.positionCount).toBe(0); + }); + + it('handles null positions', () => { + // Arrange & Act + const { result } = renderHook(() => usePerpsCloseAllPositions(null)); + + // Assert + expect(result.current.positionCount).toBe(0); + }); + + it('closes all positions successfully', async () => { + // Arrange + const positions = [ + createMockPosition(), + createMockPosition({ coin: 'ETH' }), + ]; + const mockResult = { + success: true, + successCount: 2, + failureCount: 0, + results: [], + }; + ( + Engine.context.PerpsController.closePositions as jest.Mock + ).mockResolvedValue(mockResult); + const { result } = renderHook(() => usePerpsCloseAllPositions(positions)); + + // Act + await act(async () => { + await result.current.handleCloseAll(); + }); + + // Assert + await waitFor(() => { + expect(result.current.isClosing).toBe(false); + }); + expect(Engine.context.PerpsController.closePositions).toHaveBeenCalledWith({ + closeAll: true, + }); + expect(mockNavigation.goBack).toHaveBeenCalled(); + expect(result.current.error).toBeNull(); + }); + + it('handles partial success (some positions close, some fail)', async () => { + // Arrange + const positions = [ + createMockPosition(), + createMockPosition({ coin: 'ETH' }), + ]; + const mockResult = { + success: false, + successCount: 1, + failureCount: 1, + results: [], + }; + ( + Engine.context.PerpsController.closePositions as jest.Mock + ).mockResolvedValue(mockResult); + const { result } = renderHook(() => usePerpsCloseAllPositions(positions)); + + // Act + await act(async () => { + await result.current.handleCloseAll(); + }); + + // Assert + await waitFor(() => { + expect(result.current.isClosing).toBe(false); + }); + expect(mockNavigation.goBack).toHaveBeenCalled(); + expect(result.current.error).toBeNull(); + }); + + it('handles complete failure (all positions fail)', async () => { + // Arrange + const positions = [ + createMockPosition(), + createMockPosition({ coin: 'ETH' }), + ]; + const mockResult = { + success: false, + successCount: 0, + failureCount: 2, + results: [], + }; + ( + Engine.context.PerpsController.closePositions as jest.Mock + ).mockResolvedValue(mockResult); + const { result } = renderHook(() => usePerpsCloseAllPositions(positions)); + + // Act + await act(async () => { + await result.current.handleCloseAll(); + }); + + // Assert + await waitFor(() => { + expect(result.current.isClosing).toBe(false); + }); + expect(mockNavigation.goBack).not.toHaveBeenCalled(); + expect(result.current.error).not.toBeNull(); + expect(result.current.error?.message).toContain( + 'Failed to close 2 positions', + ); + }); + + it('handles network errors', async () => { + // Arrange + const positions = [createMockPosition()]; + const networkError = new Error('Network request failed'); + ( + Engine.context.PerpsController.closePositions as jest.Mock + ).mockRejectedValue(networkError); + const { result } = renderHook(() => usePerpsCloseAllPositions(positions)); + + // Act + await act(async () => { + await result.current.handleCloseAll(); + }); + + // Assert + await waitFor(() => { + expect(result.current.isClosing).toBe(false); + }); + expect(result.current.error).toEqual(networkError); + expect(mockNavigation.goBack).not.toHaveBeenCalled(); + }); + + it('manages loading state correctly', async () => { + // Arrange + const positions = [createMockPosition()]; + let resolveClose: (value: unknown) => void; + const closePromise = new Promise((resolve) => { + resolveClose = resolve; + }); + ( + Engine.context.PerpsController.closePositions as jest.Mock + ).mockReturnValue(closePromise); + const { result } = renderHook(() => usePerpsCloseAllPositions(positions)); + + // Act - Start closing + act(() => { + result.current.handleCloseAll(); + }); + + // Assert - Should be closing + await waitFor(() => { + expect(result.current.isClosing).toBe(true); + }); + + // Act - Resolve the promise + await act(async () => { + resolveClose({ + success: true, + successCount: 1, + failureCount: 0, + results: [], + }); + await closePromise; + }); + + // Assert - Should no longer be closing + await waitFor(() => { + expect(result.current.isClosing).toBe(false); + }); + }); + + it('invokes onSuccess callback when provided', async () => { + // Arrange + const positions = [createMockPosition()]; + const mockResult = { + success: true, + successCount: 1, + failureCount: 0, + results: [], + }; + ( + Engine.context.PerpsController.closePositions as jest.Mock + ).mockResolvedValue(mockResult); + const onSuccess = jest.fn(); + const { result } = renderHook(() => + usePerpsCloseAllPositions(positions, { onSuccess }), + ); + + // Act + await act(async () => { + await result.current.handleCloseAll(); + }); + + // Assert + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledWith(mockResult); + }); + }); + + it('invokes onError callback when provided', async () => { + // Arrange + const positions = [createMockPosition()]; + const error = new Error('Close failed'); + ( + Engine.context.PerpsController.closePositions as jest.Mock + ).mockRejectedValue(error); + const onError = jest.fn(); + const { result } = renderHook(() => + usePerpsCloseAllPositions(positions, { onError }), + ); + + // Act + await act(async () => { + await result.current.handleCloseAll(); + }); + + // Assert + await waitFor(() => { + expect(onError).toHaveBeenCalledWith(error); + }); + }); + + it('does not navigate back when navigateBackOnSuccess is false', async () => { + // Arrange + const positions = [createMockPosition()]; + const mockResult = { + success: true, + successCount: 1, + failureCount: 0, + results: [], + }; + ( + Engine.context.PerpsController.closePositions as jest.Mock + ).mockResolvedValue(mockResult); + const { result } = renderHook(() => + usePerpsCloseAllPositions(positions, { navigateBackOnSuccess: false }), + ); + + // Act + await act(async () => { + await result.current.handleCloseAll(); + }); + + // Assert + await waitFor(() => { + expect(result.current.isClosing).toBe(false); + }); + expect(mockNavigation.goBack).not.toHaveBeenCalled(); + }); + + it('handles keepPositions action', () => { + // Arrange + const positions = [createMockPosition()]; + const { result } = renderHook(() => usePerpsCloseAllPositions(positions)); + + // Act + act(() => { + result.current.handleKeepPositions(); + }); + + // Assert + expect(mockNavigation.goBack).toHaveBeenCalled(); + }); + + it('does nothing when handleCloseAll called with no positions', async () => { + // Arrange + const { result } = renderHook(() => usePerpsCloseAllPositions(null)); + + // Act + await act(async () => { + await result.current.handleCloseAll(); + }); + + // Assert + expect( + Engine.context.PerpsController.closePositions, + ).not.toHaveBeenCalled(); + expect(result.current.isClosing).toBe(false); + }); + + it('clears error state on subsequent successful close', async () => { + // Arrange + const positions = [createMockPosition()]; + const error = new Error('First close failed'); + (Engine.context.PerpsController.closePositions as jest.Mock) + .mockRejectedValueOnce(error) + .mockResolvedValueOnce({ + success: true, + successCount: 1, + failureCount: 0, + results: [], + }); + const { result } = renderHook(() => usePerpsCloseAllPositions(positions)); + + // Act - First close fails + await act(async () => { + await result.current.handleCloseAll(); + }); + + // Assert - Error is set + await waitFor(() => { + expect(result.current.error).toEqual(error); + }); + + // Act - Second close succeeds + await act(async () => { + await result.current.handleCloseAll(); + }); + + // Assert - Error is cleared + await waitFor(() => { + expect(result.current.error).toBeNull(); + }); + }); + + it('logs calculation metadata when provided', async () => { + // Arrange + const positions = [createMockPosition()]; + const mockResult = { + success: true, + successCount: 1, + failureCount: 0, + results: [], + }; + ( + Engine.context.PerpsController.closePositions as jest.Mock + ).mockResolvedValue(mockResult); + const calculations = { + totalMargin: '1000', + totalPnl: '100', + totalFees: '10', + receiveAmount: '1090', + }; + const { result } = renderHook(() => + usePerpsCloseAllPositions(positions, { calculations }), + ); + + // Act + await act(async () => { + await result.current.handleCloseAll(); + }); + + // Assert + await waitFor(() => { + expect(result.current.isClosing).toBe(false); + }); + expect(mockNavigation.goBack).toHaveBeenCalled(); + }); +}); diff --git a/app/components/UI/Perps/hooks/usePerpsCloseAllPositions.ts b/app/components/UI/Perps/hooks/usePerpsCloseAllPositions.ts new file mode 100644 index 000000000000..55078e939c8c --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsCloseAllPositions.ts @@ -0,0 +1,173 @@ +import { useCallback, useState } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; +import Engine from '../../../../core/Engine'; +import type { Position, ClosePositionsResult } from '../controllers/types'; +import { strings } from '../../../../../locales/i18n'; +import Routes from '../../../../constants/navigation/Routes'; + +export interface UsePerpsCloseAllPositionsOptions { + /** Callback invoked when closing succeeds */ + onSuccess?: (result: ClosePositionsResult) => void; + /** Callback invoked when closing fails */ + onError?: (error: Error) => void; + /** Whether to navigate back on success (default: true) */ + navigateBackOnSuccess?: boolean; + /** Additional metadata for logging */ + calculations?: { + totalMargin?: string; + totalPnl?: string; + totalFees?: string; + receiveAmount?: string; + }; +} + +export interface UsePerpsCloseAllPositionsReturn { + /** Whether closing is in progress */ + isClosing: boolean; + /** Number of positions to close */ + positionCount: number; + /** Close all positions */ + handleCloseAll: () => Promise; + /** Keep positions and navigate back */ + handleKeepPositions: () => void; + /** Last error that occurred */ + error: Error | null; +} + +/** + * Hook for managing close all positions business logic + * + * Handles: + * - Closing state management + * - Controller interaction + * - Error handling + * - Success/partial success/failure logic + * - Navigation + * - Performance logging + * + * @param positions - Array of positions to close + * @param options - Configuration options + * @returns Close all positions state and handlers + */ +export const usePerpsCloseAllPositions = ( + positions: Position[] | null, + options?: UsePerpsCloseAllPositionsOptions, +): UsePerpsCloseAllPositionsReturn => { + const navigation = useNavigation(); + const [isClosing, setIsClosing] = useState(false); + const [error, setError] = useState(null); + + const { + onSuccess, + onError, + navigateBackOnSuccess = true, + calculations, + } = options || {}; + + const positionCount = positions?.length || 0; + + const handleCloseAll = useCallback(async () => { + if (!positions || positions.length === 0) { + DevLogger.log('[usePerpsCloseAllPositions] No positions to close'); + return; + } + + const startTime = Date.now(); + setIsClosing(true); + setError(null); + + DevLogger.log('[usePerpsCloseAllPositions] Starting close all positions', { + positionCount: positions.length, + totalMargin: calculations?.totalMargin, + totalPnl: calculations?.totalPnl, + estimatedTotalFees: calculations?.totalFees, + estimatedReceiveAmount: calculations?.receiveAmount, + }); + + try { + const result = await Engine.context.PerpsController.closePositions({ + closeAll: true, + }); + + const executionTime = Date.now() - startTime; + + DevLogger.log('[usePerpsCloseAllPositions] Close result', { + success: result.success, + successCount: result.successCount, + failureCount: result.failureCount, + executionTimeMs: executionTime, + }); + + // Invoke success callback if provided + if (onSuccess) { + onSuccess(result); + } + + // Navigate back on any success (full or partial) + if (navigateBackOnSuccess && result.successCount > 0) { + if (navigation.canGoBack()) { + navigation.goBack(); + } else { + // Fallback: navigate to Markets view if can't go back + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.PERPS_HOME, + }); + } + } + + // If complete failure, throw error to trigger catch block + if (result.successCount === 0 && result.failureCount > 0) { + throw new Error( + strings('perps.close_all_modal.error_message', { + count: result.failureCount, + }), + ); + } + } catch (err) { + const executionTime = Date.now() - startTime; + const errorObj = err instanceof Error ? err : new Error(String(err)); + setError(errorObj); + + DevLogger.log('[usePerpsCloseAllPositions] Close failed', { + error: errorObj.message, + errorStack: errorObj.stack, + executionTimeMs: executionTime, + }); + + // Invoke error callback if provided + if (onError) { + onError(errorObj); + } + } finally { + setIsClosing(false); + } + }, [ + positions, + calculations, + onSuccess, + onError, + navigateBackOnSuccess, + navigation, + ]); + + const handleKeepPositions = useCallback(() => { + DevLogger.log('[usePerpsCloseAllPositions] User chose to keep positions'); + if (navigation.canGoBack()) { + navigation.goBack(); + } else { + // Fallback: navigate to Markets view if can't go back + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.PERPS_HOME, + }); + } + }, [navigation]); + + return { + isClosing, + positionCount, + handleCloseAll, + handleKeepPositions, + error, + }; +}; diff --git a/app/components/UI/Perps/hooks/usePerpsDataMonitor.test.ts b/app/components/UI/Perps/hooks/usePerpsDataMonitor.test.ts index 443b2adb05a3..a788dafbadbd 100644 --- a/app/components/UI/Perps/hooks/usePerpsDataMonitor.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsDataMonitor.test.ts @@ -94,7 +94,10 @@ describe('usePerpsDataMonitor (Declarative API)', () => { jest.useFakeTimers(); // Default mock implementations - mockUsePerpsLiveOrders.mockReturnValue([]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [], + isInitialLoading: false, + }); mockUsePerpsLivePositions.mockReturnValue({ positions: [], isInitialLoading: false, @@ -303,7 +306,10 @@ describe('usePerpsDataMonitor (Declarative API)', () => { const onDataDetected = jest.fn(); // Start with no orders - mockUsePerpsLiveOrders.mockReturnValue([]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [], + isInitialLoading: false, + }); const { rerender } = renderHook(() => usePerpsDataMonitor({ @@ -316,7 +322,10 @@ describe('usePerpsDataMonitor (Declarative API)', () => { ); // Add new BTC order to trigger detection - mockUsePerpsLiveOrders.mockReturnValue([mockBTCOrder]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [mockBTCOrder], + isInitialLoading: false, + }); rerender({}); expect(onDataDetected).toHaveBeenCalledWith({ @@ -350,7 +359,10 @@ describe('usePerpsDataMonitor (Declarative API)', () => { const onDataDetected = jest.fn(); // Start with no orders - mockUsePerpsLiveOrders.mockReturnValue([]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [], + isInitialLoading: false, + }); const { rerender } = renderHook(() => usePerpsDataMonitor({ @@ -363,7 +375,10 @@ describe('usePerpsDataMonitor (Declarative API)', () => { ); // Add new BTC order - mockUsePerpsLiveOrders.mockReturnValue([mockBTCOrder]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [mockBTCOrder], + isInitialLoading: false, + }); rerender({}); expect(onDataDetected).toHaveBeenCalledWith({ @@ -377,7 +392,10 @@ describe('usePerpsDataMonitor (Declarative API)', () => { const onDataDetected = jest.fn(); // Start with no orders - mockUsePerpsLiveOrders.mockReturnValue([]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [], + isInitialLoading: false, + }); const { rerender } = renderHook(() => usePerpsDataMonitor({ @@ -390,7 +408,10 @@ describe('usePerpsDataMonitor (Declarative API)', () => { ); // Add ETH order (different asset) - mockUsePerpsLiveOrders.mockReturnValue([mockETHOrder]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [mockETHOrder], + isInitialLoading: false, + }); rerender({}); expect(onDataDetected).not.toHaveBeenCalled(); @@ -400,7 +421,10 @@ describe('usePerpsDataMonitor (Declarative API)', () => { const onDataDetected = jest.fn(); // Start with no orders - mockUsePerpsLiveOrders.mockReturnValue([]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [], + isInitialLoading: false, + }); const { result, rerender } = renderHook(() => usePerpsDataMonitor({ @@ -415,7 +439,10 @@ describe('usePerpsDataMonitor (Declarative API)', () => { expect(result.current).toBeUndefined(); // Add new BTC order - mockUsePerpsLiveOrders.mockReturnValue([mockBTCOrder]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [mockBTCOrder], + isInitialLoading: false, + }); rerender({}); expect(result.current).toBeUndefined(); @@ -563,7 +590,10 @@ describe('usePerpsDataMonitor (Declarative API)', () => { const onDataDetected = jest.fn(); // Start with no data - mockUsePerpsLiveOrders.mockReturnValue([]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [], + isInitialLoading: false, + }); mockUsePerpsLivePositions.mockReturnValue({ positions: [], isInitialLoading: false, @@ -579,7 +609,10 @@ describe('usePerpsDataMonitor (Declarative API)', () => { ); // Add new BTC order - should detect since we're monitoring both - mockUsePerpsLiveOrders.mockReturnValue([mockBTCOrder]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [mockBTCOrder], + isInitialLoading: false, + }); rerender({}); expect(onDataDetected).toHaveBeenCalledWith({ @@ -593,7 +626,10 @@ describe('usePerpsDataMonitor (Declarative API)', () => { const onDataDetected = jest.fn(); // Start with no data - mockUsePerpsLiveOrders.mockReturnValue([]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [], + isInitialLoading: false, + }); mockUsePerpsLivePositions.mockReturnValue({ positions: [], isInitialLoading: false, @@ -610,7 +646,10 @@ describe('usePerpsDataMonitor (Declarative API)', () => { ); // Add new BTC order - mockUsePerpsLiveOrders.mockReturnValue([mockBTCOrder]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [mockBTCOrder], + isInitialLoading: false, + }); rerender({}); expect(onDataDetected).toHaveBeenCalledWith({ @@ -624,7 +663,10 @@ describe('usePerpsDataMonitor (Declarative API)', () => { const onDataDetected = jest.fn(); // Start with no data - mockUsePerpsLiveOrders.mockReturnValue([]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [], + isInitialLoading: false, + }); mockUsePerpsLivePositions.mockReturnValue({ positions: [], isInitialLoading: false, @@ -658,7 +700,10 @@ describe('usePerpsDataMonitor (Declarative API)', () => { const onDataDetected = jest.fn(); // Start with no data - mockUsePerpsLiveOrders.mockReturnValue([]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [], + isInitialLoading: false, + }); mockUsePerpsLivePositions.mockReturnValue({ positions: [], isInitialLoading: false, @@ -675,7 +720,10 @@ describe('usePerpsDataMonitor (Declarative API)', () => { ); // Add both orders and positions simultaneously - mockUsePerpsLiveOrders.mockReturnValue([mockBTCOrder]); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [mockBTCOrder], + isInitialLoading: false, + }); mockUsePerpsLivePositions.mockReturnValue({ positions: [mockBTCPosition], isInitialLoading: false, @@ -776,13 +824,14 @@ describe('usePerpsDataMonitor (Declarative API)', () => { }); describe('loading states', () => { - it('does not trigger monitoring while orders are loading', () => { + it('triggers monitoring after loading completes', () => { const onDataDetected = jest.fn(); // Orders are loading (undefined) - mockUsePerpsLiveOrders.mockReturnValue( - undefined as Position[] | undefined, - ); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [], + isInitialLoading: true, + }); const { rerender } = renderHook(() => usePerpsDataMonitor({ @@ -794,11 +843,19 @@ describe('usePerpsDataMonitor (Declarative API)', () => { }), ); - // Try to add order while loading - mockUsePerpsLiveOrders.mockReturnValue([mockBTCOrder]); + // Loading completes with order - should trigger detection + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [mockBTCOrder], + isInitialLoading: false, + }); rerender({}); - expect(onDataDetected).not.toHaveBeenCalled(); + // Should detect the order since monitoring was enabled and order appeared + expect(onDataDetected).toHaveBeenCalledWith({ + asset: 'BTC', + detectedData: 'orders', + reason: 'new_orders_detected', + }); }); it('does not trigger monitoring while positions are loading', () => { @@ -856,9 +913,10 @@ describe('usePerpsDataMonitor (Declarative API)', () => { it('handles undefined orders gracefully', () => { const onDataDetected = jest.fn(); - mockUsePerpsLiveOrders.mockReturnValue( - undefined as Position[] | undefined, - ); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [], + isInitialLoading: true, + }); expect(() => { renderHook(() => diff --git a/app/components/UI/Perps/hooks/usePerpsDataMonitor.ts b/app/components/UI/Perps/hooks/usePerpsDataMonitor.ts index 6086abd01c24..ee9f26f87b14 100644 --- a/app/components/UI/Perps/hooks/usePerpsDataMonitor.ts +++ b/app/components/UI/Perps/hooks/usePerpsDataMonitor.ts @@ -84,7 +84,7 @@ export function usePerpsDataMonitor( const [initialOrderCount, setInitialOrderCount] = useState(0); // Get orders data (real-time WebSocket) - const orders = usePerpsLiveOrders({}); + const { orders } = usePerpsLiveOrders({}); const isLoadingOrders = !orders; // Simple loading check - orders is undefined until loaded // Get all positions data (real-time WebSocket) diff --git a/app/components/UI/Perps/hooks/usePerpsHomeData.test.ts b/app/components/UI/Perps/hooks/usePerpsHomeData.test.ts new file mode 100644 index 000000000000..761c5a53e381 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsHomeData.test.ts @@ -0,0 +1,848 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; +import type { + Order, + OrderFill, + PerpsMarketData, + Position, +} from '../controllers/types'; +import { sortMarkets, type SortField } from '../utils/sortMarkets'; + +// Type for markets with volumeNumber (returned by usePerpsMarkets) +type PerpsMarketDataWithVolumeNumber = PerpsMarketData & { + volumeNumber: number; +}; +import { + usePerpsLiveFills, + usePerpsLiveOrders, + usePerpsLivePositions, +} from './stream'; +import { usePerpsHomeData } from './usePerpsHomeData'; +import { usePerpsMarkets } from './usePerpsMarkets'; +import { + selectPerpsWatchlistMarkets, + selectPerpsMarketFilterPreferences, +} from '../selectors/perpsController'; + +// Mock dependencies +jest.mock('./stream'); +jest.mock('./usePerpsMarkets'); +jest.mock('../utils/sortMarkets'); +jest.mock('react-redux'); +jest.mock('../selectors/perpsController'); + +// Type mock functions +const mockUsePerpsLivePositions = usePerpsLivePositions as jest.MockedFunction< + typeof usePerpsLivePositions +>; +const mockUsePerpsLiveOrders = usePerpsLiveOrders as jest.MockedFunction< + typeof usePerpsLiveOrders +>; +const mockUsePerpsLiveFills = usePerpsLiveFills as jest.MockedFunction< + typeof usePerpsLiveFills +>; +const mockUsePerpsMarkets = usePerpsMarkets as jest.MockedFunction< + typeof usePerpsMarkets +>; +const mockSortMarkets = sortMarkets as jest.MockedFunction; +const mockUseSelector = useSelector as jest.MockedFunction; +const mockSelectPerpsWatchlistMarkets = + selectPerpsWatchlistMarkets as jest.MockedFunction< + typeof selectPerpsWatchlistMarkets + >; +const mockSelectPerpsMarketFilterPreferences = + selectPerpsMarketFilterPreferences as jest.MockedFunction< + typeof selectPerpsMarketFilterPreferences + >; + +// Test data helper functions +const createMockPosition = (overrides: Partial = {}): Position => ({ + coin: 'BTC', + size: '0.5', + entryPrice: '45000', + positionValue: '22500', + unrealizedPnl: '250', + returnOnEquity: '0.05', + leverage: { + type: 'cross', + value: 2, + rawUsd: '3000', + }, + liquidationPrice: '40000', + marginUsed: '1500', + maxLeverage: 100, + cumulativeFunding: { + allTime: '50', + sinceOpen: '10', + sinceChange: '5', + }, + takeProfitCount: 0, + stopLossCount: 0, + ...overrides, +}); + +const createMockOrder = (overrides: Partial = {}): Order => ({ + orderId: '12345', + symbol: 'BTC', + side: 'buy', + orderType: 'limit', + size: '0.1', + originalSize: '0.1', + price: '46000', + filledSize: '0', + remainingSize: '0.1', + status: 'open', + timestamp: 1234567890, + ...overrides, +}); + +const createMockOrderFill = ( + overrides: Partial = {}, +): OrderFill => ({ + orderId: 'fill-123', + symbol: 'ETH', + side: 'buy', + price: '3000', + size: '1.0', + pnl: '0', + direction: 'open_long', + timestamp: 1234567890, + fee: '10', + feeToken: 'USDC', + ...overrides, +}); + +const createMockMarket = ( + overrides: Partial = {}, +): PerpsMarketDataWithVolumeNumber => ({ + symbol: 'BTC', + name: 'Bitcoin', + maxLeverage: '40x', + price: '$50,000.00', + change24h: '+2.5%', + change24hPercent: '2.5', + volume: '$1.2B', + volumeNumber: 1200000000, + ...overrides, +}); + +describe('usePerpsHomeData', () => { + let mockPositions: Position[]; + let mockOrders: Order[]; + let mockFills: OrderFill[]; + let mockMarkets: PerpsMarketDataWithVolumeNumber[]; + let mockRefreshMarkets: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + // Create mock data + mockPositions = [ + createMockPosition({ coin: 'BTC' }), + createMockPosition({ coin: 'ETH' }), + ]; + + mockOrders = [ + createMockOrder({ symbol: 'BTC' }), + createMockOrder({ symbol: 'SOL' }), + ]; + + mockFills = [ + createMockOrderFill({ symbol: 'ETH' }), + createMockOrderFill({ symbol: 'BTC' }), + ]; + + mockMarkets = [ + createMockMarket({ + symbol: 'BTC', + name: 'Bitcoin', + volume: '$1.2B', + volumeNumber: 1200000000, + }), + createMockMarket({ + symbol: 'ETH', + name: 'Ethereum', + volume: '$900M', + volumeNumber: 900000000, + }), + createMockMarket({ + symbol: 'SOL', + name: 'Solana', + volume: '$500M', + volumeNumber: 500000000, + }), + ]; + + mockRefreshMarkets = jest.fn().mockResolvedValue(undefined); + + // Setup default mock implementations + mockUsePerpsLivePositions.mockReturnValue({ + positions: mockPositions, + isInitialLoading: false, + }); + + mockUsePerpsLiveOrders.mockReturnValue({ + orders: mockOrders, + isInitialLoading: false, + }); + + mockUsePerpsLiveFills.mockReturnValue({ + fills: mockFills, + isInitialLoading: false, + }); + + mockUsePerpsMarkets.mockReturnValue({ + markets: mockMarkets, + isLoading: false, + isRefreshing: false, + error: null, + refresh: mockRefreshMarkets, + }); + + // Mock sortMarkets to return markets as-is by default + mockSortMarkets.mockImplementation(({ markets }) => markets); + + // Mock Redux selectors - call the appropriate mocked selector based on which selector is passed + mockUseSelector.mockImplementation((selector) => { + if (selector === selectPerpsWatchlistMarkets) { + return mockSelectPerpsWatchlistMarkets({} as never); + } + if (selector === selectPerpsMarketFilterPreferences) { + return mockSelectPerpsMarketFilterPreferences({} as never); + } + throw new Error(`Unmocked selector called: ${selector.name || selector}`); + }); + + // Set default return values for the selectors + mockSelectPerpsWatchlistMarkets.mockReturnValue(['BTC', 'ETH']); + mockSelectPerpsMarketFilterPreferences.mockReturnValue('volume'); + }); + + describe('Initial state and data loading', () => { + it('returns initial data from all sources', () => { + const { result } = renderHook(() => usePerpsHomeData()); + + expect(result.current.positions).toEqual(mockPositions); + expect(result.current.orders).toEqual(mockOrders); + expect(result.current.recentActivity).toEqual(mockFills); + expect(result.current.perpsMarkets).toEqual(mockMarkets); + expect(result.current.watchlistMarkets).toEqual([ + mockMarkets[0], + mockMarkets[1], + ]); + }); + + it('applies default limits to data', () => { + // Create more data than default limits + const manyPositions = Array.from({ length: 10 }, (_, i) => + createMockPosition({ coin: `COIN${i}` }), + ); + const manyOrders = Array.from({ length: 10 }, (_, i) => + createMockOrder({ symbol: `COIN${i}` }), + ); + const manyFills = Array.from({ length: 20 }, (_, i) => + createMockOrderFill({ symbol: `COIN${i}` }), + ); + + mockUsePerpsLivePositions.mockReturnValue({ + positions: manyPositions, + isInitialLoading: false, + }); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: manyOrders, + isInitialLoading: false, + }); + mockUsePerpsLiveFills.mockReturnValue({ + fills: manyFills, + isInitialLoading: false, + }); + + const { result } = renderHook(() => usePerpsHomeData()); + + // Default limits from HOME_SCREEN_CONFIG + expect(result.current.positions.length).toBeLessThanOrEqual(10); + expect(result.current.orders.length).toBeLessThanOrEqual(10); + expect(result.current.recentActivity.length).toBeLessThanOrEqual(3); + }); + + it('respects custom limits from parameters', () => { + const manyPositions = Array.from({ length: 10 }, (_, i) => + createMockPosition({ coin: `COIN${i}` }), + ); + + mockUsePerpsLivePositions.mockReturnValue({ + positions: manyPositions, + isInitialLoading: false, + }); + + const { result } = renderHook(() => + usePerpsHomeData({ positionsLimit: 3 }), + ); + + expect(result.current.positions).toHaveLength(3); + }); + + it('passes throttleMs to live data hooks', () => { + renderHook(() => usePerpsHomeData()); + + expect(mockUsePerpsLivePositions).toHaveBeenCalledWith( + expect.objectContaining({ throttleMs: 1000 }), + ); + expect(mockUsePerpsLiveOrders).toHaveBeenCalledWith( + expect.objectContaining({ throttleMs: 1000 }), + ); + expect(mockUsePerpsLiveFills).toHaveBeenCalledWith( + expect.objectContaining({ throttleMs: 1000 }), + ); + }); + + it('hides TP/SL orders from home screen', () => { + renderHook(() => usePerpsHomeData()); + + expect(mockUsePerpsLiveOrders).toHaveBeenCalledWith( + expect.objectContaining({ hideTpSl: true }), + ); + }); + + it('fetches markets with skipInitialFetch false', () => { + renderHook(() => usePerpsHomeData()); + + expect(mockUsePerpsMarkets).toHaveBeenCalledWith( + expect.objectContaining({ skipInitialFetch: false }), + ); + }); + }); + + describe('Loading states', () => { + it('returns loading state for positions', () => { + mockUsePerpsLivePositions.mockReturnValue({ + positions: [], + isInitialLoading: true, + }); + + const { result } = renderHook(() => usePerpsHomeData()); + + expect(result.current.isLoading.positions).toBe(true); + }); + + it('returns loading state for markets', () => { + mockUsePerpsMarkets.mockReturnValue({ + markets: [], + isLoading: true, + isRefreshing: false, + error: null, + refresh: mockRefreshMarkets, + }); + + const { result } = renderHook(() => usePerpsHomeData()); + + expect(result.current.isLoading.markets).toBe(true); + }); + + it('returns false when all data is loaded', () => { + const { result } = renderHook(() => usePerpsHomeData()); + + expect(result.current.isLoading.positions).toBe(false); + expect(result.current.isLoading.markets).toBe(false); + }); + }); + + describe('Watchlist filtering', () => { + it('filters markets by watchlist symbols', () => { + // Mock selector to return only BTC in watchlist + mockSelectPerpsWatchlistMarkets.mockReturnValue(['BTC']); + + const { result } = renderHook(() => usePerpsHomeData()); + + expect(result.current.watchlistMarkets).toHaveLength(1); + expect(result.current.watchlistMarkets[0].symbol).toBe('BTC'); + }); + + it('returns empty array when watchlist is empty', () => { + mockSelectPerpsWatchlistMarkets.mockReturnValue([]); + + const { result } = renderHook(() => usePerpsHomeData()); + + expect(result.current.watchlistMarkets).toEqual([]); + }); + + it('returns all watchlist markets regardless of watchlistMarketsLimit', () => { + const { result } = renderHook(() => usePerpsHomeData()); + + expect(result.current.watchlistMarkets).toHaveLength(2); + expect(result.current.watchlistMarkets.map((m) => m.symbol)).toEqual([ + 'BTC', + 'ETH', + ]); + }); + }); + + describe('Trending markets sorting', () => { + it('sorts markets using saved sort preference', () => { + renderHook(() => usePerpsHomeData()); + + expect(mockSortMarkets).toHaveBeenCalledWith( + expect.objectContaining({ + markets: mockMarkets, + sortBy: 'volume', + direction: 'desc', + }), + ); + }); + + it('applies trendingLimit to sorted markets', () => { + const manyMarkets = Array.from({ length: 10 }, (_, i) => + createMockMarket({ symbol: `COIN${i}` }), + ); + + mockUsePerpsMarkets.mockReturnValue({ + markets: manyMarkets, + isLoading: false, + isRefreshing: false, + error: null, + refresh: mockRefreshMarkets, + }); + + mockSortMarkets.mockImplementation(({ markets }) => markets); + + const { result } = renderHook(() => + usePerpsHomeData({ trendingLimit: 3 }), + ); + + expect(result.current.perpsMarkets).toHaveLength(3); + }); + + it('uses default direction when sort option not found', () => { + mockSelectPerpsMarketFilterPreferences.mockReturnValue( + 'unknown-sort-option' as never, + ); + + renderHook(() => usePerpsHomeData()); + + expect(mockSortMarkets).toHaveBeenCalledWith( + expect.objectContaining({ + direction: 'desc', + }), + ); + }); + + it('returns sort field from hook', () => { + const { result } = renderHook(() => usePerpsHomeData()); + + expect(result.current.sortBy).toBe('volume' as SortField); + }); + }); + + describe('Search filtering', () => { + it('filters positions by coin field', () => { + const { result } = renderHook(() => + usePerpsHomeData({ searchQuery: 'BTC' }), + ); + + expect(result.current.positions).toHaveLength(1); + expect(result.current.positions[0].coin).toBe('BTC'); + }); + + it('filters orders by symbol field', () => { + const { result } = renderHook(() => + usePerpsHomeData({ searchQuery: 'SOL' }), + ); + + expect(result.current.orders).toHaveLength(1); + expect(result.current.orders[0].symbol).toBe('SOL'); + }); + + it('filters fills by symbol field', () => { + const { result } = renderHook(() => + usePerpsHomeData({ searchQuery: 'ETH' }), + ); + + expect(result.current.recentActivity).toHaveLength(1); + expect(result.current.recentActivity[0].symbol).toBe('ETH'); + }); + + it('filters watchlist markets by symbol or name', () => { + const { result } = renderHook(() => + usePerpsHomeData({ searchQuery: 'Bit' }), + ); + + expect(result.current.watchlistMarkets).toHaveLength(1); + expect(result.current.watchlistMarkets[0].symbol).toBe('BTC'); + }); + + it('searches all markets when search query present', () => { + const allMarkets = [ + ...mockMarkets, + createMockMarket({ symbol: 'DOGE', name: 'Dogecoin' }), + ]; + + mockUsePerpsMarkets.mockReturnValue({ + markets: allMarkets, + isLoading: false, + isRefreshing: false, + error: null, + refresh: mockRefreshMarkets, + }); + + const { result } = renderHook(() => + usePerpsHomeData({ searchQuery: 'DOGE' }), + ); + + expect(result.current.perpsMarkets).toHaveLength(1); + expect(result.current.perpsMarkets[0].symbol).toBe('DOGE'); + }); + + it('shows top trending markets when search query is empty', () => { + const { result } = renderHook(() => + usePerpsHomeData({ searchQuery: '', trendingLimit: 2 }), + ); + + expect(result.current.perpsMarkets).toHaveLength(2); + }); + + it('performs case-insensitive search', () => { + const { result } = renderHook(() => + usePerpsHomeData({ searchQuery: 'btc' }), + ); + + expect(result.current.positions.length).toBeGreaterThan(0); + expect(result.current.positions[0].coin).toBe('BTC'); + }); + + it('trims whitespace from search query', () => { + const { result } = renderHook(() => + usePerpsHomeData({ searchQuery: ' BTC ' }), + ); + + expect(result.current.positions).toHaveLength(1); + expect(result.current.positions[0].coin).toBe('BTC'); + }); + + it('returns all data when search query is empty', () => { + const { result } = renderHook(() => + usePerpsHomeData({ searchQuery: '' }), + ); + + expect(result.current.positions).toEqual(mockPositions); + expect(result.current.orders).toEqual(mockOrders); + expect(result.current.recentActivity).toEqual(mockFills); + }); + + it('returns all data when search query is only whitespace', () => { + const { result } = renderHook(() => + usePerpsHomeData({ searchQuery: ' ' }), + ); + + expect(result.current.positions).toEqual(mockPositions); + expect(result.current.orders).toEqual(mockOrders); + }); + + it('handles undefined fields gracefully', () => { + const positionWithoutCoin = createMockPosition({ coin: undefined }); + mockUsePerpsLivePositions.mockReturnValue({ + positions: [positionWithoutCoin], + isInitialLoading: false, + }); + + const { result } = renderHook(() => + usePerpsHomeData({ searchQuery: 'BTC' }), + ); + + expect(result.current.positions).toEqual([]); + }); + }); + + describe('Refresh functionality', () => { + it('calls refresh on markets', async () => { + const { result } = renderHook(() => usePerpsHomeData()); + + await act(async () => { + await result.current.refresh(); + }); + + expect(mockRefreshMarkets).toHaveBeenCalledTimes(1); + }); + + it('returns refresh function', () => { + const { result } = renderHook(() => usePerpsHomeData()); + + expect(typeof result.current.refresh).toBe('function'); + }); + + it('handles refresh errors gracefully', async () => { + mockRefreshMarkets.mockRejectedValue(new Error('Refresh failed')); + + const { result } = renderHook(() => usePerpsHomeData()); + + await act(async () => { + await expect(result.current.refresh()).rejects.toThrow( + 'Refresh failed', + ); + }); + }); + + it('does not refresh WebSocket data', async () => { + const { result } = renderHook(() => usePerpsHomeData()); + + const positionsBeforeRefresh = result.current.positions; + + await act(async () => { + await result.current.refresh(); + }); + + expect(result.current.positions).toBe(positionsBeforeRefresh); + }); + }); + + describe('Edge cases', () => { + it('handles empty positions array', () => { + mockUsePerpsLivePositions.mockReturnValue({ + positions: [], + isInitialLoading: false, + }); + + const { result } = renderHook(() => usePerpsHomeData()); + + expect(result.current.positions).toEqual([]); + }); + + it('handles empty orders array', () => { + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [], + isInitialLoading: false, + }); + + const { result } = renderHook(() => usePerpsHomeData()); + + expect(result.current.orders).toEqual([]); + }); + + it('handles empty fills array', () => { + mockUsePerpsLiveFills.mockReturnValue({ + fills: [], + isInitialLoading: false, + }); + + const { result } = renderHook(() => usePerpsHomeData()); + + expect(result.current.recentActivity).toEqual([]); + }); + + it('handles empty markets array', () => { + mockUsePerpsMarkets.mockReturnValue({ + markets: [], + isLoading: false, + isRefreshing: false, + error: null, + refresh: mockRefreshMarkets, + }); + + const { result } = renderHook(() => usePerpsHomeData()); + + expect(result.current.perpsMarkets).toEqual([]); + expect(result.current.watchlistMarkets).toEqual([]); + }); + + it('handles all empty data sources', () => { + mockUsePerpsLivePositions.mockReturnValue({ + positions: [], + isInitialLoading: false, + }); + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [], + isInitialLoading: false, + }); + mockUsePerpsLiveFills.mockReturnValue({ + fills: [], + isInitialLoading: false, + }); + mockUsePerpsMarkets.mockReturnValue({ + markets: [], + isLoading: false, + isRefreshing: false, + error: null, + refresh: mockRefreshMarkets, + }); + + const { result } = renderHook(() => usePerpsHomeData()); + + expect(result.current.positions).toEqual([]); + expect(result.current.orders).toEqual([]); + expect(result.current.recentActivity).toEqual([]); + expect(result.current.perpsMarkets).toEqual([]); + expect(result.current.watchlistMarkets).toEqual([]); + }); + + it('handles zero limits', () => { + const { result } = renderHook(() => + usePerpsHomeData({ + positionsLimit: 0, + ordersLimit: 0, + trendingLimit: 0, + activityLimit: 0, + }), + ); + + expect(result.current.positions).toEqual([]); + expect(result.current.orders).toEqual([]); + expect(result.current.perpsMarkets).toEqual([]); + expect(result.current.recentActivity).toEqual([]); + }); + + it('handles very large limits', () => { + const { result } = renderHook(() => + usePerpsHomeData({ + positionsLimit: 1000, + ordersLimit: 1000, + trendingLimit: 1000, + activityLimit: 1000, + }), + ); + + expect(result.current.positions).toEqual(mockPositions); + expect(result.current.orders).toEqual(mockOrders); + expect(result.current.perpsMarkets).toEqual(mockMarkets); + expect(result.current.recentActivity).toEqual(mockFills); + }); + + it('handles special characters in search query', () => { + const { result } = renderHook(() => + usePerpsHomeData({ searchQuery: '$BTC*' }), + ); + + expect(result.current.positions).toEqual([]); + }); + }); + + describe('Data reactivity', () => { + it('updates when positions change', () => { + const { result, rerender } = renderHook(() => usePerpsHomeData()); + + const initialPositions = result.current.positions; + + const newPositions = [createMockPosition({ coin: 'SOL' })]; + mockUsePerpsLivePositions.mockReturnValue({ + positions: newPositions, + isInitialLoading: false, + }); + + rerender(); + + expect(result.current.positions).not.toBe(initialPositions); + expect(result.current.positions).toEqual(newPositions); + }); + + it('updates when orders change', () => { + const { result, rerender } = renderHook(() => usePerpsHomeData()); + + const newOrders = [createMockOrder({ symbol: 'DOGE' })]; + mockUsePerpsLiveOrders.mockReturnValue({ + orders: newOrders, + isInitialLoading: false, + }); + + rerender(); + + expect(result.current.orders).toEqual(newOrders); + }); + + it('updates when markets change', () => { + const { result, rerender } = renderHook(() => usePerpsHomeData()); + + const newMarkets = [createMockMarket({ symbol: 'AVAX' })]; + mockUsePerpsMarkets.mockReturnValue({ + markets: newMarkets, + isLoading: false, + isRefreshing: false, + error: null, + refresh: mockRefreshMarkets, + }); + + rerender(); + + expect(result.current.perpsMarkets).toEqual(newMarkets); + }); + + it('updates when watchlist changes', () => { + const { result, rerender } = renderHook(() => usePerpsHomeData()); + + expect(result.current.watchlistMarkets).toHaveLength(2); + + mockSelectPerpsWatchlistMarkets.mockReturnValue(['SOL']); + + rerender(); + + expect(result.current.watchlistMarkets).toHaveLength(1); + expect(result.current.watchlistMarkets[0].symbol).toBe('SOL'); + }); + + it('updates when search query changes', () => { + const { result, rerender } = renderHook( + ({ searchQuery }) => usePerpsHomeData({ searchQuery }), + { initialProps: { searchQuery: '' } }, + ); + + expect(result.current.positions).toHaveLength(2); + + rerender({ searchQuery: 'BTC' }); + + expect(result.current.positions).toHaveLength(1); + expect(result.current.positions[0].coin).toBe('BTC'); + }); + }); + + describe('Combined scenarios', () => { + it('applies search and limits together', () => { + const manyPositions = [ + createMockPosition({ coin: 'BTC' }), + createMockPosition({ coin: 'BTC' }), + createMockPosition({ coin: 'BTC' }), + createMockPosition({ coin: 'ETH' }), + ]; + + mockUsePerpsLivePositions.mockReturnValue({ + positions: manyPositions, + isInitialLoading: false, + }); + + const { result } = renderHook(() => + usePerpsHomeData({ searchQuery: 'BTC', positionsLimit: 2 }), + ); + + expect(result.current.positions).toHaveLength(2); + expect(result.current.positions.every((p) => p.coin === 'BTC')).toBe( + true, + ); + }); + + it('combines search with watchlist filtering', () => { + const { result } = renderHook(() => + usePerpsHomeData({ searchQuery: 'BTC' }), + ); + + const watchlistSymbols = result.current.watchlistMarkets.map( + (m) => m.symbol, + ); + + expect(watchlistSymbols).toContain('BTC'); + expect(watchlistSymbols).not.toContain('SOL'); + }); + + it('handles all parameters together', () => { + const { result } = renderHook(() => + usePerpsHomeData({ + positionsLimit: 1, + ordersLimit: 1, + trendingLimit: 1, + activityLimit: 1, + searchQuery: 'BTC', + }), + ); + + expect(result.current.positions.length).toBeLessThanOrEqual(1); + expect(result.current.orders.length).toBeLessThanOrEqual(1); + expect(result.current.perpsMarkets.length).toBeLessThanOrEqual(1); + expect(result.current.recentActivity.length).toBeLessThanOrEqual(1); + }); + }); +}); diff --git a/app/components/UI/Perps/hooks/usePerpsHomeData.ts b/app/components/UI/Perps/hooks/usePerpsHomeData.ts new file mode 100644 index 000000000000..5679872d6f92 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsHomeData.ts @@ -0,0 +1,291 @@ +import { useCallback, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { + usePerpsLivePositions, + usePerpsLiveOrders, + usePerpsLiveFills, +} from './stream'; +import { usePerpsMarkets } from './usePerpsMarkets'; +import type { + Position, + Order, + OrderFill, + PerpsMarketData, +} from '../controllers/types'; +import { + HOME_SCREEN_CONFIG, + MARKET_SORTING_CONFIG, +} from '../constants/perpsConfig'; +import { sortMarkets, type SortField } from '../utils/sortMarkets'; +import { + selectPerpsWatchlistMarkets, + selectPerpsMarketFilterPreferences, +} from '../selectors/perpsController'; + +interface UsePerpsHomeDataParams { + positionsLimit?: number; + ordersLimit?: number; + trendingLimit?: number; + activityLimit?: number; + searchQuery?: string; +} + +interface UsePerpsHomeDataReturn { + positions: Position[]; + orders: Order[]; + watchlistMarkets: PerpsMarketData[]; + perpsMarkets: PerpsMarketData[]; // Crypto markets (renamed from trending) + stocksMarkets: PerpsMarketData[]; // Equity markets + commoditiesMarkets: PerpsMarketData[]; // Commodity markets + forexMarkets: PerpsMarketData[]; // Forex markets + recentActivity: OrderFill[]; + sortBy: SortField; + isLoading: { + positions: boolean; + orders: boolean; + markets: boolean; + activity: boolean; + }; + refresh: () => Promise; +} + +/** + * Combined hook for Perps home screen data using WebSocket live hooks + * Real-time updates for positions, orders, and fills via WebSocket + * Uses object parameters pattern for maintainability + */ +export const usePerpsHomeData = ({ + positionsLimit = HOME_SCREEN_CONFIG.POSITIONS_CAROUSEL_LIMIT, + ordersLimit = HOME_SCREEN_CONFIG.ORDERS_CAROUSEL_LIMIT, + trendingLimit = HOME_SCREEN_CONFIG.TRENDING_MARKETS_LIMIT, + activityLimit = HOME_SCREEN_CONFIG.RECENT_ACTIVITY_LIMIT, + searchQuery = '', +}: UsePerpsHomeDataParams = {}): UsePerpsHomeDataReturn => { + // Fetch positions via WebSocket with throttling for performance + const { positions, isInitialLoading: isPositionsLoading } = + usePerpsLivePositions({ + throttleMs: 1000, // Throttle updates to once per second + }); + + // Fetch orders via WebSocket (excluding TP/SL orders) + const { orders: allOrders, isInitialLoading: isOrdersLoading } = + usePerpsLiveOrders({ + throttleMs: 1000, + hideTpSl: true, // Hide Take Profit and Stop Loss orders from home screen + }); + + // Fetch recent activity (order fills) via WebSocket + const { fills: allFills, isInitialLoading: isActivityLoading } = + usePerpsLiveFills({ + throttleMs: 1000, + }); + + // Fetch markets data for trending section (markets don't need real-time updates) + const { + markets: allMarkets, + isLoading: isMarketsLoading, + refresh: refreshMarkets, + } = usePerpsMarkets({ + skipInitialFetch: false, + }); + + // Get watchlist symbols from Redux + const watchlistSymbols = useSelector(selectPerpsWatchlistMarkets); + + // Get saved market filter preferences + const savedSortPreference = useSelector(selectPerpsMarketFilterPreferences); + + // Filter markets that are in watchlist + const watchlistMarkets = useMemo( + () => + allMarkets.filter((market) => watchlistSymbols.includes(market.symbol)), + [allMarkets, watchlistSymbols], + ); + + // Derive sort field and direction from saved preference + const { sortBy, direction } = useMemo(() => { + const sortOption = MARKET_SORTING_CONFIG.SORT_OPTIONS.find( + (opt) => opt.id === savedSortPreference, + ); + + return { + sortBy: sortOption?.field ?? MARKET_SORTING_CONFIG.SORT_FIELDS.VOLUME, + direction: + sortOption?.direction ?? MARKET_SORTING_CONFIG.DEFAULT_DIRECTION, + }; + }, [savedSortPreference]); + + // Filter and sort markets by type + // Perps (crypto) - exclude all non-crypto markets + const perpsMarkets = useMemo( + () => + sortMarkets({ + markets: allMarkets.filter((m) => !m.marketType), // Crypto markets have no marketType + sortBy, + direction, + }).slice(0, trendingLimit), + [allMarkets, sortBy, direction, trendingLimit], + ); + + // Stocks (equity) - top N by user preference + const stocksMarkets = useMemo( + () => + sortMarkets({ + markets: allMarkets.filter((m) => m.marketType === 'equity'), + sortBy, + direction, + }).slice(0, trendingLimit), + [allMarkets, sortBy, direction, trendingLimit], + ); + + // Commodities - top N by user preference + const commoditiesMarkets = useMemo( + () => + sortMarkets({ + markets: allMarkets.filter((m) => m.marketType === 'commodity'), + sortBy, + direction, + }).slice(0, trendingLimit), + [allMarkets, sortBy, direction, trendingLimit], + ); + + // Forex - top N by user preference + const forexMarkets = useMemo( + () => + sortMarkets({ + markets: allMarkets.filter((m) => m.marketType === 'forex'), + sortBy, + direction, + }).slice(0, trendingLimit), + [allMarkets, sortBy, direction, trendingLimit], + ); + + // Refresh markets data (WebSocket data auto-updates, only markets need manual refresh) + const refresh = useCallback(async () => { + await refreshMarkets(); + }, [refreshMarkets]); + + // Filter data by search query with type-safe field access + const filterBySearchQuery = useCallback( + (query: string) => { + if (!query.trim()) { + return { + positions, + orders: allOrders, + watchlistMarkets, // Show all watchlisted markets + markets: perpsMarkets, // Show top 5 perps (crypto) when no search + fills: allFills, + }; + } + + const lowerQuery = query.toLowerCase().trim(); + + return { + // Position only has 'coin' field (no 'symbol') + positions: positions.filter((pos: Position) => + pos.coin?.toLowerCase().includes(lowerQuery), + ), + // Order only has 'symbol' field (no 'coin') + orders: allOrders.filter((order: Order) => + order.symbol?.toLowerCase().includes(lowerQuery), + ), + // Filter watchlist markets by search query + watchlistMarkets: watchlistMarkets.filter( + (market: PerpsMarketData) => + market.symbol?.toLowerCase().includes(lowerQuery) || + market.name?.toLowerCase().includes(lowerQuery), + ), + // Market has both 'symbol' and 'name' + // Search through ALL markets, not just top 5 trending + markets: allMarkets.filter( + (market: PerpsMarketData) => + market.symbol?.toLowerCase().includes(lowerQuery) || + market.name?.toLowerCase().includes(lowerQuery), + ), + // OrderFill only has 'symbol' field (no 'coin' or 'asset') + fills: allFills.filter((fill: OrderFill) => + fill.symbol?.toLowerCase().includes(lowerQuery), + ), + }; + }, + [ + positions, + allOrders, + watchlistMarkets, + perpsMarkets, + allMarkets, + allFills, + ], + ); + + // Apply filtering and limits + const filteredData = useMemo( + () => filterBySearchQuery(searchQuery), + [filterBySearchQuery, searchQuery], + ); + + const limitedPositions = useMemo( + () => filteredData.positions.slice(0, positionsLimit), + [filteredData.positions, positionsLimit], + ); + const limitedOrders = useMemo( + () => filteredData.orders.slice(0, ordersLimit), + [filteredData.orders, ordersLimit], + ); + const limitedWatchlistMarkets = useMemo( + () => filteredData.watchlistMarkets, + [filteredData.watchlistMarkets], + ); + const limitedActivity = useMemo( + () => filteredData.fills.slice(0, activityLimit), + [filteredData.fills, activityLimit], + ); + + // When searching, split filtered markets by type + const searchedPerpsMarkets = useMemo(() => { + if (!searchQuery.trim()) { + return perpsMarkets; + } + return filteredData.markets.filter((m) => !m.marketType); + }, [searchQuery, perpsMarkets, filteredData.markets]); + + const searchedStocksMarkets = useMemo(() => { + if (!searchQuery.trim()) { + return stocksMarkets; + } + return filteredData.markets.filter((m) => m.marketType === 'equity'); + }, [searchQuery, stocksMarkets, filteredData.markets]); + + const searchedCommoditiesMarkets = useMemo(() => { + if (!searchQuery.trim()) { + return commoditiesMarkets; + } + return filteredData.markets.filter((m) => m.marketType === 'commodity'); + }, [searchQuery, commoditiesMarkets, filteredData.markets]); + + const searchedForexMarkets = useMemo(() => { + if (!searchQuery.trim()) { + return forexMarkets; + } + return filteredData.markets.filter((m) => m.marketType === 'forex'); + }, [searchQuery, forexMarkets, filteredData.markets]); + + return { + positions: limitedPositions, + orders: limitedOrders, + watchlistMarkets: limitedWatchlistMarkets, + perpsMarkets: searchedPerpsMarkets, // Crypto markets (renamed from trendingMarkets) + stocksMarkets: searchedStocksMarkets, + commoditiesMarkets: searchedCommoditiesMarkets, + forexMarkets: searchedForexMarkets, + recentActivity: limitedActivity, + sortBy, + isLoading: { + positions: isPositionsLoading, + orders: isOrdersLoading, + markets: isMarketsLoading, + activity: isActivityLoading, + }, + refresh, + }; +}; diff --git a/app/components/UI/Perps/hooks/usePerpsMarketListView.test.ts b/app/components/UI/Perps/hooks/usePerpsMarketListView.test.ts new file mode 100644 index 000000000000..5e6aca12fa65 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsMarketListView.test.ts @@ -0,0 +1,533 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; +import { usePerpsMarketListView } from './usePerpsMarketListView'; +import { usePerpsMarkets } from './usePerpsMarkets'; +import { usePerpsSearch } from './usePerpsSearch'; +import { usePerpsSorting } from './usePerpsSorting'; +import { PERPS_CONSTANTS } from '../constants/perpsConfig'; +import type { PerpsMarketData } from '../controllers/types'; +import type { SortField, SortDirection } from '../utils/sortMarkets'; +import Engine from '../../../../core/Engine'; + +// Mock dependencies +jest.mock('./usePerpsMarkets'); +jest.mock('./usePerpsSearch'); +jest.mock('./usePerpsSorting'); +jest.mock('react-redux'); +jest.mock('../../../../core/Engine', () => ({ + context: { + PerpsController: { + saveMarketFilterPreferences: jest.fn(), + }, + }, +})); + +const mockUsePerpsMarkets = usePerpsMarkets as jest.MockedFunction< + typeof usePerpsMarkets +>; +const mockUsePerpsSearch = usePerpsSearch as jest.MockedFunction< + typeof usePerpsSearch +>; +const mockUsePerpsSorting = usePerpsSorting as jest.MockedFunction< + typeof usePerpsSorting +>; +const mockUseSelector = useSelector as jest.MockedFunction; + +// Test data +const createMockMarket = (symbol: string, volume: string): PerpsMarketData => ({ + symbol, + name: `${symbol} Token`, + maxLeverage: '20x', + price: '$1,000.00', + change24h: '+2.5%', + change24hPercent: '2.5', + volume, +}); + +const mockMarketsWithValidVolume: PerpsMarketData[] = [ + createMockMarket('BTC', '$1.2B'), + createMockMarket('ETH', '$900M'), + createMockMarket('SOL', '$500M'), +]; + +const mockMarketsWithInvalidVolume: PerpsMarketData[] = [ + createMockMarket('ZERO1', PERPS_CONSTANTS.ZERO_AMOUNT_DISPLAY), + createMockMarket('ZERO2', PERPS_CONSTANTS.ZERO_AMOUNT_DETAILED_DISPLAY), + createMockMarket('FALLBACK1', PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY), + createMockMarket('FALLBACK2', PERPS_CONSTANTS.FALLBACK_DATA_DISPLAY), +]; + +const mockAllMarkets = [ + ...mockMarketsWithValidVolume, + ...mockMarketsWithInvalidVolume, +]; + +describe('usePerpsMarketListView', () => { + beforeEach(() => { + jest.clearAllMocks(); + + // Default mock implementations + // Cast to unknown to avoid volumeNumber type issues in tests + mockUsePerpsMarkets.mockReturnValue({ + markets: mockAllMarkets as unknown as ReturnType< + typeof usePerpsMarkets + >['markets'], + isLoading: false, + isRefreshing: false, + error: null, + refresh: jest.fn(), + }); + + mockUsePerpsSearch.mockReturnValue({ + searchQuery: '', + setSearchQuery: jest.fn(), + isSearchVisible: false, + setIsSearchVisible: jest.fn(), + toggleSearchVisibility: jest.fn(), + filteredMarkets: mockMarketsWithValidVolume, // Already filtered by volume + clearSearch: jest.fn(), + }); + + mockUsePerpsSorting.mockReturnValue({ + selectedOptionId: 'volume', + sortBy: 'volume' as SortField, + direction: 'desc' as SortDirection, + handleOptionChange: jest.fn(), + sortMarketsList: jest.fn((markets) => markets), // Pass through by default + }); + + // Mock Redux selectors - need to mock based on call order + // First call is watchlistMarkets, second is sortPreference + let selectorCallCount = 0; + mockUseSelector.mockImplementation(() => { + selectorCallCount++; + if (selectorCallCount % 2 === 1) { + // Odd calls are watchlist (first, third, fifth, etc.) + return ['BTC', 'ETH']; + } + // Even calls are sort preference (second, fourth, sixth, etc.) + return 'volume'; + }); + }); + + describe('Initial State', () => { + it('returns correct initial state with default parameters', () => { + const { result } = renderHook(() => usePerpsMarketListView()); + + expect(result.current.markets).toEqual(mockMarketsWithValidVolume); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.searchState).toBeDefined(); + expect(result.current.sortState).toBeDefined(); + expect(result.current.favoritesState).toBeDefined(); + }); + + it('respects defaultSearchVisible parameter', () => { + mockUsePerpsSearch.mockReturnValue({ + searchQuery: '', + setSearchQuery: jest.fn(), + isSearchVisible: true, + setIsSearchVisible: jest.fn(), + toggleSearchVisibility: jest.fn(), + filteredMarkets: mockMarketsWithValidVolume, + clearSearch: jest.fn(), + }); + + const { result } = renderHook(() => + usePerpsMarketListView({ defaultSearchVisible: true }), + ); + + expect(result.current.searchState.isSearchVisible).toBe(true); + expect(mockUsePerpsSearch).toHaveBeenCalledWith({ + markets: expect.any(Array), + initialSearchVisible: true, + }); + }); + + it('respects showWatchlistOnly parameter', () => { + const { result } = renderHook(() => + usePerpsMarketListView({ showWatchlistOnly: true }), + ); + + expect(result.current.favoritesState.showFavoritesOnly).toBe(true); + }); + + it('passes enablePolling to usePerpsMarkets', () => { + renderHook(() => usePerpsMarketListView({ enablePolling: true })); + + expect(mockUsePerpsMarkets).toHaveBeenCalledWith({ + enablePolling: true, + }); + }); + }); + + describe('Volume Filtering', () => { + it('filters out markets with zero volume displays', () => { + renderHook(() => usePerpsMarketListView()); + + // Should only pass markets with valid volume to usePerpsSearch + expect(mockUsePerpsSearch).toHaveBeenCalledWith( + expect.objectContaining({ + markets: expect.not.arrayContaining([ + expect.objectContaining({ + volume: PERPS_CONSTANTS.ZERO_AMOUNT_DISPLAY, + }), + ]), + }), + ); + }); + + it('filters out markets with fallback displays', () => { + renderHook(() => usePerpsMarketListView()); + + // Should only pass markets with valid volume to usePerpsSearch + expect(mockUsePerpsSearch).toHaveBeenCalledWith( + expect.objectContaining({ + markets: expect.not.arrayContaining([ + expect.objectContaining({ + volume: PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY, + }), + ]), + }), + ); + }); + + it('keeps markets with valid volume', () => { + renderHook(() => usePerpsMarketListView()); + + // Check that valid markets are passed through + const marketsPassedToSearch = (mockUsePerpsSearch as jest.Mock).mock + .calls[0][0].markets; + expect(marketsPassedToSearch).toHaveLength( + mockMarketsWithValidVolume.length, + ); + expect(marketsPassedToSearch).toEqual( + expect.arrayContaining(mockMarketsWithValidVolume), + ); + }); + + it('handles empty markets array', () => { + mockUsePerpsMarkets.mockReturnValue({ + markets: [] as unknown as ReturnType['markets'], + isLoading: false, + isRefreshing: false, + error: null, + refresh: jest.fn(), + }); + + mockUsePerpsSearch.mockReturnValue({ + searchQuery: '', + setSearchQuery: jest.fn(), + isSearchVisible: false, + setIsSearchVisible: jest.fn(), + toggleSearchVisibility: jest.fn(), + filteredMarkets: [], + clearSearch: jest.fn(), + }); + + const { result } = renderHook(() => usePerpsMarketListView()); + + expect(result.current.markets).toEqual([]); + }); + }); + + describe('Search Integration', () => { + it('exposes search state correctly', () => { + const mockSearchState = { + searchQuery: 'BTC', + setSearchQuery: jest.fn(), + isSearchVisible: true, + setIsSearchVisible: jest.fn(), + toggleSearchVisibility: jest.fn(), + filteredMarkets: [mockMarketsWithValidVolume[0]], + clearSearch: jest.fn(), + }; + + mockUsePerpsSearch.mockReturnValue(mockSearchState); + + const { result } = renderHook(() => usePerpsMarketListView()); + + expect(result.current.searchState.searchQuery).toBe('BTC'); + expect(result.current.searchState.isSearchVisible).toBe(true); + expect(result.current.searchState.setSearchQuery).toBe( + mockSearchState.setSearchQuery, + ); + expect(result.current.searchState.toggleSearchVisibility).toBe( + mockSearchState.toggleSearchVisibility, + ); + expect(result.current.searchState.clearSearch).toBe( + mockSearchState.clearSearch, + ); + }); + }); + + describe('Sort Integration', () => { + it('uses saved sort preference from Redux', () => { + let selectorCallCount = 0; + mockUseSelector.mockImplementation(() => { + selectorCallCount++; + if (selectorCallCount % 2 === 1) { + // Odd calls are watchlist + return ['BTC']; + } + // Even calls are sort preference + return 'priceChange-desc'; + }); + + renderHook(() => usePerpsMarketListView()); + + expect(mockUsePerpsSorting).toHaveBeenCalledWith({ + initialOptionId: 'priceChange-desc', + }); + }); + + it('exposes sort state correctly', () => { + const { result } = renderHook(() => usePerpsMarketListView()); + + expect(result.current.sortState.selectedOptionId).toBe('volume'); + expect(result.current.sortState.sortBy).toBe('volume'); + expect(result.current.sortState.direction).toBe('desc'); + expect(typeof result.current.sortState.handleOptionChange).toBe( + 'function', + ); + }); + + it('saves sort preference to PerpsController when changed', () => { + const { result } = renderHook(() => usePerpsMarketListView()); + + // Call handleOptionChange + result.current.sortState.handleOptionChange( + 'priceChange-desc', + 'priceChange' as SortField, + 'desc' as SortDirection, + ); + + // Should have called Engine's saveMarketFilterPreferences + expect( + Engine.context.PerpsController.saveMarketFilterPreferences, + ).toHaveBeenCalledWith('priceChange-desc'); + }); + + it('applies sorting to filtered markets', () => { + const mockSortedMarkets = [ + mockMarketsWithValidVolume[2], + mockMarketsWithValidVolume[1], + mockMarketsWithValidVolume[0], + ]; + + const mockSortMarketsList = jest.fn(() => mockSortedMarkets); + + mockUsePerpsSorting.mockReturnValue({ + selectedOptionId: 'volume', + sortBy: 'volume' as SortField, + direction: 'asc' as SortDirection, + handleOptionChange: jest.fn(), + sortMarketsList: mockSortMarketsList, + }); + + const { result } = renderHook(() => usePerpsMarketListView()); + + expect(mockSortMarketsList).toHaveBeenCalled(); + expect(result.current.markets).toEqual(mockSortedMarkets); + }); + }); + + describe('Favorites Filtering', () => { + it('filters by watchlist when showFavoritesOnly is true', () => { + let selectorCallCount = 0; + mockUseSelector.mockImplementation(() => { + selectorCallCount++; + if (selectorCallCount % 2 === 1) { + return ['BTC', 'ETH']; + } + return 'volume'; + }); + + const { result } = renderHook(() => + usePerpsMarketListView({ showWatchlistOnly: true }), + ); + + // Only BTC and ETH should be in the result + const symbols = result.current.markets.map((m) => m.symbol); + expect(symbols).toEqual(expect.arrayContaining(['BTC', 'ETH'])); + expect(symbols).not.toContain('SOL'); + }); + + it('shows all markets when showFavoritesOnly is false', () => { + const { result } = renderHook(() => + usePerpsMarketListView({ showWatchlistOnly: false }), + ); + + expect(result.current.markets).toEqual(mockMarketsWithValidVolume); + }); + + it('exposes favorites state correctly', () => { + const { result } = renderHook(() => + usePerpsMarketListView({ showWatchlistOnly: true }), + ); + + expect(result.current.favoritesState.showFavoritesOnly).toBe(true); + expect(typeof result.current.favoritesState.setShowFavoritesOnly).toBe( + 'function', + ); + }); + + it('updates when favorites filter changes', () => { + const { result, rerender } = renderHook( + ({ showWatchlistOnly }) => + usePerpsMarketListView({ showWatchlistOnly }), + { + initialProps: { showWatchlistOnly: false }, + }, + ); + + // Initially all markets + expect(result.current.markets).toEqual(mockMarketsWithValidVolume); + + // Change to show favorites only + rerender({ showWatchlistOnly: true }); + + // Now only watchlisted markets + const symbols = result.current.markets.map((m) => m.symbol); + expect(symbols).toEqual(expect.arrayContaining(['BTC', 'ETH'])); + }); + }); + + describe('Combined Filtering', () => { + it('applies filters in correct order: volume → search → favorites → sort', () => { + let selectorCallCount = 0; + mockUseSelector.mockImplementation(() => { + selectorCallCount++; + if (selectorCallCount % 2 === 1) { + return ['BTC', 'ETH']; + } + return 'volume'; + }); + + // Mock search to return only BTC + mockUsePerpsSearch.mockReturnValue({ + searchQuery: 'BTC', + setSearchQuery: jest.fn(), + isSearchVisible: true, + setIsSearchVisible: jest.fn(), + toggleSearchVisibility: jest.fn(), + filteredMarkets: [mockMarketsWithValidVolume[0]], // Only BTC + clearSearch: jest.fn(), + }); + + const { result } = renderHook(() => + usePerpsMarketListView({ showWatchlistOnly: true }), + ); + + // Should only have BTC (searched AND in watchlist) + expect(result.current.markets).toHaveLength(1); + expect(result.current.markets[0].symbol).toBe('BTC'); + }); + + it('handles all filters active simultaneously', () => { + let selectorCallCount = 0; + mockUseSelector.mockImplementation(() => { + selectorCallCount++; + if (selectorCallCount % 2 === 1) { + return ['ETH']; + } + return 'volume'; + }); + + mockUsePerpsSearch.mockReturnValue({ + searchQuery: 'ETH', + setSearchQuery: jest.fn(), + isSearchVisible: true, + setIsSearchVisible: jest.fn(), + toggleSearchVisibility: jest.fn(), + filteredMarkets: [mockMarketsWithValidVolume[1]], // Only ETH + clearSearch: jest.fn(), + }); + + const mockSortMarketsList = jest.fn((markets) => + markets.slice().reverse(), + ); + + mockUsePerpsSorting.mockReturnValue({ + selectedOptionId: 'priceChange-asc', + sortBy: 'priceChange' as SortField, + direction: 'asc' as SortDirection, + handleOptionChange: jest.fn(), + sortMarketsList: mockSortMarketsList, + }); + + const { result } = renderHook(() => + usePerpsMarketListView({ + showWatchlistOnly: true, + defaultSearchVisible: true, + }), + ); + + // All filters applied + expect(result.current.markets).toHaveLength(1); + expect(result.current.markets[0].symbol).toBe('ETH'); + expect(mockSortMarketsList).toHaveBeenCalled(); + }); + }); + + describe('Loading & Error States', () => { + it('exposes loading state from usePerpsMarkets', () => { + mockUsePerpsMarkets.mockReturnValue({ + markets: [] as unknown as ReturnType['markets'], + isLoading: true, + isRefreshing: false, + error: null, + refresh: jest.fn(), + }); + + const { result } = renderHook(() => usePerpsMarketListView()); + + expect(result.current.isLoading).toBe(true); + }); + + it('exposes error state from usePerpsMarkets', () => { + const mockError = 'Failed to fetch markets'; + mockUsePerpsMarkets.mockReturnValue({ + markets: [] as unknown as ReturnType['markets'], + isLoading: false, + isRefreshing: false, + error: mockError, + refresh: jest.fn(), + }); + + const { result } = renderHook(() => usePerpsMarketListView()); + + expect(result.current.error).toBe(mockError); + }); + + it('handles loading state changes', () => { + mockUsePerpsMarkets.mockReturnValue({ + markets: [] as unknown as ReturnType['markets'], + isLoading: true, + isRefreshing: false, + error: null, + refresh: jest.fn(), + }); + + const { result, rerender } = renderHook(() => usePerpsMarketListView()); + + expect(result.current.isLoading).toBe(true); + + // Update to loaded state + mockUsePerpsMarkets.mockReturnValue({ + markets: mockAllMarkets as unknown as ReturnType< + typeof usePerpsMarkets + >['markets'], + isLoading: false, + isRefreshing: false, + error: null, + refresh: jest.fn(), + }); + + rerender(); + + expect(result.current.isLoading).toBe(false); + expect(result.current.markets.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/app/components/UI/Perps/hooks/usePerpsMarketListView.ts b/app/components/UI/Perps/hooks/usePerpsMarketListView.ts new file mode 100644 index 000000000000..2295dd2d0c64 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsMarketListView.ts @@ -0,0 +1,299 @@ +import { useState, useCallback, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { usePerpsMarkets } from './usePerpsMarkets'; +import { usePerpsSearch } from './usePerpsSearch'; +import { usePerpsSorting } from './usePerpsSorting'; +import type { PerpsMarketData, MarketTypeFilter } from '../controllers/types'; +import type { SortField, SortDirection } from '../utils/sortMarkets'; +import { PERPS_CONSTANTS, type SortOptionId } from '../constants/perpsConfig'; +import { + selectPerpsWatchlistMarkets, + selectPerpsMarketFilterPreferences, +} from '../selectors/perpsController'; +import Engine from '../../../../core/Engine'; + +interface UsePerpsMarketListViewParams { + /** + * Initial search visibility + * @default false + */ + defaultSearchVisible?: boolean; + /** + * Enable polling for markets data + * @default false + */ + enablePolling?: boolean; + /** + * Show only watchlist markets initially + * @default false + */ + showWatchlistOnly?: boolean; + /** + * Initial market type filter + * @default 'all' + */ + defaultMarketTypeFilter?: MarketTypeFilter; + /** + * Show markets with $0.00 volume + * @default false + */ + showZeroVolume?: boolean; +} + +interface UsePerpsMarketListViewReturn { + /** + * Final filtered and sorted markets ready for display + */ + markets: PerpsMarketData[]; + /** + * Search state and controls + */ + searchState: { + searchQuery: string; + setSearchQuery: (query: string) => void; + isSearchVisible: boolean; + setIsSearchVisible: (visible: boolean) => void; + toggleSearchVisibility: () => void; + clearSearch: () => void; + }; + /** + * Sort state and controls + */ + sortState: { + selectedOptionId: SortOptionId; + sortBy: SortField; + direction: SortDirection; + handleOptionChange: ( + optionId: SortOptionId, + field: SortField, + direction: SortDirection, + ) => void; + }; + /** + * Favorites filter state + */ + favoritesState: { + showFavoritesOnly: boolean; + setShowFavoritesOnly: (show: boolean) => void; + }; + /** + * Market type filter state (not persisted, UI-only) + */ + marketTypeFilterState: { + marketTypeFilter: MarketTypeFilter; + setMarketTypeFilter: (filter: MarketTypeFilter) => void; + }; + /** + * Market counts by type (for hiding empty tabs) + */ + marketCounts: { + crypto: number; + equity: number; + commodity: number; + forex: number; + }; + /** + * Loading state + */ + isLoading: boolean; + /** + * Error state + */ + error: string | null; +} + +/** + * Hook for managing Perps Market List View business logic + * + * Responsibilities: + * - Fetches and filters markets data + * - Manages search state and filtering + * - Manages sorting state and filtering + * - Filters markets by volume validity + * - Filters markets by watchlist (favorites) + * - Saves sort preferences to PerpsController + * - Exposes combined filtered markets ready for display + * + * This hook follows the pattern established by usePerpsHomeData, + * extracting all business logic from the view component. + * + * @example + * ```tsx + * const { + * markets, + * searchState, + * sortState, + * favoritesState, + * isLoading, + * error, + * } = usePerpsMarketListView({ + * defaultSearchVisible: false, + * enablePolling: false, + * }); + * ``` + */ +export const usePerpsMarketListView = ({ + defaultSearchVisible = false, + enablePolling = false, + showWatchlistOnly = false, + defaultMarketTypeFilter = 'all', + showZeroVolume = false, +}: UsePerpsMarketListViewParams = {}): UsePerpsMarketListViewReturn => { + // Fetch markets data + const { + markets: allMarkets, + isLoading: isLoadingMarkets, + error, + } = usePerpsMarkets({ + enablePolling, + }); + + // Get Redux state + const watchlistMarkets = useSelector(selectPerpsWatchlistMarkets); + const savedSortPreference = useSelector(selectPerpsMarketFilterPreferences); + + // Favorites filter state + const [showFavoritesOnly, setShowFavoritesOnly] = useState(showWatchlistOnly); + + // Market type filter state (can be changed in UI, not persisted) + const [marketTypeFilter, setMarketTypeFilter] = useState( + defaultMarketTypeFilter, + ); + + // Filter out markets with no valid volume + const marketsWithVolume = useMemo( + () => + allMarkets.filter((market: PerpsMarketData) => { + // Always filter out fallback/error values + if ( + market.volume === PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY || + market.volume === PERPS_CONSTANTS.FALLBACK_DATA_DISPLAY + ) { + return false; + } + + // If showZeroVolume is true, allow $0.00 and $0 values + if (showZeroVolume) { + // Only filter if volume is completely missing + return !!market.volume; + } + + // Default behavior: filter out zero and missing values + if ( + !market.volume || + market.volume === PERPS_CONSTANTS.ZERO_AMOUNT_DISPLAY || + market.volume === PERPS_CONSTANTS.ZERO_AMOUNT_DETAILED_DISPLAY + ) { + return false; + } + + return true; + }), + [allMarkets, showZeroVolume], + ); + + // Use search hook for search state and filtering + // Pass ALL markets to search so it can search across all market types + const searchHook = usePerpsSearch({ + markets: marketsWithVolume, + initialSearchVisible: defaultSearchVisible, + }); + + const { filteredMarkets: searchedMarkets, searchQuery } = searchHook; + + // Apply market type filter AFTER search + // When searching: show all search results across all market types + // When not searching: filter by current tab + const marketTypeFilteredMarkets = useMemo(() => { + // If searching, return search results from all markets (ignore tab filter) + if (searchQuery.trim()) { + return searchedMarkets; + } + + // If not searching, filter by current tab + if (marketTypeFilter === 'all') { + return searchedMarkets; + } + if (marketTypeFilter === 'crypto') { + // Crypto markets have no marketType set + return searchedMarkets.filter((m) => !m.marketType); + } + // Filter by specific market type (equity, commodity, forex) + return searchedMarkets.filter((m) => m.marketType === marketTypeFilter); + }, [searchedMarkets, searchQuery, marketTypeFilter]); + + // Use sorting hook for sort state and sorting logic + const sortingHook = usePerpsSorting({ + initialOptionId: savedSortPreference, + }); + + // Wrap handleOptionChange to save preference to PerpsController + const handleOptionChange = useCallback( + (optionId: SortOptionId, field: SortField, direction: SortDirection) => { + // Save preference to controller + Engine.context.PerpsController.saveMarketFilterPreferences(optionId); + // Update local state + sortingHook.handleOptionChange(optionId, field, direction); + }, + [sortingHook], + ); + + // Apply favorites filter if enabled + const favoritesFilteredMarkets = useMemo(() => { + if (!showFavoritesOnly) { + return marketTypeFilteredMarkets; + } + return marketTypeFilteredMarkets.filter((market) => + watchlistMarkets.includes(market.symbol), + ); + }, [marketTypeFilteredMarkets, showFavoritesOnly, watchlistMarkets]); + + // Apply sorting to searched and favorites-filtered markets + const finalMarkets = sortingHook.sortMarketsList(favoritesFilteredMarkets); + + // Calculate market counts by type (for hiding empty tabs) + const marketCounts = useMemo(() => { + const counts = { crypto: 0, equity: 0, commodity: 0, forex: 0 }; + marketsWithVolume.forEach((market) => { + if (!market.marketType) { + counts.crypto++; + } else if (market.marketType === 'equity') { + counts.equity++; + } else if (market.marketType === 'commodity') { + counts.commodity++; + } else if (market.marketType === 'forex') { + counts.forex++; + } + }); + return counts; + }, [marketsWithVolume]); + + return { + markets: finalMarkets, + searchState: { + searchQuery: searchHook.searchQuery, + setSearchQuery: searchHook.setSearchQuery, + isSearchVisible: searchHook.isSearchVisible, + setIsSearchVisible: searchHook.setIsSearchVisible, + toggleSearchVisibility: searchHook.toggleSearchVisibility, + clearSearch: searchHook.clearSearch, + }, + sortState: { + selectedOptionId: sortingHook.selectedOptionId, + sortBy: sortingHook.sortBy, + direction: sortingHook.direction, + handleOptionChange, + }, + favoritesState: { + showFavoritesOnly, + setShowFavoritesOnly, + }, + marketTypeFilterState: { + marketTypeFilter, + setMarketTypeFilter, + }, + marketCounts, + isLoading: isLoadingMarkets, + error, + }; +}; diff --git a/app/components/UI/Perps/hooks/usePerpsMarkets.ts b/app/components/UI/Perps/hooks/usePerpsMarkets.ts index e88687f4434c..d961148626fc 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarkets.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarkets.ts @@ -76,7 +76,10 @@ export const parseVolume = (volumeStr: string | undefined): number => { // Handle special cases if (volumeStr === PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY) return -1; - if (volumeStr === '$<1') return 0.5; // Treat as very small but not zero + // Special case: '$<1' represents volumes less than $1 (e.g., $0.50, $0.75) + // This is a display format from the provider, not a validation constant + // We treat it as 0.5 for sorting purposes (small but not zero) + if (volumeStr === '$<1') return 0.5; // Handle suffixed values (e.g., "$1.5M", "$2.3B", "$500K") const suffixMatch = VOLUME_SUFFIX_REGEX.exec(volumeStr); diff --git a/app/components/UI/Perps/hooks/usePerpsNavigation.test.ts b/app/components/UI/Perps/hooks/usePerpsNavigation.test.ts new file mode 100644 index 000000000000..20b9bad24097 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsNavigation.test.ts @@ -0,0 +1,265 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { usePerpsNavigation } from './usePerpsNavigation'; +import Routes from '../../../../constants/navigation/Routes'; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(), +})); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +describe('usePerpsNavigation', () => { + const mockNavigate = jest.fn(); + const mockCanGoBack = jest.fn(); + const mockGoBack = jest.fn(); + const mockUseNavigation = useNavigation as jest.MockedFunction< + typeof useNavigation + >; + const mockUseSelector = useSelector as jest.MockedFunction< + typeof useSelector + >; + + beforeEach(() => { + jest.clearAllMocks(); + mockCanGoBack.mockReturnValue(true); + mockUseNavigation.mockReturnValue({ + navigate: mockNavigate, + canGoBack: mockCanGoBack, + goBack: mockGoBack, + } as Partial> as ReturnType); + mockUseSelector.mockReturnValue(false); // isRewardsEnabled = false + }); + + describe('Main App Navigation', () => { + it('navigates to wallet view', () => { + const { result } = renderHook(() => usePerpsNavigation()); + + result.current.navigateToWallet(); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET.HOME, { + screen: Routes.WALLET.TAB_STACK_FLOW, + params: { + screen: Routes.WALLET_VIEW, + }, + }); + }); + + it('navigates to browser view', () => { + const { result } = renderHook(() => usePerpsNavigation()); + + result.current.navigateToBrowser(); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.BROWSER.HOME, { + screen: Routes.BROWSER.VIEW, + }); + }); + + it('navigates to actions modal', () => { + const { result } = renderHook(() => usePerpsNavigation()); + + result.current.navigateToActions(); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.MODAL.WALLET_ACTIONS, + }); + }); + + it('navigates to activity view', () => { + const { result } = renderHook(() => usePerpsNavigation()); + + result.current.navigateToActivity(); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW); + }); + + it('navigates to settings when rewards disabled', () => { + mockUseSelector.mockReturnValue(false); + const { result } = renderHook(() => usePerpsNavigation()); + + result.current.navigateToRewardsOrSettings(); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.SETTINGS_VIEW, { + screen: 'Settings', + }); + }); + + it('navigates to rewards when rewards enabled', () => { + mockUseSelector.mockReturnValue(true); + const { result } = renderHook(() => usePerpsNavigation()); + + result.current.navigateToRewardsOrSettings(); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.REWARDS_VIEW); + }); + }); + + describe('Perps-Specific Navigation', () => { + it('navigates to market details without source', () => { + const { result } = renderHook(() => usePerpsNavigation()); + const mockMarket = { symbol: 'BTC' } as Partial< + Parameters[0] + >; + + result.current.navigateToMarketDetails( + mockMarket as Parameters< + typeof result.current.navigateToMarketDetails + >[0], + ); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.MARKET_DETAILS, { + market: mockMarket, + source: undefined, + }); + }); + + it('navigates to market details with source', () => { + const { result } = renderHook(() => usePerpsNavigation()); + const mockMarket = { symbol: 'ETH' } as Partial< + Parameters[0] + >; + + result.current.navigateToMarketDetails( + mockMarket as Parameters< + typeof result.current.navigateToMarketDetails + >[0], + 'home_screen', + ); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.MARKET_DETAILS, { + market: mockMarket, + source: 'home_screen', + }); + }); + + it('navigates to perps home without source', () => { + const { result } = renderHook(() => usePerpsNavigation()); + + result.current.navigateToHome(); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.PERPS_HOME, { + source: undefined, + }); + }); + + it('navigates to perps home with source', () => { + const { result } = renderHook(() => usePerpsNavigation()); + + result.current.navigateToHome('market_list'); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.PERPS_HOME, { + source: 'market_list', + }); + }); + + it('navigates to market list without params', () => { + const { result } = renderHook(() => usePerpsNavigation()); + + result.current.navigateToMarketList(); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.PERPS.MARKET_LIST, + undefined, + ); + }); + + it('navigates to market list with params', () => { + const { result } = renderHook(() => usePerpsNavigation()); + const params = { source: 'test', variant: 'full' as const }; + + result.current.navigateToMarketList(params); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.PERPS.MARKET_LIST, + params, + ); + }); + + it('navigates to order screen with direction and asset', () => { + const { result } = renderHook(() => usePerpsNavigation()); + const params = { direction: 'long' as const, asset: 'BTC' }; + + result.current.navigateToOrder(params); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ORDER, params); + }); + + it('navigates to tutorial without params', () => { + const { result } = renderHook(() => usePerpsNavigation()); + + result.current.navigateToTutorial(); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.PERPS.TUTORIAL, + undefined, + ); + }); + + it('navigates to tutorial with params', () => { + const { result } = renderHook(() => usePerpsNavigation()); + const params = { isFromDeeplink: true }; + + result.current.navigateToTutorial(params); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.TUTORIAL, params); + }); + }); + + describe('Utility Navigation', () => { + it('navigates back when can go back', () => { + mockCanGoBack.mockReturnValue(true); + const { result } = renderHook(() => usePerpsNavigation()); + + result.current.navigateBack(); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('does not navigate back when cannot go back', () => { + mockCanGoBack.mockReturnValue(false); + const { result } = renderHook(() => usePerpsNavigation()); + + result.current.navigateBack(); + + expect(mockGoBack).not.toHaveBeenCalled(); + }); + + it('returns canGoBack state', () => { + mockCanGoBack.mockReturnValue(true); + const { result } = renderHook(() => usePerpsNavigation()); + + expect(result.current.canGoBack).toBe(true); + }); + + it('returns false when cannot go back', () => { + mockCanGoBack.mockReturnValue(false); + const { result } = renderHook(() => usePerpsNavigation()); + + expect(result.current.canGoBack).toBe(false); + }); + }); + + describe('Handler Stability', () => { + it('maintains stable function references', () => { + const { result, rerender } = renderHook(() => usePerpsNavigation()); + + const firstRenderHandlers = { ...result.current }; + rerender(); + const secondRenderHandlers = { ...result.current }; + + // All handlers should be stable (same reference) + expect(firstRenderHandlers.navigateToWallet).toBe( + secondRenderHandlers.navigateToWallet, + ); + expect(firstRenderHandlers.navigateToMarketDetails).toBe( + secondRenderHandlers.navigateToMarketDetails, + ); + expect(firstRenderHandlers.navigateBack).toBe( + secondRenderHandlers.navigateBack, + ); + }); + }); +}); diff --git a/app/components/UI/Perps/hooks/usePerpsNavigation.ts b/app/components/UI/Perps/hooks/usePerpsNavigation.ts new file mode 100644 index 000000000000..b6c327e58fc0 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsNavigation.ts @@ -0,0 +1,172 @@ +import { useCallback } from 'react'; +import { useNavigation, NavigationProp } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import Routes from '../../../../constants/navigation/Routes'; +import { selectRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; +import type { PerpsNavigationParamList } from '../types/navigation'; +import type { PerpsMarketData } from '../controllers/types'; + +/** + * Navigation handler result interface + */ +export interface PerpsNavigationHandlers { + // Main app navigation + navigateToWallet: () => void; + navigateToBrowser: () => void; + navigateToActions: () => void; + navigateToActivity: () => void; + navigateToRewardsOrSettings: () => void; + + // Perps-specific navigation + navigateToMarketDetails: (market: PerpsMarketData, source?: string) => void; + navigateToHome: (source?: string) => void; + navigateToMarketList: ( + params?: PerpsNavigationParamList['PerpsMarketListView'], + ) => void; + navigateToOrder: (params: PerpsNavigationParamList['PerpsOrder']) => void; + navigateToTutorial: ( + params?: PerpsNavigationParamList['PerpsTutorial'], + ) => void; + + // Utility navigation + navigateBack: () => void; + canGoBack: boolean; +} + +/** + * usePerpsNavigation Hook + * + * Centralized navigation handlers for Perps views + * Provides consistent navigation patterns across all Perps components + * + * Features: + * - Main app navigation (wallet, browser, activity, etc.) + * - Perps-specific navigation (market details, home, list) + * - Back navigation with canGoBack check + * - Rewards/Settings toggle based on feature flag + * + * @example + * ```tsx + * const { + * navigateToMarketDetails, + * navigateBack, + * canGoBack + * } = usePerpsNavigation(); + * + * const handleMarketPress = (market: PerpsMarketData) => { + * navigateToMarketDetails(market, 'home_screen'); + * }; + * ``` + * + * @returns Object containing all navigation handler functions + */ +export const usePerpsNavigation = (): PerpsNavigationHandlers => { + const navigation = useNavigation>(); + const isRewardsEnabled = useSelector(selectRewardsEnabledFlag); + + // Main app navigation handlers + const navigateToWallet = useCallback(() => { + navigation.navigate(Routes.WALLET.HOME, { + screen: Routes.WALLET.TAB_STACK_FLOW, + params: { + screen: Routes.WALLET_VIEW, + }, + }); + }, [navigation]); + + const navigateToBrowser = useCallback(() => { + navigation.navigate(Routes.BROWSER.HOME, { + screen: Routes.BROWSER.VIEW, + }); + }, [navigation]); + + const navigateToActions = useCallback(() => { + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.MODAL.WALLET_ACTIONS, + }); + }, [navigation]); + + const navigateToActivity = useCallback(() => { + navigation.navigate(Routes.TRANSACTIONS_VIEW); + }, [navigation]); + + const navigateToRewardsOrSettings = useCallback(() => { + if (isRewardsEnabled) { + navigation.navigate(Routes.REWARDS_VIEW); + } else { + navigation.navigate(Routes.SETTINGS_VIEW, { + screen: 'Settings', + }); + } + }, [navigation, isRewardsEnabled]); + + // Perps-specific navigation handlers + const navigateToMarketDetails = useCallback( + (market: PerpsMarketData, source?: string) => { + navigation.navigate(Routes.PERPS.MARKET_DETAILS, { + market, + source, + }); + }, + [navigation], + ); + + const navigateToHome = useCallback( + (source?: string) => { + navigation.navigate(Routes.PERPS.PERPS_HOME, { + source, + }); + }, + [navigation], + ); + + const navigateToMarketList = useCallback( + (params?: PerpsNavigationParamList['PerpsMarketListView']) => { + navigation.navigate(Routes.PERPS.MARKET_LIST, params); + }, + [navigation], + ); + + const navigateToOrder = useCallback( + (params: PerpsNavigationParamList['PerpsOrder']) => { + navigation.navigate(Routes.PERPS.ORDER, params); + }, + [navigation], + ); + + const navigateToTutorial = useCallback( + (params?: PerpsNavigationParamList['PerpsTutorial']) => { + navigation.navigate(Routes.PERPS.TUTORIAL, params); + }, + [navigation], + ); + + // Utility navigation handlers + const navigateBack = useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack(); + } + }, [navigation]); + + const canGoBack = navigation.canGoBack(); + + return { + // Main app navigation + navigateToWallet, + navigateToBrowser, + navigateToActions, + navigateToActivity, + navigateToRewardsOrSettings, + + // Perps-specific navigation + navigateToMarketDetails, + navigateToHome, + navigateToMarketList, + navigateToOrder, + navigateToTutorial, + + // Utility navigation + navigateBack, + canGoBack, + }; +}; diff --git a/app/components/UI/Perps/hooks/usePerpsOrderFees.ts b/app/components/UI/Perps/hooks/usePerpsOrderFees.ts index 4ad442c66015..1a335903be13 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderFees.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderFees.ts @@ -5,7 +5,6 @@ import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; import { selectSelectedInternalAccountFormattedAddress } from '../../../../selectors/accountsController'; import { selectRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards'; import { selectChainId } from '../../../../selectors/networkController'; -import { usePerpsTrading } from './usePerpsTrading'; import { setMeasurement } from '@sentry/react-native'; import performance from 'react-native-performance'; @@ -19,6 +18,8 @@ import { PERFORMANCE_CONFIG, } from '../constants/perpsConfig'; import { formatAccountToCaipAccountId } from '../utils/rewardsUtils'; +import { usePerpsTrading } from './usePerpsTrading'; +import { determineMakerStatus } from '../utils/orderUtils'; // Cache for fee discount to avoid repeated API calls let feeDiscountCache: { @@ -84,60 +85,6 @@ interface UsePerpsOrderFeesParams { currentBidPrice?: number; } -/** - * Determines if a limit order will likely be a maker or taker - * - * Logic: - * 1. Validates price data freshness and market state - * 2. Market orders are always taker - * 3. Limit orders that would execute immediately are taker - * 4. Limit orders that go into order book are maker - * - * @param params Order parameters - * @returns boolean - true if maker, false if taker - */ -function determineMakerStatus(params: { - orderType: 'market' | 'limit'; - limitPrice?: string; - direction: 'long' | 'short'; - bestAsk?: number; - bestBid?: number; - coin?: string; -}): boolean { - const { orderType, limitPrice, direction, bestAsk, bestBid, coin } = params; - // Market orders are always taker - if (orderType === 'market') { - return false; - } - - // Default to taker when limit price is not specified - if (!limitPrice || limitPrice === '') { - return false; - } - - const limitPriceNum = Number.parseFloat(limitPrice); - - if (Number.isNaN(limitPriceNum) || limitPriceNum <= 0) { - return false; - } - - if (bestBid !== undefined && bestAsk !== undefined) { - if (direction === 'long') { - return limitPriceNum < bestAsk; - } - - // Short direction - return limitPriceNum > bestBid; - } - - // Default to taker when no bid/ask data is available - DevLogger.log( - 'Fee Calculation: No bid/ask data available, using conservative taker fee', - { coin }, - ); - return false; -} - /** * Hook to calculate order fees (protocol + MetaMask) * Protocol-agnostic - each provider determines its own fee structure @@ -596,6 +543,7 @@ export function usePerpsOrderFees({ orderType, isMaker, amount, + coin, }); if (!isComponentMounted) return; @@ -660,6 +608,7 @@ export function usePerpsOrderFees({ orderType, isMaker, amount, + coin, calculateFees, applyFeeDiscount, handlePointsEstimation, diff --git a/app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts b/app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts index ca7776c40a9c..a5ab4c4fed7a 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts @@ -1,19 +1,25 @@ +/* eslint-disable react/no-children-prop */ import React from 'react'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; import { renderHook, act } from '@testing-library/react-native'; import { usePerpsOrderForm } from './usePerpsOrderForm'; import { usePerpsNetwork } from './usePerpsNetwork'; import { usePerpsLiveAccount } from './stream/usePerpsLiveAccount'; import { usePerpsLivePrices } from './stream/usePerpsLivePrices'; +import { usePerpsLivePositions } from './stream/usePerpsLivePositions'; import { usePerpsMarketData } from './usePerpsMarketData'; import { TRADING_DEFAULTS } from '../constants/hyperLiquidConfig'; import { PerpsStreamProvider, PerpsStreamManager, } from '../providers/PerpsStreamManager'; +import type { Position } from '../controllers/types'; jest.mock('./usePerpsNetwork'); jest.mock('./stream/usePerpsLiveAccount'); jest.mock('./stream/usePerpsLivePrices'); +jest.mock('./stream/usePerpsLivePositions'); jest.mock('./usePerpsMarketData'); // Create a mock stream manager for testing @@ -59,13 +65,53 @@ const createMockStreamManager = (): PerpsStreamManager => { return mockStreamManager; }; -// Test wrapper component -function TestWrapper({ children }: { children: React.ReactNode }) { - return React.createElement(PerpsStreamProvider, { - testStreamManager: createMockStreamManager(), - children, - } as React.ComponentProps); -} +// Test wrapper with Redux Provider using configureStore pattern +const createWrapper = () => { + const mockStore = configureStore({ + reducer: { + engine: ( + state = { + backgroundState: { + PerpsController: {}, + }, + }, + ) => state, + }, + }); + + return function TestWrapper({ children }: { children: React.ReactNode }) { + const streamProvider = React.createElement(PerpsStreamProvider, { + testStreamManager: createMockStreamManager(), + children, + } as React.ComponentProps); + + return React.createElement(Provider, { + store: mockStore, + children: streamProvider, + }); + }; +}; + +// Helper to create mock positions +const createMockPosition = (coin: string, leverageValue: number): Position => ({ + coin, + size: '1.5', + entryPrice: '50000', + positionValue: '75000', + unrealizedPnl: '0', + marginUsed: '7500', + leverage: { type: 'isolated', value: leverageValue }, + liquidationPrice: '45000', + maxLeverage: 50, + returnOnEquity: '0', + cumulativeFunding: { + allTime: '0', + sinceOpen: '0', + sinceChange: '0', + }, + takeProfitCount: 0, + stopLossCount: 0, +}); describe('usePerpsOrderForm', () => { const mockUsePerpsNetwork = usePerpsNetwork as jest.MockedFunction< @@ -77,6 +123,8 @@ describe('usePerpsOrderForm', () => { const mockUsePerpsLivePrices = usePerpsLivePrices as jest.MockedFunction< typeof usePerpsLivePrices >; + const mockUsePerpsLivePositions = + usePerpsLivePositions as jest.MockedFunction; const mockUsePerpsMarketData = usePerpsMarketData as jest.MockedFunction< typeof usePerpsMarketData >; @@ -98,6 +146,10 @@ describe('usePerpsOrderForm', () => { BTC: { price: '50000', timestamp: Date.now(), coin: 'BTC' }, ETH: { price: '3000', timestamp: Date.now(), coin: 'ETH' }, }); + mockUsePerpsLivePositions.mockReturnValue({ + positions: [], + isInitialLoading: false, + }); mockUsePerpsMarketData.mockReturnValue({ marketData: { szDecimals: 6, @@ -114,7 +166,7 @@ describe('usePerpsOrderForm', () => { describe('initialization', () => { it('should initialize with default values', () => { const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: TestWrapper, + wrapper: createWrapper(), }); expect(result.current.orderForm).toEqual({ @@ -130,6 +182,200 @@ describe('usePerpsOrderForm', () => { }); }); + it('should prioritize existing position leverage over saved config', () => { + // Mock existing position with 10x leverage + mockUsePerpsLivePositions.mockReturnValue({ + positions: [createMockPosition('BTC', 10)], + isInitialLoading: false, + }); + + // Create a wrapper with saved config for BTC at 5x leverage + const mockStoreWithSavedConfig = configureStore({ + reducer: { + engine: ( + state = { + backgroundState: { + PerpsController: { + isTestnet: false, + tradeConfigurations: { + mainnet: { + BTC: { leverage: 5 }, + }, + testnet: {}, + }, + }, + }, + }, + ) => state, + }, + }); + + const WrapperWithSavedConfig = ({ + children, + }: { + children: React.ReactNode; + }) => { + const streamProvider = React.createElement(PerpsStreamProvider, { + testStreamManager: createMockStreamManager(), + children, + } as React.ComponentProps); + + return React.createElement(Provider, { + store: mockStoreWithSavedConfig, + children: streamProvider, + }); + }; + + // Render hook - should use existing position leverage from mocked positions + const { result } = renderHook( + () => + usePerpsOrderForm({ + initialAsset: 'BTC', + }), + { wrapper: WrapperWithSavedConfig }, + ); + + // Should use existing position leverage (10x), not saved config (5x) or default (3x) + expect(result.current.orderForm.leverage).toBe(10); + }); + + it('should use saved config when no existing position leverage', () => { + // Create a wrapper with saved config for BTC at 5x leverage + const mockStoreWithSavedConfig = configureStore({ + reducer: { + engine: ( + state = { + backgroundState: { + PerpsController: { + isTestnet: false, + tradeConfigurations: { + mainnet: { + BTC: { leverage: 5 }, + }, + testnet: {}, + }, + }, + }, + }, + ) => state, + }, + }); + + const WrapperWithSavedConfig = ({ + children, + }: { + children: React.ReactNode; + }) => { + const streamProvider = React.createElement(PerpsStreamProvider, { + testStreamManager: createMockStreamManager(), + children, + } as React.ComponentProps); + + return React.createElement(Provider, { + store: mockStoreWithSavedConfig, + children: streamProvider, + }); + }; + + // Render without existing position leverage + const { result } = renderHook( + () => + usePerpsOrderForm({ + initialAsset: 'BTC', + }), + { wrapper: WrapperWithSavedConfig }, + ); + + // Should use saved config (5x), not default (3x) + expect(result.current.orderForm.leverage).toBe(5); + }); + + it('should prioritize navigation param over existing position leverage', () => { + // Mock existing position with 10x leverage + mockUsePerpsLivePositions.mockReturnValue({ + positions: [createMockPosition('BTC', 10)], + isInitialLoading: false, + }); + + // Render with both navigation param (12x) and existing position leverage (10x) + const { result } = renderHook( + () => + usePerpsOrderForm({ + initialAsset: 'BTC', + initialLeverage: 12, + }), + { wrapper: createWrapper() }, + ); + + // Should use navigation param (12x), highest priority + expect(result.current.orderForm.leverage).toBe(12); + }); + + it('should update leverage when existing position loads asynchronously', async () => { + // Initial render without existing position (positions haven't loaded via WebSocket yet) + mockUsePerpsLivePositions.mockReturnValue({ + positions: [], + isInitialLoading: false, + }); + + const { result, rerender } = renderHook( + () => + usePerpsOrderForm({ + initialAsset: 'BTC', + }), + { wrapper: createWrapper() }, + ); + + // Initially should use default leverage (3x) since no position loaded yet + expect(result.current.orderForm.leverage).toBe(3); + + // Simulate position loading asynchronously with 10x leverage + act(() => { + mockUsePerpsLivePositions.mockReturnValue({ + positions: [createMockPosition('BTC', 10)], + isInitialLoading: false, + }); + }); + + // Re-render the existing hook instance to trigger useEffect + rerender({}); + + // Should update to 10x when position loads + expect(result.current.orderForm.leverage).toBe(10); + }); + + it('should not update leverage if navigation param is provided even when position loads', () => { + // Initial render with navigation param but no existing position + mockUsePerpsLivePositions.mockReturnValue({ + positions: [], + isInitialLoading: false, + }); + + const { result, rerender } = renderHook( + () => + usePerpsOrderForm({ + initialAsset: 'BTC', + initialLeverage: 12, // explicit navigation param + }), + { wrapper: createWrapper() }, + ); + + // Should use navigation param (12x) + expect(result.current.orderForm.leverage).toBe(12); + + // Simulate position loading asynchronously with 10x leverage + mockUsePerpsLivePositions.mockReturnValue({ + positions: [createMockPosition('BTC', 10)], + isInitialLoading: false, + }); + + // Re-render the existing hook instance to trigger useEffect + rerender({}); + + // Should still be 12x (navigation param takes priority) + expect(result.current.orderForm.leverage).toBe(12); + }); + it('should initialize with provided values', () => { const { result } = renderHook( () => @@ -140,7 +386,7 @@ describe('usePerpsOrderForm', () => { initialLeverage: 20, initialType: 'limit', }), - { wrapper: TestWrapper }, + { wrapper: createWrapper() }, ); expect(result.current.orderForm).toEqual({ @@ -160,7 +406,7 @@ describe('usePerpsOrderForm', () => { mockUsePerpsNetwork.mockReturnValue('testnet'); const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: TestWrapper, + wrapper: createWrapper(), }); expect(result.current.orderForm.amount).toBe( @@ -183,7 +429,7 @@ describe('usePerpsOrderForm', () => { // Act const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: TestWrapper, + wrapper: createWrapper(), }); // Assert @@ -207,7 +453,7 @@ describe('usePerpsOrderForm', () => { // Act const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: TestWrapper, + wrapper: createWrapper(), }); // Assert @@ -234,7 +480,7 @@ describe('usePerpsOrderForm', () => { mockUsePerpsLiveAccount.mockReturnValue(mockAccount); const { result, rerender } = renderHook(() => usePerpsOrderForm(), { - wrapper: TestWrapper, + wrapper: createWrapper(), }); // Verify initial amount is set correctly @@ -274,7 +520,7 @@ describe('usePerpsOrderForm', () => { }); const { result: result1 } = renderHook(() => usePerpsOrderForm(), { - wrapper: TestWrapper, + wrapper: createWrapper(), }); expect(result1.current.orderForm.amount).toBe('6'); // Should use maxPossibleAmount @@ -292,7 +538,7 @@ describe('usePerpsOrderForm', () => { }); const { result: result2 } = renderHook(() => usePerpsOrderForm(), { - wrapper: TestWrapper, + wrapper: createWrapper(), }); expect(result2.current.orderForm.amount).toBe( @@ -304,7 +550,7 @@ describe('usePerpsOrderForm', () => { describe('form updates', () => { it('should update amount', () => { const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: TestWrapper, + wrapper: createWrapper(), }); act(() => { @@ -316,7 +562,7 @@ describe('usePerpsOrderForm', () => { it('should update leverage', () => { const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: TestWrapper, + wrapper: createWrapper(), }); act(() => { @@ -328,7 +574,7 @@ describe('usePerpsOrderForm', () => { it('should update direction', () => { const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: TestWrapper, + wrapper: createWrapper(), }); act(() => { @@ -340,7 +586,7 @@ describe('usePerpsOrderForm', () => { it('should update asset', () => { const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: TestWrapper, + wrapper: createWrapper(), }); act(() => { @@ -352,7 +598,7 @@ describe('usePerpsOrderForm', () => { it('should update take profit price', () => { const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: TestWrapper, + wrapper: createWrapper(), }); act(() => { @@ -364,7 +610,7 @@ describe('usePerpsOrderForm', () => { it('should update stop loss price', () => { const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: TestWrapper, + wrapper: createWrapper(), }); act(() => { @@ -376,7 +622,7 @@ describe('usePerpsOrderForm', () => { it('should update limit price', () => { const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: TestWrapper, + wrapper: createWrapper(), }); act(() => { @@ -388,7 +634,7 @@ describe('usePerpsOrderForm', () => { it('should update order type', () => { const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: TestWrapper, + wrapper: createWrapper(), }); act(() => { @@ -400,7 +646,7 @@ describe('usePerpsOrderForm', () => { it('should update multiple fields at once', () => { const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: TestWrapper, + wrapper: createWrapper(), }); act(() => { @@ -420,7 +666,7 @@ describe('usePerpsOrderForm', () => { describe('percentage handlers', () => { it('should handle percentage amount', () => { const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: TestWrapper, + wrapper: createWrapper(), }); act(() => { @@ -432,7 +678,7 @@ describe('usePerpsOrderForm', () => { it('should handle max amount', () => { const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: TestWrapper, + wrapper: createWrapper(), }); act(() => { @@ -444,7 +690,7 @@ describe('usePerpsOrderForm', () => { it('should handle min amount for mainnet', () => { const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: TestWrapper, + wrapper: createWrapper(), }); act(() => { @@ -459,7 +705,7 @@ describe('usePerpsOrderForm', () => { it('should handle min amount for testnet', () => { mockUsePerpsNetwork.mockReturnValue('testnet'); const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: TestWrapper, + wrapper: createWrapper(), }); act(() => { @@ -484,7 +730,7 @@ describe('usePerpsOrderForm', () => { }); const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: TestWrapper, + wrapper: createWrapper(), }); const initialAmount = result.current.orderForm.amount; @@ -499,7 +745,7 @@ describe('usePerpsOrderForm', () => { describe('empty amount handling', () => { it('should convert empty string to 0', () => { const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: TestWrapper, + wrapper: createWrapper(), }); act(() => { @@ -513,7 +759,7 @@ describe('usePerpsOrderForm', () => { describe('optimizeOrderAmount', () => { it('should not optimize when amount is empty', () => { const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: TestWrapper, + wrapper: createWrapper(), }); act(() => { @@ -526,7 +772,7 @@ describe('usePerpsOrderForm', () => { it('should not optimize when amount is zero', () => { const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: TestWrapper, + wrapper: createWrapper(), }); act(() => { @@ -539,7 +785,7 @@ describe('usePerpsOrderForm', () => { it('should optimize amount when valid amount is provided', () => { const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: TestWrapper, + wrapper: createWrapper(), }); act(() => { @@ -553,7 +799,7 @@ describe('usePerpsOrderForm', () => { it('should not update amount if optimized amount exceeds maxPossibleAmount', () => { const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: TestWrapper, + wrapper: createWrapper(), }); const initialAmount = '100'; @@ -570,7 +816,7 @@ describe('usePerpsOrderForm', () => { it('should only update amount if optimized amount is different from current', () => { const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: TestWrapper, + wrapper: createWrapper(), }); act(() => { @@ -591,7 +837,7 @@ describe('usePerpsOrderForm', () => { it('should handle undefined szDecimals parameter', () => { const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: TestWrapper, + wrapper: createWrapper(), }); act(() => { diff --git a/app/components/UI/Perps/hooks/usePerpsOrderForm.ts b/app/components/UI/Perps/hooks/usePerpsOrderForm.ts index 4f3df5f53dfc..e6274ccb86a9 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderForm.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderForm.ts @@ -8,9 +8,15 @@ import { findOptimalAmount, getMaxAllowedAmount as getMaxAllowedAmountUtils, } from '../utils/orderCalculations'; -import { usePerpsLiveAccount, usePerpsLivePrices } from './stream'; +import { + usePerpsLiveAccount, + usePerpsLivePositions, + usePerpsLivePrices, +} from './stream'; import { usePerpsMarketData } from './usePerpsMarketData'; import { usePerpsNetwork } from './usePerpsNetwork'; +import { selectTradeConfiguration } from '../controllers/selectors'; +import { usePerpsSelector } from './usePerpsSelector'; interface UsePerpsOrderFormParams { initialAsset?: string; @@ -55,6 +61,7 @@ export function usePerpsOrderForm( const currentNetwork = usePerpsNetwork(); const { account } = usePerpsLiveAccount(); + const { positions } = usePerpsLivePositions(); const prices = usePerpsLivePrices({ symbols: [initialAsset], throttleMs: 1000, @@ -62,6 +69,18 @@ export function usePerpsOrderForm( const currentPrice = prices[initialAsset]; const { marketData } = usePerpsMarketData(initialAsset); + // Get existing position leverage for this asset (protocol constraint) + // Positions load asynchronously via WebSocket, so this may be undefined initially + const existingPositionLeverage = useMemo( + () => positions.find((p) => p.coin === initialAsset)?.leverage?.value, + [positions, initialAsset], + ); + + // Get saved trade configuration for this asset (user preference for new positions) + const savedConfig = usePerpsSelector((state) => + selectTradeConfiguration(state, initialAsset), + ); + // Get available balance from live account data const availableBalance = Number.parseFloat( account?.availableBalance?.toString() || '0', @@ -73,8 +92,12 @@ export function usePerpsOrderForm( ? TRADING_DEFAULTS.amount.mainnet : TRADING_DEFAULTS.amount.testnet; - // Calculate the maximum possible amount based on available balance and leverage - const defaultLeverage = initialLeverage || TRADING_DEFAULTS.leverage; + // Priority: navigation param > existing position leverage > saved config > default (3x) + const defaultLeverage = + initialLeverage || + existingPositionLeverage || + savedConfig?.leverage || + TRADING_DEFAULTS.leverage; // Use memoized calculation for initial amount to ensure it updates when dependencies change const initialAmountValue = useMemo(() => { @@ -202,6 +225,26 @@ export function usePerpsOrderForm( } }, [initialAmountValue]); + // Sync leverage from existing position when it loads asynchronously + // This handles the case where positions haven't loaded yet when form initializes + const hasSyncedLeverage = useRef(false); + useEffect(() => { + // Only update if: + // 1. Haven't synced yet (avoid fighting with user input) + // 2. No explicit initialLeverage was provided (respect navigation params) + // 3. existingPositionLeverage loaded (was undefined, now has value) + // 4. Current leverage would cause protocol violation (< existing) + if ( + !hasSyncedLeverage.current && + !initialLeverage && + existingPositionLeverage && + orderForm.leverage < existingPositionLeverage + ) { + setOrderForm((prev) => ({ ...prev, leverage: existingPositionLeverage })); + hasSyncedLeverage.current = true; + } + }, [existingPositionLeverage, initialLeverage, orderForm.leverage]); + // Update entire form const updateOrderForm = (updates: Partial) => { setOrderForm((prev) => ({ ...prev, ...updates })); diff --git a/app/components/UI/Perps/hooks/usePerpsOrderValidation.ts b/app/components/UI/Perps/hooks/usePerpsOrderValidation.ts index e3a39c2ac11a..af544502b96d 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderValidation.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderValidation.ts @@ -16,6 +16,7 @@ interface UsePerpsOrderValidationParams { assetPrice: number; availableBalance: number; marginRequired: string; + existingPositionLeverage?: number; } interface ValidationResult { @@ -45,6 +46,7 @@ export function usePerpsOrderValidation( assetPrice, availableBalance, marginRequired, + existingPositionLeverage, } = params; const { validateOrder } = usePerpsTrading(); @@ -95,6 +97,7 @@ export function usePerpsOrderValidation( price: orderForm.limitPrice, leverage: orderForm.leverage, currentPrice: assetPrice, + existingPositionLeverage, }; // Get protocol-specific validation @@ -150,6 +153,7 @@ export function usePerpsOrderValidation( assetPrice, availableBalance, marginRequired, + existingPositionLeverage, validateOrder, ]); diff --git a/app/components/UI/Perps/hooks/usePerpsSearch.test.ts b/app/components/UI/Perps/hooks/usePerpsSearch.test.ts new file mode 100644 index 000000000000..aebcaee39a06 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsSearch.test.ts @@ -0,0 +1,419 @@ +import { renderHook, act } from '@testing-library/react-native'; +import type { PerpsMarketData } from '../controllers/types'; +import { usePerpsSearch } from './usePerpsSearch'; + +describe('usePerpsSearch', () => { + const createMockMarket = (symbol: string, name: string): PerpsMarketData => ({ + symbol, + name, + maxLeverage: '50x', + price: '$50,000.00', + change24h: '+$2,600.00', + change24hPercent: '+5.2%', + volume: '$1,000,000', + fundingRate: 0.01, + }); + + const mockMarkets: PerpsMarketData[] = [ + createMockMarket('BTC', 'Bitcoin'), + createMockMarket('ETH', 'Ethereum'), + createMockMarket('SOL', 'Solana'), + createMockMarket('AVAX', 'Avalanche'), + ]; + + describe('initialization', () => { + it('returns all markets when search is not visible', () => { + const { result } = renderHook(() => + usePerpsSearch({ markets: mockMarkets }), + ); + + expect(result.current.filteredMarkets).toEqual(mockMarkets); + expect(result.current.searchQuery).toBe(''); + expect(result.current.isSearchVisible).toBe(false); + }); + + it('initializes with search visible when specified', () => { + const { result } = renderHook(() => + usePerpsSearch({ markets: mockMarkets, initialSearchVisible: true }), + ); + + expect(result.current.isSearchVisible).toBe(true); + }); + + it('returns empty array when markets array is empty', () => { + const { result } = renderHook(() => usePerpsSearch({ markets: [] })); + + expect(result.current.filteredMarkets).toEqual([]); + }); + }); + + describe('search visibility', () => { + it('shows search when setIsSearchVisible is called with true', () => { + const { result } = renderHook(() => + usePerpsSearch({ markets: mockMarkets }), + ); + + act(() => { + result.current.setIsSearchVisible(true); + }); + + expect(result.current.isSearchVisible).toBe(true); + }); + + it('hides search when setIsSearchVisible is called with false', () => { + const { result } = renderHook(() => + usePerpsSearch({ + markets: mockMarkets, + initialSearchVisible: true, + }), + ); + + act(() => { + result.current.setIsSearchVisible(false); + }); + + expect(result.current.isSearchVisible).toBe(false); + }); + + it('toggles search visibility from hidden to visible', () => { + const { result } = renderHook(() => + usePerpsSearch({ markets: mockMarkets }), + ); + + act(() => { + result.current.toggleSearchVisibility(); + }); + + expect(result.current.isSearchVisible).toBe(true); + }); + + it('toggles search visibility from visible to hidden', () => { + const { result } = renderHook(() => + usePerpsSearch({ + markets: mockMarkets, + initialSearchVisible: true, + }), + ); + + act(() => { + result.current.toggleSearchVisibility(); + }); + + expect(result.current.isSearchVisible).toBe(false); + }); + }); + + describe('search query management', () => { + it('updates search query when setSearchQuery is called', () => { + const { result } = renderHook(() => + usePerpsSearch({ markets: mockMarkets }), + ); + + act(() => { + result.current.setSearchQuery('BTC'); + }); + + expect(result.current.searchQuery).toBe('BTC'); + }); + + it('clears search query and hides search when clearSearch is called', () => { + const { result } = renderHook(() => + usePerpsSearch({ + markets: mockMarkets, + initialSearchVisible: true, + }), + ); + + act(() => { + result.current.setSearchQuery('ETH'); + }); + + act(() => { + result.current.clearSearch(); + }); + + expect(result.current.searchQuery).toBe(''); + expect(result.current.isSearchVisible).toBe(false); + }); + }); + + describe('market filtering by symbol', () => { + it('filters markets by exact symbol match', () => { + const { result } = renderHook(() => + usePerpsSearch({ markets: mockMarkets }), + ); + + act(() => { + result.current.setIsSearchVisible(true); + result.current.setSearchQuery('BTC'); + }); + + expect(result.current.filteredMarkets).toHaveLength(1); + expect(result.current.filteredMarkets[0].symbol).toBe('BTC'); + }); + + it('filters markets by partial symbol match', () => { + const { result } = renderHook(() => + usePerpsSearch({ markets: mockMarkets }), + ); + + act(() => { + result.current.setIsSearchVisible(true); + result.current.setSearchQuery('A'); + }); + + expect(result.current.filteredMarkets).toHaveLength(2); + expect(result.current.filteredMarkets.map((m) => m.symbol)).toEqual([ + 'SOL', + 'AVAX', + ]); + }); + + it('performs case-insensitive symbol search', () => { + const { result } = renderHook(() => + usePerpsSearch({ markets: mockMarkets }), + ); + + act(() => { + result.current.setIsSearchVisible(true); + result.current.setSearchQuery('btc'); + }); + + expect(result.current.filteredMarkets).toHaveLength(1); + expect(result.current.filteredMarkets[0].symbol).toBe('BTC'); + }); + }); + + describe('market filtering by name', () => { + it('filters markets by exact name match', () => { + const { result } = renderHook(() => + usePerpsSearch({ markets: mockMarkets }), + ); + + act(() => { + result.current.setIsSearchVisible(true); + result.current.setSearchQuery('Bitcoin'); + }); + + expect(result.current.filteredMarkets).toHaveLength(1); + expect(result.current.filteredMarkets[0].name).toBe('Bitcoin'); + }); + + it('filters markets by partial name match', () => { + const { result } = renderHook(() => + usePerpsSearch({ markets: mockMarkets }), + ); + + act(() => { + result.current.setIsSearchVisible(true); + result.current.setSearchQuery('Ava'); + }); + + expect(result.current.filteredMarkets).toHaveLength(1); + expect(result.current.filteredMarkets[0].name).toBe('Avalanche'); + }); + + it('performs case-insensitive name search', () => { + const { result } = renderHook(() => + usePerpsSearch({ markets: mockMarkets }), + ); + + act(() => { + result.current.setIsSearchVisible(true); + result.current.setSearchQuery('ethereum'); + }); + + expect(result.current.filteredMarkets).toHaveLength(1); + expect(result.current.filteredMarkets[0].name).toBe('Ethereum'); + }); + }); + + describe('edge cases', () => { + it('returns all markets when search query is empty string', () => { + const { result } = renderHook(() => + usePerpsSearch({ + markets: mockMarkets, + initialSearchVisible: true, + }), + ); + + act(() => { + result.current.setSearchQuery(''); + }); + + expect(result.current.filteredMarkets).toEqual(mockMarkets); + }); + + it('returns all markets when search query is only whitespace', () => { + const { result } = renderHook(() => + usePerpsSearch({ + markets: mockMarkets, + initialSearchVisible: true, + }), + ); + + act(() => { + result.current.setSearchQuery(' '); + }); + + expect(result.current.filteredMarkets).toEqual(mockMarkets); + }); + + it('returns empty array when no markets match search', () => { + const { result } = renderHook(() => + usePerpsSearch({ + markets: mockMarkets, + initialSearchVisible: true, + }), + ); + + act(() => { + result.current.setSearchQuery('NONEXISTENT'); + }); + + expect(result.current.filteredMarkets).toEqual([]); + }); + + it('returns all markets when search is hidden regardless of query', () => { + const { result } = renderHook(() => + usePerpsSearch({ markets: mockMarkets }), + ); + + act(() => { + result.current.setIsSearchVisible(false); + result.current.setSearchQuery('BTC'); + }); + + expect(result.current.filteredMarkets).toEqual(mockMarkets); + }); + + it('trims whitespace from search query', () => { + const { result } = renderHook(() => + usePerpsSearch({ + markets: mockMarkets, + initialSearchVisible: true, + }), + ); + + act(() => { + result.current.setSearchQuery(' ETH '); + }); + + expect(result.current.filteredMarkets).toHaveLength(1); + expect(result.current.filteredMarkets[0].symbol).toBe('ETH'); + }); + + it('handles markets with null or undefined symbol', () => { + const marketsWithNullSymbol: PerpsMarketData[] = [ + { + ...createMockMarket('BTC', 'Bitcoin'), + symbol: null as unknown as string, + }, + createMockMarket('ETH', 'Ethereum'), + ]; + + const { result } = renderHook(() => + usePerpsSearch({ + markets: marketsWithNullSymbol, + initialSearchVisible: true, + }), + ); + + act(() => { + result.current.setSearchQuery('BTC'); + }); + + expect(result.current.filteredMarkets).toEqual([]); + }); + + it('handles markets with null or undefined name', () => { + const marketsWithNullName: PerpsMarketData[] = [ + { + ...createMockMarket('BTC', 'Bitcoin'), + name: null as unknown as string, + }, + createMockMarket('ETH', 'Ethereum'), + ]; + + const { result } = renderHook(() => + usePerpsSearch({ + markets: marketsWithNullName, + initialSearchVisible: true, + }), + ); + + act(() => { + result.current.setSearchQuery('Bitcoin'); + }); + + expect(result.current.filteredMarkets).toEqual([]); + }); + }); + + describe('filtering behavior updates', () => { + it('updates filtered markets when query changes', () => { + const { result } = renderHook(() => + usePerpsSearch({ + markets: mockMarkets, + initialSearchVisible: true, + }), + ); + + act(() => { + result.current.setSearchQuery('BTC'); + }); + + expect(result.current.filteredMarkets).toHaveLength(1); + + act(() => { + result.current.setSearchQuery('ETH'); + }); + + expect(result.current.filteredMarkets).toHaveLength(1); + expect(result.current.filteredMarkets[0].symbol).toBe('ETH'); + }); + + it('updates filtered markets when markets array changes', () => { + const { result, rerender } = renderHook( + ({ markets }) => + usePerpsSearch({ markets, initialSearchVisible: true }), + { initialProps: { markets: mockMarkets } }, + ); + + act(() => { + result.current.setSearchQuery('BTC'); + }); + + expect(result.current.filteredMarkets).toHaveLength(1); + + const newMarkets = [ + createMockMarket('BTC', 'Bitcoin'), + createMockMarket('BTC-PERP', 'Bitcoin Perpetual'), + ]; + + rerender({ markets: newMarkets }); + + expect(result.current.filteredMarkets).toHaveLength(2); + }); + + it('updates filtered markets when search visibility changes', () => { + const { result } = renderHook(() => + usePerpsSearch({ markets: mockMarkets }), + ); + + act(() => { + result.current.setSearchQuery('BTC'); + result.current.setIsSearchVisible(false); + }); + + expect(result.current.filteredMarkets).toEqual(mockMarkets); + + act(() => { + result.current.setIsSearchVisible(true); + }); + + expect(result.current.filteredMarkets).toHaveLength(1); + expect(result.current.filteredMarkets[0].symbol).toBe('BTC'); + }); + }); +}); diff --git a/app/components/UI/Perps/hooks/usePerpsSearch.ts b/app/components/UI/Perps/hooks/usePerpsSearch.ts new file mode 100644 index 000000000000..f28da065eb71 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsSearch.ts @@ -0,0 +1,108 @@ +import { useState, useCallback, useMemo } from 'react'; +import type { PerpsMarketData } from '../controllers/types'; + +interface UsePerpsSearchParams { + /** + * Markets to filter + */ + markets: PerpsMarketData[]; + /** + * Initial search visibility + * @default false + */ + initialSearchVisible?: boolean; +} + +interface UsePerpsSearchReturn { + /** + * Current search query + */ + searchQuery: string; + /** + * Update search query + */ + setSearchQuery: (query: string) => void; + /** + * Whether search bar is visible + */ + isSearchVisible: boolean; + /** + * Show/hide search bar + */ + setIsSearchVisible: (visible: boolean) => void; + /** + * Toggle search visibility + */ + toggleSearchVisibility: () => void; + /** + * Markets filtered by search query + */ + filteredMarkets: PerpsMarketData[]; + /** + * Clear search and hide search bar + */ + clearSearch: () => void; +} + +/** + * Hook for managing market search state and filtering + * + * Responsibilities: + * - Manages search query state + * - Manages search visibility state + * - Filters markets by symbol/name + * - Type-safe field access + * + * @example + * ```tsx + * const { markets } = usePerpsMarkets(); + * const { + * searchQuery, + * setSearchQuery, + * isSearchVisible, + * toggleSearchVisibility, + * filteredMarkets, + * } = usePerpsSearch({ markets }); + * ``` + */ +export const usePerpsSearch = ({ + markets, + initialSearchVisible = false, +}: UsePerpsSearchParams): UsePerpsSearchReturn => { + const [searchQuery, setSearchQuery] = useState(''); + const [isSearchVisible, setIsSearchVisible] = useState(initialSearchVisible); + + const toggleSearchVisibility = useCallback(() => { + setIsSearchVisible((prev) => !prev); + }, []); + + const clearSearch = useCallback(() => { + setSearchQuery(''); + setIsSearchVisible(false); + }, []); + + // Filter markets based on search query + const filteredMarkets = useMemo(() => { + if (!isSearchVisible || !searchQuery.trim()) { + return markets; + } + + const lowerQuery = searchQuery.toLowerCase().trim(); + + return markets.filter( + (market) => + market.symbol?.toLowerCase().includes(lowerQuery) || + market.name?.toLowerCase().includes(lowerQuery), + ); + }, [markets, searchQuery, isSearchVisible]); + + return { + searchQuery, + setSearchQuery, + isSearchVisible, + setIsSearchVisible, + toggleSearchVisibility, + filteredMarkets, + clearSearch, + }; +}; diff --git a/app/components/UI/Perps/hooks/usePerpsSorting.test.ts b/app/components/UI/Perps/hooks/usePerpsSorting.test.ts new file mode 100644 index 000000000000..035901db124f --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsSorting.test.ts @@ -0,0 +1,340 @@ +import { renderHook, act } from '@testing-library/react-native'; +import type { PerpsMarketData } from '../controllers/types'; +import { MARKET_SORTING_CONFIG } from '../constants/perpsConfig'; +import { usePerpsSorting } from './usePerpsSorting'; +import { sortMarkets } from '../utils/sortMarkets'; + +// Mock the sortMarkets utility +jest.mock('../utils/sortMarkets', () => ({ + sortMarkets: jest.fn((params) => params.markets), +})); + +const mockSortMarkets = sortMarkets as jest.MockedFunction; + +describe('usePerpsSorting', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const createMockMarket = ( + symbol: string, + overrides: Partial = {}, + ): PerpsMarketData => ({ + symbol, + name: `${symbol} Market`, + maxLeverage: '50x', + price: '$50,000.00', + change24h: '+$2,600.00', + change24hPercent: '+5.2%', + volume: '$1,000,000', + fundingRate: 0.01, + ...overrides, + }); + + const mockMarkets: PerpsMarketData[] = [ + createMockMarket('BTC', { volume: '$5,000,000' }), + createMockMarket('ETH', { volume: '$3,000,000' }), + createMockMarket('SOL', { volume: '$1,000,000' }), + ]; + + describe('initialization', () => { + it('initializes with default sort option', () => { + const { result } = renderHook(() => usePerpsSorting()); + + expect(result.current.selectedOptionId).toBe( + MARKET_SORTING_CONFIG.DEFAULT_SORT_OPTION_ID, + ); + expect(result.current.sortBy).toBe( + MARKET_SORTING_CONFIG.SORT_FIELDS.VOLUME, + ); + expect(result.current.direction).toBe( + MARKET_SORTING_CONFIG.DEFAULT_DIRECTION, + ); + }); + + it('initializes with custom sort option', () => { + const { result } = renderHook(() => + usePerpsSorting({ initialOptionId: 'priceChange-asc' }), + ); + + expect(result.current.selectedOptionId).toBe('priceChange-asc'); + expect(result.current.sortBy).toBe('priceChange'); + expect(result.current.direction).toBe('asc'); + }); + }); + + describe('option selection', () => { + it('updates selected option when handleOptionChange is called', () => { + const { result } = renderHook(() => usePerpsSorting()); + + act(() => { + result.current.handleOptionChange('fundingRate', 'fundingRate', 'desc'); + }); + + expect(result.current.selectedOptionId).toBe('fundingRate'); + expect(result.current.sortBy).toBe('fundingRate'); + expect(result.current.direction).toBe('desc'); + }); + + it('updates sort direction for price change ascending', () => { + const { result } = renderHook(() => usePerpsSorting()); + + act(() => { + result.current.handleOptionChange( + 'priceChange-asc', + 'priceChange', + 'asc', + ); + }); + + expect(result.current.selectedOptionId).toBe('priceChange-asc'); + expect(result.current.sortBy).toBe('priceChange'); + expect(result.current.direction).toBe('asc'); + }); + + it('updates sort direction for price change descending', () => { + const { result } = renderHook(() => usePerpsSorting()); + + act(() => { + result.current.handleOptionChange( + 'priceChange-desc', + 'priceChange', + 'desc', + ); + }); + + expect(result.current.selectedOptionId).toBe('priceChange-desc'); + expect(result.current.sortBy).toBe('priceChange'); + expect(result.current.direction).toBe('desc'); + }); + + it('maintains handleOptionChange reference across renders', () => { + const { result } = renderHook(() => usePerpsSorting()); + + const initialHandler = result.current.handleOptionChange; + + // Function reference should remain stable + expect(result.current.handleOptionChange).toBe(initialHandler); + }); + }); + + describe('sorting markets', () => { + it('calls sortMarkets with current sort settings', () => { + const { result } = renderHook(() => usePerpsSorting()); + + act(() => { + result.current.sortMarketsList(mockMarkets); + }); + + expect(mockSortMarkets).toHaveBeenCalledWith({ + markets: mockMarkets, + sortBy: MARKET_SORTING_CONFIG.SORT_FIELDS.VOLUME, + direction: MARKET_SORTING_CONFIG.DEFAULT_DIRECTION, + }); + }); + + it('calls sortMarkets with updated sort field', () => { + const { result } = renderHook(() => usePerpsSorting()); + + act(() => { + result.current.handleOptionChange('fundingRate', 'fundingRate', 'desc'); + }); + + act(() => { + result.current.sortMarketsList(mockMarkets); + }); + + expect(mockSortMarkets).toHaveBeenCalledWith({ + markets: mockMarkets, + sortBy: 'fundingRate', + direction: 'desc', + }); + }); + + it('calls sortMarkets with ascending direction', () => { + const { result } = renderHook(() => usePerpsSorting()); + + act(() => { + result.current.handleOptionChange( + 'priceChange-asc', + 'priceChange', + 'asc', + ); + }); + + act(() => { + result.current.sortMarketsList(mockMarkets); + }); + + expect(mockSortMarkets).toHaveBeenCalledWith({ + markets: mockMarkets, + sortBy: 'priceChange', + direction: 'asc', + }); + }); + + it('returns sorted markets from sortMarkets utility', () => { + const sortedMarkets = [mockMarkets[2], mockMarkets[1], mockMarkets[0]]; + mockSortMarkets.mockReturnValue(sortedMarkets); + + const { result } = renderHook(() => usePerpsSorting()); + + const resultMarkets = result.current.sortMarketsList(mockMarkets); + + expect(resultMarkets).toEqual(sortedMarkets); + }); + + it('updates sortMarketsList when sort options change', () => { + const { result } = renderHook(() => usePerpsSorting()); + + const initialSortFn = result.current.sortMarketsList; + + act(() => { + result.current.handleOptionChange('fundingRate', 'fundingRate', 'desc'); + }); + + expect(result.current.sortMarketsList).not.toBe(initialSortFn); + }); + + it('maintains sortMarketsList reference when sort options unchanged', () => { + const { result } = renderHook(() => usePerpsSorting()); + + const initialSortFn = result.current.sortMarketsList; + + // Function reference should remain stable + expect(result.current.sortMarketsList).toBe(initialSortFn); + }); + }); + + describe('edge cases', () => { + it('handles empty markets array', () => { + const { result } = renderHook(() => usePerpsSorting()); + + act(() => { + result.current.sortMarketsList([]); + }); + + expect(mockSortMarkets).toHaveBeenCalledWith({ + markets: [], + sortBy: MARKET_SORTING_CONFIG.SORT_FIELDS.VOLUME, + direction: MARKET_SORTING_CONFIG.DEFAULT_DIRECTION, + }); + }); + + it('falls back to default values when option not found', () => { + const { result } = renderHook(() => + usePerpsSorting({ + initialOptionId: 'invalid-option' as 'volume', + }), + ); + + expect(result.current.selectedOptionId).toBe('invalid-option'); + expect(result.current.sortBy).toBe( + MARKET_SORTING_CONFIG.SORT_FIELDS.VOLUME, + ); + expect(result.current.direction).toBe( + MARKET_SORTING_CONFIG.DEFAULT_DIRECTION, + ); + }); + + it('handles multiple sort changes in sequence', () => { + const { result } = renderHook(() => usePerpsSorting()); + + act(() => { + result.current.handleOptionChange('fundingRate', 'fundingRate', 'desc'); + }); + + expect(result.current.sortBy).toBe('fundingRate'); + + act(() => { + result.current.handleOptionChange( + 'priceChange-asc', + 'priceChange', + 'asc', + ); + }); + + expect(result.current.sortBy).toBe('priceChange'); + expect(result.current.direction).toBe('asc'); + + act(() => { + result.current.handleOptionChange('volume', 'volume', 'desc'); + }); + + expect(result.current.sortBy).toBe('volume'); + expect(result.current.direction).toBe('desc'); + }); + }); + + describe('all sort options', () => { + it('handles volume sort option', () => { + const { result } = renderHook(() => usePerpsSorting()); + + act(() => { + result.current.handleOptionChange('volume', 'volume', 'desc'); + }); + + expect(result.current.selectedOptionId).toBe('volume'); + expect(result.current.sortBy).toBe('volume'); + expect(result.current.direction).toBe('desc'); + }); + + it('handles funding rate sort option', () => { + const { result } = renderHook(() => usePerpsSorting()); + + act(() => { + result.current.handleOptionChange('fundingRate', 'fundingRate', 'desc'); + }); + + expect(result.current.selectedOptionId).toBe('fundingRate'); + expect(result.current.sortBy).toBe('fundingRate'); + expect(result.current.direction).toBe('desc'); + }); + + it('handles open interest sort option', () => { + const { result } = renderHook(() => usePerpsSorting()); + + act(() => { + result.current.handleOptionChange( + 'openInterest', + 'openInterest', + 'desc', + ); + }); + + expect(result.current.selectedOptionId).toBe('openInterest'); + expect(result.current.sortBy).toBe('openInterest'); + expect(result.current.direction).toBe('desc'); + }); + }); + + describe('derived values', () => { + it('derives sortBy and direction from selectedOptionId', () => { + const { result } = renderHook( + ({ optionId }) => usePerpsSorting({ initialOptionId: optionId }), + { initialProps: { optionId: 'volume' as const } }, + ); + + expect(result.current.sortBy).toBe('volume'); + expect(result.current.direction).toBe('desc'); + + act(() => { + result.current.handleOptionChange('fundingRate', 'fundingRate', 'desc'); + }); + + expect(result.current.sortBy).toBe('fundingRate'); + expect(result.current.direction).toBe('desc'); + }); + + it('recalculates derived values only when selectedOptionId changes', () => { + const { result } = renderHook(() => usePerpsSorting()); + + const initialSortBy = result.current.sortBy; + const initialDirection = result.current.direction; + + // Re-render should not change derived values if selectedOptionId hasn't changed + expect(result.current.sortBy).toBe(initialSortBy); + expect(result.current.direction).toBe(initialDirection); + }); + }); +}); diff --git a/app/components/UI/Perps/hooks/usePerpsSorting.ts b/app/components/UI/Perps/hooks/usePerpsSorting.ts new file mode 100644 index 000000000000..c6530aebe546 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsSorting.ts @@ -0,0 +1,74 @@ +import { useState, useCallback, useMemo } from 'react'; +import type { PerpsMarketData } from '../controllers/types'; +import { + sortMarkets, + type SortField, + type SortDirection, +} from '../utils/sortMarkets'; +import { + MARKET_SORTING_CONFIG, + type SortOptionId, +} from '../constants/perpsConfig'; + +interface UsePerpsSortingParams { + initialOptionId?: SortOptionId; +} + +interface UsePerpsSortingReturn { + selectedOptionId: SortOptionId; + sortBy: SortField; + direction: SortDirection; + handleOptionChange: ( + optionId: SortOptionId, + field: SortField, + direction: SortDirection, + ) => void; + sortMarketsList: (markets: PerpsMarketData[]) => PerpsMarketData[]; +} + +/** + * Hook for managing market sorting state + * Uses combined option IDs (e.g., 'volume', 'priceChange-desc') for simplified selection + */ +export const usePerpsSorting = ({ + initialOptionId = MARKET_SORTING_CONFIG.DEFAULT_SORT_OPTION_ID, +}: UsePerpsSortingParams = {}): UsePerpsSortingReturn => { + const [selectedOptionId, setSelectedOptionId] = + useState(initialOptionId); + + // Derive sortBy and direction from selectedOptionId + const { sortBy, direction } = useMemo(() => { + const option = MARKET_SORTING_CONFIG.SORT_OPTIONS.find( + (opt) => opt.id === selectedOptionId, + ); + return { + sortBy: option?.field ?? MARKET_SORTING_CONFIG.SORT_FIELDS.VOLUME, + direction: option?.direction ?? MARKET_SORTING_CONFIG.DEFAULT_DIRECTION, + }; + }, [selectedOptionId]); + + const handleOptionChange = useCallback( + (optionId: SortOptionId, _field: SortField, _direction: SortDirection) => { + setSelectedOptionId(optionId); + }, + [], + ); + + const sortMarketsList = useCallback( + (markets: PerpsMarketData[]) => + sortMarkets({ + markets, + sortBy, + direction, + }), + [sortBy, direction], + ); + + return { + selectedOptionId, + sortBy, + direction, + handleOptionChange, + sortMarketsList, + }; +}; diff --git a/app/components/UI/Perps/hooks/usePerpsTrading.test.ts b/app/components/UI/Perps/hooks/usePerpsTrading.test.ts index 2c004026995e..ce9d7b68e4ae 100644 --- a/app/components/UI/Perps/hooks/usePerpsTrading.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsTrading.test.ts @@ -529,6 +529,7 @@ describe('usePerpsTrading', () => { orderType: 'market' as const, isMaker: false, amount: '100000', + coin: 'BTC', }; const response = await result.current.calculateFees(params); @@ -555,6 +556,7 @@ describe('usePerpsTrading', () => { orderType: 'limit' as const, isMaker: true, amount: '100000', + coin: 'ETH', }; const resultPromise = result.current.calculateFees(params); @@ -579,6 +581,7 @@ describe('usePerpsTrading', () => { orderType: 'market' as const, isMaker: false, amount: '100000', + coin: 'SOL', }; await expect(result.current.calculateFees(params)).rejects.toThrow( @@ -607,6 +610,7 @@ describe('usePerpsTrading', () => { orderType: 'market', isMaker: false, amount: '100000', + coin: 'BTC', }); expect(marketResult).toEqual(mockMarketFeeResult); @@ -619,6 +623,7 @@ describe('usePerpsTrading', () => { orderType: 'limit', isMaker: true, amount: '100000', + coin: 'ETH', }); expect(limitResult).toEqual(mockLimitFeeResult); }); diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.tsx index 6e6f7b010a23..ca68c2462811 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.tsx @@ -43,8 +43,15 @@ abstract class StreamChannel { protected accountAddress: string | null = null; // Track WebSocket connection timing for first data measurement protected wsConnectionStartTime: number | null = null; + // Flag to pause emission during operations (keeps WebSocket alive) + protected isPaused = false; protected notifySubscribers(updates: T) { + // Block emission if paused (WebSocket continues receiving updates) + if (this.isPaused) { + return; + } + this.subscribers.forEach((subscriber) => { // Check if this is the first update for this subscriber if (!subscriber.hasReceivedFirstUpdate) { @@ -127,6 +134,23 @@ abstract class StreamChannel { this.wsConnectionStartTime = null; } + /** + * Pause emission of updates to subscribers + * WebSocket connection stays alive and continues receiving data + * Used during batch operations to prevent UI re-renders from stale data + */ + public pause(): void { + this.isPaused = true; + } + + /** + * Resume emission of updates to subscribers + * Subscribers will receive the next update from the WebSocket + */ + public resume(): void { + this.isPaused = false; + } + protected getCachedData(): T | null { // Override in subclasses to return null for no cache, or actual data return null; @@ -814,10 +838,11 @@ class MarketDataChannel extends StreamChannel { private readonly CACHE_DURATION = PERFORMANCE_CONFIG.MARKET_DATA_CACHE_DURATION_MS; - protected async connect() { - // Wait for connection to complete if in progress - while (PerpsConnectionManager.isCurrentlyConnecting()) { - await new Promise((resolve) => setTimeout(resolve, 200)); + protected connect() { + // Check if connection manager is still connecting - retry later if so + if (PerpsConnectionManager.isCurrentlyConnecting()) { + setTimeout(() => this.connect(), 200); + return; } // Fetch if cache is stale or empty diff --git a/app/components/UI/Perps/routes/index.tsx b/app/components/UI/Perps/routes/index.tsx index 573973a46601..33c6a698cebd 100644 --- a/app/components/UI/Perps/routes/index.tsx +++ b/app/components/UI/Perps/routes/index.tsx @@ -4,13 +4,16 @@ import { strings } from '../../../../../locales/i18n'; import Routes from '../../../../constants/navigation/Routes'; import { PerpsConnectionProvider } from '../providers/PerpsConnectionProvider'; import { PerpsStreamProvider } from '../providers/PerpsStreamManager'; -import PerpsMarketListView from '../Views/PerpsMarketListView/PerpsMarketListView'; +import PerpsHomeView from '../Views/PerpsHomeView/PerpsHomeView'; import PerpsMarketDetailsView from '../Views/PerpsMarketDetailsView'; +import PerpsMarketListView from '../Views/PerpsMarketListView'; import PerpsRedirect from '../Views/PerpsRedirect'; import PerpsPositionsView from '../Views/PerpsPositionsView'; import PerpsWithdrawView from '../Views/PerpsWithdrawView'; import PerpsOrderView from '../Views/PerpsOrderView'; import PerpsClosePositionView from '../Views/PerpsClosePositionView'; +import PerpsCloseAllPositionsView from '../Views/PerpsCloseAllPositionsView/PerpsCloseAllPositionsView'; +import PerpsCancelAllOrdersView from '../Views/PerpsCancelAllOrdersView/PerpsCancelAllOrdersView'; import PerpsQuoteExpiredModal from '../components/PerpsQuoteExpiredModal'; import { Confirm } from '../../../Views/confirmations/components/confirm'; import PerpsGTMModal from '../components/PerpsGTMModal'; @@ -22,34 +25,52 @@ const Stack = createStackNavigator(); const ModalStack = createStackNavigator(); const PerpsModalStack = () => ( - - - - + + + + + + + + + + ); const PerpsScreenStack = () => ( - + {/* Redirect to wallet perps tab */} ( /> ( }} /> + + {/* Withdrawal flow screens */} state.engine.backgroundState.PerpsController; @@ -53,6 +58,22 @@ const selectIsFirstTimePerpsUser = createSelector( (perpsControllerState) => selectIsFirstTimeUser(perpsControllerState), ); +const selectPerpsWatchlistMarkets = createSelector( + selectPerpsControllerState, + (perpsControllerState) => selectWatchlistMarkets(perpsControllerState), +); + +const selectPerpsMarketFilterPreferences = createSelector( + selectPerpsControllerState, + (perpsControllerState) => selectMarketFilterPreferences(perpsControllerState), +); + +// Factory function to create selector for specific market +export const createSelectIsWatchlistMarket = (symbol: string) => + createSelector(selectPerpsControllerState, (perpsControllerState) => + selectIsWatchlistMarket(perpsControllerState, symbol), + ); + export { selectPerpsProvider, selectPerpsAccountState, @@ -61,4 +82,6 @@ export { selectPerpsNetwork, selectPerpsBalances, selectIsFirstTimePerpsUser, + selectPerpsWatchlistMarkets, + selectPerpsMarketFilterPreferences, }; diff --git a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts index 51994ece8fa8..ab1ef540b004 100644 --- a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts +++ b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts @@ -97,6 +97,8 @@ export class HyperLiquidSubscriptionService { private cachedPositions: Position[] | null = null; // Aggregated positions private cachedOrders: Order[] | null = null; // Aggregated orders private cachedAccount: AccountState | null = null; // Aggregated account + private ordersCacheInitialized = false; // Track if orders cache has received WebSocket data + private positionsCacheInitialized = false; // Track if positions cache has received WebSocket data // Global price data cache private cachedPriceData: Map | null = null; @@ -799,6 +801,7 @@ export class HyperLiquidSubscriptionService { if (positionsChanged) { this.cachedPositions = aggregatedPositions; this.cachedPositionsHash = positionsHash; + this.positionsCacheInitialized = true; // Mark cache as initialized this.positionSubscribers.forEach((callback) => { callback(aggregatedPositions); }); @@ -807,6 +810,7 @@ export class HyperLiquidSubscriptionService { if (ordersChanged) { this.cachedOrders = aggregatedOrders; this.cachedOrdersHash = ordersHash; + this.ordersCacheInitialized = true; // Mark cache as initialized this.orderSubscribers.forEach((callback) => { callback(aggregatedOrders); }); @@ -893,6 +897,8 @@ export class HyperLiquidSubscriptionService { this.cachedPositions = null; this.cachedOrders = null; this.cachedAccount = null; + this.ordersCacheInitialized = false; // Reset cache initialization flag + this.positionsCacheInitialized = false; // Reset cache initialization flag // Clear hash caches this.cachedPositionsHash = ''; @@ -1092,6 +1098,38 @@ export class HyperLiquidSubscriptionService { }; } + /** + * Check if orders cache has been initialized from WebSocket + * @returns true if WebSocket has sent at least one update, false otherwise + */ + public isOrdersCacheInitialized(): boolean { + return this.ordersCacheInitialized; + } + + /** + * Check if positions cache has been initialized from WebSocket + * @returns true if WebSocket has sent at least one update, false otherwise + */ + public isPositionsCacheInitialized(): boolean { + return this.positionsCacheInitialized; + } + + /** + * Get cached positions from WebSocket subscription + * @returns Cached positions array, or null if not initialized + */ + public getCachedPositions(): Position[] | null { + return this.cachedPositions; + } + + /** + * Get cached orders from WebSocket subscription + * @returns Cached orders array, or null if not initialized + */ + public getCachedOrders(): Order[] | null { + return this.cachedOrders; + } + /** * Create subscription with common error handling */ @@ -1702,6 +1740,7 @@ export class HyperLiquidSubscriptionService { if (positionsChanged) { this.cachedPositions = aggregatedPositions; this.cachedPositionsHash = positionsHash; + this.positionsCacheInitialized = true; // Mark cache as initialized this.positionSubscribers.forEach((callback) => { callback(aggregatedPositions); }); @@ -1710,6 +1749,7 @@ export class HyperLiquidSubscriptionService { if (ordersChanged) { this.cachedOrders = aggregatedOrders; this.cachedOrdersHash = ordersHash; + this.ordersCacheInitialized = true; // Mark cache as initialized this.orderSubscribers.forEach((callback) => { callback(aggregatedOrders); }); @@ -1885,6 +1925,8 @@ export class HyperLiquidSubscriptionService { this.cachedPositions = null; this.cachedOrders = null; this.cachedAccount = null; + this.ordersCacheInitialized = false; // Reset cache initialization flag + this.positionsCacheInitialized = false; // Reset cache initialization flag this.marketDataCache.clear(); this.orderBookCache.clear(); this.symbolSubscriberCounts.clear(); diff --git a/app/components/UI/Perps/styles/sharedStyles.ts b/app/components/UI/Perps/styles/sharedStyles.ts new file mode 100644 index 000000000000..8077d87facdb --- /dev/null +++ b/app/components/UI/Perps/styles/sharedStyles.ts @@ -0,0 +1,26 @@ +import { StyleSheet } from 'react-native'; +import type { Theme } from '../../../../util/theme/models'; + +/** + * Shared styles for PerpsWatchlistMarkets component + */ +export const createMarketListStyles = (_theme: Theme) => + StyleSheet.create({ + container: { + marginBottom: 16, + paddingBottom: 16, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, + paddingHorizontal: 16, + }, + listContent: { + paddingHorizontal: 16, + }, + emptyText: { + paddingHorizontal: 16, + }, + }); diff --git a/app/components/UI/Perps/types/navigation.ts b/app/components/UI/Perps/types/navigation.ts index 2b505e7a6b39..2c80d15b1249 100644 --- a/app/components/UI/Perps/types/navigation.ts +++ b/app/components/UI/Perps/types/navigation.ts @@ -68,6 +68,18 @@ export interface PerpsNavigationParamList extends ParamListBase { PerpsMarketListView: { source?: string; + variant?: 'full' | 'minimal'; + title?: string; + showBalanceActions?: boolean; + showBottomNav?: boolean; + defaultSearchVisible?: boolean; + showWatchlistOnly?: boolean; + defaultMarketTypeFilter?: + | 'crypto' + | 'equity' + | 'commodity' + | 'forex' + | 'all'; }; PerpsMarketDetails: { diff --git a/app/components/UI/Perps/utils/formatUtils.ts b/app/components/UI/Perps/utils/formatUtils.ts index b19e350948b6..3470a1fab1bb 100644 --- a/app/components/UI/Perps/utils/formatUtils.ts +++ b/app/components/UI/Perps/utils/formatUtils.ts @@ -472,7 +472,7 @@ export const formatPnl = (pnl: string | number): string => { const num = typeof pnl === 'string' ? parseFloat(pnl) : pnl; if (isNaN(num)) { - return '$0.00'; + return PERPS_CONSTANTS.ZERO_AMOUNT_DETAILED_DISPLAY; } const formatted = getIntlNumberFormatter('en-US', { diff --git a/app/components/UI/Perps/utils/marketDataTransform.test.ts b/app/components/UI/Perps/utils/marketDataTransform.test.ts index bf738472c893..667095a5b6bc 100644 --- a/app/components/UI/Perps/utils/marketDataTransform.test.ts +++ b/app/components/UI/Perps/utils/marketDataTransform.test.ts @@ -13,6 +13,7 @@ import { formatPerpsFiat, PRICE_RANGES_UNIVERSAL, } from './formatUtils'; +import { HIP3_ASSET_MARKET_TYPES } from '../constants/hyperLiquidConfig'; import type { AllMidsResponse, PerpsAssetCtx, @@ -77,6 +78,7 @@ describe('marketDataTransform', () => { fundingRate: 0.01, marketSource: undefined, // Main DEX has no source marketType: undefined, // Main DEX has no type + openInterest: '$1.00M', }); }); @@ -304,7 +306,7 @@ describe('marketDataTransform', () => { expect(result[0].fundingIntervalHours).toBeUndefined(); }); - it('extracts marketSource and marketType for HIP-3 DEX assets', () => { + it('extracts marketSource and marketType for HIP-3 equity assets', () => { const xyzAsset = { name: 'xyz:XYZ100', maxLeverage: 20, @@ -318,7 +320,10 @@ describe('marketDataTransform', () => { allMids: { 'xyz:XYZ100': '105' }, }; - const result = transformMarketData(hyperLiquidData); + const result = transformMarketData( + hyperLiquidData, + HIP3_ASSET_MARKET_TYPES, + ); expect(result).toHaveLength(1); expect(result[0].symbol).toBe('xyz:XYZ100'); @@ -326,6 +331,31 @@ describe('marketDataTransform', () => { expect(result[0].marketType).toBe('equity'); }); + it('extracts marketSource and marketType for HIP-3 commodity assets', () => { + const goldAsset = { + name: 'xyz:GOLD', + maxLeverage: 20, + szDecimals: 2, + marginTableId: 0, + }; + const goldAssetCtx = createMockAssetCtx({ prevDayPx: '2000' }); + const hyperLiquidData: HyperLiquidMarketData = { + universe: [goldAsset], + assetCtxs: [goldAssetCtx], + allMids: { 'xyz:GOLD': '2050' }, + }; + + const result = transformMarketData( + hyperLiquidData, + HIP3_ASSET_MARKET_TYPES, + ); + + expect(result).toHaveLength(1); + expect(result[0].symbol).toBe('xyz:GOLD'); + expect(result[0].marketSource).toBe('xyz'); + expect(result[0].marketType).toBe('commodity'); + }); + it('handles unmapped HIP-3 DEX with marketSource but no marketType', () => { const unknownDexAsset = { name: 'unknown:ASSET1', diff --git a/app/components/UI/Perps/utils/marketDataTransform.ts b/app/components/UI/Perps/utils/marketDataTransform.ts index eda5a74595e6..3e9c1357dedc 100644 --- a/app/components/UI/Perps/utils/marketDataTransform.ts +++ b/app/components/UI/Perps/utils/marketDataTransform.ts @@ -5,10 +5,7 @@ import type { PredictedFunding, } from '../types/hyperliquid-types'; import { PERPS_CONSTANTS } from '../constants/perpsConfig'; -import { - HYPERLIQUID_CONFIG, - HIP3_DEX_MARKET_TYPES, -} from '../constants/hyperLiquidConfig'; +import { HYPERLIQUID_CONFIG } from '../constants/hyperLiquidConfig'; import type { PerpsMarketData, MarketType } from '../controllers/types'; import { formatVolume, @@ -138,6 +135,7 @@ function extractFundingData(params: ExtractFundingDataParams): FundingData { */ export function transformMarketData( hyperLiquidData: HyperLiquidMarketData, + assetMarketTypes?: Record, ): PerpsMarketData[] { const { universe, assetCtxs, allMids, predictedFundings } = hyperLiquidData; @@ -172,6 +170,12 @@ export function transformMarketData( // If assetCtx is missing or dayNtlVlm is not available, use NaN to indicate missing data const volume = assetCtx?.dayNtlVlm ? parseFloat(assetCtx.dayNtlVlm) : NaN; + // Format open interest + // If assetCtx is missing or openInterest is not available, use NaN to indicate missing data + const openInterest = assetCtx?.openInterest + ? parseFloat(assetCtx.openInterest) + : NaN; + // Get current funding rate from assetCtx - this is the actual current funding rate let fundingRate: number | undefined; @@ -194,9 +198,9 @@ export function transformMarketData( // Extract DEX and determine market type for badge display const { dex } = parseAssetName(symbol); const marketSource = dex || undefined; - const marketType: MarketType | undefined = dex - ? HIP3_DEX_MARKET_TYPES[dex as keyof typeof HIP3_DEX_MARKET_TYPES] - : undefined; + + // Simple per-asset lookup from feature flag (e.g., 'xyz:GOLD' → 'commodity') + const marketType: MarketType | undefined = assetMarketTypes?.[symbol]; return { symbol, @@ -205,13 +209,18 @@ export function transformMarketData( price: isNaN(currentPrice) ? PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY : formatPerpsFiat(currentPrice, { ranges: PRICE_RANGES_UNIVERSAL }), - change24h: isNaN(change24h) ? '$0.00' : formatChange(change24h), + change24h: isNaN(change24h) + ? PERPS_CONSTANTS.ZERO_AMOUNT_DETAILED_DISPLAY + : formatChange(change24h), change24hPercent: isNaN(change24hPercent) ? '0.00%' : formatPercentage(change24hPercent), volume: isNaN(volume) ? PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY : formatVolume(volume), + openInterest: isNaN(openInterest) + ? PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY + : formatVolume(openInterest), nextFundingTime: fundingData.nextFundingTime, fundingIntervalHours: fundingData.fundingIntervalHours, fundingRate, diff --git a/app/components/UI/Perps/utils/orderUtils.test.ts b/app/components/UI/Perps/utils/orderUtils.test.ts index 0a9922a69b8e..307d274c9aaf 100644 --- a/app/components/UI/Perps/utils/orderUtils.test.ts +++ b/app/components/UI/Perps/utils/orderUtils.test.ts @@ -1,5 +1,26 @@ -import { formatOrderLabel, getOrderLabelDirection } from './orderUtils'; -import type { Order } from '../controllers/types'; +import { + formatOrderLabel, + getOrderLabelDirection, + getOrderDirection, + willFlipPosition, + determineMakerStatus, +} from './orderUtils'; +import { Order, OrderParams } from '../controllers/types'; +import { Position } from '../hooks'; + +// Mock i18n strings +jest.mock('../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => key), +})); + +// Mock DevLogger +jest.mock('../../../../core/SDKConnect/utils/DevLogger', () => ({ + DevLogger: { + log: jest.fn(), + }, +})); + +import { strings } from '../../../../../locales/i18n'; describe('orderUtils', () => { describe('formatOrderLabel', () => { @@ -267,4 +288,354 @@ describe('orderUtils', () => { expect(getOrderLabelDirection(order)).toBe('Close Short'); }); }); + + describe('getOrderDirection', () => { + const mockStrings = strings as jest.MockedFunction; + + it('should return long for buy with no position', () => { + mockStrings.mockImplementation((key: string) => { + if (key === 'perps.market.long') return 'Long'; + if (key === 'perps.market.short') return 'Short'; + return key; + }); + + const result = getOrderDirection('buy', undefined); + expect(result).toBe('Long'); + }); + + it('should return short for sell with no position', () => { + mockStrings.mockImplementation((key: string) => { + if (key === 'perps.market.long') return 'Long'; + if (key === 'perps.market.short') return 'Short'; + return key; + }); + + const result = getOrderDirection('sell', undefined); + expect(result).toBe('Short'); + }); + + it('should return long for positive position', () => { + mockStrings.mockImplementation((key: string) => { + if (key === 'perps.market.long') return 'Long'; + if (key === 'perps.market.short') return 'Short'; + return key; + }); + + const result = getOrderDirection('sell', '1.5'); + expect(result).toBe('Long'); + }); + + it('should return short for negative position', () => { + mockStrings.mockImplementation((key: string) => { + if (key === 'perps.market.long') return 'Long'; + if (key === 'perps.market.short') return 'Short'; + return key; + }); + + const result = getOrderDirection('buy', '-1.5'); + expect(result).toBe('Short'); + }); + }); + + describe('willFlipPosition', () => { + const mockPosition: Position = { + size: '100', + entryPrice: '50000', + unrealizedPnl: '100', + liquidationPrice: '40000', + leverage: { type: 'isolated', value: 2 }, + coin: 'BTC', + positionValue: '100000', + marginUsed: '2500', + maxLeverage: 20, + returnOnEquity: '0', + cumulativeFunding: { + allTime: '0', + sinceOpen: '0', + sinceChange: '0', + }, + takeProfitCount: 0, + stopLossCount: 0, + }; + + const mockOrderParams: OrderParams = { + coin: 'BTC', + isBuy: false, + size: '150', + orderType: 'market', + reduceOnly: false, + }; + + it('returns false when reduceOnly is true', () => { + const orderParams = { ...mockOrderParams, reduceOnly: true }; + const result = willFlipPosition(mockPosition, orderParams); + expect(result).toBe(false); + }); + + it('returns false when orderType is not market', () => { + const orderParams = { + ...mockOrderParams, + orderType: 'limit' as OrderParams['orderType'], + }; + const result = willFlipPosition(mockPosition, orderParams); + expect(result).toBe(false); + }); + + it('returns false when position and order direction match', () => { + const orderParams = { ...mockOrderParams, isBuy: true }; + const result = willFlipPosition(mockPosition, orderParams); + expect(result).toBe(false); + }); + + it('returns true when order size exceeds absolute position size', () => { + const result = willFlipPosition(mockPosition, mockOrderParams); + expect(result).toBe(true); + }); + + it('returns false when order size does not exceed absolute position size', () => { + const orderParams = { ...mockOrderParams, size: '50' }; + const result = willFlipPosition(mockPosition, orderParams); + expect(result).toBe(false); + }); + + it('returns false when order size equals position size', () => { + const currentPosition: Position = { + coin: 'BTC', + size: '0.5', + entryPrice: '45000', + positionValue: '22500', + unrealizedPnl: '0', + marginUsed: '500', + leverage: { type: 'isolated', value: 5 }, + liquidationPrice: '40000', + maxLeverage: 20, + returnOnEquity: '0', + cumulativeFunding: { + allTime: '0', + sinceOpen: '0', + sinceChange: '0', + }, + takeProfitCount: 0, + stopLossCount: 0, + }; + + const orderParams: OrderParams = { + coin: 'BTC', + isBuy: false, + size: '0.5', + orderType: 'market', + }; + + const result = willFlipPosition(currentPosition, orderParams); + expect(result).toBe(false); + }); + + it('handles negative position sizes correctly', () => { + const currentPosition: Position = { + coin: 'BTC', + size: '-0.5', + entryPrice: '45000', + positionValue: '22500', + unrealizedPnl: '0', + marginUsed: '500', + leverage: { type: 'isolated', value: 5 }, + liquidationPrice: '40000', + maxLeverage: 20, + returnOnEquity: '0', + cumulativeFunding: { + allTime: '0', + sinceOpen: '0', + sinceChange: '0', + }, + takeProfitCount: 0, + stopLossCount: 0, + }; + + const orderParams: OrderParams = { + coin: 'BTC', + isBuy: true, + size: '1.0', + orderType: 'market', + }; + + const result = willFlipPosition(currentPosition, orderParams); + expect(result).toBe(true); + }); + }); + + describe('determineMakerStatus', () => { + describe('Market Orders', () => { + it('treats market orders as taker regardless of price', () => { + const result = determineMakerStatus({ + orderType: 'market', + direction: 'long', + limitPrice: '50000', + bestAsk: 50001, + bestBid: 49999, + coin: 'BTC', + }); + + expect(result).toBe(false); + }); + }); + + describe('Limit Orders - Long Direction', () => { + it('treats buy limit above ask as taker', () => { + const result = determineMakerStatus({ + orderType: 'limit', + direction: 'long', + limitPrice: '50100', + bestAsk: 50001, + bestBid: 49999, + coin: 'BTC', + }); + + expect(result).toBe(false); + }); + + it('treats buy limit at ask price as taker', () => { + const result = determineMakerStatus({ + orderType: 'limit', + direction: 'long', + limitPrice: '50001', + bestAsk: 50001, + bestBid: 49999, + coin: 'BTC', + }); + + expect(result).toBe(false); + }); + + it('treats buy limit below ask as maker', () => { + const result = determineMakerStatus({ + orderType: 'limit', + direction: 'long', + limitPrice: '49500', + bestAsk: 50001, + bestBid: 49999, + coin: 'BTC', + }); + + expect(result).toBe(true); + }); + }); + + describe('Limit Orders - Short Direction', () => { + it('treats sell limit below bid as taker', () => { + const result = determineMakerStatus({ + orderType: 'limit', + direction: 'short', + limitPrice: '49900', + bestAsk: 50001, + bestBid: 49999, + coin: 'BTC', + }); + + expect(result).toBe(false); + }); + + it('treats sell limit at bid price as taker', () => { + const result = determineMakerStatus({ + orderType: 'limit', + direction: 'short', + limitPrice: '49999', + bestAsk: 50001, + bestBid: 49999, + coin: 'BTC', + }); + + expect(result).toBe(false); + }); + + it('treats sell limit above bid as maker', () => { + const result = determineMakerStatus({ + orderType: 'limit', + direction: 'short', + limitPrice: '50500', + bestAsk: 50001, + bestBid: 49999, + coin: 'BTC', + }); + + expect(result).toBe(true); + }); + }); + + describe('Edge Cases', () => { + it('defaults to taker when limitPrice is missing', () => { + const result = determineMakerStatus({ + orderType: 'limit', + direction: 'long', + bestAsk: 50001, + bestBid: 49999, + coin: 'BTC', + }); + + expect(result).toBe(false); + }); + + it('defaults to taker when limitPrice is empty string', () => { + const result = determineMakerStatus({ + orderType: 'limit', + direction: 'long', + limitPrice: '', + bestAsk: 50001, + bestBid: 49999, + coin: 'BTC', + }); + + expect(result).toBe(false); + }); + + it('defaults to taker when limitPrice is NaN', () => { + const result = determineMakerStatus({ + orderType: 'limit', + direction: 'long', + limitPrice: 'invalid', + bestAsk: 50001, + bestBid: 49999, + coin: 'BTC', + }); + + expect(result).toBe(false); + }); + + it('defaults to taker when limitPrice is zero', () => { + const result = determineMakerStatus({ + orderType: 'limit', + direction: 'long', + limitPrice: '0', + bestAsk: 50001, + bestBid: 49999, + coin: 'BTC', + }); + + expect(result).toBe(false); + }); + + it('defaults to taker when limitPrice is negative', () => { + const result = determineMakerStatus({ + orderType: 'limit', + direction: 'long', + limitPrice: '-1000', + bestAsk: 50001, + bestBid: 49999, + coin: 'BTC', + }); + + expect(result).toBe(false); + }); + + it('defaults to taker when bid/ask data is unavailable', () => { + const result = determineMakerStatus({ + orderType: 'limit', + direction: 'long', + limitPrice: '49500', + coin: 'BTC', + }); + + expect(result).toBe(false); + }); + }); + }); }); diff --git a/app/components/UI/Perps/utils/orderUtils.ts b/app/components/UI/Perps/utils/orderUtils.ts index 5540437b1a6b..1fe29fe08a16 100644 --- a/app/components/UI/Perps/utils/orderUtils.ts +++ b/app/components/UI/Perps/utils/orderUtils.ts @@ -1,6 +1,7 @@ import { strings } from '../../../../../locales/i18n'; import { OrderParams, Order } from '../controllers/types'; import { Position } from '../hooks'; +import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; import { capitalize } from 'lodash'; /** @@ -123,3 +124,57 @@ export const getOrderLabelDirection = (order: Order): string => { // For opening orders: buy is long, sell is short return side === 'buy' ? 'long' : 'short'; }; + +/** + * Determines if a limit order will likely be a maker or taker + * + * Logic: + * 1. Validates price data freshness and market state + * 2. Market orders are always taker + * 3. Limit orders that would execute immediately are taker + * 4. Limit orders that go into order book are maker + * + * @param params Order parameters + * @returns boolean - true if maker, false if taker + */ +export function determineMakerStatus(params: { + orderType: 'market' | 'limit'; + limitPrice?: string; + direction: 'long' | 'short'; + bestAsk?: number; + bestBid?: number; + coin?: string; +}): boolean { + const { orderType, limitPrice, direction, bestAsk, bestBid, coin } = params; + // Market orders are always taker + if (orderType === 'market') { + return false; + } + + // Default to taker when limit price is not specified + if (!limitPrice || limitPrice === '') { + return false; + } + + const limitPriceNum = Number.parseFloat(limitPrice); + + if (Number.isNaN(limitPriceNum) || limitPriceNum <= 0) { + return false; + } + + if (bestBid !== undefined && bestAsk !== undefined) { + if (direction === 'long') { + return limitPriceNum < bestAsk; + } + + // Short direction + return limitPriceNum > bestBid; + } + + // Default to taker when no bid/ask data is available + DevLogger.log( + 'Fee Calculation: No bid/ask data available, using conservative taker fee', + { coin }, + ); + return false; +} diff --git a/app/components/UI/Perps/utils/sortMarkets.test.ts b/app/components/UI/Perps/utils/sortMarkets.test.ts new file mode 100644 index 000000000000..9fa80aea318d --- /dev/null +++ b/app/components/UI/Perps/utils/sortMarkets.test.ts @@ -0,0 +1,544 @@ +import type { PerpsMarketData } from '../controllers/types'; +import { parseVolume } from '../hooks/usePerpsMarkets'; +import { sortMarkets, type SortDirection, type SortField } from './sortMarkets'; + +// Mock dependencies +jest.mock('../hooks/usePerpsMarkets', () => ({ + parseVolume: jest.fn(), +})); + +const mockParseVolume = parseVolume as jest.MockedFunction; + +describe('sortMarkets', () => { + const createMockMarket = ( + overrides: Partial = {}, + ): PerpsMarketData => + ({ + symbol: 'BTC', + volume: '$1M', + change24hPercent: '+2.5%', + fundingRate: 0.01, + openInterest: '$500K', + price: '$50000', + ...overrides, + } as PerpsMarketData); + + beforeEach(() => { + jest.clearAllMocks(); + // Default mock implementation for parseVolume + mockParseVolume.mockImplementation((value: string | undefined) => { + if (!value) return -1; + if (value === '$1M') return 1000000; + if (value === '$2M') return 2000000; + if (value === '$500K') return 500000; + if (value === '$750K') return 750000; + if (value === '$1B') return 1000000000; + return 0; + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('volume sorting', () => { + it('sorts markets by volume in descending order by default', () => { + const markets = [ + createMockMarket({ symbol: 'BTC', volume: '$1M' }), + createMockMarket({ symbol: 'ETH', volume: '$2M' }), + createMockMarket({ symbol: 'SOL', volume: '$500K' }), + ]; + + const result = sortMarkets({ + markets, + sortBy: 'volume' as SortField, + }); + + expect(result[0].symbol).toBe('ETH'); + expect(result[1].symbol).toBe('BTC'); + expect(result[2].symbol).toBe('SOL'); + expect(mockParseVolume).toHaveBeenCalledWith('$2M'); + expect(mockParseVolume).toHaveBeenCalledWith('$1M'); + expect(mockParseVolume).toHaveBeenCalledWith('$500K'); + }); + + it('sorts markets by volume in ascending order when specified', () => { + const markets = [ + createMockMarket({ symbol: 'BTC', volume: '$1M' }), + createMockMarket({ symbol: 'ETH', volume: '$2M' }), + createMockMarket({ symbol: 'SOL', volume: '$500K' }), + ]; + + const result = sortMarkets({ + markets, + sortBy: 'volume' as SortField, + direction: 'asc' as SortDirection, + }); + + expect(result[0].symbol).toBe('SOL'); + expect(result[1].symbol).toBe('BTC'); + expect(result[2].symbol).toBe('ETH'); + }); + + it('places markets with undefined volume at the end', () => { + mockParseVolume.mockImplementation((value: string | undefined) => { + if (!value) return -1; + if (value === '$1M') return 1000000; + return 0; + }); + + const markets = [ + createMockMarket({ symbol: 'BTC', volume: '$1M' }), + createMockMarket({ symbol: 'ETH', volume: undefined }), + createMockMarket({ symbol: 'SOL', volume: '$500K' }), + ]; + + const result = sortMarkets({ + markets, + sortBy: 'volume' as SortField, + }); + + expect(result[2].symbol).toBe('ETH'); + }); + + it('handles markets with zero volume', () => { + mockParseVolume.mockImplementation((value: string | undefined) => { + if (value === '$0') return 0; + if (value === '$1M') return 1000000; + return -1; + }); + + const markets = [ + createMockMarket({ symbol: 'BTC', volume: '$1M' }), + createMockMarket({ symbol: 'ETH', volume: '$0' }), + ]; + + const result = sortMarkets({ + markets, + sortBy: 'volume' as SortField, + }); + + expect(result[0].symbol).toBe('BTC'); + expect(result[1].symbol).toBe('ETH'); + }); + }); + + describe('price change sorting', () => { + it('sorts markets by price change in descending order by default', () => { + const markets = [ + createMockMarket({ symbol: 'BTC', change24hPercent: '+2.5%' }), + createMockMarket({ symbol: 'ETH', change24hPercent: '+5.0%' }), + createMockMarket({ symbol: 'SOL', change24hPercent: '-1.8%' }), + ]; + + const result = sortMarkets({ + markets, + sortBy: 'priceChange' as SortField, + }); + + expect(result[0].symbol).toBe('ETH'); + expect(result[1].symbol).toBe('BTC'); + expect(result[2].symbol).toBe('SOL'); + }); + + it('sorts markets by price change in ascending order when specified', () => { + const markets = [ + createMockMarket({ symbol: 'BTC', change24hPercent: '+2.5%' }), + createMockMarket({ symbol: 'ETH', change24hPercent: '+5.0%' }), + createMockMarket({ symbol: 'SOL', change24hPercent: '-1.8%' }), + ]; + + const result = sortMarkets({ + markets, + sortBy: 'priceChange' as SortField, + direction: 'asc' as SortDirection, + }); + + expect(result[0].symbol).toBe('SOL'); + expect(result[1].symbol).toBe('BTC'); + expect(result[2].symbol).toBe('ETH'); + }); + + it('handles negative price changes correctly', () => { + const markets = [ + createMockMarket({ symbol: 'BTC', change24hPercent: '-2.5%' }), + createMockMarket({ symbol: 'ETH', change24hPercent: '-5.0%' }), + createMockMarket({ symbol: 'SOL', change24hPercent: '-1.8%' }), + ]; + + const result = sortMarkets({ + markets, + sortBy: 'priceChange' as SortField, + }); + + expect(result[0].symbol).toBe('SOL'); + expect(result[1].symbol).toBe('BTC'); + expect(result[2].symbol).toBe('ETH'); + }); + + it('handles markets with missing price change', () => { + const markets = [ + createMockMarket({ symbol: 'BTC', change24hPercent: '+2.5%' }), + createMockMarket({ symbol: 'ETH', change24hPercent: undefined }), + createMockMarket({ symbol: 'SOL', change24hPercent: '-1.8%' }), + ]; + + const result = sortMarkets({ + markets, + sortBy: 'priceChange' as SortField, + }); + + expect(result[0].symbol).toBe('BTC'); + expect(result[1].symbol).toBe('ETH'); + expect(result[2].symbol).toBe('SOL'); + }); + + it('removes + sign when parsing price change', () => { + const markets = [ + createMockMarket({ symbol: 'BTC', change24hPercent: '+10.0%' }), + createMockMarket({ symbol: 'ETH', change24hPercent: '5.0%' }), + ]; + + const result = sortMarkets({ + markets, + sortBy: 'priceChange' as SortField, + }); + + expect(result[0].symbol).toBe('BTC'); + expect(result[1].symbol).toBe('ETH'); + }); + + it('handles zero price change', () => { + const markets = [ + createMockMarket({ symbol: 'BTC', change24hPercent: '+2.5%' }), + createMockMarket({ symbol: 'ETH', change24hPercent: '0%' }), + createMockMarket({ symbol: 'SOL', change24hPercent: '-1.8%' }), + ]; + + const result = sortMarkets({ + markets, + sortBy: 'priceChange' as SortField, + }); + + expect(result[0].symbol).toBe('BTC'); + expect(result[1].symbol).toBe('ETH'); + expect(result[2].symbol).toBe('SOL'); + }); + }); + + describe('funding rate sorting', () => { + it('sorts markets by funding rate in descending order by default', () => { + const markets = [ + createMockMarket({ symbol: 'BTC', fundingRate: 0.01 }), + createMockMarket({ symbol: 'ETH', fundingRate: 0.05 }), + createMockMarket({ symbol: 'SOL', fundingRate: -0.02 }), + ]; + + const result = sortMarkets({ + markets, + sortBy: 'fundingRate' as SortField, + }); + + expect(result[0].symbol).toBe('ETH'); + expect(result[1].symbol).toBe('BTC'); + expect(result[2].symbol).toBe('SOL'); + }); + + it('sorts markets by funding rate in ascending order when specified', () => { + const markets = [ + createMockMarket({ symbol: 'BTC', fundingRate: 0.01 }), + createMockMarket({ symbol: 'ETH', fundingRate: 0.05 }), + createMockMarket({ symbol: 'SOL', fundingRate: -0.02 }), + ]; + + const result = sortMarkets({ + markets, + sortBy: 'fundingRate' as SortField, + direction: 'asc' as SortDirection, + }); + + expect(result[0].symbol).toBe('SOL'); + expect(result[1].symbol).toBe('BTC'); + expect(result[2].symbol).toBe('ETH'); + }); + + it('handles negative funding rates correctly', () => { + const markets = [ + createMockMarket({ symbol: 'BTC', fundingRate: -0.01 }), + createMockMarket({ symbol: 'ETH', fundingRate: -0.05 }), + createMockMarket({ symbol: 'SOL', fundingRate: -0.02 }), + ]; + + const result = sortMarkets({ + markets, + sortBy: 'fundingRate' as SortField, + }); + + expect(result[0].symbol).toBe('BTC'); + expect(result[1].symbol).toBe('SOL'); + expect(result[2].symbol).toBe('ETH'); + }); + + it('handles markets with undefined funding rate as zero', () => { + const markets = [ + createMockMarket({ symbol: 'BTC', fundingRate: 0.01 }), + createMockMarket({ symbol: 'ETH', fundingRate: undefined }), + createMockMarket({ symbol: 'SOL', fundingRate: -0.02 }), + ]; + + const result = sortMarkets({ + markets, + sortBy: 'fundingRate' as SortField, + }); + + expect(result[0].symbol).toBe('BTC'); + expect(result[1].symbol).toBe('ETH'); + expect(result[2].symbol).toBe('SOL'); + }); + + it('handles zero funding rate', () => { + const markets = [ + createMockMarket({ symbol: 'BTC', fundingRate: 0.01 }), + createMockMarket({ symbol: 'ETH', fundingRate: 0 }), + createMockMarket({ symbol: 'SOL', fundingRate: -0.01 }), + ]; + + const result = sortMarkets({ + markets, + sortBy: 'fundingRate' as SortField, + }); + + expect(result[0].symbol).toBe('BTC'); + expect(result[1].symbol).toBe('ETH'); + expect(result[2].symbol).toBe('SOL'); + }); + }); + + describe('open interest sorting', () => { + it('sorts markets by open interest in descending order by default', () => { + const markets = [ + createMockMarket({ symbol: 'BTC', openInterest: '$1M' }), + createMockMarket({ symbol: 'ETH', openInterest: '$2M' }), + createMockMarket({ symbol: 'SOL', openInterest: '$500K' }), + ]; + + const result = sortMarkets({ + markets, + sortBy: 'openInterest' as SortField, + }); + + expect(result[0].symbol).toBe('ETH'); + expect(result[1].symbol).toBe('BTC'); + expect(result[2].symbol).toBe('SOL'); + expect(mockParseVolume).toHaveBeenCalledWith('$2M'); + expect(mockParseVolume).toHaveBeenCalledWith('$1M'); + expect(mockParseVolume).toHaveBeenCalledWith('$500K'); + }); + + it('sorts markets by open interest in ascending order when specified', () => { + const markets = [ + createMockMarket({ symbol: 'BTC', openInterest: '$1M' }), + createMockMarket({ symbol: 'ETH', openInterest: '$2M' }), + createMockMarket({ symbol: 'SOL', openInterest: '$500K' }), + ]; + + const result = sortMarkets({ + markets, + sortBy: 'openInterest' as SortField, + direction: 'asc' as SortDirection, + }); + + expect(result[0].symbol).toBe('SOL'); + expect(result[1].symbol).toBe('BTC'); + expect(result[2].symbol).toBe('ETH'); + }); + + it('handles markets with undefined open interest', () => { + mockParseVolume.mockImplementation((value: string | undefined) => { + if (!value) return -1; + if (value === '$1M') return 1000000; + return 0; + }); + + const markets = [ + createMockMarket({ symbol: 'BTC', openInterest: '$1M' }), + createMockMarket({ symbol: 'ETH', openInterest: undefined }), + ]; + + const result = sortMarkets({ + markets, + sortBy: 'openInterest' as SortField, + }); + + expect(result[0].symbol).toBe('BTC'); + expect(result[1].symbol).toBe('ETH'); + }); + + it('handles large open interest values', () => { + const markets = [ + createMockMarket({ symbol: 'BTC', openInterest: '$1B' }), + createMockMarket({ symbol: 'ETH', openInterest: '$2M' }), + ]; + + const result = sortMarkets({ + markets, + sortBy: 'openInterest' as SortField, + }); + + expect(result[0].symbol).toBe('BTC'); + expect(result[1].symbol).toBe('ETH'); + }); + }); + + describe('edge cases', () => { + it('returns empty array when given empty array', () => { + const result = sortMarkets({ + markets: [], + sortBy: 'volume' as SortField, + }); + + expect(result).toEqual([]); + }); + + it('returns single market unchanged', () => { + const markets = [createMockMarket({ symbol: 'BTC' })]; + + const result = sortMarkets({ + markets, + sortBy: 'volume' as SortField, + }); + + expect(result).toHaveLength(1); + expect(result[0].symbol).toBe('BTC'); + }); + + it('maintains order when all values are equal', () => { + const markets = [ + createMockMarket({ symbol: 'BTC', fundingRate: 0.01 }), + createMockMarket({ symbol: 'ETH', fundingRate: 0.01 }), + createMockMarket({ symbol: 'SOL', fundingRate: 0.01 }), + ]; + + const result = sortMarkets({ + markets, + sortBy: 'fundingRate' as SortField, + }); + + expect(result).toHaveLength(3); + expect(result[0].fundingRate).toBe(0.01); + expect(result[1].fundingRate).toBe(0.01); + expect(result[2].fundingRate).toBe(0.01); + }); + + it('does not mutate original array', () => { + const markets = [ + createMockMarket({ symbol: 'BTC', fundingRate: 0.01 }), + createMockMarket({ symbol: 'ETH', fundingRate: 0.05 }), + ]; + const originalOrder = markets.map((m) => m.symbol); + + sortMarkets({ + markets, + sortBy: 'fundingRate' as SortField, + }); + + expect(markets.map((m) => m.symbol)).toEqual(originalOrder); + }); + + it('maintains original order for unsupported sort field', () => { + const markets = [ + createMockMarket({ symbol: 'BTC' }), + createMockMarket({ symbol: 'ETH' }), + createMockMarket({ symbol: 'SOL' }), + ]; + + const result = sortMarkets({ + markets, + sortBy: 'unsupported' as SortField, + }); + + expect(result[0].symbol).toBe('BTC'); + expect(result[1].symbol).toBe('ETH'); + expect(result[2].symbol).toBe('SOL'); + }); + }); + + describe('complex scenarios', () => { + it('handles mixed positive and negative values for all sort types', () => { + const markets = [ + createMockMarket({ + symbol: 'BTC', + change24hPercent: '+5.0%', + fundingRate: 0.01, + }), + createMockMarket({ + symbol: 'ETH', + change24hPercent: '-3.0%', + fundingRate: -0.02, + }), + createMockMarket({ + symbol: 'SOL', + change24hPercent: '+2.0%', + fundingRate: 0.03, + }), + ]; + + const priceChangeResult = sortMarkets({ + markets, + sortBy: 'priceChange' as SortField, + }); + + expect(priceChangeResult[0].symbol).toBe('BTC'); + expect(priceChangeResult[2].symbol).toBe('ETH'); + + const fundingRateResult = sortMarkets({ + markets, + sortBy: 'fundingRate' as SortField, + }); + + expect(fundingRateResult[0].symbol).toBe('SOL'); + expect(fundingRateResult[2].symbol).toBe('ETH'); + }); + + it('handles very large numbers correctly', () => { + mockParseVolume.mockImplementation((value: string | undefined) => { + if (value === '$1B') return 1000000000; + if (value === '$999M') return 999000000; + if (value === '$1.001B') return 1001000000; + return 0; + }); + + const markets = [ + createMockMarket({ symbol: 'BTC', volume: '$1B' }), + createMockMarket({ symbol: 'ETH', volume: '$1.001B' }), + createMockMarket({ symbol: 'SOL', volume: '$999M' }), + ]; + + const result = sortMarkets({ + markets, + sortBy: 'volume' as SortField, + }); + + expect(result[0].symbol).toBe('ETH'); + expect(result[1].symbol).toBe('BTC'); + expect(result[2].symbol).toBe('SOL'); + }); + + it('handles very small decimal values for funding rate', () => { + const markets = [ + createMockMarket({ symbol: 'BTC', fundingRate: 0.0001 }), + createMockMarket({ symbol: 'ETH', fundingRate: 0.00001 }), + createMockMarket({ symbol: 'SOL', fundingRate: 0.001 }), + ]; + + const result = sortMarkets({ + markets, + sortBy: 'fundingRate' as SortField, + }); + + expect(result[0].symbol).toBe('SOL'); + expect(result[1].symbol).toBe('BTC'); + expect(result[2].symbol).toBe('ETH'); + }); + }); +}); diff --git a/app/components/UI/Perps/utils/sortMarkets.ts b/app/components/UI/Perps/utils/sortMarkets.ts new file mode 100644 index 000000000000..dd7d186136a2 --- /dev/null +++ b/app/components/UI/Perps/utils/sortMarkets.ts @@ -0,0 +1,82 @@ +import type { PerpsMarketData } from '../controllers/types'; +import { MARKET_SORTING_CONFIG } from '../constants/perpsConfig'; +import { parseVolume } from '../hooks/usePerpsMarkets'; + +export type SortField = + | 'volume' + | 'priceChange' + | 'fundingRate' + | 'openInterest'; +export type SortDirection = 'asc' | 'desc'; + +interface SortMarketsParams { + markets: PerpsMarketData[]; + sortBy: SortField; + direction?: SortDirection; +} + +/** + * Sorts markets based on the specified criteria + * Uses object parameters pattern for maintainability + */ +export const sortMarkets = ({ + markets, + sortBy, + direction = MARKET_SORTING_CONFIG.DEFAULT_DIRECTION, +}: SortMarketsParams): PerpsMarketData[] => { + const sortedMarkets = [...markets]; + + sortedMarkets.sort((a, b) => { + let compareValue = 0; + + switch (sortBy) { + case MARKET_SORTING_CONFIG.SORT_FIELDS.VOLUME: { + // Parse volume strings with magnitude suffixes (e.g., '$1.2B', '$850M') + const volumeA = parseVolume(a.volume); + const volumeB = parseVolume(b.volume); + compareValue = volumeA - volumeB; + break; + } + + case MARKET_SORTING_CONFIG.SORT_FIELDS.PRICE_CHANGE: { + // Use 24h price change percentage (e.g., '+2.5%', '-1.8%') + // Parse and remove % sign + const changeA = parseFloat( + a.change24hPercent?.replace(/[%+]/g, '') || '0', + ); + const changeB = parseFloat( + b.change24hPercent?.replace(/[%+]/g, '') || '0', + ); + compareValue = changeA - changeB; + break; + } + + case MARKET_SORTING_CONFIG.SORT_FIELDS.FUNDING_RATE: { + // Funding rate is a number (not string) + const fundingA = a.fundingRate ?? 0; + const fundingB = b.fundingRate ?? 0; + compareValue = fundingA - fundingB; + break; + } + + case MARKET_SORTING_CONFIG.SORT_FIELDS.OPEN_INTEREST: { + // Parse open interest strings (similar to volume) + const openInterestA = parseVolume(a.openInterest); + const openInterestB = parseVolume(b.openInterest); + compareValue = openInterestA - openInterestB; + break; + } + + default: + // Unsupported sort field - maintain current order (compareValue remains 0) + break; + } + + // Apply sort direction + return direction === MARKET_SORTING_CONFIG.DEFAULT_DIRECTION + ? compareValue * -1 // desc (larger first) + : compareValue; // asc (smaller first) + }); + + return sortedMarkets; +}; diff --git a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx index 5f5762f2f9b2..955f6d0fca7c 100644 --- a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx +++ b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx @@ -317,7 +317,7 @@ describe('WaysToEarn', () => { // Assert expect(mockGoBack).toHaveBeenCalled(); expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKETS, + screen: Routes.PERPS.PERPS_HOME, }); }); diff --git a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.tsx b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.tsx index 2bdfc306fa66..f54a44a95d65 100644 --- a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.tsx +++ b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.tsx @@ -195,7 +195,7 @@ export const WaysToEarn = () => { navigation.navigate(Routes.PERPS.TUTORIAL); } else { navigation.navigate(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKETS, + screen: Routes.PERPS.PERPS_HOME, }); } }, [navigation, isFirstTimePerpsUser]); diff --git a/app/components/Views/TradeWalletActions/TradeWalletActions.tsx b/app/components/Views/TradeWalletActions/TradeWalletActions.tsx index 2d70cb27830b..f61e5d35ba9c 100644 --- a/app/components/Views/TradeWalletActions/TradeWalletActions.tsx +++ b/app/components/Views/TradeWalletActions/TradeWalletActions.tsx @@ -135,7 +135,7 @@ function TradeWalletActions() { navigate(Routes.PERPS.TUTORIAL); } else { navigate(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKETS, + screen: Routes.PERPS.PERPS_HOME, }); } }; diff --git a/app/components/Views/WalletActions/WalletActions.tsx b/app/components/Views/WalletActions/WalletActions.tsx index c82df78a7b84..d1287833b8e4 100644 --- a/app/components/Views/WalletActions/WalletActions.tsx +++ b/app/components/Views/WalletActions/WalletActions.tsx @@ -136,7 +136,7 @@ const WalletActions = () => { navigate(Routes.PERPS.TUTORIAL); } else { navigate(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKETS, + screen: Routes.PERPS.PERPS_HOME, params: { source: PerpsEventValues.SOURCE.MAIN_ACTION_BUTTON }, }); } diff --git a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts index a527d0e9bf54..32b2a3f9a711 100644 --- a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts +++ b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts @@ -390,7 +390,7 @@ describe('useTransactionConfirm', () => { }); expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKETS, + screen: Routes.PERPS.PERPS_HOME, }); }); diff --git a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts index a771c3425691..7330bbe96519 100644 --- a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts +++ b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts @@ -148,7 +148,7 @@ export function useTransactionConfirm() { if (type === TransactionType.perpsDeposit) { navigation.navigate(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKETS, + screen: Routes.PERPS.PERPS_HOME, }); } else if ( isFullScreenConfirmation && diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 05e6a1651e40..ad2acff7d5ff 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -243,12 +243,13 @@ const Routes = { }, PERPS: { ROOT: 'Perps', - TRADING_VIEW: 'PerpsTradingView', + PERPS_TAB: 'PerpsTradingView', // Redirect to wallet home and select perps tab ORDER: 'PerpsOrder', WITHDRAW: 'PerpsWithdraw', POSITIONS: 'PerpsPositions', - MARKETS: 'PerpsMarketListView', + PERPS_HOME: 'PerpsMarketListView', // Home screen (positions, orders, watchlist, markets) MARKET_DETAILS: 'PerpsMarketDetails', + MARKET_LIST: 'PerpsTrendingView', // Full tabbed market list view TUTORIAL: 'PerpsTutorial', CLOSE_POSITION: 'PerpsClosePosition', HIP3_DEBUG: 'PerpsHIP3Debug', @@ -257,6 +258,8 @@ const Routes = { ROOT: 'PerpsModals', QUOTE_EXPIRED_MODAL: 'PerpsQuoteExpiredModal', GTM_MODAL: 'PerpsGTMModal', + CLOSE_ALL_POSITIONS: 'PerpsCloseAllPositions', + CANCEL_ALL_ORDERS: 'PerpsCancelAllOrders', }, POSITION_TRANSACTION: 'PerpsPositionTransaction', ORDER_TRANSACTION: 'PerpsOrderTransaction', diff --git a/app/core/DeeplinkManager/Handlers/handlePerpsUrl.test.ts b/app/core/DeeplinkManager/Handlers/handlePerpsUrl.test.ts index 254fc16a33bf..f07744c8871c 100644 --- a/app/core/DeeplinkManager/Handlers/handlePerpsUrl.test.ts +++ b/app/core/DeeplinkManager/Handlers/handlePerpsUrl.test.ts @@ -107,7 +107,7 @@ describe('handlePerpsUrl', () => { expect(mockNavigate).toHaveBeenCalledTimes(2); expect(mockNavigate).toHaveBeenLastCalledWith(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKETS, + screen: Routes.PERPS.PERPS_HOME, }); }); }); @@ -170,7 +170,7 @@ describe('handlePerpsUrl', () => { await handlePerpsUrl({ perpsPath: 'perps?screen=asset' }); expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKETS, + screen: Routes.PERPS.PERPS_HOME, }); }); @@ -178,7 +178,7 @@ describe('handlePerpsUrl', () => { await handlePerpsUrl({ perpsPath: 'perps?screen=asset&symbol=' }); expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKETS, + screen: Routes.PERPS.PERPS_HOME, }); }); @@ -192,7 +192,7 @@ describe('handlePerpsUrl', () => { expect(mockNavigate).toHaveBeenCalledTimes(2); expect(mockNavigate).toHaveBeenLastCalledWith(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKETS, + screen: Routes.PERPS.PERPS_HOME, }); }); @@ -202,7 +202,7 @@ describe('handlePerpsUrl', () => { }); expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKETS, + screen: Routes.PERPS.PERPS_HOME, }); }); @@ -246,7 +246,7 @@ describe('handlePerpsUrl', () => { // Returning users with screen=markets parameter go directly to markets expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKETS, + screen: Routes.PERPS.PERPS_HOME, }); expect(selectIsFirstTimePerpsUser).toHaveBeenCalled(); // Should not call setParams for direct navigation @@ -282,7 +282,7 @@ describe('handlePerpsUrl', () => { // Should navigate to markets for screen=markets parameter expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKETS, + screen: Routes.PERPS.PERPS_HOME, }); expect(selectIsFirstTimePerpsUser).toHaveBeenCalled(); }); diff --git a/app/core/DeeplinkManager/Handlers/handlePerpsUrl.ts b/app/core/DeeplinkManager/Handlers/handlePerpsUrl.ts index fb96406ccb82..4a16cdef9192 100644 --- a/app/core/DeeplinkManager/Handlers/handlePerpsUrl.ts +++ b/app/core/DeeplinkManager/Handlers/handlePerpsUrl.ts @@ -59,7 +59,7 @@ const handleAssetNavigation = async (symbol: string) => { '[handlePerpsUrl] No symbol provided, fallback to markets list', ); NavigationService.navigation.navigate(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKETS, + screen: Routes.PERPS.PERPS_HOME, }); return; } @@ -166,7 +166,7 @@ export const handlePerpsUrl = async ({ perpsPath }: HandlePerpsUrlParams) => { case 'markets': DevLogger.log('[handlePerpsUrl] Navigating to markets list'); NavigationService.navigation.navigate(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKETS, + screen: Routes.PERPS.PERPS_HOME, }); break; @@ -191,7 +191,7 @@ export const handlePerpsUrl = async ({ perpsPath }: HandlePerpsUrlParams) => { DevLogger.log('Failed to handle perps deeplink:', error); // Fallback to markets list on error NavigationService.navigation.navigate(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKETS, + screen: Routes.PERPS.PERPS_HOME, }); } }; diff --git a/app/core/Engine/Engine.test.ts b/app/core/Engine/Engine.test.ts index 3aac6dd6443f..838b14f5bd83 100644 --- a/app/core/Engine/Engine.test.ts +++ b/app/core/Engine/Engine.test.ts @@ -251,6 +251,15 @@ describe('Engine', () => { lastUpdated: 0, activeWithdrawalId: undefined, }, + marketFilterPreferences: 'volume', + tradeConfigurations: { + mainnet: {}, + testnet: {}, + }, + watchlistMarkets: { + mainnet: [], + testnet: [], + }, }, }; diff --git a/app/core/Engine/controllers/perps-controller/index.test.ts b/app/core/Engine/controllers/perps-controller/index.test.ts index b6b890504fd0..3b54bfba86fb 100644 --- a/app/core/Engine/controllers/perps-controller/index.test.ts +++ b/app/core/Engine/controllers/perps-controller/index.test.ts @@ -6,6 +6,7 @@ import { PerpsControllerMessenger, PerpsControllerState, } from '../../../../components/UI/Perps/controllers'; +import { MARKET_SORTING_CONFIG } from '../../../../components/UI/Perps/constants/perpsConfig'; import { perpsControllerInit } from '.'; jest.mock('../../../../components/UI/Perps/controllers', () => { @@ -116,6 +117,15 @@ describe('perps controller init', () => { testnet: false, mainnet: false, }, + watchlistMarkets: { + testnet: [], + mainnet: [], + }, + tradeConfigurations: { + testnet: {}, + mainnet: {}, + }, + marketFilterPreferences: MARKET_SORTING_CONFIG.DEFAULT_SORT_OPTION_ID, withdrawInProgress: false, lastWithdrawResult: null, withdrawalRequests: [], diff --git a/docs/perps/hyperliquid/exchange-endpoint.md b/docs/perps/hyperliquid/exchange-endpoint.md index e69de29bb2d1..bbb0cb60db73 100644 --- a/docs/perps/hyperliquid/exchange-endpoint.md +++ b/docs/perps/hyperliquid/exchange-endpoint.md @@ -0,0 +1,963 @@ +# Exchange endpoint + +### Asset + +Many of the requests take asset as an input. For perpetuals this is the index in the `universe` field returned by the`meta` response. For spot assets, use `10000 + index` where `index` is the corresponding index in `spotMeta.universe`. For example, when submitting an order for `PURR/USDC`, the asset that should be used is `10000` because its asset index in the spot metadata is `0`. + +### Subaccounts and vaults + +Subaccounts and vaults do not have private keys. To perform actions on behalf of a subaccount or vault signing should be done by the master account and the vaultAddress field should be set to the address of the subaccount or vault. The basic_vault.py example in the Python SDK demonstrates this. + +### Expires After + +Some actions support an optional field `expiresAfter` which is a timestamp in milliseconds after which the action will be rejected. User-signed actions such as Core USDC transfer do not support the `expiresAfter` field. Note that actions consume 5x the usual address-based rate limit when canceled due to a stale `expiresAfter` field. + +See the Python SDK for details on how to incorporate this field when signing. + +## Place an order + +`POST` `https://api.hyperliquid.xyz/exchange` + +See Python SDK for full featured examples on the fields of the order request. + +For limit orders, TIF (time-in-force) sets the behavior of the order upon first hitting the book. + +ALO (add liquidity only, i.e. "post only") will be canceled instead of immediately matching. + +IOC (immediate or cancel) will have the unfilled part canceled instead of resting. + +GTC (good til canceled) orders have no special behavior. + +Client Order ID (cloid) is an optional 128 bit hex string, e.g. `0x1234567890abcdef1234567890abcdef` + +#### Headers + +| Name | Type | Description | +| ---------------------------------------------- | ------ | ------------------ | +| Content-Type\* | String | "application/json" | + +#### Request Body + +| Name | Type | Description | | | | | | +| ------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| action\* | Object |

{

"type": "order",
"orders": \[{

"a": Number,

"b": Boolean,

"p": String,

"s": String,

"r": Boolean,

"t": {

"limit": {

"tif": "Alo" | "Ioc" | "Gtc"

} or

"trigger": {

"isMarket": Boolean,

"triggerPx": String,

"tpsl": "tp" | "sl"

}

},

"c": Cloid (optional)

}],

"grouping": "na" | "normalTpsl" | "positionTpsl",

"builder": Optional({"b": "address", "f": Number})

}

Meaning of keys:
a is asset
b is isBuy
p is price
s is size
r is reduceOnly
t is type
c is cloid (client order id)

Meaning of keys in optional builder argument:
b is the address the should receive the additional fee
f is the size of the fee in tenths of a basis point e.g. if f is 10, 1bp of the order notional will be charged to the user and sent to the builder

| +| nonce\* | Number | Recommended to use the current timestamp in milliseconds | | | | | | +| signature\* | Object | | | | | | | +| vaultAddress | String | If trading on behalf of a vault or subaccount, its Onchain address in 42-character hexadecimal format; e.g. 0x0000000000000000000000000000000000000000 | | | | | | +| expiresAfter | Number | Timestamp in milliseconds | | | | | | + +{% tabs %} +{% tab title="200: OK Successful Response (resting)" %} + +``` +{ + "status":"ok", + "response":{ + "type":"order", + "data":{ + "statuses":[ + { + "resting":{ + "oid":77738308 + } + } + ] + } + } +} +``` + +{% endtab %} + +{% tab title="200: OK Error Response" %} + +``` +{ + "status":"ok", + "response":{ + "type":"order", + "data":{ + "statuses":[ + { + "error":"Order must have minimum value of $10." + } + ] + } + } +} +``` + +{% endtab %} + +{% tab title="200: OK Successful Response (filled)" %} + +``` +{ + "status":"ok", + "response":{ + "type":"order", + "data":{ + "statuses":[ + { + "filled":{ + "totalSz":"0.02", + "avgPx":"1891.4", + "oid":77747314 + } + } + ] + } + } +} +``` + +{% endtab %} +{% endtabs %} + +## Cancel order(s) + +`POST` `https://api.hyperliquid.xyz/exchange` + +#### Headers + +| Name | Type | Description | +| ---------------------------------------------- | ------ | ------------------ | +| Content-Type\* | String | "application/json" | + +#### Request Body + +| Name | Type | Description | +| ------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| action\* | Object |

{

"type": "cancel",

"cancels": \[

{

"a": Number,

"o": Number

}

]

}

Meaning of keys:
a is asset
o is oid (order id)

| +| | | | +| nonce\* | Number | Recommended to use the current timestamp in milliseconds | +| signature\* | Object | | +| vaultAddress | String | If trading on behalf of a vault or subaccount, its address in 42-character hexadecimal format; e.g. 0x0000000000000000000000000000000000000000 | +| expiresAfter | Number | Timestamp in milliseconds | + +{% tabs %} +{% tab title="200: OK Successful Response" %} + +``` +{ + "status":"ok", + "response":{ + "type":"cancel", + "data":{ + "statuses":[ + "success" + ] + } + } +} +``` + +{% endtab %} + +{% tab title="200: OK Error Response" %} + +``` +{ + "status":"ok", + "response":{ + "type":"cancel", + "data":{ + "statuses":[ + { + "error":"Order was never placed, already canceled, or filled." + } + ] + } + } +} +``` + +{% endtab %} +{% endtabs %} + +## Cancel order(s) by cloid + +`POST` `https://api.hyperliquid.xyz/exchange` + +#### Headers + +| Name | Type | Description | +| ---------------------------------------------- | ------ | ------------------ | +| Content-Type\* | String | "application/json" | + +#### Request Body + +| Name | Type | Description | +| ------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| action\* | Object |

{

"type": "cancelByCloid",

"cancels": \[

{

"asset": Number,

"cloid": String

}

]

}

| +| nonce\* | Number | Recommended to use the current timestamp in milliseconds | +| signature\* | Object | | +| vaultAddress | String | If trading on behalf of a vault or subaccount, its address in 42-character hexadecimal format; e.g. 0x0000000000000000000000000000000000000000 | +| expiresAfter | Number | Timestamp in milliseconds | + +{% tabs %} +{% tab title="200: OK Successful Response" %} + +{% endtab %} + +{% tab title="200: OK Error Response" %} + +{% endtab %} +{% endtabs %} + +## Schedule cancel (dead man's switch) + +`POST` `https://api.hyperliquid.xyz/exchange` + +#### Headers + +| Name | Type | Description | +| ---------------------------------------------- | ------ | ------------------ | +| Content-Type\* | String | "application/json" | + +#### Request Body + +| Name | Type | Description | +| ------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| action\* | Object |

{

"type": "scheduleCancel",

"time": number (optional)

}

| +| nonce\* | Number | Recommended to use the current timestamp in milliseconds | +| signature\* | Object | | +| vaultAddress | String | If trading on behalf of a vault or subaccount, its address in 42-character hexadecimal format; e.g. 0x0000000000000000000000000000000000000000 | +| expiresAfter | Number | Timestamp in milliseconds | + +Schedule a cancel-all operation at a future time. Not including time will remove the scheduled cancel operation. The time must be at least 5 seconds after the current time. Once the time comes, all open orders will be canceled and a trigger count will be incremented. The max number of triggers per day is 10. This trigger count is reset at 00:00 UTC. + +## Modify an order + +`POST` `https://api.hyperliquid.xyz/exchange` + +#### Headers + +| Name | Type | Description | +| ---------------------------------------------- | ------ | ------------------ | +| Content-Type\* | String | "application/json" | + +#### Request Body + +| Name | Type | Description | | | | | +| ------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| action\* | Object |

{

"type": "modify",

"oid": Number | Cloid,

"order": {

"a": Number,

"b": Boolean,

"p": String,

"s": String,

"r": Boolean,

"t": {

"limit": {

"tif": "Alo" | "Ioc" | "Gtc"

} or

"trigger": {

"isMarket": Boolean,

"triggerPx": String,

"tpsl": "tp" | "sl"

}

},

"c": Cloid (optional)

}

}

Meaning of keys:
a is asset
b is isBuy
p is price
s is size
r is reduceOnly
t is type
c is cloid (client order id)

| +| nonce\* | Number | Recommended to use the current timestamp in milliseconds | | | | | +| signature\* | Object | | | | | | +| vaultAddress | String | If trading on behalf of a vault or subaccount, its Onchain address in 42-character hexadecimal format; e.g. 0x0000000000000000000000000000000000000000 | | | | | +| expiresAfter | Number | Timestamp in milliseconds | | | | | + +{% tabs %} +{% tab title="200: OK Successful Response" %} + +{% endtab %} + +{% tab title="200: OK Error Response" %} + +{% endtab %} +{% endtabs %} + +## Modify multiple orders + +`POST` `https://api.hyperliquid.xyz/exchange` + +#### Headers + +| Name | Type | Description | +| ---------------------------------------------- | ------ | ------------------ | +| Content-Type\* | String | "application/json" | + +#### Request Body + +| Name | Type | Description | | | | | +| ------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| action\* | Object |

{

"type": "batchModify",

"modifies": \[{

"oid": Number | Cloid,

"order": {

"a": Number,

"b": Boolean,

"p": String,

"s": String,

"r": Boolean,

"t": {

"limit": {

"tif": "Alo" | "Ioc" | "Gtc"

} or

"trigger": {

"isMarket": Boolean,

"triggerPx": String,

"tpsl": "tp" | "sl"

}

},

"c": Cloid (optional)

}

}]

}

Meaning of keys:
a is asset
b is isBuy
p is price
s is size
r is reduceOnly
t is type
c is cloid (client order id)

| +| nonce\* | Number | Recommended to use the current timestamp in milliseconds | | | | | +| signature\* | Object | | | | | | +| vaultAddress | String | If trading on behalf of a vault or subaccount, its Onchain address in 42-character hexadecimal format; e.g. 0x0000000000000000000000000000000000000000 | | | | | +| expiresAfter | Number | Timestamp in milliseconds | | | | | + +## Update leverage + +`POST` `https://api.hyperliquid.xyz/exchange` + +Update cross or isolated leverage on a coin. + +#### Headers + +| Name | Type | Description | +| ---------------------------------------------- | ------ | ------------------ | +| Content-Type\* | String | "application/json" | + +#### Request Body + +| Name | Type | Description | +| ------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| action\* | Object |

{

"type": "updateLeverage",

"asset": index of coin,

"isCross": true or false if updating cross-leverage,

"leverage": integer representing new leverage, subject to leverage constraints on that coin

}

| +| nonce\* | Number | Recommended to use the current timestamp in milliseconds | +| signature\* | Object | | +| vaultAddress | String | If trading on behalf of a vault or subaccount, its Onchain address in 42-character hexadecimal format; e.g. 0x0000000000000000000000000000000000000000 | +| expiresAfter | Number | Timestamp in milliseconds | + +{% tabs %} +{% tab title="200: OK Successful response" %} + +``` +{'status': 'ok', 'response': {'type': 'default'}} +``` + +{% endtab %} +{% endtabs %} + +## Update isolated margin + +`POST` `https://api.hyperliquid.xyz/exchange` + +Add or remove margin from isolated position + +Note that to target a specific leverage instead of a USDC value of margin change, there is an alternate action `{"type": "topUpIsolatedOnlyMargin", "asset": , "leverage": }` + +#### Headers + +| Name | Type | Description | +| ---------------------------------------------- | ------ | ------------------ | +| Content-Type\* | String | "application/json" | + +#### Request Body + +| Name | Type | Description | +| ------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| action\* | Object |

{

"type": "updateIsolatedMargin",

"asset": index of coin,

"isBuy": true, (this parameter won't have any effect until hedge mode is introduced)

"ntli": int representing amount to add or remove with 6 decimals, e.g. 1000000 for 1 usd,

}

| +| nonce\* | Number | Recommended to use the current timestamp in milliseconds | +| signature\* | Object | | +| vaultAddress | String | If trading on behalf of a vault or subaccount, its Onchain address in 42-character hexadecimal format; e.g. 0x0000000000000000000000000000000000000000 | +| expiresAfter | Number | Timestamp in milliseconds | + +{% tabs %} +{% tab title="200: OK Successful response" %} + +``` +{'status': 'ok', 'response': {'type': 'default'}} +``` + +{% endtab %} +{% endtabs %} + +## Core USDC transfer + +`POST` `https://api.hyperliquid.xyz/exchange` + +Send usd to another address. This transfer does not touch the EVM bridge. The signature format is human readable for wallet interfaces. + +#### Headers + +| Name | Type | Description | +| ---------------------------------------------- | ------ | ------------------ | +| Content-Type\* | String | "application/json" | + +#### Request Body + +| Name | Type | Description | +| ------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| action\* | Object |

{

"type": "usdSend",

"hyperliquidChain": "Mainnet" (on testnet use "Testnet" instead),
"signatureChainId": the id of the chain used when signing in hexadecimal format; e.g. "0xa4b1" for Arbitrum,

"destination": address in 42-character hexadecimal format; e.g. 0x0000000000000000000000000000000000000000,

"amount": amount of usd to send as a string, e.g. "1" for 1 usd,

"time": current timestamp in milliseconds as a Number, should match nonce

}

| +| nonce\* | Number | Recommended to use the current timestamp in milliseconds | +| signature\* | Object | | + +{% tabs %} +{% tab title="200: OK Successful Response" %} + +``` +{'status': 'ok', 'response': {'type': 'default'}} +``` + +{% endtab %} +{% endtabs %} + +## Core spot transfer + +`POST` `https://api.hyperliquid.xyz/exchange` + +Send spot assets to another address. This transfer does not touch the EVM bridge. The signature format is human readable for wallet interfaces. + +#### Headers + +| Name | Type | Description | +| ---------------------------------------------- | ------ | ------------------ | +| Content-Type\* | String | "application/json" | + +#### Request Body + +| Name | Type | Description | +| ------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| action\* | Object |

{

"type": "spotSend",

"hyperliquidChain": "Mainnet" (on testnet use "Testnet" instead),
"signatureChainId": the id of the chain used when signing in hexadecimal format; e.g. "0xa4b1" for Arbitrum,

"destination": address in 42-character hexadecimal format; e.g. 0x0000000000000000000000000000000000000000,
"token": tokenName:tokenId; e.g. "PURR:0xc4bf3f870c0e9465323c0b6ed28096c2",

"amount": amount of token to send as a string, e.g. "0.01",

"time": current timestamp in milliseconds as a Number, should match nonce

}

| +| nonce\* | Number | Recommended to use the current timestamp in milliseconds | +| signature\* | Object | | + +{% tabs %} +{% tab title="200: OK Successful Response" %} + +``` +{'status': 'ok', 'response': {'type': 'default'}} +``` + +{% endtab %} +{% endtabs %} + +``` +Example sign typed data for generating the signature: +{ + "types": { + "HyperliquidTransaction:SpotSend": [ + { + "name": "hyperliquidChain", + "type": "string" + }, + { + "name": "destination", + "type": "string" + }, + { + "name": "token", + "type": "string" + }, + { + "name": "amount", + "type": "string" + }, + { + "name": "time", + "type": "uint64" + } + ] + }, + "primaryType": "HyperliquidTransaction:SpotSend", + "domain": { + "name": "HyperliquidSignTransaction", + "version": "1", + "chainId": 42161, + "verifyingContract": "0x0000000000000000000000000000000000000000" + }, + "message": { + "destination": "0x0000000000000000000000000000000000000000", + "token": "PURR:0xc1fb593aeffbeb02f85e0308e9956a90", + "amount": "0.1", + "time": 1716531066415, + "hyperliquidChain": "Mainnet" + } +} +``` + +## Initiate a withdrawal request + +`POST` `https://api.hyperliquid.xyz/exchange` + +This method is used to initiate the withdrawal flow. After making this request, the L1 validators will sign and send the withdrawal request to the bridge contract. There is a $1 fee for withdrawing at the time of this writing and withdrawals take approximately 5 minutes to finalize. + +#### Headers + +| Name | Type | Description | +| ---------------------------------------------- | ------ | ------------------ | +| Content-Type\* | String | "application/json" | + +#### Request Body + +| Name | Type | Description | +| ------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| action\* | Object |

{
"type": "withdraw3",

"hyperliquidChain": "Mainnet" (on testnet use "Testnet" instead),
"signatureChainId": the id of the chain used when signing in hexadecimal format; e.g. "0xa4b1" for Arbitrum,

"amount": amount of usd to send as a string, e.g. "1" for 1 usd,

"time": current timestamp in milliseconds as a Number, should match nonce,

"destination": address in 42-character hexadecimal format; e.g. 0x0000000000000000000000000000000000000000

}

| +| nonce\* | Number | Recommended to use the current timestamp in milliseconds, must match the nonce in the action Object above | +| signature\* | Object | | + +{% tabs %} +{% tab title="200: OK " %} + +``` +{'status': 'ok', 'response': {'type': 'default'}} +``` + +{% endtab %} +{% endtabs %} + +## Transfer from Spot account to Perp account (and vice versa) + +`POST` `https://api.hyperliquid.xyz/exchange` + +This method is used to transfer USDC from the user's spot wallet to perp wallet and vice versa. + +**Headers** + +| Name | Value | +| ---------------------------------------------- | ------------------ | +| Content-Type\* | "application/json" | + +**Body** + +
NameTypeDescription
action*Object

{

"type": "usdClassTransfer",

"hyperliquidChain": "Mainnet" (on testnet use "Testnet" instead),
"signatureChainId": the id of the chain used when signing in hexadecimal format; e.g. "0xa4b1" for Arbitrum,

"amount": amount of usd to transfer as a string, e.g. "1" for 1 usd. If you want to use this action for a subaccount, you can include subaccount: address after the amount, e.g. "1" subaccount:0x0000000000000000000000000000000000000000,

"toPerp": true if (spot -> perp) else false,

"nonce": current timestamp in milliseconds as a Number, must match nonce in outer request body

}

nonce*NumberRecommended to use the current timestamp in milliseconds, must match the nonce in the action Object above
signature*Object
+ +**Response** + +{% tabs %} +{% tab title="200: OK" %} + +```json +{ "status": "ok", "response": { "type": "default" } } +``` + +{% endtab %} +{% endtabs %} + +## Send Asset + +`POST` `https://api.hyperliquid.xyz/exchange` + +This generalized method is used to transfer tokens between different perp DEXs, spot balance, users, and/or sub-accounts. Use "" to specify the default USDC perp DEX and "spot" to specify spot. Only the collateral token can be transferred to or from a perp DEX. + +#### Headers + +| Name | Value | +| ---------------------------------------------- | ------------------ | +| Content-Type\* | `application/json` | + +#### Body + +| Name | Type | Description | +| ------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| action\* | Object |

{

"type": "sendAsset",

"hyperliquidChain": "Mainnet" (on testnet use "Testnet" instead),

"signatureChainId": the id of the chain used when signing in hexadecimal format; e.g. "0xa4b1" for Arbitrum,

"destination": address in 42-character hexadecimal format; e.g. 0x0000000000000000000000000000000000000000,

"sourceDex": name of perp dex to transfer from,

"destinationDex": name of the perp dex to transfer to,

"token": tokenName:tokenId; e.g. "PURR:0xc4bf3f870c0e9465323c0b6ed28096c2",

"amount": amount of token to send as a string; e.g. "0.01",

"fromSubAccount": address in 42-character hexadecimal format or empty string if not from a subaccount,

"nonce": current timestamp in milliseconds as a Number, should match nonce

}

| +| nonce\* | Number | Recommended to use the current timestamp in milliseconds, must match the nonce in the action Object above | +| signature\* | Object | | + +#### Response + +{% tabs %} +{% tab title="200: OK" %} + +``` +{'status': 'ok', 'response': {'type': 'default'}} +``` + +{% endtab %} +{% endtabs %} + +## Deposit into staking + +`POST` `https://api.hyperliquid.xyz/exchange` + +This method is used to transfer native token from the user's spot account into staking for delegating to validators. + +#### Headers + +| Name | Value | +| ---------------------------------------------- | ------------------ | +| Content-Type\* | `application/json` | + +#### Body + +| Name | Type | Description | +| ------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| action\* | Object |

{

"type": "cDeposit",

"hyperliquidChain": "Mainnet" (on testnet use "Testnet" instead),
"signatureChainId": the id of the chain used when signing in hexadecimal format; e.g. "0xa4b1" for Arbitrum,

"wei": amount of wei to transfer as a number,

"nonce": current timestamp in milliseconds as a Number, must match nonce in outer request body

}

| +| nonce\* | Number | Recommended to use the current timestamp in milliseconds, must match the nonce in the action Object above | +| signature\* | Object | | + +#### Response + +{% tabs %} +{% tab title="200: OK" %} + +```json +{ "status": "ok", "response": { "type": "default" } } +``` + +{% endtab %} +{% endtabs %} + +## Withdraw from staking + +`POST` `https://api.hyperliquid.xyz/exchange` + +This method is used to transfer native token from staking into the user's spot account. Note that transfers from staking to spot account go through a 7 day unstaking queue. + +#### Headers + +| Name | Value | +| ---------------------------------------------- | ------------------ | +| Content-Type\* | `application/json` | + +#### Body + +| Name | Type | Description | +| ------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| action\* | Object |

{

"type": "cWithdraw",

"hyperliquidChain": "Mainnet" (on testnet use "Testnet" instead),
"signatureChainId": the id of the chain used when signing in hexadecimal format; e.g. "0xa4b1" for Arbitrum,

"wei": amount of wei to transfer as a number,

"nonce": current timestamp in milliseconds as a Number, must match nonce in outer request body

}

| +| nonce\* | Number | Recommended to use the current timestamp in milliseconds, must match the nonce in the action Object above | +| signature\* | Object | | + +#### Response + +{% tabs %} +{% tab title="200: OK" %} + +```json +{ "status": "ok", "response": { "type": "default" } } +``` + +{% endtab %} +{% endtabs %} + +## Delegate or undelegate stake from validator + +`POST` `https://api.hyperliquid.xyz/exchange` + +Delegate or undelegate native tokens to or from a validator. Note that delegations to a particular validator have a lockup duration of 1 day. + +#### Headers + +| Name | Value | +| ---------------------------------------------- | ------------------ | +| Content-Type\* | `application/json` | + +#### Body + +| Name | Type | Description | +| ------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| action\* | Object |

{

"type": "tokenDelegate",

"hyperliquidChain": "Mainnet" (on testnet use "Testnet" instead),
"signatureChainId": the id of the chain used when signing in hexadecimal format; e.g. "0xa4b1" for Arbitrum,

"validator": address in 42-character hexadecimal format; e.g. 0x0000000000000000000000000000000000000000,
"isUndelegate": boolean,

"wei": number,

"nonce": current timestamp in milliseconds as a Number, must match nonce in outer request body

}

| +| nonce\* | number | Recommended to use the current timestamp in milliseconds | +| signature\* | Object | | + +#### Response + +{% tabs %} +{% tab title="200: OK" %} + +```json +{ "status": "ok", "response": { "type": "default" } } +``` + +{% endtab %} +{% endtabs %} + +## Deposit or withdraw from a vault + +`POST` `https://api.hyperliquid.xyz/exchange` + +Add or remove funds from a vault. + +**Headers** + +| Name | Value | +| ---------------------------------------------- | ------------------ | +| Content-Type\* | `application/json` | + +**Body** + +| Name | Type | Description | +| ------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| action\* | Object |

{

"type": "vaultTransfer",

"vaultAddress": address in 42-character hexadecimal format; e.g. 0x0000000000000000000000000000000000000000,
"isDeposit": boolean,

"usd": number

}

| +| nonce\* | number | Recommended to use the current timestamp in milliseconds | +| signature\* | Object | | +| expiresAfter | Number | Timestamp in milliseconds | + +**Response** + +{% tabs %} +{% tab title="200" %} + +```json +{ "status": "ok", "response": { "type": "default" } } +``` + +{% endtab %} +{% endtabs %} + +## Approve an API wallet + +`POST` `https://api.hyperliquid.xyz/exchange` + +Approves an API Wallet (also sometimes referred to as an Agent Wallet). See [here](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/nonces-and-api-wallets#api-wallets) for more details. + +**Headers** + +| Name | Value | +| ---------------------------------------------- | ------------------ | +| Content-Type\* | `application/json` | + +**Body** + +| Name | Type | Description | +| ------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| action\* | Object |

{
"type": "approveAgent",

"hyperliquidChain": "Mainnet" (on testnet use "Testnet" instead),
"signatureChainId": the id of the chain used when signing in hexadecimal format; e.g. "0xa4b1" for Arbitrum,

"agentAddress": address in 42-character hexadecimal format; e.g. 0x0000000000000000000000000000000000000000,

"agentName": Optional name for the API wallet. An account can have 1 unnamed approved wallet and up to 3 named ones. And additional 2 named agents are allowed per subaccount,

"nonce": current timestamp in milliseconds as a Number, must match nonce in outer request body

}

| +| nonce\* | number | Recommended to use the current timestamp in milliseconds | +| signature\* | Object | | + +**Response** + +{% tabs %} +{% tab title="200" %} + +```json +{ "status": "ok", "response": { "type": "default" } } +``` + +{% endtab %} +{% endtabs %} + +## Approve a builder fee + +`POST` `https://api.hyperliquid.xyz/exchange` + +Approve a maximum fee rate for a builder. + +**Headers** + +| Name | Value | +| ---------------------------------------------- | ------------------ | +| Content-Type\* | `application/json` | + +**Body** + +| Name | Type | Description | +| ------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| action\* | Object |

{
"type": "approveBuilderFee",

"hyperliquidChain": "Mainnet" (on testnet use "Testnet" instead),
"signatureChainId": the id of the chain used when signing in hexadecimal format; e.g. "0xa4b1" for Arbitrum,

"maxFeeRate": the maximum allowed builder fee rate as a percent string; e.g. "0.001%",

"builder": address in 42-character hexadecimal format; e.g. 0x0000000000000000000000000000000000000000,

"nonce": current timestamp in milliseconds as a Number, must match nonce in outer request body

}

| +| nonce\* | number | Recommended to use the current timestamp in milliseconds | +| signature\* | Object | | + +**Response** + +{% tabs %} +{% tab title="200" %} + +```json +{ "status": "ok", "response": { "type": "default" } } +``` + +{% endtab %} +{% endtabs %} + +## Place a TWAP order + +`POST` `https://api.hyperliquid.xyz/exchange` + +#### Headers + +| Name | Type | Description | +| ---------------------------------------------- | ------ | ------------------ | +| Content-Type\* | String | "application/json" | + +#### Request Body + +| Name | Type | Description | +| ------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| action\* | Object |

{

"type": "twapOrder",
"twap": {

"a": Number,

"b": Boolean,

"s": String,

"r": Boolean,

"m": Number,

"t": Boolean

}

}

Meaning of keys:
a is asset
b is isBuy
s is size
r is reduceOnly

m is minutes
t is randomize

| +| nonce\* | Number | Recommended to use the current timestamp in milliseconds | +| signature\* | Object | | +| vaultAddress | String | If trading on behalf of a vault or subaccount, its Onchain address in 42-character hexadecimal format; e.g. 0x0000000000000000000000000000000000000000 | +| expiresAfter | Number | Timestamp in milliseconds | + +{% tabs %} +{% tab title="200: OK Successful Response" %} + +``` +{ + "status":"ok", + "response":{ + "type":"twapOrder", + "data":{ + "status": { + "running":{ + "twapId":77738308 + } + } + } + } +} +``` + +{% endtab %} + +{% tab title="200: OK Error Response" %} + +``` +{ + "status":"ok", + "response":{ + "type":"twapOrder", + "data":{ + "status": { + "error":"Invalid TWAP duration: 1 min(s)" + } + } + } +} +``` + +{% endtab %} +{% endtabs %} + +## Cancel a TWAP order + +`POST` `https://api.hyperliquid.xyz/exchange` + +#### Headers + +| Name | Type | Description | +| ---------------------------------------------- | ------ | ------------------ | +| Content-Type\* | String | "application/json" | + +#### Request Body + +| Name | Type | Description | +| ------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| action\* | Object |

{

"type": "twapCancel",

"a": Number,

"t": Number

}

Meaning of keys:
a is asset
t is twap_id

| +| nonce\* | Number | Recommended to use the current timestamp in milliseconds | +| signature\* | Object | | +| vaultAddress | String | If trading on behalf of a vault or subaccount, its address in 42-character hexadecimal format; e.g. 0x0000000000000000000000000000000000000000 | +| expiresAfter | Number | Timestamp in milliseconds | + +{% tabs %} +{% tab title="200: OK Successful Response" %} + +``` +{ + "status":"ok", + "response":{ + "type":"twapCancel", + "data":{ + "status": "success" + } + } +} +``` + +{% endtab %} + +{% tab title="200: OK Error Response" %} + +``` +{ + "status":"ok", + "response":{ + "type":"twapCancel", + "data":{ + "status": { + "error": "TWAP was never placed, already canceled, or filled." + } + } + } +} +``` + +{% endtab %} +{% endtabs %} + +## Reserve Additional Actions + +`POST` `https://api.hyperliquid.xyz/exchange` + +Instead of trading to increase the address based rate limits, this action allows reserving additional actions for 0.0005 USDC per request. The cost is paid from the Perps balance. + +#### Headers + +| Name | Type | Description | +| ---------------------------------------------- | ------ | ------------------ | +| Content-Type\* | String | "application/json" | + +#### Request Body + +| Name | Type | Description | +| ------------------------------------------- | ------ | ------------------------------------------------------------------------------- | +| action\* | Object |

{

"type": "reserveRequestWeight",

"weight": Number

}

| +| nonce\* | Number | Recommended to use the current timestamp in milliseconds | +| signature\* | Object | | +| expiresAfter | Number | Timestamp in milliseconds | + +{% tabs %} +{% tab title="200: OK Successful Response" %} + +``` +{'status': 'ok', 'response': {'type': 'default'}} +``` + +{% endtab %} +{% endtabs %} + +## Invalidate Pending Nonce (noop) + +`POST` `https://api.hyperliquid.xyz/exchange` + +This action does not do anything (no operation), but causes the nonce to be marked as used. This can be a more effective way to cancel in-flight orders than the cancel action. + +#### Headers + +| Name | Type | Description | +| ---------------------------------------------- | ------ | ------------------ | +| Content-Type\* | String | "application/json" | + +#### Request Body + +| Name | Type | Description | +| ------------------------------------------- | ------ | -------------------------------------------------------- | +| action\* | Object |

{

"type": "noop"

}

| +| nonce\* | Number | Recommended to use the current timestamp in milliseconds | +| signature\* | Object | | +| expiresAfter | Number | Timestamp in milliseconds | + +{% tabs %} +{% tab title="200: OK Successful Response" %} + +``` +{'status': 'ok', 'response': {'type': 'default'}} +``` + +{% endtab %} +{% endtabs %} + +## Enable HIP-3 DEX abstraction + +`POST` `https://api.hyperliquid.xyz/exchange` + +If set, actions on HIP-3 perps will automatically transfer collateral from validator-operated USDC perps balance for HIP-3 DEXs where USDC is the collateral token, and spot otherwise. When HIP-3 DEX abstraction is active, collateral is returned to the same source (validator-operated USDC perps or spot balance) when released from positions or open orders. + +#### Headers + +| Name | Type | Description | +| ---------------------------------------------- | ------ | ------------------ | +| Content-Type\* | String | "application/json" | + +#### Request Body + +
NameTypeDescription
action*Object

{

"type": "userEnableDexAbstraction",

"hyperliquidChain": "Mainnet" (on testnet use "Testnet" instead),

"signatureChainId": the id of the chain used when signing in hexadecimal format; e.g. "0xa4b1" for Arbitrum,

"user": address in 42-character hexadecimal format. Can be a sub-account of the user,

"enabled": boolean,

"nonce": current timestamp in milliseconds as a Number, should match nonce

}

nonce*NumberRecommended to use the current timestamp in milliseconds
signature*Object
+ +{% tabs %} +{% tab title="200: OK Successful Response" %} + +``` +{'status': 'ok', 'response': {'type': 'default'}} +``` + +{% endtab %} +{% endtabs %} + +## Enable HIP-3 DEX abstraction (agent) + +Same effect as UserDexAbstraction above, but only works if setting the value from `null` to `true`. + +#### Headers + +| Name | Type | Description | +| ---------------------------------------------- | ------ | ------------------ | +| Content-Type\* | String | "application/json" | + +#### Request Body + +| Name | Type | Description | +| ------------------------------------------- | ------ | ----------------------------------------------------------- | +| action\* | Object |

{

"type": "agentEnableDexAbstraction"

}

| +| nonce\* | Number | Recommended to use the current timestamp in milliseconds | +| signature\* | Object | | + +{% tabs %} +{% tab title="200: OK Successful Response" %} + +``` +{'status': 'ok', 'response': {'type': 'default'}} +``` + +{% endtab %} +{% endtabs %} diff --git a/docs/perps/hyperliquid/fees.md b/docs/perps/hyperliquid/fees.md new file mode 100644 index 000000000000..719b00ec2b4e --- /dev/null +++ b/docs/perps/hyperliquid/fees.md @@ -0,0 +1,70 @@ +# Fees + +Fees are based on your rolling 14 day volume and are assessed at the end of each day in UTC. Sub-account volume counts toward the master account and all sub-accounts share the same fee tier. Vault volume is treated separately from the master account. Referral rewards apply for a user's first $1B in volume and referral discounts apply for a user's first $25M in volume. + +Maker rebates are paid out continuously on each trade directly to the trading wallet. Users can claim referral rewards from the Referrals page. + +There are separate fee schedules for perps vs spot. Perps and spot volume will be counted together to determine your fee tier, and spot volume will count double toward your fee tier. i.e., `(14d weighted volume) = (14d perps volume) + 2 * (14d spot volume)`. + +There is one fee tier across all assets, including perps, HIP-3 perps, and spot. HIP-3 perps have 2x fees and the same rebates. + +Spot pairs between two spot quote assets have 80% lower taker fees, maker rebates, and user volume contribution. + +[aligned-quote-assets](https://hyperliquid.gitbook.io/hyperliquid-docs/hypercore/aligned-quote-assets 'mention') benefit from 20% lower taker fees, 50% better maker rebates, and 20% more volume contribution toward fee tiers. + +### Perps fee tiers + +| | | Base rate | | Diamond | | Platinum | | Gold | | Silver | | Bronze | | Wood | | +| ---- | ----------------------- | --------- | ------ | ------- | ------- | -------- | ------- | ------- | ------- | ------- | ------- | ------- | ------- | ------- | ------- | +| Tier | 14d weighted volume ($) | Taker | Maker | Taker | Maker | Taker | Maker | Taker | Maker | Taker | Maker | Taker | Maker | Taker | Maker | +| 0 | | 0.045% | 0.015% | 0.0270% | 0.0090% | 0.0315% | 0.0105% | 0.0360% | 0.0120% | 0.0383% | 0.0128% | 0.0405% | 0.0135% | 0.0428% | 0.0143% | +| 1 | >5M | 0.040% | 0.012% | 0.0240% | 0.0072% | 0.0280% | 0.0084% | 0.0320% | 0.0096% | 0.0340% | 0.0102% | 0.0360% | 0.0108% | 0.0380% | 0.0114% | +| 2 | >25M | 0.035% | 0.008% | 0.0210% | 0.0048% | 0.0245% | 0.0056% | 0.0280% | 0.0064% | 0.0298% | 0.0068% | 0.0315% | 0.0072% | 0.0333% | 0.0076% | +| 3 | >100M | 0.030% | 0.004% | 0.0180% | 0.0024% | 0.0210% | 0.0028% | 0.0240% | 0.0032% | 0.0255% | 0.0034% | 0.0270% | 0.0036% | 0.0285% | 0.0038% | +| 4 | >500M | 0.028% | 0.000% | 0.0168% | 0.0000% | 0.0196% | 0.0000% | 0.0224% | 0.0000% | 0.0238% | 0.0000% | 0.0252% | 0.0000% | 0.0266% | 0.0000% | +| 5 | >2B | 0.026% | 0.000% | 0.0156% | 0.0000% | 0.0182% | 0.0000% | 0.0208% | 0.0000% | 0.0221% | 0.0000% | 0.0234% | 0.0000% | 0.0247% | 0.0000% | +| 6 | >7B | 0.024% | 0.000% | 0.0144% | 0.0000% | 0.0168% | 0.0000% | 0.0192% | 0.0000% | 0.0204% | 0.0000% | 0.0216% | 0.0000% | 0.0228% | 0.0000% | + +### Spot fee tiers + +| Spot | | Base rate | | Diamond | | Platinum | | Gold | | Silver | | Bronze | | Wood | | +| ---- | ----------------------- | --------- | ------ | ------- | ------- | -------- | ------- | ------- | ------- | ------- | ------- | ------- | ------- | ------- | ------- | +| Tier | 14d weighted volume ($) | Taker | Maker | Taker | Maker | Taker | Maker | Taker | Maker | Taker | Maker | Taker | Maker | Taker | Maker | +| 0 | | 0.070% | 0.040% | 0.0420% | 0.0240% | 0.0490% | 0.0280% | 0.0560% | 0.0320% | 0.0595% | 0.0340% | 0.0630% | 0.0360% | 0.0665% | 0.0380% | +| 1 | >5M | 0.060% | 0.030% | 0.0360% | 0.0180% | 0.0420% | 0.0210% | 0.0480% | 0.0240% | 0.0510% | 0.0255% | 0.0540% | 0.0270% | 0.0570% | 0.0285% | +| 2 | >25M | 0.050% | 0.020% | 0.0300% | 0.0120% | 0.0350% | 0.0140% | 0.0400% | 0.0160% | 0.0425% | 0.0170% | 0.0450% | 0.0180% | 0.0475% | 0.0190% | +| 3 | >100M | 0.040% | 0.010% | 0.0240% | 0.0060% | 0.0280% | 0.0070% | 0.0320% | 0.0080% | 0.0340% | 0.0085% | 0.0360% | 0.0090% | 0.0380% | 0.0095% | +| 4 | >500M | 0.035% | 0.000% | 0.0210% | 0.0000% | 0.0245% | 0.0000% | 0.0280% | 0.0000% | 0.0298% | 0.0000% | 0.0315% | 0.0000% | 0.0333% | 0.0000% | +| 5 | >2B | 0.030% | 0.000% | 0.0180% | 0.0000% | 0.0210% | 0.0000% | 0.0240% | 0.0000% | 0.0255% | 0.0000% | 0.0270% | 0.0000% | 0.0285% | 0.0000% | +| 6 | >7B | 0.025% | 0.000% | 0.0150% | 0.0000% | 0.0175% | 0.0000% | 0.0200% | 0.0000% | 0.0213% | 0.0000% | 0.0225% | 0.0000% | 0.0238% | 0.0000% | + +### Staking tiers + +| Tier | HYPE staked | Trading fee discount | +| -------- | ----------- | -------------------- | +| Wood | >10 | 5% | +| Bronze | >100 | 10% | +| Silver | >1,000 | 15% | +| Gold | >10,000 | 20% | +| Platinum | >100,000 | 30% | +| Diamond | >500,000 | 40% | + +### Maker rebates + +| Tier | 14d weighted maker volume | Maker fee | +| ---- | ------------------------- | --------- | +| 1 | >0.5% | -0.001% | +| 2 | >1.5% | -0.002% | +| 3 | >3.0% | -0.003% | + +On most other protocols, the team or insiders are the main beneficiaries of fees. On Hyperliquid, fees are entirely directed to the community (HLP, the assistance fund, and spot deployers). Spot deployers may choose to keep up to 50% of trading fees generated by their token. For security, the assistance fund holds a majority of its assets in HYPE, which is the most liquid native asset on the Hyperliquid L1. The assistance fund uses the system address `0xfefefefefefefefefefefefefefefefefefefefe` which operates entirely onchain as part of the L1 execution. The assistance fund requires validator quorum to use in special situations. + +### Staking linking + +A "staking user" and a "trading user" can be linked so that the staking user's HYPE staked can be attributed to the trading user's fees. A few important points to note: + +- The staking user will be able to unilaterally control the trading user. In particular, linking to a specific staking user essentially gives them full control of funds in the trading account. +- Linking is permanent. Unlinking is not supported. +- The staking user will not receive any staking-related fee discount after being linked. +- Linking requires the trading user to send an action first, and then the staking user to finalize the link. See "Link Staking" at app.hyperliquid.xyz/portfolio for details. +- No action is required if you plan to trade and stake from the same address. diff --git a/docs/perps/hyperliquid/hip3-implementation.md b/docs/perps/hyperliquid/hip3-implementation.md new file mode 100644 index 000000000000..4b8f020c3cd8 --- /dev/null +++ b/docs/perps/hyperliquid/hip3-implementation.md @@ -0,0 +1,984 @@ +# HIP-3 Implementation Reference + +**Last Updated**: 2025-01-28 +**Status**: Production (Feature Flag Controlled) + +## 1. Overview + +This document provides a comprehensive reference for how MetaMask implements support for HyperLiquid's HIP-3 (builder-deployed perpetuals) protocol. HIP-3 enables builders to deploy custom perpetuals DEXs on HyperLiquid by staking 500k HYPE tokens. + +### Key HIP-3 Characteristics + +- **Isolated Margin Only**: HIP-3 DEXs use isolated margin mode +- **Separate Orderbooks**: Each DEX maintains independent orderbooks and liquidity +- **Global Asset IDs**: Unique asset ID formula enables seamless routing across DEXs +- **Settlement Authority**: Deployers have settlement authority for their markets +- **Open Interest Caps**: Per-DEX and per-asset caps to manage risk + +## 2. High-Level Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ PerpsController │ +│ (Protocol-Agnostic Layer) │ +│ - No HIP-3-specific code │ +│ - Injects feature flags to provider │ +└────────────────────┬────────────────────────────────────────┘ + │ IPerpsProvider Interface + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ HyperLiquidProvider │ +│ (Protocol Implementation) │ +│ - Feature flags: equityEnabled, enabledDexs │ +│ - Asset mapping with DEX prefixes │ +│ - Balance management (native/programmatic) │ +│ - Auto-transfer for HIP-3 orders │ +└────────────────────┬────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ HyperLiquidSubscriptionService │ +│ (WebSocket Management) │ +│ - webData2: Main DEX (positions, orders, account) │ +│ - clearinghouseState: HIP-3 DEXs (positions, account) │ +│ - assetCtxs: Per-DEX market data │ +│ - Data aggregation with change detection │ +└────────────────────┬────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ hyperLiquidAdapter │ +│ (Type Transformations) │ +│ - Asset ID calculation │ +│ - Asset name parsing (dex:SYMBOL format) │ +│ - SDK ↔ Protocol-agnostic type conversion │ +└─────────────────────────────────────────────────────────────┘ +``` + +## 3. Key Implementation Decisions + +### 3.1 Feature Flag System + +**Location**: `HyperLiquidProvider.ts:158-163` + +```typescript +private equityEnabled: boolean; // Kill switch for all HIP-3 features +private enabledDexs: string[]; // Whitelist of allowed HIP-3 DEXs +private useDexAbstraction: boolean; // Native balance abstraction mode +``` + +**Decision Rationale**: + +- **Staged Rollout**: Enables gradual feature deployment +- **Risk Management**: Kill switch for quick disablement if issues arise +- **Whitelist Mode**: Granular control over which HIP-3 DEXs are exposed +- **Balance Strategy Toggle**: Allows switching between native and programmatic balance management + +**Configuration Injection**: + +```typescript +// PerpsController.ts:633-648 +constructor({ clientConfig }: PerpsControllerOptions) { + // Store HIP-3 configuration (immutable after construction) + this.equityEnabled = clientConfig.equityEnabled ?? false; + this.enabledDexs = [...(clientConfig.enabledDexs ?? [])]; + // Passes to provider without direct HIP-3 knowledge +} +``` + +### 3.2 Asset ID Calculation + +**Location**: `hyperLiquidAdapter.ts:456-468`, `hyperLiquidConfig.ts:231-239` + +```typescript +export function calculateHip3AssetId( + perpDexIndex: number, + indexInMeta: number, +): number { + if (perpDexIndex === 0) { + return indexInMeta; // Main DEX: direct index (0, 1, 2, ...) + } + return ( + HIP3_ASSET_ID_CONFIG.BASE_ASSET_ID + // 100000 + perpDexIndex * HIP3_ASSET_ID_CONFIG.DEX_MULTIPLIER + // 10000 + indexInMeta + ); +} +``` + +**Formula Breakdown**: + +- **Main DEX** (perpDexIndex=0): `assetId = index` (BTC=0, ETH=1, SOL=2, ...) +- **xyz DEX** (perpDexIndex=1): `assetId = 100000 + (1 × 10000) + index` = 110000-110999 +- **abc DEX** (perpDexIndex=2): `assetId = 100000 + (2 × 10000) + index` = 120000-120999 + +**Decision Rationale**: + +- **Global Uniqueness**: Each asset across all DEXs has a unique ID +- **Scalability**: Supports up to 10 HIP-3 DEXs with 10,000 assets each +- **Seamless Routing**: Order placement doesn't require explicit DEX parameters +- **Backward Compatible**: Main DEX asset IDs unchanged (direct index) + +**Example Calculation**: + +``` +Main DEX BTC: perpDexIndex=0, index=0 → assetId = 0 +Main DEX ETH: perpDexIndex=0, index=1 → assetId = 1 +xyz TSLA: perpDexIndex=1, index=0 → assetId = 110000 +xyz NVDA: perpDexIndex=1, index=1 → assetId = 110001 +abc GOLD: perpDexIndex=2, index=0 → assetId = 120000 +``` + +### 3.3 Asset Naming Convention + +**Location**: `hyperLiquidAdapter.ts:484-496` + +```typescript +export function parseAssetName(assetName: string): { + dex: string | null; + symbol: string; +} { + const colonIndex = assetName.indexOf(':'); + if (colonIndex === -1) { + return { dex: null, symbol: assetName }; // Main DEX: "BTC" + } + return { + dex: assetName.substring(0, colonIndex), // "xyz" + symbol: assetName.substring(colonIndex + 1), // "XYZ100" + }; +} +``` + +**Naming Format**: + +- **Main DEX**: `"BTC"`, `"ETH"`, `"SOL"` (no prefix) +- **HIP-3 DEXs**: `"xyz:TSLA"`, `"xyz:NVDA"`, `"abc:GOLD"` (dex:SYMBOL format) + +**Decision Rationale**: + +- **Unambiguous Identification**: Asset names uniquely identify both DEX and symbol +- **API Consistency**: HyperLiquid API returns assets in this format +- **UI Simplicity**: Easy parsing for display logic (show DEX badges for HIP-3 assets) + +### 3.4 Multi-DEX WebSocket Strategy + +**Location**: `HyperLiquidSubscriptionService.ts:44-112, 671-717` + +**WebSocket Connection Architecture**: + +- **1 shared WebSocket connection** (`HyperLiquidClientService.ts:45,85-87`) +- **Multiple subscriptions** on the same connection + +```typescript +// Main DEX: webData2 (rich data with orders) +private readonly webData2Subscriptions = new Map(); + +// HIP-3 DEXs: clearinghouseState (positions/account only) +private readonly clearinghouseStateSubscriptions = new Map(); + +// All DEXs: assetCtxs (market data) +private readonly assetCtxsSubscriptions = new Map(); +``` + +**Subscription Count per Configuration**: + +| Enabled DEXs | Total Subscriptions | Breakdown | +| ---------------- | ------------------- | --------------------------------------------------------------------------- | +| Main only | 1 | 1 webData2 (main) | +| Main + xyz | 2 | 1 webData2 (main) + 1 clearinghouseState (xyz) | +| Main + xyz + abc | 3 | 1 webData2 (main) + 1 clearinghouseState (xyz) + 1 clearinghouseState (abc) | + +**Subscription Types**: + +| Subscription Type | Count | DEXs | Data Provided | Update Frequency | +| -------------------- | -------- | ----------------- | --------------------------------- | ---------------- | +| `webData2` | 1 | Main DEX only | Positions, Orders, Account, Fills | Real-time | +| `clearinghouseState` | N | 1 per HIP-3 DEX | Positions, Account | Real-time | +| `assetCtxs` | Variable | Per-DEX as needed | Market data, Funding rates | Real-time | + +**Decision Rationale**: + +- **Single Connection**: All subscriptions share one WebSocket connection for efficiency +- **SDK API Design**: `webData2` has no `dex` parameter (SDK source: `webData2.ts:15-31`), `clearinghouseState` has optional `dex` parameter (SDK source: `clearinghouseState.ts:11-32`) +- **Per-DEX Subscriptions**: Each HIP-3 DEX requires separate `clearinghouseState` subscription +- **REST Fallback for Orders**: `clearinghouseState` doesn't include orders; fetched via REST API (`HyperLiquidSubscriptionService.ts:1662-1666`) +- **Reference Counting**: Subscription lifecycle managed with subscriber counts (`HyperLiquidSubscriptionService.ts:849-886`) + +**Subscription Setup Flow**: + +```typescript +// HyperLiquidSubscriptionService.ts:671-717 +private async ensureSharedWebData2Subscription(accountId?: CaipAccountId): Promise { + // 1. Main DEX: webData2 (richer data) + if (!this.webData2Subscriptions.has('')) { + await this.createWebData2Subscription(accountId); + } + + // 2. HIP-3 DEXs: clearinghouseState (positions/account only) + const hip3Dexs = this.enabledDexs.filter((dex): dex is string => dex !== null); + await Promise.all( + hip3Dexs.map(async (dex) => { + await this.ensureClearinghouseStateSubscription(userAddress, dex); + }) + ); +} +``` + +### 3.5 Data Aggregation Strategy + +**Location**: `HyperLiquidSubscriptionService.ts:477-511, 532-586` + +**Per-DEX Caches**: + +```typescript +// Separate caches for each DEX +private readonly dexPositionsCache = new Map(); +private readonly dexOrdersCache = new Map(); +private readonly dexAccountCache = new Map(); + +// Per-DEX reference counting +private readonly dexSubscriberCounts = new Map(); +``` + +**Account State Aggregation**: + +```typescript +// HyperLiquidSubscriptionService.ts:477-511 +private aggregateAccountStates(): AccountState { + let totalAvailableBalance = 0; + let totalBalance = 0; + + // Sum balances across all DEXs + this.dexAccountCache.forEach((state, dex) => { + totalAvailableBalance += parseFloat(state.availableBalance); + totalBalance += parseFloat(state.totalBalance); + }); + + return { + availableBalance: totalAvailableBalance.toString(), + totalBalance: totalBalance.toString(), + subAccountBreakdown // Per-DEX breakdown for advanced users + }; +} +``` + +**Change Detection**: + +```typescript +// HyperLiquidSubscriptionService.ts:532-586 +private positionsChanged(newPositions: Position[]): boolean { + const newHash = this.hashPositions(newPositions); + const changed = newHash !== this.lastPositionsHash; + this.lastPositionsHash = newHash; + return changed; +} + +private hashPositions(positions: Position[]): string { + return positions + .map((p) => `${p.asset}:${p.size}:${p.entryPrice}:${p.leverage}`) + .sort() + .join('|'); +} +``` + +**Decision Rationale**: + +- **Per-DEX Isolation**: Caches reflect HIP-3's isolated margin architecture +- **Efficient Updates**: Hash-based change detection reduces unnecessary re-renders +- **Unified Output**: Aggregated data provides unified view for UI +- **Advanced Visibility**: `subAccountBreakdown` enables per-DEX analysis + +## 4. WebSocket Subscription Details + +### 4.1 Main DEX Subscription (webData2) + +**Setup**: `HyperLiquidSubscriptionService.ts:738-782` + +```typescript +private async createWebData2Subscription(accountId?: CaipAccountId): Promise { + const userAddress = this.getAccountAddress(accountId); + + const webData2Sub = await this.subscriptionClient.subscribeToWebData2( + { user: userAddress }, + (data) => { + // Process positions + if (data.clearinghouseState?.assetPositions) { + const positions = this.transformPositions(data.clearinghouseState.assetPositions, ''); + this.dexPositionsCache.set('', positions); + } + + // Process orders + if (data.openOrders) { + const orders = this.transformOrders(data.openOrders, ''); + this.dexOrdersCache.set('', orders); + } + + // Process account state + if (data.clearinghouseState) { + const accountState = this.transformAccountState(data.clearinghouseState, ''); + this.dexAccountCache.set('', accountState); + } + + this.emitAggregatedData(); + } + ); + + this.webData2Subscriptions.set('', webData2Sub); +} +``` + +**Data Available**: + +- Positions with unrealized PnL +- Open orders with status +- Account balance and margin +- Recent fills history + +### 4.2 HIP-3 DEX Subscription (clearinghouseState) + +**Setup**: `HyperLiquidSubscriptionService.ts:784-832` + +```typescript +private async ensureClearinghouseStateSubscription( + userAddress: string, + dex: string +): Promise { + const subKey = `${userAddress}:${dex}`; + + if (!this.clearinghouseStateSubscriptions.has(subKey)) { + const chStateSub = await this.subscriptionClient.subscribeToClearinghouseState( + { user: userAddress, dex }, + (data) => { + // Process positions + if (data.assetPositions) { + const positions = this.transformPositions(data.assetPositions, dex); + this.dexPositionsCache.set(dex, positions); + } + + // Process account state + const accountState = this.transformAccountState(data, dex); + this.dexAccountCache.set(dex, accountState); + + this.emitAggregatedData(); + } + ); + + this.clearinghouseStateSubscriptions.set(subKey, chStateSub); + } +} +``` + +**Data Available**: + +- Positions with unrealized PnL +- Account balance and margin +- **Not Available**: Orders (requires REST API fallback) + +**Orders Fallback**: `HyperLiquidProvider.ts:1247-1304` + +```typescript +// Fetch orders via REST for HIP-3 DEXs +async getOpenOrders(accountId?: CaipAccountId): Promise { + const allOrders: Order[] = []; + + // Get orders for each DEX + for (const dex of this.getValidatedDexs()) { + const dexOrders = await this.infoClient.frontendOpenOrders(userAddress, dex ?? ''); + allOrders.push(...this.transformOrders(dexOrders, dex)); + } + + return allOrders; +} +``` + +### 4.3 Market Data Subscription (assetCtxs) + +**Setup**: `HyperLiquidSubscriptionService.ts:911-966` + +```typescript +async subscribeToMarketData( + assets: string[], + callback: (data: MarketDataUpdate) => void +): Promise<() => void> { + // Group assets by DEX + const assetsByDex = this.groupAssetsByDex(assets); + + for (const [dex, dexAssets] of assetsByDex) { + const subKey = `${dex}:${dexAssets.join(',')}`; + + if (!this.assetCtxsSubscriptions.has(subKey)) { + const assetCtxsSub = await this.subscriptionClient.subscribeToAssetCtxs( + { coins: dexAssets, dex: dex || '' }, + (data) => { + // Transform and emit market data + const marketData = this.transformMarketData(data, dex); + callback(marketData); + } + ); + + this.assetCtxsSubscriptions.set(subKey, assetCtxsSub); + } + } + + return () => this.unsubscribeFromMarketData(assets); +} +``` + +**Data Available**: + +- Current price (oracle price) +- 24h price change +- 24h volume +- Funding rate (current + predicted) +- Open interest +- Mark price + +## 5. Balance Management + +HyperLiquid supports two modes for managing balances across DEXs: + +### 5.1 Native DEX Abstraction (Primary) + +**Feature Flag**: `useDexAbstraction: true` (default) + +**How It Works**: + +- User maintains single unified balance on main DEX +- HyperLiquid SDK automatically transfers required margin to HIP-3 DEX during order placement +- User sees single balance in UI (aggregated view) + +**Implementation**: `HyperLiquidProvider.ts:1084-1147` + +```typescript +async placeOrder(params: OrderParams): Promise { + // Construct order with native balance abstraction + const order = { + asset: params.asset, // Asset ID (handles routing automatically) + isBuy: params.isBuy, + limitPx: params.limitPrice, + sz: params.size, + reduceOnly: params.reduceOnly, + // No explicit DEX parameter needed - SDK handles it + }; + + return this.exchangeClient.placeOrder(order, vaultAddress); +} +``` + +**Decision Rationale**: + +- **Simplified UX**: User doesn't need to manage per-DEX balances +- **SDK Handling**: HyperLiquid SDK manages transfers automatically + +### 5.2 Programmatic Transfer (Fallback) + +**Feature Flag**: `useDexAbstraction: false` + +**How It Works**: + +- User maintains separate balances per DEX +- MetaMask calculates required margin and auto-transfers before order placement +- Cleanup transfers return excess margin to main DEX after order fills + +**Implementation**: `HyperLiquidProvider.ts:863-934` + +```typescript +private async autoTransferForHip3Order(params: { + targetDex: string; + requiredMargin: number; +}): Promise<{ amount: number; sourceDex: string } | null> { + // 1. Check if target DEX has sufficient balance + const targetBalance = await this.getBalanceForDex({ dex: params.targetDex }); + if (targetBalance >= params.requiredMargin) { + return null; // No transfer needed + } + + // 2. Calculate shortfall + const shortfall = params.requiredMargin - targetBalance; + + // 3. Find source DEX with sufficient balance + const source = await this.findSourceDexWithBalance({ + targetDex: params.targetDex, + requiredAmount: shortfall + }); + + if (!source) { + throw new Error('Insufficient balance across all DEXs'); + } + + // 4. Execute transfer + const result = await this.transferBetweenDexs({ + sourceDex: source.sourceDex, + destinationDex: params.targetDex, + amount: (shortfall * HIP3_MARGIN_CONFIG.BUFFER_MULTIPLIER).toFixed(USDC_DECIMALS) + }); + + return result; +} +``` + +**Margin Calculation**: `HyperLiquidProvider.ts:936-969` + +```typescript +private calculateRequiredMargin(params: { + size: number; + price: number; + leverage: number; +}): number { + const notionalValue = params.size * params.price; + const baseMargin = notionalValue / params.leverage; + + // Add buffer for fees and slippage + return baseMargin * HIP3_MARGIN_CONFIG.BUFFER_MULTIPLIER; // 1.003 (0.3% buffer) +} +``` + +**Auto-Rebalance**: `HyperLiquidProvider.ts:971-1037` + +```typescript +private async autoRebalanceAfterTrade(params: { + targetDex: string; +}): Promise { + // 1. Get current balance on HIP-3 DEX + const currentBalance = await this.getBalanceForDex({ dex: params.targetDex }); + + // 2. Get locked margin from open positions + const lockedMargin = await this.getLockedMarginForDex({ dex: params.targetDex }); + + // 3. Calculate excess + const excess = currentBalance - lockedMargin - HIP3_MARGIN_CONFIG.REBALANCE_DESIRED_BUFFER; + + // 4. Transfer excess back to main DEX if above threshold + if (excess >= HIP3_MARGIN_CONFIG.REBALANCE_MIN_THRESHOLD) { + await this.transferBetweenDexs({ + sourceDex: params.targetDex, + destinationDex: null, // Main DEX + amount: excess.toFixed(USDC_DECIMALS) + }); + } +} +``` + +**Configuration**: `hyperLiquidConfig.ts:288-307` + +```typescript +export const HIP3_MARGIN_CONFIG = { + BUFFER_MULTIPLIER: 1.003, // 0.3% buffer for fees/slippage + REBALANCE_DESIRED_BUFFER: 0.1, // Keep $0.1 on DEX for quick orders + REBALANCE_MIN_THRESHOLD: 0.1, // Only rebalance if excess > $0.1 +}; +``` + +**Decision Rationale**: + +- **Fallback Safety**: Ensures functionality if native abstraction has issues +- **Minimal Locked Capital**: Auto-rebalance keeps only necessary margin on HIP-3 DEXs +- **Efficient Capital Use**: Excess margin returns to main DEX for global utilization +- **Fee Optimization**: Buffer covers HyperLiquid's max taker fee (0.045%) + +## 6. Asset Mapping and DEX Routing + +### 6.1 Asset Mapping Build Process + +**Location**: `HyperLiquidProvider.ts:444-561` + +```typescript +private async buildAssetMapping(): Promise { + // Get list of DEXs to map: [null (main), 'xyz', 'abc', ...] + const dexsToMap = await this.getValidatedDexs(); + + // Fetch metadata for all DEXs in parallel + const allMetas = await Promise.allSettled( + dexsToMap.map((dex) => this.infoClient.meta({ dex: dex ?? '' })) + ); + + // Build mappings for each DEX + for (let perpDexIndex = 0; perpDexIndex < allMetas.length; perpDexIndex++) { + const metaResult = allMetas[perpDexIndex]; + if (metaResult.status !== 'fulfilled') continue; + + const dex = dexsToMap[perpDexIndex]; + const meta = metaResult.value; + + // Build asset mappings using utility function + const { coinToAssetId, assetIdToCoin } = buildAssetMapping({ + metaUniverse: meta.universe, + dex, + perpDexIndex + }); + + // Store in provider's lookup maps + coinToAssetId.forEach((assetId, coin) => { + this.coinToAssetId.set(coin, assetId); + this.assetIdToCoin.set(assetId, coin); + }); + } +} +``` + +**Mapping Utility**: `hyperLiquidAdapter.ts:331-358` + +```typescript +export function buildAssetMapping(params: { + metaUniverse: MetaResponse['universe']; + dex?: string | null; + perpDexIndex: number; +}): { + coinToAssetId: Map; + assetIdToCoin: Map; +} { + const coinToAssetId = new Map(); + const assetIdToCoin = new Map(); + + params.metaUniverse.forEach((asset, index) => { + // Calculate global asset ID + const assetId = calculateHip3AssetId(params.perpDexIndex, index); + + // API returns asset names already formatted: + // Main DEX: "BTC", "ETH", "SOL" + // HIP-3 DEXs: "xyz:TSLA", "xyz:NVDA", "abc:GOLD" + const assetName = asset.name; + + coinToAssetId.set(assetName, assetId); + assetIdToCoin.set(assetId, assetName); + }); + + return { coinToAssetId, assetIdToCoin }; +} +``` + +**Example Mapping**: + +``` +Main DEX (perpDexIndex=0): + "BTC" → 0 + "ETH" → 1 + "SOL" → 2 + +xyz DEX (perpDexIndex=1): + "xyz:TSLA" → 110000 + "xyz:NVDA" → 110001 + "xyz:XYZ100" → 110002 + +abc DEX (perpDexIndex=2): + "abc:GOLD" → 120000 + "abc:ABC500" → 120001 +``` + +### 6.2 Order Routing + +**Order Placement**: `HyperLiquidProvider.ts:1084-1147` + +```typescript +async placeOrder(params: OrderParams): Promise { + // 1. Resolve asset name to global asset ID + const assetId = this.coinToAssetId.get(params.asset); + if (assetId === undefined) { + throw new Error(`Unknown asset: ${params.asset}`); + } + + // 2. Extract DEX from asset name (if HIP-3) + const { dex, symbol } = parseAssetName(params.asset); + + // 3. Auto-transfer if needed (programmatic mode only) + if (!this.useDexAbstraction && dex) { + const requiredMargin = this.calculateRequiredMargin({ + size: params.size, + price: params.limitPrice, + leverage: params.leverage + }); + + await this.autoTransferForHip3Order({ + targetDex: dex, + requiredMargin + }); + } + + // 4. Place order (asset ID handles routing) + const order = { + asset: assetId, // Global asset ID determines DEX automatically + isBuy: params.isBuy, + limitPx: params.limitPrice, + sz: params.size, + reduceOnly: params.reduceOnly, + }; + + const result = await this.exchangeClient.placeOrder(order, vaultAddress); + + // 5. Auto-rebalance after trade (programmatic mode only) + if (!this.useDexAbstraction && dex) { + await this.autoRebalanceAfterTrade({ targetDex: dex }); + } + + return result; +} +``` + +**DEX Resolution**: + +- **Main DEX**: Asset ID 0-9999 → routes to main DEX +- **xyz DEX**: Asset ID 110000-110999 → routes to xyz DEX (perpDexIndex=1) +- **abc DEX**: Asset ID 120000-120999 → routes to abc DEX (perpDexIndex=2) + +**No Explicit DEX Parameter**: HyperLiquid SDK uses asset ID to determine routing automatically. + +## 7. Protocol Abstraction Preservation + +### 7.1 Controller Layer (Zero HIP-3 Awareness) + +**File**: `PerpsController.ts` + +**Key Principle**: PerpsController has **zero HIP-3-specific code**. All HIP-3 complexity is encapsulated in the provider layer. + +**Feature Flag Injection**: `PerpsController.ts:633-648` + +```typescript +constructor({ + messenger, + state = {}, + clientConfig = {}, +}: PerpsControllerOptions) { + // Store HIP-3 configuration (immutable after construction) + this.equityEnabled = clientConfig.equityEnabled ?? false; + this.enabledDexs = [...(clientConfig.enabledDexs ?? [])]; + + // Pass to provider without direct HIP-3 knowledge + // Controller doesn't interpret these flags +} +``` + +**Order Placement**: `PerpsController.ts:1084-1333` + +```typescript +async placeOrder(params: OrderParams): Promise { + // Get active provider (protocol-agnostic) + const provider = this.getActiveProvider(); + + // Provider handles all HIP-3 complexity: + // - Asset ID resolution + // - DEX routing + // - Balance management + // - Auto-transfers + return provider.placeOrder(params); +} +``` + +**Balance Retrieval**: `PerpsController.ts:920-974` + +```typescript +async getAccountBalance(accountId?: CaipAccountId): Promise { + const provider = this.getActiveProvider(); + + // Provider returns aggregated balance + // Controller doesn't know about per-DEX balances + return provider.getAccountBalance(accountId); +} +``` + +**Decision Rationale**: + +- **Protocol-Agnostic**: Controller works with any perps provider (HyperLiquid, dYdX, etc.) +- **Easy Migration**: Adding new providers doesn't require controller changes +- **Clean Separation**: Business logic (controller) vs. protocol specifics (provider) + +### 7.2 Provider Layer (HIP-3 Implementation) + +**File**: `HyperLiquidProvider.ts` + +**Key Principle**: Provider implements IPerpsProvider interface while handling all HIP-3 specifics internally. + +**Interface Implementation**: + +```typescript +export class HyperLiquidProvider implements IPerpsProvider { + // Feature flags (private, not exposed via interface) + private equityEnabled: boolean; + private enabledDexs: string[]; + private useDexAbstraction: boolean; + + // Asset mappings (private, not exposed via interface) + private coinToAssetId: Map; + private assetIdToCoin: Map; + + // IPerpsProvider interface methods (protocol-agnostic signatures) + async initialize(config: ProviderConfig): Promise; + async placeOrder(params: OrderParams): Promise; + async getPositions(accountId?: CaipAccountId): Promise; + async getAccountBalance(accountId?: CaipAccountId): Promise; + // ... more interface methods +} +``` + +**Interface Compliance**: + +- **Standard Method Signatures**: No HIP-3-specific parameters in interface methods +- **Internal Complexity**: HIP-3 logic hidden behind interface methods +- **Aggregated Data**: Returns unified views (positions, balances) across all DEXs + +**Decision Rationale**: + +- **Swappable Providers**: Controller can switch providers without code changes +- **Encapsulation**: HIP-3 complexity doesn't leak into controller layer +- **Testing**: Easy to mock providers for controller testing + +### 7.3 Adapter Layer (Type Transformations) + +**File**: `hyperLiquidAdapter.ts` + +**Purpose**: Convert between HyperLiquid SDK types and protocol-agnostic types. + +**Example Transformations**: + +```typescript +// SDK Position → Protocol-Agnostic Position +export function transformPosition( + sdkPosition: SDKPosition, + dex: string | null, +): Position { + return { + asset: sdkPosition.coin, // Already formatted: "BTC" or "xyz:TSLA" + size: parseFloat(sdkPosition.szi), + entryPrice: parseFloat(sdkPosition.entryPx), + unrealizedPnl: parseFloat(sdkPosition.unrealizedPnl), + leverage: parseFloat(sdkPosition.leverage.value), + marginUsed: parseFloat(sdkPosition.marginUsed), + // ... more fields + }; +} + +// SDK Order → Protocol-Agnostic Order +export function transformOrder(sdkOrder: SDKOrder, dex: string | null): Order { + return { + orderId: sdkOrder.oid.toString(), + asset: sdkOrder.coin, // Already formatted: "BTC" or "xyz:TSLA" + side: sdkOrder.side === 'B' ? 'buy' : 'sell', + size: parseFloat(sdkOrder.sz), + price: parseFloat(sdkOrder.limitPx), + // ... more fields + }; +} +``` + +**Decision Rationale**: + +- **Decoupling**: Controller doesn't depend on HyperLiquid SDK types +- **Provider Flexibility**: Easy to swap SDK versions without controller changes +- **Type Safety**: Compile-time checks for protocol-agnostic types + +## 8. Code Reference Map + +### Configuration & Constants + +- **`hyperLiquidConfig.ts:231-239`**: HIP-3 asset ID configuration (BASE_ASSET_ID, DEX_MULTIPLIER) +- **`hyperLiquidConfig.ts:288-307`**: HIP-3 margin configuration (buffer, rebalance thresholds) +- **`hyperLiquidConfig.ts:266-279`**: HIP-3 asset market types (equity, commodity, forex badges) + +### Asset Mapping & Routing + +- **`hyperLiquidAdapter.ts:331-358`**: `buildAssetMapping()` - Asset mapping builder +- **`hyperLiquidAdapter.ts:456-468`**: `calculateHip3AssetId()` - Asset ID calculation formula +- **`hyperLiquidAdapter.ts:484-496`**: `parseAssetName()` - Asset name parsing (dex:SYMBOL) +- **`HyperLiquidProvider.ts:444-561`**: `buildAssetMapping()` - Provider asset mapping initialization + +### Feature Flags + +- **`PerpsController.ts:633-648`**: Feature flag injection (constructor) +- **`HyperLiquidProvider.ts:158-163`**: Feature flag storage (private fields) +- **`HyperLiquidProvider.ts:239-304`**: Feature flag initialization + +### WebSocket Subscriptions + +- **`HyperLiquidSubscriptionService.ts:44-112`**: Subscription storage (Maps for all subscription types) +- **`HyperLiquidSubscriptionService.ts:671-717`**: `ensureSharedWebData2Subscription()` - Main subscription setup +- **`HyperLiquidSubscriptionService.ts:738-782`**: `createWebData2Subscription()` - Main DEX subscription +- **`HyperLiquidSubscriptionService.ts:784-832`**: `ensureClearinghouseStateSubscription()` - HIP-3 DEX subscription +- **`HyperLiquidSubscriptionService.ts:911-966`**: `subscribeToMarketData()` - Market data subscription + +### Data Aggregation + +- **`HyperLiquidSubscriptionService.ts:477-511`**: `aggregateAccountStates()` - Balance aggregation +- **`HyperLiquidSubscriptionService.ts:513-530`**: `aggregatePositions()` - Position aggregation +- **`HyperLiquidSubscriptionService.ts:532-586`**: Change detection (hash-based) + +### Balance Management + +- **`HyperLiquidProvider.ts:863-934`**: `autoTransferForHip3Order()` - Programmatic auto-transfer +- **`HyperLiquidProvider.ts:936-969`**: `calculateRequiredMargin()` - Margin calculation +- **`HyperLiquidProvider.ts:971-1037`**: `autoRebalanceAfterTrade()` - Post-trade rebalance +- **`HyperLiquidProvider.ts:1039-1082`**: `transferBetweenDexs()` - DEX-to-DEX transfer + +### Order Placement + +- **`HyperLiquidProvider.ts:1084-1147`**: `placeOrder()` - Order placement with routing +- **`PerpsController.ts:1084-1333`**: `placeOrder()` - Controller (protocol-agnostic) + +### Protocol Abstraction + +- **`PerpsController.ts:920-974`**: `getAccountBalance()` - Controller (protocol-agnostic) +- **`HyperLiquidProvider.ts:1247-1304`**: `getOpenOrders()` - Provider (HIP-3 REST fallback) + +## Appendix: HIP-3 Protocol Summary + +### Core Concepts + +- **Builder Deployment**: Builders stake 500k HYPE to deploy custom perpetuals +- **Isolated Margin**: Each DEX uses isolated margin +- **Separate Orderbooks**: Each DEX has independent liquidity +- **Settlement Authority**: Deployers can settle positions (with slashing risk) +- **Open Interest Caps**: Per-DEX notional caps + per-asset size caps + +### Asset ID Formula + +``` +Main DEX (perpDexIndex=0): + assetId = index + +HIP-3 DEX (perpDexIndex > 0): + assetId = 100000 + (perpDexIndex × 10000) + index +``` + +### Naming Convention + +``` +Main DEX: "BTC", "ETH", "SOL" +HIP-3 DEXs: "xyz:TSLA", "xyz:NVDA", "abc:GOLD" +``` + +### WebSocket Subscriptions + +**Connection**: 1 shared WebSocket connection for all subscriptions + +| Type | Count | DEXs | Data | +| -------------------- | ----------------- | -------------- | ------------------------------------ | +| `webData2` | 1 | Main DEX only | Positions, Orders, Account, Fills | +| `clearinghouseState` | 1 per HIP-3 DEX | Each HIP-3 DEX | Positions, Account (orders via REST) | +| `assetCtxs` | Per-DEX as needed | All DEXs | Market data, Funding rates | + +**Example**: With "xyz" and "abc" enabled → 3 subscriptions on 1 WebSocket: + +- 1 webData2 (main) +- 1 clearinghouseState (xyz) +- 1 clearinghouseState (abc) + +### Balance Management Modes + +**Native DEX Abstraction** (default): + +- Single unified balance +- SDK auto-transfers during orders +- Simplified UX + +**Programmatic Transfer**: + +- Per-DEX balances +- MetaMask manages transfers +- Auto-rebalance after trades + +--- + +**Document Version**: 1.0 +**Last Updated**: 2025-01-28 +**Related Docs**: + +- [HIP-3 Specification](./HIP-3.md) +- [HyperLiquid Protocol Overview](./HYPERLIQUID.md) diff --git a/docs/perps/perps-connection-architecture.md b/docs/perps/perps-connection-architecture.md index 349190616b1e..c53a54449041 100644 --- a/docs/perps/perps-connection-architecture.md +++ b/docs/perps/perps-connection-architecture.md @@ -90,7 +90,7 @@ graph TD - Grace period timer (`CONNECTION_GRACE_PERIOD_MS` = 20s delay before disconnect) - Connection timeout management (30s limit for connection attempts) - Reference counting (tracks active provider instances) -- Stream manager caches +- Stream manager caches (via PerpsStreamManager singleton - separate channels for prices, orders, positions, account state; provides instant cached data to subscribers; supports pause/resume for race condition prevention) - Redux store subscription for account/network change monitoring **Responsibilities**: diff --git a/docs/perps/perps-metametrics-reference.md b/docs/perps/perps-metametrics-reference.md index bac4bf3105c7..f511b3b671e9 100644 --- a/docs/perps/perps-metametrics-reference.md +++ b/docs/perps/perps-metametrics-reference.md @@ -75,26 +75,31 @@ MetaMetrics.getInstance().trackEvent( **Properties:** -- `screen_type` (required): `'homescreen' | 'markets' | 'trading' | 'position_close' | 'leverage' | 'tutorial' | 'withdrawal' | 'tp_sl' | 'asset_details'` +- `screen_type` (required): `'homescreen' | 'markets' | 'trading' | 'position_close' | 'leverage' | 'tutorial' | 'withdrawal' | 'tp_sl' | 'asset_details' | 'close_all_positions' | 'cancel_all_orders'` - `asset` (optional): Asset symbol (e.g., `'BTC'`, `'ETH'`) - `direction` (optional): `'long' | 'short'` - `source` (optional): Where user came from (e.g., `'banner'`, `'notification'`, `'main_action_button'`, `'position_tab'`, `'perp_markets'`, `'deeplink'`, `'tutorial'`) +- `open_position` (optional): Number of open positions (used for close_all_positions screen, number) ### 2. PERPS_UI_INTERACTION **Properties:** -- `interaction_type` (required): `'tap' | 'zoom' | 'slide' | 'search_clicked' | 'order_type_viewed' | 'order_type_selected' | 'setting_changed' | 'tutorial_started' | 'tutorial_completed' | 'tutorial_navigation' | 'candle_period_viewed' | 'candle_period_changed'` -- `action_type` (optional): `'start_trading' | 'skip' | 'stop_loss_set' | 'take_profit_set'` +- `interaction_type` (required): `'tap' | 'zoom' | 'slide' | 'search_clicked' | 'order_type_viewed' | 'order_type_selected' | 'setting_changed' | 'tutorial_started' | 'tutorial_completed' | 'tutorial_navigation' | 'candle_period_viewed' | 'candle_period_changed' | 'sort_field_changed' | 'sort_direction_changed' | 'favorite_toggled'` (Note: `favorite_toggled` = watchlist toggle) +- `action_type` (optional): `'start_trading' | 'skip' | 'stop_loss_set' | 'take_profit_set' | 'close_all_positions' | 'cancel_all_orders' | 'learn_more' | 'favorite_market' | 'unfavorite_market'` (Note: `favorite_market` = add to watchlist, `unfavorite_market` = remove from watchlist) - `asset` (optional): Asset symbol (e.g., `'BTC'`, `'ETH'`) - `direction` (optional): `'long' | 'short'` - `order_size` (optional): Size of the order in tokens (number) - `leverage_used` (optional): Leverage value being used (number) - `order_type` (optional): `'market' | 'limit'` -- `setting_type` (optional): Type of setting changed (e.g., `'leverage'`) +- `setting_type` (optional): Type of setting changed (e.g., `'leverage'`, `'sort_field'`, `'sort_direction'`) - `input_method` (optional): How value was entered: `'slider' | 'keyboard' | 'preset' | 'manual' | 'percentage_button'` - `candle_period` (optional): Selected candle period - `component_name` (optional): Name of the component interacted with +- `sort_field` (optional): Market sort field selected: `'volume' | 'price_change' | 'market_cap' | 'funding_rate' | 'new'` +- `sort_direction` (optional): Sort direction: `'asc' | 'desc'` +- `search_query` (optional): Search query text (when search_clicked) +- `favorites_count` (optional): Total number of markets in watchlist after toggle (number, used with `favorite_toggled`) ### 3. PERPS_TRADE_TRANSACTION @@ -123,28 +128,35 @@ MetaMetrics.getInstance().trackEvent( **Properties:** - `status` (required): `'submitted' | 'executed' | 'partially_filled' | 'failed'` -- `asset` (required): Asset symbol (e.g., `'BTC'`, `'ETH'`) -- `direction` (required): `'long' | 'short'` +- `asset` (required): Asset symbol (e.g., `'BTC'`, `'ETH'`) - For batch operations, use `'MULTIPLE'` +- `direction` (required): `'long' | 'short'` - For batch operations with mixed directions, use `'mixed'` - `order_type` (required): `'market' | 'limit'` - `open_position_size` (required): Size of the open position (number) - `order_size` (required): Size being closed (number) - `completion_duration` (required): Duration in milliseconds (number) -- `close_type` (optional): `'full' | 'partial'` +- `close_type` (optional): `'full' | 'partial' | 'batch'` - Use `'batch'` for close all operations - `percentage_closed` (optional): Percentage of position closed (number) - `dollar_pnl` (optional): Profit/loss in dollars (number) - `percent_pnl` (optional): Profit/loss as percentage (number) - `fee` (optional): Fee paid in USDC (number) - `received_amount` (optional): Amount received after close (number) - `input_method` (optional): How value was entered: `'slider' | 'keyboard' | 'preset' | 'manual' | 'percentage_button'` +- `positions_count` (optional): Number of positions closed in batch operation (number) +- `success_count` (optional): Number of positions successfully closed in batch (number) +- `failure_count` (optional): Number of positions that failed to close in batch (number) ### 5. PERPS_ORDER_CANCEL_TRANSACTION **Properties:** - `status` (required): `'submitted' | 'executed' | 'failed'` -- `asset` (required): Asset symbol (e.g., `'BTC'`, `'ETH'`) +- `asset` (required): Asset symbol (e.g., `'BTC'`, `'ETH'`) - For batch operations, use `'MULTIPLE'` - `completion_duration` (required): Duration in milliseconds (number) - `order_type` (optional): `'market' | 'limit'` +- `cancel_type` (optional): `'single' | 'batch'` - Use `'batch'` for cancel all operations +- `orders_count` (optional): Number of orders cancelled in batch operation (number) +- `success_count` (optional): Number of orders successfully cancelled in batch (number) +- `failure_count` (optional): Number of orders that failed to cancel in batch (number) ### 6. PERPS_WITHDRAWAL_TRANSACTION diff --git a/docs/perps/perps-sentry-reference.md b/docs/perps/perps-sentry-reference.md index fee2c4d71e13..f60fa9f05383 100644 --- a/docs/perps/perps-sentry-reference.md +++ b/docs/perps/perps-sentry-reference.md @@ -139,16 +139,18 @@ setMeasurement( **Purpose:** Track screen load times and user-perceived performance. -| TraceName | Conditions Tracked | Notes | -| --------------------------- | --------------------------------------------- | ------------------ | -| `PerpsTabView` | Tab visible, markets loaded, connection ready | Main perps landing | -| `PerpsMarketListView` | Markets data, prices available | Market browser | -| `PerpsPositionDetailsView` | Position data, market stats, history loaded | Position details | -| `PerpsOrderView` | Current price, market data, account available | Trade entry | -| `PerpsClosePositionView` | Position data, current price | Position exit | -| `PerpsWithdrawView` | Account balance, destination token | Withdrawal form | -| `PerpsTransactionsView` | Order fills loaded | History view | -| `PerpsOrderSubmissionToast` | Immediate (shows when toast appears) | Order feedback | +| TraceName | Conditions Tracked | Notes | +| --------------------------- | --------------------------------------------- | -------------------------------------------------------- | +| `PerpsTabView` | Tab visible, markets loaded, connection ready | Main perps landing | +| `PerpsMarketListView` | Markets data, prices available | Market browser (also used for home view for consistency) | +| `PerpsPositionDetailsView` | Position data, market stats, history loaded | Position details | +| `PerpsOrderView` | Current price, market data, account available | Trade entry | +| `PerpsClosePositionView` | Position data, current price | Position exit | +| `PerpsWithdrawView` | Account balance, destination token | Withdrawal form | +| `PerpsTransactionsView` | Order fills loaded | History view | +| `PerpsOrderSubmissionToast` | Immediate (shows when toast appears) | Order feedback | + +**Note on PerpsHomeView:** The new home view introduced in TAT-1538 uses `PerpsMarketListView` trace name for consistency with existing metrics, as it replaced the previous market list view. This maintains historical performance comparison capability. **Measurements (sub-operations):** | PerpsMeasurementName | Unit | Description | @@ -170,15 +172,23 @@ setMeasurement( **Purpose:** Track order execution, position management, and transaction completion. -| TraceName | Operation | Tags | Data Attributes | -| -------------------- | ------------------------- | ------------------------------------------------ | --------------------------------------- | -| `PerpsPlaceOrder` | `PerpsOrderSubmission` | provider, orderType, market, leverage, isTestnet | isBuy, orderPrice, success, orderId | -| `PerpsEditOrder` | `PerpsOrderSubmission` | provider, orderType, market, leverage, isTestnet | isBuy, orderPrice, success, orderId | -| `PerpsCancelOrder` | `PerpsOrderSubmission` | provider, market, isTestnet | orderId, success | -| `PerpsClosePosition` | `PerpsPositionManagement` | provider, coin, closeSize, isTestnet | success, filledSize | -| `PerpsUpdateTPSL` | `PerpsPositionManagement` | provider, market, isTestnet | takeProfitPrice, stopLossPrice, success | -| `PerpsWithdraw` | `PerpsOperation` | assetId, provider, isTestnet | success, txHash, withdrawalId | -| `PerpsDeposit` | `PerpsOperation` | assetId, provider, isTestnet | success, txHash | +| TraceName | Operation | Tags | Data Attributes | +| -------------------- | ------------------------- | --------------------------------------------------------- | ----------------------------------------------------------------- | +| `PerpsPlaceOrder` | `PerpsOrderSubmission` | provider, orderType, market, leverage, isTestnet | isBuy, orderPrice, success, orderId | +| `PerpsEditOrder` | `PerpsOrderSubmission` | provider, orderType, market, leverage, isTestnet | isBuy, orderPrice, success, orderId | +| `PerpsCancelOrder` | `PerpsOrderSubmission` | provider, market, isTestnet, **isBatch** (batch ops only) | orderId, success, **coinCount** (batch), **successCount** (batch) | +| `PerpsClosePosition` | `PerpsPositionManagement` | provider, coin, closeSize, isTestnet, **isBatch** (batch) | success, filledSize, **closeAll** (batch), **coinCount** (batch) | +| `PerpsUpdateTPSL` | `PerpsPositionManagement` | provider, market, isTestnet | takeProfitPrice, stopLossPrice, success | +| `PerpsWithdraw` | `PerpsOperation` | assetId, provider, isTestnet | success, txHash, withdrawalId | +| `PerpsDeposit` | `PerpsOperation` | assetId, provider, isTestnet | success, txHash | + +**Batch Operations Pattern:** + +- `closePositions()` and `cancelOrders()` use the same trace names as single operations +- Add `isBatch: 'true'` tag to distinguish batch from single operations +- Include `coinCount` or order count in data attributes +- Add `closeAll: 'true'` data attribute when closing all positions +- Batch operations execute individual traces in parallel and aggregate results ### WebSocket Performance (6 events) diff --git a/e2e/selectors/Perps/Perps.selectors.ts b/e2e/selectors/Perps/Perps.selectors.ts index 2402587372fc..362c40ef57cf 100644 --- a/e2e/selectors/Perps/Perps.selectors.ts +++ b/e2e/selectors/Perps/Perps.selectors.ts @@ -107,9 +107,14 @@ export const PerpsMarketListViewSelectorsIDs = { CLOSE_BUTTON: 'perps-market-list-close-button', BACK_HEADER_BUTTON: 'perps-market-header-back-button', BACK_LIST_BUTTON: 'perps-market-list-back-button', - SEARCH_CLEAR_BUTTON: 'perps-market-list-search-clear-button', + BACK_BUTTON: 'perps-market-list-back-button', + SEARCH_CLEAR_BUTTON: 'perps-market-list-search-bar-clear', + SEARCH_BAR: 'perps-market-list-search-bar', SKELETON_ROW: 'perps-market-list-skeleton-row', LIST_HEADER: 'perps-market-list-header', + MARKET_LIST: 'perps-market-list', + SORT_FILTERS: 'perps-market-list-sort-filters', + WATCHLIST_TOGGLE: 'perps-market-list-watchlist-toggle', }; // ======================================== @@ -177,9 +182,21 @@ export const PerpsTabViewSelectorsIDs = { SCROLL_VIEW: 'perps-tab-scroll-view', }; +export const PerpsHomeViewSelectorsIDs = { + BACK_BUTTON: 'back-button', + SEARCH_TOGGLE: 'perps-home-search-toggle', + SEARCH_INPUT: 'perps-home-search', + SCROLL_CONTENT: 'scroll-content', + // TabBar mock items (for testing) + TAB_BAR_WALLET: 'tab-bar-item-wallet', + TAB_BAR_BROWSER: 'tab-bar-item-browser', + TAB_BAR_ACTIONS: 'tab-bar-item-actions', + TAB_BAR_ACTIVITY: 'tab-bar-item-activity', +}; + export const PerpsPositionsViewSelectorsIDs = { REFRESH_CONTROL: 'refresh-control', - BACK_BUTTON: 'button-icon-arrow-left', + BACK_BUTTON: 'back-button', POSITION_ITEM: 'perps-positions-item', POSITIONS_SECTION: 'perps-positions-section', POSITIONS_SECTION_TITLE: 'perps-positions-section-title', @@ -205,6 +222,7 @@ export const PerpsPositionDetailsViewSelectorsIDs = { // ======================================== export const PerpsTPSLViewSelectorsIDs = { + BACK_BUTTON: 'back-button', BOTTOM_SHEET: 'perps-tpsl-bottomsheet', SET_BUTTON: 'bottomsheetfooter-button', } as const; @@ -476,6 +494,9 @@ export const PerpsMarketTabsSelectorsIDs = { // Statistics-only view STATISTICS_ONLY_TITLE: 'perps-market-tabs-statistics-only-title', + // Activity link + ACTIVITY_LINK: 'perps-market-tabs-activity-link', + // Loading states SKELETON_TAB_BAR: 'perps-market-tabs-skeleton-tab-bar', SKELETON_CONTENT: 'perps-market-tabs-skeleton-content', diff --git a/locales/languages/en.json b/locales/languages/en.json index 257ef02cb640..205fa149edd1 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -416,9 +416,9 @@ "title": "MetaMask password", "description": "Unlocks MetaMask on this device only.", "description_social_login": "Use this for wallet recovery on all devices.", - "description_social_login_update" : "If you lose this password, you’ll lose access to your wallet on all devices. Store it somewhere safe,", - "description_social_login_update_ios" : "Use this for wallet recovery on all devices. MetaMask can’t reset it.", - "description_social_login_update_bold" : "MetaMask can't reset it.", + "description_social_login_update": "If you lose this password, you’ll lose access to your wallet on all devices. Store it somewhere safe,", + "description_social_login_update_ios": "Use this for wallet recovery on all devices. MetaMask can’t reset it.", + "description_social_login_update_bold": "MetaMask can't reset it.", "subtitle": "This password will unlock your MetaMask wallet only on this device.", "password": "Create password", "confirm_password": "Confirm password", @@ -445,8 +445,8 @@ "create_password_cta": "Create password", "steps": "Step {{currentStep}} of {{totalSteps}}", "password_error": "Passwords don’t match.", - "marketing_opt_in_description" : "Get product updates, tips, and news including by email. We may use your interactions to improve what we share.", - "loose_password_description" : "If I lose this password, MetaMask can’t reset it." + "marketing_opt_in_description": "Get product updates, tips, and news including by email. We may use your interactions to improve what we share.", + "loose_password_description": "If I lose this password, MetaMask can’t reset it." }, "reset_password": { "title": "Change password", @@ -1080,6 +1080,7 @@ "insufficient_funds": "Insufficient funds", "insufficient_balance": "Insufficient balance. Required: ${{required}}, Available: ${{available}}", "invalid_leverage": "Leverage must be between {{min}}x and {{max}}x", + "leverage_below_position": "Leverage must be at least {{required}}x to match your existing position (current: {{provided}}x)", "max_order_value": "Order size must be below ${{maxValue}}", "high_leverage_warning": "High leverage increases liquidation risk", "invalid_take_profit": "Take profit must be {{direction}} current price for {{positionType}} positions", @@ -1201,7 +1202,7 @@ "your_funds_will_be_available_momentarily": "Your funds will be available momentarily", "cancel": "Cancel", "margin": "Margin", - "includes_pnl":"includes P&L", + "includes_pnl": "includes P&L", "pnl": "PnL", "estimated_pnl": "Estimated PnL", "fees": "Fees", @@ -1396,6 +1397,7 @@ "add_funds_to_start_trading_perps": "Add funds to start trading perps", "position": "Position", "orders": "Orders", + "go_to_activity": "Go to your activity", "badge": { "experimental": "EXPERIMENTAL", "equity": "STOCK", @@ -1511,12 +1513,88 @@ "days": "Days" } }, + "home": { + "close_all": "Close all", + "cancel_all": "Cancel all", + "no_markets": "No markets available", + "recent_activity": "Recent Activity", + "loading": "Loading...", + "no_activity": "No recent activity", + "see_all": "See all", + "positions": "Positions", + "orders": "Orders", + "perps": "Perps", + "crypto": "Crypto", + "stocks": "Stocks", + "commodities": "Commodities", + "forex": "Forex", + "watchlist": "Watchlist", + "markets": "Markets" + }, + "learn_more": { + "title": "Learn more about Perps", + "description": "Discover how perpetual trading works and how to get started", + "cta": "Learn more" + }, + "support": { + "title": "Need help?", + "description": "Contact MetaMask Support for assistance" + }, + "close_all_modal": { + "title": "Close all positions", + "description": "We'll close all your open positions at current market price.", + "margin": "Margin", + "includes_pnl": "includes {{pnlAmount}}", + "fees": "Fees", + "you_will_receive": "You'll receive", + "keep_positions": "Keep positions", + "close_all": "Close all", + "closing": "Closing...", + "success_title": "Positions closed", + "success_message": "Successfully closed {{count}} position(s)", + "partial_success": "Closed {{successCount}} of {{totalCount}} positions", + "error_title": "Failed to close positions", + "error_message": "Failed to close {{count}} position(s)" + }, + "cancel_all_modal": { + "title": "Cancel all orders", + "description": "We'll cancel all your open orders.", + "keep_orders": "Keep orders", + "confirm": "Confirm", + "canceling": "Canceling...", + "success_title": "Orders canceled", + "success_message": "Successfully canceled {{count}} order(s)", + "partial_success": "Canceled {{successCount}} of {{totalCount}} orders", + "error_title": "Failed to cancel orders", + "error_message": "Failed to cancel {{count}} order(s)" + }, + "sort": { + "volume": "Volume", + "price_change": "Price Change", + "funding_rate": "Funding Rate", + "open_interest": "Open Interest", + "high_to_low": "High to Low", + "low_to_high": "Low to High", + "high": "High", + "low": "Low", + "sort_by": "Sort by", + "sort_direction": "Sort direction", + "favorites": "Watchlist", + "price_change_high_to_low": "Price change: high to low", + "price_change_low_to_high": "Price change: low to high", + "past_hour": "Past hour", + "past_24_hours": "Past 24 hours", + "time": "Time" + }, "perps_markets": "Perps markets", "volume": "Volume", "price_24h_change": "Price / 24h change", "failed_to_load_market_data": "Failed to load market data", + "data_updates_automatically": "Data updates automatically every few seconds", "tap_to_retry": "Tap to retry", "search_by_token_symbol": "Search by token symbol", + "no_favorites_found": "No markets in watchlist", + "no_favorites_description": "Tap the star icon on any market to add it to your watchlist", "no_tokens_found": "No tokens found", "no_tokens_found_description": "We couldn't find any tokens with the name \"{{searchQuery}}\". Try a different search.", "testnet": "Testnet", @@ -1657,21 +1735,21 @@ "outcomes": "outcomes", "volume_abbreviated": "Vol.", "recurrence": { - "none": "None", - "daily": "Daily", - "weekly": "Weekly", - "monthly": "Monthly", - "yearly": "Yearly", - "quarterly": "Quarterly" + "none": "None", + "daily": "Daily", + "weekly": "Weekly", + "monthly": "Monthly", + "yearly": "Yearly", + "quarterly": "Quarterly" }, "outcomes_singular": "outcome", "outcomes_plural": "outcomes", "category": { - "trending": "Trending", - "new": "New", - "sports": "Sports", - "crypto": "Crypto", - "politics": "Politics" + "trending": "Trending", + "new": "New", + "sports": "Sports", + "crypto": "Crypto", + "politics": "Politics" }, "search_placeholder": "Search prediction markets", "search_cancel": "Cancel", @@ -4679,10 +4757,10 @@ "title": "New UI update", "introduction": "We've made updates to improve the app experience.", "descriptions": { - "description_1": "Network dropdown moved to your assets", - "description_2": "Swap and Bridge in one simple flow", - "description_3": "Streamlined Send experience", - "description_4": "A fresh account view" + "description_1": "Network dropdown moved to your assets", + "description_2": "Swap and Bridge in one simple flow", + "description_3": "Streamlined Send experience", + "description_4": "A fresh account view" }, "more_information": "Now you can focus on your tokens and activity, not the networks behind them.", "got_it": "Got it" @@ -5734,7 +5812,6 @@ "user_cancelled_title": "Login cancelled", "user_cancelled_description": "You cancelled the login process.\nTry again when you’re ready.", "user_cancelled_button": "Try again", - "google_login_no_credential_title": "Google login failed", "google_login_no_credential_description": "We couldn’t find a Google account associated with this login. Try again with a different login method.", "google_login_no_credential_button": "Try again", @@ -5819,16 +5896,16 @@ "section_1_description": "One account, addresses on all the networks MetaMask supports. So now you can use Ethereum, Solana, and more without switching accounts.", "section_2_title": "Same address, more networks", "section_2_description": "We’ve grouped your accounts, so keep using MetaMask the same as before. Your funds are safe and unchanged", - "view_accounts_button": "View accounts", - "learn_more_button": "Learn more", - "setting_up_accounts": "Setting up your accounts" - }, - "learn_more": { - "title": "Learn more", - "description": "Multichain accounts are now the default. To opt out, turn off basic functionality.", - "checkbox_label": "Turn off basic functionality", - "confirm_button": "Confirm" - }, + "view_accounts_button": "View accounts", + "learn_more_button": "Learn more", + "setting_up_accounts": "Setting up your accounts" + }, + "learn_more": { + "title": "Learn more", + "description": "Multichain accounts are now the default. To opt out, turn off basic functionality.", + "checkbox_label": "Turn off basic functionality", + "confirm_button": "Confirm" + }, "add_wallet": "Add wallet", "add_hardware_wallet": "Add a hardware wallet", "syncing": "Syncing", @@ -6017,7 +6094,7 @@ "invalid_phone_number": "Invalid phone number", "legal_terms": "By continuing, you agree to receiving SMS to verify your phone number." }, - "confirm_phone_number":{ + "confirm_phone_number": { "title": "Confirm your phone number", "description": "We've sent a confirmation code to:\n{{phoneNumber}}", "confirm_code_label": "Confirmation code", @@ -6052,7 +6129,7 @@ "nationality_placeholder": "Select your nationality", "ssn_placeholder": "Enter your SSN", "invalid_ssn": "Invalid SSN", - "invalid_date_of_birth":"Invalid date of birth. You must be at least 18 years old" + "invalid_date_of_birth": "Invalid date of birth. You must be at least 18 years old" }, "physical_address": { "title": "Residential address", @@ -6068,7 +6145,7 @@ "state_placeholder": "Enter your state", "zip_code_placeholder": "Enter your ZIP code or postal code", "same_mailing_address_label": "Residential address is the same as mailing address", - "electronic_consent":"I agree to the E-Sign Act Consent and Disclosure and to receive all communications electronically." + "electronic_consent": "I agree to the E-Sign Act Consent and Disclosure and to receive all communications electronically." }, "mailing_address": { "title": "Mailing address", @@ -6078,10 +6155,10 @@ "title": "Approved!", "description": "Your KYC was approved. You can now use MetaMask Card." }, - "continue_button": "Next", - "confirm_button": "Confirm", - "retry_button":"Try again", - "errors": { + "continue_button": "Next", + "confirm_button": "Confirm", + "retry_button": "Try again", + "errors": { "email_already_exists": "Email already exists. Please use a different email.", "invalid_email_format": "Invalid email format. Please check your email and try again.", "invalid_verification_code": "Invalid verification code. Please check your code and try again.", @@ -6198,56 +6275,45 @@ "failed_to_authenticate": "Failed to authenticate with rewards program", "not_implemented": "Coming soon", "not_implemented_season_summary": "Season Summary Coming Soon", - - "optin_error": { "title": "Opt-in failed", "description": "Check your connection and try again." }, - "season_status_error": { "error_fetching_title": "Season balance couldn’t be loaded", "error_fetching_description": "Check your connection and try again.", "retry_button": "Retry" }, - "active_boosts_error": { "error_fetching_title": "Boosts couldn’t be loaded", "error_fetching_description": "Check your connection and try again.", "retry_button": "Retry" }, - "unlocked_rewards_error": { "error_fetching_title": "Rewards couldn’t be loaded", "error_fetching_description": "Check your connection and try again.", "retry_button": "Retry" }, - "upcoming_rewards_error": { "error_fetching_title": "Rewards couldn’t be loaded", "error_fetching_description": "Check your connection and try again.", "retry_button": "Retry" }, - "referral_details_error": { "error_fetching_title": "Referral details couldn’t be loaded", "error_fetching_description": "Check your connection and try again.", "retry_button": "Retry" }, - "referral_validation_unknown_error": { "title": "Referral code couldn’t be validated", "description": "Check your connection and try again." }, - "active_activity_error": { "error_fetching_title": "Activity couldn’t be loaded", "error_fetching_description": "Check your connection and try again.", "retry_button": "Retry" }, - "solana_signup_not_supported": "Signing in to Rewards with Solana accounts is not supported yet. Please use an Ethereum account instead.", - "error_messages": { "unknown_error": "Unknown error occurred", "something_went_wrong": "Something went wrong. Please try again shortly.", @@ -6256,18 +6322,14 @@ "failed_to_claim_reward": "Failed to claim reward. Please try again shortly.", "service_not_available": "Service is not available at the moment. Please try again shortly." }, - - "claim_reward_error": { "title": "Failed to claim reward" }, - "accounts_opt_in_state_error": { "error_fetching_title": "Accounts couldn’t be loaded", "error_fetching_description": "Check your connection and try again.", "retry_button": "Retry" }, - "referral_rewards_title": "Referrals", "points": "Points", "point": "Point", @@ -6277,20 +6339,16 @@ "season_ended": "Season ended", "main_title": "Rewards", "referral_title": "Referrals", - "tab_overview_title": "Overview", "tab_activity_title": "Activity", "tab_levels_title": "Levels", - "referral_stats_earned_from_referrals": "Earned from referrals", "referral_stats_referrals": "Referrals", - "loading_activity": "Loading activity...", "error_loading_activity": "Error loading activity", "activity_empty_title": "No recent activity.", "activity_empty_description": "Use MetaMask to earn points, level up, and unlock rewards.", "activity_empty_link": "See ways to earn", - "events": { "to": "to", "type": { @@ -6317,7 +6375,6 @@ "points_boost": "Boost", "points_total": "Total" }, - "onboarding": { "not_supported_region_title": "Region not supported", "not_supported_region_description": "Rewards are not supported in your region yet. We are working on expanding access, so check back later.", @@ -6333,7 +6390,7 @@ "geo_check_fail_description": "We cannot determine if your country allows opting into the rewards program. Please check your connection and try again.", "gtm_title": "Rewards are here", "gtm_description": "Earn points for your activity. \nAdvance through levels to unlock rewards.", - "gtm_confirm":"Get started", + "gtm_confirm": "Get started", "intro_title": "Season 1 \nis Live", "intro_description": "Earn points for your activity. \nAdvance through levels to unlock rewards.", "intro_confirm": "Claim 250 points", @@ -6341,19 +6398,14 @@ "checking_opt_in": "Checking accounts...", "redirecting_to_dashboard": "Redirecting...", "intro_skip": "Not now", - "step_confirm": "Next", "step_skip": "Skip", - "step1_title": "Earn points on every trade", "step1_description": "Every swap and perps trade you make in MetaMask gets you closer to rewards. Add your accounts and watch your points add up.", - "step2_title": "Level up for bigger perks", "step2_description": "Hit points milestones to get perks like 50% off perps fees, exclusive tokens, and a free MetaMask Metal Card.", - "step3_title": "Exclusive seasonal rewards", "step3_description": "Each season brings new perks. Join in, compete, and claim what you can before time runs out.", - "step4_title": "You'll earn 250 points when you sign up!", "step4_title_referral_bonus": "You'll earn 500 points when you sign up with this code!", "step4_title_referral_validating": "Validating referral code...", @@ -6367,11 +6419,9 @@ "step4_success_description": "You have successfully signed up for MetaMask Rewards!", "step4_legal_disclaimer_1": "By selecting 'Claim Points', you agree to the MetaMask Rewards", "step4_legal_disclaimer_2": "Supplemental Terms of Use and Privacy Notice", - "step4_legal_disclaimer_3":". We'll track onchain activity to reward you automatically –", - "step4_legal_disclaimer_4":"learn more" - + "step4_legal_disclaimer_3": ". We'll track onchain activity to reward you automatically –", + "step4_legal_disclaimer_4": "learn more" }, - "settings": { "title": "Rewards Settings", "subtitle": "Accounts", @@ -6387,23 +6437,20 @@ "link_account_failed_error": "Failed to link account", "link_account_unknown_error": "Unknown error occurred" }, - "optout": { - "title": "Opt out of Rewards", - "description": "This will remove your accounts from the Rewards program and erase your points and progress. You won't be able to undo this.", - "confirm": "Opt out", - "modal": { - "confirmation_title": "Are you sure?", - "confirmation_description": "This will remove all your progress, and can't be reversed. If you rejoin the Rewards program later, you'll start back at 0.", - "cancel": "Cancel", - "confirm": "Confirm", - "error_message": "Failed to opt out of Rewards. Please try again.", - "processing": "Processing..." - } + "title": "Opt out of Rewards", + "description": "This will remove your accounts from the Rewards program and erase your points and progress. You won't be able to undo this.", + "confirm": "Opt out", + "modal": { + "confirmation_title": "Are you sure?", + "confirmation_description": "This will remove all your progress, and can't be reversed. If you rejoin the Rewards program later, you'll start back at 0.", + "cancel": "Cancel", + "confirm": "Confirm", + "error_message": "Failed to opt out of Rewards. Please try again.", + "processing": "Processing..." + } }, - "toast_dismiss": "Dismiss", - "dashboard_modal_info": { "active_account": { "title": "Don't miss out", @@ -6422,7 +6469,6 @@ "confirm": "Go back" } }, - "ways_to_earn": { "title": "Ways to earn", "supported_networks": "Supported Networks", @@ -6488,7 +6534,6 @@ "description": "Your friends get a 2x sign up bonus. You get 10 points for every 50 they earn from trading." } }, - "link_account_group": { "link_account": "Add account", "tracked": "Tracked", @@ -6496,7 +6541,6 @@ "link_account_success": "{{accountName}} added successfully", "link_account_error": "Failed to add one or more addresses for {{accountName}}" }, - "active_boosts_title": "Active boosts", "season_1": "Season 1", "upcoming_rewards": { @@ -6570,4 +6614,4 @@ "unable_to_connect_network": "Unable to connect to {{networkName}}.", "update_rpc": "Update RPC" } -} +} \ No newline at end of file