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
12 changes: 6 additions & 6 deletions packages/perps-controller/.sync-state.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"lastSyncedMobileCommit": "355c9b656b9b51e154212666275f6d83ccb60fc5",
"lastSyncedMobileBranch": "main",
"lastSyncedCoreCommit": "fe175509d6f54207a06ebc710a9e5c07c5bc2cd8",
"lastSyncedCoreBranch": "feat/perps/sync-4-may-2026",
"lastSyncedDate": "2026-05-04T14:25:52Z",
"sourceChecksum": "2e7e9eab35667ca1bed2e2c3cd2f59361467910574b428ec47e9c6a7d0958bf5"
"lastSyncedMobileCommit": "106369c46bd01ee87faeaf51530fe67ad03ca178",
"lastSyncedMobileBranch": "fix/perps/flip-position-tat-2123",
"lastSyncedCoreCommit": "db4cc9ec8ff8530d8e9e0e3737b358e3269ce1b2",
"lastSyncedCoreBranch": "feat/perps/controller-sync-may-5",
"lastSyncedDate": "2026-05-05T20:50:44Z",
"sourceChecksum": "894eb0960741e569f1f025dbaaff6e1ffcab2489a80ad71e74cdf43af98aa764"
}
1 change: 1 addition & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **BREAKING:** Rename `AccountState.availableBalance` to `spendableBalance` and `AccountState.availableToTradeBalance` to `withdrawableBalance` for clearer semantics across abstraction modes ([#8678](https://github.com/MetaMask/core/pull/8678))
- Mode-aware spot fold: `addSpotBalanceToAccountState` now folds free spot USDC into both `spendableBalance` and `withdrawableBalance` for Unified/Portfolio modes, while Standard/DEX-abstraction modes keep spot separate ([#8678](https://github.com/MetaMask/core/pull/8678))
- Add throttled WS-driven `userAbstraction` refresh so HL-web mode flips propagate back without requiring a restart or account switch ([#8678](https://github.com/MetaMask/core/pull/8678))
- Fix position direction display for flipped positions ([#8707](https://github.com/MetaMask/core/pull/8707))

## [5.0.0]

Expand Down
69 changes: 42 additions & 27 deletions packages/perps-controller/src/providers/HyperLiquidProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3543,6 +3543,9 @@ export class HyperLiquidProvider implements PerpsProvider {
* @returns A promise that resolves to the result.
*/
async placeOrder(params: OrderParams, retryCount = 0): Promise<OrderResult> {
// Hoisted so the retry path in the catch block can use the fetched price
// even when the caller (e.g. flipPosition) omits currentPrice from params.
let effectivePrice: number | undefined;
try {
this.#deps.debugLogger.log('Placing order via HyperLiquid SDK:', params);

Expand All @@ -3557,38 +3560,18 @@ export class HyperLiquidProvider implements PerpsProvider {
throw new Error(validation.error);
}

// Validate order at provider level (enforces USD validation rules)
await this.#validateOrderBeforePlacement(params);

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

// Debug: Log asset map state before order placement
const allMapKeys = Array.from(this.#symbolToAssetId.keys());
const hip3Keys = allMapKeys.filter((key) => key.includes(':'));
const assetExists = this.#symbolToAssetId.has(params.symbol);
this.#deps.debugLogger.log('Asset map state at order time', {
requestedCoin: params.symbol,
assetExistsInMap: assetExists,
totalAssetsInMap: this.#symbolToAssetId.size,
hip3AssetsCount: hip3Keys.length,
hip3AssetsSample: hip3Keys.slice(0, 10),
hip3Enabled: this.#hip3Enabled,
allowlistMarkets: this.#allowlistMarkets,
blocklistMarkets: this.#blocklistMarkets,
});

// Extract DEX name for API calls (main DEX = null)
const { dex: dexName } = parseAssetName(params.symbol);

// 1. Get asset info and current price
// 1. Get asset info and current price before validation so price-less
// callers (e.g. flipPosition) can validate against the live fetched price.
const { assetInfo, currentPrice, meta } = await this.#getAssetInfo({
symbol: params.symbol,
dexName,
});

// Allow override with UI-provided price (optimization to avoid API call)
const effectivePrice =
// Allow override with UI-provided price (optimization to avoid API call).
effectivePrice =
params.currentPrice && params.currentPrice > 0
? params.currentPrice
: currentPrice;
Expand All @@ -3601,6 +3584,35 @@ export class HyperLiquidProvider implements PerpsProvider {
});
}

// Validate order at provider level (enforces USD validation rules).
// Pass effectivePrice so price-less market orders (e.g. flipPosition)
// validate against the live fetched price instead of failing with
// ORDER_PRICE_REQUIRED.
await this.#validateOrderBeforePlacement({
...params,
currentPrice: effectivePrice,
});

// Ensure provider is ready for trading (includes signing operations).
// Kept after validation so invalid orders never trigger signature prompts
// (builder-fee approval, DEX abstraction enablement, etc.).
await this.#ensureReadyForTrading();

// Debug: Log asset map state before order placement
const allMapKeys = Array.from(this.#symbolToAssetId.keys());
const hip3Keys = allMapKeys.filter((key) => key.includes(':'));
const assetExists = this.#symbolToAssetId.has(params.symbol);
this.#deps.debugLogger.log('Asset map state at order time', {
requestedCoin: params.symbol,
assetExistsInMap: assetExists,
totalAssetsInMap: this.#symbolToAssetId.size,
hip3AssetsCount: hip3Keys.length,
hip3AssetsSample: hip3Keys.slice(0, 10),
hip3Enabled: this.#hip3Enabled,
allowlistMarkets: this.#allowlistMarkets,
blocklistMarkets: this.#blocklistMarkets,
});

// 2. Calculate final position size with USD reconciliation
const { finalPositionSize } = calculateFinalPositionSize({
usdAmount: params.usdAmount,
Expand Down Expand Up @@ -3706,10 +3718,13 @@ export class HyperLiquidProvider implements PerpsProvider {
// USD-based order: adjust the USD amount directly
originalValue = params.usdAmount;
adjustedUsdAmount = (parseFloat(params.usdAmount) * 1.015).toFixed(2);
} else if (params.currentPrice) {
// Size-based order: calculate USD from size and adjust
} else if (effectivePrice) {
// Size-based order: calculate USD from size and adjust.
// Use the hoisted effectivePrice (fetched live price) so callers that
// omit currentPrice (e.g. flipPosition) can still recover from the
// $10-minimum edge case.
const sizeValue = parseFloat(params.size);
const estimatedUsd = sizeValue * params.currentPrice;
const estimatedUsd = sizeValue * effectivePrice;
originalValue = `${estimatedUsd.toFixed(2)} (calculated from size ${params.size})`;
adjustedUsdAmount = (estimatedUsd * 1.015).toFixed(2);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -353,13 +353,44 @@ export class HyperLiquidSubscriptionService {
if (this.#isClearing) {
return;
}
if (this.#isTransientSdkError(error)) {
// Expected SDK lifecycle: reconnect churn, intentional terminations, or
// request-side aborts. Forwarding these to Sentry pollutes the error
// budget with handled events the SDK already recovers from. Keep them
// visible locally via debugLogger for diagnosis.
this.#deps.debugLogger.log(
`[Perps transient SDK error] ${(context?.context?.data?.method as string) ?? 'unknown'}: ${error.message}`,
);
return;
}
this.#deps.logger.error(error, context);
}

#isTransientAssetCtxsError(error: unknown): boolean {
/**
* Detects transient SDK errors that are part of normal WebSocket / HTTP
* lifecycle and should not surface to Sentry. The Hyperliquid SDK
* (`@nktkas/hyperliquid`) and its `@nktkas/rews` v2 transport surface several
* error classes that are caught and recovered automatically by the SDK or
* by our own teardown paths.
*
* Returns true for `WebSocketRequestError` (rews queue rejection on close),
* `ReconnectingWebSocketError` (rews v2 lifecycle: RECONNECTION_LIMIT,
* TERMINATED_BY_USER, UNKNOWN_ERROR — v1 silently hung), `TimeoutError`
* with "Signal timed out" message (AbortSignal.timeout shim firing inside
* the SDK transport as designed), and reconnect-churn fallbacks (unknown
* or undefined errors while in Connecting / Disconnected states).
*
* Used both to drop these from Sentry (`#logErrorUnlessClearing`) and to
* decide whether to retry on the assetCtxs subscription path.
*
* @param error - The error thrown by the SDK or rews transport.
* @returns True if the error is part of normal SDK lifecycle and should
* be downgraded from Sentry capture to debug logging.
*/
#isTransientSdkError(error: unknown): boolean {
const ensuredError = ensureError(
error,
'HyperLiquidSubscriptionService.createAssetCtxsSubscription',
'HyperLiquidSubscriptionService.isTransientSdkError',
);
const connectionState = this.#clientService.getConnectionState?.();
const messageParts = [
Expand All @@ -377,6 +408,9 @@ export class HyperLiquidSubscriptionService {
return (
messageParts.includes('websocketrequesterror') ||
messageParts.includes('unknown error while making a websocket request') ||
messageParts.includes('reconnectingwebsocketerror') ||
(messageParts.includes('timeouterror') &&
messageParts.includes('signal timed out')) ||
(isReconnectChurn &&
(messageParts.includes('unknown error (no details provided)') ||
messageParts.includes('undefined')))
Expand Down Expand Up @@ -3482,10 +3516,7 @@ export class HyperLiquidSubscriptionService {
'HyperLiquidSubscriptionService.createAssetCtxsSubscription',
);
const isLastAttempt = attempt === maxAttempts;
if (
isLastAttempt ||
!this.#isTransientAssetCtxsError(ensuredError)
) {
if (isLastAttempt || !this.#isTransientSdkError(ensuredError)) {
throw ensuredError;
}

Expand Down
Loading