Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions app/components/UI/Perps/Perps.testIds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -759,3 +759,13 @@ export const PerpsTransactionsViewSelectorsIDs = {
TAB_FUNDING: 'perps-transactions-tab-funding',
TAB_DEPOSITS: 'perps-transactions-tab-deposits',
} as const;

// ========================================
// PERPS FLIP POSITION CONFIRM SHEET SELECTORS
// ========================================

export const PerpsFlipPositionConfirmSheetSelectorsIDs = {
SHEET: 'perps-flip-position-confirm-sheet',
CANCEL_BUTTON: 'perps-flip-position-cancel-button',
FLIP_BUTTON: 'perps-flip-position-flip-button',
} as const;
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from '@testing-library/react-native';
import PerpsFlipPositionConfirmSheet from './PerpsFlipPositionConfirmSheet';
import { type Position } from '@metamask/perps-controller';
import { usePerpsOrderFees } from '../../hooks';

const mockHandleFlipPosition = jest.fn();
let mockIsFlipping = false;
Expand Down Expand Up @@ -52,12 +53,12 @@ jest.mock('../../../../../../locales/i18n', () => ({
}));

jest.mock('../../hooks', () => ({
usePerpsOrderFees: () => ({
usePerpsOrderFees: jest.fn(() => ({
totalFee: 0.5,
makerFee: 0.2,
takerFee: 0.3,
isLoadingMetamaskFee: false,
}),
})),
usePerpsRewards: () => ({
shouldShowRewardsRow: false,
estimatedPoints: undefined,
Expand Down Expand Up @@ -270,6 +271,12 @@ describe('PerpsFlipPositionConfirmSheet', () => {
beforeEach(() => {
jest.clearAllMocks();
mockIsFlipping = false;
(usePerpsOrderFees as jest.Mock).mockReturnValue({
totalFee: 0.5,
makerFee: 0.2,
takerFee: 0.3,
isLoadingMetamaskFee: false,
});
});

it('renders the flip position title', () => {
Expand Down Expand Up @@ -366,4 +373,13 @@ describe('PerpsFlipPositionConfirmSheet', () => {
// Math.abs(-2.5) = 2.5
expect(screen.getByText('2.5 ETH')).toBeOnTheScreen();
});

it('passes 2x position notional to usePerpsOrderFees for accurate fee estimate', () => {
render(<PerpsFlipPositionConfirmSheet position={mockLongPosition} />);

// ETH position: size=2.5, markPrice=2502 → 2x notional = 2.5 * 2 * 2502 = 12510
expect(usePerpsOrderFees).toHaveBeenCalledWith(
expect.objectContaining({ amount: '12510' }),
);
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useCallback, useMemo, useRef } from 'react';
import { View, ActivityIndicator } from 'react-native';
import { strings } from '../../../../../../locales/i18n';
import { PerpsFlipPositionConfirmSheetSelectorsIDs } from '../../Perps.testIds';
import BottomSheet, {
BottomSheetRef,
} from '../../../../../component-library/components/BottomSheets/BottomSheet';
Expand Down Expand Up @@ -67,9 +68,11 @@ const PerpsFlipPositionConfirmSheet: React.FC<
const price = parseFloat(currentPrice?.price || '0');
const markPrice = parseFloat(currentPrice?.markPrice || '0');

// Calculate USD amount for fee estimation
// Calculate USD amount for fee estimation.
// A flip places one order of 2x position size (1x to close current, 1x to open opposite).
// Fee is charged on the full 2x notional, so multiply by 2 for an accurate estimate.
const usdAmount = useMemo(
() => (positionSize * (markPrice || price)).toString(),
() => (positionSize * 2 * (markPrice || price)).toString(),
[positionSize, markPrice, price],
);

Expand Down Expand Up @@ -140,6 +143,7 @@ const PerpsFlipPositionConfirmSheet: React.FC<
variant: ButtonVariants.Secondary,
size: ButtonSize.Lg,
disabled: isFlipping,
testID: PerpsFlipPositionConfirmSheetSelectorsIDs.CANCEL_BUTTON,
},
{
label: isFlipping
Expand All @@ -150,6 +154,7 @@ const PerpsFlipPositionConfirmSheet: React.FC<
size: ButtonSize.Lg,
disabled: isFlipping || !hasValidAmount,
danger: true,
testID: PerpsFlipPositionConfirmSheetSelectorsIDs.FLIP_BUTTON,
},
],
[handleCloseInternal, handleReverse, isFlipping, hasValidAmount],
Expand All @@ -160,6 +165,7 @@ const PerpsFlipPositionConfirmSheet: React.FC<
ref={sheetRef}
shouldNavigateBack={!externalSheetRef}
onClose={externalSheetRef ? onClose : undefined}
testID={PerpsFlipPositionConfirmSheetSelectorsIDs.SHEET}
>
<BottomSheetHeader onClose={handleCloseInternal}>
<Text variant={TextVariant.HeadingMD}>
Expand Down
25 changes: 25 additions & 0 deletions app/controllers/perps/services/TradingService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2070,6 +2070,31 @@ describe('TradingService', () => {
).rejects.toThrow(/Insufficient balance for flip fees/);
});

it('allows flip when balance covers 1x notional fee estimate', async () => {
// position: size=0.5, entryPrice=50000
// estimatedFees = positionSize * entryPrice * ESTIMATED_FEE_RATE
// = 0.5 * 50000 * 0.0009 = $22.50 (1x notional, correct)
// pre-fix would compute 2x: 1.0 * 50000 * 0.0009 = $45 → would block this user
mockProvider.getAccountState = jest.fn().mockResolvedValue({
...mockAccountState,
availableBalance: '30', // $30 > $22.50, sufficient with 1x
});
mockProvider.placeOrder.mockResolvedValue({
success: true,
orderId: 'flip-balance-fixed',
filledSize: '1.0',
averagePrice: '50000',
});

const result = await tradingService.flipPosition({
provider: mockProvider,
position: mockPosition,
context: mockContext,
});

expect(result.success).toBe(true);
});

it('throws error when account state cannot be retrieved', async () => {
mockProvider.getAccountState = jest.fn().mockResolvedValue(null);

Expand Down
7 changes: 4 additions & 3 deletions app/controllers/perps/services/TradingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1951,11 +1951,12 @@ export class TradingService {

const availableBalance = parseFloat(accountState.availableBalance);

// Estimate fees (close + open, approximately 0.09% of notional)
// Flip requires 2x position size (1x to close, 1x to open opposite)
// Estimate fees: ESTIMATED_FEE_RATE (0.09%) already accounts for both legs
// (close at 0.045% + open at 0.045% = 0.09% of position notional).
// Apply to 1x notional (positionSize * entryPrice), not 2x (flipSize * entryPrice).
const entryPrice = parseFloat(position.entryPrice);
const flipSize = positionSize * 2;
const notionalValue = flipSize * entryPrice;
const notionalValue = positionSize * entryPrice;
const estimatedFees = notionalValue * ESTIMATED_FEE_RATE;

if (estimatedFees > availableBalance) {
Expand Down
Loading