From 30d2e84994b95750b17b3da772d91e367c322940 Mon Sep 17 00:00:00 2001 From: geositta Date: Tue, 22 Sep 2026 20:37:42 -0500 Subject: [PATCH 1/9] fix: fix(perps): clarify IOC and price movement failures Add structured PRICE_MOVED errors and full-close handling in the perps controller, then surface slippage-aware recovery copy, controls, and analytics in Mobile. --- .../src/constants/eventNames.ts | 1 + packages/perps-controller/src/index.ts | 6 ++ .../perps-controller/src/perpsErrorCodes.ts | 1 + .../src/providers/HyperLiquidProvider.ts | 10 ++- .../src/providers/LighterProvider.ts | 47 ++++++++++-- .../src/services/TradingService.ts | 71 +++++++++++++++++-- packages/perps-controller/src/types/index.ts | 11 +++ .../src/utils/hyperLiquidValidation.ts | 20 +++++- .../src/utils/orderCalculations.ts | 25 ++++--- .../HyperLiquidProvider.trading.test.ts | 34 ++++++--- .../src/providers/LighterProvider.test.ts | 41 ++++++++++- .../tests/src/services/TradingService.test.ts | 53 ++++++++++++++ .../tests/src/utils/orderCalculations.test.ts | 35 ++++++++- 13 files changed, 319 insertions(+), 36 deletions(-) diff --git a/packages/perps-controller/src/constants/eventNames.ts b/packages/perps-controller/src/constants/eventNames.ts index 1c9cac54d82..0864623e88b 100644 --- a/packages/perps-controller/src/constants/eventNames.ts +++ b/packages/perps-controller/src/constants/eventNames.ts @@ -183,6 +183,7 @@ export const PERPS_EVENT_PROPERTY = { MAX_SLIPPAGE_PCT: 'max_slippage_pct', MAX_SLIPPAGE_SOURCE: 'max_slippage_source', ESTIMATED_SLIPPAGE_PCT: 'estimated_slippage_pct', + PRICE_DELTA_BPS: 'price_delta_bps', // Account setup / abstraction mode (PERPS_ACCOUNT_SETUP) ABSTRACTION_MODE: 'abstraction_mode', diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 73e07901e53..e02a6b556d9 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -734,6 +734,12 @@ export { // Error codes (explicit named exports) export { PERPS_ERROR_CODES } from './perpsErrorCodes.js'; export type { PerpsErrorCode } from './perpsErrorCodes.js'; +export { PerpsControllerError, createPriceMovedError } from './errors.js'; +export type { + PerpsErrorDetails, + PerpsErrorResultFields, + PriceMovedErrorDetails, +} from './errors.js'; // Selectors (explicit named exports) export { diff --git a/packages/perps-controller/src/perpsErrorCodes.ts b/packages/perps-controller/src/perpsErrorCodes.ts index b93d8b91e96..884cd6db682 100644 --- a/packages/perps-controller/src/perpsErrorCodes.ts +++ b/packages/perps-controller/src/perpsErrorCodes.ts @@ -130,6 +130,7 @@ export const PERPS_ERROR_CODES = { // Order execution errors ORDER_REJECTED: 'ORDER_REJECTED', SLIPPAGE_EXCEEDED: 'SLIPPAGE_EXCEEDED', + PRICE_MOVED: 'PRICE_MOVED', RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED', // Network/service errors SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE', diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index d55362eadcc..c2d5248a238 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -6394,7 +6394,14 @@ export class HyperLiquidProvider implements PerpsProvider { ); } - return createErrorResult(mappedError, { success: false }); + const result = createErrorResult(mappedError, { success: false }); + const mappedErrorCode = Object.values(PERPS_ERROR_CODES).includes( + mappedError.message as PerpsErrorCode, + ) + ? (mappedError.message as PerpsErrorCode) + : undefined; + + return mappedErrorCode ? { ...result, errorCode: mappedErrorCode } : result; } /** @@ -6539,6 +6546,7 @@ export class HyperLiquidProvider implements PerpsProvider { szDecimals: assetInfo.szDecimals, leverage: params.leverage, reduceOnly: params.reduceOnly, + isFullClose: params.reduceOnly === true && params.isFullClose === true, }); const { orderPrice, formattedSize, formattedPrice } = diff --git a/packages/perps-controller/src/providers/LighterProvider.ts b/packages/perps-controller/src/providers/LighterProvider.ts index 2728da6216b..8ed63ec9468 100644 --- a/packages/perps-controller/src/providers/LighterProvider.ts +++ b/packages/perps-controller/src/providers/LighterProvider.ts @@ -131,6 +131,7 @@ import type { PerpsChaseOrderDispatch, } from '../types/index.js'; import { WebSocketConnectionState } from '../types/index.js'; +import { PerpsControllerError, createPriceMovedError } from '../errors.js'; import type { AccountState, TwapOrder, @@ -11236,8 +11237,18 @@ export class LighterProvider implements PerpsProvider { params.symbol, slippageFraction, params.priceAtCalculation, + market.supportedSizeDecimals, + params.reduceOnly === true && params.isFullClose === true, ); if (resolved.error !== null) { + if (resolved.error instanceof PerpsControllerError) { + return { + success: false, + error: resolved.error.message, + errorCode: resolved.error.errorCode, + errorDetails: resolved.error.errorDetails, + }; + } return { success: false, error: resolved.error }; } referencePrice = resolved.referencePrice; @@ -13998,9 +14009,11 @@ export class LighterProvider implements PerpsProvider { symbol: string, slippageFraction: number, priceAtCalculation?: number, + szDecimals = 2, + isFullClose = false, ): Promise< | { referencePrice: number; error: null } - | { referencePrice: null; error: string } + | { referencePrice: null; error: string | PerpsControllerError } > => { // Numeric intent validates fail-closed BEFORE any drift math. A // non-finite/non-positive snapshot makes the drift comparison NaN @@ -14043,12 +14056,18 @@ export class LighterProvider implements PerpsProvider { if ( priceAtCalculation !== undefined && priceAtCalculation > 0 && + !isFullClose && Math.abs(freshPrice - priceAtCalculation) / priceAtCalculation > slippageFraction ) { return { referencePrice: null, - error: `Price moved beyond the ${(slippageFraction * 100).toFixed(2)}% slippage tolerance since sizing`, + error: createPriceMovedError({ + expectedPrice: priceAtCalculation, + currentPrice: freshPrice, + maxSlippageBps: slippageFraction * 10_000, + szDecimals, + }), }; } return { referencePrice: freshPrice, error: null }; @@ -17070,12 +17089,14 @@ export class LighterProvider implements PerpsProvider { // fresh-price lookup can throw on REST failure. let resolved: | { referencePrice: number; error: null } - | { referencePrice: null; error: string }; + | { referencePrice: null; error: string | PerpsControllerError }; try { resolved = await this.#resolveMarketReferencePrice( params.symbol, slippageFraction, params.priceAtCalculation, + market.supportedSizeDecimals, + params.size === undefined && params.usdAmount === undefined, ); } catch (error) { return { @@ -17084,7 +17105,13 @@ export class LighterProvider implements PerpsProvider { }; } if (resolved.error !== null) { - return { isValid: false, error: resolved.error }; + return { + isValid: false, + error: + resolved.error instanceof Error + ? resolved.error.message + : resolved.error, + }; } referencePrice = resolved.referencePrice; executionPrice = deriveLighterExecutionPrice( @@ -17277,12 +17304,14 @@ export class LighterProvider implements PerpsProvider { // Same validator contract as validateOrder: REST failures resolve. let resolved: | { referencePrice: number; error: null } - | { referencePrice: null; error: string }; + | { referencePrice: null; error: string | PerpsControllerError }; try { resolved = await this.#resolveMarketReferencePrice( params.symbol, slippageFraction, params.priceAtCalculation, + market.supportedSizeDecimals, + params.size === undefined && params.usdAmount === undefined, ); } catch (error) { return { @@ -17292,7 +17321,13 @@ export class LighterProvider implements PerpsProvider { }; } if (resolved.error !== null) { - return { isValid: false, error: resolved.error }; + return { + isValid: false, + error: + resolved.error instanceof Error + ? resolved.error.message + : resolved.error, + }; } referencePrice = resolved.referencePrice; // Closing is the opposite side: a SHORT closes with a BUY, whose diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index 379f3904f13..90cde8adbe1 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -9,6 +9,7 @@ import { isTPSLOrder } from '../constants/orderTypes.js'; import { PerpsMeasurementName } from '../constants/performanceMetrics.js'; import { PERPS_CONSTANTS } from '../constants/perpsConfig.js'; import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; +import { PerpsControllerError } from '../errors.js'; import { PerpsAnalyticsEvent, PerpsTraceNames, @@ -166,6 +167,56 @@ export class TradingService { return properties; } + /** + * Build normalized slippage and failure properties without removing the + * existing raw error message used by current analytics dashboards. + * + * @param trackingData - Optional caller-provided slippage context. + * @param result - Provider result, when available. + * @param error - Thrown provider error, when available. + * @returns Slippage and normalized failure properties. + */ + #buildSlippageProperties( + trackingData?: TrackingData, + result?: OrderResult | null, + error?: Error, + ): PerpsAnalyticsProperties { + const properties: PerpsAnalyticsProperties = {}; + + if (trackingData?.maxSlippageBps !== undefined) { + properties[PERPS_EVENT_PROPERTY.MAX_SLIPPAGE_PCT] = + trackingData.maxSlippageBps / 100; + } + if (trackingData?.maxSlippageSource !== undefined) { + properties[PERPS_EVENT_PROPERTY.MAX_SLIPPAGE_SOURCE] = + trackingData.maxSlippageSource; + } + if (trackingData?.estimatedSlippageBps !== undefined) { + properties[PERPS_EVENT_PROPERTY.ESTIMATED_SLIPPAGE_PCT] = + trackingData.estimatedSlippageBps / 100; + } + + const errorCode = + result?.errorCode ?? + (error instanceof PerpsControllerError ? error.errorCode : undefined); + const errorDetails = + result?.errorDetails ?? + (error instanceof PerpsControllerError ? error.errorDetails : undefined); + + if (errorCode !== undefined) { + properties[PERPS_EVENT_PROPERTY.FAILURE_REASON] = errorCode; + } + if ( + errorDetails?.code === PERPS_ERROR_CODES.PRICE_MOVED && + Number.isFinite(errorDetails.priceDeltaBps) + ) { + properties[PERPS_EVENT_PROPERTY.PRICE_DELTA_BPS] = + errorDetails.priceDeltaBps; + } + + return properties; + } + /** * Build properties that identify the submitted order intent. * @@ -243,6 +294,7 @@ export class TradingService { ? PERPS_EVENT_VALUE.DIRECTION.LONG : PERPS_EVENT_VALUE.DIRECTION.SHORT, ...this.#buildTradeIntentProperties(params), + ...this.#buildSlippageProperties(params.trackingData, result, error), [PERPS_EVENT_PROPERTY.LEVERAGE]: parseFloat(String(params.leverage ?? 1)), [PERPS_EVENT_PROPERTY.ORDER_SIZE]: trackedOrderSize, [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: duration, @@ -673,6 +725,7 @@ export class TradingService { [PERPS_EVENT_PROPERTY.LEVERAGE]: parseFloat( String(params.leverage ?? 1), ), + ...this.#buildSlippageProperties(params.trackingData), ...this.#buildAttributionProperties(params.trackingData), }); @@ -938,7 +991,7 @@ export class TradingService { }, result: OrderResult | null, status: string, - error?: string, + error?: Error | string, ): Record { // Effective leverage = positionUSD / marginUSD, rounded to 1 decimal place. // Computed from the live position rather than the configured leverage so it's @@ -958,6 +1011,7 @@ export class TradingService { [PERPS_EVENT_PROPERTY.DIRECTION]: metrics.direction, [PERPS_EVENT_PROPERTY.ORDER_TYPE]: metrics.orderType, [PERPS_EVENT_PROPERTY.ORDER_SIZE]: metrics.requestedSize, + [PERPS_EVENT_PROPERTY.CLOSE_TYPE]: metrics.closeType, [PERPS_EVENT_PROPERTY.OPEN_POSITION_SIZE]: Math.abs( parseFloat(position.size), ), @@ -1010,6 +1064,11 @@ export class TradingService { ...(params.trackingData?.vipDiscount !== undefined && { [PERPS_EVENT_PROPERTY.VIP_DISCOUNT]: params.trackingData.vipDiscount, }), + ...this.#buildSlippageProperties( + params.trackingData, + result, + error instanceof Error ? error : undefined, + ), // Effective leverage on close events ...(effectiveLeverage !== undefined && { [PERPS_EVENT_PROPERTY.LEVERAGE]: effectiveLeverage, @@ -1031,7 +1090,6 @@ export class TradingService { if (status === PERPS_EVENT_VALUE.STATUS.EXECUTED) { return { ...baseProperties, - [PERPS_EVENT_PROPERTY.CLOSE_TYPE]: metrics.closeType, ...(orderValue !== undefined && { [PERPS_EVENT_PROPERTY.ORDER_VALUE]: orderValue, }), @@ -1041,7 +1099,10 @@ export class TradingService { // Add error for failures return { ...baseProperties, - ...(error && { [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: error }), + ...(error && { + [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: + error instanceof Error ? error.message : error, + }), ...(orderValue !== undefined && { [PERPS_EVENT_PROPERTY.ORDER_VALUE]: orderValue, }), @@ -1098,6 +1159,7 @@ export class TradingService { ...(errorMessage && { [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: errorMessage, }), + ...this.#buildSlippageProperties(params.trackingData, result, error), ...this.#buildAttributionProperties(params.trackingData), ...bulkActionProps, }, @@ -1163,7 +1225,7 @@ export class TradingService { metrics, result, status, - errorMessage, + error ?? errorMessage, ); this.#deps.metrics.trackPerpsEvent( @@ -2089,6 +2151,7 @@ export class TradingService { [PERPS_EVENT_PROPERTY.ASSET]: params.symbol, [PERPS_EVENT_PROPERTY.ORDER_TYPE]: params.orderType ?? PERPS_EVENT_VALUE.ORDER_TYPE.MARKET, + ...this.#buildSlippageProperties(params.trackingData), ...this.#buildAttributionProperties(params.trackingData), ...(bulkActionId && { [PERPS_EVENT_PROPERTY.BULK_ACTION_ID]: bulkActionId, diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index d183ef4912b..97c8bff6fc2 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -8,6 +8,7 @@ import type { import type { CandlePeriod, TimeDuration } from '../constants/chartConfig.js'; import type { CHASE_ORDER_STATUS } from '../constants/perpsConfig.js'; +import type { PerpsErrorDetails, PerpsErrorResultFields } from '../errors.js'; import type { LighterSignerBridge } from './lighter-types.js'; import type { CandleData, @@ -202,6 +203,12 @@ export type TrackingData = { // close events when present; omitted entirely when unavailable. hlFeeRate?: number; + // Slippage context for trade and close transaction analytics. + maxSlippageBps?: number; + maxSlippageSource?: string; + estimatedSlippageBps?: number; + isFullClose?: boolean; + // Pay with any token: true when user paid with a custom token (not Perps balance) tradeWithToken?: boolean; mmPayTokenSelected?: string; // Token symbol when tradeWithToken is true @@ -470,6 +477,10 @@ export type OrderResult = { */ orderId?: string; error?: string; + /** Stable error classification for client-side translation. */ + errorCode?: PerpsErrorResultFields['errorCode']; + /** Optional structured data for the classified error. */ + errorDetails?: PerpsErrorDetails; filledSize?: string; // Amount filled // Full normalized size submitted to the exchange (post precision rounding, // USD recalculation, and any $10-minimum retry). Present only when the diff --git a/packages/perps-controller/src/utils/hyperLiquidValidation.ts b/packages/perps-controller/src/utils/hyperLiquidValidation.ts index e1e43eff859..3605a05c9f0 100644 --- a/packages/perps-controller/src/utils/hyperLiquidValidation.ts +++ b/packages/perps-controller/src/utils/hyperLiquidValidation.ts @@ -25,6 +25,10 @@ import { isTriggerOrderType, SCALE_ORDER_COUNT, } from './orderTypes.js'; +import { + PerpsControllerError, + type PerpsErrorResultFields, +} from '../errors.js'; /** * Optional debug logger for validation functions. @@ -46,12 +50,26 @@ export type ValidationDebugLogger = PerpsDebugLogger | undefined; */ export function createErrorResult< TValue extends { success: boolean; error?: string }, ->(error: unknown, defaultResponse: TValue): TValue { +>(error: unknown, defaultResponse: TValue): TValue & PerpsErrorResultFields { + const errorFields = + error instanceof PerpsControllerError + ? { + errorCode: error.errorCode, + errorDetails: error.errorDetails, + } + : error instanceof Error && + Object.values(PERPS_ERROR_CODES).includes( + error.message as (typeof PERPS_ERROR_CODES)[keyof typeof PERPS_ERROR_CODES], + ) + ? { errorCode: error.message as PerpsErrorResultFields['errorCode'] } + : {}; + return { ...defaultResponse, success: false, error: error instanceof Error ? error.message : PERPS_ERROR_CODES.UNKNOWN_ERROR, + ...errorFields, }; } diff --git a/packages/perps-controller/src/utils/orderCalculations.ts b/packages/perps-controller/src/utils/orderCalculations.ts index 64c16bf7177..7940e5235c8 100644 --- a/packages/perps-controller/src/utils/orderCalculations.ts +++ b/packages/perps-controller/src/utils/orderCalculations.ts @@ -14,6 +14,7 @@ import { formatHyperLiquidPrice, formatHyperLiquidSize, } from './hyperLiquidAdapter.js'; +import { createPriceMovedError } from '../errors.js'; import { getTriggerDirection, isLimitExecutionOrderType, @@ -72,6 +73,9 @@ export type CalculateFinalPositionSizeParams = { // rejects a reduce-only order whose size exceeds the live position with // "Reduce only order would increase position". reduceOnly?: boolean; + // Full market closes use the live position size and retain venue-side + // slippage protection, so they do not need the local snapshot guard. + isFullClose?: boolean; debugLogger?: OrderCalculationsDebugLogger; }; @@ -612,16 +616,17 @@ export function calculateFinalPositionSize( szDecimals, leverage, reduceOnly, + isFullClose, debugLogger, } = params; let finalPositionSize: number; - // Validate price staleness whenever the caller supplied a calculation-time - // price. This runs before the sizing branches on purpose: a full close submits - // the exact live position size rather than a USD-derived one, and it must still - // be rejected when the price has moved past the caller's tolerance. - if (priceAtCalculation) { + // USD-derived sizing needs a fresh snapshot to avoid submitting a size that + // no longer represents the user's requested USD amount. A full close already + // submits the live position size, so the venue-side slippage-capped limit is + // the relevant protection and the local snapshot guard is redundant. + if (priceAtCalculation && !isFullClose) { const priceDeltaBps = Math.abs( ((currentPrice - priceAtCalculation) / priceAtCalculation) * 10000, ); @@ -629,10 +634,12 @@ export function calculateFinalPositionSize( maxSlippageBps ?? ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps; if (priceDeltaBps > maxSlippageBpsValue) { - throw new Error( - `Price moved too much: ${priceDeltaBps.toFixed(0)} bps (max: ${maxSlippageBpsValue} bps). ` + - `Expected: ${priceAtCalculation.toFixed(2)}, Current: ${currentPrice.toFixed(2)}`, - ); + throw createPriceMovedError({ + expectedPrice: priceAtCalculation, + currentPrice, + maxSlippageBps: maxSlippageBpsValue, + szDecimals, + }); } debugLogger?.log('Price validation passed:', { diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts index 66b3947c143..7c43d03254b 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts @@ -728,6 +728,25 @@ describe('HyperLiquidProvider', () => { expect(result.success).toBe(false); }); + it('maps an immediate-match rejection to IOC_CANCEL with a code', async () => { + ( + mockClientService.getExchangeClient().order as jest.Mock + ).mockRejectedValueOnce(new Error('Order could not immediately match')); + + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }); + + expect(result).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.IOC_CANCEL, + errorCode: PERPS_ERROR_CODES.IOC_CANCEL, + }); + }); + it('edits an order successfully', async () => { const editParams = { orderId: '123', @@ -2416,10 +2435,10 @@ describe('HyperLiquidProvider', () => { expect(getSubmittedOrder()).toMatchObject({ s: '0.1', r: true }); }); - it('rejects a full close whose price moved beyond maxSlippageBps', async () => { - // A full close submits the exact live size rather than a USD-derived one, - // but the caller's staleness guard must still apply: priceAtCalculation is - // 50000 and the live price is 40000, a 2000 bps move against a 300 bps cap + it('submits a full close when the local price snapshot is stale', async () => { + // A full close submits the exact live size rather than a USD-derived one. + // The venue-side slippage-capped price remains the execution protection, + // so the redundant local snapshot guard is skipped. primePositionsCache([createPositionSnapshot({ size: '0.1' })]); const result = await provider.closePosition({ @@ -2431,11 +2450,8 @@ describe('HyperLiquidProvider', () => { position: createPositionSnapshot({ size: '0.1' }), }); - expect(result.success).toBe(false); - expect(result.error).toContain('Price moved too much'); - expect( - mockClientService.getExchangeClient().order, - ).not.toHaveBeenCalled(); + expect(result.success).toBe(true); + expect(getSubmittedOrder()).toMatchObject({ s: '0.1', r: true }); }); it('submits the exact live size for a full close inside maxSlippageBps', async () => { diff --git a/packages/perps-controller/tests/src/providers/LighterProvider.test.ts b/packages/perps-controller/tests/src/providers/LighterProvider.test.ts index 17222b8bcf7..6fb7c94bdd5 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.test.ts @@ -24968,10 +24968,45 @@ describe('LighterProvider', () => { }; const result = await provider.validateClosePosition(request); expect(result.isValid).toBe(false); - expect(result.error).toContain('slippage tolerance since sizing'); + expect(result.error).toContain('Price moved too much'); const execution = await provider.closePosition(request); expect(execution.success).toBe(false); - expect(execution.error).toContain('slippage tolerance since sizing'); + expect(execution.error).toContain('Price moved too much'); + expect(execution.errorCode).toBe('PRICE_MOVED'); + expect(execution.errorDetails).toMatchObject({ + code: 'PRICE_MOVED', + maxSlippageBps: 500, + }); + }); + + it('allows a full market close when the caller price snapshot is stale', async () => { + const { provider, clientInstance, calls } = buildProvider(); + clientInstance.getAccountByIndex.mockResolvedValue({ + code: 200, + accounts: [ + { + ...ACCOUNT, + positions: [{ ...ACCOUNT.positions[0], position: '0.001' }], + }, + ], + }); + + const request = { + symbol: 'BTC', + orderType: 'market' as const, + currentPrice: 90_000, + priceAtCalculation: 90_000, + }; + + const validation = await provider.validateClosePosition(request); + expect(validation.isValid).toBe(true); + + const result = await provider.closePosition(request); + + expect(result.success).toBe(true); + expect(calls.some((call) => call.function === '_signCreateOrder')).toBe( + true, + ); }); it("rejects an 'Infinity' limit price in validators and placement alike", async () => { @@ -25975,7 +26010,7 @@ describe('LighterProvider', () => { priceAtCalculation: 90000, }); expect(result.success).toBe(false); - expect(result.error).toContain('slippage tolerance'); + expect(result.error).toContain('Price moved too much'); }); }); diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index 749f8c4cc57..3ea8beb5822 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -931,6 +931,59 @@ describe('TradingService', () => { ); }); + it('preserves raw errors and adds normalized slippage analytics', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + trackingData: { + totalFee: 0, + marketPrice: 50_000, + maxSlippageBps: 300, + maxSlippageSource: 'user_configured', + estimatedSlippageBps: 125, + }, + }; + const mockOrderResult: OrderResult = { + success: false, + error: 'Price moved too much: 336 bps (max: 300 bps)', + errorCode: PERPS_ERROR_CODES.PRICE_MOVED, + errorDetails: { + code: PERPS_ERROR_CODES.PRICE_MOVED, + priceDeltaBps: 336, + maxSlippageBps: 300, + expectedPrice: 50_000, + currentPrice: 51_680, + szDecimals: 3, + }, + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.TradeTransaction, + expect.objectContaining({ + error_message: mockOrderResult.error, + failure_reason: PERPS_ERROR_CODES.PRICE_MOVED, + max_slippage_pct: 3, + max_slippage_source: 'user_configured', + estimated_slippage_pct: 1.25, + price_delta_bps: 336, + }), + ); + }); + it('reports order to data lake on success (fire-and-forget)', async () => { const orderParams: OrderParams = { symbol: 'BTC', diff --git a/packages/perps-controller/tests/src/utils/orderCalculations.test.ts b/packages/perps-controller/tests/src/utils/orderCalculations.test.ts index 2adceda199c..af3e2e8f60f 100644 --- a/packages/perps-controller/tests/src/utils/orderCalculations.test.ts +++ b/packages/perps-controller/tests/src/utils/orderCalculations.test.ts @@ -1,4 +1,5 @@ import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { PerpsControllerError } from '../../../src/errors.js'; import { calculateFinalPositionSize, floorToSizeDecimals, @@ -511,15 +512,43 @@ describe('calculateFinalPositionSize', () => { }); it('throws when the price moved beyond the allowed slippage', () => { - expect(() => + try { calculateFinalPositionSize({ usdAmount: '5000', currentPrice: 45000, priceAtCalculation: 50000, maxSlippageBps: 300, szDecimals: 3, - }), - ).toThrow('Price moved too much'); + }); + throw new Error('Expected price movement to be rejected'); + } catch (error) { + expect(error).toBeInstanceOf(PerpsControllerError); + expect(error).toMatchObject({ + errorCode: PERPS_ERROR_CODES.PRICE_MOVED, + errorDetails: { + code: PERPS_ERROR_CODES.PRICE_MOVED, + priceDeltaBps: 1000, + maxSlippageBps: 300, + expectedPrice: 50000, + currentPrice: 45000, + szDecimals: 3, + }, + }); + } + }); + + it('does not reject a full reduce-only close when the snapshot is stale', () => { + const result = calculateFinalPositionSize({ + size: '0.123', + currentPrice: 45000, + priceAtCalculation: 50000, + maxSlippageBps: 300, + szDecimals: 3, + reduceOnly: true, + isFullClose: true, + }); + + expect(result.finalPositionSize).toBeCloseTo(0.123, 10); }); }); From 5b8e68594ca7d61ce3a4fc3cc8754309f829c643 Mon Sep 17 00:00:00 2001 From: geositta Date: Tue, 22 Sep 2026 20:46:49 -0500 Subject: [PATCH 2/9] chore: add file --- packages/perps-controller/src/errors.ts | 80 +++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 packages/perps-controller/src/errors.ts diff --git a/packages/perps-controller/src/errors.ts b/packages/perps-controller/src/errors.ts new file mode 100644 index 00000000000..d475656284e --- /dev/null +++ b/packages/perps-controller/src/errors.ts @@ -0,0 +1,80 @@ +import { PERPS_ERROR_CODES, type PerpsErrorCode } from './perpsErrorCodes.js'; + +/** + * Details for a local price movement rejection. + * + * The numeric values are kept unformatted so clients can localize and format + * them for their own surfaces while the legacy error string remains available. + */ +export type PriceMovedErrorDetails = { + code: typeof PERPS_ERROR_CODES.PRICE_MOVED; + priceDeltaBps: number; + maxSlippageBps: number; + expectedPrice: number; + currentPrice: number; + szDecimals: number; +}; + +export type PerpsErrorDetails = PriceMovedErrorDetails; + +export type PerpsErrorResultFields = { + errorCode?: PerpsErrorCode; + errorDetails?: PerpsErrorDetails; +}; + +/** + * Error that preserves a stable client-facing code and optional structured + * details across provider and controller error boundaries. + */ +export class PerpsControllerError extends Error { + readonly errorCode: PerpsErrorCode; + + readonly errorDetails?: PerpsErrorDetails; + + constructor( + errorCode: PerpsErrorCode, + message: string, + errorDetails?: PerpsErrorDetails, + ) { + super(message); + this.name = 'PerpsControllerError'; + this.errorCode = errorCode; + this.errorDetails = errorDetails; + } +} + +/** + * Creates the normalized local price movement error used by both providers. + * + * @param params - Price snapshots, tolerance, and asset precision. + * @returns A structured price movement error. + */ +export function createPriceMovedError(params: { + expectedPrice: number; + currentPrice: number; + maxSlippageBps: number; + szDecimals: number; +}): PerpsControllerError { + const { expectedPrice, currentPrice, maxSlippageBps, szDecimals } = params; + const priceDeltaBps = Math.abs( + ((currentPrice - expectedPrice) / expectedPrice) * 10000, + ); + const priceDecimals = + Number.isInteger(szDecimals) && szDecimals >= 0 + ? Math.max(2, szDecimals) + : 2; + + return new PerpsControllerError( + PERPS_ERROR_CODES.PRICE_MOVED, + `Price moved too much: ${priceDeltaBps.toFixed(0)} bps (max: ${maxSlippageBps} bps). ` + + `Expected: ${expectedPrice.toFixed(priceDecimals)}, Current: ${currentPrice.toFixed(priceDecimals)}`, + { + code: PERPS_ERROR_CODES.PRICE_MOVED, + priceDeltaBps, + maxSlippageBps, + expectedPrice, + currentPrice, + szDecimals, + }, + ); +} From c6940e47e64922e4c45ced1b7c2dac126a67b5fb Mon Sep 17 00:00:00 2001 From: geositta Date: Tue, 22 Sep 2026 23:08:16 -0500 Subject: [PATCH 3/9] fix(perps): clarify price movement failures --- packages/perps-controller/CHANGELOG.md | 2 + .../src/constants/eventNames.ts | 3 ++ packages/perps-controller/src/errors.ts | 37 +++++++++------ packages/perps-controller/src/index.ts | 10 ++-- .../perps-controller/src/perpsErrorCodes.ts | 13 +++++ .../src/providers/HyperLiquidProvider.ts | 18 +++---- .../src/providers/LighterProvider.ts | 47 +++---------------- .../src/services/TradingService.ts | 2 +- packages/perps-controller/src/types/index.ts | 9 ++-- .../src/utils/hyperLiquidValidation.ts | 35 +++++++------- .../src/utils/orderCalculations.ts | 24 +++++----- .../HyperLiquidProvider.trading.test.ts | 22 ++++----- .../src/providers/LighterProvider.test.ts | 41 ++-------------- .../tests/src/utils/orderCalculations.test.ts | 32 ++++++++----- 14 files changed, 124 insertions(+), 171 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index cc29586fab1..5012b2b53ca 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -217,6 +217,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add an optional `chargesMetamaskBuilderFee` field to `FeeCalculationResult`, which reports whether a placement can carry a MetaMask builder fee at all. A `metamaskFeeRate` of `0` is otherwise ambiguous between a venue or order type that has no builder field and a fully waived fee. ([#10294](https://github.com/MetaMask/core/pull/10294)) - Add an optional `registerTradingAddress` hook to the injected `subscription` dependency, for registering the current HyperLiquid trading address (CAIP-10) against the subscription profile. The injected hook remains a fallback for clients that do not provide `SubscriptionController:registerAddress`. ([#10294](https://github.com/MetaMask/core/pull/10294)) - Add optional `fillId` field to the `OrderFill` type, an opaque execution identifier (built from HyperLiquid `coin`, `time` and `tid`, or Lighter `tradeId`) so clients can tell apart two executions of one order that share `orderId`, `timestamp`, `size` and `price` ([#10384](https://github.com/MetaMask/core/pull/10384)) +- Add structured Perps order error codes and price-movement details for client localization and analytics ([#10385](https://github.com/MetaMask/core/pull/10385)) ### Changed @@ -239,6 +240,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Allow exact-size Hyperliquid market closes to use the live venue slippage cap without being blocked by a stale USD-sizing snapshot ([#10385](https://github.com/MetaMask/core/pull/10385)) - Report `source: 'subscription'` only when the waiver survives the venue's fee quantization. The builder fee is submitted in integer tenths of a basis point, so a blend a fraction below the default rounds to the same charge; such an order was labelled as subscription-sourced with a `0` bips discount while paying full price, disagreeing with the client order ID, which already withheld its marking in that case. ([#10294](https://github.com/MetaMask/core/pull/10294)) - Leave a fee tie to the source that already holds it, rather than claiming it for the subscription waiver. A blend that merely matched the winning rate — a 5-bip blend against a 5-bip VIP discount, or a full waiver against a rewards rate already at `0` — was reported as `source: 'subscription'`, which marks the client order ID and spends the remaining allowance without making the order any cheaper. The waiver now has to be strictly cheaper at venue precision to win. ([#10294](https://github.com/MetaMask/core/pull/10294)) - Reprice a quote whose `metamaskFeeRate` reads `0` because a concurrent fully waived submit left that rate in provider state. An ordinary preview racing such a submit inherited the other order's waiver and quoted no MetaMask fee; the provider's own builder-fee policy now distinguishes that from a placement that genuinely carries no fee. Repricing a `0` rate is opt-in: a provider that does not report `chargesMetamaskBuilderFee` keeps its own rate, so a `PerpsProvider` implementation written before that field existed cannot gain a MetaMask fee it does not charge. ([#10294](https://github.com/MetaMask/core/pull/10294)) diff --git a/packages/perps-controller/src/constants/eventNames.ts b/packages/perps-controller/src/constants/eventNames.ts index 0864623e88b..bad1972992d 100644 --- a/packages/perps-controller/src/constants/eventNames.ts +++ b/packages/perps-controller/src/constants/eventNames.ts @@ -693,3 +693,6 @@ export const PERPS_EVENT_VALUE = { ASSET_DETAILS: 'asset_details', }, } as const; + +export type PerpsMaxSlippageSource = + (typeof PERPS_EVENT_VALUE.MAX_SLIPPAGE_SOURCE)[keyof typeof PERPS_EVENT_VALUE.MAX_SLIPPAGE_SOURCE]; diff --git a/packages/perps-controller/src/errors.ts b/packages/perps-controller/src/errors.ts index d475656284e..2eb77cb5a9e 100644 --- a/packages/perps-controller/src/errors.ts +++ b/packages/perps-controller/src/errors.ts @@ -1,4 +1,5 @@ -import { PERPS_ERROR_CODES, type PerpsErrorCode } from './perpsErrorCodes.js'; +import { PERPS_ERROR_CODES } from './perpsErrorCodes.js'; +import type { PerpsErrorCode } from './perpsErrorCodes.js'; /** * Details for a local price movement rejection. @@ -12,7 +13,6 @@ export type PriceMovedErrorDetails = { maxSlippageBps: number; expectedPrice: number; currentPrice: number; - szDecimals: number; }; export type PerpsErrorDetails = PriceMovedErrorDetails; @@ -44,37 +44,44 @@ export class PerpsControllerError extends Error { } /** - * Creates the normalized local price movement error used by both providers. + * Creates a normalized local price movement error. * - * @param params - Price snapshots, tolerance, and asset precision. + * @param params - Price movement context. + * @param params.expectedPrice - Price used when the order was sized. + * @param params.currentPrice - Current price used for submission. + * @param params.formattedExpectedPrice - Provider-formatted expected price. + * @param params.formattedCurrentPrice - Provider-formatted current price. + * @param params.priceDeltaBps - Absolute price movement in basis points. + * @param params.maxSlippageBps - Maximum allowed movement in basis points. * @returns A structured price movement error. */ export function createPriceMovedError(params: { expectedPrice: number; currentPrice: number; + formattedExpectedPrice: string; + formattedCurrentPrice: string; + priceDeltaBps: number; maxSlippageBps: number; - szDecimals: number; }): PerpsControllerError { - const { expectedPrice, currentPrice, maxSlippageBps, szDecimals } = params; - const priceDeltaBps = Math.abs( - ((currentPrice - expectedPrice) / expectedPrice) * 10000, - ); - const priceDecimals = - Number.isInteger(szDecimals) && szDecimals >= 0 - ? Math.max(2, szDecimals) - : 2; + const { + expectedPrice, + currentPrice, + formattedExpectedPrice, + formattedCurrentPrice, + priceDeltaBps, + maxSlippageBps, + } = params; return new PerpsControllerError( PERPS_ERROR_CODES.PRICE_MOVED, `Price moved too much: ${priceDeltaBps.toFixed(0)} bps (max: ${maxSlippageBps} bps). ` + - `Expected: ${expectedPrice.toFixed(priceDecimals)}, Current: ${currentPrice.toFixed(priceDecimals)}`, + `Expected: ${formattedExpectedPrice}, Current: ${formattedCurrentPrice}`, { code: PERPS_ERROR_CODES.PRICE_MOVED, priceDeltaBps, maxSlippageBps, expectedPrice, currentPrice, - szDecimals, }, ); } diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index e02a6b556d9..4ba23fd4bb6 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -460,6 +460,7 @@ export { calculateCandleCount, } from './constants/index.js'; export { PERPS_EVENT_PROPERTY, PERPS_EVENT_VALUE } from './constants/index.js'; +export type { PerpsMaxSlippageSource } from './constants/index.js'; export { DETAILED_ORDER_TYPES, isTPSLOrder } from './constants/index.js'; export { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from './constants/index.js'; export { @@ -732,14 +733,9 @@ export { } from './utils/index.js'; // Error codes (explicit named exports) -export { PERPS_ERROR_CODES } from './perpsErrorCodes.js'; +export { isPerpsErrorCode, PERPS_ERROR_CODES } from './perpsErrorCodes.js'; export type { PerpsErrorCode } from './perpsErrorCodes.js'; -export { PerpsControllerError, createPriceMovedError } from './errors.js'; -export type { - PerpsErrorDetails, - PerpsErrorResultFields, - PriceMovedErrorDetails, -} from './errors.js'; +export type { PerpsErrorDetails, PriceMovedErrorDetails } from './errors.js'; // Selectors (explicit named exports) export { diff --git a/packages/perps-controller/src/perpsErrorCodes.ts b/packages/perps-controller/src/perpsErrorCodes.ts index 884cd6db682..076f407c188 100644 --- a/packages/perps-controller/src/perpsErrorCodes.ts +++ b/packages/perps-controller/src/perpsErrorCodes.ts @@ -139,3 +139,16 @@ export const PERPS_ERROR_CODES = { export type PerpsErrorCode = (typeof PERPS_ERROR_CODES)[keyof typeof PERPS_ERROR_CODES]; + +/** + * Checks whether a value is a known Perps error code. + * + * @param value - Value to classify. + * @returns Whether the value is a Perps error code. + */ +export function isPerpsErrorCode(value: unknown): value is PerpsErrorCode { + return ( + typeof value === 'string' && + Object.values(PERPS_ERROR_CODES).some((code) => code === value) + ); +} diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index c2d5248a238..074fc30534d 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -47,6 +47,7 @@ import { WITHDRAWAL_CONSTANTS, } from '../constants/perpsConfig.js'; import { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from '../constants/transactionsHistoryConfig.js'; +import { PerpsControllerError } from '../errors.js'; import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; import type { PerpsErrorCode } from '../perpsErrorCodes.js'; import { @@ -4849,12 +4850,15 @@ export class HyperLiquidProvider implements PerpsProvider { // client can translate ("fund your account") instead of leaking the raw // exchange string to the UI and to failed-trade analytics. if (isHyperLiquidUserNotFoundError(error)) { - return new Error(PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND); + return new PerpsControllerError( + PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, + PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, + ); } for (const [pattern, code] of Object.entries(this.#errorMappings)) { if (message.toLowerCase().includes(pattern.toLowerCase())) { - return new Error(code); + return new PerpsControllerError(code, code); } } @@ -6394,14 +6398,7 @@ export class HyperLiquidProvider implements PerpsProvider { ); } - const result = createErrorResult(mappedError, { success: false }); - const mappedErrorCode = Object.values(PERPS_ERROR_CODES).includes( - mappedError.message as PerpsErrorCode, - ) - ? (mappedError.message as PerpsErrorCode) - : undefined; - - return mappedErrorCode ? { ...result, errorCode: mappedErrorCode } : result; + return createErrorResult(mappedError, { success: false }); } /** @@ -6546,7 +6543,6 @@ export class HyperLiquidProvider implements PerpsProvider { szDecimals: assetInfo.szDecimals, leverage: params.leverage, reduceOnly: params.reduceOnly, - isFullClose: params.reduceOnly === true && params.isFullClose === true, }); const { orderPrice, formattedSize, formattedPrice } = diff --git a/packages/perps-controller/src/providers/LighterProvider.ts b/packages/perps-controller/src/providers/LighterProvider.ts index 8ed63ec9468..2728da6216b 100644 --- a/packages/perps-controller/src/providers/LighterProvider.ts +++ b/packages/perps-controller/src/providers/LighterProvider.ts @@ -131,7 +131,6 @@ import type { PerpsChaseOrderDispatch, } from '../types/index.js'; import { WebSocketConnectionState } from '../types/index.js'; -import { PerpsControllerError, createPriceMovedError } from '../errors.js'; import type { AccountState, TwapOrder, @@ -11237,18 +11236,8 @@ export class LighterProvider implements PerpsProvider { params.symbol, slippageFraction, params.priceAtCalculation, - market.supportedSizeDecimals, - params.reduceOnly === true && params.isFullClose === true, ); if (resolved.error !== null) { - if (resolved.error instanceof PerpsControllerError) { - return { - success: false, - error: resolved.error.message, - errorCode: resolved.error.errorCode, - errorDetails: resolved.error.errorDetails, - }; - } return { success: false, error: resolved.error }; } referencePrice = resolved.referencePrice; @@ -14009,11 +13998,9 @@ export class LighterProvider implements PerpsProvider { symbol: string, slippageFraction: number, priceAtCalculation?: number, - szDecimals = 2, - isFullClose = false, ): Promise< | { referencePrice: number; error: null } - | { referencePrice: null; error: string | PerpsControllerError } + | { referencePrice: null; error: string } > => { // Numeric intent validates fail-closed BEFORE any drift math. A // non-finite/non-positive snapshot makes the drift comparison NaN @@ -14056,18 +14043,12 @@ export class LighterProvider implements PerpsProvider { if ( priceAtCalculation !== undefined && priceAtCalculation > 0 && - !isFullClose && Math.abs(freshPrice - priceAtCalculation) / priceAtCalculation > slippageFraction ) { return { referencePrice: null, - error: createPriceMovedError({ - expectedPrice: priceAtCalculation, - currentPrice: freshPrice, - maxSlippageBps: slippageFraction * 10_000, - szDecimals, - }), + error: `Price moved beyond the ${(slippageFraction * 100).toFixed(2)}% slippage tolerance since sizing`, }; } return { referencePrice: freshPrice, error: null }; @@ -17089,14 +17070,12 @@ export class LighterProvider implements PerpsProvider { // fresh-price lookup can throw on REST failure. let resolved: | { referencePrice: number; error: null } - | { referencePrice: null; error: string | PerpsControllerError }; + | { referencePrice: null; error: string }; try { resolved = await this.#resolveMarketReferencePrice( params.symbol, slippageFraction, params.priceAtCalculation, - market.supportedSizeDecimals, - params.size === undefined && params.usdAmount === undefined, ); } catch (error) { return { @@ -17105,13 +17084,7 @@ export class LighterProvider implements PerpsProvider { }; } if (resolved.error !== null) { - return { - isValid: false, - error: - resolved.error instanceof Error - ? resolved.error.message - : resolved.error, - }; + return { isValid: false, error: resolved.error }; } referencePrice = resolved.referencePrice; executionPrice = deriveLighterExecutionPrice( @@ -17304,14 +17277,12 @@ export class LighterProvider implements PerpsProvider { // Same validator contract as validateOrder: REST failures resolve. let resolved: | { referencePrice: number; error: null } - | { referencePrice: null; error: string | PerpsControllerError }; + | { referencePrice: null; error: string }; try { resolved = await this.#resolveMarketReferencePrice( params.symbol, slippageFraction, params.priceAtCalculation, - market.supportedSizeDecimals, - params.size === undefined && params.usdAmount === undefined, ); } catch (error) { return { @@ -17321,13 +17292,7 @@ export class LighterProvider implements PerpsProvider { }; } if (resolved.error !== null) { - return { - isValid: false, - error: - resolved.error instanceof Error - ? resolved.error.message - : resolved.error, - }; + return { isValid: false, error: resolved.error }; } referencePrice = resolved.referencePrice; // Closing is the opposite side: a SHORT closes with a BUY, whose diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index 90cde8adbe1..6a6200e6447 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -8,8 +8,8 @@ import { import { isTPSLOrder } from '../constants/orderTypes.js'; import { PerpsMeasurementName } from '../constants/performanceMetrics.js'; import { PERPS_CONSTANTS } from '../constants/perpsConfig.js'; -import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; import { PerpsControllerError } from '../errors.js'; +import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; import { PerpsAnalyticsEvent, PerpsTraceNames, diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 97c8bff6fc2..f2182660c47 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -7,8 +7,10 @@ import type { } from '@metamask/utils'; import type { CandlePeriod, TimeDuration } from '../constants/chartConfig.js'; +import type { PerpsMaxSlippageSource } from '../constants/eventNames.js'; import type { CHASE_ORDER_STATUS } from '../constants/perpsConfig.js'; -import type { PerpsErrorDetails, PerpsErrorResultFields } from '../errors.js'; +import type { PerpsErrorDetails } from '../errors.js'; +import type { PerpsErrorCode } from '../perpsErrorCodes.js'; import type { LighterSignerBridge } from './lighter-types.js'; import type { CandleData, @@ -205,9 +207,8 @@ export type TrackingData = { // Slippage context for trade and close transaction analytics. maxSlippageBps?: number; - maxSlippageSource?: string; + maxSlippageSource?: PerpsMaxSlippageSource; estimatedSlippageBps?: number; - isFullClose?: boolean; // Pay with any token: true when user paid with a custom token (not Perps balance) tradeWithToken?: boolean; @@ -478,7 +479,7 @@ export type OrderResult = { orderId?: string; error?: string; /** Stable error classification for client-side translation. */ - errorCode?: PerpsErrorResultFields['errorCode']; + errorCode?: PerpsErrorCode; /** Optional structured data for the classified error. */ errorDetails?: PerpsErrorDetails; filledSize?: string; // Amount filled diff --git a/packages/perps-controller/src/utils/hyperLiquidValidation.ts b/packages/perps-controller/src/utils/hyperLiquidValidation.ts index 3605a05c9f0..e8b0d207a4e 100644 --- a/packages/perps-controller/src/utils/hyperLiquidValidation.ts +++ b/packages/perps-controller/src/utils/hyperLiquidValidation.ts @@ -12,12 +12,15 @@ import { HYPERLIQUID_ORDER_LIMITS, HYPERLIQUID_TWAP_LIMITS, } from '../constants/perpsConfig.js'; -import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; +import { PerpsControllerError } from '../errors.js'; +import type { PerpsErrorResultFields } from '../errors.js'; +import { isPerpsErrorCode, PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; import type { GetSupportedPathsParams, PerpsDebugLogger, } from '../types/index.js'; import type { OrderType, TpslLinkage } from '../types/perps-types.js'; +import { ensureError } from './errorUtils.js'; import { getTriggerExecution, isLimitExecutionOrderType, @@ -25,10 +28,6 @@ import { isTriggerOrderType, SCALE_ORDER_COUNT, } from './orderTypes.js'; -import { - PerpsControllerError, - type PerpsErrorResultFields, -} from '../errors.js'; /** * Optional debug logger for validation functions. @@ -51,24 +50,22 @@ export type ValidationDebugLogger = PerpsDebugLogger | undefined; export function createErrorResult< TValue extends { success: boolean; error?: string }, >(error: unknown, defaultResponse: TValue): TValue & PerpsErrorResultFields { - const errorFields = - error instanceof PerpsControllerError - ? { - errorCode: error.errorCode, - errorDetails: error.errorDetails, - } - : error instanceof Error && - Object.values(PERPS_ERROR_CODES).includes( - error.message as (typeof PERPS_ERROR_CODES)[keyof typeof PERPS_ERROR_CODES], - ) - ? { errorCode: error.message as PerpsErrorResultFields['errorCode'] } - : {}; + const normalizedError = ensureError(error, 'createErrorResult'); + let errorFields: PerpsErrorResultFields = {}; + + if (error instanceof PerpsControllerError) { + errorFields = { + errorCode: error.errorCode, + errorDetails: error.errorDetails, + }; + } else if (isPerpsErrorCode(normalizedError.message)) { + errorFields = { errorCode: normalizedError.message }; + } return { ...defaultResponse, success: false, - error: - error instanceof Error ? error.message : PERPS_ERROR_CODES.UNKNOWN_ERROR, + error: normalizedError.message, ...errorFields, }; } diff --git a/packages/perps-controller/src/utils/orderCalculations.ts b/packages/perps-controller/src/utils/orderCalculations.ts index 7940e5235c8..395a5a142fb 100644 --- a/packages/perps-controller/src/utils/orderCalculations.ts +++ b/packages/perps-controller/src/utils/orderCalculations.ts @@ -6,6 +6,7 @@ import { MAX_ORDER_MARGIN_BUFFER, ORDER_SLIPPAGE_CONFIG, } from '../constants/perpsConfig.js'; +import { createPriceMovedError } from '../errors.js'; import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; import type { SDKOrderParams } from '../types/hyperliquid-types.js'; import type { PerpsDebugLogger } from '../types/index.js'; @@ -14,7 +15,6 @@ import { formatHyperLiquidPrice, formatHyperLiquidSize, } from './hyperLiquidAdapter.js'; -import { createPriceMovedError } from '../errors.js'; import { getTriggerDirection, isLimitExecutionOrderType, @@ -73,9 +73,6 @@ export type CalculateFinalPositionSizeParams = { // rejects a reduce-only order whose size exceeds the live position with // "Reduce only order would increase position". reduceOnly?: boolean; - // Full market closes use the live position size and retain venue-side - // slippage protection, so they do not need the local snapshot guard. - isFullClose?: boolean; debugLogger?: OrderCalculationsDebugLogger; }; @@ -616,17 +613,14 @@ export function calculateFinalPositionSize( szDecimals, leverage, reduceOnly, - isFullClose, debugLogger, } = params; let finalPositionSize: number; - // USD-derived sizing needs a fresh snapshot to avoid submitting a size that - // no longer represents the user's requested USD amount. A full close already - // submits the live position size, so the venue-side slippage-capped limit is - // the relevant protection and the local snapshot guard is redundant. - if (priceAtCalculation && !isFullClose) { + // Only USD-derived sizing needs a snapshot guard. Exact-size orders retain + // venue-side slippage protection without depending on a calculation price. + if (priceAtCalculation && usdAmount) { const priceDeltaBps = Math.abs( ((currentPrice - priceAtCalculation) / priceAtCalculation) * 10000, ); @@ -637,8 +631,16 @@ export function calculateFinalPositionSize( throw createPriceMovedError({ expectedPrice: priceAtCalculation, currentPrice, + formattedExpectedPrice: formatHyperLiquidPrice({ + price: priceAtCalculation, + szDecimals, + }), + formattedCurrentPrice: formatHyperLiquidPrice({ + price: currentPrice, + szDecimals, + }), + priceDeltaBps, maxSlippageBps: maxSlippageBpsValue, - szDecimals, }); } diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts index 7c43d03254b..6b76df6d0ec 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts @@ -68,6 +68,7 @@ jest.mock('../../../src/utils/standaloneInfoClient', () => ({ })); jest.mock('../../../src/utils/hyperLiquidValidation', () => ({ + ...jest.requireActual('../../../src/utils/hyperLiquidValidation'), validateOrderParams: jest.fn(), validateWithdrawalParams: jest.fn(), validateDepositParams: jest.fn(), @@ -84,11 +85,6 @@ jest.mock('../../../src/utils/hyperLiquidValidation', () => ({ chainId: 'eip155:42161', contractAddress: '0x1234567890123456789012345678901234567890', }), - createErrorResult: jest.fn((error, defaultResponse) => ({ - ...defaultResponse, - success: false, - error: error instanceof Error ? error.message : String(error), - })), })); // Mock adapter functions @@ -522,13 +518,6 @@ describe('HyperLiquidProvider', () => { chainId: 'eip155:42161', contractAddress: '0x1234567890123456789012345678901234567890', }); - hyperLiquidValidation.createErrorResult.mockImplementation( - (error: unknown, defaultResponse: Record) => ({ - ...defaultResponse, - success: false, - error: error instanceof Error ? error.message : String(error), - }), - ); const hyperLiquidAdapter = jest.requireMock( '../../../src/utils/hyperLiquidAdapter', ); @@ -2451,7 +2440,11 @@ describe('HyperLiquidProvider', () => { }); expect(result.success).toBe(true); - expect(getSubmittedOrder()).toMatchObject({ s: '0.1', r: true }); + expect(getSubmittedOrder()).toMatchObject({ + p: '38800', + s: '0.1', + r: true, + }); }); it('submits the exact live size for a full close inside maxSlippageBps', async () => { @@ -2809,6 +2802,7 @@ describe('HyperLiquidProvider', () => { expect(result).toStrictEqual({ success: false, error: PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE, + errorCode: PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE, }); expect( mockClientService.getExchangeClient().order, @@ -2934,6 +2928,7 @@ describe('HyperLiquidProvider', () => { expect(result).toStrictEqual({ success: false, error: PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE, + errorCode: PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE, }); expect(mockOrder).not.toHaveBeenCalled(); }); @@ -3163,6 +3158,7 @@ describe('HyperLiquidProvider', () => { expect(result).toStrictEqual({ success: false, error: PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE, + errorCode: PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE, }); expect( mockClientService.getExchangeClient().order, diff --git a/packages/perps-controller/tests/src/providers/LighterProvider.test.ts b/packages/perps-controller/tests/src/providers/LighterProvider.test.ts index 6fb7c94bdd5..17222b8bcf7 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.test.ts @@ -24968,45 +24968,10 @@ describe('LighterProvider', () => { }; const result = await provider.validateClosePosition(request); expect(result.isValid).toBe(false); - expect(result.error).toContain('Price moved too much'); + expect(result.error).toContain('slippage tolerance since sizing'); const execution = await provider.closePosition(request); expect(execution.success).toBe(false); - expect(execution.error).toContain('Price moved too much'); - expect(execution.errorCode).toBe('PRICE_MOVED'); - expect(execution.errorDetails).toMatchObject({ - code: 'PRICE_MOVED', - maxSlippageBps: 500, - }); - }); - - it('allows a full market close when the caller price snapshot is stale', async () => { - const { provider, clientInstance, calls } = buildProvider(); - clientInstance.getAccountByIndex.mockResolvedValue({ - code: 200, - accounts: [ - { - ...ACCOUNT, - positions: [{ ...ACCOUNT.positions[0], position: '0.001' }], - }, - ], - }); - - const request = { - symbol: 'BTC', - orderType: 'market' as const, - currentPrice: 90_000, - priceAtCalculation: 90_000, - }; - - const validation = await provider.validateClosePosition(request); - expect(validation.isValid).toBe(true); - - const result = await provider.closePosition(request); - - expect(result.success).toBe(true); - expect(calls.some((call) => call.function === '_signCreateOrder')).toBe( - true, - ); + expect(execution.error).toContain('slippage tolerance since sizing'); }); it("rejects an 'Infinity' limit price in validators and placement alike", async () => { @@ -26010,7 +25975,7 @@ describe('LighterProvider', () => { priceAtCalculation: 90000, }); expect(result.success).toBe(false); - expect(result.error).toContain('Price moved too much'); + expect(result.error).toContain('slippage tolerance'); }); }); diff --git a/packages/perps-controller/tests/src/utils/orderCalculations.test.ts b/packages/perps-controller/tests/src/utils/orderCalculations.test.ts index af3e2e8f60f..c41fbf11541 100644 --- a/packages/perps-controller/tests/src/utils/orderCalculations.test.ts +++ b/packages/perps-controller/tests/src/utils/orderCalculations.test.ts @@ -1,5 +1,5 @@ -import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; import { PerpsControllerError } from '../../../src/errors.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; import { calculateFinalPositionSize, floorToSizeDecimals, @@ -512,7 +512,7 @@ describe('calculateFinalPositionSize', () => { }); it('throws when the price moved beyond the allowed slippage', () => { - try { + const calculate = (): ReturnType => calculateFinalPositionSize({ usdAmount: '5000', currentPrice: 45000, @@ -520,10 +520,10 @@ describe('calculateFinalPositionSize', () => { maxSlippageBps: 300, szDecimals: 3, }); - throw new Error('Expected price movement to be rejected'); - } catch (error) { - expect(error).toBeInstanceOf(PerpsControllerError); - expect(error).toMatchObject({ + + expect(calculate).toThrow(PerpsControllerError); + expect(calculate).toThrow( + expect.objectContaining({ errorCode: PERPS_ERROR_CODES.PRICE_MOVED, errorDetails: { code: PERPS_ERROR_CODES.PRICE_MOVED, @@ -531,13 +531,12 @@ describe('calculateFinalPositionSize', () => { maxSlippageBps: 300, expectedPrice: 50000, currentPrice: 45000, - szDecimals: 3, }, - }); - } + }), + ); }); - it('does not reject a full reduce-only close when the snapshot is stale', () => { + it('does not reject an exact-size close when the snapshot is stale', () => { const result = calculateFinalPositionSize({ size: '0.123', currentPrice: 45000, @@ -545,11 +544,22 @@ describe('calculateFinalPositionSize', () => { maxSlippageBps: 300, szDecimals: 3, reduceOnly: true, - isFullClose: true, }); expect(result.finalPositionSize).toBeCloseTo(0.123, 10); }); + + it('formats sub-cent price movement without collapsing prices to zero', () => { + expect(() => + calculateFinalPositionSize({ + usdAmount: '100', + currentPrice: 0.021, + priceAtCalculation: 0.017, + maxSlippageBps: 300, + szDecimals: 0, + }), + ).toThrow('Expected: 0.017, Current: 0.021'); + }); }); describe('legacy size path', () => { From 4792008c1a00ec9c053b50bbe2ee33fc12f2c1fb Mon Sep 17 00:00:00 2001 From: geositta Date: Wed, 23 Sep 2026 16:21:08 -0500 Subject: [PATCH 4/9] fix(perps): rm slippage param --- .../perps-controller/tests/src/services/TradingService.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index 3ea8beb5822..1758693dfba 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -955,7 +955,6 @@ describe('TradingService', () => { maxSlippageBps: 300, expectedPrice: 50_000, currentPrice: 51_680, - szDecimals: 3, }, }; From e20b9e619325efd45f708bb5503d4bd5573a399a Mon Sep 17 00:00:00 2001 From: geositta Date: Wed, 7 Oct 2026 21:27:31 -0500 Subject: [PATCH 5/9] chore: linting --- .../tests/src/utils/orderCalculations.test.ts | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/packages/perps-controller/tests/src/utils/orderCalculations.test.ts b/packages/perps-controller/tests/src/utils/orderCalculations.test.ts index c41fbf11541..1789872315a 100644 --- a/packages/perps-controller/tests/src/utils/orderCalculations.test.ts +++ b/packages/perps-controller/tests/src/utils/orderCalculations.test.ts @@ -521,19 +521,28 @@ describe('calculateFinalPositionSize', () => { szDecimals: 3, }); - expect(calculate).toThrow(PerpsControllerError); - expect(calculate).toThrow( - expect.objectContaining({ - errorCode: PERPS_ERROR_CODES.PRICE_MOVED, - errorDetails: { - code: PERPS_ERROR_CODES.PRICE_MOVED, - priceDeltaBps: 1000, - maxSlippageBps: 300, - expectedPrice: 50000, - currentPrice: 45000, - }, - }), - ); + let thrownError: unknown; + try { + calculate(); + } catch (error: unknown) { + thrownError = error; + } + + expect(thrownError).toBeInstanceOf(PerpsControllerError); + if (!(thrownError instanceof PerpsControllerError)) { + throw new Error( + 'Expected calculate() to throw a PerpsControllerError', + ); + } + + expect(thrownError.errorCode).toBe(PERPS_ERROR_CODES.PRICE_MOVED); + expect(thrownError.errorDetails).toStrictEqual({ + code: PERPS_ERROR_CODES.PRICE_MOVED, + priceDeltaBps: 1000, + maxSlippageBps: 300, + expectedPrice: 50000, + currentPrice: 45000, + }); }); it('does not reject an exact-size close when the snapshot is stale', () => { From d6510bdbfaeaef99e75b8690c90a109357ef2b1f Mon Sep 17 00:00:00 2001 From: geositta Date: Wed, 7 Oct 2026 22:00:15 -0500 Subject: [PATCH 6/9] chore: test updates --- .../perps-controller/src/utils/hyperLiquidValidation.ts | 8 ++++---- .../providers/HyperLiquidProvider.agent-rejection.test.ts | 3 +++ .../src/providers/HyperLiquidProvider.trading.test.ts | 3 --- .../tests/src/utils/orderCalculations.test.ts | 4 +--- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/perps-controller/src/utils/hyperLiquidValidation.ts b/packages/perps-controller/src/utils/hyperLiquidValidation.ts index e8b0d207a4e..94ea93a8d01 100644 --- a/packages/perps-controller/src/utils/hyperLiquidValidation.ts +++ b/packages/perps-controller/src/utils/hyperLiquidValidation.ts @@ -14,7 +14,7 @@ import { } from '../constants/perpsConfig.js'; import { PerpsControllerError } from '../errors.js'; import type { PerpsErrorResultFields } from '../errors.js'; -import { isPerpsErrorCode, PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; +import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; import type { GetSupportedPathsParams, PerpsDebugLogger, @@ -56,10 +56,10 @@ export function createErrorResult< if (error instanceof PerpsControllerError) { errorFields = { errorCode: error.errorCode, - errorDetails: error.errorDetails, + ...(error.errorDetails === undefined + ? {} + : { errorDetails: error.errorDetails }), }; - } else if (isPerpsErrorCode(normalizedError.message)) { - errorFields = { errorCode: normalizedError.message }; } return { diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-rejection.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-rejection.test.ts index 95b9e46bbee..8354a9a17b5 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-rejection.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-rejection.test.ts @@ -1040,6 +1040,7 @@ describe('HyperLiquidProvider with accountSigner: agent rejection', () => { expect(order).toStrictEqual({ success: false, error: PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, + errorCode: PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, }); expect(onAgentRejected).not.toHaveBeenCalled(); // The referral set up for the first order, the order, then the next @@ -1189,6 +1190,7 @@ describe('HyperLiquidProvider with accountSigner: agent rejection', () => { success: false, orderId: '123', error: PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, + errorCode: PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, }); expect(onAgentRejected).not.toHaveBeenCalled(); // Kept, so the next L1 action does not ask again. @@ -1388,6 +1390,7 @@ describe('HyperLiquidProvider with accountSigner: agent rejection', () => { expect(order).toStrictEqual({ success: false, error: PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, + errorCode: PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, }); expect(onAgentRejected).not.toHaveBeenCalled(); expect(loggerError).not.toHaveBeenCalled(); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts index 6b76df6d0ec..e5ba79426cf 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts @@ -2802,7 +2802,6 @@ describe('HyperLiquidProvider', () => { expect(result).toStrictEqual({ success: false, error: PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE, - errorCode: PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE, }); expect( mockClientService.getExchangeClient().order, @@ -2928,7 +2927,6 @@ describe('HyperLiquidProvider', () => { expect(result).toStrictEqual({ success: false, error: PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE, - errorCode: PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE, }); expect(mockOrder).not.toHaveBeenCalled(); }); @@ -3158,7 +3156,6 @@ describe('HyperLiquidProvider', () => { expect(result).toStrictEqual({ success: false, error: PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE, - errorCode: PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE, }); expect( mockClientService.getExchangeClient().order, diff --git a/packages/perps-controller/tests/src/utils/orderCalculations.test.ts b/packages/perps-controller/tests/src/utils/orderCalculations.test.ts index 1789872315a..0bc1a1c3206 100644 --- a/packages/perps-controller/tests/src/utils/orderCalculations.test.ts +++ b/packages/perps-controller/tests/src/utils/orderCalculations.test.ts @@ -530,9 +530,7 @@ describe('calculateFinalPositionSize', () => { expect(thrownError).toBeInstanceOf(PerpsControllerError); if (!(thrownError instanceof PerpsControllerError)) { - throw new Error( - 'Expected calculate() to throw a PerpsControllerError', - ); + throw new Error('Expected calculate() to throw a PerpsControllerError'); } expect(thrownError.errorCode).toBe(PERPS_ERROR_CODES.PRICE_MOVED); From ddfefc31891cf6223c85f806876f21a20915fe4a Mon Sep 17 00:00:00 2001 From: geositta Date: Thu, 8 Oct 2026 12:56:48 -0500 Subject: [PATCH 7/9] chore: update changelog --- packages/perps-controller/CHANGELOG.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 5012b2b53ca..8d1a2f3d8b8 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,11 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `PERPS_ERROR_CODES.PRICE_MOVED` and optional `errorCode` and `errorDetails` on `OrderResult`, so a failed placement can be localized without parsing `OrderResult.error` ([#10385](https://github.com/MetaMask/core/pull/10385)) + - Export `isPerpsErrorCode`, `PriceMovedErrorDetails`, and `PerpsErrorDetails`. `PriceMovedErrorDetails` carries unformatted `priceDeltaBps`, `maxSlippageBps`, `expectedPrice`, and `currentPrice` + - `createErrorResult` copies `errorCode` and `errorDetails` when the failure is a structured controller error. HyperLiquid mapped rejections, including an immediate-match rejection (`IOC_CANCEL`) and `EXCHANGE_ACCOUNT_NOT_FOUND`, now set `errorCode`; `OrderResult.error` stays the same message + - A USD-derived order whose price moved past `maxSlippageBps` (or the default market slippage) reports `PRICE_MOVED`. The message still starts with `Price moved too much:`; the expected and current prices in it use HyperLiquid price formatting +- Add `PERPS_EVENT_PROPERTY.PRICE_DELTA_BPS` (`price_delta_bps`) and export `PerpsMaxSlippageSource`. Trade and close analytics now include `failure_reason` from `errorCode` when one is present, `price_delta_bps` for a `PRICE_MOVED` rejection, and `max_slippage_pct`, `max_slippage_source`, and `estimated_slippage_pct` from the caller's tracking data. Raw `error_message` is still sent. Failed close events also include `close_type`, which previously appeared only on executed closes ([#10385](https://github.com/MetaMask/core/pull/10385)) + ### Changed - Bump `reselect` from `^5.1.1` to `^5.3.0` ([#10532](https://github.com/MetaMask/core/pull/10532)) - Bump `@metamask/abi-utils` from `^2.0.3` to `^2.0.4` ([#10715](https://github.com/MetaMask/core/pull/10715)) +### Fixed + +- Skip the calculation-time price guard for exact-size HyperLiquid orders, including a full close that submits the live position size. Those orders are no longer rejected locally when `priceAtCalculation` is stale; the venue's slippage-capped limit still applies. The guard still runs for USD-derived sizing ([#10385](https://github.com/MetaMask/core/pull/10385)) + ## [20.0.0] ### Added @@ -217,7 +229,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add an optional `chargesMetamaskBuilderFee` field to `FeeCalculationResult`, which reports whether a placement can carry a MetaMask builder fee at all. A `metamaskFeeRate` of `0` is otherwise ambiguous between a venue or order type that has no builder field and a fully waived fee. ([#10294](https://github.com/MetaMask/core/pull/10294)) - Add an optional `registerTradingAddress` hook to the injected `subscription` dependency, for registering the current HyperLiquid trading address (CAIP-10) against the subscription profile. The injected hook remains a fallback for clients that do not provide `SubscriptionController:registerAddress`. ([#10294](https://github.com/MetaMask/core/pull/10294)) - Add optional `fillId` field to the `OrderFill` type, an opaque execution identifier (built from HyperLiquid `coin`, `time` and `tid`, or Lighter `tradeId`) so clients can tell apart two executions of one order that share `orderId`, `timestamp`, `size` and `price` ([#10384](https://github.com/MetaMask/core/pull/10384)) -- Add structured Perps order error codes and price-movement details for client localization and analytics ([#10385](https://github.com/MetaMask/core/pull/10385)) ### Changed @@ -240,7 +251,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Allow exact-size Hyperliquid market closes to use the live venue slippage cap without being blocked by a stale USD-sizing snapshot ([#10385](https://github.com/MetaMask/core/pull/10385)) - Report `source: 'subscription'` only when the waiver survives the venue's fee quantization. The builder fee is submitted in integer tenths of a basis point, so a blend a fraction below the default rounds to the same charge; such an order was labelled as subscription-sourced with a `0` bips discount while paying full price, disagreeing with the client order ID, which already withheld its marking in that case. ([#10294](https://github.com/MetaMask/core/pull/10294)) - Leave a fee tie to the source that already holds it, rather than claiming it for the subscription waiver. A blend that merely matched the winning rate — a 5-bip blend against a 5-bip VIP discount, or a full waiver against a rewards rate already at `0` — was reported as `source: 'subscription'`, which marks the client order ID and spends the remaining allowance without making the order any cheaper. The waiver now has to be strictly cheaper at venue precision to win. ([#10294](https://github.com/MetaMask/core/pull/10294)) - Reprice a quote whose `metamaskFeeRate` reads `0` because a concurrent fully waived submit left that rate in provider state. An ordinary preview racing such a submit inherited the other order's waiver and quoted no MetaMask fee; the provider's own builder-fee policy now distinguishes that from a placement that genuinely carries no fee. Repricing a `0` rate is opt-in: a provider that does not report `chargesMetamaskBuilderFee` keeps its own rate, so a `PerpsProvider` implementation written before that field existed cannot gain a MetaMask fee it does not charge. ([#10294](https://github.com/MetaMask/core/pull/10294)) From a8644e3d059f7e3c6ce549da5b3aafaf354d1f54 Mon Sep 17 00:00:00 2001 From: geositta Date: Fri, 9 Oct 2026 10:32:29 -0500 Subject: [PATCH 8/9] fix(perps-controller): classify thrown partial closes from requested size A thrown close has no order result, so the analytics fallback reported close_type full even when percentage_closed reflected a partial size. Co-authored-by: Cursor --- packages/perps-controller/CHANGELOG.md | 2 +- .../src/services/TradingService.ts | 27 ++++++------------ .../tests/src/services/TradingService.test.ts | 28 +++++++++++++++++++ 3 files changed, 37 insertions(+), 20 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 8d1a2f3d8b8..0e2e9c42d02 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Export `isPerpsErrorCode`, `PriceMovedErrorDetails`, and `PerpsErrorDetails`. `PriceMovedErrorDetails` carries unformatted `priceDeltaBps`, `maxSlippageBps`, `expectedPrice`, and `currentPrice` - `createErrorResult` copies `errorCode` and `errorDetails` when the failure is a structured controller error. HyperLiquid mapped rejections, including an immediate-match rejection (`IOC_CANCEL`) and `EXCHANGE_ACCOUNT_NOT_FOUND`, now set `errorCode`; `OrderResult.error` stays the same message - A USD-derived order whose price moved past `maxSlippageBps` (or the default market slippage) reports `PRICE_MOVED`. The message still starts with `Price moved too much:`; the expected and current prices in it use HyperLiquid price formatting -- Add `PERPS_EVENT_PROPERTY.PRICE_DELTA_BPS` (`price_delta_bps`) and export `PerpsMaxSlippageSource`. Trade and close analytics now include `failure_reason` from `errorCode` when one is present, `price_delta_bps` for a `PRICE_MOVED` rejection, and `max_slippage_pct`, `max_slippage_source`, and `estimated_slippage_pct` from the caller's tracking data. Raw `error_message` is still sent. Failed close events also include `close_type`, which previously appeared only on executed closes ([#10385](https://github.com/MetaMask/core/pull/10385)) +- Add `PERPS_EVENT_PROPERTY.PRICE_DELTA_BPS` (`price_delta_bps`) and export `PerpsMaxSlippageSource`. Trade and close analytics now include `failure_reason` from `errorCode` when one is present, `price_delta_bps` for a `PRICE_MOVED` rejection, and `max_slippage_pct`, `max_slippage_source`, and `estimated_slippage_pct` from the caller's tracking data. Raw `error_message` is still sent. Failed close events also include `close_type`, derived from the requested size, including when the provider throws. That property previously appeared only on executed closes ([#10385](https://github.com/MetaMask/core/pull/10385)) ### Changed diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index 6a6200e6447..8e971b50ba5 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -1167,25 +1167,14 @@ export class TradingService { return; } - const metrics = result - ? this.#calculateCloseMetrics(position, params, result) - : { - direction: - parseFloat(position.size) > 0 - ? PERPS_EVENT_VALUE.DIRECTION.LONG - : PERPS_EVENT_VALUE.DIRECTION.SHORT, - closePercentage: params.size - ? (parseFloat(params.size) / Math.abs(parseFloat(position.size))) * - 100 - : 100, - closeType: PERPS_EVENT_VALUE.CLOSE_TYPE.FULL, - orderType: params.orderType ?? PERPS_EVENT_VALUE.ORDER_TYPE.MARKET, - requestedSize: params.size - ? parseFloat(params.size) - : Math.abs(parseFloat(position.size)), - filledSize: 0, - isPartiallyFilled: false, - }; + // A thrown close has no OrderResult. Classify it from the requested size + // the same way a returned failure is classified, so a partial request is + // not reported as a full close. + const metrics = this.#calculateCloseMetrics( + position, + params, + result ?? { success: false }, + ); // Track partially filled event if applicable if (result?.success && metrics.isPartiallyFilled) { diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index 1758693dfba..445ca339695 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -2357,6 +2357,34 @@ describe('TradingService', () => { ); }); + it('classifies a thrown partial close from the requested size', async () => { + // A thrown close has no OrderResult, so close_type must come from the + // requested size. Closing 0.25 of a 1-unit position is 25%, not a full close. + mockGetPositions.mockResolvedValue([{ ...mockPosition, size: '1' }]); + mockProvider.closePosition.mockRejectedValue(new Error('route removed')); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await expect( + tradingService.closePosition({ + provider: mockProvider, + params: { symbol: 'BTC', size: '0.25' }, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }), + ).rejects.toThrow('route removed'); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.PositionCloseTransaction, + expect.objectContaining({ + status: 'failed', + close_type: 'partial', + percentage_closed: 25, + }), + ); + }); + it('logs error when provider returns a failure result without throwing', async () => { const params: ClosePositionParams = { symbol: 'BTC' }; const mockFailureResult: OrderResult = { From d5a350f968dcbe7f2453ea2d2242f1cda584bb36 Mon Sep 17 00:00:00 2001 From: geositta Date: Fri, 9 Oct 2026 19:27:34 -0500 Subject: [PATCH 9/9] fix(perps): preserve exact-size retry semantics Co-authored-by: Cursor --- .../perps-controller/src/providers/HyperLiquidProvider.ts | 8 ++++++++ .../src/providers/HyperLiquidProvider.trading.test.ts | 7 ++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 074fc30534d..e52ad6fee74 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -6697,6 +6697,14 @@ export class HyperLiquidProvider implements PerpsProvider { { ...params, usdAmount: adjustedUsdAmount, + // This USD amount was derived from the effective price solely to + // grow an exact-size order past the venue minimum. Preserve the + // original exact-size semantics instead of applying its unrelated + // calculation snapshot to the retry. Genuine USD-sized orders keep + // their snapshot guard. + priceAtCalculation: params.usdAmount + ? params.priceAtCalculation + : undefined, }, 1, // Retry count = 1, prevents further retries ); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts index e5ba79426cf..1cee44de542 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts @@ -1155,7 +1155,7 @@ describe('HyperLiquidProvider', () => { ); }); - it('retries size-based order with currentPrice when rejected for $10 minimum', async () => { + it('retries a size-based order without applying its stale calculation snapshot to the synthesized USD amount', async () => { // Create provider with PUMP in the asset mapping provider = createTestProvider({ initialAssetMapping: [ @@ -1217,6 +1217,11 @@ describe('HyperLiquidProvider', () => { size: '2553', orderType: 'market', currentPrice: 0.003918, + // Exact-size orders do not derive their size from this snapshot. The + // minimum-order retry synthesizes a USD amount from currentPrice, so it + // must not turn this stale snapshot into a PRICE_MOVED rejection. + priceAtCalculation: 0.003, + maxSlippageBps: 300, }; mockClientService.getExchangeClient = jest.fn().mockReturnValue({