diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index b8d36c8df31..be937fac11a 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,6 +7,14 @@ 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`, 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 - Bump `reselect` from `^5.1.1` to `^5.3.0` ([#10532](https://github.com/MetaMask/core/pull/10532)) @@ -19,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Log deposit transaction failures that reject after confirmation, for both deposit-only and deposit-and-order, with `financial_operations` / `financial_deposit` tags, and leave user cancellation unlogged ([#10681](https://github.com/MetaMask/core/pull/10681)) - Log flip results that resolve `success: false` with `position_management` / `flip_position` tags, and leave `KEYRING_LOCKED` and `EXCHANGE_ACCOUNT_NOT_FOUND` unlogged ([#10681](https://github.com/MetaMask/core/pull/10681)) - Add optional `OrderParams.deferProviderErrorReport`. A flip sets it so HyperLiquid and Lighter do not log `place_order` for that failure, and `flipPosition` logs it once as `position_management` / `flip_position`. Delegated close failures likewise log once as `position_management` / `close_position`. Expected signer, account, session, and selected local-validation outcomes stay unlogged ([#10681](https://github.com/MetaMask/core/pull/10681)) +- 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] diff --git a/packages/perps-controller/src/constants/eventNames.ts b/packages/perps-controller/src/constants/eventNames.ts index 1c9cac54d82..bad1972992d 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', @@ -692,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 new file mode 100644 index 00000000000..2eb77cb5a9e --- /dev/null +++ b/packages/perps-controller/src/errors.ts @@ -0,0 +1,87 @@ +import { PERPS_ERROR_CODES } from './perpsErrorCodes.js'; +import 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; +}; + +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 a normalized local price movement error. + * + * @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; +}): PerpsControllerError { + 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: ${formattedExpectedPrice}, Current: ${formattedCurrentPrice}`, + { + code: PERPS_ERROR_CODES.PRICE_MOVED, + priceDeltaBps, + maxSlippageBps, + expectedPrice, + currentPrice, + }, + ); +} diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 73e07901e53..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,8 +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 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 b93d8b91e96..076f407c188 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', @@ -138,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 5ade0442ce2..e35d3f1e001 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 { @@ -4870,12 +4871,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); } } @@ -6754,6 +6758,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 shouldReportFailure, diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index f274563609b..acd55dc22bb 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -8,6 +8,7 @@ import { import { isTPSLOrder } from '../constants/orderTypes.js'; import { PerpsMeasurementName } from '../constants/performanceMetrics.js'; import { PERPS_CONSTANTS } from '../constants/perpsConfig.js'; +import { PerpsControllerError } from '../errors.js'; import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; import { PerpsAnalyticsEvent, @@ -210,6 +211,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. * @@ -287,6 +338,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, @@ -714,6 +766,7 @@ export class TradingService { [PERPS_EVENT_PROPERTY.LEVERAGE]: parseFloat( String(params.leverage ?? 1), ), + ...this.#buildSlippageProperties(params.trackingData), ...this.#buildAttributionProperties(params.trackingData), }); @@ -980,7 +1033,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 @@ -1000,6 +1053,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), ), @@ -1052,6 +1106,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, @@ -1073,7 +1132,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, }), @@ -1083,7 +1141,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, }), @@ -1140,6 +1201,7 @@ export class TradingService { ...(errorMessage && { [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: errorMessage, }), + ...this.#buildSlippageProperties(params.trackingData, result, error), ...this.#buildAttributionProperties(params.trackingData), ...bulkActionProps, }, @@ -1147,25 +1209,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) { @@ -1205,7 +1256,7 @@ export class TradingService { metrics, result, status, - errorMessage, + error ?? errorMessage, ); this.#deps.metrics.trackPerpsEvent( @@ -2162,6 +2213,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 7620444e127..ee5a492eb30 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -7,7 +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 } from '../errors.js'; +import type { PerpsErrorCode } from '../perpsErrorCodes.js'; import type { LighterSignerBridge } from './lighter-types.js'; import type { CandleData, @@ -202,6 +205,11 @@ export type TrackingData = { // close events when present; omitted entirely when unavailable. hlFeeRate?: number; + // Slippage context for trade and close transaction analytics. + maxSlippageBps?: number; + maxSlippageSource?: PerpsMaxSlippageSource; + estimatedSlippageBps?: number; + // 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 @@ -476,6 +484,10 @@ export type OrderResult = { */ orderId?: string; error?: string; + /** Stable error classification for client-side translation. */ + errorCode?: PerpsErrorCode; + /** 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..94ea93a8d01 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 { PerpsControllerError } from '../errors.js'; +import type { PerpsErrorResultFields } from '../errors.js'; import { 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, @@ -46,12 +49,24 @@ 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 normalizedError = ensureError(error, 'createErrorResult'); + let errorFields: PerpsErrorResultFields = {}; + + if (error instanceof PerpsControllerError) { + errorFields = { + errorCode: error.errorCode, + ...(error.errorDetails === undefined + ? {} + : { errorDetails: error.errorDetails }), + }; + } + 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 64c16bf7177..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'; @@ -617,11 +618,9 @@ export function calculateFinalPositionSize( 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) { + // 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, ); @@ -629,10 +628,20 @@ 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, + formattedExpectedPrice: formatHyperLiquidPrice({ + price: priceAtCalculation, + szDecimals, + }), + formattedCurrentPrice: formatHyperLiquidPrice({ + price: currentPrice, + szDecimals, + }), + priceDeltaBps, + maxSlippageBps: maxSlippageBpsValue, + }); } debugLogger?.log('Price validation passed:', { 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 59040138f59..fc20064fc81 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', ); @@ -728,6 +717,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', @@ -1147,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: [ @@ -1209,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({ @@ -2416,10 +2429,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 +2444,12 @@ 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({ + p: '38800', + 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/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index 6fe49527960..5f4a49fa23b 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -931,6 +931,58 @@ 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, + }, + }; + + 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', @@ -2341,6 +2393,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 = { diff --git a/packages/perps-controller/tests/src/utils/orderCalculations.test.ts b/packages/perps-controller/tests/src/utils/orderCalculations.test.ts index 2adceda199c..0bc1a1c3206 100644 --- a/packages/perps-controller/tests/src/utils/orderCalculations.test.ts +++ b/packages/perps-controller/tests/src/utils/orderCalculations.test.ts @@ -1,3 +1,4 @@ +import { PerpsControllerError } from '../../../src/errors.js'; import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; import { calculateFinalPositionSize, @@ -511,15 +512,60 @@ describe('calculateFinalPositionSize', () => { }); it('throws when the price moved beyond the allowed slippage', () => { - expect(() => + const calculate = (): ReturnType => calculateFinalPositionSize({ usdAmount: '5000', currentPrice: 45000, priceAtCalculation: 50000, maxSlippageBps: 300, szDecimals: 3, + }); + + 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', () => { + const result = calculateFinalPositionSize({ + size: '0.123', + currentPrice: 45000, + priceAtCalculation: 50000, + maxSlippageBps: 300, + szDecimals: 3, + reduceOnly: 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('Price moved too much'); + ).toThrow('Expected: 0.017, Current: 0.021'); }); });