diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json
index 8e9f816880f4..214fded603b2 100644
--- a/app/_locales/en/messages.json
+++ b/app/_locales/en/messages.json
@@ -5887,6 +5887,10 @@
"message": "Stop loss must be $1 $2 price",
"description": "Validation error when stop loss price is on the wrong side of the reference price. $1 is above/below, $2 is current/entry."
},
+ "perpsStopLossInvalidLiquidationPrice": {
+ "message": "Stop loss must be $1 liquidation price",
+ "description": "Validation error when stop loss crosses the liquidation price boundary. $1 is above/below."
+ },
"perpsSubmitting": {
"message": "Submitting...",
"description": "Loading text shown while an order is being submitted"
diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json
index 8e9f816880f4..214fded603b2 100644
--- a/app/_locales/en_GB/messages.json
+++ b/app/_locales/en_GB/messages.json
@@ -5887,6 +5887,10 @@
"message": "Stop loss must be $1 $2 price",
"description": "Validation error when stop loss price is on the wrong side of the reference price. $1 is above/below, $2 is current/entry."
},
+ "perpsStopLossInvalidLiquidationPrice": {
+ "message": "Stop loss must be $1 liquidation price",
+ "description": "Validation error when stop loss crosses the liquidation price boundary. $1 is above/below."
+ },
"perpsSubmitting": {
"message": "Submitting...",
"description": "Loading text shown while an order is being submitted"
diff --git a/ui/components/app/perps/order-entry/components/auto-close-section/auto-close-section.test.tsx b/ui/components/app/perps/order-entry/components/auto-close-section/auto-close-section.test.tsx
index f96d1c9821b3..642bbac630eb 100644
--- a/ui/components/app/perps/order-entry/components/auto-close-section/auto-close-section.test.tsx
+++ b/ui/components/app/perps/order-entry/components/auto-close-section/auto-close-section.test.tsx
@@ -646,6 +646,24 @@ describe('AutoCloseSection', () => {
);
});
+ it('shows liquidation-boundary error when long SL is below liquidation price', () => {
+ renderWithProvider(
+ ,
+ mockStore,
+ );
+
+ expect(screen.getByTestId('sl-validation-error')).toHaveTextContent(
+ /above.*liquidation/iu,
+ );
+ });
+
it('uses limit price as reference for limit orders', () => {
renderWithProvider(
= ({
enabled,
@@ -65,6 +65,7 @@ export const AutoCloseSection: React.FC = ({
onStopLossPriceChange,
direction,
currentPrice,
+ liquidationPrice,
entryPrice: entryPriceProp,
estimatedSize,
orderType,
@@ -352,17 +353,24 @@ export const AutoCloseSection: React.FC = ({
[takeProfitPrice, validationReferencePrice, direction],
);
+ const stopLossValidation = useMemo(
+ () =>
+ getStopLossValidationResult(stopLossPrice, {
+ currentPrice: validationReferencePrice,
+ direction,
+ liquidationPrice,
+ }),
+ [stopLossPrice, validationReferencePrice, direction, liquidationPrice],
+ );
+
const isSlInvalid = useMemo(
() =>
Boolean(
stopLossPrice.trim() &&
validationReferencePrice > 0 &&
- !isValidStopLossPrice(stopLossPrice, {
- currentPrice: validationReferencePrice,
- direction,
- }),
+ !stopLossValidation.isValid,
),
- [stopLossPrice, validationReferencePrice, direction],
+ [stopLossPrice, validationReferencePrice, stopLossValidation.isValid],
);
const tpErrorMessage = useMemo(() => {
@@ -379,11 +387,18 @@ export const AutoCloseSection: React.FC = ({
if (!isSlInvalid) {
return null;
}
- return t('perpsStopLossInvalidPrice', [
- getStopLossErrorDirection(direction),
- priceLabel,
- ]);
- }, [isSlInvalid, direction, priceLabel, t]);
+ return t(
+ stopLossValidation.referencePrice === 'liquidation'
+ ? 'perpsStopLossInvalidLiquidationPrice'
+ : 'perpsStopLossInvalidPrice',
+ [
+ stopLossValidation.direction,
+ stopLossValidation.referencePrice === 'liquidation'
+ ? undefined
+ : priceLabel,
+ ].filter(Boolean) as string[],
+ );
+ }, [isSlInvalid, stopLossValidation, priceLabel, t]);
return (
diff --git a/ui/components/app/perps/order-entry/order-entry.tsx b/ui/components/app/perps/order-entry/order-entry.tsx
index cd268b88c27e..e70c48c74078 100644
--- a/ui/components/app/perps/order-entry/order-entry.tsx
+++ b/ui/components/app/perps/order-entry/order-entry.tsx
@@ -352,6 +352,7 @@ export const OrderEntry: React.FC = ({
onStopLossPriceChange={handleStopLossPriceChange}
direction={formState.direction}
currentPrice={currentPrice}
+ liquidationPrice={calculations.liquidationPriceRaw}
leverage={formState.leverage}
entryPrice={undefined}
estimatedSize={estimatedSize}
diff --git a/ui/components/app/perps/order-entry/order-entry.types.ts b/ui/components/app/perps/order-entry/order-entry.types.ts
index 0d7c00dd153e..a7ad9e2c6c8c 100644
--- a/ui/components/app/perps/order-entry/order-entry.types.ts
+++ b/ui/components/app/perps/order-entry/order-entry.types.ts
@@ -215,6 +215,8 @@ export type AutoCloseSectionProps = {
direction: OrderDirection;
/** Current asset price (for TP/SL calculations and new orders) */
currentPrice: number;
+ /** Estimated liquidation price as a raw number for stop-loss boundary validation */
+ liquidationPrice?: number | null;
/** Position entry price (for modify mode - use instead of currentPrice for accurate % calc) */
entryPrice?: number;
/** Signed position size in asset units (positive=long, negative=short) for estimated PnL */
diff --git a/ui/components/app/perps/update-tpsl/update-tpsl-modal-content.test.tsx b/ui/components/app/perps/update-tpsl/update-tpsl-modal-content.test.tsx
index 44ea70c5f3d8..aa392c12db77 100644
--- a/ui/components/app/perps/update-tpsl/update-tpsl-modal-content.test.tsx
+++ b/ui/components/app/perps/update-tpsl/update-tpsl-modal-content.test.tsx
@@ -167,6 +167,33 @@ describe('UpdateTPSLModalContent', () => {
});
});
+ describe('stop loss validation', () => {
+ it('shows a liquidation-price validation error and disables submit for short positions above liquidation', async () => {
+ const shortPosition = mockPositions[1];
+
+ renderTpslModalContent({
+ position: shortPosition,
+ currentPrice: 45000,
+ });
+
+ const slInput = screen.getAllByPlaceholderText(
+ '0.00',
+ )[1] as HTMLInputElement;
+
+ fireEvent.change(slInput, { target: { value: '50000' } });
+
+ expect(screen.getByTestId('sl-validation-error')).toHaveTextContent(
+ 'Stop loss must be below liquidation price',
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getByTestId('perps-update-tpsl-modal-submit'),
+ ).toBeDisabled();
+ });
+ });
+ });
+
describe('initialization', () => {
it('initializes TP/SL prices from position data', () => {
renderTpslModalContent();
diff --git a/ui/components/app/perps/update-tpsl/update-tpsl-modal-content.tsx b/ui/components/app/perps/update-tpsl/update-tpsl-modal-content.tsx
index 4db68e021b2c..49e11eb5533c 100644
--- a/ui/components/app/perps/update-tpsl/update-tpsl-modal-content.tsx
+++ b/ui/components/app/perps/update-tpsl/update-tpsl-modal-content.tsx
@@ -48,9 +48,8 @@ import {
import { PerpsGeoBlockModal } from '../perps-geo-block-modal';
import {
isValidTakeProfitPrice,
- isValidStopLossPrice,
+ getStopLossValidationResult,
getTakeProfitErrorDirection,
- getStopLossErrorDirection,
} from '../utils/tpslValidation';
// RoE (Return on Equity) preset percentages - matching mobile
@@ -250,17 +249,35 @@ export const UpdateTPSLModalContent: React.FC = ({
[editingTpPrice, currentPrice, positionDirection],
);
+ const liquidationPrice = useMemo(() => {
+ if (!position.liquidationPrice) {
+ return null;
+ }
+
+ const parsedLiquidationPrice = Number.parseFloat(
+ position.liquidationPrice.replaceAll(',', ''),
+ );
+ return Number.isNaN(parsedLiquidationPrice) ? null : parsedLiquidationPrice;
+ }, [position.liquidationPrice]);
+
+ const stopLossValidation = useMemo(
+ () =>
+ getStopLossValidationResult(editingSlPrice, {
+ currentPrice,
+ direction: positionDirection,
+ liquidationPrice,
+ }),
+ [editingSlPrice, currentPrice, positionDirection, liquidationPrice],
+ );
+
const isSlInvalid = useMemo(
() =>
Boolean(
editingSlPrice.replaceAll(',', '').trim() &&
currentPrice > 0 &&
- !isValidStopLossPrice(editingSlPrice, {
- currentPrice,
- direction: positionDirection,
- }),
+ !stopLossValidation.isValid,
),
- [editingSlPrice, currentPrice, positionDirection],
+ [editingSlPrice, currentPrice, stopLossValidation.isValid],
);
const hasInvalidTPSL = isTpInvalid || isSlInvalid;
@@ -755,10 +772,17 @@ export const UpdateTPSLModalContent: React.FC = ({
color={TextColor.ErrorDefault}
data-testid="sl-validation-error"
>
- {t('perpsStopLossInvalidPrice', [
- getStopLossErrorDirection(positionDirection),
- 'current',
- ])}
+ {t(
+ stopLossValidation.referencePrice === 'liquidation'
+ ? 'perpsStopLossInvalidLiquidationPrice'
+ : 'perpsStopLossInvalidPrice',
+ [
+ stopLossValidation.direction,
+ stopLossValidation.referencePrice === 'liquidation'
+ ? undefined
+ : 'current',
+ ].filter(Boolean) as string[],
+ )}
)}
diff --git a/ui/components/app/perps/utils/tpslValidation.test.ts b/ui/components/app/perps/utils/tpslValidation.test.ts
index 1c3067b19c8f..76351fedb4b9 100644
--- a/ui/components/app/perps/utils/tpslValidation.test.ts
+++ b/ui/components/app/perps/utils/tpslValidation.test.ts
@@ -1,6 +1,7 @@
import {
isValidTakeProfitPrice,
isValidStopLossPrice,
+ getStopLossValidationResult,
getTakeProfitErrorDirection,
getStopLossErrorDirection,
} from './tpslValidation';
@@ -132,6 +133,26 @@ describe('tpslValidation', () => {
}),
).toBe(false);
});
+
+ it('returns false when SL is below liquidation price', () => {
+ expect(
+ isValidStopLossPrice('39000', {
+ currentPrice,
+ direction: 'long',
+ liquidationPrice: 40000,
+ }),
+ ).toBe(false);
+ });
+
+ it('returns true when SL is between current and liquidation price', () => {
+ expect(
+ isValidStopLossPrice('45000', {
+ currentPrice,
+ direction: 'long',
+ liquidationPrice: 40000,
+ }),
+ ).toBe(true);
+ });
});
describe('short direction', () => {
@@ -152,6 +173,26 @@ describe('tpslValidation', () => {
}),
).toBe(false);
});
+
+ it('returns false when SL is above liquidation price', () => {
+ expect(
+ isValidStopLossPrice('61000', {
+ currentPrice,
+ direction: 'short',
+ liquidationPrice: 60000,
+ }),
+ ).toBe(false);
+ });
+
+ it('returns true when SL is between current and liquidation price', () => {
+ expect(
+ isValidStopLossPrice('55000', {
+ currentPrice,
+ direction: 'short',
+ liquidationPrice: 60000,
+ }),
+ ).toBe(true);
+ });
});
describe('edge cases', () => {
@@ -180,6 +221,16 @@ describe('tpslValidation', () => {
expect(isValidStopLossPrice('100', { currentPrice })).toBe(true);
});
+ it('falls back to current price validation when liquidation price is unavailable', () => {
+ expect(
+ isValidStopLossPrice('55000', {
+ currentPrice,
+ direction: 'short',
+ liquidationPrice: null,
+ }),
+ ).toBe(true);
+ });
+
it('handles formatted prices with commas and dollar signs', () => {
expect(
isValidStopLossPrice('$45,000', {
@@ -198,6 +249,52 @@ describe('tpslValidation', () => {
});
});
+ describe('getStopLossValidationResult', () => {
+ const currentPrice = 50000;
+
+ it('reports current price boundary errors for short positions', () => {
+ expect(
+ getStopLossValidationResult('45000', {
+ currentPrice,
+ direction: 'short',
+ liquidationPrice: 60000,
+ }),
+ ).toStrictEqual({
+ isValid: false,
+ direction: 'above',
+ referencePrice: 'current',
+ });
+ });
+
+ it('reports liquidation price boundary errors for short positions', () => {
+ expect(
+ getStopLossValidationResult('61000', {
+ currentPrice,
+ direction: 'short',
+ liquidationPrice: 60000,
+ }),
+ ).toStrictEqual({
+ isValid: false,
+ direction: 'below',
+ referencePrice: 'liquidation',
+ });
+ });
+
+ it('reports liquidation price boundary errors for long positions', () => {
+ expect(
+ getStopLossValidationResult('39000', {
+ currentPrice,
+ direction: 'long',
+ liquidationPrice: 40000,
+ }),
+ ).toStrictEqual({
+ isValid: false,
+ direction: 'above',
+ referencePrice: 'liquidation',
+ });
+ });
+ });
+
describe('getTakeProfitErrorDirection', () => {
it('returns "above" for long', () => {
expect(getTakeProfitErrorDirection('long')).toBe('above');
diff --git a/ui/components/app/perps/utils/tpslValidation.ts b/ui/components/app/perps/utils/tpslValidation.ts
index f37aacb3156f..bf027404534d 100644
--- a/ui/components/app/perps/utils/tpslValidation.ts
+++ b/ui/components/app/perps/utils/tpslValidation.ts
@@ -16,6 +16,13 @@ type Direction = 'long' | 'short';
type ValidationParams = {
currentPrice: number;
direction?: Direction;
+ liquidationPrice?: number | null;
+};
+
+export type StopLossValidationResult = {
+ isValid: boolean;
+ direction: 'above' | 'below' | '';
+ referencePrice: 'current' | 'liquidation' | '';
};
/**
@@ -52,21 +59,69 @@ export const isValidTakeProfitPrice = (
* @param params.currentPrice
* @param params.direction
*/
-export const isValidStopLossPrice = (
+export function isValidStopLossPrice(price: string, params: ValidationParams) {
+ return getStopLossValidationResult(price, params).isValid;
+}
+
+/**
+ * Validates stop loss against both the current price and, when available,
+ * the liquidation price. A valid stop loss must stay between those two
+ * boundaries for the current position direction.
+ *
+ * @param price - The stop loss price string (may include `$` / `,` formatting)
+ * @param params - Object with `currentPrice`, `direction`, and optional `liquidationPrice`
+ * @param params.currentPrice
+ * @param params.direction
+ * @param params.liquidationPrice
+ */
+export function getStopLossValidationResult(
price: string,
- { currentPrice, direction }: ValidationParams,
-): boolean => {
+ { currentPrice, direction, liquidationPrice }: ValidationParams,
+): StopLossValidationResult {
if (!currentPrice || !direction || !price) {
- return true;
+ return { isValid: true, direction: '', referencePrice: '' };
}
const slPrice = Number.parseFloat(price.replaceAll(/[$,]/gu, ''));
if (Number.isNaN(slPrice)) {
- return true;
+ return { isValid: true, direction: '', referencePrice: '' };
}
- return direction === 'long' ? slPrice < currentPrice : slPrice > currentPrice;
-};
+ const isWithinCurrentBoundary =
+ direction === 'long' ? slPrice < currentPrice : slPrice > currentPrice;
+
+ if (!isWithinCurrentBoundary) {
+ return {
+ isValid: false,
+ direction: direction === 'long' ? 'below' : 'above',
+ referencePrice: 'current',
+ };
+ }
+
+ if (
+ liquidationPrice === undefined ||
+ liquidationPrice === null ||
+ !Number.isFinite(liquidationPrice) ||
+ liquidationPrice <= 0
+ ) {
+ return { isValid: true, direction: '', referencePrice: '' };
+ }
+
+ const isWithinLiquidationBoundary =
+ direction === 'long'
+ ? slPrice > liquidationPrice
+ : slPrice < liquidationPrice;
+
+ if (!isWithinLiquidationBoundary) {
+ return {
+ isValid: false,
+ direction: direction === 'long' ? 'above' : 'below',
+ referencePrice: 'liquidation',
+ };
+ }
+
+ return { isValid: true, direction: '', referencePrice: '' };
+}
/**
* Returns the directional word for a take-profit validation error message.
diff --git a/ui/pages/perps/perps-order-entry-page.test.tsx b/ui/pages/perps/perps-order-entry-page.test.tsx
index 74891fd8bb26..dfe89e0b2ad0 100644
--- a/ui/pages/perps/perps-order-entry-page.test.tsx
+++ b/ui/pages/perps/perps-order-entry-page.test.tsx
@@ -784,6 +784,33 @@ describe('PerpsOrderEntryPage', () => {
expect(screen.getByTestId('submit-order-button')).toBeDisabled();
});
});
+
+ it('disables submit when auto-close stop loss crosses liquidation price', async () => {
+ const store = mockStore(createMockState());
+ renderWithProvider(, store);
+
+ const amountContainer = screen.getByTestId('amount-input-field');
+ const amountInput = amountContainer.querySelector('input');
+ fireEvent.change(amountInput as HTMLInputElement, {
+ target: { value: '1000' },
+ });
+
+ fireEvent.click(screen.getByTestId('auto-close-toggle'));
+
+ const slContainer = screen.getByTestId('sl-price-input');
+ const slInput = slContainer.querySelector('input');
+ fireEvent.change(slInput as HTMLInputElement, {
+ target: { value: '1' },
+ });
+
+ expect(screen.getByTestId('sl-validation-error')).toHaveTextContent(
+ /liquidation/iu,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('submit-order-button')).toBeDisabled();
+ });
+ });
});
describe('order submission', () => {
diff --git a/ui/pages/perps/perps-order-entry-page.tsx b/ui/pages/perps/perps-order-entry-page.tsx
index 72dbfcd86fd3..6cdb16448f53 100644
--- a/ui/pages/perps/perps-order-entry-page.tsx
+++ b/ui/pages/perps/perps-order-entry-page.tsx
@@ -88,7 +88,7 @@ import {
} from '../../components/app/perps/order-entry/limit-price-warnings';
import {
isValidTakeProfitPrice,
- isValidStopLossPrice,
+ getStopLossValidationResult,
} from '../../components/app/perps/utils/tpslValidation';
import { PerpsDetailPageSkeleton } from '../../components/app/perps/perps-skeletons';
import {
@@ -514,14 +514,15 @@ const PerpsOrderEntryPage: React.FC = () => {
);
const slInvalid = Boolean(
sl?.trim() &&
- !isValidStopLossPrice(sl, {
+ !getStopLossValidationResult(sl, {
currentPrice: referencePrice,
direction: dir,
- }),
+ liquidationPrice: orderCalculations?.liquidationPriceRaw,
+ }).isValid,
);
return tpInvalid || slInvalid;
- }, [orderFormState, orderType, currentPrice, orderMode]);
+ }, [orderFormState, orderType, currentPrice, orderMode, orderCalculations]);
const isInsufficientFunds = useMemo(() => {
if (!orderFormState || orderMode === 'close') {