diff --git a/app/components/Base/StatusText.js b/app/components/Base/StatusText.js index e39eae520791..49c65e109b57 100644 --- a/app/components/Base/StatusText.js +++ b/app/components/Base/StatusText.js @@ -15,41 +15,50 @@ const styles = StyleSheet.create({ }, }); -export const ConfirmedText = ({ testID, ...props }) => ( - +export const ConfirmedText = ({ testID, style: styleProp, ...props }) => ( + ); ConfirmedText.propTypes = { testID: PropTypes.string, + style: PropTypes.oneOfType([PropTypes.object, PropTypes.array]), }; -export const PendingText = ({ testID, ...props }) => { +export const PendingText = ({ testID, style: styleProp, ...props }) => { const { colors } = useTheme(); return ( ); }; PendingText.propTypes = { testID: PropTypes.string, + style: PropTypes.oneOfType([PropTypes.object, PropTypes.array]), }; -export const FailedText = ({ testID, ...props }) => { +export const FailedText = ({ testID, style: styleProp, ...props }) => { const { colors } = useTheme(); return ( ); }; FailedText.propTypes = { testID: PropTypes.string, + style: PropTypes.oneOfType([PropTypes.object, PropTypes.array]), }; function StatusText({ status, context, testID, ...props }) { @@ -65,6 +74,8 @@ function StatusText({ status, context, testID, ...props }) { case 'pending': case 'Submitted': case 'submitted': + case 'Unconfirmed': + case 'unconfirmed': case TransactionStatus.signed: return ( diff --git a/app/components/UI/Assets/hooks/useTrendingRequest/index.ts b/app/components/UI/Assets/hooks/useTrendingRequest/index.ts index c7e0ed2ebeca..72696b59127b 100644 --- a/app/components/UI/Assets/hooks/useTrendingRequest/index.ts +++ b/app/components/UI/Assets/hooks/useTrendingRequest/index.ts @@ -6,6 +6,12 @@ import { SortTrendingBy, } from '@metamask/assets-controllers'; import { useStableArray } from '../../../Perps/hooks/useStableArray'; +import { + NetworkType, + useNetworksByNamespace, + ProcessedNetwork, +} from '../../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; +import { useNetworksToUse } from '../../../../hooks/useNetworksToUse/useNetworksToUse'; export const DEBOUNCE_WAIT = 500; /** @@ -13,7 +19,7 @@ export const DEBOUNCE_WAIT = 500; * @returns {Object} An object containing the trending tokens results, loading state, error, and a function to trigger fetch */ export const useTrendingRequest = (options: { - chainIds: CaipChainId[]; + chainIds?: CaipChainId[]; sortBy?: SortTrendingBy; minLiquidity?: number; minVolume24hUsd?: number; @@ -22,7 +28,7 @@ export const useTrendingRequest = (options: { maxMarketCap?: number; }) => { const { - chainIds, + chainIds: providedChainIds = [], sortBy, minLiquidity, minVolume24hUsd, @@ -31,10 +37,30 @@ export const useTrendingRequest = (options: { maxMarketCap, } = options; + // Get default networks when chainIds is empty + const { networks } = useNetworksByNamespace({ + networkType: NetworkType.Popular, + }); + + const { networksToUse } = useNetworksToUse({ + networks, + networkType: NetworkType.Popular, + }); + + // Use provided chainIds or default to popular networks + const chainIds = useMemo((): CaipChainId[] => { + if (providedChainIds.length > 0) { + return providedChainIds; + } + return networksToUse.map( + (network: ProcessedNetwork) => network.caipChainId, + ); + }, [providedChainIds, networksToUse]); + const [results, setResults] = useState > | null>(null); - const [isLoading, setIsLoading] = useState(false); + const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); // Track the current request ID to prevent stale results from overwriting current ones @@ -111,7 +137,7 @@ export const useTrendingRequest = (options: { debouncedFetchTrendingTokens.cancel(); // If chainIds is empty, don't trigger fetch - if (!memoizedOptions.chainIds.length) { + if (!stableChainIds.length) { return; } @@ -122,7 +148,7 @@ export const useTrendingRequest = (options: { return () => { debouncedFetchTrendingTokens.cancel(); }; - }, [debouncedFetchTrendingTokens, memoizedOptions.chainIds.length]); + }, [debouncedFetchTrendingTokens, stableChainIds]); return { results: results || [], diff --git a/app/components/UI/Assets/hooks/useTrendingRequest/useTrendingRequest.test.ts b/app/components/UI/Assets/hooks/useTrendingRequest/useTrendingRequest.test.ts index 85659f10cb1a..a5f9d94f81d8 100644 --- a/app/components/UI/Assets/hooks/useTrendingRequest/useTrendingRequest.test.ts +++ b/app/components/UI/Assets/hooks/useTrendingRequest/useTrendingRequest.test.ts @@ -3,11 +3,76 @@ import { renderHookWithProvider } from '../../../../../util/test/renderWithProvi import { act } from '@testing-library/react-native'; // eslint-disable-next-line import/no-namespace import * as assetsControllers from '@metamask/assets-controllers'; +import { + ProcessedNetwork, + useNetworksByNamespace, +} from '../../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; +import { useNetworksToUse } from '../../../../hooks/useNetworksToUse/useNetworksToUse'; + +// Mock the network hooks +jest.mock( + '../../../../hooks/useNetworksByNamespace/useNetworksByNamespace', + () => ({ + useNetworksByNamespace: jest.fn(), + NetworkType: { + Popular: 'popular', + Custom: 'custom', + }, + }), +); + +jest.mock('../../../../hooks/useNetworksToUse/useNetworksToUse', () => ({ + useNetworksToUse: jest.fn(), +})); + +const mockUseNetworksByNamespace = + useNetworksByNamespace as jest.MockedFunction; +const mockUseNetworksToUse = useNetworksToUse as jest.MockedFunction< + typeof useNetworksToUse +>; + +// Default mock networks +const mockDefaultNetworks: ProcessedNetwork[] = [ + { + id: '1', + name: 'Ethereum Mainnet', + caipChainId: 'eip155:1' as const, + isSelected: true, + imageSource: { uri: 'ethereum' }, + }, + { + id: '137', + name: 'Polygon', + caipChainId: 'eip155:137' as const, + isSelected: true, + imageSource: { uri: 'polygon' }, + }, +]; describe('useTrendingRequest', () => { beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); + // Set up default mocks for network hooks + mockUseNetworksByNamespace.mockReturnValue({ + networks: mockDefaultNetworks, + selectedNetworks: mockDefaultNetworks, + areAllNetworksSelected: true, + areAnyNetworksSelected: true, + networkCount: mockDefaultNetworks.length, + selectedCount: mockDefaultNetworks.length, + }); + mockUseNetworksToUse.mockReturnValue({ + networksToUse: mockDefaultNetworks, + evmNetworks: mockDefaultNetworks, + solanaNetworks: [], + selectedEvmAccount: null, + selectedSolanaAccount: null, + isMultichainAccountsState2Enabled: false, + areAllNetworksSelectedCombined: true, + areAllEvmNetworksSelected: true, + areAllSolanaNetworksSelected: false, + } as unknown as ReturnType); }); it('returns an object with results, isLoading, error, and fetch function', () => { @@ -195,12 +260,23 @@ describe('useTrendingRequest', () => { unmount(); }); - it('skips fetch when chain ids are empty', async () => { + it('uses default popular networks when chainIds is empty', async () => { const spyGetTrendingTokens = jest.spyOn( assetsControllers, 'getTrendingTokens', ); - spyGetTrendingTokens.mockResolvedValue([]); + const mockResults: assetsControllers.TrendingAsset[] = [ + { + assetId: 'eip155:1/erc20:0x123', + symbol: 'TOKEN1', + name: 'Token 1', + decimals: 18, + price: '1', + aggregatedUsdVolume: 1, + marketCap: 1, + }, + ]; + spyGetTrendingTokens.mockResolvedValue(mockResults as never); const { result, unmount } = renderHookWithProvider(() => useTrendingRequest({ @@ -213,20 +289,82 @@ describe('useTrendingRequest', () => { await Promise.resolve(); }); - expect(spyGetTrendingTokens).not.toHaveBeenCalled(); - expect(result.current.results).toEqual([]); + expect(mockUseNetworksByNamespace).toHaveBeenCalledWith({ + networkType: 'popular', + }); + expect(mockUseNetworksToUse).toHaveBeenCalledWith({ + networks: mockDefaultNetworks, + networkType: 'popular', + }); + expect(spyGetTrendingTokens).toHaveBeenCalledWith( + expect.objectContaining({ + chainIds: ['eip155:1', 'eip155:137'], + }), + ); + expect(result.current.results).toEqual(mockResults); expect(result.current.isLoading).toBe(false); + spyGetTrendingTokens.mockRestore(); + unmount(); + }); + + it('uses default popular networks when chainIds is not provided', async () => { + const spyGetTrendingTokens = jest.spyOn( + assetsControllers, + 'getTrendingTokens', + ); + const mockResults: assetsControllers.TrendingAsset[] = []; + spyGetTrendingTokens.mockResolvedValue(mockResults as never); + + renderHookWithProvider(() => useTrendingRequest({})); + await act(async () => { - await result.current.fetch(); jest.advanceTimersByTime(DEBOUNCE_WAIT); await Promise.resolve(); }); - expect(spyGetTrendingTokens).not.toHaveBeenCalled(); + expect(mockUseNetworksByNamespace).toHaveBeenCalledWith({ + networkType: 'popular', + }); + expect(spyGetTrendingTokens).toHaveBeenCalledWith( + expect.objectContaining({ + chainIds: ['eip155:1', 'eip155:137'], + }), + ); + + spyGetTrendingTokens.mockRestore(); + }); + + it('uses provided chainIds when available instead of default networks', async () => { + const spyGetTrendingTokens = jest.spyOn( + assetsControllers, + 'getTrendingTokens', + ); + const mockResults: assetsControllers.TrendingAsset[] = []; + spyGetTrendingTokens.mockResolvedValue(mockResults as never); + + const customChainIds: `${string}:${string}`[] = [ + 'eip155:56', + 'eip155:42161', + ]; + renderHookWithProvider(() => + useTrendingRequest({ + chainIds: customChainIds, + }), + ); + + await act(async () => { + jest.advanceTimersByTime(DEBOUNCE_WAIT); + await Promise.resolve(); + }); + + expect(spyGetTrendingTokens).toHaveBeenCalledWith( + expect.objectContaining({ + chainIds: customChainIds, + }), + ); spyGetTrendingTokens.mockRestore(); - unmount(); }); it('coalesces multiple rapid calls into a single fetch', async () => { diff --git a/app/components/UI/Perps/Debug/HIP3DebugView.tsx b/app/components/UI/Perps/Debug/HIP3DebugView.tsx index 8ea1db9e33e8..d03a20a45222 100644 --- a/app/components/UI/Perps/Debug/HIP3DebugView.tsx +++ b/app/components/UI/Perps/Debug/HIP3DebugView.tsx @@ -15,7 +15,6 @@ import styleSheet from './HIP3DebugView.styles'; import Engine from '../../../../core/Engine'; import type { HyperLiquidProvider } from '../controllers/providers/HyperLiquidProvider'; import type { MarketInfo } from '../controllers/types'; -import { findOptimalAmount } from '../utils/orderCalculations'; interface TestResult { status: 'idle' | 'loading' | 'success' | 'error'; @@ -367,36 +366,37 @@ const HIP3DebugView: React.FC = () => { const szDecimals = marketInfo.szDecimals; // Calculate position size for $11 USD notional value - // Use findOptimalAmount to handle rounding correctly and ensure we meet $10 minimum + // USD as source of truth - provider will recalculate size with fresh price const targetUsdAmount = 11; - const optimalAmount = findOptimalAmount({ - targetAmount: targetUsdAmount.toString(), - maxAllowedAmount: 1000, // Reasonable max for test orders - minAllowedAmount: 10, // HyperLiquid minimum order size - price: currentPrice, - szDecimals, - }); - // Calculate actual position size from optimal amount - const positionSize = parseFloat(optimalAmount) / currentPrice; + // Calculate position size from USD amount + const positionSize = targetUsdAmount / currentPrice; + const multiplier = Math.pow(10, szDecimals); + const roundedPositionSize = + Math.round(positionSize * multiplier) / multiplier; DevLogger.log('Order calculation:', { market: selectedMarket, currentPrice: currentPrice.toFixed(2), szDecimals, targetAmount: targetUsdAmount, - optimalAmount, - calculatedPositionSize: positionSize.toFixed(szDecimals), - expectedNotional: (positionSize * currentPrice).toFixed(2), + calculatedPositionSize: roundedPositionSize.toFixed(szDecimals), + expectedNotional: (roundedPositionSize * currentPrice).toFixed(2), }); // Place order with calculated size + // USD-as-source-of-truth: provide currentPrice and usdAmount for validation const result = await provider.placeOrder({ coin: selectedMarket, isBuy: true, - size: positionSize.toFixed(szDecimals), + size: roundedPositionSize.toFixed(szDecimals), orderType: 'market', leverage: 5, + // Required by USD-as-source-of-truth validation + currentPrice, + usdAmount: targetUsdAmount.toString(), + priceAtCalculation: currentPrice, + maxSlippageBps: 100, // 1% slippage tolerance }); if (result.success) { diff --git a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx index 6aa1fd71287d..b56a4a55d689 100644 --- a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx @@ -2072,12 +2072,12 @@ describe('PerpsClosePositionView', () => { // HyperLiquid's marginUsed already includes PnL // receivedAmount = marginUsed - fees = 1450 - 45 = 1405 // realizedPnl = unrealizedPnl = 150 (from defaultPerpsPositionMock) - expect(handleClosePosition).toHaveBeenCalledWith( - defaultPerpsPositionMock, - '', - 'market', - undefined, - { + expect(handleClosePosition).toHaveBeenCalledWith({ + position: defaultPerpsPositionMock, + size: '', + orderType: 'market', + limitPrice: undefined, + trackingData: { totalFee: 45, marketPrice: 3000, receivedAmount: 1405, @@ -2088,8 +2088,14 @@ describe('PerpsClosePositionView', () => { estimatedPoints: undefined, inputMethod: 'default', }, - '3000.00', - ); + marketPrice: '3000.00', + // Slippage parameters added in USD-as-source-of-truth refactor + slippage: { + usdAmount: '4500', // closingValueString: absSize * currentPrice * (closePercentage / 100) = 1.5 * 3000 * 1.0 + priceAtCalculation: 3000, // effectivePrice: currentPrice for market orders + maxSlippageBps: 100, // maxSlippageBps: 1% slippage tolerance (100 basis points) + }, + }); }); it('validates limit order requires price before confirmation', async () => { @@ -2154,12 +2160,12 @@ describe('PerpsClosePositionView', () => { if (orderType === 'limit' && !limitPrice) { return; // Should not proceed without price } - await handleClosePosition( - defaultPerpsPositionMock, - '', + await handleClosePosition({ + position: defaultPerpsPositionMock, + size: '', orderType, - orderType === 'limit' ? limitPrice : undefined, - { + limitPrice: orderType === 'limit' ? limitPrice : undefined, + trackingData: { totalFee: 45, marketPrice: 3000, receivedAmount: 1405, @@ -2170,7 +2176,13 @@ describe('PerpsClosePositionView', () => { estimatedPoints: undefined, inputMethod: 'default', }, - ); + marketPrice: '3000.00', + slippage: { + usdAmount: '4500', + priceAtCalculation: 3000, + maxSlippageBps: 100, + }, + }); }} > Confirm @@ -2186,12 +2198,12 @@ describe('PerpsClosePositionView', () => { // Assert - Should call with limit price and specific calculated values await waitFor(() => { - expect(handleClosePosition).toHaveBeenCalledWith( - defaultPerpsPositionMock, - '', - 'limit', - '50000', - { + expect(handleClosePosition).toHaveBeenCalledWith({ + position: defaultPerpsPositionMock, + size: '', + orderType: 'limit', + limitPrice: '50000', + trackingData: { totalFee: 45, marketPrice: 3000, receivedAmount: 1405, @@ -2202,7 +2214,13 @@ describe('PerpsClosePositionView', () => { estimatedPoints: undefined, inputMethod: 'default', }, - ); + marketPrice: '3000.00', + slippage: { + usdAmount: '4500', + priceAtCalculation: 3000, + maxSlippageBps: 100, + }, + }); }); }); }); @@ -2742,7 +2760,10 @@ describe('PerpsClosePositionView', () => { ); // Assert - Component renders without error and uses market data - expect(usePerpsMarketDataMock).toHaveBeenCalledWith('BTC'); + expect(usePerpsMarketDataMock).toHaveBeenCalledWith({ + asset: 'BTC', + showErrorToast: true, + }); }); it('formats position size with different szDecimals values', () => { @@ -2784,14 +2805,17 @@ describe('PerpsClosePositionView', () => { PerpsClosePositionViewSelectorsIDs.CLOSE_POSITION_CONFIRM_BUTTON, ), ).toBeDefined(); - expect(usePerpsMarketDataMock).toHaveBeenCalledWith(coin); + expect(usePerpsMarketDataMock).toHaveBeenCalledWith({ + asset: coin, + showErrorToast: true, + }); }); }); it('handles missing market data with undefined szDecimals', () => { - // Arrange - Market data is null + // Arrange - Market data with minimal required fields (szDecimals is now required) usePerpsMarketDataMock.mockReturnValue({ - marketData: null, + marketData: { szDecimals: 4 }, // Provide required szDecimals isLoading: false, error: null, }); @@ -2812,7 +2836,7 @@ describe('PerpsClosePositionView', () => { true, ); - // Assert - Component renders and falls back to default formatting + // Assert - Component renders with minimal market data expect( queryByTestId( PerpsClosePositionViewSelectorsIDs.CLOSE_POSITION_CONFIRM_BUTTON, @@ -2844,9 +2868,9 @@ describe('PerpsClosePositionView', () => { }); it('handles market data fetch error gracefully', () => { - // Arrange - Market data fetch failed + // Arrange - Market data fetch failed but provides minimal required data usePerpsMarketDataMock.mockReturnValue({ - marketData: null, + marketData: { szDecimals: 4 }, // Provide required szDecimals even in error case isLoading: false, error: 'Failed to fetch market data', }); @@ -2858,7 +2882,7 @@ describe('PerpsClosePositionView', () => { true, ); - // Assert - Component still renders with error state + // Assert - Component still renders with minimal data despite error expect( queryByTestId( PerpsClosePositionViewSelectorsIDs.CLOSE_POSITION_CONFIRM_BUTTON, @@ -2900,7 +2924,10 @@ describe('PerpsClosePositionView', () => { ); // Assert - Fetches market data for specific asset - expect(usePerpsMarketDataMock).toHaveBeenCalledWith(coin); + expect(usePerpsMarketDataMock).toHaveBeenCalledWith({ + asset: coin, + showErrorToast: true, + }); }); }); @@ -2986,7 +3013,10 @@ describe('PerpsClosePositionView', () => { ); // Assert - Hook called with correct asset symbol - expect(usePerpsMarketDataMock).toHaveBeenCalledWith('ETH'); + expect(usePerpsMarketDataMock).toHaveBeenCalledWith({ + asset: 'ETH', + showErrorToast: true, + }); }); }); diff --git a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx index c139dbdfe6d6..7b2768ea7971 100644 --- a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx +++ b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx @@ -37,6 +37,10 @@ import { useTheme } from '../../../../../util/theme'; import Keypad from '../../../../Base/Keypad'; import type { InputMethod, OrderType, Position } from '../../controllers/types'; import type { PerpsNavigationParamList } from '../../types/navigation'; +import { + DECIMAL_PRECISION_CONFIG, + ORDER_SLIPPAGE_CONFIG, +} from '../../constants/perpsConfig'; import { useMinimumOrderAmount, usePerpsClosePosition, @@ -90,8 +94,11 @@ const PerpsClosePositionView: React.FC = () => { const { showToast, PerpsToastOptions } = usePerpsToasts(); - // Get market data for szDecimals - const { marketData } = usePerpsMarketData(position.coin); + // Get market data for szDecimals with automatic error toast handling + const { marketData, isLoading: isLoadingMarketData } = usePerpsMarketData({ + asset: position.coin, + showErrorToast: true, + }); // Track screen load performance with unified hook (immediate measurement) usePerpsMeasurement({ @@ -157,17 +164,37 @@ const PerpsClosePositionView: React.FC = () => { // Calculate display values directly from closePercentage for immediate updates const { closeAmount, calculatedUSDString } = useMemo(() => { + // During loading, return '0' as temporary state (not a default - intentional for loading UX) + if (isLoadingMarketData) { + return { + closeAmount: '0', + calculatedUSDString: '0.00', + }; + } + + // Defensive fallback if market data fails to load - prevents crashes + // Real szDecimals should come from market data (varies by asset) + const szDecimals = + marketData?.szDecimals ?? DECIMAL_PRECISION_CONFIG.FALLBACK_SIZE_DECIMALS; + const { tokenAmount, usdValue } = calculateCloseAmountFromPercentage({ percentage: closePercentage, positionSize: absSize, currentPrice: effectivePrice, + szDecimals, }); return { closeAmount: tokenAmount.toString(), calculatedUSDString: formatCloseAmountUSD(usdValue), }; - }, [closePercentage, absSize, effectivePrice]); + }, [ + closePercentage, + absSize, + effectivePrice, + marketData?.szDecimals, + isLoadingMarketData, + ]); // Use calculated USD string when not in input mode, user input when typing const displayUSDString = @@ -287,6 +314,7 @@ const PerpsClosePositionView: React.FC = () => { remainingPositionValue, receiveAmount, isPartialClose, + skipValidation: isInputFocused, }); const { handleClosePosition, isClosing } = usePerpsClosePosition(); @@ -349,12 +377,12 @@ const PerpsClosePositionView: React.FC = () => { // Go back immediately to close the position screen navigation.goBack(); - await handleClosePosition( - livePosition, - sizeToClose || '', + await handleClosePosition({ + position: livePosition, + size: sizeToClose || '', orderType, - orderType === 'limit' ? limitPrice : undefined, - { + limitPrice: orderType === 'limit' ? limitPrice : undefined, + trackingData: { totalFee: feeResults.totalFee, marketPrice: currentPrice, receivedAmount: receiveAmount, @@ -365,8 +393,14 @@ const PerpsClosePositionView: React.FC = () => { estimatedPoints: rewardsState.estimatedPoints, inputMethod: inputMethodRef.current, }, - priceData[position.coin]?.price, - ); + marketPrice: priceData[position.coin]?.price, + // Slippage parameters for consistent validation (same as PerpsOrderView) + slippage: { + usdAmount: closingValueString, + priceAtCalculation: effectivePrice, + maxSlippageBps: ORDER_SLIPPAGE_CONFIG.DEFAULT_SLIPPAGE_BPS, + }, + }); }; const handleAmountPress = () => { diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index db08fcfbb097..bf9b213892f4 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -19,6 +19,7 @@ import { import { PerpsOrderViewSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; import { ButtonSize as ButtonSizeRNDesignSystem } from '@metamask/design-system-react-native'; +import { BigNumber } from 'bignumber.js'; import { strings } from '../../../../../../locales/i18n'; import ButtonSemantic, { ButtonSemanticSeverity, @@ -32,7 +33,6 @@ import Icon, { IconName, IconSize, } from '../../../../../component-library/components/Icons/Icon'; -import PerpsFeesDisplay from '../../components/PerpsFeesDisplay'; import ListItem from '../../../../../component-library/components/List/ListItem'; import ListItemColumn, { WidthType, @@ -41,28 +41,34 @@ import Text, { TextColor, TextVariant, } from '../../../../../component-library/components/Texts/Text'; -import { useTheme } from '../../../../../util/theme'; +import useTooltipModal from '../../../../../components/hooks/useTooltipModal'; import Routes from '../../../../../constants/navigation/Routes'; +import { useTheme } from '../../../../../util/theme'; import { TraceName } from '../../../../../util/trace'; import Keypad from '../../../../Base/Keypad'; import { MetaMetricsEvents } from '../../../../hooks/useMetrics'; +import RewardsAnimations, { + RewardAnimationState, +} from '../../../Rewards/components/RewardPointsAnimation'; import PerpsAmountDisplay from '../../components/PerpsAmountDisplay'; import PerpsBottomSheetTooltip from '../../components/PerpsBottomSheetTooltip'; import { PerpsTooltipContentKey } from '../../components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types'; +import PerpsFeesDisplay from '../../components/PerpsFeesDisplay'; import PerpsLeverageBottomSheet from '../../components/PerpsLeverageBottomSheet'; import PerpsLimitPriceBottomSheet from '../../components/PerpsLimitPriceBottomSheet'; import PerpsOICapWarning from '../../components/PerpsOICapWarning'; import PerpsOrderHeader from '../../components/PerpsOrderHeader'; import PerpsOrderTypeBottomSheet from '../../components/PerpsOrderTypeBottomSheet'; import PerpsSlider from '../../components/PerpsSlider'; -import RewardsAnimations, { - RewardAnimationState, -} from '../../../Rewards/components/RewardPointsAnimation'; import { PerpsEventProperties, PerpsEventValues, } from '../../constants/eventNames'; -import { PERPS_CONSTANTS } from '../../constants/perpsConfig'; +import { + DECIMAL_PRECISION_CONFIG, + ORDER_SLIPPAGE_CONFIG, + PERPS_CONSTANTS, +} from '../../constants/perpsConfig'; import { PerpsOrderProvider, usePerpsOrderContext, @@ -99,19 +105,17 @@ import { PRICE_RANGES_MINIMAL_VIEW, PRICE_RANGES_UNIVERSAL, } from '../../utils/formatUtils'; +import { getPerpsDisplaySymbol } from '../../utils/marketUtils'; import { calculateMarginRequired, calculatePositionSize, } from '../../utils/orderCalculations'; +import { willFlipPosition } from '../../utils/orderUtils'; import { calculateRoEForPrice, isStopLossSafeFromLiquidation, } from '../../utils/tpslValidation'; import createStyles from './PerpsOrderView.styles'; -import { willFlipPosition } from '../../utils/orderUtils'; -import { BigNumber } from 'bignumber.js'; -import { getPerpsDisplaySymbol } from '../../utils/marketUtils'; -import useTooltipModal from '../../../../../components/hooks/useTooltipModal'; // Navigation params interface interface OrderRouteParams { @@ -182,7 +186,6 @@ const PerpsOrderViewContentBase: React.FC = () => { orderForm, setAmount, setLeverage, - optimizeOrderAmount, setTakeProfitPrice, setStopLossPrice, setLimitPrice, @@ -213,12 +216,11 @@ const PerpsOrderViewContentBase: React.FC = () => { * updating leverage after positions load to prevent protocol violations. */ - // Market data hook - now uses orderForm.asset from context - const { - marketData, - isLoading: isLoadingMarketData, - error: marketDataError, - } = usePerpsMarketData(orderForm.asset); + // Market data hook with automatic error toast handling + const { marketData, isLoading: isLoadingMarketData } = usePerpsMarketData({ + asset: orderForm.asset, + showErrorToast: true, + }); // Check if user has an existing position for this market const { existingPosition } = useHasExistingPosition({ @@ -352,36 +354,29 @@ const PerpsOrderViewContentBase: React.FC = () => { }, }); - // Show error toast if market data is not available - useEffect(() => { - // Don't show error during initial load - only for persistent failures - if (marketDataError && !isLoadingMarketData) { - showToast( - PerpsToastOptions.dataFetching.market.error.marketDataUnavailable( - orderForm.asset, - ), - ); + // Real-time position size calculation - memoized to prevent recalculation + const positionSize = useMemo(() => { + // During loading, show '--' placeholder (consistent with other unavailable data displays) + if (isLoadingMarketData) { + return PERPS_CONSTANTS.FALLBACK_DATA_DISPLAY; } + + return calculatePositionSize({ + amount: orderForm.amount, + price: assetData.price, + // Defensive fallback if market data fails to load - prevents crashes + // Real szDecimals should come from market data (varies by asset) + szDecimals: + marketData?.szDecimals ?? + DECIMAL_PRECISION_CONFIG.FALLBACK_SIZE_DECIMALS, + }); }, [ - marketDataError, + orderForm.amount, + assetData.price, + marketData?.szDecimals, isLoadingMarketData, - orderForm.asset, - navigation, - showToast, - PerpsToastOptions.dataFetching.market.error, ]); - // Real-time position size calculation - memoized to prevent recalculation - const positionSize = useMemo( - () => - calculatePositionSize({ - amount: orderForm.amount, - price: assetData.price, - szDecimals: marketData?.szDecimals, - }), - [orderForm.amount, assetData.price, marketData?.szDecimals], - ); - const marginRequired = useMemo(() => { if (!isLoadingMarketData && orderForm.amount) { return calculateMarginRequired({ @@ -557,6 +552,8 @@ const PerpsOrderViewContentBase: React.FC = () => { availableBalance, marginRequired: marginRequired || '0', existingPositionLeverage: existingPositionLeverageForValidation, + skipValidation: isInputFocused, + originalUsdAmount: orderForm.amount, // Pass original USD input to prevent validation flash from price updates }); // Filter out specific validation error(s) from display (similar to ClosePositionView pattern) @@ -635,7 +632,6 @@ const PerpsOrderViewContentBase: React.FC = () => { const handlePercentagePress = (percentage: number) => { inputMethodRef.current = 'percentage'; handlePercentageAmount(percentage); - optimizeOrderAmount(assetData.price, marketData?.szDecimals); }; const handleMaxPress = () => { @@ -648,29 +644,25 @@ const PerpsOrderViewContentBase: React.FC = () => { }; // Clamp amount to the maximum allowed once the keypad/input is dismissed - // Mirrors the PerpsClosePositionView behavior where, after leaving input, - // values are normalized to valid limits. + // Mirrors the PerpsClosePositionView behavior where values are normalized to valid limits useEffect(() => { if (!isInputFocused) { - const currentAmount = parseFloat(orderForm.amount || '0'); - - // If user-entered amount exceeds the max purchasable with current balance/leverage, - // snap it down to the maximum once input is closed. - if (currentAmount > maxPossibleAmount) { - setAmount(String(maxPossibleAmount)); + // Only clamp if input was from keypad (not from percentage/slider/max) + // This prevents overwriting intentional user selections from other input methods + if (inputMethodRef.current === 'keypad') { + const currentAmount = parseFloat(orderForm.amount || '0'); + + // If user-entered amount exceeds the max purchasable with current balance/leverage, + // snap it down to the maximum once input is closed. + if (currentAmount > maxPossibleAmount) { + setAmount(String(maxPossibleAmount)); + } } - optimizeOrderAmount(assetData.price, marketData?.szDecimals); } + // CRITICAL: Only isInputFocused dependency prevents infinite loops + // Other dependencies would cause re-clamping on WebSocket updates // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - isInputFocused, - availableBalance, - orderForm.leverage, - setAmount, - optimizeOrderAmount, - assetData.price, - marketData?.szDecimals, - ]); + }, [isInputFocused]); const handlePlaceOrder = useCallback(async () => { if (isSubmittingRef.current) { @@ -749,13 +741,23 @@ const PerpsOrderViewContentBase: React.FC = () => { // Execute order using the new hook // Only include TP/SL if they have valid, non-empty values + // + // HYBRID APPROACH: Pass both USD amount (source of truth) and size (for backward compatibility) + // The provider will: + // 1. Validate price hasn't moved beyond maxSlippageBps + // 2. Recalculate size with fresh price from usdAmount + // 3. Use the recalculated size for order execution const orderParams: OrderParams = { coin: orderForm.asset, isBuy: orderForm.direction === 'long', - size: positionSize, + size: positionSize, // Kept for backward compatibility, provider recalculates from usdAmount orderType: orderForm.type, currentPrice: assetData.price, leverage: orderForm.leverage, + // USD as source of truth (hybrid approach) + usdAmount: orderForm.amount, // USD amount (primary source of truth, provider calculates size from this) + priceAtCalculation: assetData.price, // Price snapshot when size was calculated (for slippage validation) + maxSlippageBps: ORDER_SLIPPAGE_CONFIG.DEFAULT_SLIPPAGE_BPS, // Slippage tolerance in basis points (100 = 1%) // Only add TP/SL/Limit if they are truthy and/or not empty strings ...(orderForm.type === 'limit' && orderForm.limitPrice ? { price: orderForm.limitPrice } @@ -820,6 +822,7 @@ const PerpsOrderViewContentBase: React.FC = () => { orderForm.limitPrice, orderForm.takeProfitPrice, orderForm.stopLossPrice, + orderForm.amount, positionSize, assetData.price, navigation, @@ -920,12 +923,7 @@ const PerpsOrderViewContentBase: React.FC = () => { onValueChange={(value) => { inputMethodRef.current = 'slider'; const amount = Math.floor(value).toString(); - setAmount(amount); - if (amount !== '0') { - // Now using debounced optimizeOrderAmount for all interactions - optimizeOrderAmount(assetData.price, marketData?.szDecimals); - } }} minimumValue={0} maximumValue={maxPossibleAmount} diff --git a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types.ts b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types.ts index 91f7be89e191..abdfa05a6b0a 100644 --- a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types.ts +++ b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types.ts @@ -43,6 +43,7 @@ export type PerpsTooltipContentKey = | 'receive' | 'open_interest' | 'funding_rate' + | 'funding_payments' | 'geo_block' | 'estimated_pnl' | 'limit_price' diff --git a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.ts b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.ts index 19c3982a3739..002a5e669bcf 100644 --- a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.ts +++ b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.ts @@ -24,6 +24,7 @@ export const tooltipContentRegistry: ContentRegistry = { margin: undefined, open_interest: undefined, funding_rate: undefined, + funding_payments: undefined, geo_block: undefined, estimated_pnl: undefined, limit_price: undefined, diff --git a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx index 7c6ecfc363c6..33c96bbc9320 100644 --- a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx +++ b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx @@ -478,7 +478,7 @@ const PerpsPositionCard: React.FC = ({ {onTooltipPress && ( onTooltipPress('funding_rate')} + onPress={() => onTooltipPress('funding_payments')} > = ({ testID, recyclingKey, }) => { - const { colors, themeAppearance } = useTheme(); - const [isLoading, setIsLoading] = useState(false); - const [hasError, setHasError] = useState(false); - - // Reset state when symbol changes (for recycling) - useEffect(() => { - setIsLoading(false); - setHasError(false); - }, [symbol]); - - // Memoize the background checks to prevent recalculation - const { needsLightBg, needsDarkBg } = useMemo(() => { - const upperSymbol = symbol?.toUpperCase(); - return { - needsLightBg: ASSETS_REQUIRING_LIGHT_BG.has(upperSymbol), - needsDarkBg: ASSETS_REQUIRING_DARK_BG.has(upperSymbol), - }; - }, [symbol]); - - const containerStyle: ViewStyle = useMemo( - () => ({ - width: size, - height: size, - borderRadius: size / 2, - backgroundColor: (() => { - if (themeAppearance === 'dark' && needsLightBg) { - return 'white'; // White in dark mode - } - if (themeAppearance === 'light' && needsDarkBg) { - return colors.icon.default; // Black in light mode - } - return colors.background.default; - })(), - alignItems: 'center' as const, - justifyContent: 'center' as const, - overflow: 'hidden' as const, - borderWidth: 1, - borderColor: colors.border.muted, - }), - [size, colors, themeAppearance, needsLightBg, needsDarkBg], - ); - - const loadingContainerStyle: ViewStyle = useMemo( - () => ({ - position: 'absolute' as const, - width: size, - height: size, - alignItems: 'center' as const, - justifyContent: 'center' as const, - }), - [size], - ); - - const imageStyle: ImageStyle = useMemo( - () => ({ - width: size, - height: size, - }), - [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; return getAssetIconUrl(symbol, K_PREFIX_ASSETS); }, [symbol]); - const handleLoadStart = () => { - setIsLoading(true); - setHasError(false); - }; - - const handleLoadEnd = () => { - setIsLoading(false); - }; + // Extract display symbol (e.g., "TSLA" from "xyz:TSLA") + const fallbackText = useMemo(() => { + const displaySymbol = getPerpsDisplaySymbol(symbol || ''); + // Get first 2 letters, uppercase + return displaySymbol.substring(0, 2).toUpperCase(); + }, [symbol]); - const handleError = () => { - setIsLoading(false); - setHasError(true); - }; + const { + isLoading, + hasError, + containerStyle, + loadingContainerStyle, + imageStyle, + fallbackTextStyle, + handleLoadStart, + handleLoadEnd, + handleError, + } = useTokenLogo({ + symbol: symbol || '', + size, + assetsRequiringLightBg: ASSETS_REQUIRING_LIGHT_BG, + assetsRequiringDarkBg: ASSETS_REQUIRING_DARK_BG, + }); // 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 ( diff --git a/app/components/UI/Perps/constants/hyperLiquidConfig.ts b/app/components/UI/Perps/constants/hyperLiquidConfig.ts index 963c5ac1aaca..86e6a226473e 100644 --- a/app/components/UI/Perps/constants/hyperLiquidConfig.ts +++ b/app/components/UI/Perps/constants/hyperLiquidConfig.ts @@ -99,7 +99,6 @@ export const TRADING_DEFAULTS: TradingDefaultsConfig = { marginPercent: 10, // 10% fixed margin default takeProfitPercent: 0.3, // 30% take profit stopLossPercent: 0.1, // 10% stop loss - slippage: 0.05, // 5% max slippage protection amount: { mainnet: 10, // $10 minimum order size testnet: 10, // $10 minimum order size diff --git a/app/components/UI/Perps/constants/perpsConfig.ts b/app/components/UI/Perps/constants/perpsConfig.ts index 0fe6ce3b6cd1..1decbcce5647 100644 --- a/app/components/UI/Perps/constants/perpsConfig.ts +++ b/app/components/UI/Perps/constants/perpsConfig.ts @@ -71,6 +71,17 @@ export const VALIDATION_THRESHOLDS = { LIMIT_PRICE_DIFFERENCE_WARNING: 0.1, // Warn if limit price differs by >10% from current price } as const; +/** + * Order slippage configuration + * Controls default slippage tolerance for market orders + */ +export const ORDER_SLIPPAGE_CONFIG = { + // Default slippage for all market orders (basis points) + // 100 basis points = 1% = 0.01 decimal + // Used when price moves between calculation and execution + DEFAULT_SLIPPAGE_BPS: 100, +} as const; + /** * Performance optimization constants * These values control debouncing and throttling for better performance @@ -273,6 +284,11 @@ export const DECIMAL_PRECISION_CONFIG = { // Maximum decimal places for price input (matches Hyperliquid limit) // Used in TP/SL forms, limit price inputs, and price validation MAX_PRICE_DECIMALS: 6, + // Defensive fallback for size decimals when market data fails to load + // Real szDecimals should always come from market data API (varies by asset) + // Using 6 as safe maximum to prevent crashes (covers most assets) + // NOTE: This is NOT semantically correct - just a defensive measure + FALLBACK_SIZE_DECIMALS: 6, } as const; export const PERPS_GTM_WHATS_NEW_MODAL = 'perps-gtm-whats-new-modal'; diff --git a/app/components/UI/Perps/contexts/PerpsOrderContext.test.tsx b/app/components/UI/Perps/contexts/PerpsOrderContext.test.tsx index 56e5190b7ad7..d8601bc37b47 100644 --- a/app/components/UI/Perps/contexts/PerpsOrderContext.test.tsx +++ b/app/components/UI/Perps/contexts/PerpsOrderContext.test.tsx @@ -42,7 +42,6 @@ describe('PerpsOrderContext', () => { handlePercentageAmount: jest.fn(), handleMaxAmount: jest.fn(), handleMinAmount: jest.fn(), - optimizeOrderAmount: jest.fn(), maxPossibleAmount: 1000, }; @@ -137,7 +136,6 @@ describe('PerpsOrderContext', () => { expect(result.current.handlePercentageAmount).toBeDefined(); expect(result.current.handleMaxAmount).toBeDefined(); expect(result.current.handleMinAmount).toBeDefined(); - expect(result.current.optimizeOrderAmount).toBeDefined(); expect(result.current.maxPossibleAmount).toBeDefined(); // Check that functions are callable @@ -217,7 +215,6 @@ describe('PerpsOrderContext', () => { it('provides access to calculations methods and data from the hook', () => { const { result } = renderHookWithProvider(() => usePerpsOrderContext()); - expect(result.current.optimizeOrderAmount).toBeDefined(); expect(result.current.maxPossibleAmount).toBe(1000); }); }); @@ -283,7 +280,6 @@ describe('PerpsOrderContext', () => { expect(typeof contextResult.orderForm).toBe('object'); expect(typeof contextResult.updateOrderForm).toBe('function'); expect(typeof contextResult.setAmount).toBe('function'); - expect(typeof contextResult.optimizeOrderAmount).toBe('function'); expect(typeof contextResult.maxPossibleAmount).toBe('number'); }); }); diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts index 18b3e2da349b..abde8b9a7d71 100644 --- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts +++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts @@ -1937,6 +1937,7 @@ describe('HyperLiquidProvider', () => { isBuy: true, size: '0.1', orderType: 'market', + currentPrice: 50000, // Add price so validation passes, then fails on asset lookup }; const result = await provider.placeOrder(orderParams); @@ -1960,7 +1961,7 @@ describe('HyperLiquidProvider', () => { const result = await provider.placeOrder(orderParams); expect(result.success).toBe(false); - expect(result.error).toContain('No price available for BTC'); + expect(result.error).toContain('perps.order.validation.price_required'); }); it('should handle missing position in close operation', async () => { @@ -2590,6 +2591,7 @@ describe('HyperLiquidProvider', () => { isBuy: true, size: '0.1', orderType: 'market', + currentPrice: 50000, // Add price for validation }; const result = await freshProvider.placeOrder(orderParams); @@ -2664,19 +2666,19 @@ describe('HyperLiquidProvider', () => { expect(result.error).toContain('Failed to update leverage'); }); - it('should handle market order without current price (fallback to API)', async () => { + it('should fail market order without current price or usdAmount', async () => { const orderParams: OrderParams = { coin: 'BTC', isBuy: true, size: '0.1', orderType: 'market', - // No currentPrice provided - should fetch from API + // No currentPrice or usdAmount provided - should fail validation }; const result = await provider.placeOrder(orderParams); - expect(result.success).toBe(true); - expect(mockClientService.getInfoClient().allMids).toHaveBeenCalled(); + expect(result.success).toBe(false); + expect(result.error).toContain('perps.order.validation.price_required'); }); it('should handle order with custom slippage', async () => { @@ -3709,6 +3711,7 @@ describe('HyperLiquidProvider', () => { isBuy: true, size: '0.001', orderType: 'market', + currentPrice: 50000, // Add price for validation }); // Assert: Verify exchangeClient.order called with discounted fee @@ -3986,6 +3989,7 @@ describe('HyperLiquidProvider', () => { coin: 'BTC', size: '0.1', price: undefined, + orderType: 'market', }); }); @@ -4060,6 +4064,7 @@ describe('HyperLiquidProvider', () => { coin: 'ETH', size: '1', price: '3000', + orderType: 'limit', }); }); diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts index cddf760ba8b7..3ebe20e3d5f0 100644 --- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts +++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts @@ -19,6 +19,7 @@ import { USDC_DECIMALS, } from '../../constants/hyperLiquidConfig'; import { + ORDER_SLIPPAGE_CONFIG, PERFORMANCE_CONFIG, PERPS_CONSTANTS, TP_SL_CONFIG, @@ -39,6 +40,11 @@ import { parseAssetName, type RawHyperLiquidLedgerUpdate, } from '../../utils/hyperLiquidAdapter'; +import { + buildOrdersArray, + calculateFinalPositionSize, + calculateOrderPriceAndSize, +} from '../../utils/orderCalculations'; import { compileMarketPattern, shouldIncludeMarket, @@ -118,6 +124,59 @@ import type { } from '../types'; import { PERPS_ERROR_CODES } from '../PerpsController'; +// Helper method parameter interfaces (module-level for class-dependent methods only) +interface GetAssetInfoParams { + coin: string; + dexName: string | null; +} + +interface GetAssetInfoResult { + assetInfo: { + name: string; + szDecimals: number; + maxLeverage: number; + }; + currentPrice: number; + meta: MetaResponse; +} + +interface PrepareAssetForTradingParams { + coin: string; + assetId: number; + leverage?: number; +} + +interface HandleHip3PreOrderParams { + dexName: string; + coin: string; + orderPrice: number; + positionSize: number; + leverage: number; + isBuy: boolean; + maxLeverage: number; +} + +interface HandleHip3PreOrderResult { + transferInfo: { amount: number; sourceDex: string } | null; +} + +interface SubmitOrderWithRollbackParams { + orders: SDKOrderParams[]; + grouping: 'na' | 'normalTpsl' | 'positionTpsl'; + isHip3Order: boolean; + dexName: string | null; + transferInfo: { amount: number; sourceDex: string } | null; + coin: string; + assetId: number; +} + +interface HandleOrderErrorParams { + error: unknown; + coin: string; + orderType: 'market' | 'limit'; + isBuy: boolean; +} + /** * HyperLiquid provider implementation * @@ -1530,26 +1589,257 @@ export class HyperLiquidProvider implements IPerpsProvider { } } + // ============================================================================ + // Helper Methods for placeOrder Refactoring + // ============================================================================ + + /** + * Validates order parameters before placement using provider-level validation + * @throws Error if validation fails + */ + private async validateOrderBeforePlacement( + params: OrderParams, + ): Promise { + DevLogger.log('Provider: Validating order before placement:', params); + + const validation = await this.validateOrder(params); + if (!validation.isValid) { + throw new Error( + validation.error || 'Order validation failed at provider level', + ); + } + } + /** - * Place an order using direct wallet signing (same as working debug test) + * Gets asset info and current price from the correct DEX + */ + private async getAssetInfo( + params: GetAssetInfoParams, + ): Promise { + const { coin, dexName } = params; + + const infoClient = this.clientService.getInfoClient(); + const meta = await this.getCachedMeta({ dexName }); + + const assetInfo = meta.universe.find((asset) => asset.name === coin); + if (!assetInfo) { + throw new Error( + `Asset ${coin} not found in ${dexName || 'main'} DEX universe`, + ); + } + + const mids = await infoClient.allMids({ dex: dexName ?? '' }); + const currentPrice = parseFloat(mids[coin] || '0'); + if (currentPrice === 0) { + throw new Error(`No price available for ${coin}`); + } + + return { assetInfo, currentPrice, meta }; + } + + /** + * Prepares asset for trading by updating leverage if specified + */ + private async prepareAssetForTrading( + params: PrepareAssetForTradingParams, + ): Promise { + const { coin, assetId, leverage } = params; + + if (!leverage) { + return; + } + + DevLogger.log('Updating leverage before order:', { + coin, + assetId, + requestedLeverage: leverage, + leverageType: 'isolated', + }); + + const exchangeClient = this.clientService.getExchangeClient(); + const leverageResult = await exchangeClient.updateLeverage({ + asset: assetId, + isCross: false, + leverage, + }); + + if (leverageResult.status !== 'ok') { + throw new Error( + `Failed to update leverage: ${JSON.stringify(leverageResult)}`, + ); + } + + DevLogger.log('Leverage updated successfully:', { coin, leverage }); + } + + /** + * Handles HIP-3 pre-order balance management + */ + private async handleHip3PreOrder( + params: HandleHip3PreOrderParams, + ): Promise { + const { dexName, coin, orderPrice, positionSize, leverage, isBuy } = params; + + if (this.useDexAbstraction) { + DevLogger.log('Using DEX abstraction (no manual transfer)', { + coin, + dex: dexName, + }); + return { transferInfo: null }; + } + + DevLogger.log('Using manual auto-transfer', { coin, dex: dexName }); + + const requiredMarginWithBuffer = await this.calculateHip3RequiredMargin({ + coin, + dexName, + positionSize, + orderPrice, + leverage, + isBuy, + }); + + try { + const transferInfo = await this.autoTransferForHip3Order({ + targetDex: dexName, + requiredMargin: requiredMarginWithBuffer, + }); + return { transferInfo }; + } catch (transferError) { + const errorMsg = (transferError as Error)?.message || ''; + + if (errorMsg.includes('Cannot transfer with DEX abstraction enabled')) { + DevLogger.log('Detected DEX abstraction is enabled, switching mode'); + this.useDexAbstraction = true; + return { transferInfo: null }; + } + + throw transferError; + } + } + + /** + * Submits order with atomic rollback for HIP-3 failures + */ + private async submitOrderWithRollback( + params: SubmitOrderWithRollbackParams, + ): Promise { + const { orders, grouping, isHip3Order, dexName, transferInfo, coin } = + params; + + const exchangeClient = this.clientService.getExchangeClient(); + + // Calculate discounted builder fee + let builderFee = BUILDER_FEE_CONFIG.maxFeeTenthsBps; + if (this.userFeeDiscountBips !== undefined) { + builderFee = Math.floor( + builderFee * (1 - this.userFeeDiscountBips / BASIS_POINTS_DIVISOR), + ); + DevLogger.log('Applying builder fee discount', { + originalFee: BUILDER_FEE_CONFIG.maxFeeTenthsBps, + discountBips: this.userFeeDiscountBips, + discountedFee: builderFee, + }); + } + + DevLogger.log('Submitting order via asset ID routing', { + coin, + assetId: orders[0].a, + orderCount: orders.length, + mainOrder: orders[0], + dexName: dexName || 'main', + isHip3: !!dexName, + }); + + try { + const result = await exchangeClient.order({ + orders, + grouping, + builder: { + b: this.getBuilderAddress(this.clientService.isTestnetMode()), + f: builderFee, + }, + }); + + if (result.status !== 'ok') { + throw new Error(`Order failed: ${JSON.stringify(result)}`); + } + + const status = result.response?.data?.statuses?.[0]; + const restingOrder = + status && 'resting' in status ? status.resting : null; + const filledOrder = status && 'filled' in status ? status.filled : null; + + // Success - auto-rebalance excess funds + if (isHip3Order && transferInfo && dexName) { + await this.handleHip3PostOrderRebalance({ dexName, transferInfo }); + } + + return { + success: true, + orderId: restingOrder?.oid?.toString() || filledOrder?.oid?.toString(), + filledSize: filledOrder?.totalSz, + averagePrice: filledOrder?.avgPx, + }; + } catch (orderError) { + // Failure - rollback transfer + if (transferInfo && dexName) { + await this.handleHip3OrderRollback({ dexName, transferInfo }); + } + throw orderError; + } + } + + /** + * Handles order errors with proper error mapping + */ + private handleOrderError(params: HandleOrderErrorParams): OrderResult { + const { error, coin, orderType, isBuy } = params; + + Logger.error( + ensureError(error), + this.getErrorContext('placeOrder', { + coin, + orderType, + isBuy, + }), + ); + + const mappedError = this.mapError(error); + return createErrorResult(mappedError, { success: false }); + } + + /** + * Place an order using direct wallet signing + * + * Refactored to use helper methods for better maintainability and reduced complexity. + * Each helper method is focused on a single responsibility. */ async placeOrder(params: OrderParams): Promise { try { DevLogger.log('Placing order via HyperLiquid SDK:', params); - // Validate order parameters - const validation = validateOrderParams(params); + // Basic sync validation (backward compatibility) + const validation = validateOrderParams({ + coin: params.coin, + size: params.size, + price: params.price, + orderType: params.orderType, + }); if (!validation.isValid) { throw new Error(validation.error); } + // Validate order at provider level (enforces USD validation rules) + await this.validateOrderBeforePlacement(params); + await this.ensureReady(); // Debug: Log asset map state before order placement const allMapKeys = Array.from(this.coinToAssetId.keys()); const hip3Keys = allMapKeys.filter((k) => k.includes(':')); const assetExists = this.coinToAssetId.has(params.coin); - DevLogger.log('HyperLiquidProvider: Asset map state at order time', { + DevLogger.log('Asset map state at order time', { requestedCoin: params.coin, assetExistsInMap: assetExists, totalAssetsInMap: this.coinToAssetId.size, @@ -1560,79 +1850,56 @@ export class HyperLiquidProvider implements IPerpsProvider { blocklistMarkets: this.blocklistMarkets, }); - // See ensureReady() - builder fee and referral are session-cached - // Extract DEX name for API calls (main DEX = null) const { dex: dexName } = parseAssetName(params.coin); - // Get asset info from the correct DEX (uses cache to avoid redundant API calls) - const infoClient = this.clientService.getInfoClient(); - const meta = await this.getCachedMeta({ dexName }); + // 1. Get asset info and current price + const { assetInfo, currentPrice } = await this.getAssetInfo({ + coin: params.coin, + dexName, + }); - // asset.name format: "BTC" for main DEX, "xyz:XYZ100" for HIP-3 - const assetInfo = meta.universe.find( - (asset) => asset.name === params.coin, - ); - if (!assetInfo) { - throw new Error( - `Asset ${params.coin} not found in ${dexName || 'main'} DEX universe`, - ); - } + // Allow override with UI-provided price (optimization to avoid API call) + const effectivePrice = + params.currentPrice && params.currentPrice > 0 + ? params.currentPrice + : currentPrice; - // Use provided current price or fetch if not provided - let currentPrice: number; if (params.currentPrice && params.currentPrice > 0) { - currentPrice = params.currentPrice; DevLogger.log('Using provided current price:', { coin: params.coin, - providedPrice: currentPrice, + providedPrice: effectivePrice, source: 'UI price feed', }); - } else { - DevLogger.log('Fetching current price via API (fallback)'); - const mids = await infoClient.allMids({ dex: dexName ?? '' }); - // allMids returns prices keyed by asset name ("BTC" or "xyz:XYZ100") - currentPrice = parseFloat(mids[params.coin] || '0'); - if (currentPrice === 0) { - throw new Error(`No price available for ${params.coin}`); - } } - // Calculate order parameters using the same logic as debug test - let orderPrice: number; - let formattedSize: string; + // 2. Calculate final position size with USD reconciliation + const { finalPositionSize } = calculateFinalPositionSize({ + usdAmount: params.usdAmount, + size: params.size, + currentPrice: effectivePrice, + priceAtCalculation: params.priceAtCalculation, + maxSlippageBps: params.maxSlippageBps, + szDecimals: assetInfo.szDecimals, + leverage: params.leverage, + }); - if (params.orderType === 'market') { - // For market orders, calculate position size and add slippage - const positionSize = parseFloat(params.size); - const slippage = params.slippage ?? 0.01; // Default to 1% slippage if not specified - orderPrice = params.isBuy - ? currentPrice * (1 + slippage) // Buy above market - : currentPrice * (1 - slippage); // Sell below market - formattedSize = formatHyperLiquidSize({ - size: positionSize, - szDecimals: assetInfo.szDecimals, - }); - } else { - // For limit orders, use provided price and size - orderPrice = parseFloat(params.price || '0'); - formattedSize = formatHyperLiquidSize({ - size: parseFloat(params.size), + // 3. Calculate order price and formatted size + const { orderPrice, formattedSize, formattedPrice } = + calculateOrderPriceAndSize({ + orderType: params.orderType, + isBuy: params.isBuy, + finalPositionSize, + currentPrice: effectivePrice, + limitPrice: params.price, + slippage: params.slippage, szDecimals: assetInfo.szDecimals, }); - } - const formattedPrice = formatHyperLiquidPrice({ - price: orderPrice, - szDecimals: assetInfo.szDecimals, - }); - - // Get the asset ID for this DEX - // Each DEX has its own universe with indices starting from 0 - // e.g., xyz:XYZ100 is at index 0 in xyz DEX, BTC is at index 0 in main DEX + // 4. Get asset ID and validate it exists const assetId = this.coinToAssetId.get(params.coin); if (assetId === undefined) { - DevLogger.log('HyperLiquidProvider: Asset ID lookup failed', { + DevLogger.log('Asset ID lookup failed', { requestedCoin: params.coin, dexName: dexName || 'main', mapSize: this.coinToAssetId.size, @@ -1642,271 +1909,69 @@ export class HyperLiquidProvider implements IPerpsProvider { throw new Error(`Asset ID not found for ${params.coin}`); } - DevLogger.log('HyperLiquidProvider: Resolved DEX-specific asset ID', { + DevLogger.log('Resolved DEX-specific asset ID', { coin: params.coin, dex: dexName || 'main', assetId, - note: `Asset ID ${assetId} is correct for ${params.coin} in ${ - dexName || 'main' - } DEX`, }); - // Update leverage if specified - if (params.leverage) { - DevLogger.log('Updating leverage before order:', { - coin: params.coin, - assetId, - requestedLeverage: params.leverage, - leverageType: 'isolated', // Default to isolated leverage - }); - - const exchangeClient = this.clientService.getExchangeClient(); - const leverageResult = await exchangeClient.updateLeverage({ - asset: assetId, - isCross: false, // Default to isolated leverage for now - leverage: params.leverage, - }); - - if (leverageResult.status !== 'ok') { - throw new Error( - `Failed to update leverage: ${JSON.stringify(leverageResult)}`, - ); - } - - DevLogger.log('Leverage updated successfully:', { - coin: params.coin, - leverage: params.leverage, - }); - } + // 5. Update leverage if specified + await this.prepareAssetForTrading({ + coin: params.coin, + assetId, + leverage: params.leverage, + }); - // HIP-3 balance management: native abstraction or programmatic transfer + // 6. Handle HIP-3 balance management (if applicable) const isHip3Order = dexName !== null; let transferInfo: { amount: number; sourceDex: string } | null = null; - if (isHip3Order && !this.useDexAbstraction) { - // Manual auto-transfer logic (when DEX abstraction is disabled) - DevLogger.log('HyperLiquidProvider: Using manual auto-transfer', { - coin: params.coin, - dex: dexName, - }); - - // Calculate required margin based on existing position - const positionSize = parseFloat(formattedSize); + if (isHip3Order && dexName) { const effectiveLeverage = params.leverage || assetInfo.maxLeverage || 1; - - const requiredMarginWithBuffer = await this.calculateHip3RequiredMargin( - { - coin: params.coin, - dexName, - positionSize, - orderPrice, - leverage: effectiveLeverage, - isBuy: params.isBuy, - }, - ); - - // Transfer funds to reach required TOTAL margin in available balance - // autoTransferForHip3Order checks current balance and only transfers shortfall - try { - transferInfo = await this.autoTransferForHip3Order({ - targetDex: dexName, - requiredMargin: requiredMarginWithBuffer, - }); - } catch (transferError) { - // Reactive fix: Check if transfer failed because DEX abstraction is actually enabled - const errorMsg = (transferError as Error)?.message || ''; - - if ( - errorMsg.includes('Cannot transfer with DEX abstraction enabled') - ) { - DevLogger.log( - 'HyperLiquidProvider: Detected DEX abstraction is enabled, switching to abstraction mode', - ); - - // Update flag to prevent this issue on future orders - this.useDexAbstraction = true; - - // Continue without manual transfer - let DEX abstraction handle it - transferInfo = null; - } else { - // Different error - rethrow - throw transferError; - } - } - } else if (isHip3Order && this.useDexAbstraction) { - DevLogger.log( - 'HyperLiquidProvider: Using DEX abstraction (no manual transfer)', - { - coin: params.coin, - dex: dexName, - note: 'HyperLiquid will auto-manage collateral', - }, - ); - } - - // Build orders array - main order plus optional TP/SL orders - const orders: SDKOrderParams[] = []; - - // 1. Main order (always present) - const mainOrder: SDKOrderParams = { - a: assetId, - b: params.isBuy, - p: formattedPrice, - s: formattedSize, - r: params.reduceOnly || false, - /** - * HyperLiquid Time-In-Force (TIF) options: - * - 'Gtc' (Good Till Canceled): Standard limit orders that remain active until filled or canceled - * - 'Ioc' (Immediate or Cancel): Limit orders that fill immediately or cancel unfilled portion - * - 'FrontendMarket': True market orders as used in HyperLiquid UI - USE THIS FOR MARKET ORDERS - * - 'Alo' (Add Liquidity Only): Maker-only orders that add liquidity to order book - * - 'LiquidationMarket': Similar to IoC, used for liquidation orders - * - * IMPORTANT: Use 'FrontendMarket' for market orders, NOT 'Ioc' - * HyperLiquid treats 'Ioc' as limit orders, causing incorrect order type display - */ - t: - params.orderType === 'limit' - ? { limit: { tif: 'Gtc' } } // Standard limit order - : { limit: { tif: 'FrontendMarket' } }, // True market order - c: params.clientOrderId ? (params.clientOrderId as Hex) : undefined, - }; - orders.push(mainOrder); - - // 2. Take Profit order (if specified) - if (params.takeProfitPrice) { - const tpOrder: SDKOrderParams = { - a: assetId, - b: !params.isBuy, // Opposite side to close position - p: formatHyperLiquidPrice({ - price: parseFloat(params.takeProfitPrice), - szDecimals: assetInfo.szDecimals, - }), - s: formattedSize, // Same size as main order - r: true, // Always reduce-only for TP - t: { - trigger: { - isMarket: false, // Limit order when triggered - triggerPx: formatHyperLiquidPrice({ - price: parseFloat(params.takeProfitPrice), - szDecimals: assetInfo.szDecimals, - }), - tpsl: 'tp', - }, - }, - }; - orders.push(tpOrder); - } - - // 3. Stop Loss order (if specified) - if (params.stopLossPrice) { - const slOrder: SDKOrderParams = { - a: assetId, - b: !params.isBuy, // Opposite side to close position - p: formatHyperLiquidPrice({ - price: parseFloat(params.stopLossPrice), - szDecimals: assetInfo.szDecimals, - }), - s: formattedSize, // Same size as main order - r: true, // Always reduce-only for SL - t: { - trigger: { - isMarket: true, // Market order when triggered for faster execution - triggerPx: formatHyperLiquidPrice({ - price: parseFloat(params.stopLossPrice), - szDecimals: assetInfo.szDecimals, - }), - tpsl: 'sl', - }, - }, - }; - orders.push(slOrder); - } - - // 4. Determine grouping - use explicit override or smart defaults - const grouping = - params.grouping || - (params.takeProfitPrice || params.stopLossPrice ? 'normalTpsl' : 'na'); - - // 5. 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), - ); - DevLogger.log('HyperLiquid: Applying builder fee discount', { - originalFee: BUILDER_FEE_CONFIG.maxFeeTenthsBps, - discountBips: this.userFeeDiscountBips, - discountedFee: builderFee, - }); - } - - // 6. Submit order with atomic rollback for HIP-3 failures - // Asset ID determines routing (main DEX: direct index, HIP-3: BASE_ASSET_ID + dexIndex*DEX_MULTIPLIER + coinIndex) - // The exchange client handles all DEXs through a single instance - const exchangeClient = this.clientService.getExchangeClient(); - - DevLogger.log( - 'HyperLiquidProvider: Submitting order via asset ID routing', - { + const hip3Result = await this.handleHip3PreOrder({ + dexName, coin: params.coin, - assetId: orders[0].a, - orderCount: orders.length, - mainOrder: orders[0], - dexName: dexName || 'main', - isHip3: !!dexName, - }, - ); - - try { - const result = await exchangeClient.order({ - orders, - grouping, - builder: { - b: this.getBuilderAddress(this.clientService.isTestnetMode()), - f: builderFee, - }, + orderPrice, + positionSize: parseFloat(formattedSize), + leverage: effectiveLeverage, + isBuy: params.isBuy, + maxLeverage: assetInfo.maxLeverage, }); + transferInfo = hip3Result.transferInfo; + } - if (result.status !== 'ok') { - throw new Error(`Order failed: ${JSON.stringify(result)}`); - } - - const status = result.response?.data?.statuses?.[0]; - const restingOrder = - status && 'resting' in status ? status.resting : null; - const filledOrder = status && 'filled' in status ? status.filled : null; - - // Order succeeded - auto-rebalance excess funds back to main DEX - if (isHip3Order && transferInfo && dexName) { - await this.handleHip3PostOrderRebalance({ dexName, transferInfo }); - } + // 7. Build orders array (main + TP/SL if specified) + const { orders, grouping } = buildOrdersArray({ + assetId, + isBuy: params.isBuy, + formattedPrice, + formattedSize, + reduceOnly: params.reduceOnly || false, + orderType: params.orderType, + clientOrderId: params.clientOrderId, + takeProfitPrice: params.takeProfitPrice, + stopLossPrice: params.stopLossPrice, + szDecimals: assetInfo.szDecimals, + grouping: params.grouping, + }); - return { - success: true, - orderId: - restingOrder?.oid?.toString() || filledOrder?.oid?.toString(), - filledSize: filledOrder?.totalSz, - averagePrice: filledOrder?.avgPx, - }; - } catch (orderError) { - // Order failed - rollback HIP-3 transfer if funds were moved - if (transferInfo && dexName) { - await this.handleHip3OrderRollback({ dexName, transferInfo }); - } - throw orderError; - } + // 8. Submit order with atomic rollback + return await this.submitOrderWithRollback({ + orders, + grouping, + isHip3Order, + dexName, + transferInfo, + coin: params.coin, + assetId, + }); } catch (error) { - Logger.error( - ensureError(error), - this.getErrorContext('placeOrder', { - coin: params.coin, - orderType: params.orderType, - isBuy: params.isBuy, - }), - ); - const mappedError = this.mapError(error); - return createErrorResult(mappedError, { success: false }); + return this.handleOrderError({ + error, + coin: params.coin, + orderType: params.orderType, + isBuy: params.isBuy, + }); } } @@ -1923,6 +1988,26 @@ export class HyperLiquidProvider implements IPerpsProvider { try { DevLogger.log('Editing order:', params); + // Validate size is positive (validateOrderParams no longer validates size) + const size = parseFloat(params.newOrder.size || '0'); + if (size <= 0) { + return { + success: false, + error: strings('perps.errors.orderValidation.sizePositive'), + }; + } + + // Validate new order parameters + const validation = validateOrderParams({ + coin: params.newOrder.coin, + size: params.newOrder.size, + price: params.newOrder.price, + orderType: params.newOrder.orderType, + }); + if (!validation.isValid) { + throw new Error(validation.error); + } + await this.ensureReady(); // Extract DEX name for API calls (main DEX = null) @@ -1956,7 +2041,9 @@ export class HyperLiquidProvider implements IPerpsProvider { if (params.newOrder.orderType === 'market') { const positionSize = parseFloat(params.newOrder.size); - const slippage = params.newOrder.slippage ?? 0.01; // Default to 1% slippage if not specified + const slippage = + params.newOrder.slippage ?? + ORDER_SLIPPAGE_CONFIG.DEFAULT_SLIPPAGE_BPS / 10000; orderPrice = params.newOrder.isBuy ? currentPrice * (1 + slippage) : currentPrice * (1 - slippage); @@ -1965,7 +2052,12 @@ export class HyperLiquidProvider implements IPerpsProvider { szDecimals: assetInfo.szDecimals, }); } else { - orderPrice = parseFloat(params.newOrder.price || '0'); + if (!params.newOrder.price) { + throw new Error( + strings('perps.errors.orderValidation.limitPriceRequired'), + ); + } + orderPrice = parseFloat(params.newOrder.price); formattedSize = formatHyperLiquidSize({ size: parseFloat(params.newOrder.size), szDecimals: assetInfo.szDecimals, @@ -2270,7 +2362,7 @@ export class HyperLiquidProvider implements IPerpsProvider { } // Calculate order price with slippage - const slippage = TRADING_DEFAULTS.slippage; + const slippage = ORDER_SLIPPAGE_CONFIG.DEFAULT_SLIPPAGE_BPS / 10000; const orderPrice = isBuy ? currentPrice * (1 + slippage) : currentPrice * (1 - slippage); @@ -2664,6 +2756,26 @@ export class HyperLiquidProvider implements IPerpsProvider { const freedMarginRatio = closeSizeNum / totalPositionSize; const freedMargin = totalMarginUsed * freedMarginRatio; + // Get current price for validation if not provided (and not a full close) + // Full closes don't need price for validation + let currentPrice = params.currentPrice; + if (!currentPrice && params.size && !params.usdAmount) { + // Partial close without USD or price: use limit price as fallback for validation + // For limit orders, the limit price is a reasonable proxy for validation purposes + if (params.price && params.orderType === 'limit') { + currentPrice = parseFloat(params.price); + DevLogger.log( + 'Using limit price for close position validation (limit order)', + { + coin: params.coin, + currentPrice, + }, + ); + } + // Note: For market orders without usdAmount/currentPrice, validation will fail + // with "price_required" error, which is correct behavior (prevents invalid orders) + } + DevLogger.log('Position close details', { coin: position.coin, isHip3Position, @@ -2673,7 +2785,7 @@ export class HyperLiquidProvider implements IPerpsProvider { freedMargin: freedMargin.toFixed(2), }); - // Execute position close + // Execute position close with consistent slippage handling const result = await this.placeOrder({ coin: params.coin, isBuy, @@ -2681,6 +2793,12 @@ export class HyperLiquidProvider implements IPerpsProvider { orderType: params.orderType || 'market', price: params.price, reduceOnly: true, + isFullClose: !params.size, // True if closing 100% (size not provided) + // Pass through price and slippage parameters for consistent validation + currentPrice, + usdAmount: params.usdAmount, + priceAtCalculation: params.priceAtCalculation, + maxSlippageBps: params.maxSlippageBps, }); // Return freed margin using native abstraction or programmatic transfer @@ -3773,6 +3891,7 @@ export class HyperLiquidProvider implements IPerpsProvider { coin: params.coin, size: params.size, price: params.price, + orderType: params.orderType, }); if (!basicValidation.isValid) { return basicValidation; @@ -3780,29 +3899,74 @@ export class HyperLiquidProvider implements IPerpsProvider { // Check minimum order size using consistent defaults (matching useMinimumOrderAmount hook) // Note: For full validation with market-specific limits, use async methods - const coinAmount = parseFloat(params.size || '0'); const minimumOrderSize = this.clientService.isTestnetMode() ? TRADING_DEFAULTS.amount.testnet : TRADING_DEFAULTS.amount.mainnet; - // Convert coin amount to USD value for comparison with minimum - // Price is required for proper validation - if (!params.currentPrice) { - return { - isValid: false, - error: strings('perps.order.validation.price_required'), - }; - } + // Skip USD validation and minimum check for full closes (100% position close) + if (params.reduceOnly && params.isFullClose) { + DevLogger.log( + 'Full close detected: skipping USD validation and $10 minimum', + ); + } else { + // Calculate order value in USD for minimum validation + let orderValueUSD: number; - const orderValueUSD = coinAmount * params.currentPrice; + if (params.usdAmount) { + // Preferred: Use provided USD amount (source of truth, no rounding loss) + orderValueUSD = parseFloat(params.usdAmount); - if (orderValueUSD < minimumOrderSize) { - return { - isValid: false, - error: strings('perps.order.validation.minimum_amount', { - amount: minimumOrderSize.toString(), - }), - }; + DevLogger.log('Validating USD amount (source of truth):', { + usdAmount: orderValueUSD, + minimumRequired: minimumOrderSize, + }); + } else { + // Fallback: Calculate from size × price + const size = parseFloat(params.size || '0'); + let priceForValidation = params.currentPrice; + + // For limit orders without currentPrice, use limit price as fallback + if ( + !priceForValidation && + params.price && + params.orderType === 'limit' + ) { + priceForValidation = parseFloat(params.price); + DevLogger.log( + 'Using limit price for order validation (limit order):', + { + size, + limitPrice: priceForValidation, + }, + ); + } + + if (!priceForValidation) { + return { + isValid: false, + error: strings('perps.order.validation.price_required'), + }; + } + + orderValueUSD = size * priceForValidation; + + DevLogger.log('Validating calculated USD from size:', { + size, + price: priceForValidation, + calculatedUsd: orderValueUSD, + minimumRequired: minimumOrderSize, + }); + } + + // Validate minimum order size + if (orderValueUSD < minimumOrderSize) { + return { + isValid: false, + error: strings('perps.order.validation.minimum_amount', { + amount: minimumOrderSize.toString(), + }), + }; + } } // Asset-specific leverage validation diff --git a/app/components/UI/Perps/controllers/types/index.ts b/app/components/UI/Perps/controllers/types/index.ts index f7ea2a3f25b1..468db3274cb5 100644 --- a/app/components/UI/Perps/controllers/types/index.ts +++ b/app/components/UI/Perps/controllers/types/index.ts @@ -92,17 +92,23 @@ export interface TPSLTrackingData { export type OrderParams = { coin: string; // Asset symbol (e.g., 'ETH', 'BTC') isBuy: boolean; // true = BUY order, false = SELL order - size: string; // Order size as string + size: string; // Order size as string (derived for validation, provider recalculates from usdAmount) orderType: OrderType; // Order type price?: string; // Limit price (required for limit orders) reduceOnly?: boolean; // Reduce-only flag + isFullClose?: boolean; // Indicates closing 100% of position (skips $10 minimum validation) timeInForce?: 'GTC' | 'IOC' | 'ALO'; // Time in force + // USD as source of truth (hybrid approach) + usdAmount?: string; // USD amount (primary source of truth, provider calculates size from this) + priceAtCalculation?: number; // Price snapshot when size was calculated (for slippage validation) + maxSlippageBps?: number; // Slippage tolerance in basis points (e.g., 100 = 1%, default if not provided) + // Advanced order features takeProfitPrice?: string; // Take profit price stopLossPrice?: string; // Stop loss price clientOrderId?: string; // Optional client-provided order ID - slippage?: number; // Slippage tolerance for market orders (e.g., 0.01 = 1%) + slippage?: number; // Slippage tolerance for market orders (default: ORDER_SLIPPAGE_CONFIG.DEFAULT_SLIPPAGE_BPS / 10000 = 1%) 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) @@ -181,6 +187,12 @@ export type ClosePositionParams = { orderType?: OrderType; // Close order type (default: market) price?: string; // Limit price (required for limit close) currentPrice?: number; // Current market price for validation + + // USD as source of truth (hybrid approach - same as OrderParams) + usdAmount?: string; // USD amount (primary source of truth, provider calculates size from this) + priceAtCalculation?: number; // Price snapshot when size was calculated (for slippage validation) + maxSlippageBps?: number; // Slippage tolerance in basis points (e.g., 100 = 1%, default if not provided) + // Optional tracking data for MetaMetrics events trackingData?: TrackingData; }; diff --git a/app/components/UI/Perps/hooks/index.ts b/app/components/UI/Perps/hooks/index.ts index 191a4ef87f0a..b66cf7f0f62f 100644 --- a/app/components/UI/Perps/hooks/index.ts +++ b/app/components/UI/Perps/hooks/index.ts @@ -31,7 +31,10 @@ export { usePerpsPrices } from './usePerpsPrices'; export { usePerpsAssetMetadata } from './usePerpsAssetsMetadata'; // Market data and calculation hooks export { usePerpsLiquidationPrice } from './usePerpsLiquidationPrice'; -export { usePerpsMarketData } from './usePerpsMarketData'; +export { + usePerpsMarketData, + type UsePerpsMarketDataParams, +} from './usePerpsMarketData'; export { usePerpsMarketStats } from './usePerpsMarketStats'; // Withdrawal specific hooks diff --git a/app/components/UI/Perps/hooks/usePerpsClosePosition.test.ts b/app/components/UI/Perps/hooks/usePerpsClosePosition.test.ts index dde05cf6a5d8..f1a030c58a0f 100644 --- a/app/components/UI/Perps/hooks/usePerpsClosePosition.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsClosePosition.test.ts @@ -106,8 +106,9 @@ describe('usePerpsClosePosition', () => { const { result } = renderHook(() => usePerpsClosePosition({ onSuccess })); await act(async () => { - const closeResult = - await result.current.handleClosePosition(mockPosition); + const closeResult = await result.current.handleClosePosition({ + position: mockPosition, + }); expect(closeResult).toEqual(successResult); }); @@ -116,6 +117,10 @@ describe('usePerpsClosePosition', () => { size: undefined, orderType: 'market', price: undefined, + trackingData: undefined, + usdAmount: undefined, + priceAtCalculation: undefined, + maxSlippageBps: undefined, }); expect(onSuccess).toHaveBeenCalledWith(successResult); @@ -145,12 +150,12 @@ describe('usePerpsClosePosition', () => { const { result } = renderHook(() => usePerpsClosePosition({ onSuccess })); await act(async () => { - const closeResult = await result.current.handleClosePosition( - mockPosition, - '0.05', - 'limit', - '51000', - ); + const closeResult = await result.current.handleClosePosition({ + position: mockPosition, + size: '0.05', + orderType: 'limit', + limitPrice: '51000', + }); expect(closeResult).toEqual(successResult); }); @@ -159,6 +164,10 @@ describe('usePerpsClosePosition', () => { size: '0.05', orderType: 'limit', price: '51000', + trackingData: undefined, + usdAmount: undefined, + priceAtCalculation: undefined, + maxSlippageBps: undefined, }); expect(onSuccess).toHaveBeenCalledWith(successResult); @@ -185,7 +194,7 @@ describe('usePerpsClosePosition', () => { await act(async () => { await expect( - result.current.handleClosePosition(mockPosition), + result.current.handleClosePosition({ position: mockPosition }), ).rejects.toThrow('perps.close_position.error_unknown'); }); @@ -214,7 +223,7 @@ describe('usePerpsClosePosition', () => { await act(async () => { await expect( - result.current.handleClosePosition(mockPosition), + result.current.handleClosePosition({ position: mockPosition }), ).rejects.toThrow('perps.close_position.error_unknown'); }); }); @@ -228,7 +237,7 @@ describe('usePerpsClosePosition', () => { await act(async () => { await expect( - result.current.handleClosePosition(mockPosition), + result.current.handleClosePosition({ position: mockPosition }), ).rejects.toThrow('Network error'); }); @@ -248,7 +257,7 @@ describe('usePerpsClosePosition', () => { await act(async () => { await expect( - result.current.handleClosePosition(mockPosition), + result.current.handleClosePosition({ position: mockPosition }), ).rejects.toThrow('perps.close_position.error_unknown'); }); @@ -271,7 +280,9 @@ describe('usePerpsClosePosition', () => { // Start closing let closePromise: Promise; act(() => { - closePromise = result.current.handleClosePosition(mockPosition); + closePromise = result.current.handleClosePosition({ + position: mockPosition, + }); }); // Check loading state @@ -297,11 +308,11 @@ describe('usePerpsClosePosition', () => { const { result } = renderHook(() => usePerpsClosePosition()); await act(async () => { - const closeResult = await result.current.handleClosePosition( - mockPosition, - '0.1', - 'market', - ); + const closeResult = await result.current.handleClosePosition({ + position: mockPosition, + size: '0.1', + orderType: 'market', + }); expect(closeResult).toEqual(successResult); }); @@ -310,6 +321,10 @@ describe('usePerpsClosePosition', () => { size: '0.1', orderType: 'market', price: undefined, + trackingData: undefined, + usdAmount: undefined, + priceAtCalculation: undefined, + maxSlippageBps: undefined, }); }); @@ -325,7 +340,7 @@ describe('usePerpsClosePosition', () => { const { result } = renderHook(() => usePerpsClosePosition()); await act(async () => { - await result.current.handleClosePosition(mockPosition); + await result.current.handleClosePosition({ position: mockPosition }); }); // Check logging calls @@ -361,8 +376,9 @@ describe('usePerpsClosePosition', () => { const { result } = renderHook(() => usePerpsClosePosition()); await act(async () => { - const closeResult = - await result.current.handleClosePosition(positionWithTPSL); + const closeResult = await result.current.handleClosePosition({ + position: positionWithTPSL, + }); expect(closeResult).toEqual(successResult); }); @@ -372,6 +388,10 @@ describe('usePerpsClosePosition', () => { size: undefined, orderType: 'market', price: undefined, + trackingData: undefined, + usdAmount: undefined, + priceAtCalculation: undefined, + maxSlippageBps: undefined, }); }); @@ -383,7 +403,7 @@ describe('usePerpsClosePosition', () => { await act(async () => { await expect( - result.current.handleClosePosition(mockPosition), + result.current.handleClosePosition({ position: mockPosition }), ).rejects.toThrow('First error'); }); @@ -398,7 +418,7 @@ describe('usePerpsClosePosition', () => { }); await act(async () => { - await result.current.handleClosePosition(mockPosition); + await result.current.handleClosePosition({ position: mockPosition }); }); expect(result.current.error).toBeNull(); @@ -416,11 +436,10 @@ describe('usePerpsClosePosition', () => { const { result } = renderHook(() => usePerpsClosePosition()); await act(async () => { - await result.current.handleClosePosition( - mockPosition, - undefined, - 'market', - ); + await result.current.handleClosePosition({ + position: mockPosition, + orderType: 'market', + }); }); // Verify progress toast is called with correct parameters @@ -440,11 +459,11 @@ describe('usePerpsClosePosition', () => { const { result } = renderHook(() => usePerpsClosePosition()); await act(async () => { - await result.current.handleClosePosition( - mockPosition, - '0.05', - 'market', - ); + await result.current.handleClosePosition({ + position: mockPosition, + size: '0.05', + orderType: 'market', + }); }); // Verify progress toast is called with correct parameters @@ -469,11 +488,10 @@ describe('usePerpsClosePosition', () => { const { result } = renderHook(() => usePerpsClosePosition()); await act(async () => { - await result.current.handleClosePosition( - shortPosition, - undefined, - 'market', - ); + await result.current.handleClosePosition({ + position: shortPosition, + orderType: 'market', + }); }); // Verify progress toast is called with correct direction for short position @@ -495,11 +513,10 @@ describe('usePerpsClosePosition', () => { const { result } = renderHook(() => usePerpsClosePosition()); await act(async () => { - await result.current.handleClosePosition( - mockPosition, - undefined, - 'market', - ); + await result.current.handleClosePosition({ + position: mockPosition, + orderType: 'market', + }); }); // Should show progress toast first, then success toast @@ -520,11 +537,11 @@ describe('usePerpsClosePosition', () => { const { result } = renderHook(() => usePerpsClosePosition()); await act(async () => { - await result.current.handleClosePosition( - mockPosition, - '0.05', - 'market', - ); + await result.current.handleClosePosition({ + position: mockPosition, + size: '0.05', + orderType: 'market', + }); }); // Should show progress toast first, then success toast @@ -545,11 +562,11 @@ describe('usePerpsClosePosition', () => { const { result } = renderHook(() => usePerpsClosePosition()); await act(async () => { - await result.current.handleClosePosition( - mockPosition, - '', - 'market', - ); + await result.current.handleClosePosition({ + position: mockPosition, + size: '', + orderType: 'market', + }); }); expect( @@ -571,11 +588,10 @@ describe('usePerpsClosePosition', () => { await act(async () => { await expect( - result.current.handleClosePosition( - mockPosition, - undefined, - 'market', - ), + result.current.handleClosePosition({ + position: mockPosition, + orderType: 'market', + }), ).rejects.toThrow(); }); @@ -599,11 +615,11 @@ describe('usePerpsClosePosition', () => { await act(async () => { await expect( - result.current.handleClosePosition( - mockPosition, - '0.05', - 'market', - ), + result.current.handleClosePosition({ + position: mockPosition, + size: '0.05', + orderType: 'market', + }), ).rejects.toThrow(); }); @@ -627,7 +643,11 @@ describe('usePerpsClosePosition', () => { await act(async () => { await expect( - result.current.handleClosePosition(mockPosition, '', 'market'), + result.current.handleClosePosition({ + position: mockPosition, + size: '', + orderType: 'market', + }), ).rejects.toThrow(); }); @@ -649,11 +669,11 @@ describe('usePerpsClosePosition', () => { await act(async () => { await expect( - result.current.handleClosePosition( - mockPosition, - '0.05', - 'market', - ), + result.current.handleClosePosition({ + position: mockPosition, + size: '0.05', + orderType: 'market', + }), ).rejects.toThrow(); }); @@ -677,12 +697,11 @@ describe('usePerpsClosePosition', () => { const { result } = renderHook(() => usePerpsClosePosition()); await act(async () => { - await result.current.handleClosePosition( - mockPosition, - undefined, - 'limit', - '51000', - ); + await result.current.handleClosePosition({ + position: mockPosition, + orderType: 'limit', + limitPrice: '51000', + }); }); // Should only show submission toast for limit orders, not success toast @@ -703,12 +722,12 @@ describe('usePerpsClosePosition', () => { const { result } = renderHook(() => usePerpsClosePosition()); await act(async () => { - await result.current.handleClosePosition( - mockPosition, - '0.05', - 'limit', - '51000', - ); + await result.current.handleClosePosition({ + position: mockPosition, + size: '0.05', + orderType: 'limit', + limitPrice: '51000', + }); }); // Should only show submission toast for limit orders, not success toast @@ -730,12 +749,12 @@ describe('usePerpsClosePosition', () => { await act(async () => { await expect( - result.current.handleClosePosition( - mockPosition, - '0.05', - 'limit', - '51000', - ), + result.current.handleClosePosition({ + position: mockPosition, + size: '0.05', + orderType: 'limit', + limitPrice: '51000', + }), ).rejects.toThrow(); }); @@ -759,11 +778,10 @@ describe('usePerpsClosePosition', () => { const { result } = renderHook(() => usePerpsClosePosition()); await act(async () => { - await result.current.handleClosePosition( - mockPosition, - undefined, - 'market', - ); + await result.current.handleClosePosition({ + position: mockPosition, + orderType: 'market', + }); }); // Verify exact sequence: progress first, then success @@ -799,11 +817,11 @@ describe('usePerpsClosePosition', () => { await act(async () => { await expect( - result.current.handleClosePosition( - mockPosition, - '0.05', - 'market', - ), + result.current.handleClosePosition({ + position: mockPosition, + size: '0.05', + orderType: 'market', + }), ).rejects.toThrow(); }); @@ -844,14 +862,11 @@ describe('usePerpsClosePosition', () => { const { result } = renderHook(() => usePerpsClosePosition()); await act(async () => { - await result.current.handleClosePosition( - mockPosition, - undefined, - 'market', - undefined, - undefined, - '$55000', - ); + await result.current.handleClosePosition({ + position: mockPosition, + orderType: 'market', + marketPrice: '$55000', + }); }); expect(mockClosePosition).toHaveBeenCalled(); @@ -867,14 +882,11 @@ describe('usePerpsClosePosition', () => { const { result } = renderHook(() => usePerpsClosePosition()); await act(async () => { - await result.current.handleClosePosition( - mockPosition, - undefined, - 'market', - undefined, - undefined, - '$55000', - ); + await result.current.handleClosePosition({ + position: mockPosition, + orderType: 'market', + marketPrice: '$55000', + }); }); expect( @@ -893,14 +905,12 @@ describe('usePerpsClosePosition', () => { const { result } = renderHook(() => usePerpsClosePosition()); await act(async () => { - await result.current.handleClosePosition( - mockPosition, - '0.05', - 'market', - undefined, - undefined, - '$55000', - ); + await result.current.handleClosePosition({ + position: mockPosition, + size: '0.05', + orderType: 'market', + marketPrice: '$55000', + }); }); expect( @@ -919,14 +929,11 @@ describe('usePerpsClosePosition', () => { const { result } = renderHook(() => usePerpsClosePosition()); await act(async () => { - await result.current.handleClosePosition( - mockPosition, - undefined, - 'market', - undefined, - undefined, - '$50000', - ); + await result.current.handleClosePosition({ + position: mockPosition, + orderType: 'market', + marketPrice: '$50000', + }); }); expect( @@ -948,11 +955,10 @@ describe('usePerpsClosePosition', () => { const { result } = renderHook(() => usePerpsClosePosition()); await act(async () => { - await result.current.handleClosePosition( - mockPosition, - undefined, - 'market', - ); + await result.current.handleClosePosition({ + position: mockPosition, + orderType: 'market', + }); }); expect( @@ -973,7 +979,7 @@ describe('usePerpsClosePosition', () => { const { result, rerender } = renderHook(() => usePerpsClosePosition()); await act(async () => { - await result.current.handleClosePosition(mockPosition); + await result.current.handleClosePosition({ position: mockPosition }); }); const handleClosePositionBefore = result.current.handleClosePosition; @@ -1010,7 +1016,7 @@ describe('usePerpsClosePosition', () => { mockClosePosition.mockResolvedValue({ success: true, orderId: '555' }); await act(async () => { - await result.current.handleClosePosition(mockPosition); + await result.current.handleClosePosition({ position: mockPosition }); }); expect(onSuccess1).not.toHaveBeenCalled(); diff --git a/app/components/UI/Perps/hooks/usePerpsClosePosition.ts b/app/components/UI/Perps/hooks/usePerpsClosePosition.ts index aea14050c94f..e8bf062d8b3c 100644 --- a/app/components/UI/Perps/hooks/usePerpsClosePosition.ts +++ b/app/components/UI/Perps/hooks/usePerpsClosePosition.ts @@ -11,6 +11,27 @@ interface UsePerpsClosePositionOptions { onError?: (error: Error) => void; } +interface ClosePositionParams { + // Required + position: Position; + + // Core parameters + size?: string; + orderType?: 'market' | 'limit'; + limitPrice?: string; + + // Tracking data + trackingData?: TrackingData; + marketPrice?: string; // Used for PnL toast to lock in the market price at time of closing + + // Slippage validation (grouped for clarity) + slippage?: { + usdAmount?: string; + priceAtCalculation?: number; + maxSlippageBps?: number; + }; +} + export const usePerpsClosePosition = ( options?: UsePerpsClosePositionOptions, ) => { @@ -20,14 +41,17 @@ export const usePerpsClosePosition = ( const { showToast, PerpsToastOptions } = usePerpsToasts(); const handleClosePosition = useCallback( - async ( - position: Position, - size?: string, - orderType: 'market' | 'limit' = 'market', - limitPrice?: string, - trackingData?: TrackingData, - marketPrice?: string, // Used for PnL toast to lock in the market price at time of closing - ) => { + async (params: ClosePositionParams) => { + const { + position, + size, + orderType = 'market', + limitPrice, + trackingData, + marketPrice, + slippage, + } = params; + try { setIsClosing(true); setError(null); @@ -91,13 +115,17 @@ export const usePerpsClosePosition = ( } } - // Close position + // Close position with slippage parameters for consistent validation const result = await closePosition({ coin: position.coin, size, // If undefined, will close full position orderType, price: limitPrice, trackingData, + // Pass through slippage parameters + usdAmount: slippage?.usdAmount, + priceAtCalculation: slippage?.priceAtCalculation, + maxSlippageBps: slippage?.maxSlippageBps, }); DevLogger.log('usePerpsClosePosition: Close result', result); diff --git a/app/components/UI/Perps/hooks/usePerpsClosePositionValidation.ts b/app/components/UI/Perps/hooks/usePerpsClosePositionValidation.ts index edfd2b9a2904..52b2b96d2eaa 100644 --- a/app/components/UI/Perps/hooks/usePerpsClosePositionValidation.ts +++ b/app/components/UI/Perps/hooks/usePerpsClosePositionValidation.ts @@ -19,6 +19,7 @@ interface UsePerpsClosePositionValidationParams { remainingPositionValue: number; // Value remaining after close (kept for interface completeness) receiveAmount: number; // Amount user will receive after fees isPartialClose: boolean; + skipValidation?: boolean; } interface ValidationResult { @@ -107,6 +108,7 @@ export function usePerpsClosePositionValidation( receiveAmount, isPartialClose, positionValue, + skipValidation, } = params; const { validateClosePosition } = usePerpsTrading(); @@ -235,6 +237,11 @@ export function usePerpsClosePositionValidation( ]); useEffect(() => { + // Skip validation during keypad input to prevent flickering + if (skipValidation) { + return; + } + // Skip validation if critical data is missing if (!coin || currentPrice === 0) { setValidation({ @@ -247,7 +254,7 @@ export function usePerpsClosePositionValidation( } performValidation(); - }, [performValidation, coin, currentPrice]); + }, [performValidation, coin, currentPrice, skipValidation]); return validation; } diff --git a/app/components/UI/Perps/hooks/usePerpsMarketData.test.ts b/app/components/UI/Perps/hooks/usePerpsMarketData.test.ts index 56cedb5b2e93..7cd4e7b4473a 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarketData.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarketData.test.ts @@ -1,11 +1,29 @@ import { renderHook } from '@testing-library/react-hooks'; import { usePerpsMarketData } from './usePerpsMarketData'; import { usePerpsTrading } from './usePerpsTrading'; +import usePerpsToasts, { type PerpsToastOptionsConfig } from './usePerpsToasts'; import type { MarketInfo } from '../controllers/types'; // Mock the usePerpsTrading hook jest.mock('./usePerpsTrading'); +// Mock usePerpsToasts hook +jest.mock('./usePerpsToasts', () => ({ + __esModule: true, + default: jest.fn(() => ({ + showToast: jest.fn(), + PerpsToastOptions: { + dataFetching: { + market: { + error: { + marketDataUnavailable: jest.fn(), + }, + }, + }, + }, + })), +})); + // Mock DevLogger jest.mock('../../../../core/SDKConnect/utils/DevLogger', () => ({ __esModule: true, @@ -146,4 +164,103 @@ describe('usePerpsMarketData', () => { expect(mockGetMarkets).toHaveBeenCalledWith({ symbols: ['BTC'] }); expect(mockGetMarkets).toHaveBeenCalledWith({ symbols: ['ETH'] }); }); + + describe('showErrorToast parameter', () => { + it('should NOT show toast by default when using string parameter', async () => { + const error = new Error('Network error'); + mockGetMarkets.mockRejectedValue(error); + + const { result, waitForNextUpdate } = renderHook(() => + usePerpsMarketData('BTC'), + ); + + await waitForNextUpdate(); + + expect(result.current.error).toBe('Network error'); + // Toast should not be called with default string parameter + }); + + it('should NOT show toast when showErrorToast is false', async () => { + const error = new Error('Network error'); + mockGetMarkets.mockRejectedValue(error); + + const { result, waitForNextUpdate } = renderHook(() => + usePerpsMarketData({ asset: 'BTC', showErrorToast: false }), + ); + + await waitForNextUpdate(); + + expect(result.current.error).toBe('Network error'); + // Toast should not be called + }); + + it('should show toast when showErrorToast is true and error occurs', async () => { + const mockShowToast = jest.fn(); + const mockToastConfig = { + variant: 'error' as const, + hasNoTimeout: false, + }; + const mockMarketDataUnavailable = jest.fn(() => mockToastConfig); + + // Override the mock for this test (use mockReturnValue for all calls in this test) + const mockedUsePerpsToasts = jest.mocked(usePerpsToasts); + mockedUsePerpsToasts.mockReturnValue({ + showToast: mockShowToast, + PerpsToastOptions: { + dataFetching: { + market: { + error: { + marketDataUnavailable: mockMarketDataUnavailable, + }, + }, + }, + } as unknown as PerpsToastOptionsConfig, + }); + + const error = new Error('Network error'); + mockGetMarkets.mockRejectedValue(error); + + const { result, waitForNextUpdate } = renderHook(() => + usePerpsMarketData({ asset: 'BTC', showErrorToast: true }), + ); + + await waitForNextUpdate(); + + expect(result.current.error).toBe('Network error'); + expect(mockMarketDataUnavailable).toHaveBeenCalledWith('BTC'); + expect(mockShowToast).toHaveBeenCalledWith(mockToastConfig); + + // Reset mock to default for other tests + mockedUsePerpsToasts.mockReturnValue({ + showToast: jest.fn(), + PerpsToastOptions: { + dataFetching: { + market: { + error: { + marketDataUnavailable: jest.fn(), + }, + }, + }, + } as unknown as PerpsToastOptionsConfig, + }); + }); + + it('should support both string and object parameter formats', async () => { + mockGetMarkets.mockResolvedValue([mockMarketData]); + + // Test string format + const { result: stringResult, waitForNextUpdate: wait1 } = renderHook( + () => usePerpsMarketData('BTC'), + ); + await wait1(); + expect(stringResult.current.marketData).toEqual(mockMarketData); + + // Test object format + const { result: objectResult, waitForNextUpdate: wait2 } = renderHook( + () => usePerpsMarketData({ asset: 'BTC', showErrorToast: false }), + ); + await wait2(); + expect(objectResult.current.marketData).toEqual(mockMarketData); + }); + }); }); diff --git a/app/components/UI/Perps/hooks/usePerpsMarketData.ts b/app/components/UI/Perps/hooks/usePerpsMarketData.ts index 5507209239b5..fb7581e0f4c4 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarketData.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarketData.ts @@ -1,19 +1,47 @@ import { useCallback, useEffect, useState } from 'react'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; import type { MarketInfo } from '../controllers/types'; +import usePerpsToasts from './usePerpsToasts'; import { usePerpsTrading } from './usePerpsTrading'; +export interface UsePerpsMarketDataParams { + /** Asset symbol to fetch market data for */ + asset: string; + /** Whether to show error toast notifications (default: false) */ + showErrorToast?: boolean; +} + /** * Hook to fetch and manage market data for a specific asset - * @param asset - The asset symbol to fetch market data for + * @param params - Asset symbol (string) or configuration object * @returns Market data, loading state, and error state + * + * @example + * // Simple usage (legacy, no toast) + * const { marketData, isLoading, error } = usePerpsMarketData('BTC'); + * + * @example + * // With error toast notifications + * const { marketData, isLoading, error } = usePerpsMarketData({ + * asset: 'BTC', + * showErrorToast: true, + * }); */ -export const usePerpsMarketData = (asset: string) => { +export const usePerpsMarketData = ( + params: string | UsePerpsMarketDataParams, +) => { + // Support both legacy string and new object params + const asset = typeof params === 'string' ? params : params.asset; + const showErrorToast = + typeof params === 'string' ? false : (params.showErrorToast ?? false); const { getMarkets } = usePerpsTrading(); const [marketData, setMarketData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + // Always call hook (Rules of Hooks requirement) + const { showToast, PerpsToastOptions } = usePerpsToasts(); + const fetchMarketData = useCallback(async () => { if (!asset) { setMarketData(null); @@ -49,6 +77,17 @@ export const usePerpsMarketData = (asset: string) => { fetchMarketData(); }, [fetchMarketData]); + // Show error toast if enabled (only for persistent failures, not initial load) + useEffect(() => { + if (showErrorToast && error && !isLoading) { + showToast( + PerpsToastOptions.dataFetching.market.error.marketDataUnavailable( + asset, + ), + ); + } + }, [showErrorToast, error, isLoading, asset, showToast, PerpsToastOptions]); + return { marketData, isLoading, diff --git a/app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts b/app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts index a5ab4c4fed7a..88b60d3019f2 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts @@ -755,97 +755,4 @@ describe('usePerpsOrderForm', () => { expect(result.current.orderForm.amount).toBe('0'); }); }); - - describe('optimizeOrderAmount', () => { - it('should not optimize when amount is empty', () => { - const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: createWrapper(), - }); - - act(() => { - result.current.setAmount(''); - result.current.optimizeOrderAmount(50000, 6); - }); - - expect(result.current.orderForm.amount).toBe('0'); - }); - - it('should not optimize when amount is zero', () => { - const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: createWrapper(), - }); - - act(() => { - result.current.setAmount('0'); - result.current.optimizeOrderAmount(50000, 6); - }); - - expect(result.current.orderForm.amount).toBe('0'); - }); - - it('should optimize amount when valid amount is provided', () => { - const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: createWrapper(), - }); - - act(() => { - result.current.setAmount('100'); - result.current.optimizeOrderAmount(50000, 6); - }); - - // The optimized amount should be calculated by findOptimalAmount - expect(result.current.orderForm.amount).toBeTruthy(); - }); - - it('should not update amount if optimized amount exceeds maxPossibleAmount', () => { - const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: createWrapper(), - }); - - const initialAmount = '100'; - - act(() => { - result.current.setAmount(initialAmount); - // Use a very low maxPossibleAmount to trigger the condition - result.current.optimizeOrderAmount(50000, 6); - }); - - // Amount should remain unchanged if optimization would exceed max - expect(result.current.orderForm.amount).toBe(initialAmount); - }); - - it('should only update amount if optimized amount is different from current', () => { - const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: createWrapper(), - }); - - act(() => { - result.current.setAmount('10'); // Use default amount - result.current.optimizeOrderAmount(50000, 6); - }); - - const optimizedAmount = result.current.orderForm.amount; - - // Call optimize again with same parameters - act(() => { - result.current.optimizeOrderAmount(50000, 6); - }); - - // Amount should not change if already optimized - expect(result.current.orderForm.amount).toBe(optimizedAmount); - }); - - it('should handle undefined szDecimals parameter', () => { - const { result } = renderHook(() => usePerpsOrderForm(), { - wrapper: createWrapper(), - }); - - act(() => { - result.current.setAmount('100'); - result.current.optimizeOrderAmount(50000, undefined); - }); - - expect(result.current.orderForm.amount).toBeTruthy(); - }); - }); }); diff --git a/app/components/UI/Perps/hooks/usePerpsOrderForm.ts b/app/components/UI/Perps/hooks/usePerpsOrderForm.ts index e6274ccb86a9..3210d6932945 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderForm.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderForm.ts @@ -1,13 +1,9 @@ -import { debounce } from 'lodash'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; import { TRADING_DEFAULTS } from '../constants/hyperLiquidConfig'; import { OrderType } from '../controllers/types'; import type { OrderFormState } from '../types/perps-types'; -import { - findOptimalAmount, - getMaxAllowedAmount as getMaxAllowedAmountUtils, -} from '../utils/orderCalculations'; +import { getMaxAllowedAmount } from '../utils/orderCalculations'; import { usePerpsLiveAccount, usePerpsLivePositions, @@ -40,7 +36,6 @@ export interface UsePerpsOrderFormReturn { handlePercentageAmount: (percentage: number) => void; handleMaxAmount: () => void; handleMinAmount: () => void; - optimizeOrderAmount: (price: number, szDecimals?: number) => void; maxPossibleAmount: number; } @@ -106,24 +101,21 @@ export function usePerpsOrderForm( return defaultAmount.toString(); } - const tempMaxAmount = getMaxAllowedAmountUtils({ + const tempMaxAmount = getMaxAllowedAmount({ availableBalance, assetPrice: Number.parseFloat(currentPrice.price), assetSzDecimals: marketData?.szDecimals ?? 6, leverage: defaultLeverage, // Use default leverage for initial calculation }); - return findOptimalAmount({ - targetAmount: - initialAmount || - (tempMaxAmount < defaultAmount - ? tempMaxAmount.toString() - : defaultAmount.toString()), - price: Number.parseFloat(currentPrice.price), - szDecimals: marketData?.szDecimals ?? 6, - maxAllowedAmount: tempMaxAmount, - minAllowedAmount: defaultAmount, - }); + // Return the target amount directly (USD as source of truth, no optimization) + const targetAmount = + initialAmount || + (tempMaxAmount < defaultAmount + ? tempMaxAmount.toString() + : defaultAmount.toString()); + + return targetAmount; }, [ initialAmount, availableBalance, @@ -157,7 +149,7 @@ export function usePerpsOrderForm( // Calculate the maximum possible amount based on available balance and current leverage const maxPossibleAmount = useMemo( () => - getMaxAllowedAmountUtils({ + getMaxAllowedAmount({ availableBalance, assetPrice: Number.parseFloat(currentPrice?.price) || 0, assetSzDecimals: marketData?.szDecimals ?? 6, @@ -171,50 +163,6 @@ export function usePerpsOrderForm( ], ); - // Optimize order amount to get the optimal USD value for the position size - const optimizeOrderAmount = useMemo(() => { - const optimizeFunction = (price: number, szDecimals?: number) => { - setOrderForm((prev) => { - if (!prev.amount || Number.parseFloat(prev.amount) === 0) { - return prev; - } - - const optimizedAmount = findOptimalAmount({ - targetAmount: prev.amount, - price, - szDecimals, - maxAllowedAmount: maxPossibleAmount, - minAllowedAmount: defaultAmount, - }); - - const optimizedAmountNum = Number.parseFloat(optimizedAmount); - - // Only update if the optimized amount is different - if ( - optimizedAmount !== prev.amount && - optimizedAmountNum <= maxPossibleAmount - ) { - return { - ...prev, - amount: optimizedAmount, - }; - } - - return prev; - }); - }; - - return debounce(optimizeFunction, 100); - }, [maxPossibleAmount, defaultAmount]); - - // Cleanup debounced function on unmount - useEffect( - () => () => { - optimizeOrderAmount.cancel(); - }, - [optimizeOrderAmount], - ); - // Update amount only once when the hook first calculates the initial value // We use a ref to track if we've already set the initial amount to avoid overwriting user input const hasSetInitialAmount = useRef(false); @@ -346,7 +294,6 @@ export function usePerpsOrderForm( handlePercentageAmount, handleMaxAmount, handleMinAmount, - optimizeOrderAmount, maxPossibleAmount, }; } diff --git a/app/components/UI/Perps/hooks/usePerpsOrderValidation.test.ts b/app/components/UI/Perps/hooks/usePerpsOrderValidation.test.ts index 41ab4871ec42..2bff74db5d2e 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderValidation.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderValidation.test.ts @@ -3,12 +3,16 @@ import { VALIDATION_THRESHOLDS } from '../constants/perpsConfig'; import type { OrderFormState } from '../types/perps-types'; import { usePerpsOrderValidation } from './usePerpsOrderValidation'; import { usePerpsTrading } from './usePerpsTrading'; +import { usePerpsNetwork } from './usePerpsNetwork'; // Configure waitFor with a shorter timeout for all tests const fastWaitFor = (callback: () => void, options = {}) => waitFor(callback, { timeout: 1000, ...options }); jest.mock('./usePerpsTrading'); +jest.mock('./usePerpsNetwork', () => ({ + usePerpsNetwork: jest.fn(), +})); jest.mock('../../../../core/SDKConnect/utils/DevLogger', () => ({ __esModule: true, default: { @@ -30,6 +34,9 @@ jest.mock('../../../../../locales/i18n', () => ({ describe('usePerpsOrderValidation', () => { const mockValidateOrder = jest.fn(); + const mockUsePerpsNetwork = usePerpsNetwork as jest.MockedFunction< + typeof usePerpsNetwork + >; beforeEach(() => { jest.clearAllMocks(); @@ -39,6 +46,8 @@ describe('usePerpsOrderValidation', () => { (usePerpsTrading as jest.Mock).mockReturnValue({ validateOrder: mockValidateOrder, }); + // Default to mainnet for tests + mockUsePerpsNetwork.mockReturnValue('mainnet'); }); afterEach(() => { diff --git a/app/components/UI/Perps/hooks/usePerpsOrderValidation.ts b/app/components/UI/Perps/hooks/usePerpsOrderValidation.ts index af544502b96d..a3f4e0812fac 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderValidation.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderValidation.ts @@ -5,8 +5,10 @@ import { PERFORMANCE_CONFIG, VALIDATION_THRESHOLDS, } from '../constants/perpsConfig'; +import { TRADING_DEFAULTS } from '../constants/hyperLiquidConfig'; import type { OrderParams } from '../controllers/types'; import type { OrderFormState } from '../types/perps-types'; +import { usePerpsNetwork } from './usePerpsNetwork'; import { usePerpsTrading } from './usePerpsTrading'; import { useStableArray } from './useStableArray'; @@ -17,6 +19,8 @@ interface UsePerpsOrderValidationParams { availableBalance: number; marginRequired: string; existingPositionLeverage?: number; + skipValidation?: boolean; + originalUsdAmount?: string; // Original USD input for validation (prevents precision loss from recalculation) } interface ValidationResult { @@ -47,9 +51,12 @@ export function usePerpsOrderValidation( availableBalance, marginRequired, existingPositionLeverage, + skipValidation, + originalUsdAmount, } = params; const { validateOrder } = usePerpsTrading(); + const network = usePerpsNetwork(); const [validation, setValidation] = useState({ errors: EMPTY_ERRORS, @@ -87,6 +94,23 @@ export function usePerpsOrderValidation( ); } + // Minimum order size validation using original USD input (prevents precision loss) + // Validate USD amount directly (source of truth) instead of recalculated value + // This prevents validation flash when price updates cause rounding near the $10 minimum + const usdAmount = Number.parseFloat(originalUsdAmount || '0'); + const minimumOrderSize = + network === 'mainnet' + ? TRADING_DEFAULTS.amount.mainnet + : TRADING_DEFAULTS.amount.testnet; + + if (usdAmount > 0 && usdAmount < minimumOrderSize) { + immediateErrors.push( + strings('perps.order.validation.minimum_amount', { + amount: minimumOrderSize.toString(), + }), + ); + } + try { // Convert form state to OrderParams for protocol validation const orderParams: OrderParams = { @@ -98,6 +122,7 @@ export function usePerpsOrderValidation( leverage: orderForm.leverage, currentPrice: assetPrice, existingPositionLeverage, + usdAmount: originalUsdAmount, // Pass USD as source of truth for validation }; // Get protocol-specific validation @@ -154,10 +179,17 @@ export function usePerpsOrderValidation( availableBalance, marginRequired, existingPositionLeverage, + originalUsdAmount, validateOrder, + network, ]); useEffect(() => { + // Skip validation during keypad input to prevent flickering + if (skipValidation) { + return; + } + // Skip validation if critical data is missing if (!positionSize || assetPrice === 0) { setValidation((prev) => ({ @@ -186,7 +218,7 @@ export function usePerpsOrderValidation( clearTimeout(validationTimerRef.current); } }; - }, [performValidation, positionSize, assetPrice]); + }, [performValidation, positionSize, assetPrice, skipValidation]); // Return validation with stable array references return { diff --git a/app/components/UI/Perps/types/config.ts b/app/components/UI/Perps/types/config.ts index 08eb9836a1db..409b8c577ceb 100644 --- a/app/components/UI/Perps/types/config.ts +++ b/app/components/UI/Perps/types/config.ts @@ -54,7 +54,6 @@ export interface TradingDefaultsConfig { marginPercent: number; takeProfitPercent: number; stopLossPercent: number; - slippage: number; amount: TradingAmountConfig; } diff --git a/app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts b/app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts index 5c1600983f9c..4a22f8db7a4e 100644 --- a/app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts +++ b/app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts @@ -1033,6 +1033,19 @@ describe('hyperLiquidAdapter', () => { '1.123456', ); }); + + it('should NOT strip trailing zeros from integers (regression test)', () => { + // Critical: With szDecimals=0, integers ending in 0 should stay intact + // e.g., 10 tokens should format as "10", not "1" + expect(formatHyperLiquidSize({ size: 10, szDecimals: 0 })).toBe('10'); + expect(formatHyperLiquidSize({ size: 100, szDecimals: 0 })).toBe('100'); + expect(formatHyperLiquidSize({ size: 20, szDecimals: 0 })).toBe('20'); + expect(formatHyperLiquidSize({ size: 1000, szDecimals: 0 })).toBe('1000'); + + // But should still strip zeros after decimal points + expect(formatHyperLiquidSize({ size: 10.0, szDecimals: 2 })).toBe('10'); + expect(formatHyperLiquidSize({ size: 10.5, szDecimals: 4 })).toBe('10.5'); + }); }); describe('calculatePositionSize', () => { diff --git a/app/components/UI/Perps/utils/hyperLiquidAdapter.ts b/app/components/UI/Perps/utils/hyperLiquidAdapter.ts index 0930adf3d210..6543b9952418 100644 --- a/app/components/UI/Perps/utils/hyperLiquidAdapter.ts +++ b/app/components/UI/Perps/utils/hyperLiquidAdapter.ts @@ -430,7 +430,16 @@ export function formatHyperLiquidSize(params: { if (isNaN(num)) return '0'; // Use asset-specific decimal precision and remove trailing zeros - return num.toFixed(szDecimals).replace(/\.?0+$/, ''); + const formatted = num.toFixed(szDecimals); + + // Only strip trailing zeros after decimal point, not from integers + // e.g., "10.000" → "10", "10.5000" → "10.5", but "10" stays "10" + if (!formatted.includes('.')) { + return formatted; // Integer, keep as-is + } + + // Has decimal, strip trailing zeros and decimal if needed + return formatted.replace(/\.?0+$/, ''); } /** diff --git a/app/components/UI/Perps/utils/hyperLiquidValidation.test.ts b/app/components/UI/Perps/utils/hyperLiquidValidation.test.ts index 57207f1883d4..9936baf3e8bc 100644 --- a/app/components/UI/Perps/utils/hyperLiquidValidation.test.ts +++ b/app/components/UI/Perps/utils/hyperLiquidValidation.test.ts @@ -682,21 +682,6 @@ describe('hyperLiquidValidation', () => { }); }); - it('should require positive size', () => { - const params = { - coin: 'BTC', - size: '0', - price: '50000', - }; - - const result = validateOrderParams(params); - - expect(result).toEqual({ - isValid: false, - error: 'Size must be a positive number', - }); - }); - it('should require positive price if provided', () => { const params = { coin: 'BTC', @@ -712,10 +697,11 @@ describe('hyperLiquidValidation', () => { }); }); - it('should allow missing price', () => { + it('should allow missing price for market orders', () => { const params = { coin: 'BTC', size: '0.1', + orderType: 'market' as const, }; const result = validateOrderParams(params); @@ -723,31 +709,29 @@ describe('hyperLiquidValidation', () => { expect(result).toEqual({ isValid: true }); }); - it('should handle negative size', () => { + it('should allow missing price when orderType is not specified', () => { const params = { coin: 'BTC', - size: '-0.1', + size: '0.1', }; const result = validateOrderParams(params); - expect(result).toEqual({ - isValid: false, - error: 'Size must be a positive number', - }); + expect(result).toEqual({ isValid: true }); }); - it('should handle missing size', () => { + it('should reject limit orders without price', () => { const params = { coin: 'BTC', - price: '50000', + size: '0.1', + orderType: 'limit' as const, }; const result = validateOrderParams(params); expect(result).toEqual({ isValid: false, - error: 'Size must be a positive number', + error: 'Price is required for limit orders', }); }); }); diff --git a/app/components/UI/Perps/utils/hyperLiquidValidation.ts b/app/components/UI/Perps/utils/hyperLiquidValidation.ts index 1cee53d3b811..b3a6e5071070 100644 --- a/app/components/UI/Perps/utils/hyperLiquidValidation.ts +++ b/app/components/UI/Perps/utils/hyperLiquidValidation.ts @@ -461,11 +461,14 @@ export function getMaxOrderValue( /** * Validate order parameters + * Basic validation - checks required fields are present + * Amount validation (size/USD) is handled by validateOrder */ export function validateOrderParams(params: { coin?: string; size?: string; price?: string; + orderType?: 'market' | 'limit'; }): { isValid: boolean; error?: string } { if (!params.coin) { return { @@ -474,10 +477,13 @@ export function validateOrderParams(params: { }; } - if (!params.size || parseFloat(params.size) <= 0) { + // Note: Size validation removed - validateOrder handles amount validation using USD as source of truth + + // Require price for limit orders + if (params.orderType === 'limit' && !params.price) { return { isValid: false, - error: strings('perps.errors.orderValidation.sizePositive'), + error: strings('perps.errors.orderValidation.limitPriceRequired'), }; } diff --git a/app/components/UI/Perps/utils/orderCalculations.test.ts b/app/components/UI/Perps/utils/orderCalculations.test.ts index f5a0255e9873..f73a975c1e98 100644 --- a/app/components/UI/Perps/utils/orderCalculations.test.ts +++ b/app/components/UI/Perps/utils/orderCalculations.test.ts @@ -1,17 +1,16 @@ import { calculatePositionSize, calculateMarginRequired, - findOptimalAmount, - findHighestAmountForPositionSize, getMaxAllowedAmount, } from './orderCalculations'; describe('orderCalculations', () => { describe('calculatePositionSize', () => { - it('should calculate position size correctly with default decimals', () => { + it('should calculate position size correctly with szDecimals', () => { const result = calculatePositionSize({ amount: '10000', price: 50000, + szDecimals: 6, }); expect(result).toBe('0.200000'); @@ -32,7 +31,9 @@ describe('orderCalculations', () => { price: 3000, szDecimals: 4, }); - expect(ethResult).toBe('3.3333'); // Properly rounded down from 3.3333... + // 10000 / 3000 = 3.33333... → Math.round = 3.3333 + // 3.3333 * 3000 = 9999.9 < 10000, so increment by 0.0001 + expect(ethResult).toBe('3.3334'); // Incremented to meet USD minimum // DOGE-style decimals (0) const dogeResult = calculatePositionSize({ @@ -47,6 +48,7 @@ describe('orderCalculations', () => { const resultDefault = calculatePositionSize({ amount: '0', price: 50000, + szDecimals: 6, }); expect(resultDefault).toBe('0.000000'); @@ -62,6 +64,7 @@ describe('orderCalculations', () => { const resultDefault = calculatePositionSize({ amount: '10000', price: 0, + szDecimals: 6, }); expect(resultDefault).toBe('0.000000'); @@ -77,6 +80,7 @@ describe('orderCalculations', () => { const result = calculatePositionSize({ amount: '', price: 50000, + szDecimals: 6, }); expect(result).toBe('0.000000'); @@ -86,6 +90,7 @@ describe('orderCalculations', () => { const result = calculatePositionSize({ amount: '1', price: 50000, + szDecimals: 6, }); expect(result).toBe('0.000020'); @@ -95,13 +100,14 @@ describe('orderCalculations', () => { const result = calculatePositionSize({ amount: '1000000', price: 50000, + szDecimals: 6, }); expect(result).toBe('20.000000'); }); - it('should use proper rounding (Math.floor)', () => { - // Test that Math.floor is used (always rounding down for conservative estimates) + it('should use proper rounding (Math.round with USD validation)', () => { + // Test that Math.round is used with validation to meet USD minimum const result = calculatePositionSize({ amount: '11', price: 50000, @@ -109,14 +115,86 @@ describe('orderCalculations', () => { }); expect(result).toBe('0.000220'); // Exact value, no rounding needed - // Test case where Math.floor rounds down + // Test case where Math.round rounds down but then gets adjusted up const result2 = calculatePositionSize({ amount: '100', price: 30000, szDecimals: 8, }); // 100 / 30000 = 0.00333333... - expect(result2).toBe('0.00333333'); // Math.floor rounds down for conservative estimate + // Math.round(0.00333333 * 10^8) / 10^8 = 0.00333333 + // But 0.00333333 * 30000 = 99.9999 < 100, so increment by 1/10^8 + expect(result2).toBe('0.00333334'); // Incremented to meet USD minimum + }); + + it('should handle $10 minimum order with low precision asset (ASTER edge case)', () => { + // ASTER-like asset: $1.07575, szDecimals=0 + // User requests $10.00 + // 10 / 1.07575 = 9.295... → Math.round = 9 + // 9 * 1.07575 = 9.68175 < 10, so increment by 1 + // Result: 10 tokens = $10.7575 + const result = calculatePositionSize({ + amount: '10', + price: 1.07575, + szDecimals: 0, + }); + + expect(result).toBe('10'); // Incremented from 9 to 10 to meet $10 minimum + + // Verify actual USD value meets minimum + const actualUsd = parseFloat(result) * 1.07575; + expect(actualUsd).toBeGreaterThanOrEqual(10); + }); + + it('should handle $10 minimum order with mid precision asset (ETH edge case)', () => { + // ETH-like asset: $3000, szDecimals=4 + // User requests $10.00 + // 10 / 3000 = 0.00333333... → Math.round = 0.0033 + // 0.0033 * 3000 = 9.90 < 10, so increment by 0.0001 + // Result: 0.0034 ETH = $10.20 + const result = calculatePositionSize({ + amount: '10', + price: 3000, + szDecimals: 4, + }); + + expect(result).toBe('0.0034'); // Incremented from 0.0033 to 0.0034 + + // Verify actual USD value meets minimum + const actualUsd = parseFloat(result) * 3000; + expect(actualUsd).toBeGreaterThanOrEqual(10); + }); + + it('should throw error when szDecimals is undefined', () => { + expect(() => + calculatePositionSize({ + amount: '100', + price: 50000, + // @ts-expect-error Testing runtime validation + szDecimals: undefined, + }), + ).toThrow('szDecimals is required for position size calculation'); + }); + + it('should throw error when szDecimals is null', () => { + expect(() => + calculatePositionSize({ + amount: '100', + price: 50000, + // @ts-expect-error Testing runtime validation + szDecimals: null, + }), + ).toThrow('szDecimals is required for position size calculation'); + }); + + it('should throw error when szDecimals is negative', () => { + expect(() => + calculatePositionSize({ + amount: '100', + price: 50000, + szDecimals: -1, + }), + ).toThrow('szDecimals must be >= 0, got: -1'); }); }); @@ -176,161 +254,6 @@ describe('orderCalculations', () => { }); }); - describe('findOptimalAmount', () => { - it('should return target amount when parameters are invalid', () => { - // Arrange - const params = { - targetAmount: '100', - maxAllowedAmount: 1000, - minAllowedAmount: 10, - price: 0, // Invalid price - }; - - // Act - const result = findOptimalAmount(params); - - // Assert - expect(result).toBe('100'); - }); - - it('should return target amount when amount is below minimum', () => { - // Arrange - const params = { - targetAmount: '5', - maxAllowedAmount: 1000, - minAllowedAmount: 10, - price: 50000, - }; - - // Act - const result = findOptimalAmount(params); - - // Assert - expect(result).toBe('5'); - }); - - it('should return target amount when position size is zero', () => { - // Arrange - const params = { - targetAmount: '0', - maxAllowedAmount: 1000, - minAllowedAmount: 10, - price: 50000, - }; - - // Act - const result = findOptimalAmount(params); - - // Assert - expect(result).toBe('0'); - }); - - it('should find optimal amount within max allowed limit', () => { - // Arrange - const params = { - targetAmount: '100', - maxAllowedAmount: 1000, - minAllowedAmount: 10, - price: 50000, - szDecimals: 6, - }; - - // Act - const result = findOptimalAmount(params); - - // Assert - expect(result).toBeTruthy(); - expect(parseFloat(result)).toBeGreaterThan(0); - }); - - it('should handle case when highest amount exceeds max allowed', () => { - // Arrange - const params = { - targetAmount: '100', - maxAllowedAmount: 50, // Lower than target - minAllowedAmount: 10, - price: 50000, - szDecimals: 6, - }; - - // Act - const result = findOptimalAmount(params); - - // Assert - expect(result).toBeTruthy(); - expect(parseFloat(result)).toBeGreaterThan(0); - // The function should return a valid result, even if it's not exactly within the max limit - // due to position size rounding constraints - }); - - it('should handle minimum amount adjustment', () => { - // Arrange - const params = { - targetAmount: '20', - maxAllowedAmount: 1000, - minAllowedAmount: 50, // Higher than what position size would give - price: 50000, - szDecimals: 6, - }; - - // Act - const result = findOptimalAmount(params); - - // Assert - expect(result).toBeTruthy(); - expect(parseFloat(result)).toBeGreaterThan(0); - }); - }); - - describe('findHighestAmountForPositionSize', () => { - it('should calculate highest amount for given position size', () => { - // Arrange - const params = { - positionSize: 0.002, - price: 50000, - szDecimals: 6, - }; - - // Act - const result = findHighestAmountForPositionSize(params); - - // Assert - expect(result).toBeGreaterThan(0); - expect(Number.isInteger(result)).toBe(true); // Should be floored to integer - }); - - it('should handle different decimal precisions', () => { - // Arrange - const params = { - positionSize: 1.5, - price: 3000, - szDecimals: 4, - }; - - // Act - const result = findHighestAmountForPositionSize(params); - - // Assert - expect(result).toBeGreaterThan(0); - expect(Number.isInteger(result)).toBe(true); - }); - - it('should handle very small position sizes', () => { - // Arrange - const params = { - positionSize: 0.000001, - price: 50000, - szDecimals: 8, - }; - - // Act - const result = findHighestAmountForPositionSize(params); - - // Assert - expect(result).toBeGreaterThanOrEqual(0); - }); - }); - describe('getMaxAllowedAmount', () => { it('should return 0 when available balance is 0', () => { // Arrange diff --git a/app/components/UI/Perps/utils/orderCalculations.ts b/app/components/UI/Perps/utils/orderCalculations.ts index 9b1fb14534f8..191aef29a174 100644 --- a/app/components/UI/Perps/utils/orderCalculations.ts +++ b/app/components/UI/Perps/utils/orderCalculations.ts @@ -1,7 +1,17 @@ +import type { Hex } from '@metamask/utils'; +import { strings } from '../../../../../locales/i18n'; +import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; +import { ORDER_SLIPPAGE_CONFIG } from '../constants/perpsConfig'; +import type { SDKOrderParams } from '../types/hyperliquid-types'; +import { + formatHyperLiquidPrice, + formatHyperLiquidSize, +} from './hyperLiquidAdapter'; + interface PositionSizeParams { amount: string; price: number; - szDecimals?: number; + szDecimals: number; } interface MarginRequiredParams { @@ -16,13 +26,72 @@ interface MaxAllowedAmountParams { leverage: number; } +// Advanced order calculation interfaces +export interface CalculateFinalPositionSizeParams { + usdAmount?: string; + size?: string; + currentPrice: number; + priceAtCalculation?: number; + maxSlippageBps?: number; + szDecimals: number; + leverage?: number; +} + +export interface CalculateFinalPositionSizeResult { + finalPositionSize: number; +} + +export interface CalculateOrderPriceAndSizeParams { + orderType: 'market' | 'limit'; + isBuy: boolean; + finalPositionSize: number; + currentPrice: number; + limitPrice?: string; + slippage?: number; + szDecimals: number; +} + +export interface CalculateOrderPriceAndSizeResult { + orderPrice: number; + formattedSize: string; + formattedPrice: string; +} + +export interface BuildOrdersArrayParams { + assetId: number; + isBuy: boolean; + formattedPrice: string; + formattedSize: string; + reduceOnly: boolean; + orderType: 'market' | 'limit'; + clientOrderId?: string; + takeProfitPrice?: string; + stopLossPrice?: string; + szDecimals: number; + grouping?: 'na' | 'normalTpsl' | 'positionTpsl'; +} + +export interface BuildOrdersArrayResult { + orders: SDKOrderParams[]; + grouping: 'na' | 'normalTpsl' | 'positionTpsl'; +} + /** * Calculate position size based on USD amount and asset price - * @param params - Amount in USD, current asset price, and optional decimal precision + * @param params - Amount in USD, current asset price, and required decimal precision * @returns Position size formatted to the asset's decimal precision */ export function calculatePositionSize(params: PositionSizeParams): string { - const { amount, price, szDecimals = 6 } = params; + const { amount, price, szDecimals } = params; + + // Validate required parameters + if (szDecimals === undefined || szDecimals === null) { + throw new Error('szDecimals is required for position size calculation'); + } + if (szDecimals < 0) { + throw new Error(`szDecimals must be >= 0, got: ${szDecimals}`); + } + const amountNum = parseFloat(amount || '0'); if (isNaN(amountNum) || isNaN(price) || amountNum === 0 || price === 0) { @@ -31,7 +100,13 @@ export function calculatePositionSize(params: PositionSizeParams): string { const positionSize = amountNum / price; const multiplier = Math.pow(10, szDecimals); - const rounded = Math.floor(positionSize * multiplier) / multiplier; + let rounded = Math.round(positionSize * multiplier) / multiplier; + + // Ensure rounded size meets requested USD (fix validation gap) + const actualUsd = rounded * price; + if (actualUsd < amountNum) { + rounded += 1 / multiplier; + } return rounded.toFixed(szDecimals); } @@ -57,153 +132,6 @@ export function calculateMarginRequired(params: MarginRequiredParams): string { return (amountNum / leverage).toFixed(2); } -interface OptimalAmountParams { - targetAmount: string; - maxAllowedAmount: number; - minAllowedAmount: number; - price: number; - szDecimals?: number; -} - -interface HighestAmountForPositionSizeParams { - positionSize: number; - price: number; - szDecimals?: number; -} - -/** - * Find the optimal (highest) USD amount that results in the same position size - * as the target amount. This maximizes the USD value while maintaining the same - * position size due to rounding behavior. - * - * For example, if $10, $11, and $12 all result in the same position size of 0.0500, - * this function will return $12 as the optimal amount. - * - * When sizeDown=true, it finds the optimal amount for the next position size down. - * For example, if target amount results in position size 0.0500, with sizeDown=true - * it will find the optimal amount for position size 0.0499 (one increment smaller). - * - * @param params - Target amount, asset price, optional decimal precision, and sizeDown flag - * @returns Optimal USD amount as string - */ -export function findOptimalAmount(params: OptimalAmountParams): string { - const { - targetAmount, - price, - szDecimals = 6, - maxAllowedAmount, - minAllowedAmount, - } = params; - - const targetAmountNum = parseFloat(targetAmount || '0'); - - if ( - isNaN(targetAmountNum) || - isNaN(price) || - targetAmountNum === 0 || - price === 0 || - targetAmountNum < minAllowedAmount - ) { - return targetAmount; - } - - // Calculate the position size for the target amount - const targetPositionSize = calculatePositionSize({ - amount: targetAmount, - price, - szDecimals, - }); - - // If position size is 0, return original amount - if (parseFloat(targetPositionSize) === 0) { - return targetAmount; - } - - let positionSizeNum = parseFloat(targetPositionSize); - const multiplier = Math.pow(10, szDecimals); - - let highestAmount = findHighestAmountForPositionSize({ - positionSize: positionSizeNum, - price, - szDecimals, - }); - - // Position size for $1 USD - const dollarPositionSize = 1 / price; - // get the position increment base - // but at times wwe will need to skip by a few increments - let positionIncrement = 10 ** -szDecimals; - let dollarBasedPositionIncrement = ( - Math.round(dollarPositionSize * multiplier) / multiplier - ).toFixed(szDecimals); - - if (parseFloat(dollarBasedPositionIncrement) === 0) { - dollarBasedPositionIncrement = ( - (dollarPositionSize * multiplier) / - multiplier - ).toFixed(szDecimals); - } - if (parseFloat(dollarBasedPositionIncrement) > positionIncrement) { - positionIncrement = parseFloat(dollarBasedPositionIncrement); - } - - if (highestAmount > maxAllowedAmount) { - const decrementedPositionSize = - ((positionSizeNum - positionIncrement) * multiplier) / multiplier; - // If the decremented position size would be 0 or negative, return original amount - if (decrementedPositionSize <= 0) { - return targetAmount; - } - - positionSizeNum = decrementedPositionSize; - highestAmount = findHighestAmountForPositionSize({ - positionSize: positionSizeNum, - price, - szDecimals, - }); - } - - if ( - parseFloat(targetAmount) >= minAllowedAmount && - positionSizeNum * price < minAllowedAmount - ) { - const incrementedPositionSize = - ((positionSizeNum + positionIncrement) * multiplier) / multiplier; - - // If the incremented position size would be 0 or negative, return original amount - if (incrementedPositionSize <= 0) { - return targetAmount; - } - - positionSizeNum = incrementedPositionSize; - highestAmount = findHighestAmountForPositionSize({ - positionSize: positionSizeNum, - price, - szDecimals, - }); - } - - return highestAmount.toString(); -} - -export function findHighestAmountForPositionSize( - params: HighestAmountForPositionSizeParams, -): number { - const { positionSize, price, szDecimals = 6 } = params; - - // Calculate the exact USD value for this position size - const exactUsdValue = parseFloat(positionSize.toFixed(szDecimals)) * price; - - // Calculate the increment that would bump to the next position size - const multiplier = Math.pow(10, szDecimals); - const usdIncrement = price / multiplier; - - // The highest amount is just before the next position size tier - const highestAmount = exactUsdValue + usdIncrement; - - return Math.floor(highestAmount); -} - export function getMaxAllowedAmount(params: MaxAllowedAmountParams): number { const { availableBalance, assetPrice, assetSzDecimals, leverage } = params; if (availableBalance === 0 || !assetPrice || assetSzDecimals === undefined) { @@ -238,3 +166,266 @@ export function getMaxAllowedAmount(params: MaxAllowedAmountParams): number { return Math.max(0, maxAmount); } + +/** + * Calculates final position size using USD as source of truth with price validation + * + * This function implements the hybrid approach where USD is the source of truth, + * but includes price staleness validation and proper rounding to prevent precision loss. + * + * @param params - USD amount, size, prices, and configuration + * @returns Final position size as a number + */ +export function calculateFinalPositionSize( + params: CalculateFinalPositionSizeParams, +): CalculateFinalPositionSizeResult { + const { + usdAmount, + size, + currentPrice, + priceAtCalculation, + maxSlippageBps, + szDecimals, + leverage, + } = params; + + let finalPositionSize: number; + + if (usdAmount && parseFloat(usdAmount) > 0) { + // USD amount provided - use it as source of truth + const usdValue = parseFloat(usdAmount); + + // 1. Validate price staleness if priceAtCalculation provided + if (priceAtCalculation) { + const priceDeltaBps = Math.abs( + ((currentPrice - priceAtCalculation) / priceAtCalculation) * 10000, + ); + const maxSlippageBpsValue = + maxSlippageBps ?? ORDER_SLIPPAGE_CONFIG.DEFAULT_SLIPPAGE_BPS; + + if (priceDeltaBps > maxSlippageBpsValue) { + throw new Error( + `Price moved too much: ${priceDeltaBps.toFixed(0)} bps (max: ${maxSlippageBpsValue} bps). ` + + `Expected: ${priceAtCalculation.toFixed(2)}, Current: ${currentPrice.toFixed(2)}`, + ); + } + + DevLogger.log('Price validation passed:', { + priceAtCalculation, + currentPrice, + deltaBps: priceDeltaBps.toFixed(2), + maxSlippageBps: maxSlippageBpsValue, + }); + } + + // 2. Recalculate position size with fresh price + finalPositionSize = usdValue / currentPrice; + + // 3. Apply size decimals rounding + const multiplier = Math.pow(10, szDecimals); + finalPositionSize = Math.round(finalPositionSize * multiplier) / multiplier; + + // 4. Ensure rounded size meets requested USD (fix validation gap) + let actualNotionalValue = finalPositionSize * currentPrice; + if (actualNotionalValue < usdValue) { + // Add 1 minimum increment to meet requested USD + finalPositionSize += 1 / multiplier; + actualNotionalValue = finalPositionSize * currentPrice; + + DevLogger.log('Position size adjusted to meet USD minimum:', { + requestedUsd: usdValue, + beforeAdjustment: finalPositionSize - 1 / multiplier, + afterAdjustment: finalPositionSize, + actualUsd: actualNotionalValue, + }); + } + + const requiredMargin = actualNotionalValue / (leverage || 1); + + // Log if rounding caused significant difference + const usdDifference = Math.abs(actualNotionalValue - usdValue); + if (usdDifference > 0.01) { + DevLogger.log( + 'Position size rounding caused USD difference (acceptable):', + { + requestedUsd: usdValue, + actualUsd: actualNotionalValue, + difference: usdDifference, + positionSize: finalPositionSize, + }, + ); + } + + DevLogger.log('Recalculated position size with fresh price:', { + usdAmount: usdValue, + priceAtCalculation, + currentPrice, + originalSize: size, + recalculatedSize: finalPositionSize, + requiredMargin, + minIncrement: 1 / multiplier, + }); + } else { + // Legacy: Use provided size (backward compatibility) + finalPositionSize = parseFloat(size || '0'); + + DevLogger.log('Using legacy size calculation (no USD amount provided):', { + providedSize: size, + finalSize: finalPositionSize, + }); + } + + return { finalPositionSize }; +} + +/** + * Calculates order price and formatted size based on order type + * + * @param params - Order parameters including type, direction, size, and prices + * @returns Formatted order price, size, and price string + */ +export function calculateOrderPriceAndSize( + params: CalculateOrderPriceAndSizeParams, +): CalculateOrderPriceAndSizeResult { + const { + orderType, + isBuy, + finalPositionSize, + currentPrice, + limitPrice, + slippage, + szDecimals, + } = params; + + let orderPrice: number; + let formattedSize: string; + + if (orderType === 'market') { + // Market orders: add slippage + const slippageValue = + slippage ?? ORDER_SLIPPAGE_CONFIG.DEFAULT_SLIPPAGE_BPS / 10000; + orderPrice = isBuy + ? currentPrice * (1 + slippageValue) + : currentPrice * (1 - slippageValue); + formattedSize = formatHyperLiquidSize({ + size: finalPositionSize, + szDecimals, + }); + } else { + // Limit orders: use provided price + if (!limitPrice) { + throw new Error( + strings('perps.errors.orderValidation.limitPriceRequired'), + ); + } + orderPrice = parseFloat(limitPrice); + formattedSize = formatHyperLiquidSize({ + size: finalPositionSize, + szDecimals, + }); + } + + const formattedPrice = formatHyperLiquidPrice({ + price: orderPrice, + szDecimals, + }); + + return { orderPrice, formattedSize, formattedPrice }; +} + +/** + * Builds orders array including main order and optional TP/SL orders + * + * @param params - Order construction parameters + * @returns Array of SDK order params and grouping type + */ +export function buildOrdersArray( + params: BuildOrdersArrayParams, +): BuildOrdersArrayResult { + const { + assetId, + isBuy, + formattedPrice, + formattedSize, + reduceOnly, + orderType, + clientOrderId, + takeProfitPrice, + stopLossPrice, + szDecimals, + grouping, + } = params; + + const orders: SDKOrderParams[] = []; + + // 1. Main order + const mainOrder: SDKOrderParams = { + a: assetId, + b: isBuy, + p: formattedPrice, + s: formattedSize, + r: reduceOnly || false, + t: + orderType === 'limit' + ? { limit: { tif: 'Gtc' } } + : { limit: { tif: 'FrontendMarket' } }, + c: clientOrderId ? (clientOrderId as Hex) : undefined, + }; + orders.push(mainOrder); + + // 2. Take Profit order + if (takeProfitPrice) { + const tpOrder: SDKOrderParams = { + a: assetId, + b: !isBuy, + p: formatHyperLiquidPrice({ + price: parseFloat(takeProfitPrice), + szDecimals, + }), + s: formattedSize, + r: true, + t: { + trigger: { + isMarket: false, + triggerPx: formatHyperLiquidPrice({ + price: parseFloat(takeProfitPrice), + szDecimals, + }), + tpsl: 'tp', + }, + }, + }; + orders.push(tpOrder); + } + + // 3. Stop Loss order + if (stopLossPrice) { + const slOrder: SDKOrderParams = { + a: assetId, + b: !isBuy, + p: formatHyperLiquidPrice({ + price: parseFloat(stopLossPrice), + szDecimals, + }), + s: formattedSize, + r: true, + t: { + trigger: { + isMarket: true, + triggerPx: formatHyperLiquidPrice({ + price: parseFloat(stopLossPrice), + szDecimals, + }), + tpsl: 'sl', + }, + }, + }; + orders.push(slOrder); + } + + // Determine grouping + const finalGrouping: 'na' | 'normalTpsl' | 'positionTpsl' = + grouping || (takeProfitPrice || stopLossPrice ? 'normalTpsl' : 'na'); + + return { orders, grouping: finalGrouping }; +} diff --git a/app/components/UI/Perps/utils/positionCalculations.test.ts b/app/components/UI/Perps/utils/positionCalculations.test.ts index fd7506f8a62b..2ac70535e766 100644 --- a/app/components/UI/Perps/utils/positionCalculations.test.ts +++ b/app/components/UI/Perps/utils/positionCalculations.test.ts @@ -16,6 +16,7 @@ describe('Position Calculations Utils', () => { percentage: 50, positionSize: 10, currentPrice: 100, + szDecimals: 6, }); expect(result.tokenAmount).toBe(5); @@ -27,6 +28,7 @@ describe('Position Calculations Utils', () => { percentage: NaN, positionSize: 10, currentPrice: 100, + szDecimals: 6, }); expect(result.tokenAmount).toBe(0); @@ -38,11 +40,111 @@ describe('Position Calculations Utils', () => { percentage: 25, positionSize: -8, currentPrice: 50, + szDecimals: 6, }); expect(result.tokenAmount).toBe(2); expect(result.usdValue).toBe(100); }); + + it('should ensure rounded size meets USD minimum for close position', () => { + // Test BTC-like asset: $10.53 close at $105,258 price, szDecimals=5 + // (10/100) * 0.001 BTC = 0.0001 BTC + // 0.0001 * 105258 = $10.5258 + // After rounding: 0.00010 → $10.5258 (meets minimum) + const result = calculateCloseAmountFromPercentage({ + percentage: 10, + positionSize: 0.001, + currentPrice: 105258, + szDecimals: 5, + }); + + // Verify token amount is rounded correctly + expect(result.tokenAmount).toBe(0.0001); + + // Verify actual USD value from token amount + // Note: result.usdValue is rounded to 2 decimals for display ($10.53) + // but tokenAmount * price gives the actual value ($10.5258) + const actualUsd = result.tokenAmount * 105258; + expect(actualUsd).toBeCloseTo(10.5258, 2); + + // The returned usdValue should be the rounded display value + expect(result.usdValue).toBe(10.53); + }); + + it('should handle low precision assets (ASTER with szDecimals=0)', () => { + // ASTER-like asset: Close 10% of 100 tokens at $1.07575, szDecimals=0 + // (10/100) * 100 = 10 tokens + // 10 * 1.07575 = $10.7575 + // After rounding: 10 tokens → $10.7575 (meets minimum) + const result = calculateCloseAmountFromPercentage({ + percentage: 10, + positionSize: 100, + currentPrice: 1.07575, + szDecimals: 0, + }); + + expect(result.tokenAmount).toBe(10); + + // Verify actual USD value from token amount + const actualUsd = result.tokenAmount * 1.07575; + expect(actualUsd).toBeCloseTo(10.7575, 2); + + // The returned usdValue should be the rounded display value + expect(result.usdValue).toBe(10.76); + }); + + it('should add minimum increment when rounded size falls below USD value', () => { + // Edge case: rounding causes USD value to fall below minimum + // Close 1% of 1 BTC at $105,000, szDecimals=5 + // (1/100) * 1 = 0.01 BTC → USD = $1,050 + // After rounding to 5 decimals: 0.01000 BTC + // Should verify this still equals at least $1,050 + const result = calculateCloseAmountFromPercentage({ + percentage: 1, + positionSize: 1, + currentPrice: 105000, + szDecimals: 5, + }); + + const actualUsd = result.tokenAmount * 105000; + expect(actualUsd).toBeGreaterThanOrEqual(result.usdValue); + }); + + it('should throw error when szDecimals is undefined', () => { + expect(() => + calculateCloseAmountFromPercentage({ + percentage: 50, + positionSize: 10, + currentPrice: 100, + // @ts-expect-error Testing runtime validation + szDecimals: undefined, + }), + ).toThrow('szDecimals is required for close position calculation'); + }); + + it('should throw error when szDecimals is null', () => { + expect(() => + calculateCloseAmountFromPercentage({ + percentage: 50, + positionSize: 10, + currentPrice: 100, + // @ts-expect-error Testing runtime validation + szDecimals: null, + }), + ).toThrow('szDecimals is required for close position calculation'); + }); + + it('should throw error when szDecimals is negative', () => { + expect(() => + calculateCloseAmountFromPercentage({ + percentage: 50, + positionSize: 10, + currentPrice: 100, + szDecimals: -1, + }), + ).toThrow('szDecimals must be >= 0, got: -1'); + }); }); describe('validateCloseAmountLimits', () => { diff --git a/app/components/UI/Perps/utils/positionCalculations.ts b/app/components/UI/Perps/utils/positionCalculations.ts index e0ae4bdb1123..0639d86270ab 100644 --- a/app/components/UI/Perps/utils/positionCalculations.ts +++ b/app/components/UI/Perps/utils/positionCalculations.ts @@ -7,6 +7,7 @@ interface CloseAmountFromPercentageParams { percentage: number; positionSize: number; currentPrice: number; + szDecimals: number; } interface CloseAmountLimitsParams { @@ -28,13 +29,22 @@ interface CloseValueParams { /** * Calculate close amounts from percentage for both USD and token modes - * @param params - Percentage, position size, and current price + * Uses USD as source of truth with precision validation (matches order placement logic) + * @param params - Percentage, position size, current price, and asset-specific decimal precision * @returns Object with token amount and USD value */ export function calculateCloseAmountFromPercentage( params: CloseAmountFromPercentageParams, ): { tokenAmount: number; usdValue: number } { - const { percentage, positionSize, currentPrice } = params; + const { percentage, positionSize, currentPrice, szDecimals } = params; + + // Validate required parameters + if (szDecimals === undefined || szDecimals === null) { + throw new Error('szDecimals is required for close position calculation'); + } + if (szDecimals < 0) { + throw new Error(`szDecimals must be >= 0, got: ${szDecimals}`); + } if ( isNaN(percentage) || @@ -46,13 +56,24 @@ export function calculateCloseAmountFromPercentage( return { tokenAmount: 0, usdValue: 0 }; } - const tokenAmount = (percentage / 100) * Math.abs(positionSize); + // Calculate initial token amount and USD value + let tokenAmount = (percentage / 100) * Math.abs(positionSize); const usdValue = tokenAmount * currentPrice; + // Apply asset-specific decimal precision rounding + const multiplier = Math.pow(10, szDecimals); + tokenAmount = Math.round(tokenAmount * multiplier) / multiplier; + + // Ensure rounded size meets requested USD value (fix validation gap) + // This matches the logic in calculatePositionSize for consistency + const actualUsd = tokenAmount * currentPrice; + if (actualUsd < usdValue) { + // Add 1 minimum increment to meet requested USD + tokenAmount += 1 / multiplier; + } + return { - tokenAmount: Number( - tokenAmount.toFixed(CLOSE_POSITION_CONFIG.AMOUNT_CALCULATION_PRECISION), - ), + tokenAmount: Number(tokenAmount.toFixed(szDecimals)), usdValue: Number( usdValue.toFixed(CLOSE_POSITION_CONFIG.USD_DECIMAL_PLACES), ), diff --git a/app/components/UI/TransactionElement/TransactionDetails/__snapshots__/index.test.tsx.snap b/app/components/UI/TransactionElement/TransactionDetails/__snapshots__/index.test.tsx.snap index 0bafc4652c97..8947cef12a9d 100644 --- a/app/components/UI/TransactionElement/TransactionDetails/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/TransactionElement/TransactionDetails/__snapshots__/index.test.tsx.snap @@ -430,11 +430,14 @@ exports[`TransactionDetails should render correctly 1`] = ` undefined, undefined, undefined, - { - "fontSize": 12, - "letterSpacing": 0.5, - "marginTop": 4, - }, + [ + { + "fontSize": 12, + "letterSpacing": 0.5, + "marginTop": 4, + }, + undefined, + ], ] } > @@ -1704,11 +1707,14 @@ exports[`TransactionDetails should render correctly for multi-layer fee network undefined, undefined, undefined, - { - "fontSize": 12, - "letterSpacing": 0.5, - "marginTop": 4, - }, + [ + { + "fontSize": 12, + "letterSpacing": 0.5, + "marginTop": 4, + }, + undefined, + ], ] } > @@ -2978,11 +2984,14 @@ exports[`TransactionDetails should render correctly for multi-layer fee network undefined, undefined, undefined, - { - "fontSize": 12, - "letterSpacing": 0.5, - "marginTop": 4, - }, + [ + { + "fontSize": 12, + "letterSpacing": 0.5, + "marginTop": 4, + }, + undefined, + ], ] } > diff --git a/app/components/Views/Onboarding/index.js b/app/components/Views/Onboarding/index.js index 03831034af06..d81bdff91ae9 100644 --- a/app/components/Views/Onboarding/index.js +++ b/app/components/Views/Onboarding/index.js @@ -625,6 +625,7 @@ class Onboarding extends PureComponent { const loginHandler = createLoginHandler(Platform.OS, provider); const result = await OAuthLoginService.handleOAuthLogin( loginHandler, + !createWallet, ).catch((error) => { this.props.unsetLoading(); this.handleLoginError(error, provider); diff --git a/app/components/Views/Onboarding/index.test.tsx b/app/components/Views/Onboarding/index.test.tsx index 433cbbc5ed4c..ee4181a82fe1 100644 --- a/app/components/Views/Onboarding/index.test.tsx +++ b/app/components/Views/Onboarding/index.test.tsx @@ -744,6 +744,7 @@ describe('Onboarding', () => { expect(mockCreateLoginHandler).toHaveBeenCalledWith('ios', 'google'); expect(mockOAuthService.handleOAuthLogin).toHaveBeenCalledWith( 'mockGoogleHandler', + false, ); expect(mockNavigate).toHaveBeenCalledWith( Routes.ONBOARDING.SOCIAL_LOGIN_SUCCESS_NEW_USER, @@ -794,6 +795,7 @@ describe('Onboarding', () => { expect(mockCreateLoginHandler).toHaveBeenCalledWith('android', 'google'); expect(mockOAuthService.handleOAuthLogin).toHaveBeenCalledWith( 'mockGoogleHandler', + false, ); // On Android, should navigate directly to ChoosePassword, not SocialLoginSuccessNewUser expect(mockNavigate).toHaveBeenCalledWith( @@ -847,6 +849,7 @@ describe('Onboarding', () => { expect(mockCreateLoginHandler).toHaveBeenCalledWith('ios', 'apple'); expect(mockOAuthService.handleOAuthLogin).toHaveBeenCalledWith( 'mockAppleHandler', + false, ); // On iOS with Apple login, should navigate to SocialLoginSuccessNewUser expect(mockNavigate).toHaveBeenCalledWith( @@ -897,6 +900,7 @@ describe('Onboarding', () => { expect(mockCreateLoginHandler).toHaveBeenCalledWith('ios', 'apple'); expect(mockOAuthService.handleOAuthLogin).toHaveBeenCalledWith( 'mockAppleHandler', + true, ); expect(mockNavigate).toHaveBeenCalledWith( Routes.ONBOARDING.SOCIAL_LOGIN_SUCCESS_EXISTING_USER, diff --git a/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokenLogo/TrendingTokenLogo.test.tsx b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokenLogo/TrendingTokenLogo.test.tsx new file mode 100644 index 000000000000..a22c227170bd --- /dev/null +++ b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokenLogo/TrendingTokenLogo.test.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { Image } from 'expo-image'; +import TrendingTokenLogo from './TrendingTokenLogo'; + +jest.mock('../../../../hooks/useTokenLogo', () => ({ + useTokenLogo: jest.fn(() => ({ + isLoading: false, + hasError: false, + containerStyle: { + width: 44, + height: 44, + borderRadius: 22, + }, + loadingContainerStyle: { + position: 'absolute', + width: 44, + height: 44, + }, + imageStyle: { + width: 44, + height: 44, + }, + fallbackTextStyle: { + fontSize: 18, + fontWeight: '600', + color: '#000000', + }, + handleLoadStart: jest.fn(), + handleLoadEnd: jest.fn(), + handleError: jest.fn(), + })), +})); + +jest.mock('../../../../../util/theme', () => ({ + useTheme: () => ({ + colors: { + background: { + default: '#FFFFFF', + }, + }, + }), +})); + +describe('TrendingTokenLogo', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders successfully with required props', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('token-logo')).toBeTruthy(); + }); + + it('renders Image component with correct URI from assetId', () => { + const assetId = 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const { UNSAFE_getByType } = render( + , + ); + + const image = UNSAFE_getByType(Image); + expect(image.props.source.uri).toBe( + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.png', + ); + }); + + it('applies custom size and style props', () => { + const customStyle = { opacity: 0.5 }; + const { getByTestId } = render( + , + ); + + const container = getByTestId('custom-logo'); + expect(container.props.style).toEqual( + expect.arrayContaining([customStyle]), + ); + }); + + it('renders with default size when not specified', () => { + const { getByTestId } = render( + , + ); + + const container = getByTestId('default-size'); + expect(container).toBeTruthy(); + }); +}); diff --git a/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokenLogo/TrendingTokenLogo.tsx b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokenLogo/TrendingTokenLogo.tsx new file mode 100644 index 000000000000..2e19c90952c9 --- /dev/null +++ b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokenLogo/TrendingTokenLogo.tsx @@ -0,0 +1,90 @@ +import { Image } from 'expo-image'; +import React, { memo, useMemo } from 'react'; +import { ActivityIndicator, View, ViewStyle } from 'react-native'; +import Text, { + TextVariant, +} from '../../../../../component-library/components/Texts/Text'; +import { useTokenLogo } from '../../../../hooks/useTokenLogo'; + +interface TrendingTokenLogoProps { + assetId: string; + symbol: string; + size?: number; + style?: ViewStyle; + testID?: string; + recyclingKey?: string; // For FlashList optimization +} + +const TrendingTokenLogo: React.FC = ({ + assetId, + symbol, + size = 44, + style, + testID, + recyclingKey, +}) => { + const imageUri = useMemo(() => { + const imageUrl = `https://static.cx.metamask.io/api/v2/tokenIcons/assets/${assetId + .split(':') + .join('/')}.png`; + return imageUrl; + }, [assetId]); + + const fallbackText = useMemo( + () => symbol.substring(0, 2).toUpperCase(), + [symbol], + ); + + const { + isLoading, + hasError, + containerStyle, + loadingContainerStyle, + imageStyle, + fallbackTextStyle, + handleLoadStart, + handleLoadEnd, + handleError, + } = useTokenLogo({ + symbol, + size, + }); + + if (!imageUri || hasError) { + return ( + + + {fallbackText} + + + ); + } + + return ( + + {isLoading && ( + + + + )} + + + ); +}; + +export default memo(TrendingTokenLogo); diff --git a/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokenLogo/index.ts b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokenLogo/index.ts new file mode 100644 index 000000000000..1247cd65bbb8 --- /dev/null +++ b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokenLogo/index.ts @@ -0,0 +1 @@ +export { default } from './TrendingTokenLogo'; diff --git a/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokenSkeleton/TrendingTokensSkeleton.test.tsx b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokenSkeleton/TrendingTokensSkeleton.test.tsx new file mode 100644 index 000000000000..f9926c8b0d12 --- /dev/null +++ b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokenSkeleton/TrendingTokensSkeleton.test.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import TrendingTokensSkeleton from './TrendingTokensSkeleton'; + +// 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('TrendingTokensSkeleton', () => { + it('renders successfully with default props', () => { + const { getAllByTestId } = render(); + const skeletons = getAllByTestId('skeleton'); + // Should render 5 skeleton elements: icon, token name, market stats, price, percentage + expect(skeletons.length).toBe(5); + }); + + it('renders single skeleton row by default', () => { + const { getAllByTestId } = render(); + const skeletons = getAllByTestId('skeleton'); + // Should render 5 skeleton elements for one row + expect(skeletons.length).toBe(5); + }); + + it('renders multiple skeleton rows when count is provided', () => { + const { getAllByTestId } = render(); + const skeletons = getAllByTestId('skeleton'); + // Should render 5 skeleton elements per row (3 rows = 15 skeletons) + expect(skeletons.length).toBe(15); + }); + + it('matches snapshot', () => { + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokenSkeleton/TrendingTokensSkeleton.tsx b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokenSkeleton/TrendingTokensSkeleton.tsx new file mode 100644 index 000000000000..6184b4f77908 --- /dev/null +++ b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokenSkeleton/TrendingTokensSkeleton.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { View, StyleSheet, type ViewStyle } from 'react-native'; +import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; + +export interface TrendingTokensSkeletonProps { + /** + * 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({ + container: { + display: 'flex', + flexDirection: 'row', + alignItems: 'flex-start', + alignSelf: 'stretch', + paddingTop: 8, + paddingBottom: 8, + }, + iconSkeleton: { + borderRadius: 100, // Fully circular + }, + leftContainer: { + paddingLeft: 16, + flex: 1, + justifyContent: 'space-between', + }, + tokenHeaderRow: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + tokenNameSkeleton: { + marginBottom: 0, + }, + marketStatsSkeleton: { + marginTop: 2, + marginBottom: 0, + }, + rightContainer: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'flex-end', + alignSelf: 'stretch', + }, + priceSkeleton: { + marginBottom: 0, + }, + percentageSkeleton: { + marginBottom: 0, + }, +}); + +const TrendingTokensSkeleton: React.FC = ({ + count = 1, + iconSize = 44, + style, +}) => { + // Generate array for count + const rows = Array.from({ length: count }, (_, i) => i); + + return ( + <> + {rows.map((index) => ( + + + + + + + + + + + + + + + + ))} + + ); +}; + +export default TrendingTokensSkeleton; diff --git a/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokenSkeleton/__snapshots__/TrendingTokensSkeleton.test.tsx.snap b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokenSkeleton/__snapshots__/TrendingTokensSkeleton.test.tsx.snap new file mode 100644 index 000000000000..40f8585b9d9a --- /dev/null +++ b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokenSkeleton/__snapshots__/TrendingTokensSkeleton.test.tsx.snap @@ -0,0 +1,268 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TrendingTokensSkeleton matches snapshot 1`] = ` +[ + + + + + + + + + + + + + + + , + + + + + + + + + + + + + + + , +] +`; diff --git a/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokenRowItem/TrendingTokenRowItem.styles.ts b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokenRowItem/TrendingTokenRowItem.styles.ts new file mode 100644 index 000000000000..63fcfa81adac --- /dev/null +++ b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokenRowItem/TrendingTokenRowItem.styles.ts @@ -0,0 +1,36 @@ +import { StyleSheet } from 'react-native'; +import { Theme } from '../../../../../../util/theme/models'; + +const styleSheet = (_params: { theme: Theme }) => + StyleSheet.create({ + container: { + display: 'flex', + flexDirection: 'row', + alignItems: 'flex-start', + alignSelf: 'stretch', + paddingTop: 8, + paddingBottom: 8, + }, + badge: { + borderRadius: 16, + }, + leftContainer: { + paddingLeft: 16, + flex: 1, + }, + tokenHeaderRow: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + rightContainer: { + display: 'flex', + justifyContent: 'flex-end', + alignItems: 'flex-end', + gap: 2, + alignSelf: 'stretch', + }, + }); + +export default styleSheet; diff --git a/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx new file mode 100644 index 000000000000..e5b068690082 --- /dev/null +++ b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx @@ -0,0 +1,467 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { TouchableOpacity } from 'react-native'; +import TrendingTokenRowItem from './TrendingTokenRowItem'; +import type { TrendingAsset } from '@metamask/assets-controllers'; + +jest.mock('../../../../../../component-library/hooks', () => ({ + useStyles: jest.fn(() => { + const actualStyleSheet = jest.requireActual( + './TrendingTokenRowItem.styles', + ).default; + const mockTheme = { + colors: { + background: { default: '#FFFFFF', muted: '#F2F4F6' }, + text: { default: '#24272A', alternative: '#6A737D', muted: '#8A8D90' }, + primary: { default: '#037DD6' }, + success: { default: '#00C853' }, + border: { muted: '#D0D5DA' }, + }, + }; + return { styles: actualStyleSheet({ theme: mockTheme }) }; + }), +})); + +jest.mock('../../TrendingTokenLogo', () => { + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: function MockTrendingTokenLogo({ + symbol, + size, + }: { + symbol: string; + size: number; + recyclingKey: string; + }) { + return ( + + {symbol} + + ); + }, + }; +}); + +jest.mock( + '../../../../../../component-library/components/Badges/BadgeWrapper', + () => { + const { View: RNView } = jest.requireActual('react-native'); + return { + __esModule: true, + default: function MockBadgeWrapper({ + children, + badgeElement, + badgePosition, + }: { + children: unknown; + badgeElement: unknown; + badgePosition: string; + }) { + return ( + + {children} + {badgeElement} + + ); + }, + BadgePosition: { + BottomRight: 'BottomRight', + }, + }; + }, +); + +jest.mock('../../../../../../component-library/components/Badges/Badge', () => { + const { View: RNView } = jest.requireActual('react-native'); + return { + __esModule: true, + default: function MockBadge({ + size, + variant, + imageSource, + isScaled, + }: { + size: string; + variant: string; + imageSource?: string; + isScaled?: boolean; + }) { + return ( + + ); + }, + BadgeVariant: { + Network: 'Network', + }, + }; +}); + +jest.mock('../../../../../../util/networks', () => ({ + getDefaultNetworkByChainId: jest.fn(), + getTestNetImageByChainId: jest.fn(), + isTestNet: jest.fn(() => false), +})); + +jest.mock('../../../../../../util/networks/customNetworks', () => ({ + CustomNetworkImgMapping: {}, + PopularList: [], + UnpopularNetworkList: [], + getNonEvmNetworkImageSourceByChainId: jest.fn(), +})); + +jest.mock('@metamask/utils', () => { + const actual = jest.requireActual('@metamask/utils'); + return { + ...actual, + parseCaipChainId: jest.fn((chainId: string) => { + const parts = chainId.split(':'); + return { + namespace: parts[0], + reference: parts[1], + }; + }), + isCaipChainId: jest.fn(() => false), + }; +}); + +const { getDefaultNetworkByChainId, isTestNet } = jest.requireMock( + '../../../../../../util/networks', +); +const { parseCaipChainId } = jest.requireMock('@metamask/utils'); + +const mockGetDefaultNetworkByChainId = + getDefaultNetworkByChainId as jest.MockedFunction< + typeof getDefaultNetworkByChainId + >; +const mockIsTestNet = isTestNet as jest.MockedFunction; +const mockParseCaipChainId = parseCaipChainId as jest.MockedFunction< + typeof parseCaipChainId +>; + +const createMockToken = ( + overrides: Partial = {}, +): TrendingAsset => ({ + assetId: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + price: '1.00135763432467', + aggregatedUsdVolume: 974248822.2, + marketCap: 75641301011.76, + ...overrides, +}); + +describe('TrendingTokenRowItem', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsTestNet.mockReturnValue(false); + mockGetDefaultNetworkByChainId.mockReturnValue({ + imageSource: 'https://example.com/ethereum.png', + type: 'mainnet', + } as { imageSource: string; type: string }); + mockParseCaipChainId.mockImplementation((chainId: string) => { + const parts = chainId.split(':'); + return { + namespace: parts[0], + reference: parts[1], + }; + }); + }); + + it('matches snapshot', () => { + const token = createMockToken(); + const mockOnPress = jest.fn(); + + const { toJSON } = render( + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('calls onPress when pressed', () => { + const token = createMockToken(); + const mockOnPress = jest.fn(); + + const { root } = render( + , + ); + + const touchableOpacity = root.findByType(TouchableOpacity); + fireEvent.press(touchableOpacity); + + expect(mockOnPress).toHaveBeenCalledTimes(1); + }); + + it('renders token name', () => { + const token = createMockToken({ name: 'Ethereum' }); + const mockOnPress = jest.fn(); + + const { getByText } = render( + , + ); + + expect(getByText('Ethereum')).toBeTruthy(); + }); + + it('renders market stats with formatted values', () => { + const token = createMockToken({ + marketCap: 75641301011.76, + aggregatedUsdVolume: 974248822.2, + }); + const mockOnPress = jest.fn(); + + const { getByText } = render( + , + ); + + expect(getByText(/\$76B cap • \$974\.2M vol/)).toBeTruthy(); + }); + + it('renders formatted price', () => { + const token = createMockToken({ price: '1.50' }); + const mockOnPress = jest.fn(); + + const { getByText } = render( + , + ); + + expect(getByText('$1.50')).toBeTruthy(); + }); + + it('renders percentage change with positive indicator', () => { + const token = createMockToken(); + const mockOnPress = jest.fn(); + + const { getByText } = render( + , + ); + + expect(getByText('+3.44%')).toBeTruthy(); + }); + + it('renders token logo with correct props', () => { + const token = createMockToken({ + assetId: 'eip155:1/erc20:0x123', + symbol: 'ETH', + }); + const mockOnPress = jest.fn(); + + const { getByTestId } = render( + , + ); + + const logo = getByTestId('trending-token-logo-ETH'); + expect(logo).toBeTruthy(); + expect(logo.props['data-size']).toBe(44); + }); + + it('renders token logo with custom iconSize', () => { + const token = createMockToken({ symbol: 'BTC' }); + const mockOnPress = jest.fn(); + + const { getByTestId } = render( + , + ); + + const logo = getByTestId('trending-token-logo-BTC'); + expect(logo.props['data-size']).toBe(60); + }); + + it('renders network badge with default network image source', () => { + const token = createMockToken(); + const mockOnPress = jest.fn(); + + const { getByTestId } = render( + , + ); + + const badge = getByTestId('network-badge'); + expect(badge).toBeTruthy(); + expect(badge.props['data-image-source']).toBe( + 'https://example.com/ethereum.png', + ); + }); + + it('renders network badge with testnet image source when chain is testnet', () => { + const { getTestNetImageByChainId } = jest.requireMock( + '../../../../../../util/networks', + ); + const mockGetTestNetImageByChainId = + getTestNetImageByChainId as jest.MockedFunction< + typeof getTestNetImageByChainId + >; + mockGetTestNetImageByChainId.mockReturnValue('https://testnet.png'); + mockIsTestNet.mockReturnValue(true); + + const token = createMockToken(); + const mockOnPress = jest.fn(); + + const { getByTestId } = render( + , + ); + + const badge = getByTestId('network-badge'); + expect(badge.props['data-image-source']).toBe('https://testnet.png'); + }); + + it('renders network badge with popular network image source', () => { + const { PopularList } = jest.requireMock( + '../../../../../../util/networks/customNetworks', + ); + PopularList.push({ + chainId: '0x1' as const, + rpcPrefs: { + imageSource: 'https://popular-network.png', + }, + } as never); + mockGetDefaultNetworkByChainId.mockReturnValue(undefined); + + const token = createMockToken(); + const mockOnPress = jest.fn(); + + const { getByTestId } = render( + , + ); + + const badge = getByTestId('network-badge'); + expect(badge.props['data-image-source']).toBe( + 'https://popular-network.png', + ); + + PopularList.pop(); + }); + + it('renders network badge with unpopular network image source', () => { + const { UnpopularNetworkList } = jest.requireMock( + '../../../../../../util/networks/customNetworks', + ); + UnpopularNetworkList.push({ + chainId: '0x1' as const, + rpcPrefs: { + imageSource: 'https://unpopular-network.png', + }, + } as never); + mockGetDefaultNetworkByChainId.mockReturnValue(undefined); + + const token = createMockToken(); + const mockOnPress = jest.fn(); + + const { getByTestId } = render( + , + ); + + const badge = getByTestId('network-badge'); + expect(badge.props['data-image-source']).toBe( + 'https://unpopular-network.png', + ); + + UnpopularNetworkList.pop(); + }); + + it('renders network badge with custom network image source', () => { + const { CustomNetworkImgMapping } = jest.requireMock( + '../../../../../../util/networks/customNetworks', + ); + CustomNetworkImgMapping['0x1'] = 'https://custom-network.png'; + mockGetDefaultNetworkByChainId.mockReturnValue(undefined); + + const token = createMockToken(); + const mockOnPress = jest.fn(); + + const { getByTestId } = render( + , + ); + + const badge = getByTestId('network-badge'); + expect(badge.props['data-image-source']).toBe('https://custom-network.png'); + + delete CustomNetworkImgMapping['0x1']; + }); + + it('renders network badge with non-EVM network image source', () => { + const { getNonEvmNetworkImageSourceByChainId } = jest.requireMock( + '../../../../../../util/networks/customNetworks', + ); + const mockGetNonEvmNetworkImageSourceByChainId = + getNonEvmNetworkImageSourceByChainId as jest.MockedFunction< + typeof getNonEvmNetworkImageSourceByChainId + >; + mockGetNonEvmNetworkImageSourceByChainId.mockReturnValue( + 'https://non-evm-network.png', + ); + + const { isCaipChainId } = jest.requireMock('@metamask/utils'); + const mockIsCaipChainId = isCaipChainId as jest.MockedFunction< + typeof isCaipChainId + >; + mockIsCaipChainId.mockReturnValue(true); + mockGetDefaultNetworkByChainId.mockReturnValue(undefined); + + const token = createMockToken(); + const mockOnPress = jest.fn(); + + const { getByTestId } = render( + , + ); + + const badge = getByTestId('network-badge'); + expect(badge.props['data-image-source']).toBe( + 'https://non-evm-network.png', + ); + }); + + it('uses correct testID format with assetId', () => { + const token = createMockToken({ + assetId: 'eip155:1/erc20:0xabc123', + }); + const mockOnPress = jest.fn(); + + const { getByTestId } = render( + , + ); + + expect( + getByTestId('trending-token-row-item-eip155:1/erc20:0xabc123'), + ).toBeTruthy(); + }); + + it('renders with zero market cap and volume', () => { + const token = createMockToken({ + marketCap: 0, + aggregatedUsdVolume: 0, + }); + const mockOnPress = jest.fn(); + + const { getByText } = render( + , + ); + + expect(getByText(/\$0\.00 cap • \$0\.00 vol/)).toBeTruthy(); + }); + + it('renders with very large market cap and volume', () => { + const token = createMockToken({ + marketCap: 1500000000000, + aggregatedUsdVolume: 5000000000, + }); + const mockOnPress = jest.fn(); + + const { getByText } = render( + , + ); + + expect(getByText(/\$1500B cap • \$5B vol/)).toBeTruthy(); + }); +}); diff --git a/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokenRowItem/TrendingTokenRowItem.tsx b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokenRowItem/TrendingTokenRowItem.tsx new file mode 100644 index 000000000000..b34e41ee5bee --- /dev/null +++ b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokenRowItem/TrendingTokenRowItem.tsx @@ -0,0 +1,182 @@ +import React, { useCallback } from 'react'; +import { TouchableOpacity, View } from 'react-native'; +import Text, { + TextColor, + TextVariant, +} from '../../../../../../component-library/components/Texts/Text'; +import { useStyles } from '../../../../../../component-library/hooks'; +import styleSheet from './TrendingTokenRowItem.styles'; +import { TrendingAsset } from '@metamask/assets-controllers'; +import TrendingTokenLogo from '../../TrendingTokenLogo'; +import Icon, { + IconName, + IconSize, +} from '../../../../../../component-library/components/Icons/Icon'; +import Badge, { + BadgeVariant, +} from '../../../../../../component-library/components/Badges/Badge'; +import BadgeWrapper, { + BadgePosition, +} from '../../../../../../component-library/components/Badges/BadgeWrapper'; +import { + parseCaipChainId, + CaipChainId, + Hex, + isCaipChainId, +} from '@metamask/utils'; +import { + getDefaultNetworkByChainId, + getTestNetImageByChainId, + isTestNet, +} from '../../../../../../util/networks'; +import { + CustomNetworkImgMapping, + PopularList, + UnpopularNetworkList, + getNonEvmNetworkImageSourceByChainId, +} from '../../../../../../util/networks/customNetworks'; +import { AvatarSize } from '../../../../../../component-library/components/Avatars/Avatar'; +import { formatMarketStats } from './utils'; +import { formatPrice } from '../../../../../UI/Predict/utils/format'; + +interface TrendingTokenRowItemProps { + token: TrendingAsset; + onPress: () => void; + iconSize?: number; +} +const TrendingTokenRowItem = ({ + token, + onPress, + iconSize = 44, +}: TrendingTokenRowItemProps) => { + const { styles } = useStyles(styleSheet, {}); + const chainId = token.assetId.split('/')[0] as CaipChainId; + + const networkBadgeSource = useCallback((currentChainId: CaipChainId) => { + const { reference } = parseCaipChainId(currentChainId); + const hexChainId = `0x${Number(reference).toString(16)}` as Hex; + + if (isTestNet(hexChainId)) { + return getTestNetImageByChainId(hexChainId); + } + + const defaultNetwork = getDefaultNetworkByChainId(hexChainId) as + | { + imageSource: string; + } + | undefined; + + if (defaultNetwork) { + return defaultNetwork.imageSource; + } + + const unpopularNetwork = UnpopularNetworkList.find( + (networkConfig) => networkConfig.chainId === hexChainId, + ); + + const customNetworkImg = CustomNetworkImgMapping[hexChainId]; + + const popularNetwork = PopularList.find( + (networkConfig) => networkConfig.chainId === hexChainId, + ); + + const network = unpopularNetwork || popularNetwork; + if (network) { + return network.rpcPrefs.imageSource; + } + if (isCaipChainId(currentChainId)) { + return getNonEvmNetworkImageSourceByChainId(currentChainId); + } + if (customNetworkImg) { + return customNetworkImg; + } + }, []); + + // TODO: Get pricePercentChange1d from token or trending hook + const pricePercentChange1d: number | undefined = 3.44; // This should come from the trending hook + + // Determine the color for percentage change + // Handle 0 as neutral (not positive or negative) + const hasPercentageChange = + pricePercentChange1d !== undefined && pricePercentChange1d !== null; + const isPositiveChange = + hasPercentageChange && (pricePercentChange1d as number) > 0; + const isNeutralChange = + hasPercentageChange && (pricePercentChange1d as number) === 0; + + const handlePress = () => { + // TODO: Implement token press logic + onPress?.(); + }; + + return ( + + + + } + > + + + + + + + {token.name} + + {/* TODO: Display verified icon conditionally based on API response */} + + + + {formatMarketStats(token.marketCap, token.aggregatedUsdVolume)} + + + + + {formatPrice(token.price, { + minimumDecimals: 2, + maximumDecimals: 2, + })} + + {hasPercentageChange && ( + + {isNeutralChange ? '' : isPositiveChange ? '+' : '-'} + {Math.abs(pricePercentChange1d as number)}% + + )} + + + ); +}; + +export default TrendingTokenRowItem; diff --git a/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokenRowItem/__snapshots__/TrendingTokenRowItem.test.tsx.snap b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokenRowItem/__snapshots__/TrendingTokenRowItem.test.tsx.snap new file mode 100644 index 000000000000..3167ca07e7b7 --- /dev/null +++ b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokenRowItem/__snapshots__/TrendingTokenRowItem.test.tsx.snap @@ -0,0 +1,106 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TrendingTokenRowItem matches snapshot 1`] = ` + + + + + USDC + + + + + + + + USD Coin + + + + + $76B cap • $974.2M vol + + + + + $1.00 + + + + + 3.44 + % + + + +`; diff --git a/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokenRowItem/utils.test.ts b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokenRowItem/utils.test.ts new file mode 100644 index 000000000000..1713e7b17970 --- /dev/null +++ b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokenRowItem/utils.test.ts @@ -0,0 +1,366 @@ +import { formatCompactUSD, formatMarketStats } from './utils'; + +describe('formatCompactUSD', () => { + describe('Billions formatting', () => { + it('formats billions with B suffix and no decimals', () => { + const value = 13000000000; + + const result = formatCompactUSD(value); + + expect(result).toBe('$13B'); + }); + + it('formats large billions correctly', () => { + const value = 999999999999; + + const result = formatCompactUSD(value); + + expect(result).toBe('$1000B'); + }); + + it('formats billions with rounding', () => { + const value = 2500000000; + + const result = formatCompactUSD(value); + + expect(result).toBe('$3B'); + }); + }); + + describe('Millions formatting', () => { + it('formats millions with M suffix and one decimal', () => { + const value = 34200000; + + const result = formatCompactUSD(value); + + expect(result).toBe('$34.2M'); + }); + + it('formats millions with rounding', () => { + const value = 974248822.2; + + const result = formatCompactUSD(value); + + expect(result).toBe('$974.2M'); + }); + + it('formats large millions correctly', () => { + const value = 999999999; + + const result = formatCompactUSD(value); + + expect(result).toBe('$1000.0M'); + }); + }); + + describe('Thousands formatting', () => { + it('formats thousands with K suffix and one decimal', () => { + const value = 850500; + + const result = formatCompactUSD(value); + + expect(result).toBe('$850.5K'); + }); + + it('formats thousands with rounding', () => { + const value = 123456; + + const result = formatCompactUSD(value); + + expect(result).toBe('$123.5K'); + }); + + it('formats large thousands correctly', () => { + const value = 999999; + + const result = formatCompactUSD(value); + + expect(result).toBe('$1000.0K'); + }); + }); + + describe('Small values formatting', () => { + it('formats values less than 1000 with two decimals', () => { + const value = 532.5; + + const result = formatCompactUSD(value); + + expect(result).toBe('$532.50'); + }); + + it('formats small decimal values correctly', () => { + const value = 123.45; + + const result = formatCompactUSD(value); + + expect(result).toBe('$123.45'); + }); + + it('formats very small values correctly', () => { + const value = 0.01; + + const result = formatCompactUSD(value); + + expect(result).toBe('$0.01'); + }); + }); + + describe('Zero and edge cases', () => { + it('formats zero correctly', () => { + const value = 0; + + const result = formatCompactUSD(value); + + expect(result).toBe('$0.00'); + }); + + it('formats exactly 1000 correctly', () => { + const value = 1000; + + const result = formatCompactUSD(value); + + expect(result).toBe('$1.0K'); + }); + + it('formats exactly 1000000 correctly', () => { + const value = 1000000; + + const result = formatCompactUSD(value); + + expect(result).toBe('$1.0M'); + }); + + it('formats exactly 1000000000 correctly', () => { + const value = 1000000000; + + const result = formatCompactUSD(value); + + expect(result).toBe('$1B'); + }); + }); + describe('Invalid inputs', () => { + it('returns Invalid number for NaN', () => { + const value = NaN; + + const result = formatCompactUSD(value); + + expect(result).toBe('Invalid number'); + }); + + it('returns Invalid number for string that converts to NaN', () => { + const value = Number('invalid'); + + const result = formatCompactUSD(value); + + expect(result).toBe('Invalid number'); + }); + }); + + describe('Boundary conditions', () => { + it('formats value just below 1000', () => { + const value = 999.99; + + const result = formatCompactUSD(value); + + expect(result).toBe('$999.99'); + }); + + it('formats value just above 1000', () => { + const value = 1000.01; + + const result = formatCompactUSD(value); + + expect(result).toBe('$1.0K'); + }); + + it('formats value just below 1000000', () => { + const value = 999999.99; + + const result = formatCompactUSD(value); + + expect(result).toBe('$1000.0K'); + }); + + it('formats value just above 1000000', () => { + const value = 1000000.01; + + const result = formatCompactUSD(value); + + expect(result).toBe('$1.0M'); + }); + + it('formats value just below 1000000000', () => { + const value = 999999999.99; + + const result = formatCompactUSD(value); + + expect(result).toBe('$1000.0M'); + }); + + it('formats value just above 1000000000', () => { + const value = 1000000000.01; + + const result = formatCompactUSD(value); + + expect(result).toBe('$1B'); + }); + }); +}); + +describe('formatMarketStats', () => { + describe('Combined formatting', () => { + it('formats market cap and volume with correct format', () => { + const marketCap = 75641301011.76; + const volume = 974248822.2; + + const result = formatMarketStats(marketCap, volume); + + expect(result).toBe('$76B cap • $974.2M vol'); + }); + + it('formats both values as billions', () => { + const marketCap = 13000000000; + const volume = 5000000000; + + const result = formatMarketStats(marketCap, volume); + + expect(result).toBe('$13B cap • $5B vol'); + }); + + it('formats both values as millions', () => { + const marketCap = 34200000; + const volume = 15000000; + + const result = formatMarketStats(marketCap, volume); + + expect(result).toBe('$34.2M cap • $15.0M vol'); + }); + + it('formats both values as thousands', () => { + const marketCap = 850500; + const volume = 123400; + + const result = formatMarketStats(marketCap, volume); + + expect(result).toBe('$850.5K cap • $123.4K vol'); + }); + + it('formats both values as small numbers', () => { + const marketCap = 532.5; + const volume = 123.45; + + const result = formatMarketStats(marketCap, volume); + + expect(result).toBe('$532.50 cap • $123.45 vol'); + }); + }); + + describe('Mixed value ranges', () => { + it('formats billion market cap with million volume', () => { + const marketCap = 13000000000; + const volume = 50000000; + + const result = formatMarketStats(marketCap, volume); + + expect(result).toBe('$13B cap • $50.0M vol'); + }); + + it('formats million market cap with thousand volume', () => { + const marketCap = 5000000; + const volume = 123400; + + const result = formatMarketStats(marketCap, volume); + + expect(result).toBe('$5.0M cap • $123.4K vol'); + }); + + it('formats thousand market cap with small volume', () => { + const marketCap = 5000; + const volume = 123.45; + + const result = formatMarketStats(marketCap, volume); + + expect(result).toBe('$5.0K cap • $123.45 vol'); + }); + }); + + describe('Zero values', () => { + it('formats zero market cap and volume', () => { + const marketCap = 0; + const volume = 0; + + const result = formatMarketStats(marketCap, volume); + + expect(result).toBe('$0.00 cap • $0.00 vol'); + }); + + it('formats zero market cap with non-zero volume', () => { + const marketCap = 0; + const volume = 1000000; + + const result = formatMarketStats(marketCap, volume); + + expect(result).toBe('$0.00 cap • $1.0M vol'); + }); + + it('formats non-zero market cap with zero volume', () => { + const marketCap = 1000000; + const volume = 0; + + const result = formatMarketStats(marketCap, volume); + + expect(result).toBe('$1.0M cap • $0.00 vol'); + }); + }); + + describe('Invalid inputs', () => { + it('handles NaN market cap', () => { + const marketCap = NaN; + const volume = 1000000; + + const result = formatMarketStats(marketCap, volume); + + expect(result).toBe('Invalid number cap • $1.0M vol'); + }); + + it('handles NaN volume', () => { + const marketCap = 1000000; + const volume = NaN; + + const result = formatMarketStats(marketCap, volume); + + expect(result).toBe('$1.0M cap • Invalid number vol'); + }); + + it('handles both NaN values', () => { + const marketCap = NaN; + const volume = NaN; + + const result = formatMarketStats(marketCap, volume); + + expect(result).toBe('Invalid number cap • Invalid number vol'); + }); + }); + + describe('Very large values', () => { + it('formats very large market cap and volume', () => { + const marketCap = 999999999999; + const volume = 500000000000; + + const result = formatMarketStats(marketCap, volume); + + expect(result).toBe('$1000B cap • $500B vol'); + }); + }); + + describe('Very small values', () => { + it('formats very small market cap and volume', () => { + const marketCap = 0.01; + const volume = 0.005; + + const result = formatMarketStats(marketCap, volume); + + expect(result).toBe('$0.01 cap • $0.01 vol'); + }); + }); +}); diff --git a/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokenRowItem/utils.ts b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokenRowItem/utils.ts new file mode 100644 index 000000000000..d6c6ca55ce3f --- /dev/null +++ b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokenRowItem/utils.ts @@ -0,0 +1,34 @@ +/** + * Formats a number as compact USD currency string + * @param value - The number to format + * @returns Formatted string (e.g., "$13B", "$34.2M", "$850.5K", "$532.50") + */ +export function formatCompactUSD(value: number): string { + const num = Number(value); + if (isNaN(num)) return 'Invalid number'; + + const absNum = Math.abs(num); + let formatted: string; + + if (absNum >= 1_000_000_000) { + formatted = `$${(num / 1_000_000_000).toFixed(0)}B`; // e.g. 13B + } else if (absNum >= 1_000_000) { + formatted = `$${(num / 1_000_000).toFixed(1)}M`; // e.g. 34.2M + } else if (absNum >= 1_000) { + formatted = `$${(num / 1_000).toFixed(1)}K`; // e.g. 850.5K + } else { + formatted = `$${num.toFixed(2)}`; // e.g. 532.50 + } + + return formatted; +} + +/** + * Formats market cap and volume as a combined string + * @param marketCap - Market capitalization value + * @param volume - Trading volume value + * @returns Formatted string (e.g., "$13B cap • $34.2M vol") + */ +export function formatMarketStats(marketCap: number, volume: number): string { + return `${formatCompactUSD(marketCap)} cap • ${formatCompactUSD(volume)} vol`; +} diff --git a/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokensList.test.tsx b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokensList.test.tsx new file mode 100644 index 000000000000..912f8a0d8d22 --- /dev/null +++ b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokensList.test.tsx @@ -0,0 +1,182 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import TrendingTokensList from './TrendingTokensList'; +import type { TrendingAsset } from '@metamask/assets-controllers'; + +// Mock FlashList +jest.mock('@shopify/flash-list', () => { + const { View } = jest.requireActual('react-native'); + return { + FlashList: ({ + data, + renderItem, + keyExtractor, + testID, + }: { + data: TrendingAsset[]; + renderItem: ({ item }: { item: TrendingAsset }) => React.ReactElement; + keyExtractor: (item: TrendingAsset) => string; + testID: string; + }) => ( + + {data.map((item: TrendingAsset) => { + const key = keyExtractor(item); + return ( + + {renderItem({ item })} + + ); + })} + + ), + }; +}); + +// Mock TrendingTokenRowItem +jest.mock('./TrendingTokenRowItem/TrendingTokenRowItem', () => { + const React = jest.requireActual('react'); + const { TouchableOpacity, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + token, + onPress, + }: { + token: TrendingAsset; + onPress: () => void; + }) => ( + + {token.name} + + ), + }; +}); + +const createMockToken = ( + overrides: Partial = {}, +): TrendingAsset => ({ + assetId: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + price: '1.00', + aggregatedUsdVolume: 974248822.2, + marketCap: 75641301011.76, + ...overrides, +}); + +describe('TrendingTokensList', () => { + const mockOnTokenPress = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders successfully with empty array', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('trending-tokens-list')).toBeTruthy(); + }); + + it('renders multiple tokens', () => { + const tokens = [ + createMockToken({ + assetId: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + name: 'USD Coin', + symbol: 'USDC', + }), + createMockToken({ + assetId: 'eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7', + name: 'Tether', + symbol: 'USDT', + }), + createMockToken({ + assetId: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + name: 'Dai Stablecoin', + symbol: 'DAI', + }), + ]; + + const { getByTestId, getAllByTestId } = render( + , + ); + + expect(getByTestId('trending-tokens-list')).toBeTruthy(); + expect(getAllByTestId(/trending-token-row-item-/)).toHaveLength(3); + }); + + it('calls onTokenPress when a token is pressed', () => { + const tokens = [ + createMockToken({ + assetId: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + name: 'USD Coin', + symbol: 'USDC', + }), + ]; + + const { getByTestId } = render( + , + ); + + const tokenItem = getByTestId( + 'trending-token-row-item-eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + ); + + fireEvent.press(tokenItem); + + expect(mockOnTokenPress).toHaveBeenCalledTimes(1); + expect(mockOnTokenPress).toHaveBeenCalledWith(tokens[0]); + }); + + it('calls onTokenPress with correct token for each item', () => { + const tokens = [ + createMockToken({ + assetId: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + name: 'USD Coin', + symbol: 'USDC', + }), + createMockToken({ + assetId: 'eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7', + name: 'Tether', + symbol: 'USDT', + }), + ]; + + const { getByTestId } = render( + , + ); + + // Press first token + const firstTokenItem = getByTestId( + 'trending-token-row-item-eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + ); + fireEvent.press(firstTokenItem); + expect(mockOnTokenPress).toHaveBeenCalledWith(tokens[0]); + + // Press second token + const secondTokenItem = getByTestId( + 'trending-token-row-item-eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7', + ); + fireEvent.press(secondTokenItem); + expect(mockOnTokenPress).toHaveBeenCalledWith(tokens[1]); + + expect(mockOnTokenPress).toHaveBeenCalledTimes(2); + }); +}); diff --git a/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokensList.tsx b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokensList.tsx new file mode 100644 index 000000000000..012c574b5819 --- /dev/null +++ b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokensList.tsx @@ -0,0 +1,39 @@ +import React, { useCallback } from 'react'; +import { FlashList } from '@shopify/flash-list'; +import { TrendingAsset } from '@metamask/assets-controllers'; +import TrendingTokenRowItem from './TrendingTokenRowItem/TrendingTokenRowItem'; + +export interface TrendingTokensListProps { + /** + * Trending tokens to display + */ + trendingTokens: TrendingAsset[]; + /** + * Callback when a token is pressed + */ + onTokenPress: (token: TrendingAsset) => void; +} + +const TrendingTokensList: React.FC = ({ + trendingTokens, + onTokenPress, +}) => { + const renderItem = useCallback( + ({ item }: { item: TrendingAsset }) => ( + onTokenPress(item)} /> + ), + [onTokenPress], + ); + + return ( + item.assetId} + keyboardShouldPersistTaps="handled" + testID="trending-tokens-list" + /> + ); +}; + +export default TrendingTokensList; diff --git a/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/index.ts b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/index.ts new file mode 100644 index 000000000000..add72b9587d2 --- /dev/null +++ b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/index.ts @@ -0,0 +1 @@ +export { default } from './TrendingTokensList'; diff --git a/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensSection.tsx b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensSection.tsx new file mode 100644 index 000000000000..424b031aa2e1 --- /dev/null +++ b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensSection.tsx @@ -0,0 +1,99 @@ +import React, { useCallback, useMemo } from 'react'; +import { View, TouchableOpacity, StyleSheet } from 'react-native'; +import { strings } from '../../../../../locales/i18n'; +import { TrendingAsset } from '@metamask/assets-controllers'; +import { useAppThemeFromContext } from '../../../../util/theme'; +import { Theme } from '../../../../util/theme/models'; +import Text, { + TextColor, + TextVariant, +} from '../../../../component-library/components/Texts/Text'; +import TrendingTokensSkeleton from './TrendingTokenSkeleton/TrendingTokensSkeleton'; +import TrendingTokensList from './TrendingTokensList'; +import Card from '../../../../component-library/components/Cards/Card'; +import { useTrendingRequest } from '../../../UI/Assets/hooks/useTrendingRequest'; + +const createStyles = (theme: Theme) => + StyleSheet.create({ + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 4, + marginBottom: 8, + }, + contentContainer: { + marginHorizontal: 16, + borderRadius: 16, + paddingTop: 12, + backgroundColor: theme.colors.background.muted, + }, + cardContainer: { + borderRadius: 12, + paddingVertical: 16, + paddingHorizontal: 16, + backgroundColor: theme.colors.background.muted, + borderColor: theme.colors.border.muted, + }, + }); + +const TrendingTokensSection = () => { + const theme = useAppThemeFromContext(); + const styles = useMemo(() => createStyles(theme), [theme]); + + const { results: trendingTokensResults, isLoading } = useTrendingRequest({}); + const trendingTokens = trendingTokensResults.slice(0, 3); + + const handleViewAll = useCallback(() => { + // TODO: Implement view all logic + }, []); + + const handleTokenPress = useCallback((token: TrendingAsset) => { + // eslint-disable-next-line no-console + console.log('🚀 ~ TrendingTokensSection ~ token:', token); + // TODO: Implement token press logic + }, []); + + // Header component + const SectionHeader = useCallback( + () => ( + + + {strings('trending.tokens')} + + + + {strings('trending.view_all')} + + + + ), + [styles.header, handleViewAll], + ); + + // Show skeleton during initial load or when there are no tokens + if (isLoading || trendingTokens.length === 0) { + return ( + + + + + + + ); + } + + return ( + + + + + + + ); +}; + +export default TrendingTokensSection; diff --git a/app/components/Views/TrendingView/TrendingView.test.tsx b/app/components/Views/TrendingView/TrendingView.test.tsx index ddc719b022a1..41aecf34e28d 100644 --- a/app/components/Views/TrendingView/TrendingView.test.tsx +++ b/app/components/Views/TrendingView/TrendingView.test.tsx @@ -21,19 +21,24 @@ jest.mock('@react-navigation/native', () => ({ }), })); -import TrendingView from './TrendingView'; -import { updateLastTrendingScreen } from '../../Nav/Main/MainNavigator'; - jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), - useSelector: jest.fn((selector) => { - if (selector.toString().includes('dataCollectionForMarketing')) { - return false; - } - return undefined; - }), + useSelector: jest.fn(), })); +import TrendingView from './TrendingView'; +import { updateLastTrendingScreen } from '../../Nav/Main/MainNavigator'; +import { + selectChainId, + selectPopularNetworkConfigurationsByCaipChainId, + selectCustomNetworkConfigurationsByCaipChainId, +} from '../../../selectors/networkController'; +import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController'; +import { selectEnabledNetworksByNamespace } from '../../../selectors/networkEnablementController'; +import { selectMultichainAccountsState2Enabled } from '../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts'; +import { selectSelectedInternalAccountByScope } from '../../../selectors/multichainAccounts/accounts'; +import { useSelector } from 'react-redux'; + jest.mock('../../../components/hooks/useMetrics', () => ({ useMetrics: () => ({ isEnabled: mockIsEnabled, @@ -51,23 +56,107 @@ jest.mock('../Browser', () => ({ default: jest.fn(() => null), })); +// Mock the network hooks used by useTrendingRequest +jest.mock( + '../../../components/hooks/useNetworksByNamespace/useNetworksByNamespace', + () => ({ + useNetworksByNamespace: jest.fn(() => ({ + networks: [], + selectedNetworks: [], + areAllNetworksSelected: false, + areAnyNetworksSelected: false, + networkCount: 0, + selectedCount: 0, + })), + NetworkType: { + Popular: 'popular', + Custom: 'custom', + }, + }), +); + +jest.mock( + '../../../components/hooks/useNetworksToUse/useNetworksToUse', + () => ({ + useNetworksToUse: jest.fn(() => ({ + networksToUse: [], + evmNetworks: [], + solanaNetworks: [], + selectedEvmAccount: null, + selectedSolanaAccount: null, + isMultichainAccountsState2Enabled: false, + areAllNetworksSelectedCombined: false, + areAllEvmNetworksSelected: false, + areAllSolanaNetworksSelected: false, + })), + }), +); + +// Mock useTrendingRequest to return empty results +jest.mock('../../../components/UI/Assets/hooks/useTrendingRequest', () => ({ + useTrendingRequest: jest.fn(() => ({ + results: [], + isLoading: false, + error: null, + fetch: jest.fn(), + })), +})); + describe('TrendingView', () => { + const mockUseSelector = useSelector as jest.MockedFunction< + typeof useSelector + >; + beforeEach(() => { jest.clearAllMocks(); mockIsEnabled.mockReturnValue(true); mockAddListener.mockReturnValue(jest.fn()); - }); - it('renders native coming soon view', () => { - const { getByTestId } = render( - - - , - ); - - const comingSoonText = getByTestId('trending-view-coming-soon'); - - expect(comingSoonText).toBeDefined(); + mockUseSelector.mockImplementation((selector) => { + // Compare selectors by reference for memoized selectors + if (selector === selectChainId) { + return '0x1'; + } + if (selector === selectIsEvmNetworkSelected) { + return true; + } + if (selector === selectEnabledNetworksByNamespace) { + return { + eip155: { + '0x1': true, + }, + }; + } + if (selector === selectPopularNetworkConfigurationsByCaipChainId) { + // Return empty array to prevent Object.entries() error + return []; + } + if (selector === selectCustomNetworkConfigurationsByCaipChainId) { + // Return empty array to prevent Object.entries() error + return []; + } + if (selector === selectMultichainAccountsState2Enabled) { + // Return false to use default networks behavior + return false; + } + // Handle selectSelectedInternalAccountByScope which is a selector factory + // It returns a function that takes a scope and returns an account + if (selector === selectSelectedInternalAccountByScope) { + // Return a function that returns null (no account selected) + return (_scope: string) => null; + } + // Fallback: if selector is a function and might be a selector factory, return a function + if (typeof selector === 'function') { + const selectorStr = selector.toString(); + if ( + selectorStr.includes('selectSelectedInternalAccountByScope') || + selectorStr.includes('SelectedInternalAccountByScope') + ) { + return (_scope: string) => null; + } + } + return undefined; + }); }); it('renders browser button in header', () => { diff --git a/app/components/Views/TrendingView/TrendingView.tsx b/app/components/Views/TrendingView/TrendingView.tsx index 632bdbc4ba48..166ea2d943a6 100644 --- a/app/components/Views/TrendingView/TrendingView.tsx +++ b/app/components/Views/TrendingView/TrendingView.tsx @@ -6,8 +6,6 @@ import { createStackNavigator } from '@react-navigation/stack'; import { Box, BoxFlexDirection, - BoxAlignItems, - BoxJustifyContent, Text, TextVariant, ButtonIcon, @@ -24,9 +22,20 @@ import { lastTrendingScreenRef, updateLastTrendingScreen, } from '../../Nav/Main/MainNavigator'; +import TrendingTokensSection from './TrendingTokensSection/TrendingTokensSection'; +import { ScrollView, StyleSheet } from 'react-native'; const Stack = createStackNavigator(); +const styles = StyleSheet.create({ + scrollView: { + flex: 1, + marginTop: 48, + paddingLeft: 16, + paddingRight: 16, + }, +}); + // Wrapper component to intercept navigation const BrowserWrapper: React.FC<{ route: object }> = ({ route }) => { const navigation = useNavigation(); @@ -88,7 +97,7 @@ const TrendingFeed: React.FC = () => { return ( - + {strings('trending.title')} @@ -102,19 +111,12 @@ const TrendingFeed: React.FC = () => { - - - {strings('trending.coming_soon')} - - + + ); }; diff --git a/app/components/Views/confirmations/components/edit-amount-keyboard/edit-amount-keyboard.tsx b/app/components/Views/confirmations/components/edit-amount-keyboard/edit-amount-keyboard.tsx index 5fc622ab7760..df4db0634341 100644 --- a/app/components/Views/confirmations/components/edit-amount-keyboard/edit-amount-keyboard.tsx +++ b/app/components/Views/confirmations/components/edit-amount-keyboard/edit-amount-keyboard.tsx @@ -71,6 +71,7 @@ export function EditAmountKeyboard({ {additionalButtons.map(({ value: val, label }) => (