Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,19 @@ jest.mock('react-native/Libraries/Linking/Linking', () => ({
}));

// Mock PerpsStreamManager
jest.mock('../../providers/PerpsStreamManager');
jest.mock('../../providers/PerpsStreamManager', () => ({
usePerpsStream: jest.fn(() => ({
prices: { subscribeToSymbols: jest.fn(() => jest.fn()) },
positions: { subscribe: jest.fn(() => jest.fn()) },
orders: { subscribe: jest.fn(() => jest.fn()) },
fills: { subscribe: jest.fn(() => jest.fn()) },
account: { subscribe: jest.fn(() => jest.fn()) },
marketData: { subscribe: jest.fn(() => jest.fn()), getMarkets: jest.fn() },
oiCaps: { subscribe: jest.fn(() => jest.fn()) },
})),
PerpsStreamProvider: ({ children }: { children: React.ReactNode }) =>
children,
}));

// Mock Redux selectors for chart preferences
jest.mock('../../selectors/chartPreferences', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
usePerpsNetworkManagement,
usePerpsNavigation,
} from '../../hooks';
import { usePerpsOICap } from '../../hooks/usePerpsOICap';
import {
usePerpsDataMonitor,
type DataMonitorParams,
Expand All @@ -66,6 +67,7 @@ import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement';
import { usePerpsLiveOrders, usePerpsLiveAccount } from '../../hooks/stream';
import PerpsMarketTabs from '../../components/PerpsMarketTabs/PerpsMarketTabs';
import type { PerpsTabId } from '../../components/PerpsMarketTabs/PerpsMarketTabs.types';
import PerpsOICapWarning from '../../components/PerpsOICapWarning';
import PerpsNotificationTooltip from '../../components/PerpsNotificationTooltip';
import PerpsNavigationCard, {
type NavigationItem,
Expand Down Expand Up @@ -183,6 +185,9 @@ const PerpsMarketDetailsView: React.FC<PerpsMarketDetailsViewProps> = () => {
const { depositWithConfirmation } = usePerpsTrading();
const { ensureArbitrumNetworkExists } = usePerpsNetworkManagement();

// Check if market is at open interest cap
const { isAtCap: isAtOICap } = usePerpsOICap(market?.symbol);

// Programmatic tab control state for data-driven navigation
const [programmaticActiveTab, setProgrammaticActiveTab] = useState<
string | null
Expand Down Expand Up @@ -741,31 +746,40 @@ const PerpsMarketDetailsView: React.FC<PerpsMarketDetailsViewProps> = () => {
)}

{hasLongShortButtons && (
<View style={styles.actionsContainer}>
<View style={styles.actionButtonWrapper}>
<ButtonSemantic
severity={ButtonSemanticSeverity.Success}
onPress={handleLongPress}
isFullWidth
size={ButtonSizeRNDesignSystem.Lg}
testID={PerpsMarketDetailsViewSelectorsIDs.LONG_BUTTON}
>
{strings('perps.market.long')}
</ButtonSemantic>
</View>

<View style={styles.actionButtonWrapper}>
<ButtonSemantic
severity={ButtonSemanticSeverity.Danger}
onPress={handleShortPress}
isFullWidth
size={ButtonSizeRNDesignSystem.Lg}
testID={PerpsMarketDetailsViewSelectorsIDs.SHORT_BUTTON}
>
{strings('perps.market.short')}
</ButtonSemantic>
<>
{/* OI Cap Warning - Shows when market is at capacity */}
{market?.symbol && (
<PerpsOICapWarning symbol={market.symbol} variant="inline" />
)}

<View style={styles.actionsContainer}>
<View style={styles.actionButtonWrapper}>
<ButtonSemantic
severity={ButtonSemanticSeverity.Success}
onPress={handleLongPress}
isFullWidth
size={ButtonSizeRNDesignSystem.Lg}
isDisabled={isAtOICap}
testID={PerpsMarketDetailsViewSelectorsIDs.LONG_BUTTON}
>
{strings('perps.market.long')}
</ButtonSemantic>
</View>

<View style={styles.actionButtonWrapper}>
<ButtonSemantic
severity={ButtonSemanticSeverity.Danger}
onPress={handleShortPress}
isFullWidth
size={ButtonSizeRNDesignSystem.Lg}
isDisabled={isAtOICap}
testID={PerpsMarketDetailsViewSelectorsIDs.SHORT_BUTTON}
>
{strings('perps.market.short')}
</ButtonSemantic>
</View>
</View>
</View>
</>
)}
</View>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -456,14 +456,14 @@ const defaultMockRoute = {

const defaultMockHooks = {
usePerpsLiveAccount: {
balance: '1000',
availableBalance: '1000',
accountInfo: {
marginSummary: {
accountValue: 1000,
totalMarginUsed: 0,
},
account: {
availableBalance: '1000',
marginUsed: '0',
unrealizedPnl: '0',
returnOnEquity: '0',
totalBalance: '1000',
},
isInitialLoading: false,
},
usePerpsTrading: {
placeOrder: jest.fn(),
Expand Down Expand Up @@ -597,6 +597,9 @@ const createMockStreamManager = () => {
subscribe: jest.fn(() => jest.fn()),
getMarkets: jest.fn(),
},
oiCaps: {
subscribe: jest.fn(() => jest.fn()),
},
};
};

Expand Down Expand Up @@ -2835,34 +2838,32 @@ describe('PerpsOrderView', () => {
});

describe('Insufficient funds handling', () => {
it('should show insufficient funds button when amount times leverage is below minimum', () => {
// Arrange - Mock low balance and high minimum
it('should not show balance warning when account is still loading', async () => {
// This test verifies our loading guard fix - balance warnings shouldn't
// appear while account data is still loading
(usePerpsLiveAccount as jest.Mock).mockReturnValue({
account: {
availableBalance: '1', // Very low balance
availableBalance: '0', // Zero balance
marginUsed: '0',
unrealizedPnl: '0',
returnOnEquity: '0',
totalBalance: '1',
totalBalance: '0',
},
isInitialLoading: false,
isInitialLoading: true, // Still loading - warning should NOT appear
});

(useMinimumOrderAmount as jest.Mock).mockReturnValue({
minimumOrderAmount: 1000, // High minimum
isLoading: false,
});

const { getByText } = render(
const { queryByText } = render(
<SafeAreaProvider initialMetrics={initialMetrics}>
<TestWrapper>
<PerpsOrderView />
</TestWrapper>
</SafeAreaProvider>,
);

// Assert - Should show insufficient funds message
expect(getByText('Insufficient funds')).toBeOnTheScreen();
// Assert - Should NOT show "No funds available" warning while loading
expect(
queryByText('No funds available. Please deposit first.'),
).toBeNull();
});

it('should show normal place order button when amount is sufficient', () => {
Expand Down Expand Up @@ -2937,16 +2938,17 @@ describe('PerpsOrderView', () => {
},
});

const { getByText } = render(
const { getByTestId, getByText } = render(
<SafeAreaProvider initialMetrics={initialMetrics}>
<TestWrapper>
<PerpsOrderView />
</TestWrapper>
</SafeAreaProvider>,
);

// Assert - Should display asset in header title and price
expect(getByText('Long BTC')).toBeOnTheScreen();
// Assert - Should display asset in header title using testID to avoid duplicate text matches
const headerTitle = getByTestId('perps-order-header-asset-title');
expect(headerTitle).toHaveTextContent('Long BTC');
expect(getByText('$3,000')).toBeOnTheScreen(); // Price from mock data
});
});
Expand Down
51 changes: 33 additions & 18 deletions app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import PerpsBottomSheetTooltip from '../../components/PerpsBottomSheetTooltip';
import { PerpsTooltipContentKey } from '../../components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types';
import PerpsLeverageBottomSheet from '../../components/PerpsLeverageBottomSheet';
import PerpsLimitPriceBottomSheet from '../../components/PerpsLimitPriceBottomSheet';
import PerpsOICapWarning from '../../components/PerpsOICapWarning';
import PerpsOrderHeader from '../../components/PerpsOrderHeader';
import PerpsOrderTypeBottomSheet from '../../components/PerpsOrderTypeBottomSheet';
import PerpsSlider from '../../components/PerpsSlider';
Expand Down Expand Up @@ -92,6 +93,7 @@ import {
} from '../../hooks/stream';
import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking';
import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement';
import { usePerpsOICap } from '../../hooks/usePerpsOICap';
import {
formatPerpsFiat,
PRICE_RANGES_MINIMAL_VIEW,
Expand Down Expand Up @@ -168,7 +170,7 @@ const PerpsOrderViewContentBase: React.FC = () => {
const orderStartTimeRef = useRef<number>(0);
const inputMethodRef = useRef<InputMethod>('default');

const { account } = usePerpsLiveAccount();
const { account, isInitialLoading: isLoadingAccount } = usePerpsLiveAccount();

// Get real HyperLiquid USDC balance
const availableBalance = parseFloat(
Expand Down Expand Up @@ -224,6 +226,9 @@ const PerpsOrderViewContentBase: React.FC = () => {
loadOnMount: true,
});

// Check if market is at OI cap (zero network overhead - uses existing webData2 subscription)
const { isAtCap: isAtOICap } = usePerpsOICap(orderForm.asset);

// Markets data for navigation
const { markets } = usePerpsMarkets();

Expand Down Expand Up @@ -349,7 +354,8 @@ const PerpsOrderViewContentBase: React.FC = () => {

// Show error toast if market data is not available
useEffect(() => {
if (marketDataError) {
// Don't show error during initial load - only for persistent failures
if (marketDataError && !isLoadingMarketData) {
showToast(
PerpsToastOptions.dataFetching.market.error.marketDataUnavailable(
orderForm.asset,
Expand All @@ -358,6 +364,7 @@ const PerpsOrderViewContentBase: React.FC = () => {
}
}, [
marketDataError,
isLoadingMarketData,
orderForm.asset,
navigation,
showToast,
Expand Down Expand Up @@ -836,7 +843,8 @@ const PerpsOrderViewContentBase: React.FC = () => {
orderForm.direction === 'long'
? 'perps.order.button.long'
: 'perps.order.button.short';
const isInsufficientFunds = amountTimesLeverage < minimumOrderAmount;
const isInsufficientFunds =
!isLoadingAccount && amountTimesLeverage < minimumOrderAmount;
const placeOrderLabel = isInsufficientFunds
? strings('perps.order.validation.insufficient_funds')
: strings(orderButtonKey, { asset: orderForm.asset });
Expand Down Expand Up @@ -876,7 +884,7 @@ const PerpsOrderViewContentBase: React.FC = () => {
{/* Amount Display */}
<PerpsAmountDisplay
amount={orderForm.amount}
showWarning={availableBalance === 0}
showWarning={!isLoadingAccount && availableBalance === 0}
onPress={handleAmountPress}
isActive={isInputFocused}
tokenAmount={positionSize}
Expand Down Expand Up @@ -1214,19 +1222,25 @@ const PerpsOrderViewContentBase: React.FC = () => {
{/* Fixed Place Order Button - Hide when keypad is active */}
{!isInputFocused && (
<View style={fixedBottomContainerStyle}>
{filteredErrors.length > 0 && (
<View style={styles.validationContainer}>
{filteredErrors.map((error) => (
<Text
key={error}
variant={TextVariant.BodySM}
color={TextColor.Error}
>
{error}
</Text>
))}
</View>
)}
{filteredErrors.length > 0 &&
!isLoadingMarketData &&
currentPrice != null &&
!orderValidation.isValidating && (
<View style={styles.validationContainer}>
{filteredErrors.map((error) => (
<Text
key={error}
variant={TextVariant.BodySM}
color={TextColor.Error}
>
{error}
</Text>
))}
</View>
)}

{/* OI Cap Warning - Only shows when market is at capacity */}
<PerpsOICapWarning symbol={orderForm.asset} variant="inline" />

<ButtonSemantic
severity={
Expand All @@ -1240,7 +1254,8 @@ const PerpsOrderViewContentBase: React.FC = () => {
isDisabled={
!orderValidation.isValid ||
isPlacingOrder ||
doesStopLossRiskLiquidation
doesStopLossRiskLiquidation ||
isAtOICap
}
isLoading={isPlacingOrder}
testID={PerpsOrderViewSelectorsIDs.PLACE_ORDER_BUTTON}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { StyleSheet } from 'react-native';
import type { Theme } from '../../../../../util/theme/models';

const styleSheet = (params: { theme: Theme }) => {
const { colors } = params.theme;

return StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 8,
},
bannerContainer: {
backgroundColor: colors.warning.muted,
borderRadius: 8,
padding: 12,
borderWidth: 1,
borderColor: colors.warning.default,
},
inlineContainer: {
paddingVertical: 8,
},
icon: {
marginTop: 2,
},
textContainer: {
flex: 1,
gap: 4,
},
title: {
fontWeight: '600',
},
description: {
lineHeight: 18,
},
});
};

export default styleSheet;
Loading
Loading