diff --git a/packages/perps-controller/.sync-state.json b/packages/perps-controller/.sync-state.json index 444e5f0222..7156bcbf90 100644 --- a/packages/perps-controller/.sync-state.json +++ b/packages/perps-controller/.sync-state.json @@ -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" } diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 40d2385268..c27f796c78 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -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] diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index fbf2528814..9ef2058fc8 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -3543,6 +3543,9 @@ export class HyperLiquidProvider implements PerpsProvider { * @returns A promise that resolves to the result. */ async placeOrder(params: OrderParams, retryCount = 0): Promise { + // 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); @@ -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; @@ -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, @@ -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 { diff --git a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts index 7ce564dac6..f0cd24a171 100644 --- a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts +++ b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts @@ -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 = [ @@ -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'))) @@ -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; }