From e3b8369b21691272ca3f9177a1600aadc900215f Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:07:29 +0800 Subject: [PATCH 1/8] fix(perps): initial qa feedbacks (#18490) ## **Description** This PR addresses the first batch of critical UX issues identified during Android testing of the Perps (perpetual futures) trading interface. The changes focus on improving the trading experience by fixing order entry, position display, and market overview issues that were preventing users from effectively trading with their full margin and understanding their positions. **Reason for changes:** 1. Users couldn't trade with 100% of their available margin due to incorrect leverage calculations 2. Position cards showed inverted PnL colors (red for profit, green for loss) causing confusion 3. Market overview was unsorted and showed misleading $0 volumes for assets with missing data **Improvements implemented:** 1. **Order Entry**: Fixed leverage calculations affecting max order size, improved slider responsiveness with real-time updates 2. **Position Cards**: - Corrected PnL colors (green for profit, red for loss) - Added live price subscriptions - Fixed decimal formatting to show exactly 2 decimals for all monetary values - Corrected "Liquidity Price" typo to "Liquidation Price" - Replaced PnL percentage with ROE (Return on Equity) display - Fixed margin display to show 2 decimals 3. **Market Overview**: Implemented volume-based sorting, fixed missing data display ($-- instead of $0), added asset icon support via RemoteImage 4. **Close Position Sheet**: - Fixed all values to display with 2 decimal formatting - Removed "(Hyperliquid USDC)" from "You'll receive" text - Fixed PnL color display with explicit sign indicators 5. **Limit Price Sheet**: Reverted unnecessary UI additions while keeping live price updates 6. Added volume filtering to out markets with no volume or $0 volume (like MATIC) These fixes are based on comprehensive testing feedback documented in https://docs.google.com/document/d/1c0TqMmeO5l65sKuCGNAl7UMeqOjbTCkni5X6j3U1LbI/edit?tab=t.0 from Aug 19, 2025 (Android device, Build 7.55). ## **Changelog** CHANGELOG entry: Fixed critical UX issues in Perps trading including incorrect leverage calculations, PnL color inversion, missing live prices, ROE display, 2-decimal formatting consistency, and improved market data display with proper volume sorting and asset icons ## **Related issues** Fixes: Internal testing issues documented in [`qa doc` ](https://docs.google.com/document/d/1c0TqMmeO5l65sKuCGNAl7UMeqOjbTCkni5X6j3U1LbI/edit?tab=t.0) ## **Manual testing steps** ```gherkin Feature: Perps Order Entry Scenario: User can trade with 100% of available margin using leverage Given user has 100 USDC available margin And user selects 10x leverage on BTC market When user taps "max" button in order form Then order size should show ~1000 USDC worth of BTC (not 100 USDC) And slider should update in real-time as user drags And order value should update without releasing slider Feature: Perps Position Display Scenario: Position card shows correct PnL colors and live prices Given user has an open BTC long position with positive PnL When user views position card on home screen Then PnL should display in green (not red) And market price should show current live price (not "--") And all prices should display with exactly 2 decimals And liquidation price label should show "Liquidation Price" (not "Liquidity Price") And ROE (Return on Equity) should display instead of PnL percentage And margin should display with 2 decimal places Feature: Perps Market Overview Scenario: Markets display sorted by volume with proper formatting Given user opens Perps markets overview screen When user views the market list Then markets should be sorted by 24h volume (highest first) And volume should display without decimals (e.g., "$123M" not "$123.4M") And markets with missing data should show "$--" (not "$0") And all token logos should have consistent 32x32 circular design ``` ## **Screenshots/Recordings** ### **Before** - Max button showed 100 USDC instead of 1000 USDC with 10x leverage - PnL showed red when positive, green when negative - Market price displayed as "--" permanently - MATIC and other markets showed "$0" volume when data was missing - Markets were not sorted by volume - Volume displayed with decimals ("$123.4M") - Position card showed PnL percentage instead of ROE - Margin and other monetary values had inconsistent decimal places - Close position sheet showed inconsistent formatting ### **After** - Max button correctly calculates leveraged position size - PnL colors corrected: green for profit, red for loss - Market price shows live updates with 2 decimal precision - Missing volume data shows "$--" for clarity - Markets sorted by 24h volume descending - Volume displays without decimals ("$123M") - Position card displays ROE (Return on Equity) with correct calculations - All monetary values consistently show 2 decimal places - Close position sheet has proper formatting and clearer copy ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --------- Co-authored-by: Claude --- .../PerpsMarketDetailsView.tsx | 31 +++-- .../PerpsMarketListView.tsx | 22 +++- .../Views/PerpsOrderView/PerpsOrderView.tsx | 4 +- .../PerpsAmountDisplay.test.tsx | 5 +- .../PerpsAmountDisplay/PerpsAmountDisplay.tsx | 12 +- .../PerpsClosePositionBottomSheet.tsx | 30 +++-- .../PerpsLeverageBottomSheet.test.tsx | 4 + .../PerpsLeverageBottomSheet.tsx | 71 ++++++----- .../PerpsLimitPriceBottomSheet.styles.ts | 16 +++ .../PerpsLimitPriceBottomSheet.test.tsx | 3 +- .../PerpsLimitPriceBottomSheet.tsx | 117 ++++++++++-------- .../PerpsMarketRowItem.styles.ts | 8 ++ .../PerpsMarketRowItem.test.tsx | 33 +++-- .../PerpsMarketRowItem/PerpsMarketRowItem.tsx | 34 ++--- .../PerpsOrderHeader/PerpsOrderHeader.tsx | 7 +- .../PerpsPositionCard.test.tsx | 96 +++++++++----- .../PerpsPositionCard/PerpsPositionCard.tsx | 73 +++++++---- .../PerpsSlider/PerpsSlider.test.tsx | 11 +- .../components/PerpsSlider/PerpsSlider.tsx | 46 +++---- .../PerpsTPSLBottomSheet.tsx | 5 +- .../UI/Perps/constants/perpsConfig.ts | 2 + .../UI/Perps/hooks/usePerpsAssetsMetadata.ts | 6 + .../Perps/hooks/usePerpsMarketStats.test.ts | 16 +-- .../UI/Perps/hooks/usePerpsMarketStats.ts | 20 ++- .../UI/Perps/hooks/usePerpsMarkets.ts | 38 +++++- .../UI/Perps/hooks/usePerpsOrderForm.test.ts | 4 +- .../UI/Perps/hooks/usePerpsOrderForm.ts | 8 +- .../UI/Perps/utils/formatUtils.test.ts | 104 +++++++++++++--- app/components/UI/Perps/utils/formatUtils.ts | 107 +++++++++++++--- .../Perps/utils/marketDataTransform.test.ts | 50 ++++---- .../UI/Perps/utils/marketDataTransform.ts | 25 ++-- locales/languages/en.json | 12 +- 32 files changed, 700 insertions(+), 320 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index 6c477d81fdf0..71faedfb04d9 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -11,7 +11,13 @@ import React, { useEffect, useRef, } from 'react'; -import { SafeAreaView, ScrollView, View, RefreshControl } from 'react-native'; +import { + SafeAreaView, + ScrollView, + View, + RefreshControl, + Linking, +} from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { strings } from '../../../../../../locales/i18n'; import Button, { @@ -53,9 +59,7 @@ import { PerpsEventProperties, PerpsEventValues, } from '../../constants/eventNames'; -import { useSelector } from 'react-redux'; -import { selectPerpsProvider } from '../../selectors/perpsController'; -import { capitalize } from '../../../../../util/general'; + import { usePerpsAccount, usePerpsConnection, @@ -104,8 +108,6 @@ const PerpsMarketDetailsView: React.FC = () => { const [activeTabId, setActiveTabId] = useState('position'); const [refreshing, setRefreshing] = useState(false); - const perpsProvider = useSelector(selectPerpsProvider); - const account = usePerpsAccount(); usePerpsConnection(); @@ -284,6 +286,12 @@ const PerpsMarketDetailsView: React.FC = () => { }); }; + const handleTradingViewPress = useCallback(() => { + Linking.openURL('https://www.tradingview.com/').catch((error) => { + console.error('Failed to open Trading View URL:', error); + }); + }, []); + // Determine if any action buttons will be visible const hasLongShortButtons = useMemo( () => !isLoadingPosition && !hasZeroBalance, @@ -387,9 +395,14 @@ const PerpsMarketDetailsView: React.FC = () => { variant={TextVariant.BodyXS} color={TextColor.Alternative} > - {strings('perps.risk_disclaimer', { - provider: capitalize(perpsProvider), - })} + {strings('perps.risk_disclaimer')}{' '} + + Trading View + diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx index 6ecbf10c3880..e081da862eea 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx @@ -160,11 +160,29 @@ const PerpsMarketListView = ({ }; const filteredMarkets = useMemo(() => { + // First filter out markets with no volume or $0 volume + const marketsWithVolume = markets.filter((market: PerpsMarketData) => { + // Check if volume exists and is not zero + if ( + !market.volume || + market.volume === '$0' || + market.volume === '$0.00' + ) { + return false; + } + // Also filter out fallback display values + if (market.volume === '$---' || market.volume === '---') { + return false; + } + return true; + }); + + // Then apply search filter if needed if (!searchQuery.trim()) { - return markets; + return marketsWithVolume; } const query = searchQuery.toLowerCase().trim(); - return markets.filter( + return marketsWithVolume.filter( (market: PerpsMarketData) => market.symbol.toLowerCase().includes(query) || market.name.toLowerCase().includes(query), diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index 764a8da3624b..453c9fa6c473 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -650,7 +650,7 @@ const PerpsOrderViewContentBase: React.FC = () => { {/* Amount Display */} { value={parseFloat(orderForm.amount || '0')} onValueChange={(value) => setAmount(Math.floor(value).toString())} minimumValue={0} - maximumValue={availableBalance} + maximumValue={availableBalance * orderForm.leverage} step={1} showPercentageLabels /> diff --git a/app/components/UI/Perps/components/PerpsAmountDisplay/PerpsAmountDisplay.test.tsx b/app/components/UI/Perps/components/PerpsAmountDisplay/PerpsAmountDisplay.test.tsx index a4672deee909..e243633ec4d5 100644 --- a/app/components/UI/Perps/components/PerpsAmountDisplay/PerpsAmountDisplay.test.tsx +++ b/app/components/UI/Perps/components/PerpsAmountDisplay/PerpsAmountDisplay.test.tsx @@ -120,6 +120,9 @@ describe('PerpsAmountDisplay', () => { render(); expect(formatPrice).toHaveBeenCalledWith('1234.56', { minimumDecimals: 0 }); - expect(formatPrice).toHaveBeenCalledWith(9876.54); + expect(formatPrice).toHaveBeenCalledWith(9876.54, { + minimumDecimals: 2, + maximumDecimals: 2, + }); }); }); diff --git a/app/components/UI/Perps/components/PerpsAmountDisplay/PerpsAmountDisplay.tsx b/app/components/UI/Perps/components/PerpsAmountDisplay/PerpsAmountDisplay.tsx index b1e29245dfac..92ea34f3e805 100644 --- a/app/components/UI/Perps/components/PerpsAmountDisplay/PerpsAmountDisplay.tsx +++ b/app/components/UI/Perps/components/PerpsAmountDisplay/PerpsAmountDisplay.tsx @@ -1,13 +1,14 @@ import React, { useEffect, useRef } from 'react'; -import { View, TouchableOpacity, Animated, Text as RNText } from 'react-native'; +import { Animated, Text as RNText, TouchableOpacity, View } from 'react-native'; +import { PerpsAmountDisplaySelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; import Text, { - TextVariant, TextColor, + TextVariant, } from '../../../../../component-library/components/Texts/Text'; import { useTheme } from '../../../../../util/theme'; import { formatPrice } from '../../utils/formatUtils'; -import { PerpsAmountDisplaySelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; import createStyles from './PerpsAmountDisplay.styles'; +import { strings } from '../../../../../../locales/i18n'; interface PerpsAmountDisplayProps { amount: string; @@ -77,11 +78,12 @@ const PerpsAmountDisplay: React.FC = ({ )} - {formatPrice(maxAmount)} max + {formatPrice(maxAmount, { minimumDecimals: 2, maximumDecimals: 2 })}{' '} + {strings('perps.order.max')} {showWarning && ( - {formatPrice(closeAmountUSD)} + {formatPrice(closeAmountUSD, { + minimumDecimals: 2, + maximumDecimals: 2, + })} = 0 ? TextColor.Success : TextColor.Error} > - {pnl >= 0 ? '+' : ''} - {formatPrice(pnl * (closePercentage / 100))} + {pnl >= 0 ? '+' : '-'} + {formatPrice(Math.abs(pnl * (closePercentage / 100)), { + minimumDecimals: 2, + maximumDecimals: 2, + })} @@ -416,7 +419,11 @@ const PerpsClosePositionBottomSheet: React.FC< variant={TextVariant.BodyMD} color={TextColor.Default} > - -{formatPrice(feeResults.totalFee)} + - + {formatPrice(feeResults.totalFee, { + minimumDecimals: 2, + maximumDecimals: 2, + })} @@ -432,7 +439,10 @@ const PerpsClosePositionBottomSheet: React.FC< variant={TextVariant.BodyLGMedium} color={TextColor.Default} > - {formatPrice(receiveAmount)} + {formatPrice(receiveAmount, { + minimumDecimals: 2, + maximumDecimals: 2, + })} diff --git a/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.test.tsx b/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.test.tsx index c63e1a171093..382694cee5cc 100644 --- a/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.test.tsx +++ b/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.test.tsx @@ -12,8 +12,12 @@ jest.mock('react-native-gesture-handler', () => ({ GestureDetector: 'View', Gesture: { Pan: jest.fn().mockReturnValue({ + onBegin: jest.fn().mockReturnThis(), onUpdate: jest.fn().mockReturnThis(), onEnd: jest.fn().mockReturnThis(), + onFinalize: jest.fn().mockReturnThis(), + withSpring: jest.fn().mockReturnThis(), + runOnJS: jest.fn().mockReturnThis(), }), Tap: jest.fn().mockReturnValue({ onEnd: jest.fn().mockReturnThis(), diff --git a/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.tsx b/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.tsx index 0e125186cea3..17c5d36d85d1 100644 --- a/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.tsx +++ b/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.tsx @@ -1,57 +1,56 @@ /* eslint-disable @metamask/design-tokens/color-no-hex */ import React, { - useRef, - useState, - useMemo, + memo, useCallback, useEffect, - memo, + useMemo, + useRef, + useState, } from 'react'; -import { View, TouchableOpacity } from 'react-native'; +import { TouchableOpacity, View } from 'react-native'; import { Gesture, GestureDetector, GestureHandlerRootView, } from 'react-native-gesture-handler'; +import LinearGradient from 'react-native-linear-gradient'; import Animated, { runOnJS, useAnimatedStyle, useSharedValue, - withSpring, } from 'react-native-reanimated'; -import LinearGradient from 'react-native-linear-gradient'; +import { strings } from '../../../../../../locales/i18n'; import BottomSheet, { BottomSheetRef, } from '../../../../../component-library/components/BottomSheets/BottomSheet'; -import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; import BottomSheetFooter from '../../../../../component-library/components/BottomSheets/BottomSheetFooter'; +import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; import { ButtonSize, ButtonVariants, } from '../../../../../component-library/components/Buttons/Button'; -import { useTheme } from '../../../../../util/theme'; -import Text, { - TextVariant, - TextColor, -} from '../../../../../component-library/components/Texts/Text'; import Icon, { - IconSize, - IconName, IconColor, + IconName, + IconSize, } from '../../../../../component-library/components/Icons/Icon'; +import Text, { + TextColor, + TextVariant, +} from '../../../../../component-library/components/Texts/Text'; import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger'; -import { formatPrice } from '../../utils/formatUtils'; -import { createStyles } from './PerpsLeverageBottomSheet.styles'; -import { strings } from '../../../../../../locales/i18n'; +import { useTheme } from '../../../../../util/theme'; import { Theme } from '../../../../../util/theme/models'; -import { PerpsMeasurementName } from '../../constants/performanceMetrics'; +import { MetaMetricsEvents } from '../../../../hooks/useMetrics'; import { PerpsEventProperties, PerpsEventValues, } from '../../constants/eventNames'; -import { MetaMetricsEvents } from '../../../../hooks/useMetrics'; -import { usePerpsScreenTracking } from '../../hooks/usePerpsScreenTracking'; +import { PerpsMeasurementName } from '../../constants/performanceMetrics'; import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; +import { usePerpsScreenTracking } from '../../hooks/usePerpsScreenTracking'; +import { formatPrice } from '../../utils/formatUtils'; +import { createStyles } from './PerpsLeverageBottomSheet.styles'; interface PerpsLeverageBottomSheetProps { isVisible: boolean; @@ -78,6 +77,8 @@ const LeverageSlider: React.FC<{ const styles = createStyles(colors); const sliderWidth = useSharedValue(0); const translateX = useSharedValue(0); + const isPressed = useSharedValue(false); + const thumbScale = useSharedValue(1); const widthRef = useRef(0); const [gradientWidth, setGradientWidth] = useState(300); @@ -114,10 +115,8 @@ const LeverageSlider: React.FC<{ if (widthRef.current > 0) { const percentage = (value - minValue) / (maxValue - minValue); const newPosition = percentage * widthRef.current; - translateX.value = withSpring(newPosition, { - damping: 15, - stiffness: 400, - }); + // Direct assignment for instant update, no spring animation + translateX.value = newPosition; } }, [value, minValue, maxValue, translateX]); @@ -126,7 +125,7 @@ const LeverageSlider: React.FC<{ })); const thumbStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: translateX.value }], + transform: [{ translateX: translateX.value }, { scale: thumbScale.value }], })); const updateValue = useCallback( @@ -138,18 +137,31 @@ const LeverageSlider: React.FC<{ ); const panGesture = Gesture.Pan() + .onBegin(() => { + isPressed.value = true; + thumbScale.value = 1.1; // Subtle scale effect, instant + }) .onUpdate((event) => { const newPosition = Math.max(0, Math.min(event.x, sliderWidth.value)); translateX.value = newPosition; + // Real-time value update during drag + const currentValue = positionToValue(newPosition, sliderWidth.value); + runOnJS(updateValue)(currentValue); }) .onEnd(() => { + isPressed.value = false; + thumbScale.value = 1; // Direct assignment, no spring const currentValue = positionToValue(translateX.value, sliderWidth.value); runOnJS(updateValue)(currentValue); + }) + .onFinalize(() => { + isPressed.value = false; + thumbScale.value = 1; // Direct assignment, no spring }); const tapGesture = Gesture.Tap().onEnd((event) => { const newPosition = Math.max(0, Math.min(event.x, sliderWidth.value)); - translateX.value = withSpring(newPosition, { damping: 15, stiffness: 400 }); + translateX.value = newPosition; // Direct assignment for instant response const newValue = positionToValue(newPosition, sliderWidth.value); runOnJS(updateValue)(newValue); }); @@ -210,7 +222,10 @@ const LeverageSlider: React.FC<{ ))} {/* Thumb */} - + diff --git a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.styles.ts b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.styles.ts index 404ccf949a36..10e551f4dbb9 100644 --- a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.styles.ts +++ b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.styles.ts @@ -73,4 +73,20 @@ export const createStyles = (colors: Theme['colors']) => paddingHorizontal: 16, paddingBottom: 24, }, + priceDifferenceContainer: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + marginTop: 8, + marginBottom: 16, + }, + priceDifferenceLabel: { + fontSize: 14, + color: colors.text.alternative, + marginRight: 8, + }, + priceDifferenceValue: { + fontSize: 14, + fontWeight: '600', + }, }); diff --git a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.test.tsx b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.test.tsx index 9a94aa8cd0c7..52e68505cee6 100644 --- a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.test.tsx +++ b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.test.tsx @@ -342,7 +342,8 @@ describe('PerpsLimitPriceBottomSheet', () => { render(); // Assert - expect(screen.getAllByText('3100')).toHaveLength(2); // Initial limit price + keypad value + expect(screen.getByText('$3100.00')).toBeOnTheScreen(); // Formatted limit price display + expect(screen.getByText('3100')).toBeOnTheScreen(); // Keypad value }); it('renders quick action buttons', () => { diff --git a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.tsx b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.tsx index 5d701f6f0c39..4588bf8c6ad7 100644 --- a/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.tsx +++ b/app/components/UI/Perps/components/PerpsLimitPriceBottomSheet/PerpsLimitPriceBottomSheet.tsx @@ -19,6 +19,7 @@ import { formatPrice } from '../../utils/formatUtils'; import { createStyles } from './PerpsLimitPriceBottomSheet.styles'; import { usePerpsLivePrices } from '../../hooks/stream'; import { ORDER_BOOK_SPREAD } from '../../constants/hyperLiquidConfig'; +import { PERPS_CONSTANTS } from '../../constants/perpsConfig'; interface PerpsLimitPriceBottomSheetProps { isVisible: boolean; @@ -52,48 +53,26 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ }); const currentPriceData = priceData[asset]; - // Store price data in state to control when it updates - const [priceSnapshot, setPriceSnapshot] = useState(() => ({ - currentPrice: passedCurrentPrice, - markPrice: passedCurrentPrice, - bestBid: passedCurrentPrice - ? passedCurrentPrice * ORDER_BOOK_SPREAD.DEFAULT_BID_MULTIPLIER - : undefined, - bestAsk: passedCurrentPrice - ? passedCurrentPrice * ORDER_BOOK_SPREAD.DEFAULT_ASK_MULTIPLIER - : undefined, - })); + // Use live data directly - updates automatically every 1000ms + const currentPrice = currentPriceData?.price + ? parseFloat(currentPriceData.price) + : passedCurrentPrice; - // Update price snapshot only when bottom sheet opens - useEffect(() => { - if (isVisible) { - const current = currentPriceData?.price - ? parseFloat(currentPriceData.price) - : passedCurrentPrice; - const mark = currentPriceData?.markPrice - ? parseFloat(currentPriceData.markPrice) - : current; - const bid = currentPriceData?.bestBid - ? parseFloat(currentPriceData.bestBid) - : current - ? current * ORDER_BOOK_SPREAD.DEFAULT_BID_MULTIPLIER - : undefined; - const ask = currentPriceData?.bestAsk - ? parseFloat(currentPriceData.bestAsk) - : current - ? current * ORDER_BOOK_SPREAD.DEFAULT_ASK_MULTIPLIER - : undefined; + const markPrice = currentPriceData?.markPrice + ? parseFloat(currentPriceData.markPrice) + : currentPrice; - setPriceSnapshot({ - currentPrice: current, - markPrice: mark, - bestBid: bid, - bestAsk: ask, - }); - } - }, [isVisible]); // eslint-disable-line react-hooks/exhaustive-deps + const bestBid = currentPriceData?.bestBid + ? parseFloat(currentPriceData.bestBid) + : currentPrice + ? currentPrice * ORDER_BOOK_SPREAD.DEFAULT_BID_MULTIPLIER + : undefined; - const { currentPrice, markPrice, bestBid, bestAsk } = priceSnapshot; + const bestAsk = currentPriceData?.bestAsk + ? parseFloat(currentPriceData.bestAsk) + : currentPrice + ? currentPrice * ORDER_BOOK_SPREAD.DEFAULT_ASK_MULTIPLIER + : undefined; useEffect(() => { if (isVisible) { @@ -102,7 +81,9 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ }, [isVisible]); const handleConfirm = () => { - onConfirm(limitPrice); + // Remove any formatting (commas, dollar signs) before passing the value + const cleanPrice = limitPrice.replace(/[$,]/g, ''); + onConfirm(cleanPrice); onClose(); }; @@ -115,11 +96,8 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ const calculatePriceForPercentage = useCallback( (percentage: number) => { - // Use the current limit price if set, otherwise use market price - const basePrice = - limitPrice && parseFloat(limitPrice) > 0 - ? parseFloat(limitPrice) - : currentPrice; + // Use the current market price (not limit price) for percentage calculations + const basePrice = currentPrice; if (!basePrice || basePrice === 0) { return ''; @@ -127,19 +105,26 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ const multiplier = 1 + percentage / 100; const calculatedPrice = basePrice * multiplier; - return calculatedPrice.toFixed(2); + return formatPrice(calculatedPrice, { + minimumDecimals: 2, + maximumDecimals: 2, + }); }, - [currentPrice, limitPrice], + [currentPrice], ); const handleMidPrice = useCallback(() => { // Mid price is the average between ask and bid if (bestAsk && bestBid) { const midPrice = (bestAsk + bestBid) / 2; - setLimitPrice(midPrice.toFixed(2)); + setLimitPrice( + formatPrice(midPrice, { minimumDecimals: 2, maximumDecimals: 2 }), + ); } else if (currentPrice) { // Fallback to current price if we don't have bid/ask - setLimitPrice(currentPrice.toFixed(2)); + setLimitPrice( + formatPrice(currentPrice, { minimumDecimals: 2, maximumDecimals: 2 }), + ); } }, [currentPrice, bestAsk, bestBid]); @@ -147,7 +132,9 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ // Use mark price if available, otherwise fall back to current price const priceToUse = markPrice || currentPrice; if (priceToUse) { - setLimitPrice(priceToUse.toFixed(2)); + setLimitPrice( + formatPrice(priceToUse, { minimumDecimals: 2, maximumDecimals: 2 }), + ); } }, [currentPrice, markPrice]); @@ -158,7 +145,9 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ size: ButtonSize.Lg, onPress: handleConfirm, disabled: - !limitPrice || limitPrice === '0' || parseFloat(limitPrice) <= 0, + !limitPrice || + limitPrice === '0' || + parseFloat(limitPrice.replace(/[$,]/g, '')) <= 0, }, ]; @@ -196,7 +185,12 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ {strings('perps.order.limit_price_modal.current_price')} ({asset}) - {currentPrice ? formatPrice(currentPrice) : '$---'} + {currentPrice + ? formatPrice(currentPrice, { + minimumDecimals: 2, + maximumDecimals: 2, + }) + : PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY} @@ -204,7 +198,12 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ {strings('perps.order.limit_price_modal.ask_price')} - {displayAskPrice ? formatPrice(displayAskPrice) : '$---'} + {displayAskPrice + ? formatPrice(displayAskPrice, { + minimumDecimals: 2, + maximumDecimals: 2, + }) + : PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY} @@ -212,7 +211,12 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ {strings('perps.order.limit_price_modal.bid_price')} - {displayBidPrice ? formatPrice(displayBidPrice) : '$---'} + {displayBidPrice + ? formatPrice(displayBidPrice, { + minimumDecimals: 2, + maximumDecimals: 2, + }) + : PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY} @@ -229,7 +233,10 @@ const PerpsLimitPriceBottomSheet: React.FC = ({ > {!limitPrice || limitPrice === '0' ? strings('perps.order.limit_price') - : limitPrice} + : formatPrice(limitPrice, { + minimumDecimals: 2, + maximumDecimals: 2, + })} USD diff --git a/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.styles.ts b/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.styles.ts index 97ca42eb44f3..92b584cc5c98 100644 --- a/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.styles.ts +++ b/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.styles.ts @@ -17,6 +17,14 @@ const styleSheet = (params: { theme: Theme }) => { width: 32, height: 32, marginRight: 16, + borderRadius: 16, + overflow: 'hidden', + backgroundColor: colors.background.alternative, + }, + tokenIcon: { + width: 32, + height: 32, + borderRadius: 16, }, leftSection: { flexDirection: 'row', diff --git a/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.test.tsx b/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.test.tsx index 68bcedaebd6d..71e58dbd0414 100644 --- a/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.test.tsx +++ b/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.test.tsx @@ -46,6 +46,12 @@ jest.mock('../../../../Base/RemoteImage', () => { }; }); +// Mock react-redux for AvatarToken component +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(() => false), // Mock selectIsIpfsGatewayEnabled to return false +})); + describe('PerpsMarketRowItem', () => { const mockMarketData: PerpsMarketData = { symbol: 'BTC', @@ -339,8 +345,8 @@ describe('PerpsMarketRowItem', () => { // Should show the new live price expect(screen.getByText('$55,000.00')).toBeOnTheScreen(); - // Should show updated volume - expect(screen.getByText('$3.0B')).toBeOnTheScreen(); + // Should show updated volume (2 decimals with formatVolume) + expect(screen.getByText('$3.00B')).toBeOnTheScreen(); }); it('does not update when live price matches current price', () => { @@ -398,39 +404,41 @@ describe('PerpsMarketRowItem', () => { render(); - expect(screen.getByText('$0.00')).toBeOnTheScreen(); + // Price and volume both show $0.00, so check for multiple instances + const zeroElements = screen.getAllByText('$0.00'); + expect(zeroElements.length).toBeGreaterThanOrEqual(1); }); it('formats different volume ranges correctly', () => { - // Test billions + // Test billions (2 decimals with formatVolume) mockUsePerpsLivePrices.mockReturnValue({ BTC: { price: '50000', volume24h: 5500000000 }, }); const { rerender } = render( , ); - expect(screen.getByText('$5.5B')).toBeOnTheScreen(); + expect(screen.getByText('$5.50B')).toBeOnTheScreen(); // B shows 2 decimals - // Test millions + // Test millions (2 decimals with formatVolume) mockUsePerpsLivePrices.mockReturnValue({ BTC: { price: '50000', volume24h: 750000000 }, }); rerender(); - expect(screen.getByText('$750.0M')).toBeOnTheScreen(); + expect(screen.getByText('$750.00M')).toBeOnTheScreen(); // M shows 2 decimals - // Test thousands + // Test thousands (0 decimals with formatVolume) mockUsePerpsLivePrices.mockReturnValue({ BTC: { price: '50000', volume24h: 50000 }, }); rerender(); - expect(screen.getByText('$50.0K')).toBeOnTheScreen(); + expect(screen.getByText('$50K')).toBeOnTheScreen(); // K shows no decimals - // Test small values + // Test small values (2 decimals with formatVolume) mockUsePerpsLivePrices.mockReturnValue({ BTC: { price: '50000', volume24h: 123.45 }, }); rerender(); - expect(screen.getByText('$123.45')).toBeOnTheScreen(); + expect(screen.getByText('$123.45')).toBeOnTheScreen(); // Shows 2 decimals }); it('handles missing live price fields gracefully', () => { @@ -513,7 +521,8 @@ describe('PerpsMarketRowItem', () => { render(); - expect(screen.getByText('$0.0001')).toBeOnTheScreen(); + // With 2 decimal enforcement, very small prices show as $0.00 + expect(screen.getByText('$0.00')).toBeOnTheScreen(); }); it('handles very large price values', () => { diff --git a/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.tsx b/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.tsx index dbd7add4634b..5af1bbd5aec2 100644 --- a/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.tsx +++ b/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.tsx @@ -19,8 +19,10 @@ import { formatPrice, formatPercentage, formatPnl, + formatVolume, } from '../../utils/formatUtils'; import { getPerpsMarketRowItemSelector } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; +import { PERPS_CONSTANTS } from '../../constants/perpsConfig'; const PerpsMarketRowItem = ({ market, onPress }: PerpsMarketRowItemProps) => { const { styles } = useStyles(styleSheet, {}); @@ -39,9 +41,12 @@ const PerpsMarketRowItem = ({ market, onPress }: PerpsMarketRowItemProps) => { return market; } - // Parse and format the price + // Parse and format the price with exactly 2 decimals for consistency const currentPrice = parseFloat(livePrice.price); - const formattedPrice = formatPrice(currentPrice); + const formattedPrice = formatPrice(currentPrice, { + minimumDecimals: 2, + maximumDecimals: 2, + }); // Only update if price actually changed if (formattedPrice === market.price) { @@ -70,17 +75,19 @@ const PerpsMarketRowItem = ({ market, onPress }: PerpsMarketRowItemProps) => { } // Update volume if available - if (livePrice.volume24h) { + if (livePrice.volume24h !== undefined) { const volume = livePrice.volume24h; - if (volume >= 1e9) { - updatedMarket.volume = `$${(volume / 1e9).toFixed(1)}B`; - } else if (volume >= 1e6) { - updatedMarket.volume = `$${(volume / 1e6).toFixed(1)}M`; - } else if (volume >= 1e3) { - updatedMarket.volume = `$${(volume / 1e3).toFixed(1)}K`; + + // Use formatVolume with auto-determined decimals based on magnitude + if (volume > 0) { + updatedMarket.volume = formatVolume(volume); } else { - updatedMarket.volume = `$${volume.toFixed(2)}`; + // Only show $0 if volume is truly 0 + updatedMarket.volume = '$0.00'; } + } else if (!market.volume || market.volume === '$0') { + // Fallback: ensure volume field always has a value + updatedMarket.volume = PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY; } return updatedMarket; @@ -97,16 +104,15 @@ const PerpsMarketRowItem = ({ market, onPress }: PerpsMarketRowItemProps) => { {assetUrl ? ( - + ) : ( )} diff --git a/app/components/UI/Perps/components/PerpsOrderHeader/PerpsOrderHeader.tsx b/app/components/UI/Perps/components/PerpsOrderHeader/PerpsOrderHeader.tsx index df2085f387d7..ec2e41f06a1a 100644 --- a/app/components/UI/Perps/components/PerpsOrderHeader/PerpsOrderHeader.tsx +++ b/app/components/UI/Perps/components/PerpsOrderHeader/PerpsOrderHeader.tsx @@ -19,8 +19,7 @@ import { HYPERLIQUID_ASSET_ICONS_BASE_URL } from '../../constants/hyperLiquidCon import type { OrderType } from '../../controllers/types'; import { PerpsOrderHeaderSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; import { createStyles } from './PerpsOrderHeader.styles'; - -const FALLBACK_PRICE_DISPLAY = '$---'; +import { PERPS_CONSTANTS } from '../../constants/perpsConfig'; interface PerpsOrderHeaderProps { asset: string; @@ -68,14 +67,14 @@ const PerpsOrderHeader: React.FC = ({ const formattedPrice = useMemo(() => { // Handle invalid or edge case values if (!price || price <= 0 || !Number.isFinite(price)) { - return FALLBACK_PRICE_DISPLAY; + return PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY; } try { return formatPrice(price); } catch { // Fallback if formatPrice throws - return FALLBACK_PRICE_DISPLAY; + return PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY; } }, [price]); diff --git a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.test.tsx b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.test.tsx index 4cf969939d17..d3baf45ba9e3 100644 --- a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.test.tsx +++ b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.test.tsx @@ -12,6 +12,11 @@ jest.mock('@react-navigation/native', () => ({ useFocusEffect: jest.fn(), })); +// Mock i18n +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key) => key), +})); + const mockUseTheme = jest.fn(); jest.mock('../../../../../util/theme', () => ({ useTheme: mockUseTheme, @@ -29,6 +34,28 @@ jest.mock('../../hooks/usePerpsAssetsMetadata', () => ({ }), })); +// Mock stream provider +jest.mock('../../providers/PerpsStreamManager', () => ({ + usePerpsStream: jest.fn(() => ({ + subscribeToPrices: jest.fn(() => jest.fn()), + subscribeToPositions: jest.fn(() => jest.fn()), + subscribeToAccount: jest.fn(() => jest.fn()), + subscribeToOrders: jest.fn(() => jest.fn()), + subscribeToFills: jest.fn(() => jest.fn()), + connect: jest.fn(), + disconnect: jest.fn(), + isConnected: false, + })), + PerpsStreamProvider: ({ children }: { children: React.ReactNode }) => + children, +})); + +// Mock stream hooks +jest.mock('../../hooks/stream', () => ({ + usePerpsLivePrices: jest.fn(() => ({})), + usePerpsLivePositions: jest.fn(() => ({})), +})); + // Mock the new hooks from ../../hooks jest.mock('../../hooks', () => ({ usePerpsPositions: jest.fn().mockReturnValue({ @@ -152,22 +179,32 @@ describe('PerpsPositionCard', () => { describe('Component Rendering', () => { it('renders position card with all sections', () => { - // Act - render(); + // Act - Render expanded to show all sections + render(); // Assert - Header section expect(screen.getByText(/10x\s+long/)).toBeOnTheScreen(); expect(screen.getByText('2.50 ETH')).toBeOnTheScreen(); expect(screen.getByText('$5,000.00')).toBeOnTheScreen(); - // Assert - Body section - expect(screen.getByText('Entry Price')).toBeOnTheScreen(); + // Assert - Body section - using string keys since strings() mock returns keys + expect( + screen.getByText('perps.position.card.entry_price'), + ).toBeOnTheScreen(); expect(screen.getByText('$2,000.00')).toBeOnTheScreen(); - expect(screen.getByText('Market Price')).toBeOnTheScreen(); - expect(screen.getByText('Liquidity Price')).toBeOnTheScreen(); - expect(screen.getByText('Take Profit')).toBeOnTheScreen(); - expect(screen.getByText('Stop Loss')).toBeOnTheScreen(); - expect(screen.getByText('Margin')).toBeOnTheScreen(); + expect( + screen.getByText('perps.position.card.market_price'), + ).toBeOnTheScreen(); + expect( + screen.getByText('perps.position.card.liquidation_price'), + ).toBeOnTheScreen(); + expect( + screen.getByText('perps.position.card.take_profit'), + ).toBeOnTheScreen(); + expect( + screen.getByText('perps.position.card.stop_loss'), + ).toBeOnTheScreen(); + expect(screen.getByText('perps.position.card.margin')).toBeOnTheScreen(); expect(screen.getByText('$500.00')).toBeOnTheScreen(); // Assert - Footer section @@ -198,22 +235,22 @@ describe('PerpsPositionCard', () => { // Act render(); - // Assert - expect(screen.getByText(/\+\$250\.00.*\+5\.00%/)).toBeOnTheScreen(); + // Assert - ROE is 12.5 * 100 = 1250% + expect(screen.getByText(/\+\$250\.00.*\+1250\.0%/)).toBeOnTheScreen(); }); it('handles missing PnL percentage data', () => { - // Arrange - const { calculatePnLPercentageFromUnrealized } = jest.requireMock( - '../../utils/pnlCalculations', - ); - calculatePnLPercentageFromUnrealized.mockReturnValueOnce(undefined); + // Arrange - Set returnOnEquity to empty string to test fallback + const positionWithoutROE = { + ...mockPosition, + returnOnEquity: '', // Use empty string instead of undefined + }; // Act - render(); + render(); - // Assert - expect(screen.getByText(/\+\$250\.00.*0\.00%/)).toBeOnTheScreen(); + // Assert - Should show 0% when ROE is missing + expect(screen.getByText(/\+\$250\.00.*\+0\.0%/)).toBeOnTheScreen(); }); it('handles missing liquidation price', () => { @@ -380,6 +417,7 @@ describe('PerpsPositionCard', () => { const positionWithZeroPnl = { ...mockPosition, unrealizedPnl: '0.00', + returnOnEquity: '0', }; const { calculatePnLPercentageFromUnrealized } = jest.requireMock( '../../utils/pnlCalculations', @@ -389,8 +427,8 @@ describe('PerpsPositionCard', () => { // Act render(); - // Assert - expect(screen.getByText(/\$0\.00.*\+0\.00%/)).toBeOnTheScreen(); + // Assert - ROE is shown as 0.0% (not 0.00%) + expect(screen.getByText(/\$0\.00.*\+0\.0%/)).toBeOnTheScreen(); }); it('handles position with empty liquidation price', () => { @@ -408,17 +446,17 @@ describe('PerpsPositionCard', () => { }); it('renders all body items in correct order', () => { - // Act - render(); + // Act - Render with expanded=true to show body items + render(); // Assert - Check that all 6 body items are present const bodyLabels = [ - 'Entry Price', - 'Market Price', - 'Liquidity Price', - 'Take Profit', - 'Stop Loss', - 'Margin', + 'perps.position.card.entry_price', + 'perps.position.card.market_price', + 'perps.position.card.liquidation_price', + 'perps.position.card.take_profit', + 'perps.position.card.stop_loss', + 'perps.position.card.margin', ]; bodyLabels.forEach((label) => { diff --git a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx index 2b9966c913cc..707032330186 100644 --- a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx +++ b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx @@ -24,21 +24,21 @@ import type { PriceUpdate, } from '../../controllers/types'; import { - formatPercentage, formatPnl, formatPrice, formatPositionSize, } from '../../utils/formatUtils'; -import { calculatePnLPercentageFromUnrealized } from '../../utils/pnlCalculations'; import styleSheet from './PerpsPositionCard.styles'; import { PerpsPositionCardSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; import { usePerpsAssetMetadata } from '../../hooks/usePerpsAssetsMetadata'; +import { PERPS_CONSTANTS } from '../../constants/perpsConfig'; import RemoteImage from '../../../../Base/RemoteImage'; import { usePerpsMarkets, usePerpsTPSLUpdate, usePerpsClosePosition, } from '../../hooks'; +import { usePerpsLivePrices } from '../../hooks/stream'; import PerpsTPSLBottomSheet from '../PerpsTPSLBottomSheet'; import PerpsClosePositionBottomSheet from '../PerpsClosePositionBottomSheet'; @@ -57,12 +57,22 @@ const PerpsPositionCard: React.FC = ({ showIcon = false, // Default to not showing icon rightAccessory, onPositionUpdate, - priceData, + priceData: externalPriceData, }) => { const { styles } = useStyles(styleSheet, {}); const navigation = useNavigation>(); const { assetUrl } = usePerpsAssetMetadata(position.coin); + // Subscribe to live prices at the leaf level to avoid re-rendering parent components + // Only subscribe when expanded (detailed view) to optimize performance + const livePrices = usePerpsLivePrices({ + symbols: expanded ? [position.coin] : [], + throttleMs: 1000, // Update every second + }); + + // Use external price data if provided, otherwise use live prices + const priceData = externalPriceData || livePrices[position.coin]; + const [isTPSLVisible, setIsTPSLVisible] = useState(false); const [isClosePositionVisible, setIsClosePositionVisible] = useState(false); const [selectedPosition, setSelectedPosition] = useState( @@ -127,14 +137,11 @@ const PerpsPositionCard: React.FC = ({ }; const pnlNum = parseFloat(position.unrealizedPnl); - const pnlPercentage = calculatePnLPercentageFromUnrealized({ - unrealizedPnl: pnlNum, - entryPrice: parseFloat(position.entryPrice), - size: parseFloat(position.size), - }); - const isPositive24h = - position.cumulativeFunding.sinceChange && - parseFloat(position.cumulativeFunding.sinceChange) >= 0; + + // ROE is always stored as a decimal (e.g., 0.171 for 17.1%) + // Convert to percentage for display + const roeValue = parseFloat(position.returnOnEquity || '0'); + const roe = isNaN(roeValue) ? 0 : roeValue * 100; const handleEditTPSL = () => { setSelectedPosition(position); @@ -191,15 +198,19 @@ const PerpsPositionCard: React.FC = ({ - {formatPrice(position.positionValue)} + {formatPrice(position.positionValue, { + minimumDecimals: 2, + maximumDecimals: 2, + })} = 0 ? TextColor.Success : TextColor.Error} > - {formatPnl(pnlNum)} ({formatPercentage(pnlPercentage)}) + {formatPnl(pnlNum)} ({roe >= 0 ? '+' : ''} + {roe.toFixed(1)}%) @@ -225,7 +236,10 @@ const PerpsPositionCard: React.FC = ({ variant={TextVariant.BodySMMedium} color={TextColor.Default} > - {formatPrice(position.entryPrice)} + {formatPrice(position.entryPrice, { + minimumDecimals: 2, + maximumDecimals: 2, + })} @@ -239,7 +253,12 @@ const PerpsPositionCard: React.FC = ({ variant={TextVariant.BodySMMedium} color={TextColor.Default} > - {priceData?.price ? formatPrice(priceData.price) : ''} + {priceData?.price + ? formatPrice(priceData.price, { + minimumDecimals: 2, + maximumDecimals: 2, + }) + : PERPS_CONSTANTS.FALLBACK_DATA_DISPLAY} @@ -247,14 +266,17 @@ const PerpsPositionCard: React.FC = ({ variant={TextVariant.BodyXS} color={TextColor.Alternative} > - {strings('perps.position.card.liquidity_price')} + {strings('perps.position.card.liquidation_price')} {position.liquidationPrice - ? formatPrice(position.liquidationPrice) + ? formatPrice(position.liquidationPrice, { + minimumDecimals: 2, + maximumDecimals: 2, + }) : 'N/A'} @@ -273,7 +295,10 @@ const PerpsPositionCard: React.FC = ({ color={TextColor.Default} > {position.takeProfitPrice - ? formatPrice(position.takeProfitPrice) + ? formatPrice(position.takeProfitPrice, { + minimumDecimals: 2, + maximumDecimals: 2, + }) : strings('perps.position.card.not_set')} @@ -289,7 +314,10 @@ const PerpsPositionCard: React.FC = ({ color={TextColor.Default} > {position.stopLossPrice - ? formatPrice(position.stopLossPrice) + ? formatPrice(position.stopLossPrice, { + minimumDecimals: 2, + maximumDecimals: 2, + }) : strings('perps.position.card.not_set')} @@ -304,7 +332,10 @@ const PerpsPositionCard: React.FC = ({ variant={TextVariant.BodySMMedium} color={TextColor.Default} > - {formatPrice(position.marginUsed)} + {formatPrice(position.marginUsed, { + minimumDecimals: 2, + maximumDecimals: 2, + })} diff --git a/app/components/UI/Perps/components/PerpsSlider/PerpsSlider.test.tsx b/app/components/UI/Perps/components/PerpsSlider/PerpsSlider.test.tsx index 35c78ab3b71d..83616f49c7e2 100644 --- a/app/components/UI/Perps/components/PerpsSlider/PerpsSlider.test.tsx +++ b/app/components/UI/Perps/components/PerpsSlider/PerpsSlider.test.tsx @@ -35,8 +35,12 @@ jest.mock('react-native-gesture-handler', () => ({ Gesture: { Pan: jest.fn().mockReturnValue({ enabled: jest.fn().mockReturnThis(), + onBegin: jest.fn().mockReturnThis(), onUpdate: jest.fn().mockReturnThis(), onEnd: jest.fn().mockReturnThis(), + onFinalize: jest.fn().mockReturnThis(), + withSpring: jest.fn().mockReturnThis(), + runOnJS: jest.fn().mockReturnThis(), }), Tap: jest.fn().mockReturnValue({ enabled: jest.fn().mockReturnThis(), @@ -385,12 +389,7 @@ describe('PerpsSlider', () => { it('handles custom spring configuration', () => { // Act - render( - , - ); + render(); // Assert - Component should render without crashing expect(screen.getByText('50%')).toBeOnTheScreen(); diff --git a/app/components/UI/Perps/components/PerpsSlider/PerpsSlider.tsx b/app/components/UI/Perps/components/PerpsSlider/PerpsSlider.tsx index 850c4412623b..a274f3290e43 100644 --- a/app/components/UI/Perps/components/PerpsSlider/PerpsSlider.tsx +++ b/app/components/UI/Perps/components/PerpsSlider/PerpsSlider.tsx @@ -11,13 +11,12 @@ import Animated, { runOnJS, useAnimatedStyle, useSharedValue, - withSpring, } from 'react-native-reanimated'; +import LinearGradient from 'react-native-linear-gradient'; import Text from '../../../../../component-library/components/Texts/Text'; import { useStyles } from '../../../../../component-library/hooks'; import styleSheet from './PerpsSlider.styles'; -import LinearGradient from 'react-native-linear-gradient'; // Only configure reanimated logger in non-test environments if ( @@ -40,10 +39,6 @@ interface PerpsSliderProps { disabled?: boolean; progressColor?: 'default' | 'gradient'; quickValues?: number[]; - springConfig?: { - damping?: number; - stiffness?: number; - }; } const PerpsSlider: React.FC = ({ @@ -56,16 +51,14 @@ const PerpsSlider: React.FC = ({ disabled = false, progressColor = 'default', quickValues, - springConfig = { - damping: 15, - stiffness: 400, - }, }) => { const { styles } = useStyles(styleSheet, {}); // Shared values for animations const sliderWidth = useSharedValue(0); const translateX = useSharedValue(0); + const isPressed = useSharedValue(false); + const thumbScale = useSharedValue(1); // Convert position to value const positionToValue = useCallback( @@ -111,12 +104,10 @@ const PerpsSlider: React.FC = ({ const range = maximumValue - minimumValue; const percentage = range === 0 ? 0 : (value - minimumValue) / range; const newPosition = percentage * widthRef.current; - translateX.value = withSpring(newPosition, { - damping: springConfig.damping, - stiffness: springConfig.stiffness, - }); + // Direct assignment for instant update, no spring animation + translateX.value = newPosition; } - }, [value, minimumValue, maximumValue, translateX, widthRef, springConfig]); + }, [value, minimumValue, maximumValue, translateX, widthRef]); // Animated styles const progressStyle = useAnimatedStyle(() => ({ @@ -124,7 +115,7 @@ const PerpsSlider: React.FC = ({ })); const thumbStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: translateX.value }], + transform: [{ translateX: translateX.value }, { scale: thumbScale.value }], })); // JS callback wrapper @@ -138,13 +129,26 @@ const PerpsSlider: React.FC = ({ // Pan gesture for dragging const panGesture = Gesture.Pan() .enabled(!disabled) + .onBegin(() => { + isPressed.value = true; + thumbScale.value = 1.1; // Subtle scale effect, instant + }) .onUpdate((event) => { const newPosition = Math.max(0, Math.min(event.x, sliderWidth.value)); translateX.value = newPosition; + // Real-time value update during drag + const currentValue = positionToValue(newPosition, sliderWidth.value); + runOnJS(updateValue)(currentValue); }) .onEnd(() => { + isPressed.value = false; + thumbScale.value = 1; // Direct assignment, no spring const currentValue = positionToValue(translateX.value, sliderWidth.value); runOnJS(updateValue)(currentValue); + }) + .onFinalize(() => { + isPressed.value = false; + thumbScale.value = 1; // Direct assignment, no spring }); // Tap gesture for clicking on track @@ -152,10 +156,7 @@ const PerpsSlider: React.FC = ({ .enabled(!disabled) .onEnd((event) => { const newPosition = Math.max(0, Math.min(event.x, sliderWidth.value)); - translateX.value = withSpring(newPosition, { - damping: springConfig.damping, - stiffness: springConfig.stiffness, - }); + translateX.value = newPosition; // Direct assignment for instant response const newValue = positionToValue(newPosition, sliderWidth.value); runOnJS(updateValue)(newValue); }); @@ -194,7 +195,10 @@ const PerpsSlider: React.FC = ({ ) : ( )} - + diff --git a/app/components/UI/Perps/components/PerpsTPSLBottomSheet/PerpsTPSLBottomSheet.tsx b/app/components/UI/Perps/components/PerpsTPSLBottomSheet/PerpsTPSLBottomSheet.tsx index cdbcc1ad70b7..a3909b571552 100644 --- a/app/components/UI/Perps/components/PerpsTPSLBottomSheet/PerpsTPSLBottomSheet.tsx +++ b/app/components/UI/Perps/components/PerpsTPSLBottomSheet/PerpsTPSLBottomSheet.tsx @@ -27,6 +27,7 @@ import { createStyles } from './PerpsTPSLBottomSheet.styles'; import { usePerpsPerformance } from '../../hooks'; import { usePerpsLivePrices } from '../../hooks/stream'; import { PerpsMeasurementName } from '../../constants/performanceMetrics'; +import { PERPS_CONSTANTS } from '../../constants/perpsConfig'; import { PerpsEventProperties, PerpsEventValues, @@ -477,7 +478,9 @@ const PerpsTPSLBottomSheet: React.FC = ({ {strings('perps.tpsl.current_price')} - {currentPrice ? formatPrice(currentPrice) : '$---'} + {currentPrice + ? formatPrice(currentPrice) + : PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY} {position?.liquidationPrice && ( diff --git a/app/components/UI/Perps/constants/perpsConfig.ts b/app/components/UI/Perps/constants/perpsConfig.ts index be2fc6d9802b..c91a4e24f814 100644 --- a/app/components/UI/Perps/constants/perpsConfig.ts +++ b/app/components/UI/Perps/constants/perpsConfig.ts @@ -7,6 +7,8 @@ export const PERPS_CONSTANTS = { WEBSOCKET_CLEANUP_DELAY: 1000, // 1 second DEFAULT_ASSET_PREVIEW_LIMIT: 5, DEFAULT_MAX_LEVERAGE: 3 as number, // Default fallback max leverage when market data is unavailable - conservative default + FALLBACK_PRICE_DISPLAY: '$---', // Display when price data is unavailable + FALLBACK_DATA_DISPLAY: '--', // Display when non-price data is unavailable } as const; /** diff --git a/app/components/UI/Perps/hooks/usePerpsAssetsMetadata.ts b/app/components/UI/Perps/hooks/usePerpsAssetsMetadata.ts index c201c73067d0..2ce4b9741616 100644 --- a/app/components/UI/Perps/hooks/usePerpsAssetsMetadata.ts +++ b/app/components/UI/Perps/hooks/usePerpsAssetsMetadata.ts @@ -1,12 +1,18 @@ import { useEffect, useState, useMemo } from 'react'; +// import { useTheme } from '../../../../util/theme'; // Available for future dark mode support import { HYPERLIQUID_ASSET_ICONS_BASE_URL } from '../constants/hyperLiquidConfig'; export const usePerpsAssetMetadata = (assetSymbol: string | undefined) => { const [assetUrl, setAssetUrl] = useState(''); const [error, setError] = useState(null); + // Note: useTheme() is available here for future dark mode logo support + // const { colors } = useTheme(); const url = useMemo(() => { if (!assetSymbol) return ''; + // For now, we use the same SVG for both themes + // In the future, we could add logic here to use different URLs based on theme + // e.g., `${base}${symbol}_${isDarkMode ? 'dark' : 'light'}.svg` return `${HYPERLIQUID_ASSET_ICONS_BASE_URL}${assetSymbol.toUpperCase()}.svg`; }, [assetSymbol]); diff --git a/app/components/UI/Perps/hooks/usePerpsMarketStats.test.ts b/app/components/UI/Perps/hooks/usePerpsMarketStats.test.ts index 3bd01656ddba..9ed219346c7e 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarketStats.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarketStats.test.ts @@ -22,10 +22,10 @@ jest.mock('../utils/formatUtils', () => ({ maximumFractionDigits: 2, })}`, formatLargeNumber: (num: number) => { - if (num >= 1e12) return `$${(num / 1e12).toFixed(2)}T`; - if (num >= 1e9) return `$${(num / 1e9).toFixed(2)}B`; - if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`; - return `$${num.toFixed(2)}`; + if (num >= 1e12) return `${Math.round(num / 1e12)}T`; + if (num >= 1e9) return `${Math.round(num / 1e9)}B`; + if (num >= 1e6) return `${Math.round(num / 1e6)}M`; + return num.toFixed(2); }, })); @@ -100,8 +100,8 @@ describe('usePerpsMarketStats', () => { expect(result.current.currentPrice).toBe(45000); expect(result.current.high24h).toBe('$46,000.00'); expect(result.current.low24h).toBe('$43,500.00'); - expect(result.current.volume24h).toBe('$1.23B'); - expect(result.current.openInterest).toBe('$987.65M'); + expect(result.current.volume24h).toBe('$1B'); // No decimals in formatVolume + expect(result.current.openInterest).toBe('$988M'); // No decimals in formatLargeNumber expect(result.current.fundingRate).toBe('1.0000%'); expect(result.current.isLoading).toBe(false); }); @@ -165,8 +165,8 @@ describe('usePerpsMarketStats', () => { const { result } = renderHook(() => usePerpsMarketStats('BTC')); - expect(result.current.volume24h).toBe('$12.35T'); - expect(result.current.openInterest).toBe('$98.77T'); + expect(result.current.volume24h).toBe('$12T'); // No decimals in formatVolume + expect(result.current.openInterest).toBe('$99T'); // No decimals in formatLargeNumber }); it('should format negative funding rate correctly', () => { diff --git a/app/components/UI/Perps/hooks/usePerpsMarketStats.ts b/app/components/UI/Perps/hooks/usePerpsMarketStats.ts index b36348589d48..cf2fb1f8d500 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarketStats.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarketStats.ts @@ -103,13 +103,25 @@ export const usePerpsMarketStats = ( return { // 24h high/low from candlestick data, with fallback estimates - high24h: high > 0 ? formatPrice(high) : formatPrice(fallbackPrice), - low24h: low > 0 ? formatPrice(low) : formatPrice(fallbackPrice), + high24h: + high > 0 + ? formatPrice(high, { minimumDecimals: 2, maximumDecimals: 2 }) + : formatPrice(fallbackPrice, { + minimumDecimals: 2, + maximumDecimals: 2, + }), + low24h: + low > 0 + ? formatPrice(low, { minimumDecimals: 2, maximumDecimals: 2 }) + : formatPrice(fallbackPrice, { + minimumDecimals: 2, + maximumDecimals: 2, + }), volume24h: marketData.volume24h - ? formatLargeNumber(marketData.volume24h) + ? `$${formatLargeNumber(marketData.volume24h)}` : '$0.00', openInterest: marketData.openInterest - ? formatLargeNumber(marketData.openInterest) + ? `$${formatLargeNumber(marketData.openInterest)}` : '$0.00', fundingRate: marketData.funding ? `${(marketData.funding * 100).toFixed(4)}%` diff --git a/app/components/UI/Perps/hooks/usePerpsMarkets.ts b/app/components/UI/Perps/hooks/usePerpsMarkets.ts index 496971c91db2..59465950b4f8 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarkets.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarkets.ts @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; import Engine from '../../../../core/Engine'; import type { PerpsMarketData } from '../controllers/types'; +import { PERPS_CONSTANTS } from '../constants/perpsConfig'; export interface UsePerpsMarketsResult { /** @@ -82,7 +83,42 @@ export const usePerpsMarkets = ( // Get markets with price data directly from the provider const marketDataWithPrices = await provider.getMarketDataWithPrices(); - setMarkets(marketDataWithPrices); + // Sort markets by 24h volume (highest first) + const sortedMarkets = [...marketDataWithPrices].sort((a, b) => { + // Helper function to parse volume string and convert to number + const getVolumeNumber = (volumeStr: string | undefined): number => { + if (!volumeStr) return -1; // Put undefined at the end + + // Handle special cases + if (volumeStr === PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY) return -1; // Put missing data at the end + if (volumeStr === '$<1') return 0.5; // Treat as very small but not zero + + // Remove $ and commas, handle different suffixes + const cleaned = volumeStr.replace(/[$,]/g, ''); + + // Handle billion (B), million (M), thousand (K) suffixes + if (cleaned.includes('B')) { + return parseFloat(cleaned.replace('B', '')) * 1e9; + } + if (cleaned.includes('M')) { + return parseFloat(cleaned.replace('M', '')) * 1e6; + } + if (cleaned.includes('K')) { + return parseFloat(cleaned.replace('K', '')) * 1e3; + } + + // Plain number without suffix (including 0) + const num = parseFloat(cleaned); + return isNaN(num) ? -1 : num; + }; + + const volumeA = getVolumeNumber(a.volume); + const volumeB = getVolumeNumber(b.volume); + + return volumeB - volumeA; // Descending order + }); + + setMarkets(sortedMarkets); DevLogger.log( 'Perps: Successfully fetched and transformed market data', diff --git a/app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts b/app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts index 3cb41ce15baa..6d4ab90b3d64 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts @@ -184,7 +184,7 @@ describe('usePerpsOrderForm', () => { result.current.handlePercentageAmount(0.5); }); - expect(result.current.orderForm.amount).toBe('500'); // 50% of 1000 + expect(result.current.orderForm.amount).toBe('1500'); // 50% of 1000 * 3x leverage }); it('should handle max amount', () => { @@ -194,7 +194,7 @@ describe('usePerpsOrderForm', () => { result.current.handleMaxAmount(); }); - expect(result.current.orderForm.amount).toBe('1000'); + expect(result.current.orderForm.amount).toBe('3000'); // 1000 * 3x leverage }); it('should handle min amount for mainnet', () => { diff --git a/app/components/UI/Perps/hooks/usePerpsOrderForm.ts b/app/components/UI/Perps/hooks/usePerpsOrderForm.ts index 3d08edee5591..d46f266d972d 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderForm.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderForm.ts @@ -125,10 +125,12 @@ export function usePerpsOrderForm( const handlePercentageAmount = useCallback( (percentage: number) => { if (availableBalance === 0) return; - const newAmount = Math.floor(availableBalance * percentage).toString(); + const newAmount = Math.floor( + availableBalance * orderForm.leverage * percentage, + ).toString(); setOrderForm((prev) => ({ ...prev, amount: newAmount })); }, - [availableBalance], + [availableBalance, orderForm.leverage], ); // Handle max amount selection @@ -136,7 +138,7 @@ export function usePerpsOrderForm( if (availableBalance === 0) return; setOrderForm((prev) => ({ ...prev, - amount: Math.floor(availableBalance).toString(), + amount: Math.floor(availableBalance * prev.leverage).toString(), })); }, [availableBalance]); diff --git a/app/components/UI/Perps/utils/formatUtils.test.ts b/app/components/UI/Perps/utils/formatUtils.test.ts index dfbc051661ac..1aa929fd9de9 100644 --- a/app/components/UI/Perps/utils/formatUtils.test.ts +++ b/app/components/UI/Perps/utils/formatUtils.test.ts @@ -8,6 +8,7 @@ import { formatPnl, formatPercentage, formatLargeNumber, + formatVolume, formatPositionSize, formatLeverage, parseCurrencyString, @@ -150,30 +151,54 @@ describe('formatUtils', () => { }); describe('formatLargeNumber', () => { - it('should format billions with B suffix', () => { - expect(formatLargeNumber(1000000000)).toBe('1.0B'); - expect(formatLargeNumber('2500000000')).toBe('2.5B'); - expect(formatLargeNumber(12300000000)).toBe('12.3B'); + it('should format billions with B suffix (default no decimals)', () => { + expect(formatLargeNumber(1000000000)).toBe('1B'); + expect(formatLargeNumber('2500000000')).toBe('3B'); + expect(formatLargeNumber(12300000000)).toBe('12B'); }); - it('should format millions with M suffix', () => { - expect(formatLargeNumber(1000000)).toBe('1.0M'); - expect(formatLargeNumber('2500000')).toBe('2.5M'); - expect(formatLargeNumber(123456789)).toBe('123.5M'); + it('should format billions with configured decimals', () => { + expect(formatLargeNumber(1000000000, { decimals: 1 })).toBe('1.0B'); + expect(formatLargeNumber('2500000000', { decimals: 1 })).toBe('2.5B'); + expect(formatLargeNumber(12300000000, { decimals: 2 })).toBe('12.30B'); }); - it('should format thousands with K suffix', () => { - expect(formatLargeNumber(1000)).toBe('1.0K'); - expect(formatLargeNumber('2500')).toBe('2.5K'); - expect(formatLargeNumber(123456)).toBe('123.5K'); + it('should format millions with M suffix (default no decimals)', () => { + expect(formatLargeNumber(1000000)).toBe('1M'); + expect(formatLargeNumber('2500000')).toBe('3M'); + expect(formatLargeNumber(123456789)).toBe('123M'); }); - it('should format numbers < 1000 with 2 decimal places', () => { + it('should format millions with configured decimals', () => { + expect(formatLargeNumber(1000000, { decimals: 1 })).toBe('1.0M'); + expect(formatLargeNumber('2500000', { decimals: 1 })).toBe('2.5M'); + expect(formatLargeNumber(123456789, { decimals: 2 })).toBe('123.46M'); + }); + + it('should format thousands with K suffix (default no decimals)', () => { + expect(formatLargeNumber(1000)).toBe('1K'); + expect(formatLargeNumber('2500')).toBe('3K'); + expect(formatLargeNumber(123456)).toBe('123K'); + }); + + it('should format thousands with configured decimals', () => { + expect(formatLargeNumber(1000, { decimals: 1 })).toBe('1.0K'); + expect(formatLargeNumber('2500', { decimals: 2 })).toBe('2.50K'); + expect(formatLargeNumber(123456, { decimals: 1 })).toBe('123.5K'); + }); + + it('should format numbers < 1000 with 2 decimal places by default', () => { expect(formatLargeNumber(999)).toBe('999.00'); expect(formatLargeNumber('123.45')).toBe('123.45'); expect(formatLargeNumber(0)).toBe('0.00'); }); + it('should format numbers < 1000 with configured raw decimals', () => { + expect(formatLargeNumber(999, { rawDecimals: 0 })).toBe('999'); + expect(formatLargeNumber('123.45', { rawDecimals: 3 })).toBe('123.450'); + expect(formatLargeNumber(0, { rawDecimals: 1 })).toBe('0.0'); + }); + it('should handle invalid inputs', () => { expect(formatLargeNumber('invalid')).toBe('0'); expect(formatLargeNumber(NaN)).toBe('0'); @@ -181,18 +206,63 @@ describe('formatUtils', () => { }); it('should handle negative numbers', () => { - expect(formatLargeNumber(-1000000)).toBe('-1000000.00'); - expect(formatLargeNumber(-2500)).toBe('-2500.00'); + expect(formatLargeNumber(-1000000)).toBe('-1M'); + expect(formatLargeNumber(-2500)).toBe('-3K'); expect(formatLargeNumber(-999)).toBe('-999.00'); }); + it('should handle negative numbers with decimals', () => { + expect(formatLargeNumber(-1000000, { decimals: 1 })).toBe('-1.0M'); + expect(formatLargeNumber(-2500, { decimals: 2 })).toBe('-2.50K'); + expect(formatLargeNumber(-999, { rawDecimals: 0 })).toBe('-999'); + }); + it('should handle edge cases at boundaries', () => { - expect(formatLargeNumber(999999999)).toBe('1000.0M'); - expect(formatLargeNumber(999999)).toBe('1000.0K'); + expect(formatLargeNumber(999999999)).toBe('1000M'); + expect(formatLargeNumber(999999)).toBe('1000K'); expect(formatLargeNumber(999.99)).toBe('999.99'); }); }); + describe('formatVolume', () => { + it('should format volume with appropriate decimals by default', () => { + expect(formatVolume(1234567890)).toBe('$1.23B'); // B: 2 decimals + expect(formatVolume(12345678)).toBe('$12.35M'); // M: 2 decimals + expect(formatVolume(123456)).toBe('$123K'); // K: 0 decimals + expect(formatVolume(999)).toBe('$999.00'); // Under 1K: 2 decimals + }); + + it('should allow configurable decimals', () => { + expect(formatVolume(1234567890, 0)).toBe('$1B'); + expect(formatVolume(12345678, 1)).toBe('$12.3M'); + expect(formatVolume(123456, 3)).toBe('$123.456K'); + }); + + it('should handle zero volume', () => { + expect(formatVolume(0)).toBe('$0.00'); + expect(formatVolume('0')).toBe('$0.00'); + expect(formatVolume(0, 0)).toBe('$0'); + }); + + it('should handle invalid inputs', () => { + expect(formatVolume('invalid')).toBe('$---'); + expect(formatVolume(NaN)).toBe('$---'); + expect(formatVolume(Infinity)).toBe('$---'); + }); + + it('should handle negative volume', () => { + expect(formatVolume(-1000000)).toBe('-$1.00M'); // M: 2 decimals + expect(formatVolume(-1234)).toBe('-$1K'); // K: 0 decimals by default + expect(formatVolume(-1234, 0)).toBe('-$1K'); // Explicit 0 decimals + }); + + it('should handle very large volumes', () => { + expect(formatVolume(1000000000000)).toBe('$1.00T'); + expect(formatVolume(2500000000000)).toBe('$2.50T'); + expect(formatVolume(2500000000000, 0)).toBe('$3T'); + }); + }); + describe('formatPositionSize', () => { it('should format very small numbers with 6 decimal places', () => { expect(formatPositionSize(0.001)).toBe('0.001000'); diff --git a/app/components/UI/Perps/utils/formatUtils.ts b/app/components/UI/Perps/utils/formatUtils.ts index 2f6ea0c92713..161b0b19cb86 100644 --- a/app/components/UI/Perps/utils/formatUtils.ts +++ b/app/components/UI/Perps/utils/formatUtils.ts @@ -38,31 +38,32 @@ export const formatPerpsFiat = (balance: string | number): string => { */ export const formatPrice = ( price: string | number, - options?: { minimumDecimals?: number }, + options?: { minimumDecimals?: number; maximumDecimals?: number }, ): string => { const num = typeof price === 'string' ? parseFloat(price) : price; const minDecimals = options?.minimumDecimals ?? 2; + const maxDecimals = options?.maximumDecimals ?? 4; if (isNaN(num)) { return minDecimals === 0 ? '$0' : '$0.00'; } - // For prices >= 1000, use specified minimum decimal places + // For prices >= 1000, use specified decimal places if (num >= 1000) { return formatWithThreshold(num, 1000, 'en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: minDecimals, - maximumFractionDigits: Math.max(minDecimals, 2), + maximumFractionDigits: maxDecimals, }); } - // For prices < 1000, use up to 4 decimal places + // For prices < 1000, use specified decimal places return formatWithThreshold(num, 0.0001, 'en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: minDecimals, - maximumFractionDigits: 4, + maximumFractionDigits: maxDecimals, }); }; @@ -112,29 +113,103 @@ export const formatPercentage = (value: string | number): string => { /** * Formats large numbers with magnitude suffixes * @param value - Raw numeric value - * @returns Format: "X.XB" / "X.XM" / "X.XK" / "X.XX" (1 decimal for suffixed, 2 for raw) - * @example formatLargeNumber(1500000) => "1.5M" - * @example formatLargeNumber(1234) => "1.2K" + * @param options - Optional formatting options + * @param options.decimals - Number of decimal places for suffixed values (default: 0) + * @param options.rawDecimals - Number of decimal places for non-suffixed values (default: 2) + * @returns Format: "X.XXB" / "X.XXM" / "X.XXK" / "X.XX" (configurable decimals) + * @example formatLargeNumber(1500000) => "2M" + * @example formatLargeNumber(1500000, { decimals: 1 }) => "1.5M" + * @example formatLargeNumber(1234, { decimals: 2 }) => "1.23K" * @example formatLargeNumber(999) => "999.00" */ -export const formatLargeNumber = (value: string | number): string => { +export const formatLargeNumber = ( + value: string | number, + options?: { decimals?: number; rawDecimals?: number }, +): string => { const num = typeof value === 'string' ? parseFloat(value) : value; + const decimals = options?.decimals ?? 0; + const rawDecimals = options?.rawDecimals ?? 2; if (isNaN(num)) { return '0'; } - if (num >= 1000000000) { - return `${(num / 1000000000).toFixed(1)}B`; + const absNum = Math.abs(num); + const sign = num < 0 ? '-' : ''; + + if (absNum >= 1000000000000) { + const trillions = absNum / 1000000000000; + return `${sign}${trillions.toFixed(decimals)}T`; } - if (num >= 1000000) { - return `${(num / 1000000).toFixed(1)}M`; + if (absNum >= 1000000000) { + const billions = absNum / 1000000000; + return `${sign}${billions.toFixed(decimals)}B`; } - if (num >= 1000) { - return `${(num / 1000).toFixed(1)}K`; + if (absNum >= 1000000) { + const millions = absNum / 1000000; + return `${sign}${millions.toFixed(decimals)}M`; + } + if (absNum >= 1000) { + const thousands = absNum / 1000; + return `${sign}${thousands.toFixed(decimals)}K`; } - return num.toFixed(2); + return num.toFixed(rawDecimals); +}; + +/** + * Formats volume with appropriate magnitude suffixes + * @param volume - Raw volume value + * @param decimals - Number of decimal places to show (optional, auto-determined by default) + * @returns Format: "$XB" / "$X.XXM" / "$XK" / "$X.XX" + * @example formatVolume(1234567890) => "$1.23B" + * @example formatVolume(12345678) => "$12.35M" + * @example formatVolume(123456) => "$123K" + */ +export const formatVolume = ( + volume: string | number, + decimals?: number, +): string => { + const num = typeof volume === 'string' ? parseFloat(volume) : volume; + + // Handle invalid inputs - return fallback display for NaN/Infinity + if (isNaN(num) || !isFinite(num)) { + return '$---'; + } + + const absNum = Math.abs(num); + + // Auto-determine decimals based on magnitude if not specified + let autoDecimals = decimals; + if (decimals === undefined) { + if (absNum >= 1000000000) { + // Billions: 2 decimals + autoDecimals = 2; + } else if (absNum >= 1000000) { + // Millions: 2 decimals + autoDecimals = 2; + } else if (absNum >= 1000) { + // Thousands: 0 decimals + autoDecimals = 0; + } else { + // Under 1000: 2 decimals + autoDecimals = 2; + } + } else { + autoDecimals = decimals; + } + + const formatted = formatLargeNumber(volume, { + decimals: autoDecimals, + rawDecimals: autoDecimals, + }); + + // Handle negative values - ensure dollar sign comes after negative sign + if (formatted.startsWith('-')) { + return `-$${formatted.slice(1)}`; + } + + return `$${formatted}`; }; /** diff --git a/app/components/UI/Perps/utils/marketDataTransform.test.ts b/app/components/UI/Perps/utils/marketDataTransform.test.ts index c93c82a8f10d..620b0517a480 100644 --- a/app/components/UI/Perps/utils/marketDataTransform.test.ts +++ b/app/components/UI/Perps/utils/marketDataTransform.test.ts @@ -7,9 +7,9 @@ import { formatPrice, formatChange, formatPercentage, - formatVolume, HyperLiquidMarketData, } from './marketDataTransform'; +import { formatVolume } from './formatUtils'; import { AllMids, PerpsAssetCtx, @@ -68,7 +68,9 @@ describe('marketDataTransform', () => { price: '$52,000.00', change24h: '+$2,000.00', change24hPercent: '+4.00%', - volume: '$1B', + volume: '$1.00B', + nextFundingTime: undefined, + fundingIntervalHours: undefined, }); }); @@ -131,7 +133,7 @@ describe('marketDataTransform', () => { // Assert expect(result[0].change24h).toBe('+$52,000.00'); expect(result[0].change24hPercent).toBe('0.00%'); - expect(result[0].volume).toBe('$0'); + expect(result[0].volume).toBe('$---'); }); it('handles null/undefined asset context values', () => { @@ -153,7 +155,7 @@ describe('marketDataTransform', () => { // Assert expect(result[0].change24h).toBe('$0.00'); expect(result[0].change24hPercent).toBe('0.00%'); - expect(result[0].volume).toBe('$0'); + expect(result[0].volume).toBe('$---'); }); it('calculates negative price changes correctly', () => { @@ -201,8 +203,8 @@ describe('marketDataTransform', () => { // Assert expect(result).toHaveLength(2); - expect(result[0].volume).toBe('$1B'); // Has context - expect(result[1].volume).toBe('$0'); // No context + expect(result[0].volume).toBe('$1.00B'); // Has context, now with 2 decimals + expect(result[1].volume).toBe('$---'); // No context }); it('handles predicted funding data correctly', () => { @@ -598,7 +600,7 @@ describe('marketDataTransform', () => { const result = formatVolume(volume); // Assert - expect(result).toBe('$0'); + expect(result).toBe('$0.00'); }); it('formats volume in billions', () => { @@ -609,7 +611,7 @@ describe('marketDataTransform', () => { const result = formatVolume(volume); // Assert - expect(result).toBe('$2.5B'); + expect(result).toBe('$2.50B'); // Now with 2 decimals }); it('formats volume in millions', () => { @@ -620,7 +622,7 @@ describe('marketDataTransform', () => { const result = formatVolume(volume); // Assert - expect(result).toBe('$150M'); + expect(result).toBe('$150.00M'); }); it('formats volume in thousands', () => { @@ -631,7 +633,7 @@ describe('marketDataTransform', () => { const result = formatVolume(volume); // Assert - expect(result).toBe('$75K'); + expect(result).toBe('$75K'); // K values have no decimals }); it('formats small volume with two decimal places', () => { @@ -642,7 +644,7 @@ describe('marketDataTransform', () => { const result = formatVolume(volume); // Assert - expect(result).toBe('$123.45'); + expect(result).toBe('$123.45'); // Now with 2 decimals }); it('formats edge case at exactly 1 billion', () => { @@ -653,7 +655,7 @@ describe('marketDataTransform', () => { const result = formatVolume(volume); // Assert - expect(result).toBe('$1B'); + expect(result).toBe('$1.00B'); }); it('formats edge case at exactly 1 million', () => { @@ -664,7 +666,7 @@ describe('marketDataTransform', () => { const result = formatVolume(volume); // Assert - expect(result).toBe('$1M'); + expect(result).toBe('$1.00M'); }); it('formats edge case at exactly 1 thousand', () => { @@ -675,7 +677,7 @@ describe('marketDataTransform', () => { const result = formatVolume(volume); // Assert - expect(result).toBe('$1K'); + expect(result).toBe('$1K'); // K values have no decimals }); it('formats decimal billions correctly', () => { @@ -686,7 +688,7 @@ describe('marketDataTransform', () => { const result = formatVolume(volume); // Assert - expect(result).toBe('$1.23B'); + expect(result).toBe('$1.23B'); // Now with 2 decimals }); it('formats decimal millions correctly', () => { @@ -697,7 +699,7 @@ describe('marketDataTransform', () => { const result = formatVolume(volume); // Assert - expect(result).toBe('$12.35M'); + expect(result).toBe('$12.35M'); // Now with 2 decimals }); it('formats decimal thousands correctly', () => { @@ -708,18 +710,18 @@ describe('marketDataTransform', () => { const result = formatVolume(volume); // Assert - expect(result).toBe('$12.35K'); + expect(result).toBe('$12K'); // K values have no decimals }); it('handles very large numbers correctly', () => { // Arrange - const volume = 999999999999; + const volume = 1000000000000; // 1 trillion // Act const result = formatVolume(volume); // Assert - expect(result).toBe('$1T'); + expect(result).toBe('$1.00T'); }); }); @@ -760,7 +762,7 @@ describe('marketDataTransform', () => { // Assert expect(result[0].change24h).toBe('$0.00'); expect(result[0].change24hPercent).toBe('0.00%'); - expect(result[0].volume).toBe('$0'); + expect(result[0].volume).toBe('$---'); }); it('handles invalid price string in allMids with safe fallbacks', () => { @@ -786,13 +788,13 @@ describe('marketDataTransform', () => { it('handles negative numbers in formatting functions', () => { // Arrange & Act & Assert expect(formatPrice(-100)).toBe('-$100.00'); - expect(formatVolume(-1000000)).toBe('-$1M'); + expect(formatVolume(-1000000)).toBe('-$1.00M'); // Now with 2 decimals }); it('handles very small numbers close to zero', () => { // Arrange & Act & Assert expect(formatPrice(0.0000001)).toBe('$0.000000'); - expect(formatVolume(0.1)).toBe('$0.1'); + expect(formatVolume(0.1)).toBe('$0.10'); // Now shows 2 decimals expect(formatPercentage(0.001)).toBe('+0.00%'); }); @@ -801,8 +803,8 @@ describe('marketDataTransform', () => { // Arrange & Act & Assert expect(formatPrice(NaN)).toBe('$0.00'); expect(formatPrice(Infinity)).toBe('$0.00'); - expect(formatVolume(NaN)).toBe('$0'); - expect(formatVolume(Infinity)).toBe('$0'); + expect(formatVolume(NaN)).toBe('$---'); + expect(formatVolume(Infinity)).toBe('$---'); expect(formatChange(NaN)).toBe('$0.00'); expect(formatChange(Infinity)).toBe('$0.00'); expect(formatPercentage(NaN)).toBe('0.00%'); diff --git a/app/components/UI/Perps/utils/marketDataTransform.ts b/app/components/UI/Perps/utils/marketDataTransform.ts index 9a7585d13f15..861ab0a15278 100644 --- a/app/components/UI/Perps/utils/marketDataTransform.ts +++ b/app/components/UI/Perps/utils/marketDataTransform.ts @@ -5,6 +5,8 @@ import type { AllMids, PredictedFunding, } from '@deeeed/hyperliquid-node20'; +import { PERPS_CONSTANTS } from '../constants/perpsConfig'; +import { formatVolume } from './formatUtils'; /** * HyperLiquid-specific market data structure @@ -57,7 +59,8 @@ export function transformMarketData( : 0; // Format volume (dayNtlVlm is daily notional volume) - const volume = assetCtx ? parseFloat(assetCtx.dayNtlVlm) : 0; + // If assetCtx is missing or dayNtlVlm is not available, use NaN to indicate missing data + const volume = assetCtx?.dayNtlVlm ? parseFloat(assetCtx.dayNtlVlm) : NaN; // Extract funding time data if available let nextFundingTime: number | undefined; @@ -92,7 +95,9 @@ export function transformMarketData( change24hPercent: isNaN(change24hPercent) ? '0.00%' : formatPercentage(change24hPercent), - volume: isNaN(volume) ? '$0' : formatVolume(volume), + volume: isNaN(volume) + ? PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY + : formatVolume(volume), nextFundingTime, fundingIntervalHours, }; @@ -177,19 +182,3 @@ export function formatPercentage(percent: number): string { return percent > 0 ? `+${formatted}` : formatted; } - -/** - * Format volume with appropriate units - */ -export function formatVolume(volume: number): string { - if (isNaN(volume) || !isFinite(volume)) return '$0'; - if (volume === 0) return '$0'; - - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - notation: 'compact', - minimumFractionDigits: 0, - maximumFractionDigits: 2, - }).format(volume); -} diff --git a/locales/languages/en.json b/locales/languages/en.json index 51a1061bbe16..b5a6dbc04bde 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -950,8 +950,7 @@ "fees": "Fees", "market": "Market", "limit": "Limit", - "take_profit": "Take profit", - "stop_loss": "Stop loss", + "max": "max", "cancel_order": "Cancel order", "filled": "filled", "reduce_only": "Reduce only", @@ -1010,7 +1009,8 @@ "market": "Market", "current_price": "Current price", "ask_price": "Ask price", - "bid_price": "Bid price" + "bid_price": "Bid price", + "difference_from_market": "Difference from market:" }, "leverage_modal": { "title": "Set Leverage", @@ -1048,7 +1048,7 @@ "closing": "Closing position...", "pnl": "PnL", "fees": "Fees", - "receive": "You'll receive (Hyperliquid USDC)", + "receive": "You'll receive", "error_unknown": "Failed to close position", "success_title": "Position Closed Successfully", "success_description": "Your {{direction}} position for {{asset}} has been closed", @@ -1135,7 +1135,7 @@ "card": { "entry_price": "Entry Price", "market_price": "Market Price", - "liquidity_price": "Liquidity Price", + "liquidation_price": "Liquidation Price", "take_profit": "Take Profit", "stop_loss": "Stop Loss", "margin": "Margin", @@ -1294,7 +1294,7 @@ "history_will_appear": "Your trading history will appear here" } }, - "risk_disclaimer": "Perpetual contracts are very risky, and you could suddenly and without notice lose your entire investment. You trade entirely at your own risk.  Market data provided by {{provider}}.", + "risk_disclaimer": "Perps trading is risky, and you could suddenly and without notice lose your entire margin. You trade entirely at your own risks. Market data provided by Hyperliquid. Price chart powered by", "tutorial": { "continue": "Continue", "skip": "Skip", From 069aa02513617c6182e10be3874143fc47682bd3 Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:47:51 +0800 Subject: [PATCH 2/8] feat(perps): improve hyperliquid fee estimation based on trading and staking volume (#18544) ## **Description** This PR improves fee estimation accuracy for perpetual trading by implementing user-specific fee rates based on trading volume and staking tiers. **Reason for change:** - Currently, all users see the same base fee rates (0.045% taker, 0.015% maker) regardless of their trading volume or HYPE staking status - HyperLiquid offers significant fee discounts based on 14-day rolling volume (up to 47% discount) and HYPE staking (up to 40% discount) - Users with high volume or staking were seeing inaccurate fee estimates that were higher than their actual fees **Improvement/Solution:** - Integrated HyperLiquid SDK's `userFees` API to fetch actual user-specific fee rates - Implemented smart caching with 5-minute TTL to reduce API calls while maintaining accuracy - Provider-agnostic implementation keeps the UI layer clean and allows other providers to implement their own fee logic - Graceful fallback to base rates when API is unavailable or user is not connected ## **Changelog** CHANGELOG entry: Improved perpetual trading fee estimation to show personalized rates based on user's trading volume and staking status ## **Related issues** Fixes: #[issue-number] ## **Manual testing steps** ```gherkin Feature: User-specific fee estimation for perpetual trading Scenario: User sees base fee rates when not connected Given the user is on the Perps order screen And the user's wallet is not connected to HyperLiquid When user enters an order amount Then the fee estimate shows base rates (0.045% for market orders) Scenario: User sees discounted fee rates based on their tier Given the user is on the Perps order screen And the user has a connected wallet with trading history When user enters an order amount Then the fee estimate shows their personalized rate with discounts applied And the rate is cached for 5 minutes to avoid excessive API calls Scenario: Fee estimation falls back gracefully on API failure Given the user is on the Perps order screen And the HyperLiquid API is temporarily unavailable When user enters an order amount Then the fee estimate shows base rates as fallback And no error is shown to the user ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- ## Additional Technical Details for Reviewers ### Implementation Highlights: - **Caching Strategy**: 5-minute TTL balances accuracy with API efficiency - **Provider-Agnostic**: Changes are isolated to HyperLiquidProvider, maintaining clean architecture - **Test Coverage**: Added comprehensive test cases for user-specific fees, caching, and fallback scenarios - **Performance**: Minimal impact - API call only made once per 5 minutes per user ### Files Changed: - `app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts` - Added fee caching and user-specific rate fetching - `app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts` - Added test coverage for new functionality ### Testing Evidence: - All 2297 tests passing in the Perps module - ESLint checks passing with no errors - Fee calculation accurately reflects HyperLiquid's tier structure --- .../providers/HyperLiquidProvider.test.ts | 240 ++++++++++++++++++ .../providers/HyperLiquidProvider.ts | 134 ++++++++-- 2 files changed, 353 insertions(+), 21 deletions(-) diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts index f383b30b3ed5..6c4429058568 100644 --- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts +++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts @@ -218,6 +218,15 @@ describe('HyperLiquidProvider', () => { }, }), maxBuilderFee: jest.fn().mockResolvedValue(1), + userFees: jest.fn().mockResolvedValue({ + feeSchedule: { + cross: '0.00030', // 0.030% taker with discount + add: '0.00010', // 0.010% maker with discount + spotCross: '0.00040', + spotAdd: '0.00020', + }, + dailyUserVlm: [], + }), }), disconnect: jest.fn().mockResolvedValue(undefined), toggleTestnet: jest.fn(), @@ -2724,6 +2733,15 @@ describe('HyperLiquidProvider', () => { }); describe('calculateFees', () => { + beforeEach(() => { + // Reset userFees mock for each test + (mockClientService.getInfoClient().userFees as jest.Mock).mockClear(); + // Default to throw error (will use base rates) + mockWalletService.getUserAddressWithDefault.mockRejectedValue( + new Error('No wallet connected'), + ); + }); + it('should calculate fees for market orders', async () => { const result = await provider.calculateFees({ orderType: 'market', @@ -2778,6 +2796,72 @@ describe('HyperLiquidProvider', () => { expect(result.feeAmount).toBeUndefined(); }); + it('should use cached user-specific fee rates when available', async () => { + // Reset mock and set user address to trigger user fee fetching + (mockClientService.getInfoClient().userFees as jest.Mock).mockClear(); + ( + mockClientService.getInfoClient().userFees as jest.Mock + ).mockResolvedValue({ + feeSchedule: { + cross: '0.00030', // 0.030% taker with discount + add: '0.00010', // 0.010% maker with discount + spotCross: '0.00040', + spotAdd: '0.00020', + }, + dailyUserVlm: [], + }); + mockWalletService.getUserAddressWithDefault.mockResolvedValue('0x123'); + + // First call should fetch from API + const result1 = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + }); + + // Should use discounted rate from userFees mock + expect(result1.feeRate).toBe(0.0003); // 0.030% with discount + expect(result1.feeAmount).toBeCloseTo(30, 5); // 100000 * 0.00030 + expect( + mockClientService.getInfoClient().userFees, + ).toHaveBeenCalledTimes(1); + + // Second call should use cache + const result2 = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + }); + + expect(result2.feeRate).toBe(0.0003); + expect(result2.feeAmount).toBeCloseTo(30, 5); + // Should not call API again (cached) + expect( + mockClientService.getInfoClient().userFees, + ).toHaveBeenCalledTimes(1); + }); + + it('should fall back to base rates on API failure', async () => { + // Reset and mock user address + (mockClientService.getInfoClient().userFees as jest.Mock).mockClear(); + mockWalletService.getUserAddressWithDefault.mockResolvedValue('0x123'); + + // Mock API failure + ( + mockClientService.getInfoClient().userFees as jest.Mock + ).mockRejectedValue(new Error('API Error')); + + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + }); + + // Should use base rates on failure + expect(result.feeRate).toBe(0.00045); // Base taker rate + expect(result.feeAmount).toBe(45); + }); + it('should handle non-numeric amount gracefully', async () => { const result = await provider.calculateFees({ orderType: 'market', @@ -2811,6 +2895,162 @@ describe('HyperLiquidProvider', () => { expect(result).toBeInstanceOf(Promise); }); + it('should fetch user-specific fee rates when wallet is connected', async () => { + const testAddress = '0xTestAddress123'; + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + testAddress, + ); + + // Mock user fees API response with discounted rates + ( + mockClientService.getInfoClient().userFees as jest.Mock + ).mockResolvedValue({ + feeSchedule: { + cross: '0.0003', // 0.03% - discounted taker rate + add: '0.0001', // 0.01% - discounted maker rate + spotCross: '0.0003', + spotAdd: '0.0001', + }, + }); + + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + }); + + expect(result.feeRate).toBe(0.0003); // Discounted rate + expect(result.feeAmount).toBeCloseTo(30, 5); + }); + + it('should cache user fee rates and reuse them', async () => { + const testAddress = '0xTestAddress123'; + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + testAddress, + ); + + ( + mockClientService.getInfoClient().userFees as jest.Mock + ).mockResolvedValue({ + feeSchedule: { + cross: '0.0003', + add: '0.0001', + spotCross: '0.0003', + spotAdd: '0.0001', + }, + }); + + // First call - should fetch from API + await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + }); + + expect( + mockClientService.getInfoClient().userFees, + ).toHaveBeenCalledTimes(1); + + // Second call - should use cache + await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + }); + + // Should not call API again + expect( + mockClientService.getInfoClient().userFees, + ).toHaveBeenCalledTimes(1); + }); + + it('should fall back to base rates when API returns invalid fee rates', async () => { + const testAddress = '0xTestAddress123'; + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + testAddress, + ); + + // Mock user fees API response with invalid rates that will produce NaN + ( + mockClientService.getInfoClient().userFees as jest.Mock + ).mockResolvedValue({ + feeSchedule: { + cross: 'invalid', // Will cause parseFloat to return NaN + add: 'invalid', + spotCross: 'invalid', + spotAdd: 'invalid', + }, + }); + + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + }); + + // Should fall back to base rates due to validation failure + expect(result.feeRate).toBe(0.00045); // Base taker rate + expect(result.feeAmount).toBe(45); + }); + + it('should fall back to base rates when API returns negative fee rates', async () => { + const testAddress = '0xTestAddress123'; + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + testAddress, + ); + + // Mock user fees API response with negative rates + ( + mockClientService.getInfoClient().userFees as jest.Mock + ).mockResolvedValue({ + feeSchedule: { + cross: '-0.0003', // Negative rate - invalid + add: '0.0001', + spotCross: '0.0003', + spotAdd: '0.0001', + }, + }); + + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + }); + + // Should fall back to base rates due to validation failure + expect(result.feeRate).toBe(0.00045); // Base taker rate + expect(result.feeAmount).toBe(45); + }); + + it('should always use taker rate for market orders regardless of isMaker', async () => { + const testAddress = '0xTestAddress123'; + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + testAddress, + ); + + ( + mockClientService.getInfoClient().userFees as jest.Mock + ).mockResolvedValue({ + feeSchedule: { + cross: '0.0003', // Taker rate + add: '0.0001', // Maker rate (lower) + spotCross: '0.0003', + spotAdd: '0.0001', + }, + }); + + // Test market order with isMaker=true (should still use taker rate) + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: true, // This should be ignored for market orders + amount: '100000', + }); + + // Should use taker rate even though isMaker is true + expect(result.feeRate).toBe(0.0003); // Taker rate, not maker rate (0.0001) + expect(result.feeAmount).toBeCloseTo(30, 5); + }); + describe('placeholder methods for future implementation', () => { it('should have getUserVolume method returning 0', async () => { // Access private method for testing diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts index 7d0b1b9bef1c..cdb94a3fab86 100644 --- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts +++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts @@ -99,6 +99,19 @@ export class HyperLiquidProvider implements IPerpsProvider { // Asset mapping private coinToAssetId = new Map(); + // Cache for user fee rates to avoid excessive API calls + private userFeeCache = new Map< + string, + { + perpsTakerRate: number; + perpsMakerRate: number; + spotTakerRate: number; + spotMakerRate: number; + timestamp: number; + ttl: number; + } + >(); + constructor(options: { isTestnet?: boolean } = {}) { const isTestnet = options.isTestnet || false; @@ -2011,48 +2024,124 @@ export class HyperLiquidProvider implements IPerpsProvider { * Calculate fees based on HyperLiquid's fee structure * Returns fee rate as decimal (e.g., 0.00045 for 0.045%) * - * HyperLiquid base fees (Tier 0, no discounts): - * - Taker: 0.045% (market orders, aggressive limit orders) - * - Maker: 0.015% (limit orders that add liquidity) - * - * TODO: Apply volume and staking discounts when APIs available + * Uses the SDK's userFees API to get actual discounted rates when available, + * falling back to base rates if the API is unavailable or user not connected. */ async calculateFees( params: FeeCalculationParams, ): Promise { const { orderType, isMaker = false, amount } = params; - // Use base rates from config - const baseRate = + // Start with base rates from config + let feeRate = orderType === 'market' || !isMaker ? FEE_RATES.taker : FEE_RATES.maker; - // TODO: When APIs available, apply user-specific discounts - // const volume = await this.getUserVolume(); - // const staking = await this.getUserStaking(); - // const { volumeTier, volumeDiscount, stakingDiscount } = await this.calculateDiscounts(volume, staking); - // const finalRate = baseRate * (1 - volumeDiscount) * (1 - stakingDiscount); + // Try to get user-specific rates if wallet is connected + try { + const userAddress = await this.walletService.getUserAddressWithDefault(); + + // Check cache first + if (this.isFeeCacheValid(userAddress)) { + const cached = this.userFeeCache.get(userAddress); + if (cached) { + // Market orders always use taker rate, limit orders check isMaker + feeRate = + orderType === 'market' || !isMaker + ? cached.perpsTakerRate + : cached.perpsMakerRate; + } + } else { + // Fetch fresh rates from SDK + await this.ensureReady(); + const infoClient = this.clientService.getInfoClient(); + const userFees = await infoClient.userFees({ + user: userAddress as `0x${string}`, + }); + + // Parse the rates (these already include all discounts!) + const perpsTakerRate = parseFloat(userFees.feeSchedule.cross); + const perpsMakerRate = parseFloat(userFees.feeSchedule.add); + const spotTakerRate = parseFloat(userFees.feeSchedule.spotCross); + const spotMakerRate = parseFloat(userFees.feeSchedule.spotAdd); + + // Validate all rates are valid numbers before caching + if ( + isNaN(perpsTakerRate) || + isNaN(perpsMakerRate) || + isNaN(spotTakerRate) || + isNaN(spotMakerRate) || + perpsTakerRate < 0 || + perpsMakerRate < 0 || + spotTakerRate < 0 || + spotMakerRate < 0 + ) { + throw new Error('Invalid fee rates received from API'); + } + + const rates = { + perpsTakerRate, + perpsMakerRate, + spotTakerRate, + spotMakerRate, + timestamp: Date.now(), + ttl: 5 * 60 * 1000, // 5 minutes + }; + + this.userFeeCache.set(userAddress, rates); + // Market orders always use taker rate, limit orders check isMaker + feeRate = + orderType === 'market' || !isMaker + ? rates.perpsTakerRate + : rates.perpsMakerRate; + + DevLogger.log('Fetched user fee rates', { + userAddress, + perpsTaker: rates.perpsTakerRate, + perpsMaker: rates.perpsMakerRate, + cacheExpiry: new Date(rates.timestamp + rates.ttl).toISOString(), + }); + } + } catch (error) { + // Silently fall back to base rates + DevLogger.log('Failed to fetch user fee rates, using base rates', error); + } const parsedAmount = amount ? parseFloat(amount) : 0; const feeAmount = amount !== undefined ? isNaN(parsedAmount) ? 0 - : parsedAmount * baseRate + : parsedAmount * feeRate : undefined; return { - feeRate: baseRate, + feeRate, feeAmount, - // Future: Include breakdown when we have user data - // breakdown: { - // baseFeeRate: baseRate, - // volumeTier, - // volumeDiscount, - // stakingDiscount, - // }, }; } + /** + * Check if the fee cache is valid for a user + * @private + */ + private isFeeCacheValid(userAddress: string): boolean { + const cached = this.userFeeCache.get(userAddress); + if (!cached) return false; + return Date.now() - cached.timestamp < cached.ttl; + } + + /** + * Clear fee cache for a specific user or all users + * @param userAddress - Optional address to clear cache for + */ + public clearFeeCache(userAddress?: string): void { + if (userAddress) { + this.userFeeCache.delete(userAddress); + } else { + this.userFeeCache.clear(); + } + } + /** * Disconnect provider */ @@ -2066,6 +2155,9 @@ export class HyperLiquidProvider implements IPerpsProvider { // Clear subscriptions through subscription service this.subscriptionService.clearAll(); + // Clear fee cache + this.clearFeeCache(); + // Disconnect client service await this.clientService.disconnect(); From e78218303d06b6a018197306973890723668250a Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:57:09 +0800 Subject: [PATCH 3/8] feat: BIP-44 account support for Perps with seamless Solana account handling (#18546) ## **Description** This PR fixes the Perps tab infinite loading issue and migrates the Perps components to use the new multichain account architecture from PR #18407. The Perps tab now works seamlessly with the multichain account system, automatically using the appropriate EVM account even when a non-EVM account (like Solana) is selected. ### Key improvements: 1. **Multichain account support**: Migrated from `AccountsController` to `AccountTreeController` using BIP-44 account addresses with the new `selectSelectedInternalAccountByScope` selector 2. **Smart account handling**: When a Solana account is selected, Perps automatically uses the last active EVM account from the account group, ensuring continuous functionality 3. **Proper error recovery**: Connection failures now show an error state with a retry button instead of infinite loading 4. **Fixed infinite loading**: Resolved the issue where the Perps tab would show an infinite skeleton loader when switching accounts ## **Changelog** CHANGELOG entry: Fixed Perps tab infinite loading issue and improved multichain account support ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/jira/software/c/projects/TAT/boards/1563/backlog?assignee=62440610247a4b00691c743a ## **Manual testing steps** ```gherkin Feature: Perps Tab Multichain Account Support Scenario: User selects a Solana account Given the user has both EVM and Solana accounts in their wallet And the user is on the wallet main screen And an EVM account is currently selected When user switches to a Solana account And navigates to the Perps tab Then the Perps tab loads successfully And Perps continues to work with the last used EVM account And no infinite loading skeleton is displayed Scenario: User switches between different account types Given the user has multiple account types (EVM, Solana, Bitcoin) And the Perps tab is open When user switches between different account types Then the Perps tab maintains functionality And always uses the appropriate EVM account from the account group And no loading issues occur Scenario: Connection failure with retry Given the user has an EVM account selected And there is a network connection issue When user navigates to the Perps tab And the connection fails Then user sees a "Connection Failed" error message And user sees a "Retry" button And clicking "Retry" attempts to reconnect Scenario: Normal operation with EVM account Given the user has an EVM account selected And network connection is stable When user navigates to the Perps tab Then the Perps tab loads successfully And user can see their positions or first-time user screen And no error messages are displayed ``` ## **Screenshots/Recordings** ### **Before** - When switching accounts, the Perps tab would show an infinite loading skeleton - The tab would fail to load properly when account changes occurred - Used the deprecated `AccountsController` which didn't support multichain accounts properly https://github.com/user-attachments/assets/99c2db16-9aa6-476c-8df2-70f3df7af44c ### **After** - Perps tab seamlessly works with multichain accounts - When a Solana account is selected, Perps automatically uses the appropriate EVM account from the same account group - Proper error handling for connection failures with retry functionality - Smooth transitions when switching between account types - Uses the new `AccountTreeController` with BIP-44 account addresses https://github.com/user-attachments/assets/f66e3b4e-c005-4c3f-b4b7-6d2e245bdc35 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../Views/PerpsTabView/PerpsTabView.test.tsx | 88 ++++++++++++ .../Perps/Views/PerpsTabView/PerpsTabView.tsx | 36 ++++- .../PerpsFundingTransactionView.test.tsx | 18 ++- .../PerpsFundingTransactionView.tsx | 6 +- .../PerpsOrderTransactionView.test.tsx | 44 ++---- .../PerpsOrderTransactionView.tsx | 6 +- .../PerpsPositionTransactionView.test.tsx | 16 ++- .../PerpsPositionTransactionView.tsx | 6 +- .../PerpsErrorState/PerpsErrorState.styles.ts | 39 ++++++ .../PerpsErrorState/PerpsErrorState.test.tsx | 129 ++++++++++++++++++ .../PerpsErrorState/PerpsErrorState.tsx | 122 +++++++++++++++++ .../Perps/components/PerpsErrorState/index.ts | 1 + .../PerpsTabControlBar/PerpsTabControlBar.tsx | 1 - .../Perps/controllers/PerpsController.test.ts | 22 ++- .../UI/Perps/controllers/PerpsController.ts | 20 ++- .../hooks/usePerpsBlockExplorerUrl.test.ts | 16 ++- .../Perps/hooks/usePerpsBlockExplorerUrl.ts | 6 +- .../providers/PerpsConnectionProvider.tsx | 6 +- .../services/HyperLiquidWalletService.test.ts | 20 ++- .../services/HyperLiquidWalletService.ts | 28 ++-- .../services/PerpsConnectionManager.test.ts | 21 ++- .../Perps/services/PerpsConnectionManager.ts | 10 +- locales/languages/en.json | 17 ++- 23 files changed, 579 insertions(+), 99 deletions(-) create mode 100644 app/components/UI/Perps/components/PerpsErrorState/PerpsErrorState.styles.ts create mode 100644 app/components/UI/Perps/components/PerpsErrorState/PerpsErrorState.test.tsx create mode 100644 app/components/UI/Perps/components/PerpsErrorState/PerpsErrorState.tsx create mode 100644 app/components/UI/Perps/components/PerpsErrorState/index.ts diff --git a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx index bbe44c8fcbe7..d53c6bdfe98d 100644 --- a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx @@ -1,6 +1,7 @@ import { useNavigation } from '@react-navigation/native'; import { act, fireEvent, render, screen } from '@testing-library/react-native'; import React from 'react'; +import { useSelector } from 'react-redux'; import Routes from '../../../../../constants/navigation/Routes'; import { strings } from '../../../../../../locales/i18n'; import type { Position } from '../../controllers/types'; @@ -12,6 +13,20 @@ jest.mock('@react-navigation/native', () => ({ useNavigation: jest.fn(), })); +// Mock Redux +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +// Mock the multichain selector +jest.mock('../../../../../selectors/multichainAccounts/accounts', () => ({ + selectSelectedInternalAccountByScope: jest.fn(() => () => ({ + address: '0x1234567890123456789012345678901234567890', + id: 'mock-account-id', + type: 'eip155:eoa', + })), +})); + // Mock PerpsConnectionProvider jest.mock('../../providers/PerpsConnectionProvider', () => ({ PerpsConnectionProvider: ({ children }: { children: React.ReactNode }) => @@ -126,10 +141,20 @@ describe('PerpsTabView', () => { jest.clearAllMocks(); (useNavigation as jest.Mock).mockReturnValue(mockNavigation); + // Mock useSelector for the multichain selector + (useSelector as jest.Mock).mockImplementation(() => () => ({ + address: '0x1234567890123456789012345678901234567890', + id: 'mock-account-id', + type: 'eip155:eoa', + })); + // Default hook mocks mockUsePerpsConnection.mockReturnValue({ isConnected: true, isInitialized: true, + error: null, + connect: jest.fn(), + resetError: jest.fn(), }); mockUsePerpsLivePositions.mockReturnValue({ @@ -477,6 +502,69 @@ describe('PerpsTabView', () => { consoleSpy.mockRestore(); }); + + it('should render connection error state when connection fails', () => { + mockUsePerpsConnection.mockReturnValue({ + isConnected: false, + isInitialized: false, + error: 'CONNECTION_FAILED', + connect: jest.fn(), + resetError: jest.fn(), + }); + + render(); + + // Should show connection failed error + expect( + screen.getByText(strings('perps.errors.connectionFailed.title')), + ).toBeOnTheScreen(); + expect( + screen.getByText(strings('perps.errors.connectionFailed.description')), + ).toBeOnTheScreen(); + }); + + it('should render network error state when network error occurs', () => { + mockUsePerpsConnection.mockReturnValue({ + isConnected: false, + isInitialized: false, + error: 'NETWORK_ERROR', + connect: jest.fn(), + resetError: jest.fn(), + }); + + render(); + + // Should show connection failed error (PerpsTabView always uses CONNECTION_FAILED) + expect( + screen.getByText(strings('perps.errors.connectionFailed.title')), + ).toBeOnTheScreen(); + expect( + screen.getByText(strings('perps.errors.connectionFailed.description')), + ).toBeOnTheScreen(); + }); + + it('should call connect when retry button is pressed on error', () => { + const mockConnect = jest.fn(); + const mockResetError = jest.fn(); + + mockUsePerpsConnection.mockReturnValue({ + isConnected: false, + isInitialized: false, + error: 'CONNECTION_FAILED', + connect: mockConnect, + resetError: mockResetError, + }); + + render(); + + const retryButton = screen.getByText( + strings('perps.errors.connectionFailed.retry'), + ); + fireEvent.press(retryButton); + + expect(mockResetError).toHaveBeenCalledTimes(1); + expect(mockConnect).toHaveBeenCalledTimes(1); + }); }); describe('Accessibility', () => { diff --git a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx index 24fb0413afc2..4fea1cbc647f 100644 --- a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx +++ b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx @@ -1,6 +1,7 @@ import { useNavigation, type NavigationProp } from '@react-navigation/native'; import React, { useCallback, useEffect, useRef } from 'react'; import { ScrollView, View } from 'react-native'; +import { useSelector } from 'react-redux'; import { strings } from '../../../../../../locales/i18n'; import Button, { ButtonSize, @@ -21,6 +22,9 @@ import Routes from '../../../../../constants/navigation/Routes'; import { MetaMetricsEvents } from '../../../../hooks/useMetrics'; import PerpsPositionCard from '../../components/PerpsPositionCard'; import { PerpsTabControlBar } from '../../components/PerpsTabControlBar'; +import PerpsErrorState, { + PerpsErrorType, +} from '../../components/PerpsErrorState'; import { PerpsEventProperties, PerpsEventValues, @@ -36,6 +40,7 @@ import { usePerpsPerformance, usePerpsLivePositions, } from '../../hooks'; +import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts'; import styleSheet from './PerpsTabView.styles'; interface PerpsTabViewProps {} @@ -43,8 +48,12 @@ interface PerpsTabViewProps {} const PerpsTabView: React.FC = () => { const { styles } = useStyles(styleSheet, {}); const navigation = useNavigation>(); + const selectedEvmAccount = useSelector(selectSelectedInternalAccountByScope)( + 'eip155:1', + ); const { getAccountState } = usePerpsTrading(); - const { isConnected, isInitialized } = usePerpsConnection(); + const { isConnected, isInitialized, error, connect, resetError } = + usePerpsConnection(); const { track } = usePerpsEventTracking(); const cachedAccountState = usePerpsAccount(); @@ -64,15 +73,15 @@ const PerpsTabView: React.FC = () => { startMeasure(PerpsMeasurementName.POSITION_DATA_LOADED_PERP_TAB); }, [startMeasure]); - // Automatically load account state on mount and when network changes + // Automatically load account state on mount and when network or account changes useEffect(() => { - // Only load account state if we're connected and initialized - if (isConnected && isInitialized) { + // Only load account state if we're connected, initialized, and have an EVM account + if (isConnected && isInitialized && selectedEvmAccount) { // Fire and forget - errors are already handled in getAccountState // and stored in the controller's state getAccountState(); } - }, [getAccountState, isConnected, isInitialized]); + }, [getAccountState, isConnected, isInitialized, selectedEvmAccount]); // Track homescreen tab viewed - only once when positions and account are loaded useEffect(() => { @@ -123,6 +132,11 @@ const PerpsTabView: React.FC = () => { }); }, [navigation]); + const handleRetryConnection = useCallback(() => { + resetError(); + connect(); + }, [connect, resetError]); + const renderPositionsSection = () => { if (isInitialLoading) { return ( @@ -208,6 +222,18 @@ const PerpsTabView: React.FC = () => { ); }; + // Check for connection errors + if (error && !isConnected && selectedEvmAccount) { + return ( + + + + ); + } + return ( {isFirstTimeUser ? ( diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView.test.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView.test.tsx index 5d93d6aa8894..865a08693324 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; import Routes from '../../../../../constants/navigation/Routes'; import PerpsFundingTransactionView from './PerpsFundingTransactionView'; import { PerpsTransactionSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; @@ -23,7 +24,6 @@ const mockTransaction = { // Mock all dependencies properly const mockUseNavigation = jest.fn(); const mockUseRoute = jest.fn(); -const mockUseSelector = jest.fn(); const mockUsePerpsNetwork = jest.fn(); const mockUsePerpsBlockExplorerUrl = jest.fn(); const mockGetHyperliquidExplorerUrl = jest.fn(); @@ -37,7 +37,13 @@ jest.mock('@react-navigation/native', () => ({ })); jest.mock('react-redux', () => ({ - useSelector: () => mockUseSelector(), + useSelector: jest.fn(), +})); + +jest.mock('../../../../../selectors/multichainAccounts/accounts', () => ({ + selectSelectedInternalAccountByScope: jest.fn(() => () => ({ + address: '0x1234567890abcdef1234567890abcdef12345678', + })), })); jest.mock('../../hooks', () => ({ @@ -69,9 +75,10 @@ describe('PerpsFundingTransactionView', () => { ), baseExplorerUrl: 'https://app.hyperliquid.xyz/explorer', }); - mockUseSelector.mockReturnValue({ + // Mock useSelector to return a function that returns the account + (useSelector as jest.Mock).mockImplementation(() => () => ({ address: '0x1234567890abcdef1234567890abcdef12345678', - }); + })); mockUseRoute.mockReturnValue({ params: { transaction: mockTransaction }, }); @@ -263,7 +270,8 @@ describe('PerpsFundingTransactionView', () => { setOptions: jest.fn(), }); - mockUseSelector.mockReturnValue(null); + // Mock useSelector to return null for no account + (useSelector as jest.Mock).mockImplementationOnce(() => () => null); const { getByTestId } = render(); diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView.tsx index 1871557fd949..e5cea34c5964 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView.tsx @@ -19,7 +19,7 @@ import Text, { TextVariant, } from '../../../../../component-library/components/Texts/Text'; import { useStyles } from '../../../../../component-library/hooks'; -import { selectSelectedInternalAccount } from '../../../../../selectors/accountsController'; +import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts'; import ScreenView from '../../../../Base/ScreenView'; import { getPerpsTransactionsDetailsNavbar } from '../../../Navbar'; import { usePerpsBlockExplorerUrl } from '../../hooks'; @@ -40,7 +40,9 @@ const PerpsFundingTransactionView: React.FC = () => { const navigation = useNavigation>(); const route = useRoute(); - const selectedInternalAccount = useSelector(selectSelectedInternalAccount); + const selectedInternalAccount = useSelector( + selectSelectedInternalAccountByScope, + )('eip155:1'); const { getExplorerUrl } = usePerpsBlockExplorerUrl(); // Get transaction from route params diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.test.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.test.tsx index 6d7b745c0525..983d9b18036a 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; import Routes from '../../../../../constants/navigation/Routes'; import PerpsOrderTransactionView from './PerpsOrderTransactionView'; import { @@ -30,7 +31,6 @@ const mockTransaction = { // Mock dependencies const mockUseNavigation = jest.fn(); const mockUseRoute = jest.fn(); -const mockUseSelector = jest.fn(); jest.mock('@react-navigation/native', () => ({ useNavigation: () => mockUseNavigation(), @@ -38,7 +38,13 @@ jest.mock('@react-navigation/native', () => ({ })); jest.mock('react-redux', () => ({ - useSelector: () => mockUseSelector(), + useSelector: jest.fn(), +})); + +jest.mock('../../../../../selectors/multichainAccounts/accounts', () => ({ + selectSelectedInternalAccountByScope: jest.fn(() => () => ({ + address: '0x1234567890abcdef1234567890abcdef12345678', + })), })); jest.mock('../../hooks', () => ({ @@ -64,32 +70,14 @@ describe('PerpsOrderTransactionView', () => { typeof usePerpsBlockExplorerUrl >; - // Mock selectedInternalAccount - const mockSelectedInternalAccount = { - id: 'test-account-id', - address: '0x1234567890abcdef1234567890abcdef12345678', - type: 'eip155:eoa' as const, - metadata: { - name: 'Test Account', - importTime: 1684232000456, - keyring: { - type: 'HD Key Tree', - }, - }, - options: {}, - methods: [ - 'personal_sign', - 'eth_signTransaction', - 'eth_signTypedData_v1', - 'eth_signTypedData_v3', - 'eth_signTypedData_v4', - ], - scopes: ['eip155:1'], - }; - beforeEach(() => { jest.clearAllMocks(); + // Mock useSelector to return a function that returns the account + (useSelector as jest.Mock).mockImplementation(() => () => ({ + address: '0x1234567890abcdef1234567890abcdef12345678', + })); + mockUsePerpsNetwork.mockReturnValue('mainnet'); mockUsePerpsBlockExplorerUrl.mockReturnValue({ getExplorerUrl: jest.fn().mockImplementation((address) => { @@ -124,9 +112,6 @@ describe('PerpsOrderTransactionView', () => { navigate: jest.fn(), setOptions: jest.fn(), }); - - // Mock selectedInternalAccount by default - mockUseSelector.mockReturnValue(mockSelectedInternalAccount); }); it('should render order transaction details correctly', () => { @@ -249,7 +234,8 @@ describe('PerpsOrderTransactionView', () => { setOptions: jest.fn(), }); - mockUseSelector.mockReturnValue(null); + // Mock useSelector to return null for no account + (useSelector as jest.Mock).mockImplementationOnce(() => () => null); const { getByTestId } = render(); diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.tsx index b6fd768a6220..c6eee742d05e 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.tsx @@ -20,7 +20,7 @@ import Button, { ButtonWidthTypes, } from '../../../../../component-library/components/Buttons/Button'; import { useStyles } from '../../../../../component-library/hooks'; -import { selectSelectedInternalAccount } from '../../../../../selectors/accountsController'; +import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts'; import ScreenView from '../../../../Base/ScreenView'; import { getPerpsTransactionsDetailsNavbar } from '../../../Navbar'; import PerpsTransactionDetailAssetHero from '../../components/PerpsTransactionDetailAssetHero'; @@ -37,7 +37,9 @@ const PerpsOrderTransactionView: React.FC = () => { const { styles } = useStyles(styleSheet, {}); const navigation = useNavigation>(); const route = useRoute(); - const selectedInternalAccount = useSelector(selectSelectedInternalAccount); + const selectedInternalAccount = useSelector( + selectSelectedInternalAccountByScope, + )('eip155:1'); const { getExplorerUrl } = usePerpsBlockExplorerUrl(); // Get transaction from route params const transaction = route.params?.transaction; diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.test.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.test.tsx index 183844a2724f..89bc7b5994fb 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.test.tsx @@ -3,6 +3,7 @@ import { fireEvent } from '@testing-library/react-native'; import PerpsPositionTransactionView from './PerpsPositionTransactionView'; import { usePerpsNetwork, usePerpsBlockExplorerUrl } from '../../hooks'; import { selectSelectedInternalAccount } from '../../../../../selectors/accountsController'; +import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts'; import renderWithProvider, { DeepPartial, } from '../../../../../util/test/renderWithProvider'; @@ -52,6 +53,13 @@ jest.mock('../../../../../selectors/accountsController', () => ({ selectSelectedInternalAccountAddress: jest.fn(), selectSelectedInternalAccountFormattedAddress: jest.fn(), selectHasCreatedSolanaMainnetAccount: jest.fn(), + selectInternalAccounts: jest.fn(() => []), +})); + +jest.mock('../../../../../selectors/multichainAccounts/accounts', () => ({ + selectSelectedInternalAccountByScope: jest.fn(() => () => ({ + address: '0x1234567890abcdef1234567890abcdef12345678', + })), })); const mockTransaction = { @@ -334,9 +342,11 @@ describe('PerpsPositionTransactionView', () => { }); it('should not navigate to block explorer when no selected account', () => { - (selectSelectedInternalAccount as unknown as jest.Mock).mockReturnValue( - null, - ); + // Mock the multichain selector to return undefined + jest + .mocked(selectSelectedInternalAccountByScope) + .mockReturnValueOnce(() => undefined); + const { getByText } = renderWithProvider(, { state: mockInitialState, }); diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx index 4d8cd4718c7e..63e52a6594a8 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx @@ -19,7 +19,7 @@ import Button, { ButtonWidthTypes, } from '../../../../../component-library/components/Buttons/Button'; import { useStyles } from '../../../../../component-library/hooks'; -import { selectSelectedInternalAccount } from '../../../../../selectors/accountsController'; +import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts'; import ScreenView from '../../../../Base/ScreenView'; import { getPerpsTransactionsDetailsNavbar } from '../../../Navbar'; import PerpsTransactionDetailAssetHero from '../../components/PerpsTransactionDetailAssetHero'; @@ -40,7 +40,9 @@ const PerpsPositionTransactionView: React.FC = () => { const { styles } = useStyles(styleSheet, {}); const navigation = useNavigation>(); const route = useRoute(); - const selectedInternalAccount = useSelector(selectSelectedInternalAccount); + const selectedInternalAccount = useSelector( + selectSelectedInternalAccountByScope, + )('eip155:1'); const { getExplorerUrl } = usePerpsBlockExplorerUrl(); // Get transaction from route params diff --git a/app/components/UI/Perps/components/PerpsErrorState/PerpsErrorState.styles.ts b/app/components/UI/Perps/components/PerpsErrorState/PerpsErrorState.styles.ts new file mode 100644 index 000000000000..3abc3527308f --- /dev/null +++ b/app/components/UI/Perps/components/PerpsErrorState/PerpsErrorState.styles.ts @@ -0,0 +1,39 @@ +import { StyleSheet } from 'react-native'; +import { Theme } from '../../../../../util/theme/models'; + +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + const { colors } = theme; + + return StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.background.default, + paddingHorizontal: 16, + }, + content: { + alignItems: 'center', + maxWidth: 320, + width: '100%', + }, + icon: { + marginBottom: 24, + }, + title: { + marginBottom: 12, + textAlign: 'center', + }, + description: { + marginBottom: 32, + textAlign: 'center', + lineHeight: 20, + }, + button: { + marginTop: 8, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/UI/Perps/components/PerpsErrorState/PerpsErrorState.test.tsx b/app/components/UI/Perps/components/PerpsErrorState/PerpsErrorState.test.tsx new file mode 100644 index 000000000000..34a1a5d6d173 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsErrorState/PerpsErrorState.test.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import PerpsErrorState, { PerpsErrorType } from './PerpsErrorState'; +import { strings } from '../../../../../../locales/i18n'; + +describe('PerpsErrorState', () => { + describe('CONNECTION_FAILED error type', () => { + it('should render connection failed error with retry button', () => { + const onRetryMock = jest.fn(); + const { getByText } = render( + , + ); + + expect( + getByText(strings('perps.errors.connectionFailed.title')), + ).toBeTruthy(); + expect( + getByText(strings('perps.errors.connectionFailed.description')), + ).toBeTruthy(); + + const retryButton = getByText( + strings('perps.errors.connectionFailed.retry'), + ); + expect(retryButton).toBeTruthy(); + + fireEvent.press(retryButton); + expect(onRetryMock).toHaveBeenCalledTimes(1); + }); + + it('should render connection failed without retry when onRetry not provided', () => { + const { getByText, queryByText } = render( + , + ); + + expect( + getByText(strings('perps.errors.connectionFailed.title')), + ).toBeTruthy(); + expect( + queryByText(strings('perps.errors.connectionFailed.retry')), + ).toBeNull(); + }); + }); + + describe('NETWORK_ERROR error type', () => { + it('should render network error with retry button', () => { + const onRetryMock = jest.fn(); + const { getByText } = render( + , + ); + + expect( + getByText(strings('perps.errors.networkError.title')), + ).toBeTruthy(); + expect( + getByText(strings('perps.errors.networkError.description')), + ).toBeTruthy(); + + const retryButton = getByText(strings('perps.errors.networkError.retry')); + expect(retryButton).toBeTruthy(); + + fireEvent.press(retryButton); + expect(onRetryMock).toHaveBeenCalledTimes(1); + }); + }); + + describe('UNKNOWN error type', () => { + it('should render unknown error with retry button when onRetry provided', () => { + const onRetryMock = jest.fn(); + const { getByText } = render( + , + ); + + expect(getByText(strings('perps.errors.unknown.title'))).toBeTruthy(); + expect( + getByText(strings('perps.errors.unknown.description')), + ).toBeTruthy(); + + const retryButton = getByText(strings('perps.errors.unknown.retry')); + expect(retryButton).toBeTruthy(); + + fireEvent.press(retryButton); + expect(onRetryMock).toHaveBeenCalledTimes(1); + }); + + it('should render unknown error without retry button when onRetry not provided', () => { + const { getByText, queryByText } = render( + , + ); + + expect(getByText(strings('perps.errors.unknown.title'))).toBeTruthy(); + expect( + getByText(strings('perps.errors.unknown.description')), + ).toBeTruthy(); + expect(queryByText(strings('perps.errors.unknown.retry'))).toBeNull(); + }); + }); + + describe('Default behavior', () => { + it('should default to UNKNOWN error type when no errorType provided', () => { + const { getByText } = render(); + + expect(getByText(strings('perps.errors.unknown.title'))).toBeTruthy(); + expect( + getByText(strings('perps.errors.unknown.description')), + ).toBeTruthy(); + }); + + it('should use default testID when not provided', () => { + const { getByTestId } = render(); + expect(getByTestId('perps-error-state')).toBeTruthy(); + }); + + it('should use custom testID when provided', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId('custom-error-state')).toBeTruthy(); + }); + }); +}); diff --git a/app/components/UI/Perps/components/PerpsErrorState/PerpsErrorState.tsx b/app/components/UI/Perps/components/PerpsErrorState/PerpsErrorState.tsx new file mode 100644 index 000000000000..1c250a3de0c2 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsErrorState/PerpsErrorState.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { View } from 'react-native'; +import { strings } from '../../../../../../locales/i18n'; +import Button, { + ButtonSize, + ButtonVariants, + ButtonWidthTypes, +} from '../../../../../component-library/components/Buttons/Button'; +import Icon, { + IconColor, + IconName, + IconSize, +} from '../../../../../component-library/components/Icons/Icon'; +import Text, { + TextColor, + TextVariant, +} from '../../../../../component-library/components/Texts/Text'; +import { useStyles } from '../../../../../component-library/hooks'; +import styleSheet from './PerpsErrorState.styles'; + +export enum PerpsErrorType { + CONNECTION_FAILED = 'connection_failed', + NETWORK_ERROR = 'network_error', + UNKNOWN = 'unknown', +} + +interface PerpsErrorStateProps { + errorType?: PerpsErrorType; + onRetry?: () => void; + testID?: string; +} + +/** + * PerpsErrorState - Error state component for Perps tab + * Displays appropriate error messages and actions based on error type + */ +const PerpsErrorState: React.FC = ({ + errorType = PerpsErrorType.UNKNOWN, + onRetry, + testID = 'perps-error-state', +}) => { + const { styles } = useStyles(styleSheet, {}); + + const getErrorContent = () => { + switch (errorType) { + case PerpsErrorType.CONNECTION_FAILED: + return { + icon: IconName.Wifi, + title: strings('perps.errors.connectionFailed.title'), + description: strings('perps.errors.connectionFailed.description'), + primaryAction: { + label: strings('perps.errors.connectionFailed.retry'), + onPress: onRetry, + }, + }; + case PerpsErrorType.NETWORK_ERROR: + return { + icon: IconName.Global, + title: strings('perps.errors.networkError.title'), + description: strings('perps.errors.networkError.description'), + primaryAction: { + label: strings('perps.errors.networkError.retry'), + onPress: onRetry, + }, + }; + default: + return { + icon: IconName.Warning, + title: strings('perps.errors.unknown.title'), + description: strings('perps.errors.unknown.description'), + primaryAction: onRetry + ? { + label: strings('perps.errors.unknown.retry'), + onPress: onRetry, + } + : undefined, + }; + } + }; + + const errorContent = getErrorContent(); + const iconSize = 48 as unknown as IconSize; + + return ( + + + + + {errorContent.title} + + + {errorContent.description} + + {errorContent.primaryAction?.onPress && ( +