Skip to content
9 changes: 9 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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]

Expand Down
4 changes: 4 additions & 0 deletions packages/perps-controller/src/constants/eventNames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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];
87 changes: 87 additions & 0 deletions packages/perps-controller/src/errors.ts
Original file line number Diff line number Diff line change
@@ -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,
},
);
}
4 changes: 3 additions & 1 deletion packages/perps-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
14 changes: 14 additions & 0 deletions packages/perps-controller/src/perpsErrorCodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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)
);
}
16 changes: 14 additions & 2 deletions packages/perps-controller/src/providers/HyperLiquidProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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,
Expand Down
98 changes: 75 additions & 23 deletions packages/perps-controller/src/services/TradingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -714,6 +766,7 @@ export class TradingService {
[PERPS_EVENT_PROPERTY.LEVERAGE]: parseFloat(
String(params.leverage ?? 1),
),
...this.#buildSlippageProperties(params.trackingData),
...this.#buildAttributionProperties(params.trackingData),
});

Expand Down Expand Up @@ -980,7 +1033,7 @@ export class TradingService {
},
result: OrderResult | null,
status: string,
error?: string,
error?: Error | string,
): Record<string, unknown> {
// Effective leverage = positionUSD / marginUSD, rounded to 1 decimal place.
// Computed from the live position rather than the configured leverage so it's
Expand All @@ -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,
Comment thread
geositta marked this conversation as resolved.
[PERPS_EVENT_PROPERTY.OPEN_POSITION_SIZE]: Math.abs(
parseFloat(position.size),
),
Expand Down Expand Up @@ -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,
Expand All @@ -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,
}),
Expand All @@ -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,
}),
Expand Down Expand Up @@ -1140,32 +1201,22 @@ export class TradingService {
...(errorMessage && {
[PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: errorMessage,
}),
...this.#buildSlippageProperties(params.trackingData, result, error),
...this.#buildAttributionProperties(params.trackingData),
...bulkActionProps,
},
);
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) {
Expand Down Expand Up @@ -1205,7 +1256,7 @@ export class TradingService {
metrics,
result,
status,
errorMessage,
error ?? errorMessage,
);

this.#deps.metrics.trackPerpsEvent(
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading