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 }) => (