Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **BREAKING:** Add `EXCHANGE_ACCOUNT_NOT_FOUND` to `PERPS_ERROR_CODES`, returned by `HyperLiquidProvider.placeOrder` when the wallet has no HyperLiquid account yet (TAT-3343) ([#9709](https://github.com/MetaMask/core/pull/9709))
- This widens the exported `PerpsErrorCode` union, so consumers that key an exhaustive `Record<PerpsErrorCode, …>` stop compiling until they add an entry for the new code. Both first-party clients do: Mobile's `app/components/UI/Perps/utils/translatePerpsError.ts` and Extension's `ui/components/app/perps/utils/translate-perps-error.ts`.
- To migrate: add a translation entry for `EXCHANGE_ACCOUNT_NOT_FOUND`. It signals that the wallet has no HyperLiquid account yet, so the message should direct the user to fund the account before trading.
- **BREAKING:** Add `EXCHANGE_MULTI_SIG_REQUIRED` and `EXCHANGE_INVALID_NONCE` to `PERPS_ERROR_CODES` for HyperLiquid exchange rejections that previously surfaced as raw `"multi-sig required"` / `"invalid nonce"` strings (TAT-3633) ([#9750](https://github.com/MetaMask/core/pull/9750))
- Like `EXCHANGE_ACCOUNT_NOT_FOUND` above, this widens the exported `PerpsErrorCode` union, so consumers that key an exhaustive `Record<PerpsErrorCode, …>` stop compiling until they add entries for both new codes — including Mobile's `app/components/UI/Perps/utils/translatePerpsError.ts` and Extension's `ui/components/app/perps/utils/translate-perps-error.ts`.
- To migrate: add translation entries for both codes before bumping. `EXCHANGE_MULTI_SIG_REQUIRED` means the account requires a multi-sig wrapper for exchange writes; `EXCHANGE_INVALID_NONCE` means the action nonce was stale or reused and the request should be retried.

### Changed

Expand All @@ -51,6 +54,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Hydrate trading readiness before coin validation in `HyperLiquidProvider.cancelOrder`, so a cold start with an empty prefetch asset map self-heals instead of returning `ORDER_UNKNOWN_COIN` for valid markets (TAT-3633) ([#9750](https://github.com/MetaMask/core/pull/9750))
- Map HyperLiquid `"multi-sig required"` and `"invalid nonce"` exchange rejections to `EXCHANGE_MULTI_SIG_REQUIRED` and `EXCHANGE_INVALID_NONCE` across `placeOrder`, `cancelOrder`, and `cancelOrders`, attaching cached abstraction mode to account-mode error log context (TAT-3633) ([#9750](https://github.com/MetaMask/core/pull/9750))
- `cancelOrder` now reads the per-status error HyperLiquid returns when it rejects a cancel without throwing, so `CancelOrderResult.error` carries the mapped code instead of the generic `'Order cancellation failed'` string. That generic string is still returned when the status entry carries no error text.
- `cancelOrders` maps both thrown batch failures and per-status rejections the same way, so `CancelOrdersResult.results[].error` carries a `PerpsErrorCode` rather than the raw exchange message for recognized rejections.
- **BREAKING:** `OrderParams.timeInForce` now controls the HyperLiquid time-in-force for plain limit orders instead of being ignored; `GTC`, `IOC`, and post-only `ALO` map to their corresponding SDK values ([#9674](https://github.com/MetaMask/core/pull/9674))
- Order shapes that cannot carry a time in force — market orders and trigger placements, whose execution is decided when they fire — now reject it with `ORDER_TIME_IN_FORCE_NOT_SUPPORTED`, where previously the field was accepted and ignored for every order type. Callers passing `timeInForce` on anything other than a `limit` order must drop it.
- The rejection happens in `validateOrderParams`, before `placeOrder` changes leverage on-chain or moves margin to a HIP-3 DEX, so a rejected order leaves no side effects behind.
Expand Down
4 changes: 4 additions & 0 deletions packages/perps-controller/src/perpsErrorCodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ export const PERPS_ERROR_CODES = {
// server-side on the first USDC credit). Actionable: the user must fund the
// account before any order can be placed.
EXCHANGE_ACCOUNT_NOT_FOUND: 'EXCHANGE_ACCOUNT_NOT_FOUND',
// HyperLiquid exchange rejects agent-signed writes for multi-sig accounts
// without a multi-sig wrapper, or when the action nonce is stale/reused.
EXCHANGE_MULTI_SIG_REQUIRED: 'EXCHANGE_MULTI_SIG_REQUIRED',
EXCHANGE_INVALID_NONCE: 'EXCHANGE_INVALID_NONCE',
// Transfer/swap errors
TRANSFER_FAILED: 'TRANSFER_FAILED',
SWAP_FAILED: 'SWAP_FAILED',
Expand Down
119 changes: 91 additions & 28 deletions packages/perps-controller/src/providers/HyperLiquidProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,8 @@ export class HyperLiquidProvider implements PerpsProvider {
'isolated position does not have sufficient margin available to decrease leverage':
PERPS_ERROR_CODES.ORDER_LEVERAGE_REDUCTION_FAILED,
'could not immediately match': PERPS_ERROR_CODES.IOC_CANCEL,
'multi-sig required': PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED,
'invalid nonce': PERPS_ERROR_CODES.EXCHANGE_INVALID_NONCE,
};

// Track whether clients have been initialized (lazy initialization)
Expand Down Expand Up @@ -2393,6 +2395,39 @@ export class HyperLiquidProvider implements PerpsProvider {
};
}

#isMappedAccountModeExchangeError(error: Error): boolean {
return (
error.message === PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED ||
error.message === PERPS_ERROR_CODES.EXCHANGE_INVALID_NONCE
);
}

async #getTradingErrorContext(
method: string,
error: Error,
extra?: Record<string, unknown>,
): Promise<{
tags?: Record<string, string | number>;
context?: { name: string; data: Record<string, unknown> };
extras?: Record<string, unknown>;
}> {
const contextExtra = { ...extra };
if (this.#isMappedAccountModeExchangeError(error)) {
try {
const userAddress =
await this.#walletService.getUserAddressWithDefault();
const abstractionMode =
this.#subscriptionService.getCachedAbstractionMode(userAddress);
if (abstractionMode) {
contextExtra[PERPS_EVENT_PROPERTY.ABSTRACTION_MODE] = abstractionMode;
}
} catch {
// Best-effort context enrichment only.
}
}
return this.#getErrorContext(method, contextExtra);
}

/**
* Get supported deposit routes with complete asset and routing information
*
Expand Down Expand Up @@ -3439,8 +3474,11 @@ export class HyperLiquidProvider implements PerpsProvider {
* @param params - The operation parameters.
* @returns The result of the operation.
*/
#handleOrderError(params: HandleOrderErrorParams): OrderResult {
async #handleOrderError(
params: HandleOrderErrorParams,
): Promise<OrderResult> {
const { error, symbol, orderType, isBuy } = params;
const mappedError = this.#mapError(error);

// A wallet with no Hyperliquid account is an expected pre-account state,
// not an app defect — same policy already applied to every other
Expand All @@ -3454,16 +3492,15 @@ export class HyperLiquidProvider implements PerpsProvider {
);
} else {
this.#deps.logger.error(
ensureError(error, 'HyperLiquidProvider.handleOrderError'),
this.#getErrorContext('placeOrder', {
mappedError,
await this.#getTradingErrorContext('placeOrder', mappedError, {
symbol,
orderType,
isBuy,
}),
);
}

const mappedError = this.#mapError(error);
return createErrorResult(mappedError, { success: false });
}

Expand Down Expand Up @@ -3711,7 +3748,7 @@ export class HyperLiquidProvider implements PerpsProvider {
adjustedUsdAmount = (estimatedUsd * 1.015).toFixed(2);
} else {
// No price information available - cannot retry
return this.#handleOrderError({
return await this.#handleOrderError({
error,
symbol: params.symbol,
orderType: params.orderType,
Expand All @@ -3737,7 +3774,7 @@ export class HyperLiquidProvider implements PerpsProvider {
);
}

return this.#handleOrderError({
return await this.#handleOrderError({
error,
symbol: params.symbol,
orderType: params.orderType,
Expand Down Expand Up @@ -4061,7 +4098,13 @@ export class HyperLiquidProvider implements PerpsProvider {
try {
this.#deps.debugLogger.log('Canceling order:', params);

// Validate coin exists
// Hydrate the asset map before coin validation so a cold start (e.g.
// service-worker restart with an empty prefetch map) can self-heal
// without signature prompts on invalid cancels. Trading setup (builder
// fee, referral, unified account) runs only after validation passes,
// matching placeOrder / editOrder.
await this.#ensureReady();

Comment thread
abretonc7s marked this conversation as resolved.
const coinValidation = validateCoinExists(
params.symbol,
this.#symbolToAssetId,
Expand All @@ -4070,7 +4113,6 @@ export class HyperLiquidProvider implements PerpsProvider {
throw new Error(coinValidation.error);
}

// Ensure provider is ready for trading (includes signing operations)
await this.#ensureReadyForTrading();

const exchangeClient = this.#clientService.getExchangeClient();
Expand All @@ -4088,22 +4130,37 @@ export class HyperLiquidProvider implements PerpsProvider {
],
});

const success = result.response?.data?.statuses?.[0] === 'success';
const status = result.response?.data?.statuses?.[0];
if (status === 'success') {
return {
success: true,
orderId: params.orderId,
};
}

// HyperLiquid usually rejects a cancel without throwing: the status entry
// carries the raw exchange string (e.g. "multi-sig required"). Map it the
// same way as a thrown rejection so callers get a standardized code
// instead of a generic message. The SDK types every status as 'success',
// so the rejection shape needs the same cast cancelOrders already uses.
const rawError =
(status as { error?: string } | undefined)?.error ??
'Order cancellation failed';

return {
success,
return createErrorResult(this.#mapError(new Error(rawError)), {
success: false,
orderId: params.orderId,
error: success ? undefined : 'Order cancellation failed',
};
});
} catch (error) {
const mappedError = this.#mapError(error);
this.#deps.logger.error(
ensureError(error, 'HyperLiquidProvider.cancelOrder'),
this.#getErrorContext('cancelOrder', {
mappedError,
await this.#getTradingErrorContext('cancelOrder', mappedError, {
orderId: params.orderId,
coin: params.symbol,
}),
);
return createErrorResult(error, { success: false });
return createErrorResult(mappedError, { success: false });
}
}

Expand Down Expand Up @@ -4166,20 +4223,26 @@ export class HyperLiquidProvider implements PerpsProvider {
success: successCount > 0,
successCount,
failureCount,
results: statuses.map((status, index) => ({
orderId: params[index].orderId,
symbol: params[index].symbol,
success: status === 'success',
error:
status === 'success'
? undefined
: (status as { error: string }).error,
})),
results: statuses.map((status, index) => {
// Map each per-status rejection the same way cancelOrder does, so a
// batch cancel reports standardized codes rather than raw exchange
// strings for the rejections this provider recognizes.
const statusError = (status as { error?: string } | undefined)?.error;
return {
orderId: params[index].orderId,
symbol: params[index].symbol,
success: status === 'success',
error: statusError
? this.#mapError(new Error(statusError)).message
: undefined,
};
}),
};
} catch (error) {
const mappedError = this.#mapError(error);
Comment thread
abretonc7s marked this conversation as resolved.
this.#deps.logger.error(
ensureError(error, 'HyperLiquidProvider.cancelOrders'),
this.#getErrorContext('cancelOrders', {
mappedError,
await this.#getTradingErrorContext('cancelOrders', mappedError, {
orderCount: params.length,
}),
);
Expand All @@ -4194,7 +4257,7 @@ export class HyperLiquidProvider implements PerpsProvider {
success: false,
error:
error instanceof Error
? error.message
? mappedError.message
: PERPS_ERROR_CODES.BATCH_CANCEL_FAILED,
})),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1291,6 +1291,18 @@ export class HyperLiquidSubscriptionService {
};
}

/**
* Return the cached HL abstraction mode for the given user address.
*
* @param userAddress - The EVM address to look up.
* @returns Cached abstraction mode, or null when unresolved.
*/
public getCachedAbstractionMode(
userAddress: string,
): HyperLiquidAbstractionMode | null {
return this.#getAbstractionModeForUser(userAddress);
}

/**
* Record a user's resolved abstraction mode and immediately re-aggregate.
* Call after the provider has confirmed the on-chain mode (already-enabled
Expand Down
Loading