From 97bcfe585a365af6cafd55a9efd3fb25bca16845 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Wed, 26 Aug 2026 10:10:05 +0200 Subject: [PATCH 1/8] feat(perps-controller): add isolated position modify preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give clients a read-only HyperLiquid isolated projection so Pro order forms can show before→after margin and liquidation from the same result used for TP/SL, including full-position leverage changes and maintenance tiers. --- packages/perps-controller/CHANGELOG.md | 5 + .../PerpsController-method-action-types.ts | 14 + .../perps-controller/src/PerpsController.ts | 23 + packages/perps-controller/src/index.ts | 19 + .../src/providers/AggregatedPerpsProvider.ts | 8 + .../src/providers/HyperLiquidProvider.ts | 52 +++ .../src/providers/MYXProvider.ts | 8 + .../src/services/MarketDataService.ts | 34 ++ packages/perps-controller/src/types/index.ts | 111 +++++ .../src/utils/hyperLiquidPositionPreview.ts | 442 ++++++++++++++++++ packages/perps-controller/src/utils/index.ts | 1 + .../tests/helpers/providerMocks.ts | 1 + .../src/PerpsController.configuration.test.ts | 1 + .../src/PerpsController.lifecycle.test.ts | 1 + .../src/PerpsController.operations.test.ts | 1 + .../PerpsController.providers-cache.test.ts | 1 + .../tests/src/PerpsController.state.test.ts | 1 + .../src/PerpsController.subscriptions.test.ts | 37 +- .../tests/src/PerpsController.trading.test.ts | 1 + .../providers/AggregatedPerpsProvider.test.ts | 19 + .../HyperLiquidProvider.validation.test.ts | 69 +++ .../tests/src/providers/MYXProvider.test.ts | 21 + .../src/services/MarketDataService.test.ts | 26 ++ .../utils/hyperLiquidPositionPreview.test.ts | 440 +++++++++++++++++ 24 files changed, 1335 insertions(+), 1 deletion(-) create mode 100644 packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts create mode 100644 packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index e41c18baede..396dbcd414e 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **BREAKING:** Add `PerpsController.previewPositionModify` and `PerpsProvider.previewPositionModify` so clients can read an isolated-margin post-trade projection without placing an order ([#XXXX](https://github.com/MetaMask/core/pull/XXXX)) + - Mobile supplies the live position and proposed order; the HyperLiquid provider fetches the asset's margin table and applies selected leverage to the whole resulting position (matching `updateLeverage` before placement). + - The result is a discriminated union (`none` / `unsupported` / `full_close` / `open`) so a non-modifying preview cannot carry a flip kind and a full close cannot report remaining size. Margin and liquidation availability are independent: a missing live liquidation or missing multi-tier table withholds only liquidation. + - Isolated increases, leverage changes, reductions, flips, and full closes are projected. Liquidation uses the maintenance tier at the resulting liquidation notional, including that tier's maintenance deduction. Cross-margin returns `{ status: 'unsupported', reason: 'cross_margin' }`. MYX returns `{ status: 'unsupported', reason: 'provider' }`. + - Consumers that implement `PerpsProvider` must add `previewPositionModify`. Clients should use `resulting.direction` (not the order direction) when validating TP/SL against the projected liquidation. - Add the public Chase lifecycle API (`getChaseOrders` and `suspendChaseOrders`), aggregated-provider routing, retained lifecycle snapshots, directional max-distance stopping, and idempotent termination of diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index 23d060cfbec..1682fda02e0 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -516,6 +516,19 @@ export type PerpsControllerCalculateLiquidationPriceAction = { handler: PerpsController['calculateLiquidationPrice']; }; +/** + * Project the isolated position that would remain after a proposed order. + * Margin and liquidation availability are independent: a missing liquidation + * does not hide a valid margin projection. Cross-margin returns unsupported. + * + * @param params - Live position plus the proposed order. + * @returns Discriminated preview of the resulting position. + */ +export type PerpsControllerPreviewPositionModifyAction = { + type: `PerpsController:previewPositionModify`; + handler: PerpsController['previewPositionModify']; +}; + /** * Calculate maintenance margin for a specific asset * Returns a percentage (e.g., 0.0125 for 1.25%) @@ -1284,6 +1297,7 @@ export type PerpsControllerMethodActions = | PerpsControllerGetAvailableDexsAction | PerpsControllerFetchHistoricalCandlesAction | PerpsControllerCalculateLiquidationPriceAction + | PerpsControllerPreviewPositionModifyAction | PerpsControllerCalculateMaintenanceMarginAction | PerpsControllerGetMaxLeverageAction | PerpsControllerValidateOrderAction diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index e0f7b51d604..8fea0f46260 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -100,6 +100,8 @@ import type { LiquidationPriceParams, LiveDataConfig, MaintenanceMarginParams, + PositionModifyPreviewParams, + PositionModifyPreviewResult, MarginResult, MarketInfo, Order, @@ -964,6 +966,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'markFirstOrderCompleted', 'markTutorialCompleted', 'placeOrder', + 'previewPositionModify', 'reconnect', 'recordMarketViewed', 'refreshEligibility', @@ -4518,6 +4521,26 @@ export class PerpsController extends BaseController< }); } + /** + * Project the isolated position that would remain after a proposed order. + * Margin and liquidation availability are independent: a missing liquidation + * does not hide a valid margin projection. Cross-margin returns unsupported. + * + * @param params - Live position plus the proposed order. + * @returns Discriminated preview of the resulting position. + */ + async previewPositionModify( + params: PositionModifyPreviewParams, + ): Promise { + const provider = this.getActiveProvider(); + const context = this.#createServiceContext('previewPositionModify'); + return this.#marketDataService.previewPositionModify({ + provider, + params, + context, + }); + } + /** * Calculate maintenance margin for a specific asset * Returns a percentage (e.g., 0.0125 for 1.25%) diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 9c566c48f61..12ce7eb7af3 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -61,6 +61,7 @@ export type { PerpsControllerCalculateFeesAction, PerpsControllerCalculateLiquidationPriceAction, PerpsControllerCalculateMaintenanceMarginAction, + PerpsControllerPreviewPositionModifyAction, PerpsControllerCancelOrderAction, PerpsControllerCancelOrdersAction, PerpsControllerClearDepositResultAction, @@ -261,6 +262,16 @@ export type { SubscribeOrderBookParams, LiquidationPriceParams, MaintenanceMarginParams, + PositionModifyPreviewParams, + PositionModifyPreviewResult, + PositionModifyPreviewSource, + PositionModifyPreviewKind, + PositionPreviewValue, + PositionModifyPreviewCurrent, + PositionModifyPreviewOpen, + PositionModifyPreviewFullClose, + PositionModifyPreviewUnsupported, + PositionModifyPreviewNone, FeeCalculationParams, FeeCalculationResult, PerpsSubscriptionBenefits, @@ -634,6 +645,14 @@ export { parseAssetName, adaptHyperLiquidLedgerUpdateToUserHistoryItem, } from './utils/index.js'; +export { + previewHyperLiquidIsolatedPositionModify, + resolveHyperLiquidMarginTiers, + buildMaintenanceSchedule, + estimateIsolatedLiquidationPrice, + estimateIsolatedLiquidationPriceAtTier, +} from './utils/index.js'; +export type { HyperLiquidMarginTier } from './utils/index.js'; export { getEnvironment } from './utils/index.js'; export type { FiatRangeConfig } from './utils/index.js'; export { diff --git a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts index 5f906d50d69..116f628a301 100644 --- a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts +++ b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts @@ -55,6 +55,8 @@ import type { LiquidationPriceParams, LiveDataConfig, MaintenanceMarginParams, + PositionModifyPreviewParams, + PositionModifyPreviewResult, MarginResult, MarketInfo, Order, @@ -637,6 +639,12 @@ export class AggregatedPerpsProvider implements PerpsProvider { return this.#getDefaultProvider().calculateFees(params); } + async previewPositionModify( + params: PositionModifyPreviewParams, + ): Promise { + return this.#getDefaultProvider().previewPositionModify(params); + } + // ============================================================================ // Subscriptions (Multiplex via SubscriptionMultiplexer) // ============================================================================ diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index a8b55a1ec7d..77f0efd08bb 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -102,6 +102,8 @@ import type { LiquidationPriceParams, LiveDataConfig, MaintenanceMarginParams, + PositionModifyPreviewParams, + PositionModifyPreviewResult, MarginResult, MarketInfo, Order, @@ -163,6 +165,10 @@ import { formatHyperLiquidSize, parseAssetName, } from '../utils/hyperLiquidAdapter.js'; +import { + previewHyperLiquidIsolatedPositionModify, + resolveHyperLiquidMarginTiers, +} from '../utils/hyperLiquidPositionPreview.js'; import { createErrorResult, getMaxOrderValue, @@ -10804,6 +10810,52 @@ export class HyperLiquidProvider implements PerpsProvider { } } + /** + * Project the isolated position that would remain after a proposed order. + * + * Fetches the asset's margin table from cached meta so liquidation uses the + * maintenance tier at the resulting liquidation notional. Cross-margin + * positions return unsupported without a table lookup. + * + * @param params - Live position plus the proposed order. + * @returns Discriminated preview; margin and liquidation are independently available. + */ + async previewPositionModify( + params: PositionModifyPreviewParams, + ): Promise { + if (params.position.leverage.type === 'cross') { + return { status: 'unsupported', reason: 'cross_margin' }; + } + + const { dex: dexName } = parseAssetName(params.position.symbol); + let marginTiers = null; + + try { + const meta = await this.#getCachedMeta({ dexName }); + const assetInfo = meta.universe.find( + (universeItem) => universeItem.name === params.position.symbol, + ); + marginTiers = resolveHyperLiquidMarginTiers({ + marginTableId: assetInfo?.marginTableId, + maxLeverage: assetInfo?.maxLeverage ?? params.position.maxLeverage, + marginTables: meta.marginTables, + }); + } catch (error) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: margin table unavailable for position preview', + { + symbol: params.position.symbol, + error, + }, + ); + } + + return previewHyperLiquidIsolatedPositionModify({ + ...params, + marginTiers, + }); + } + /** * Calculate liquidation price using HyperLiquid's formula * Formula: liq_price = price - side * margin_available / position_size / (1 - maintenanceMarginRatio * side) diff --git a/packages/perps-controller/src/providers/MYXProvider.ts b/packages/perps-controller/src/providers/MYXProvider.ts index 85f91e01610..9314fd03161 100644 --- a/packages/perps-controller/src/providers/MYXProvider.ts +++ b/packages/perps-controller/src/providers/MYXProvider.ts @@ -55,6 +55,8 @@ import type { LiquidationPriceParams, LiveDataConfig, MaintenanceMarginParams, + PositionModifyPreviewParams, + PositionModifyPreviewResult, MarginResult, MarketInfo, Order, @@ -927,6 +929,12 @@ export class MYXProvider implements PerpsProvider { }; } + async previewPositionModify( + _params: PositionModifyPreviewParams, + ): Promise { + return { status: 'unsupported', reason: 'provider' }; + } + // ============================================================================ // Subscriptions (Stage 1 - No-op) // ============================================================================ diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index df8d9758aeb..eec95293e81 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -25,6 +25,8 @@ import type { GetAvailableDexsParams, LiquidationPriceParams, MaintenanceMarginParams, + PositionModifyPreviewParams, + PositionModifyPreviewResult, FeeCalculationParams, FeeCalculationResult, OrderParams, @@ -1208,6 +1210,38 @@ export class MarketDataService { } } + /** + * Project the position that would remain after a proposed order. + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.params - Live position plus the proposed order. + * @param options.context - The service context for dependencies. + * @returns Discriminated preview of the resulting position. + */ + async previewPositionModify(options: { + provider: PerpsProvider; + params: PositionModifyPreviewParams; + context: ServiceContext; + }): Promise { + const { provider, params } = options; + + try { + return await provider.previewPositionModify(params); + } catch (error) { + this.#deps.logger.error( + ensureError(error, 'MarketDataService.previewPositionModify'), + { + context: { + name: 'MarketDataService.previewPositionModify', + data: { params }, + }, + }, + ); + throw error; + } + } + /** * Calculate maintenance margin for a position * diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 2d2a13c18e6..8ee17ed1651 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1270,6 +1270,108 @@ export type LiquidationPriceParams = { asset?: string; // Optional: for asset-specific maintenance margins }; +/** + * Live position fields required to project a modify. Clients may pass a full + * {@link Position}; extra fields are ignored. + */ +export type PositionModifyPreviewSource = Pick< + Position, + | 'symbol' + | 'size' + | 'marginUsed' + | 'liquidationPrice' + | 'entryPrice' + | 'leverage' + | 'positionValue' + | 'maxLeverage' +>; + +/** + * Proposed order plus the live position it would modify. + * + * Isolated-margin previews apply `leverage` to the *whole* resulting + * position, matching `updateLeverage` before placement. Cross-margin + * positions are not projected. + */ +export type PositionModifyPreviewParams = { + position: PositionModifyPreviewSource; + /** Proposed order direction. */ + direction: 'long' | 'short'; + /** Proposed order size in token units. */ + size: string; + /** Expected fill or limit price. */ + price: string; + /** + * Isolated leverage the provider will set on the asset before placing. + * Applied to the entire resulting position, not only the added size. + */ + leverage: number; + reduceOnly?: boolean; + /** + * Estimated trading fees in USD. Deducted from isolated margin on + * increases and flips. Omit or pass 0 when unknown. + */ + feeAmountUsd?: number; +}; + +/** + * Independently available numeric projection. Margin can be known when + * liquidation cannot (missing maintenance-tier data, or no liquidation risk). + */ +export type PositionPreviewValue = + | { available: true; value: number } + | { available: false }; + +export type PositionModifyPreviewKind = 'increase' | 'decrease' | 'flip'; + +export type PositionModifyPreviewCurrent = { + margin: PositionPreviewValue; + liquidationPrice: PositionPreviewValue; +}; + +export type PositionModifyPreviewOpen = { + status: 'open'; + kind: PositionModifyPreviewKind; + current: PositionModifyPreviewCurrent; + resulting: { + direction: 'long' | 'short'; + /** Resulting token size; always > 0 for `open`. */ + size: number; + entryPrice: number; + leverage: number; + margin: PositionPreviewValue; + liquidationPrice: PositionPreviewValue; + }; +}; + +export type PositionModifyPreviewFullClose = { + status: 'full_close'; + current: PositionModifyPreviewCurrent; + /** Direction of the position being closed. */ + resultingDirection: 'long' | 'short'; +}; + +export type PositionModifyPreviewUnsupported = { + status: 'unsupported'; + reason: 'cross_margin' | 'provider'; +}; + +export type PositionModifyPreviewNone = { + status: 'none'; +}; + +/** + * Read-only post-trade position projection. + * + * Discriminated on `status` so a non-modifying result cannot carry a + * flip/full-close kind, and a full close cannot report a remaining size. + */ +export type PositionModifyPreviewResult = + | PositionModifyPreviewNone + | PositionModifyPreviewUnsupported + | PositionModifyPreviewFullClose + | PositionModifyPreviewOpen; + export type MaintenanceMarginParams = { asset: string; positionSize?: number; // Optional: for tiered margin systems @@ -1607,6 +1709,15 @@ export type PerpsProvider = { calculateMaintenanceMargin(params: MaintenanceMarginParams): Promise; getMaxLeverage(asset: string): Promise; calculateFees(params: FeeCalculationParams): Promise; + /** + * Read-only projection of the position that would remain after the proposed + * order. Isolated-margin venues apply selected leverage to the whole + * resulting position and use the maintenance tier at liquidation notional. + * Cross-margin returns `{ status: 'unsupported', reason: 'cross_margin' }`. + */ + previewPositionModify( + params: PositionModifyPreviewParams, + ): Promise; // Live data subscriptions → Direct UI (NO Redux, maximum speed) subscribeToPrices(params: SubscribePricesParams): () => void; diff --git a/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts b/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts new file mode 100644 index 00000000000..5fb55a0ca62 --- /dev/null +++ b/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts @@ -0,0 +1,442 @@ +import type { + PositionModifyPreviewCurrent, + PositionModifyPreviewParams, + PositionModifyPreviewResult, + PositionPreviewValue, +} from '../types/index.js'; + +/** Token-size comparison tolerance (floating-point / szDecimals noise). */ +const SIZE_EPSILON = 1e-10; + +/** + * HyperLiquid documents margin-table IDs below 50 as a single tier whose max + * leverage equals the table id. Multi-tier tables need the `meta.marginTables` + * entry; without it liquidation is withheld. + * + * @see https://hyperliquid.gitbook.io/hyperliquid-docs/trading/margin-and-pnl + */ +const SINGLE_TIER_MARGIN_TABLE_ID_MAX = 50; + +export type HyperLiquidMarginTier = { + /** Inclusive notional lower bound in USD. */ + lowerBound: number; + maxLeverage: number; +}; + +export type PreviewHyperLiquidIsolatedPositionModifyParams = + PositionModifyPreviewParams & { + /** + * Maintenance tiers for the asset, lowest notional first. `null` or empty + * withholds liquidation while still returning margin when it can be known. + */ + marginTiers?: HyperLiquidMarginTier[] | null; + }; + +type MaintenanceScheduleTier = { + lowerBound: number; + upperBound: number; + maxLeverage: number; + maintenanceMarginRate: number; + maintenanceDeduction: number; +}; + +const unavailable = (): PositionPreviewValue => ({ available: false }); + +const available = (value: number): PositionPreviewValue => ({ + available: true, + value, +}); + +const parseFiniteNumber = (value: string | null | undefined): number => + Number.parseFloat(value ?? ''); + +const isPositiveFinite = (value: number): boolean => + Number.isFinite(value) && value > 0; + +const isNonNegativeFinite = (value: number): boolean => + Number.isFinite(value) && value >= 0; + +const currentFromPosition = (params: { + currentMargin: number; + currentLiquidationPrice: number; +}): PositionModifyPreviewCurrent => ({ + margin: isNonNegativeFinite(params.currentMargin) + ? available(params.currentMargin) + : unavailable(), + liquidationPrice: isPositiveFinite(params.currentLiquidationPrice) + ? available(params.currentLiquidationPrice) + : unavailable(), +}); + +/** + * Resolves the HyperLiquid margin table into preview tiers. + * + * Table IDs below 50 are single-tier. IDs at or above 50 require the matching + * `marginTables` row; missing data returns `null` so liquidation is withheld. + * + * @param params - Margin table id, asset max leverage, and optional tables. + * @returns Tiers for liquidation, or `null` when the table is required but missing. + */ +export function resolveHyperLiquidMarginTiers(params: { + marginTableId?: number; + maxLeverage: number; + marginTables?: + | [number, { marginTiers: { lowerBound: string; maxLeverage: number }[] }][] + | null; +}): HyperLiquidMarginTier[] | null { + const { marginTableId, maxLeverage, marginTables } = params; + + if ( + typeof marginTableId === 'number' && + marginTableId >= SINGLE_TIER_MARGIN_TABLE_ID_MAX + ) { + const table = marginTables?.find(([id]) => id === marginTableId)?.[1]; + const tiers = table?.marginTiers + ?.map((tier) => ({ + lowerBound: Number.parseFloat(tier.lowerBound), + maxLeverage: tier.maxLeverage, + })) + .filter( + (tier) => + Number.isFinite(tier.lowerBound) && + tier.lowerBound >= 0 && + isPositiveFinite(tier.maxLeverage), + ); + return tiers && tiers.length > 0 ? tiers : null; + } + + if (isPositiveFinite(maxLeverage)) { + return [{ lowerBound: 0, maxLeverage }]; + } + + return null; +} + +/** + * Builds the continuous maintenance-margin schedule from HyperLiquid tiers. + * + * `maintenance_margin = notional * mmr - deduction`, with + * `mmr = 1 / (2 * tierMaxLeverage)` and deduction chosen so the function is + * continuous across tier boundaries. + * + * @param tiers - Notional lower bounds and per-tier max leverage. + * @returns Sorted schedule used to pick the tier at liquidation notional. + */ +export function buildMaintenanceSchedule( + tiers: HyperLiquidMarginTier[], +): MaintenanceScheduleTier[] { + const sorted = [...tiers] + .filter( + (tier) => + Number.isFinite(tier.lowerBound) && + tier.lowerBound >= 0 && + isPositiveFinite(tier.maxLeverage), + ) + .sort((left, right) => left.lowerBound - right.lowerBound); + + const schedule: MaintenanceScheduleTier[] = []; + let deduction = 0; + let previousMmr = 0; + + for (let index = 0; index < sorted.length; index++) { + const tier = sorted[index]; + const maintenanceMarginRate = 1 / (2 * tier.maxLeverage); + if (index > 0) { + deduction += tier.lowerBound * (maintenanceMarginRate - previousMmr); + } + schedule.push({ + lowerBound: tier.lowerBound, + upperBound: + index + 1 < sorted.length ? sorted[index + 1].lowerBound : Infinity, + maxLeverage: tier.maxLeverage, + maintenanceMarginRate, + maintenanceDeduction: deduction, + }); + previousMmr = maintenanceMarginRate; + } + + return schedule; +} + +/** + * Isolated liquidation from entry, margin, size, and a maintenance tier. + * + * Long: `(entry - margin/size - deduction/size) / (1 - mmr)` + * Short: `(entry + margin/size + deduction/size) / (1 + mmr)` + * + * @param params - Position geometry plus the tier's mmr and deduction. + * @returns Liquidation price, or `null` when the inputs cannot produce one. + */ +export function estimateIsolatedLiquidationPrice(params: { + isLong: boolean; + entryPrice: number; + margin: number; + positionSize: number; + maintenanceMarginRate: number; + maintenanceDeduction?: number; +}): number | null { + const { + isLong, + entryPrice, + margin, + positionSize, + maintenanceMarginRate, + maintenanceDeduction = 0, + } = params; + + if ( + !isPositiveFinite(entryPrice) || + !isPositiveFinite(margin) || + !isPositiveFinite(positionSize) || + !Number.isFinite(maintenanceMarginRate) || + maintenanceMarginRate < 0 || + !Number.isFinite(maintenanceDeduction) + ) { + return null; + } + + const direction = isLong ? -1 : 1; + const side = isLong ? 1 : -1; + const adjustmentFactor = 1 - maintenanceMarginRate * side; + if (Math.abs(adjustmentFactor) < 0.0001) { + return null; + } + + const liquidationPrice = + (entryPrice + + direction * (margin / positionSize) + + direction * (maintenanceDeduction / positionSize)) / + adjustmentFactor; + + if (!isPositiveFinite(liquidationPrice)) { + return null; + } + + return liquidationPrice; +} + +/** + * Picks the maintenance tier whose notional range contains the liquidation + * notional (`size * liqPrice`), including that tier's deduction. + * + * @param params - Resulting geometry and the asset's maintenance schedule. + * @returns Liquidation price when a consistent tier exists. + */ +export function estimateIsolatedLiquidationPriceAtTier(params: { + isLong: boolean; + entryPrice: number; + margin: number; + positionSize: number; + marginTiers: HyperLiquidMarginTier[] | null | undefined; +}): number | null { + const schedule = buildMaintenanceSchedule(params.marginTiers ?? []); + if (schedule.length === 0) { + return null; + } + + for (const tier of schedule) { + const liquidationPrice = estimateIsolatedLiquidationPrice({ + isLong: params.isLong, + entryPrice: params.entryPrice, + margin: params.margin, + positionSize: params.positionSize, + maintenanceMarginRate: tier.maintenanceMarginRate, + maintenanceDeduction: tier.maintenanceDeduction, + }); + if (liquidationPrice === null) { + continue; + } + const notionalAtLiquidation = params.positionSize * liquidationPrice; + if ( + notionalAtLiquidation >= tier.lowerBound && + notionalAtLiquidation < tier.upperBound + ) { + return liquidationPrice; + } + } + + return null; +} + +const resultingLeverage = (params: { + size: number; + entryPrice: number; + margin: number; + fallback: number; +}): number => { + const notional = params.size * params.entryPrice; + if (params.margin > 0 && notional > 0) { + return notional / params.margin; + } + return params.fallback; +}; + +/** + * Projects the isolated position that would remain after a proposed order. + * + * Models HyperLiquid's isolated `updateLeverage` (the selected leverage is + * applied to the whole asset before the fill) and maintenance tiers at the + * resulting liquidation notional. Cross-margin positions return + * `{ status: 'unsupported', reason: 'cross_margin' }`. + * + * @param params - Live isolated position, proposed order, and optional tiers. + * @returns Discriminated preview; margin and liquidation are independently available. + */ +export function previewHyperLiquidIsolatedPositionModify( + params: PreviewHyperLiquidIsolatedPositionModifyParams, +): PositionModifyPreviewResult { + const { position, direction, reduceOnly = false, marginTiers } = params; + + if (position.leverage.type === 'cross') { + return { status: 'unsupported', reason: 'cross_margin' }; + } + + const currentSize = Math.abs(parseFiniteNumber(position.size)); + const signedSize = parseFiniteNumber(position.size); + const currentMargin = parseFiniteNumber(position.marginUsed); + const currentEntry = parseFiniteNumber(position.entryPrice); + const currentLiquidationPrice = parseFiniteNumber(position.liquidationPrice); + const currentLeverage = position.leverage.value; + const selectedLeverage = params.leverage; + const orderSize = parseFiniteNumber(params.size); + const orderPrice = parseFiniteNumber(params.price); + const feeAmountUsd = + typeof params.feeAmountUsd === 'number' && params.feeAmountUsd > 0 + ? params.feeAmountUsd + : 0; + + if ( + !isPositiveFinite(currentSize) || + !Number.isFinite(signedSize) || + signedSize === 0 || + !isNonNegativeFinite(currentMargin) || + !isPositiveFinite(currentEntry) || + !isPositiveFinite(selectedLeverage) || + !isPositiveFinite(currentLeverage) + ) { + return { status: 'none' }; + } + + const openDirection: 'long' | 'short' = signedSize > 0 ? 'long' : 'short'; + const currentSnapshot = currentFromPosition({ + currentMargin, + currentLiquidationPrice, + }); + + if (!isPositiveFinite(orderSize)) { + return { status: 'none' }; + } + + const currentNotional = (() => { + const positionValue = parseFiniteNumber(position.positionValue); + if (isPositiveFinite(positionValue)) { + return positionValue; + } + return currentSize * currentEntry; + })(); + + const leverageChanged = + Math.abs(selectedLeverage - currentLeverage) > SIZE_EPSILON; + const existingMarginAfterLeverage = leverageChanged + ? currentNotional / selectedLeverage + : currentMargin; + + const withResultingLiquidation = (preview: { + kind: 'increase' | 'decrease' | 'flip'; + resultingDirection: 'long' | 'short'; + resultingSize: number; + resultingEntryPrice: number; + newMargin: number; + }): PositionModifyPreviewResult => { + const liquidationPrice = estimateIsolatedLiquidationPriceAtTier({ + isLong: preview.resultingDirection === 'long', + entryPrice: preview.resultingEntryPrice, + margin: preview.newMargin, + positionSize: preview.resultingSize, + marginTiers, + }); + + return { + status: 'open', + kind: preview.kind, + current: currentSnapshot, + resulting: { + direction: preview.resultingDirection, + size: preview.resultingSize, + entryPrice: preview.resultingEntryPrice, + leverage: resultingLeverage({ + size: preview.resultingSize, + entryPrice: preview.resultingEntryPrice, + margin: preview.newMargin, + fallback: selectedLeverage, + }), + margin: available(preview.newMargin), + liquidationPrice: + liquidationPrice === null + ? unavailable() + : available(liquidationPrice), + }, + }; + }; + + const isSameDirection = openDirection === direction; + const fillPrice = isPositiveFinite(orderPrice) ? orderPrice : currentEntry; + const orderNotional = orderSize * fillPrice; + const orderMargin = orderNotional / selectedLeverage; + + if (isSameDirection && !reduceOnly) { + const resultingSize = currentSize + orderSize; + const resultingEntryPrice = + (currentSize * currentEntry + orderSize * fillPrice) / resultingSize; + const newMargin = Math.max( + 0, + existingMarginAfterLeverage + orderMargin - feeAmountUsd, + ); + + return withResultingLiquidation({ + kind: 'increase', + resultingDirection: openDirection, + resultingSize, + resultingEntryPrice, + newMargin, + }); + } + + if (orderSize + SIZE_EPSILON < currentSize) { + const remainingRatio = (currentSize - orderSize) / currentSize; + const resultingSize = currentSize - orderSize; + const newMargin = Math.max(0, existingMarginAfterLeverage * remainingRatio); + + return withResultingLiquidation({ + kind: 'decrease', + resultingDirection: openDirection, + resultingSize, + resultingEntryPrice: currentEntry, + newMargin, + }); + } + + const leftover = orderSize - currentSize; + if (leftover > SIZE_EPSILON && !reduceOnly) { + const leftoverRatio = leftover / orderSize; + const leftoverMargin = Math.max( + 0, + (orderMargin - feeAmountUsd) * leftoverRatio, + ); + const resultingEntryPrice = fillPrice; + + return withResultingLiquidation({ + kind: 'flip', + resultingDirection: direction, + resultingSize: leftover, + resultingEntryPrice, + newMargin: leftoverMargin, + }); + } + + return { + status: 'full_close', + current: currentSnapshot, + resultingDirection: openDirection, + }; +} diff --git a/packages/perps-controller/src/utils/index.ts b/packages/perps-controller/src/utils/index.ts index 0f37853de12..c1104121ab8 100644 --- a/packages/perps-controller/src/utils/index.ts +++ b/packages/perps-controller/src/utils/index.ts @@ -24,6 +24,7 @@ export { adaptHyperLiquidLedgerUpdateToUserHistoryItem, } from './hyperLiquidAdapter.js'; export * from './hyperLiquidOrderBookProcessor.js'; +export * from './hyperLiquidPositionPreview.js'; export * from './hyperLiquidValidation.js'; export * from './idUtils.js'; export * from './marketDataTransform.js'; diff --git a/packages/perps-controller/tests/helpers/providerMocks.ts b/packages/perps-controller/tests/helpers/providerMocks.ts index 849b79cfd62..1a8a23de31e 100644 --- a/packages/perps-controller/tests/helpers/providerMocks.ts +++ b/packages/perps-controller/tests/helpers/providerMocks.ts @@ -45,6 +45,7 @@ export const createMockHyperLiquidProvider = calculateMaintenanceMargin: jest.fn(), getMaxLeverage: jest.fn(), calculateFees: jest.fn(), + previewPositionModify: jest.fn(), getMarketDataWithPrices: jest.fn(), getBlockExplorerUrl: jest.fn(), getOrderFills: jest.fn(), diff --git a/packages/perps-controller/tests/src/PerpsController.configuration.test.ts b/packages/perps-controller/tests/src/PerpsController.configuration.test.ts index fc28d32d822..c787e8aa94f 100644 --- a/packages/perps-controller/tests/src/PerpsController.configuration.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.configuration.test.ts @@ -139,6 +139,7 @@ const mockMarketDataServiceInstance = { calculateLiquidationPrice: jest.fn(), getMaxLeverage: jest.fn(), calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + previewPositionModify: jest.fn(), getAvailableDexs: jest.fn().mockResolvedValue([]), getBlockExplorerUrl: jest.fn(), getOrderFills: jest.fn(), diff --git a/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts b/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts index 97782d6fe34..4186fadbaa4 100644 --- a/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts @@ -107,6 +107,7 @@ const mockMarketDataServiceInstance = { calculateLiquidationPrice: jest.fn(), getMaxLeverage: jest.fn(), calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + previewPositionModify: jest.fn(), getAvailableDexs: jest.fn().mockResolvedValue([]), getBlockExplorerUrl: jest.fn(), getOrderFills: jest.fn(), diff --git a/packages/perps-controller/tests/src/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts index aeffd0b9d33..188bf218fbe 100644 --- a/packages/perps-controller/tests/src/PerpsController.operations.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.operations.test.ts @@ -112,6 +112,7 @@ const mockMarketDataServiceInstance = { calculateLiquidationPrice: jest.fn(), getMaxLeverage: jest.fn(), calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + previewPositionModify: jest.fn(), getAvailableDexs: jest.fn().mockResolvedValue([]), getBlockExplorerUrl: jest.fn(), getOrderFills: jest.fn(), diff --git a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts index cb7663c7c70..d86ef4f9f06 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -137,6 +137,7 @@ const mockMarketDataServiceInstance = { calculateLiquidationPrice: jest.fn(), getMaxLeverage: jest.fn(), calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + previewPositionModify: jest.fn(), getAvailableDexs: jest.fn().mockResolvedValue([]), getBlockExplorerUrl: jest.fn(), getOrderFills: jest.fn(), diff --git a/packages/perps-controller/tests/src/PerpsController.state.test.ts b/packages/perps-controller/tests/src/PerpsController.state.test.ts index 2e6ae888715..85bae8ca31f 100644 --- a/packages/perps-controller/tests/src/PerpsController.state.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.state.test.ts @@ -110,6 +110,7 @@ const mockMarketDataServiceInstance = { calculateLiquidationPrice: jest.fn(), getMaxLeverage: jest.fn(), calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + previewPositionModify: jest.fn(), getAvailableDexs: jest.fn().mockResolvedValue([]), getBlockExplorerUrl: jest.fn(), getOrderFills: jest.fn(), diff --git a/packages/perps-controller/tests/src/PerpsController.subscriptions.test.ts b/packages/perps-controller/tests/src/PerpsController.subscriptions.test.ts index 3731e2fdc3b..db820d4f057 100644 --- a/packages/perps-controller/tests/src/PerpsController.subscriptions.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.subscriptions.test.ts @@ -6,7 +6,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { createMockHyperLiquidProvider } from '../helpers/providerMocks.js'; +import { + createMockHyperLiquidProvider, + createMockPosition, +} from '../helpers/providerMocks.js'; import { createMockInfrastructure, createMockMessenger, @@ -106,6 +109,7 @@ const mockMarketDataServiceInstance = { calculateLiquidationPrice: jest.fn(), getMaxLeverage: jest.fn(), calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + previewPositionModify: jest.fn(), getAvailableDexs: jest.fn().mockResolvedValue([]), getBlockExplorerUrl: jest.fn(), getOrderFills: jest.fn(), @@ -655,6 +659,37 @@ describe('PerpsController', () => { }); }); + describe('previewPositionModify', () => { + it('delegates to MarketDataService', async () => { + const params = { + position: createMockPosition({ + leverage: { type: 'isolated' as const, value: 5 }, + }), + direction: 'long' as const, + size: '0.1', + price: '50000', + leverage: 10, + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'previewPositionModify') + .mockResolvedValue({ status: 'none' }); + + const result = await controller.previewPositionModify(params); + + expect(result).toEqual({ status: 'none' }); + expect( + mockMarketDataServiceInstance.previewPositionModify, + ).toHaveBeenCalledWith({ + provider: mockProvider, + params, + context: expect.any(Object), + }); + }); + }); + describe('getMaxLeverage', () => { it('gets max leverage successfully', async () => { const asset = 'BTC'; diff --git a/packages/perps-controller/tests/src/PerpsController.trading.test.ts b/packages/perps-controller/tests/src/PerpsController.trading.test.ts index b3c93499fec..7ab57835b9e 100644 --- a/packages/perps-controller/tests/src/PerpsController.trading.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.trading.test.ts @@ -105,6 +105,7 @@ const mockMarketDataServiceInstance = { calculateLiquidationPrice: jest.fn(), getMaxLeverage: jest.fn(), calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + previewPositionModify: jest.fn(), getAvailableDexs: jest.fn().mockResolvedValue([]), getBlockExplorerUrl: jest.fn(), getOrderFills: jest.fn(), diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts index 627a71f1938..2e5d03e9f9d 100644 --- a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -88,6 +88,7 @@ const createMockProvider = (providerId: string): jest.Mocked => { calculateMaintenanceMargin: jest.fn().mockResolvedValue(0.05), getMaxLeverage: jest.fn().mockResolvedValue(50), calculateFees: jest.fn().mockResolvedValue({ feeRate: 0.001 }), + previewPositionModify: jest.fn().mockResolvedValue({ status: 'none' }), // Subscriptions subscribeToPrices: jest.fn().mockReturnValue(() => undefined), @@ -829,6 +830,24 @@ describe('AggregatedPerpsProvider', () => { expect(result).toEqual({ feeRate: 0.001 }); expect(mockHLProvider.calculateFees).toHaveBeenCalled(); }); + + it('delegates previewPositionModify to default provider', async () => { + mockHLProvider.previewPositionModify.mockResolvedValue({ + status: 'none', + }); + + const params = { + position: createMockPosition('BTC', '1'), + direction: 'long' as const, + size: '0.1', + price: '50000', + leverage: 10, + }; + + await aggregatedProvider.previewPositionModify(params); + + expect(mockHLProvider.previewPositionModify).toHaveBeenCalledWith(params); + }); }); describe('Subscriptions', () => { diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts index a95ac3667fe..cac9fdd5281 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts @@ -1059,6 +1059,75 @@ describe('HyperLiquidProvider', () => { }); }); + describe('previewPositionModify', () => { + const isolatedPosition = { + symbol: 'ETH', + size: '1', + entryPrice: '2000', + positionValue: '2000', + marginUsed: '400', + leverage: { type: 'isolated' as const, value: 5 }, + liquidationPrice: '1640', + maxLeverage: 25, + }; + + it('returns unsupported for cross-margin positions without fetching meta', async () => { + const result = await provider.previewPositionModify({ + position: { + ...isolatedPosition, + leverage: { type: 'cross', value: 5 }, + }, + direction: 'long', + size: '0.5', + price: '2000', + leverage: 5, + }); + + expect(result).toStrictEqual({ + status: 'unsupported', + reason: 'cross_margin', + }); + expect(mockClientService.getInfoClient).not.toHaveBeenCalled(); + }); + + it('uses cached meta margin tables for an isolated increase', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ + universe: [ + { + name: 'ETH', + szDecimals: 4, + maxLeverage: 25, + marginTableId: 25, + }, + ], + marginTables: [], + }), + }), + ); + + const result = await provider.previewPositionModify({ + position: isolatedPosition, + direction: 'long', + size: '0.5', + price: '2000', + leverage: 10, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('increase'); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 300, + }); + expect(result.resulting.direction).toBe('long'); + }); + }); + describe('getMaxLeverage', () => { it('returns max leverage for an asset', async () => { mockClientService.getInfoClient = jest.fn().mockReturnValue( diff --git a/packages/perps-controller/tests/src/providers/MYXProvider.test.ts b/packages/perps-controller/tests/src/providers/MYXProvider.test.ts index c4b00c3a043..d66062faa0b 100644 --- a/packages/perps-controller/tests/src/providers/MYXProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/MYXProvider.test.ts @@ -736,6 +736,27 @@ describe('MYXProvider', () => { protocolFeeRate: 0.0005, }); }); + + it('previewPositionModify returns unsupported', async () => { + expect( + await provider.previewPositionModify({ + position: { + symbol: 'RHEA', + size: '1', + entryPrice: '1', + positionValue: '1', + marginUsed: '1', + leverage: { type: 'isolated', value: 5 }, + liquidationPrice: '0.5', + maxLeverage: 20, + }, + direction: 'long', + size: '0.1', + price: '1', + leverage: 5, + }), + ).toEqual({ status: 'unsupported', reason: 'provider' }); + }); }); // ========================================================================== diff --git a/packages/perps-controller/tests/src/services/MarketDataService.test.ts b/packages/perps-controller/tests/src/services/MarketDataService.test.ts index 1fab3de302c..3cd3fa5e8e9 100644 --- a/packages/perps-controller/tests/src/services/MarketDataService.test.ts +++ b/packages/perps-controller/tests/src/services/MarketDataService.test.ts @@ -730,6 +730,32 @@ describe('MarketDataService', () => { }); }); + describe('previewPositionModify', () => { + it('delegates to the provider', async () => { + const params = { + position: createMockPosition({ + leverage: { type: 'isolated' as const, value: 5 }, + }), + direction: 'long' as const, + size: '0.1', + price: '50000', + leverage: 10, + }; + mockProvider.previewPositionModify.mockResolvedValue({ + status: 'none', + }); + + const result = await marketDataService.previewPositionModify({ + provider: mockProvider, + params, + context: mockContext, + }); + + expect(result).toEqual({ status: 'none' }); + expect(mockProvider.previewPositionModify).toHaveBeenCalledWith(params); + }); + }); + describe('calculateMaintenanceMargin', () => { it('calculates maintenance margin successfully', async () => { const params = { diff --git a/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts b/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts new file mode 100644 index 00000000000..e49aaf6dc45 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts @@ -0,0 +1,440 @@ +import type { PositionModifyPreviewSource } from '../../../src/types/index.js'; +import { + buildMaintenanceSchedule, + estimateIsolatedLiquidationPrice, + estimateIsolatedLiquidationPriceAtTier, + previewHyperLiquidIsolatedPositionModify, + resolveHyperLiquidMarginTiers, +} from '../../../src/utils/hyperLiquidPositionPreview.js'; + +const isolatedPosition = ( + overrides: Partial = {}, +): PositionModifyPreviewSource => ({ + symbol: 'ETH', + size: '1', + entryPrice: '2000', + positionValue: '2000', + marginUsed: '400', + leverage: { type: 'isolated', value: 5 }, + liquidationPrice: '1640', + maxLeverage: 25, + ...overrides, +}); + +const singleTier25x = [{ lowerBound: 0, maxLeverage: 25 }]; + +/** Testnet ETH maintenance tiers. */ +const testnetEthTiers = [ + { lowerBound: 0, maxLeverage: 25 }, + { lowerBound: 20_000, maxLeverage: 10 }, + { lowerBound: 50_000, maxLeverage: 5 }, + { lowerBound: 200_000, maxLeverage: 3 }, +]; + +describe('resolveHyperLiquidMarginTiers', () => { + it('treats table ids below 50 as a single tier', () => { + expect( + resolveHyperLiquidMarginTiers({ + marginTableId: 25, + maxLeverage: 25, + marginTables: [], + }), + ).toStrictEqual([{ lowerBound: 0, maxLeverage: 25 }]); + }); + + it('returns the matching multi-tier table', () => { + expect( + resolveHyperLiquidMarginTiers({ + marginTableId: 50, + maxLeverage: 25, + marginTables: [ + [ + 50, + { + marginTiers: [ + { lowerBound: '0', maxLeverage: 25 }, + { lowerBound: '20000', maxLeverage: 10 }, + ], + }, + ], + ], + }), + ).toStrictEqual([ + { lowerBound: 0, maxLeverage: 25 }, + { lowerBound: 20_000, maxLeverage: 10 }, + ]); + }); + + it('returns null when a multi-tier table is required but missing', () => { + expect( + resolveHyperLiquidMarginTiers({ + marginTableId: 50, + maxLeverage: 25, + marginTables: [], + }), + ).toBeNull(); + }); +}); + +describe('buildMaintenanceSchedule', () => { + it('applies the HyperLiquid maintenance deduction at each tier', () => { + const schedule = buildMaintenanceSchedule(testnetEthTiers); + + expect(schedule[0]).toMatchObject({ + lowerBound: 0, + upperBound: 20_000, + maintenanceMarginRate: 1 / 50, + maintenanceDeduction: 0, + }); + expect(schedule[1].maintenanceMarginRate).toBeCloseTo(1 / 20); + expect(schedule[1].maintenanceDeduction).toBeCloseTo( + 20_000 * (1 / 20 - 1 / 50), + ); + }); +}); + +describe('estimateIsolatedLiquidationPrice', () => { + it('matches the single-tier closed form for a long', () => { + const liq = estimateIsolatedLiquidationPrice({ + isLong: true, + entryPrice: 2000, + margin: 400, + positionSize: 1, + maintenanceMarginRate: 1 / 50, + }); + + expect(liq).toBeCloseTo((2000 - 400) / (1 - 1 / 50)); + }); + + it('matches the single-tier closed form for a short', () => { + const liq = estimateIsolatedLiquidationPrice({ + isLong: false, + entryPrice: 2000, + margin: 400, + positionSize: 1, + maintenanceMarginRate: 1 / 50, + }); + + expect(liq).toBeCloseTo((2000 + 400) / (1 + 1 / 50)); + }); +}); + +describe('previewHyperLiquidIsolatedPositionModify', () => { + it('returns unsupported for cross-margin positions', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + leverage: { type: 'cross', value: 5 }, + }), + direction: 'long', + size: '0.5', + price: '2000', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result).toStrictEqual({ + status: 'unsupported', + reason: 'cross_margin', + }); + }); + + it('returns none when there is no order size', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'long', + size: '0', + price: '2000', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('none'); + }); + + it('projects an isolated increase at the current leverage', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'long', + size: '0.5', + price: '2000', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('increase'); + expect(result.resulting.direction).toBe('long'); + expect(result.resulting.size).toBeCloseTo(1.5); + expect(result.resulting.entryPrice).toBeCloseTo(2000); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 600, + }); + expect(result.resulting.leverage).toBeCloseTo(5); + }); + + it('deducts fees from isolated margin on an increase', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'long', + size: '0.5', + price: '2000', + leverage: 5, + feeAmountUsd: 2, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 598, + }); + }); + + it('reallocates the existing isolated position when order leverage differs', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'long', + size: '0.5', + price: '2000', + leverage: 10, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('increase'); + // Existing 5x $400 is reset to $200 at 10x, then $100 is added for the order. + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 300, + }); + expect(result.resulting.leverage).toBeCloseTo(10); + expect(result.resulting.liquidationPrice.available).toBe(true); + if (result.resulting.liquidationPrice.available) { + const overstatedMarginLiq = estimateIsolatedLiquidationPrice({ + isLong: true, + entryPrice: 2000, + margin: 500, + positionSize: 1.5, + maintenanceMarginRate: 1 / 50, + }); + expect(result.resulting.liquidationPrice.value).toBeGreaterThan( + overstatedMarginLiq ?? 0, + ); + } + }); + + it('projects a partial decrease using the remaining position direction', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'short', + size: '0.4', + price: '2000', + leverage: 5, + reduceOnly: true, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('decrease'); + expect(result.resulting.direction).toBe('long'); + expect(result.resulting.size).toBeCloseTo(0.6); + expect(result.resulting.entryPrice).toBeCloseTo(2000); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 240, + }); + }); + + it('reallocates before a partial decrease when leverage changes', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'short', + size: '0.4', + price: '2000', + leverage: 10, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 120, + }); + expect(result.resulting.direction).toBe('long'); + }); + + it('projects a flip leftover at the selected leverage and order direction', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'short', + size: '1.5', + price: '2000', + leverage: 10, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('flip'); + expect(result.resulting.direction).toBe('short'); + expect(result.resulting.size).toBeCloseTo(0.5); + expect(result.resulting.entryPrice).toBeCloseTo(2000); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 100, + }); + }); + + it('returns full_close without a remaining size', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'short', + size: '1', + price: '2000', + leverage: 5, + reduceOnly: true, + marginTiers: singleTier25x, + }); + + expect(result).toMatchObject({ + status: 'full_close', + resultingDirection: 'long', + }); + expect(result.status === 'full_close' && 'resulting' in result).toBe(false); + }); + + it('treats a reduce-only overshoot as a full close rather than a flip', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'short', + size: '2', + price: '2000', + leverage: 5, + reduceOnly: true, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('full_close'); + }); + + it('keeps margin available when the live liquidation price is missing', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ liquidationPrice: null }), + direction: 'long', + size: '0.5', + price: '2000', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.current.liquidationPrice).toStrictEqual({ + available: false, + }); + expect(result.current.margin).toStrictEqual({ + available: true, + value: 400, + }); + expect(result.resulting.margin.available).toBe(true); + expect(result.resulting.liquidationPrice.available).toBe(true); + }); + + it('withholds liquidation and keeps margin when tier data is missing', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'long', + size: '0.5', + price: '2000', + leverage: 5, + marginTiers: null, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 600, + }); + expect(result.resulting.liquidationPrice).toStrictEqual({ + available: false, + }); + }); + + it('uses the maintenance tier at liquidation notional, including the deduction', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + size: '20', + entryPrice: '2500', + positionValue: '50000', + marginUsed: '5000', + leverage: { type: 'isolated', value: 10 }, + liquidationPrice: '2200', + }), + direction: 'long', + size: '0.0001', + price: '2500', + leverage: 10, + marginTiers: testnetEthTiers, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + + const expected = estimateIsolatedLiquidationPriceAtTier({ + isLong: true, + entryPrice: result.resulting.entryPrice, + margin: result.resulting.margin.available + ? result.resulting.margin.value + : 0, + positionSize: result.resulting.size, + marginTiers: testnetEthTiers, + }); + const singleTier = estimateIsolatedLiquidationPrice({ + isLong: true, + entryPrice: result.resulting.entryPrice, + margin: result.resulting.margin.available + ? result.resulting.margin.value + : 0, + positionSize: result.resulting.size, + maintenanceMarginRate: 1 / 50, + }); + + expect(result.resulting.liquidationPrice.available).toBe(true); + if (result.resulting.liquidationPrice.available) { + expect(result.resulting.liquidationPrice.value).toBeCloseTo( + expected ?? 0, + ); + expect(singleTier).not.toBeNull(); + if (singleTier !== null) { + expect(result.resulting.liquidationPrice.value).toBeGreaterThan( + singleTier, + ); + } + } + }); +}); From 2c40bf02fc675cd0faef49cb40e5a30e916e99f4 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Wed, 26 Aug 2026 10:11:46 +0200 Subject: [PATCH 2/8] docs(perps-controller): link preview changelog entry to PR #9968 --- packages/perps-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 396dbcd414e..2db8517ba8f 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **BREAKING:** Add `PerpsController.previewPositionModify` and `PerpsProvider.previewPositionModify` so clients can read an isolated-margin post-trade projection without placing an order ([#XXXX](https://github.com/MetaMask/core/pull/XXXX)) +- **BREAKING:** Add `PerpsController.previewPositionModify` and `PerpsProvider.previewPositionModify` so clients can read an isolated-margin post-trade projection without placing an order ([#9968](https://github.com/MetaMask/core/pull/9968)) - Mobile supplies the live position and proposed order; the HyperLiquid provider fetches the asset's margin table and applies selected leverage to the whole resulting position (matching `updateLeverage` before placement). - The result is a discriminated union (`none` / `unsupported` / `full_close` / `open`) so a non-modifying preview cannot carry a flip kind and a full close cannot report remaining size. Margin and liquidation availability are independent: a missing live liquidation or missing multi-tier table withholds only liquidation. - Isolated increases, leverage changes, reductions, flips, and full closes are projected. Liquidation uses the maintenance tier at the resulting liquidation notional, including that tier's maintenance deduction. Cross-margin returns `{ status: 'unsupported', reason: 'cross_margin' }`. MYX returns `{ status: 'unsupported', reason: 'provider' }`. From 4f170f9d3bb87cfea162671944d391fa802bf1e4 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Wed, 26 Aug 2026 10:38:56 +0200 Subject: [PATCH 3/8] fix(perps-controller): cover remaining isolated position preview cases Reject same-direction reduce-only and missing fill prices instead of projecting a false decrease or falling back to entry, and lock in short, limit, and leverage-down geometry with tests. --- packages/perps-controller/CHANGELOG.md | 2 +- packages/perps-controller/src/types/index.ts | 7 +- .../src/utils/hyperLiquidPositionPreview.ts | 31 +- .../utils/hyperLiquidPositionPreview.test.ts | 340 ++++++++++++++++++ 4 files changed, 372 insertions(+), 8 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 2db8517ba8f..fa4b66fff8e 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Add `PerpsController.previewPositionModify` and `PerpsProvider.previewPositionModify` so clients can read an isolated-margin post-trade projection without placing an order ([#9968](https://github.com/MetaMask/core/pull/9968)) - Mobile supplies the live position and proposed order; the HyperLiquid provider fetches the asset's margin table and applies selected leverage to the whole resulting position (matching `updateLeverage` before placement). - The result is a discriminated union (`none` / `unsupported` / `full_close` / `open`) so a non-modifying preview cannot carry a flip kind and a full close cannot report remaining size. Margin and liquidation availability are independent: a missing live liquidation or missing multi-tier table withholds only liquidation. - - Isolated increases, leverage changes, reductions, flips, and full closes are projected. Liquidation uses the maintenance tier at the resulting liquidation notional, including that tier's maintenance deduction. Cross-margin returns `{ status: 'unsupported', reason: 'cross_margin' }`. MYX returns `{ status: 'unsupported', reason: 'provider' }`. + - Isolated increases, leverage changes (up or down), reductions, flips, and full closes are projected for both longs and shorts. `price` is the expected fill or resting limit; the preview does not distinguish order types. Same-direction `reduceOnly` and increases/flips without a positive price return `{ status: 'none' }`. Liquidation uses the maintenance tier at the resulting liquidation notional, including that tier's maintenance deduction. Cross-margin returns `{ status: 'unsupported', reason: 'cross_margin' }`. MYX returns `{ status: 'unsupported', reason: 'provider' }`. - Consumers that implement `PerpsProvider` must add `previewPositionModify`. Clients should use `resulting.direction` (not the order direction) when validating TP/SL against the projected liquidation. - Add the public Chase lifecycle API (`getChaseOrders` and `suspendChaseOrders`), aggregated-provider routing, retained lifecycle diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 8ee17ed1651..80f1359bc0f 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1299,7 +1299,12 @@ export type PositionModifyPreviewParams = { direction: 'long' | 'short'; /** Proposed order size in token units. */ size: string; - /** Expected fill or limit price. */ + /** + * Expected fill price for a marketable order, or the resting limit price. + * Increases and flips require a positive price; a reduce does not. + * Scale, TWAP, and chase orders should pass the expected fill size and price; + * this preview models a single fill. + */ price: string; /** * Isolated leverage the provider will set on the asset before placing. diff --git a/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts b/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts index 5fb55a0ca62..cd886bdf70c 100644 --- a/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts +++ b/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts @@ -279,6 +279,14 @@ const resultingLeverage = (params: { * resulting liquidation notional. Cross-margin positions return * `{ status: 'unsupported', reason: 'cross_margin' }`. * + * `price` is the fill or resting-limit price the caller expects. A marketable + * order should pass its execution price; a limit should pass the limit. The + * preview does not distinguish order types itself, does not model whether a + * resting limit would fill, and treats scale/TWAP/chase as one aggregated fill. + * Decrease margin is the remaining isolated collateral after leverage + * reallocation; close fees and realized PnL settle to the account, not the + * leftover margin. + * * @param params - Live isolated position, proposed order, and optional tiers. * @returns Discriminated preview; margin and liquidation are independently available. */ @@ -380,11 +388,19 @@ export function previewHyperLiquidIsolatedPositionModify( }; const isSameDirection = openDirection === direction; - const fillPrice = isPositiveFinite(orderPrice) ? orderPrice : currentEntry; - const orderNotional = orderSize * fillPrice; - const orderMargin = orderNotional / selectedLeverage; + const fillPrice = isPositiveFinite(orderPrice) ? orderPrice : null; + + // Reduce-only in the position's own direction cannot add size and does not + // close it, so there is no resulting position to project. + if (isSameDirection && reduceOnly) { + return { status: 'none' }; + } - if (isSameDirection && !reduceOnly) { + if (isSameDirection) { + if (fillPrice === null) { + return { status: 'none' }; + } + const orderMargin = (orderSize * fillPrice) / selectedLeverage; const resultingSize = currentSize + orderSize; const resultingEntryPrice = (currentSize * currentEntry + orderSize * fillPrice) / resultingSize; @@ -418,18 +434,21 @@ export function previewHyperLiquidIsolatedPositionModify( const leftover = orderSize - currentSize; if (leftover > SIZE_EPSILON && !reduceOnly) { + if (fillPrice === null) { + return { status: 'none' }; + } + const orderMargin = (orderSize * fillPrice) / selectedLeverage; const leftoverRatio = leftover / orderSize; const leftoverMargin = Math.max( 0, (orderMargin - feeAmountUsd) * leftoverRatio, ); - const resultingEntryPrice = fillPrice; return withResultingLiquidation({ kind: 'flip', resultingDirection: direction, resultingSize: leftover, - resultingEntryPrice, + resultingEntryPrice: fillPrice, newMargin: leftoverMargin, }); } diff --git a/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts b/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts index e49aaf6dc45..e6d7c96fd1b 100644 --- a/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts +++ b/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts @@ -437,4 +437,344 @@ describe('previewHyperLiquidIsolatedPositionModify', () => { } } }); + + it('averages entry and posts order margin at a limit price away from entry', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'long', + size: '1', + price: '1800', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('increase'); + expect(result.resulting.size).toBeCloseTo(2); + expect(result.resulting.entryPrice).toBeCloseTo(1900); + // Existing $400 at 5x plus 1 * 1800 / 5 = $360. + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 760, + }); + }); + + it('does not project an increase or flip when the fill price is missing', () => { + const increase = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'long', + size: '0.5', + price: '0', + leverage: 5, + marginTiers: singleTier25x, + }); + const flip = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'short', + size: '1.5', + price: '', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(increase.status).toBe('none'); + expect(flip.status).toBe('none'); + }); + + it('still projects a reduce when the fill price is missing', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'short', + size: '0.4', + price: '0', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('decrease'); + expect(result.resulting.direction).toBe('long'); + }); + + it('does not treat a same-direction reduce-only order as a decrease', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'long', + size: '0.4', + price: '2000', + leverage: 5, + reduceOnly: true, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('none'); + }); + + it('keeps extra isolated margin when leverage is unchanged', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ marginUsed: '800' }), + direction: 'long', + size: '0.5', + price: '2000', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 1000, + }); + }); + + it('strips extra isolated margin when leverage increases', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ marginUsed: '800' }), + direction: 'long', + size: '0.5', + price: '2000', + leverage: 10, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 300, + }); + }); + + it('adds isolated margin when selected leverage is lower than the position', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + leverage: { type: 'isolated', value: 10 }, + marginUsed: '200', + }), + direction: 'long', + size: '0.5', + price: '2000', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + // Existing $2000 / 5 = $400, plus 0.5 * 2000 / 5 = $200. + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 600, + }); + expect(result.resulting.leverage).toBeCloseTo(5); + }); + + it('projects a short increase, keeping liquidation above entry', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + size: '-1', + liquidationPrice: '2360', + }), + direction: 'short', + size: '0.5', + price: '2000', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('increase'); + expect(result.resulting.direction).toBe('short'); + expect(result.resulting.size).toBeCloseTo(1.5); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 600, + }); + expect(result.resulting.liquidationPrice.available).toBe(true); + if (result.resulting.liquidationPrice.available) { + expect(result.resulting.liquidationPrice.value).toBeGreaterThan(2000); + } + }); + + it('reallocates a short when increasing at higher leverage', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + size: '-1', + liquidationPrice: '2360', + }), + direction: 'short', + size: '0.5', + price: '2000', + leverage: 10, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 300, + }); + expect(result.resulting.direction).toBe('short'); + }); + + it('averages a short increase at a limit above entry', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + size: '-1', + liquidationPrice: '2360', + }), + direction: 'short', + size: '1', + price: '2200', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.resulting.entryPrice).toBeCloseTo(2100); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 840, + }); + }); + + it('projects a partial cover of a short using the remaining short direction', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + size: '-1', + liquidationPrice: '2360', + }), + direction: 'long', + size: '0.4', + price: '2000', + leverage: 5, + reduceOnly: true, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('decrease'); + expect(result.resulting.direction).toBe('short'); + expect(result.resulting.size).toBeCloseTo(0.6); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 240, + }); + }); + + it('flips a short leftover into a long at the fill price', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + size: '-1', + liquidationPrice: '2360', + }), + direction: 'long', + size: '1.5', + price: '1900', + leverage: 10, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('flip'); + expect(result.resulting.direction).toBe('long'); + expect(result.resulting.size).toBeCloseTo(0.5); + expect(result.resulting.entryPrice).toBeCloseTo(1900); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 95, + }); + expect(result.resulting.liquidationPrice.available).toBe(true); + if (result.resulting.liquidationPrice.available) { + expect(result.resulting.liquidationPrice.value).toBeLessThan(1900); + } + }); + + it('fully closes a short without a remaining size', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + size: '-1', + liquidationPrice: '2360', + }), + direction: 'long', + size: '1', + price: '2000', + leverage: 5, + reduceOnly: true, + marginTiers: singleTier25x, + }); + + expect(result).toMatchObject({ + status: 'full_close', + resultingDirection: 'short', + }); + }); + + it('flips a long leftover into a short at a limit away from entry', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'short', + size: '1.5', + price: '1800', + leverage: 10, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('flip'); + expect(result.resulting.direction).toBe('short'); + expect(result.resulting.size).toBeCloseTo(0.5); + expect(result.resulting.entryPrice).toBeCloseTo(1800); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 90, + }); + expect(result.resulting.liquidationPrice.available).toBe(true); + if (result.resulting.liquidationPrice.available) { + expect(result.resulting.liquidationPrice.value).toBeGreaterThan(1800); + } + }); + + it('returns none for a negative order size', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'long', + size: '-0.5', + price: '2000', + leverage: 5, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('none'); + }); }); From b5131b56048c057b0025ab7d37c795b8a60e0c99 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Wed, 26 Aug 2026 13:38:06 +0200 Subject: [PATCH 4/8] fix(perps-controller): satisfy eslint on position modify preview Add JSDoc param fields, drop the untyped notional IIFE, and rewrite preview assertions so CI lint:eslint passes. --- .../src/utils/hyperLiquidPositionPreview.ts | 25 ++++-- .../utils/hyperLiquidPositionPreview.test.ts | 84 ++++++++++--------- 2 files changed, 63 insertions(+), 46 deletions(-) diff --git a/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts b/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts index cd886bdf70c..09003195c6a 100644 --- a/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts +++ b/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts @@ -75,6 +75,9 @@ const currentFromPosition = (params: { * `marginTables` row; missing data returns `null` so liquidation is withheld. * * @param params - Margin table id, asset max leverage, and optional tables. + * @param params.marginTableId - HyperLiquid margin table id from `meta.universe`. + * @param params.maxLeverage - Asset max leverage used for single-tier tables. + * @param params.marginTables - `meta.marginTables` rows; required for table ids ≥ 50. * @returns Tiers for liquidation, or `null` when the table is required but missing. */ export function resolveHyperLiquidMarginTiers(params: { @@ -165,6 +168,12 @@ export function buildMaintenanceSchedule( * Short: `(entry + margin/size + deduction/size) / (1 + mmr)` * * @param params - Position geometry plus the tier's mmr and deduction. + * @param params.isLong - Whether the remaining position is long. + * @param params.entryPrice - Resulting average entry price. + * @param params.margin - Isolated margin after the proposed fill. + * @param params.positionSize - Absolute remaining size in token units. + * @param params.maintenanceMarginRate - `1 / (2 * tierMaxLeverage)` for the tier. + * @param params.maintenanceDeduction - Continuity deduction at this tier. * @returns Liquidation price, or `null` when the inputs cannot produce one. */ export function estimateIsolatedLiquidationPrice(params: { @@ -220,6 +229,11 @@ export function estimateIsolatedLiquidationPrice(params: { * notional (`size * liqPrice`), including that tier's deduction. * * @param params - Resulting geometry and the asset's maintenance schedule. + * @param params.isLong - Whether the remaining position is long. + * @param params.entryPrice - Resulting average entry price. + * @param params.margin - Isolated margin after the proposed fill. + * @param params.positionSize - Absolute remaining size in token units. + * @param params.marginTiers - Maintenance tiers, lowest notional first. * @returns Liquidation price when a consistent tier exists. */ export function estimateIsolatedLiquidationPriceAtTier(params: { @@ -335,13 +349,10 @@ export function previewHyperLiquidIsolatedPositionModify( return { status: 'none' }; } - const currentNotional = (() => { - const positionValue = parseFiniteNumber(position.positionValue); - if (isPositiveFinite(positionValue)) { - return positionValue; - } - return currentSize * currentEntry; - })(); + const positionValue = parseFiniteNumber(position.positionValue); + const currentNotional = isPositiveFinite(positionValue) + ? positionValue + : currentSize * currentEntry; const leverageChanged = Math.abs(selectedLeverage - currentLeverage) > SIZE_EPSILON; diff --git a/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts b/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts index e6d7c96fd1b..deb2e8ec54e 100644 --- a/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts +++ b/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts @@ -1,4 +1,7 @@ -import type { PositionModifyPreviewSource } from '../../../src/types/index.js'; +import type { + PositionModifyPreviewSource, + PositionPreviewValue, +} from '../../../src/types/index.js'; import { buildMaintenanceSchedule, estimateIsolatedLiquidationPrice, @@ -23,6 +26,14 @@ const isolatedPosition = ( const singleTier25x = [{ lowerBound: 0, maxLeverage: 25 }]; +const availablePreviewValue = (preview: PositionPreviewValue): number => { + expect(preview.available).toBe(true); + if (!preview.available) { + throw new Error('Expected an available preview value'); + } + return preview.value; +}; + /** Testnet ETH maintenance tiers. */ const testnetEthTiers = [ { lowerBound: 0, maxLeverage: 25 }, @@ -218,19 +229,18 @@ describe('previewHyperLiquidIsolatedPositionModify', () => { value: 300, }); expect(result.resulting.leverage).toBeCloseTo(10); - expect(result.resulting.liquidationPrice.available).toBe(true); - if (result.resulting.liquidationPrice.available) { - const overstatedMarginLiq = estimateIsolatedLiquidationPrice({ - isLong: true, - entryPrice: 2000, - margin: 500, - positionSize: 1.5, - maintenanceMarginRate: 1 / 50, - }); - expect(result.resulting.liquidationPrice.value).toBeGreaterThan( - overstatedMarginLiq ?? 0, - ); - } + const liquidationPrice = availablePreviewValue( + result.resulting.liquidationPrice, + ); + const overstatedMarginLiq = estimateIsolatedLiquidationPrice({ + isLong: true, + entryPrice: 2000, + margin: 500, + positionSize: 1.5, + maintenanceMarginRate: 1 / 50, + }); + expect(overstatedMarginLiq).not.toBeNull(); + expect(liquidationPrice).toBeGreaterThan(overstatedMarginLiq ?? 0); }); it('projects a partial decrease using the remaining position direction', () => { @@ -314,11 +324,14 @@ describe('previewHyperLiquidIsolatedPositionModify', () => { marginTiers: singleTier25x, }); - expect(result).toMatchObject({ + expect(result).toStrictEqual({ status: 'full_close', + current: { + margin: { available: true, value: 400 }, + liquidationPrice: { available: true, value: 1640 }, + }, resultingDirection: 'long', }); - expect(result.status === 'full_close' && 'resulting' in result).toBe(false); }); it('treats a reduce-only overshoot as a full close rather than a flip', () => { @@ -425,17 +438,13 @@ describe('previewHyperLiquidIsolatedPositionModify', () => { }); expect(result.resulting.liquidationPrice.available).toBe(true); - if (result.resulting.liquidationPrice.available) { - expect(result.resulting.liquidationPrice.value).toBeCloseTo( - expected ?? 0, - ); - expect(singleTier).not.toBeNull(); - if (singleTier !== null) { - expect(result.resulting.liquidationPrice.value).toBeGreaterThan( - singleTier, - ); - } - } + const liquidationPrice = availablePreviewValue( + result.resulting.liquidationPrice, + ); + expect(expected).not.toBeNull(); + expect(singleTier).not.toBeNull(); + expect(liquidationPrice).toBeCloseTo(expected ?? 0); + expect(liquidationPrice).toBeGreaterThan(singleTier ?? 0); }); it('averages entry and posts order margin at a limit price away from entry', () => { @@ -605,10 +614,9 @@ describe('previewHyperLiquidIsolatedPositionModify', () => { available: true, value: 600, }); - expect(result.resulting.liquidationPrice.available).toBe(true); - if (result.resulting.liquidationPrice.available) { - expect(result.resulting.liquidationPrice.value).toBeGreaterThan(2000); - } + expect( + availablePreviewValue(result.resulting.liquidationPrice), + ).toBeGreaterThan(2000); }); it('reallocates a short when increasing at higher leverage', () => { @@ -711,10 +719,9 @@ describe('previewHyperLiquidIsolatedPositionModify', () => { available: true, value: 95, }); - expect(result.resulting.liquidationPrice.available).toBe(true); - if (result.resulting.liquidationPrice.available) { - expect(result.resulting.liquidationPrice.value).toBeLessThan(1900); - } + expect( + availablePreviewValue(result.resulting.liquidationPrice), + ).toBeLessThan(1900); }); it('fully closes a short without a remaining size', () => { @@ -759,10 +766,9 @@ describe('previewHyperLiquidIsolatedPositionModify', () => { available: true, value: 90, }); - expect(result.resulting.liquidationPrice.available).toBe(true); - if (result.resulting.liquidationPrice.available) { - expect(result.resulting.liquidationPrice.value).toBeGreaterThan(1800); - } + expect( + availablePreviewValue(result.resulting.liquidationPrice), + ).toBeGreaterThan(1800); }); it('returns none for a negative order size', () => { From 45e39d288c5b59d92228921502f5afe8d5f3da27 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Wed, 26 Aug 2026 14:11:38 +0200 Subject: [PATCH 5/8] fix(perps-controller): report mark-based preview leverage HyperLiquid isolated leverage is mark notional / margin. Deriving it from entry notional drifted after updateLeverage whenever the position had unrealized PnL. --- packages/perps-controller/CHANGELOG.md | 2 +- packages/perps-controller/src/types/index.ts | 4 +++ .../src/utils/hyperLiquidPositionPreview.ts | 15 ++++++----- .../utils/hyperLiquidPositionPreview.test.ts | 27 +++++++++++++++++++ 4 files changed, 40 insertions(+), 8 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index fa4b66fff8e..b53f63ffdb7 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Add `PerpsController.previewPositionModify` and `PerpsProvider.previewPositionModify` so clients can read an isolated-margin post-trade projection without placing an order ([#9968](https://github.com/MetaMask/core/pull/9968)) - Mobile supplies the live position and proposed order; the HyperLiquid provider fetches the asset's margin table and applies selected leverage to the whole resulting position (matching `updateLeverage` before placement). - The result is a discriminated union (`none` / `unsupported` / `full_close` / `open`) so a non-modifying preview cannot carry a flip kind and a full close cannot report remaining size. Margin and liquidation availability are independent: a missing live liquidation or missing multi-tier table withholds only liquidation. - - Isolated increases, leverage changes (up or down), reductions, flips, and full closes are projected for both longs and shorts. `price` is the expected fill or resting limit; the preview does not distinguish order types. Same-direction `reduceOnly` and increases/flips without a positive price return `{ status: 'none' }`. Liquidation uses the maintenance tier at the resulting liquidation notional, including that tier's maintenance deduction. Cross-margin returns `{ status: 'unsupported', reason: 'cross_margin' }`. MYX returns `{ status: 'unsupported', reason: 'provider' }`. + - Isolated increases, leverage changes (up or down), reductions, flips, and full closes are projected for both longs and shorts. `price` is the expected fill or resting limit; the preview does not distinguish order types. Same-direction `reduceOnly` and increases/flips without a positive price return `{ status: 'none' }`. `resulting.leverage` is mark notional / remaining isolated margin. Liquidation uses the maintenance tier at the resulting liquidation notional, including that tier's maintenance deduction. Cross-margin returns `{ status: 'unsupported', reason: 'cross_margin' }`. MYX returns `{ status: 'unsupported', reason: 'provider' }`. - Consumers that implement `PerpsProvider` must add `previewPositionModify`. Clients should use `resulting.direction` (not the order direction) when validating TP/SL against the projected liquidation. - Add the public Chase lifecycle API (`getChaseOrders` and `suspendChaseOrders`), aggregated-provider routing, retained lifecycle diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 80f1359bc0f..00502404ce4 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1343,6 +1343,10 @@ export type PositionModifyPreviewOpen = { /** Resulting token size; always > 0 for `open`. */ size: number; entryPrice: number; + /** + * Isolated leverage from mark notional / remaining margin, matching + * HyperLiquid's displayed leverage rather than entry notional / margin. + */ leverage: number; margin: PositionPreviewValue; liquidationPrice: PositionPreviewValue; diff --git a/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts b/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts index 09003195c6a..220ce81e736 100644 --- a/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts +++ b/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts @@ -273,14 +273,12 @@ export function estimateIsolatedLiquidationPriceAtTier(params: { } const resultingLeverage = (params: { - size: number; - entryPrice: number; + notional: number; margin: number; fallback: number; }): number => { - const notional = params.size * params.entryPrice; - if (params.margin > 0 && notional > 0) { - return notional / params.margin; + if (params.margin > 0 && params.notional > 0) { + return params.notional / params.margin; } return params.fallback; }; @@ -365,6 +363,7 @@ export function previewHyperLiquidIsolatedPositionModify( resultingDirection: 'long' | 'short'; resultingSize: number; resultingEntryPrice: number; + resultingNotional: number; newMargin: number; }): PositionModifyPreviewResult => { const liquidationPrice = estimateIsolatedLiquidationPriceAtTier({ @@ -384,8 +383,7 @@ export function previewHyperLiquidIsolatedPositionModify( size: preview.resultingSize, entryPrice: preview.resultingEntryPrice, leverage: resultingLeverage({ - size: preview.resultingSize, - entryPrice: preview.resultingEntryPrice, + notional: preview.resultingNotional, margin: preview.newMargin, fallback: selectedLeverage, }), @@ -425,6 +423,7 @@ export function previewHyperLiquidIsolatedPositionModify( resultingDirection: openDirection, resultingSize, resultingEntryPrice, + resultingNotional: currentNotional + orderSize * fillPrice, newMargin, }); } @@ -439,6 +438,7 @@ export function previewHyperLiquidIsolatedPositionModify( resultingDirection: openDirection, resultingSize, resultingEntryPrice: currentEntry, + resultingNotional: currentNotional * remainingRatio, newMargin, }); } @@ -460,6 +460,7 @@ export function previewHyperLiquidIsolatedPositionModify( resultingDirection: direction, resultingSize: leftover, resultingEntryPrice: fillPrice, + resultingNotional: leftover * fillPrice, newMargin: leftoverMargin, }); } diff --git a/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts b/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts index deb2e8ec54e..07d44be98ff 100644 --- a/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts +++ b/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts @@ -243,6 +243,33 @@ describe('previewHyperLiquidIsolatedPositionModify', () => { expect(liquidationPrice).toBeGreaterThan(overstatedMarginLiq ?? 0); }); + it('reports mark-based leverage when entry differs from mark after a leverage change', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition({ + entryPrice: '2000', + positionValue: '2500', + marginUsed: '500', + }), + direction: 'long', + size: '0.5', + price: '2500', + leverage: 10, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 375, + }); + // Mark notional $3750 / $375 = 10x. Entry notional would report ~8.67x. + expect(result.resulting.leverage).toBeCloseTo(10); + expect(result.resulting.entryPrice).toBeCloseTo(2166.666, 1); + }); + it('projects a partial decrease using the remaining position direction', () => { const result = previewHyperLiquidIsolatedPositionModify({ position: isolatedPosition(), From 59dfd1b316b053c3d70814aed8dca4269ab00b4e Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Wed, 26 Aug 2026 14:50:15 +0200 Subject: [PATCH 6/8] fix(perps-controller): mark increase previews at the fill price Increase leverage used live mark notional plus fill notional, which is not HyperLiquid's post-fill mark when those prices differ. Use the resulting size at the fill so leverage matches the displayed position. --- .../perps-controller/src/utils/hyperLiquidPositionPreview.ts | 4 +++- .../tests/src/utils/hyperLiquidPositionPreview.test.ts | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts b/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts index 220ce81e736..ff291b7aaed 100644 --- a/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts +++ b/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts @@ -423,7 +423,9 @@ export function previewHyperLiquidIsolatedPositionModify( resultingDirection: openDirection, resultingSize, resultingEntryPrice, - resultingNotional: currentNotional + orderSize * fillPrice, + // Post-fill mark is the fill; mixing live mark with fill notional is not + // HyperLiquid's displayed leverage when those prices differ. + resultingNotional: resultingSize * fillPrice, newMargin, }); } diff --git a/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts b/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts index 07d44be98ff..55e9cf4d673 100644 --- a/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts +++ b/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts @@ -496,6 +496,8 @@ describe('previewHyperLiquidIsolatedPositionModify', () => { available: true, value: 760, }); + // After fill the whole position is marked at 1800, not live mark plus fill. + expect(result.resulting.leverage).toBeCloseTo(3600 / 760); }); it('does not project an increase or flip when the fill price is missing', () => { From 8ce024e49ebed393e0eae781535bfbe334758c6d Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Wed, 26 Aug 2026 16:02:19 +0200 Subject: [PATCH 7/8] fix(perps-controller): correct preview liquidation, table identity, and routing Use projected mark for isolated liquidation, withhold liquidation when the margin-table id is unknown, mark decreases at the expected fill, and route aggregated previews by providerId. --- packages/perps-controller/CHANGELOG.md | 2 +- .../src/providers/AggregatedPerpsProvider.ts | 5 +- packages/perps-controller/src/types/index.ts | 6 ++ .../src/utils/hyperLiquidPositionPreview.ts | 72 +++++++++++------- .../providers/AggregatedPerpsProvider.test.ts | 74 +++++++++++++++++++ .../HyperLiquidProvider.validation.test.ts | 38 ++++++++++ .../utils/hyperLiquidPositionPreview.test.ts | 56 ++++++++++++-- 7 files changed, 218 insertions(+), 35 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 1aba162e2b7..1fa25ce6868 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Add `PerpsController.previewPositionModify` and `PerpsProvider.previewPositionModify` so clients can read an isolated-margin post-trade projection without placing an order ([#9968](https://github.com/MetaMask/core/pull/9968)) - Mobile supplies the live position and proposed order; the HyperLiquid provider fetches the asset's margin table and applies selected leverage to the whole resulting position (matching `updateLeverage` before placement). - The result is a discriminated union (`none` / `unsupported` / `full_close` / `open`) so a non-modifying preview cannot carry a flip kind and a full close cannot report remaining size. Margin and liquidation availability are independent: a missing live liquidation or missing multi-tier table withholds only liquidation. - - Isolated increases, leverage changes (up or down), reductions, flips, and full closes are projected for both longs and shorts. `price` is the expected fill or resting limit; the preview does not distinguish order types. Same-direction `reduceOnly` and increases/flips without a positive price return `{ status: 'none' }`. `resulting.leverage` is mark notional / remaining isolated margin. Liquidation uses the maintenance tier at the resulting liquidation notional, including that tier's maintenance deduction. Cross-margin returns `{ status: 'unsupported', reason: 'cross_margin' }`. MYX returns `{ status: 'unsupported', reason: 'provider' }`. + - Isolated increases, leverage changes (up or down), reductions, flips, and full closes are projected for both longs and shorts. `price` is the expected fill or resting limit; the preview does not distinguish order types. Same-direction `reduceOnly` and increases/flips without a positive price return `{ status: 'none' }`. `resulting.leverage` is mark notional / remaining isolated margin. Liquidation uses the projected mark (not average entry) because isolated `marginUsed` is mark-based equity. A missing margin-table identity withholds liquidation rather than inventing a single-tier schedule. Aggregated providers route by `providerId` / `position.providerId`. Cross-margin returns `{ status: 'unsupported', reason: 'cross_margin' }`. MYX returns `{ status: 'unsupported', reason: 'provider' }`. - Consumers that implement `PerpsProvider` must add `previewPositionModify`. Clients should use `resulting.direction` (not the order direction) when validating TP/SL against the projected liquidation. - Add the public Chase lifecycle API (`getChaseOrders` and `suspendChaseOrders`), aggregated-provider routing, retained lifecycle diff --git a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts index 0a5592cade8..cbe06c13fcc 100644 --- a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts +++ b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts @@ -757,7 +757,10 @@ export class AggregatedPerpsProvider implements PerpsProvider { async previewPositionModify( params: PositionModifyPreviewParams, ): Promise { - return this.#getDefaultProvider().previewPositionModify(params); + const [, provider] = this.#getProviderOrDefault( + params.providerId ?? params.position.providerId, + ); + return provider.previewPositionModify(params); } // ============================================================================ diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index a768b7e2729..82e6e037d71 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1352,6 +1352,7 @@ export type PositionModifyPreviewSource = Pick< | 'leverage' | 'positionValue' | 'maxLeverage' + | 'providerId' >; /** @@ -1385,6 +1386,11 @@ export type PositionModifyPreviewParams = { * increases and flips. Omit or pass 0 when unknown. */ feeAmountUsd?: number; + /** + * Explicit venue route. Aggregated providers use this, then + * `position.providerId`, then the default provider. + */ + providerId?: PerpsProviderType; }; /** diff --git a/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts b/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts index ff291b7aaed..7dcf2d452a1 100644 --- a/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts +++ b/packages/perps-controller/src/utils/hyperLiquidPositionPreview.ts @@ -73,26 +73,29 @@ const currentFromPosition = (params: { * * Table IDs below 50 are single-tier. IDs at or above 50 require the matching * `marginTables` row; missing data returns `null` so liquidation is withheld. + * An unknown table id (asset missing from `meta.universe`) also returns `null` + * instead of inventing a single tier from max leverage. * * @param params - Margin table id, asset max leverage, and optional tables. * @param params.marginTableId - HyperLiquid margin table id from `meta.universe`. * @param params.maxLeverage - Asset max leverage used for single-tier tables. * @param params.marginTables - `meta.marginTables` rows; required for table ids ≥ 50. - * @returns Tiers for liquidation, or `null` when the table is required but missing. + * @returns Tiers for liquidation, or `null` when the table identity is unknown. */ export function resolveHyperLiquidMarginTiers(params: { marginTableId?: number; - maxLeverage: number; + maxLeverage?: number; marginTables?: | [number, { marginTiers: { lowerBound: string; maxLeverage: number }[] }][] | null; }): HyperLiquidMarginTier[] | null { const { marginTableId, maxLeverage, marginTables } = params; - if ( - typeof marginTableId === 'number' && - marginTableId >= SINGLE_TIER_MARGIN_TABLE_ID_MAX - ) { + if (typeof marginTableId !== 'number' || !Number.isFinite(marginTableId)) { + return null; + } + + if (marginTableId >= SINGLE_TIER_MARGIN_TABLE_ID_MAX) { const table = marginTables?.find(([id]) => id === marginTableId)?.[1]; const tiers = table?.marginTiers ?.map((tier) => ({ @@ -108,11 +111,19 @@ export function resolveHyperLiquidMarginTiers(params: { return tiers && tiers.length > 0 ? tiers : null; } - if (isPositiveFinite(maxLeverage)) { - return [{ lowerBound: 0, maxLeverage }]; + if (marginTableId <= 0) { + return null; } - return null; + const tierMaxLeverage = + typeof maxLeverage === 'number' && isPositiveFinite(maxLeverage) + ? maxLeverage + : marginTableId; + if (!isPositiveFinite(tierMaxLeverage)) { + return null; + } + + return [{ lowerBound: 0, maxLeverage: tierMaxLeverage }]; } /** @@ -162,14 +173,18 @@ export function buildMaintenanceSchedule( } /** - * Isolated liquidation from entry, margin, size, and a maintenance tier. + * Isolated liquidation from mark, margin, size, and a maintenance tier. + * + * HyperLiquid liquidations use mark price, not average entry. Isolated + * `marginUsed` is mark-based equity (includes unrealized PnL), so the + * closed form must use the same reference: * - * Long: `(entry - margin/size - deduction/size) / (1 - mmr)` - * Short: `(entry + margin/size + deduction/size) / (1 + mmr)` + * Long: `(mark - margin/size - deduction/size) / (1 - mmr)` + * Short: `(mark + margin/size + deduction/size) / (1 + mmr)` * * @param params - Position geometry plus the tier's mmr and deduction. * @param params.isLong - Whether the remaining position is long. - * @param params.entryPrice - Resulting average entry price. + * @param params.markPrice - Projected mark after the proposed fill. * @param params.margin - Isolated margin after the proposed fill. * @param params.positionSize - Absolute remaining size in token units. * @param params.maintenanceMarginRate - `1 / (2 * tierMaxLeverage)` for the tier. @@ -178,7 +193,7 @@ export function buildMaintenanceSchedule( */ export function estimateIsolatedLiquidationPrice(params: { isLong: boolean; - entryPrice: number; + markPrice: number; margin: number; positionSize: number; maintenanceMarginRate: number; @@ -186,7 +201,7 @@ export function estimateIsolatedLiquidationPrice(params: { }): number | null { const { isLong, - entryPrice, + markPrice, margin, positionSize, maintenanceMarginRate, @@ -194,7 +209,7 @@ export function estimateIsolatedLiquidationPrice(params: { } = params; if ( - !isPositiveFinite(entryPrice) || + !isPositiveFinite(markPrice) || !isPositiveFinite(margin) || !isPositiveFinite(positionSize) || !Number.isFinite(maintenanceMarginRate) || @@ -212,7 +227,7 @@ export function estimateIsolatedLiquidationPrice(params: { } const liquidationPrice = - (entryPrice + + (markPrice + direction * (margin / positionSize) + direction * (maintenanceDeduction / positionSize)) / adjustmentFactor; @@ -230,7 +245,7 @@ export function estimateIsolatedLiquidationPrice(params: { * * @param params - Resulting geometry and the asset's maintenance schedule. * @param params.isLong - Whether the remaining position is long. - * @param params.entryPrice - Resulting average entry price. + * @param params.markPrice - Projected mark after the proposed fill. * @param params.margin - Isolated margin after the proposed fill. * @param params.positionSize - Absolute remaining size in token units. * @param params.marginTiers - Maintenance tiers, lowest notional first. @@ -238,7 +253,7 @@ export function estimateIsolatedLiquidationPrice(params: { */ export function estimateIsolatedLiquidationPriceAtTier(params: { isLong: boolean; - entryPrice: number; + markPrice: number; margin: number; positionSize: number; marginTiers: HyperLiquidMarginTier[] | null | undefined; @@ -251,7 +266,7 @@ export function estimateIsolatedLiquidationPriceAtTier(params: { for (const tier of schedule) { const liquidationPrice = estimateIsolatedLiquidationPrice({ isLong: params.isLong, - entryPrice: params.entryPrice, + markPrice: params.markPrice, margin: params.margin, positionSize: params.positionSize, maintenanceMarginRate: tier.maintenanceMarginRate, @@ -288,8 +303,9 @@ const resultingLeverage = (params: { * * Models HyperLiquid's isolated `updateLeverage` (the selected leverage is * applied to the whole asset before the fill) and maintenance tiers at the - * resulting liquidation notional. Cross-margin positions return - * `{ status: 'unsupported', reason: 'cross_margin' }`. + * resulting liquidation notional. Liquidation uses the projected mark, not + * average entry, because isolated `marginUsed` is mark-based equity. + * Cross-margin positions return `{ status: 'unsupported', reason: 'cross_margin' }`. * * `price` is the fill or resting-limit price the caller expects. A marketable * order should pass its execution price; a limit should pass the limit. The @@ -363,12 +379,13 @@ export function previewHyperLiquidIsolatedPositionModify( resultingDirection: 'long' | 'short'; resultingSize: number; resultingEntryPrice: number; + resultingMarkPrice: number; resultingNotional: number; newMargin: number; }): PositionModifyPreviewResult => { const liquidationPrice = estimateIsolatedLiquidationPriceAtTier({ isLong: preview.resultingDirection === 'long', - entryPrice: preview.resultingEntryPrice, + markPrice: preview.resultingMarkPrice, margin: preview.newMargin, positionSize: preview.resultingSize, marginTiers, @@ -423,8 +440,7 @@ export function previewHyperLiquidIsolatedPositionModify( resultingDirection: openDirection, resultingSize, resultingEntryPrice, - // Post-fill mark is the fill; mixing live mark with fill notional is not - // HyperLiquid's displayed leverage when those prices differ. + resultingMarkPrice: fillPrice, resultingNotional: resultingSize * fillPrice, newMargin, }); @@ -434,13 +450,16 @@ export function previewHyperLiquidIsolatedPositionModify( const remainingRatio = (currentSize - orderSize) / currentSize; const resultingSize = currentSize - orderSize; const newMargin = Math.max(0, existingMarginAfterLeverage * remainingRatio); + const currentMarkPrice = currentNotional / currentSize; + const resultingMarkPrice = fillPrice ?? currentMarkPrice; return withResultingLiquidation({ kind: 'decrease', resultingDirection: openDirection, resultingSize, resultingEntryPrice: currentEntry, - resultingNotional: currentNotional * remainingRatio, + resultingMarkPrice, + resultingNotional: resultingSize * resultingMarkPrice, newMargin, }); } @@ -462,6 +481,7 @@ export function previewHyperLiquidIsolatedPositionModify( resultingDirection: direction, resultingSize: leftover, resultingEntryPrice: fillPrice, + resultingMarkPrice: fillPrice, resultingNotional: leftover * fillPrice, newMargin: leftoverMargin, }); diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts index 2a224b49b6b..f170b961315 100644 --- a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -1124,6 +1124,80 @@ describe('AggregatedPerpsProvider', () => { expect(mockHLProvider.previewPositionModify).toHaveBeenCalledWith(params); }); + it('routes previewPositionModify to an explicit provider', async () => { + mockMYXProvider.previewPositionModify.mockResolvedValue({ + status: 'unsupported', + reason: 'provider', + }); + + const params = { + position: createMockPosition('RHEA', '1'), + direction: 'long' as const, + size: '0.1', + price: '1', + leverage: 5, + providerId: 'myx' as const, + }; + + await expect( + aggregatedProvider.previewPositionModify(params), + ).resolves.toStrictEqual({ + status: 'unsupported', + reason: 'provider', + }); + expect(mockMYXProvider.previewPositionModify).toHaveBeenCalledWith( + params, + ); + expect(mockHLProvider.previewPositionModify).not.toHaveBeenCalled(); + }); + + it('routes previewPositionModify from position.providerId', async () => { + mockMYXProvider.previewPositionModify.mockResolvedValue({ + status: 'unsupported', + reason: 'provider', + }); + + const params = { + position: { + ...createMockPosition('RHEA', '1'), + providerId: 'myx' as const, + }, + direction: 'long' as const, + size: '0.1', + price: '1', + leverage: 5, + }; + + await expect( + aggregatedProvider.previewPositionModify(params), + ).resolves.toStrictEqual({ + status: 'unsupported', + reason: 'provider', + }); + expect(mockMYXProvider.previewPositionModify).toHaveBeenCalledWith( + params, + ); + expect(mockHLProvider.previewPositionModify).not.toHaveBeenCalled(); + }); + + it('rejects an unregistered previewPositionModify route', async () => { + aggregatedProvider.removeProvider('myx'); + + await expect( + aggregatedProvider.previewPositionModify({ + position: createMockPosition('BTC', '1'), + direction: 'long', + size: '0.1', + price: '50000', + leverage: 10, + providerId: 'myx', + }), + ).rejects.toThrow(PERPS_ERROR_CODES.PROVIDER_NOT_FOUND); + + expect(mockHLProvider.previewPositionModify).not.toHaveBeenCalled(); + expect(mockMYXProvider.previewPositionModify).not.toHaveBeenCalled(); + }); + it('accepts an ordinary fee request held as the routed parameter type', async () => { const params: FeeCalculationParams = { orderType: 'market', diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts index cac9fdd5281..f99a0c027d8 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts @@ -1126,6 +1126,44 @@ describe('HyperLiquidProvider', () => { }); expect(result.resulting.direction).toBe('long'); }); + + it('withholds liquidation when the asset is missing from meta', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ + universe: [ + { + name: 'BTC', + szDecimals: 5, + maxLeverage: 40, + marginTableId: 40, + }, + ], + marginTables: [], + }), + }), + ); + + const result = await provider.previewPositionModify({ + position: isolatedPosition, + direction: 'long', + size: '0.5', + price: '2000', + leverage: 10, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 300, + }); + expect(result.resulting.liquidationPrice).toStrictEqual({ + available: false, + }); + }); }); describe('getMaxLeverage', () => { diff --git a/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts b/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts index 55e9cf4d673..ff832636b81 100644 --- a/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts +++ b/packages/perps-controller/tests/src/utils/hyperLiquidPositionPreview.test.ts @@ -76,6 +76,15 @@ describe('resolveHyperLiquidMarginTiers', () => { ]); }); + it('returns null when the margin-table id is unknown', () => { + expect( + resolveHyperLiquidMarginTiers({ + maxLeverage: 25, + marginTables: [], + }), + ).toBeNull(); + }); + it('returns null when a multi-tier table is required but missing', () => { expect( resolveHyperLiquidMarginTiers({ @@ -108,7 +117,7 @@ describe('estimateIsolatedLiquidationPrice', () => { it('matches the single-tier closed form for a long', () => { const liq = estimateIsolatedLiquidationPrice({ isLong: true, - entryPrice: 2000, + markPrice: 2000, margin: 400, positionSize: 1, maintenanceMarginRate: 1 / 50, @@ -120,7 +129,7 @@ describe('estimateIsolatedLiquidationPrice', () => { it('matches the single-tier closed form for a short', () => { const liq = estimateIsolatedLiquidationPrice({ isLong: false, - entryPrice: 2000, + markPrice: 2000, margin: 400, positionSize: 1, maintenanceMarginRate: 1 / 50, @@ -234,7 +243,7 @@ describe('previewHyperLiquidIsolatedPositionModify', () => { ); const overstatedMarginLiq = estimateIsolatedLiquidationPrice({ isLong: true, - entryPrice: 2000, + markPrice: 2000, margin: 500, positionSize: 1.5, maintenanceMarginRate: 1 / 50, @@ -265,9 +274,13 @@ describe('previewHyperLiquidIsolatedPositionModify', () => { available: true, value: 375, }); - // Mark notional $3750 / $375 = 10x. Entry notional would report ~8.67x. expect(result.resulting.leverage).toBeCloseTo(10); - expect(result.resulting.entryPrice).toBeCloseTo(2166.666, 1); + expect(result.resulting.entryPrice).toBeCloseTo(2166.6666667); + // Mark-based liq: (2500 - 375/1.5) / (1 - 1/50) = 2295.918... + // Entry-based liq would be ~1955.78 and is wrong for TP/SL. + expect( + availablePreviewValue(result.resulting.liquidationPrice), + ).toBeCloseTo(2295.9183673469); }); it('projects a partial decrease using the remaining position direction', () => { @@ -295,6 +308,35 @@ describe('previewHyperLiquidIsolatedPositionModify', () => { }); }); + it('marks a partial decrease at the expected fill, not live mark', () => { + const result = previewHyperLiquidIsolatedPositionModify({ + position: isolatedPosition(), + direction: 'short', + size: '0.4', + price: '1800', + leverage: 5, + reduceOnly: true, + marginTiers: singleTier25x, + }); + + expect(result.status).toBe('open'); + if (result.status !== 'open') { + return; + } + expect(result.kind).toBe('decrease'); + expect(result.resulting.size).toBeCloseTo(0.6); + expect(result.resulting.margin).toStrictEqual({ + available: true, + value: 240, + }); + // Remaining 0.6 marked at 1800 → notional 1080 / margin 240 = 4.5x. + expect(result.resulting.leverage).toBeCloseTo(4.5); + // (1800 - 240/0.6) / (1 - 1/50) = 1428.571... + expect( + availablePreviewValue(result.resulting.liquidationPrice), + ).toBeCloseTo(1428.5714285714); + }); + it('reallocates before a partial decrease when leverage changes', () => { const result = previewHyperLiquidIsolatedPositionModify({ position: isolatedPosition(), @@ -447,7 +489,7 @@ describe('previewHyperLiquidIsolatedPositionModify', () => { const expected = estimateIsolatedLiquidationPriceAtTier({ isLong: true, - entryPrice: result.resulting.entryPrice, + markPrice: 2500, margin: result.resulting.margin.available ? result.resulting.margin.value : 0, @@ -456,7 +498,7 @@ describe('previewHyperLiquidIsolatedPositionModify', () => { }); const singleTier = estimateIsolatedLiquidationPrice({ isLong: true, - entryPrice: result.resulting.entryPrice, + markPrice: 2500, margin: result.resulting.margin.available ? result.resulting.margin.value : 0, From 8305e8e4da80095badac6f7efd066c8ff121ef45 Mon Sep 17 00:00:00 2001 From: Michal Szorad Date: Wed, 26 Aug 2026 17:16:12 +0200 Subject: [PATCH 8/8] fix(perps-controller): keep preview changelog entry in Unreleased Release 13.0.0 landed on main while this PR still listed the preview under Unreleased together with those released notes. Merging parked #9968 under 13.0.0, which fails the merge-queue changelog check. --- packages/perps-controller/CHANGELOG.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 014f90c68c9..f46004086a6 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,8 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [13.0.0] - ### Added - **BREAKING:** Add `PerpsController.previewPositionModify` and `PerpsProvider.previewPositionModify` so clients can read an isolated-margin post-trade projection without placing an order ([#9968](https://github.com/MetaMask/core/pull/9968)) @@ -16,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The result is a discriminated union (`none` / `unsupported` / `full_close` / `open`) so a non-modifying preview cannot carry a flip kind and a full close cannot report remaining size. Margin and liquidation availability are independent: a missing live liquidation or missing multi-tier table withholds only liquidation. - Isolated increases, leverage changes (up or down), reductions, flips, and full closes are projected for both longs and shorts. `price` is the expected fill or resting limit; the preview does not distinguish order types. Same-direction `reduceOnly` and increases/flips without a positive price return `{ status: 'none' }`. `resulting.leverage` is mark notional / remaining isolated margin. Liquidation uses the projected mark (not average entry) because isolated `marginUsed` is mark-based equity. A missing margin-table identity withholds liquidation rather than inventing a single-tier schedule. Aggregated providers route by `providerId` / `position.providerId`. Cross-margin returns `{ status: 'unsupported', reason: 'cross_margin' }`. MYX returns `{ status: 'unsupported', reason: 'provider' }`. - Consumers that implement `PerpsProvider` must add `previewPositionModify`. Clients should use `resulting.direction` (not the order direction) when validating TP/SL against the projected liquidation. + +## [13.0.0] + +### Added + - Add the public Chase lifecycle API (`getChaseOrders` and `suspendChaseOrders`), aggregated-provider routing, retained lifecycle snapshots, directional max-distance stopping, and idempotent termination of