diff --git a/eslint-suppressions.json b/eslint-suppressions.json index cfcc1f3bae9..5d3d118c29b 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1616,56 +1616,16 @@ "count": 3 } }, - "packages/perps-controller/src/providers/HyperLiquidProvider.ts": { - "no-restricted-syntax": { - "count": 12 - } - }, - "packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts": { - "no-restricted-syntax": { - "count": 4 - } - }, - "packages/perps-controller/src/services/TradingService.ts": { - "no-restricted-syntax": { - "count": 1 - } - }, - "packages/perps-controller/src/types/index.ts": { - "no-restricted-syntax": { - "count": 2 - } - }, - "packages/perps-controller/src/types/transactionTypes.ts": { - "no-restricted-syntax": { - "count": 4 - } - }, - "packages/perps-controller/src/utils/errorUtils.ts": { - "no-restricted-syntax": { - "count": 1 - } - }, - "packages/perps-controller/src/utils/hyperLiquidAdapter.ts": { - "no-restricted-syntax": { + "packages/perps-controller/src/utils/myxAdapter.ts": { + "@typescript-eslint/no-base-to-string": { "count": 2 } }, - "packages/perps-controller/src/utils/hyperLiquidValidation.ts": { - "no-restricted-syntax": { - "count": 1 - } - }, - "packages/perps-controller/src/utils/marketDataTransform.ts": { - "no-restricted-syntax": { + "packages/perps-controller/src/utils/perpsConnectionAttemptContext.ts": { + "require-atomic-updates": { "count": 1 } }, - "packages/perps-controller/src/utils/myxAdapter.ts": { - "@typescript-eslint/no-base-to-string": { - "count": 2 - } - }, "packages/perps-controller/tests/defer-eligibility.test.ts": { "no-restricted-syntax": { "count": 1 diff --git a/packages/perps-controller/.sync-state.json b/packages/perps-controller/.sync-state.json index 9015a37ee49..5c063d759dc 100644 --- a/packages/perps-controller/.sync-state.json +++ b/packages/perps-controller/.sync-state.json @@ -1,8 +1,8 @@ { - "lastSyncedMobileCommit": "dcf50111e2b33a26030f447e483d7564c5ca03fc", - "lastSyncedMobileBranch": "main", - "lastSyncedCoreCommit": "751ca3b9258603457507742824352230ae0206ff", - "lastSyncedCoreBranch": "feat/perps/updated-mobile-perps", - "lastSyncedDate": "2026-03-30T11:04:28Z", - "sourceChecksum": "de1a472164dd36e5b761001cf6be83f69e2b6b2ce34b2e631a3b0e7000b94953" + "lastSyncedMobileCommit": "1f90a9b7a957356c02dcd201ea650c9a779a0bda", + "lastSyncedMobileBranch": "feat/tat-2863-sync-controller-code-extension", + "lastSyncedCoreCommit": "c275531d934cb592fe27c545a73b08c11f50dfa3", + "lastSyncedCoreBranch": "feat/perps/another-sync", + "lastSyncedDate": "2026-04-08T11:07:13Z", + "sourceChecksum": "5eea712091624d82e569300f9679e6595f2e77aa9e1ced15db14d3d8cce15746" } diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index acbc917b883..7da5a0463f1 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -74,6 +74,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/profile-sync-controller` from `^28.0.1` to `^28.0.2` ([#8325](https://github.com/MetaMask/core/pull/8325)) - Bump `@metamask/controller-utils` from `^11.19.0` to `^11.20.0` ([#8344](https://github.com/MetaMask/core/pull/8344)) - Bump `@metamask/messenger` from `^1.0.0` to `^1.1.1` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373)) +- Move `@myx-trade/sdk` from `dependencies` to `optionalDependencies` so consumers (extension, mobile) do not install it automatically ([#8398](https://github.com/MetaMask/core/pull/8398)) + - Combined with the MYX adapter export removal below, this prevents `@myx-trade/sdk` from entering the consumer's static webpack/metro import graph + - `MYXProvider` continues to load `@myx-trade/sdk` via dynamic `import()` when `MM_PERPS_MYX_PROVIDER_ENABLED=true` +- Add `/* webpackIgnore: true */` magic comment to the `MYXProvider` dynamic import so webpack (extension) skips static resolution of the intentionally-unshipped module ([#8398](https://github.com/MetaMask/core/pull/8398)) + +### Removed + +- **BREAKING:** Remove `adaptMarketFromMYX`, `adaptPriceFromMYX`, `adaptMarketDataFromMYX`, `filterMYXExclusiveMarkets`, `isOverlappingMarket`, `buildPoolSymbolMap`, `buildSymbolPoolsMap`, and `extractSymbolFromPoolId` from the public package exports to prevent `@myx-trade/sdk` from being included in the static webpack bundle ([#8398](https://github.com/MetaMask/core/pull/8398)) + - These functions are still used internally by `MYXProvider`, which is loaded via dynamic import + - Consumers that imported these utilities directly should instead import from `@metamask/perps-controller/src/utils/myxAdapter` or duplicate the logic locally ### Fixed diff --git a/packages/perps-controller/package.json b/packages/perps-controller/package.json index e45b9ace013..5cef396c458 100644 --- a/packages/perps-controller/package.json +++ b/packages/perps-controller/package.json @@ -57,7 +57,6 @@ "@metamask/controller-utils": "^11.20.0", "@metamask/messenger": "^1.1.1", "@metamask/utils": "^11.9.0", - "@myx-trade/sdk": "^0.1.265", "@nktkas/hyperliquid": "^0.30.2", "bignumber.js": "^9.1.2", "reselect": "^5.1.1", @@ -84,6 +83,9 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, + "optionalDependencies": { + "@myx-trade/sdk": "^0.1.265" + }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 1d8cfeab7bc..7973402e008 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -1694,7 +1694,9 @@ export class PerpsController extends BaseController< // IMPORTANT: Must use import() — NOT require() — for core/extension tree-shaking. // require() is synchronous and bundlers include it in the main bundle. // import() enables true code splitting so MYX is excluded when not enabled. - this.#myxRegistrationPromise = import('./providers/MYXProvider') + this.#myxRegistrationPromise = import( + /* webpackIgnore: true */ './providers/MYXProvider' + ) .then(({ MYXProvider }) => { this.registerMYXProvider(MYXProvider); return undefined; diff --git a/packages/perps-controller/src/constants/eventNames.ts b/packages/perps-controller/src/constants/eventNames.ts index 5740e8a26bc..b50e8d533d7 100644 --- a/packages/perps-controller/src/constants/eventNames.ts +++ b/packages/perps-controller/src/constants/eventNames.ts @@ -388,6 +388,7 @@ export const PERPS_EVENT_VALUE = { ADD_MARGIN: 'add_margin', REMOVE_MARGIN: 'remove_margin', GEO_BLOCK_NOTIF: 'geo_block_notif', + COMPLIANCE_BLOCK_NOTIF: 'compliance_block_notif', // Deposit + order (pay-with token) cancel toast CANCEL_TRADE_WITH_TOKEN_TOAST: 'cancel_trade_with_token_toast', }, diff --git a/packages/perps-controller/src/constants/perpsConfig.ts b/packages/perps-controller/src/constants/perpsConfig.ts index 9cb44b94c73..7c240fdf2f5 100644 --- a/packages/perps-controller/src/constants/perpsConfig.ts +++ b/packages/perps-controller/src/constants/perpsConfig.ts @@ -130,6 +130,10 @@ export const PERFORMANCE_CONFIG = { // Prevents excessive liquidation price calls during rapid form input changes LiquidationPriceDebounceMs: 500, + // Candle subscription debounce delay (milliseconds) + // Prevents WS subscription churn during rapid market switching (#28141) + CandleConnectDebounceMs: 500, + // Navigation params delay (milliseconds) // Required for React Navigation to complete state transitions before setting params // This ensures navigation context is available when programmatically selecting tabs diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 5723180f113..943e31086ae 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -464,15 +464,10 @@ export { } from './utils'; export type { HyperLiquidMarketData } from './utils'; export { - adaptMarketFromMYX, - adaptPriceFromMYX, - adaptMarketDataFromMYX, - filterMYXExclusiveMarkets, - isOverlappingMarket, - buildPoolSymbolMap, - buildSymbolPoolsMap, - extractSymbolFromPoolId, -} from './utils'; + getPerpsConnectionAttemptContext, + withPerpsConnectionAttemptContext, +} from './utils/perpsConnectionAttemptContext'; +export type { PerpsConnectionAttemptContext } from './utils/perpsConnectionAttemptContext'; export { MAX_MARKET_PATTERN_LENGTH, escapeRegex, diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 96103e16732..1c950938f2d 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -1,4 +1,4 @@ -import { CaipAccountId } from '@metamask/utils'; +import { CaipAccountId, hasProperty } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; import { v4 as uuidv4 } from 'uuid'; @@ -1968,14 +1968,19 @@ export class HyperLiquidProvider implements PerpsProvider { // Check order status const status = result.response?.data?.statuses?.[0]; - if (isStatusObject(status) && 'error' in status) { + if (isStatusObject(status) && hasProperty(status, 'error')) { return { success: false, error: String(status.error) }; } + // Note: `in` narrows the HyperLiquid SDK discriminated union to the + // branch that has `filled`; `hasProperty` only narrows the key and + // types `status.filled` as `unknown`, which loses access to `.totalSz`. + /* eslint-disable no-restricted-syntax */ const filledSize = isStatusObject(status) && 'filled' in status ? parseFloat(status.filled?.totalSz ?? '0') : 0; + /* eslint-enable no-restricted-syntax */ this.#deps.debugLogger.log( 'HyperLiquidProvider: USDC→USDH swap completed', @@ -3426,10 +3431,15 @@ export class HyperLiquidProvider implements PerpsProvider { } const status = result.response?.data?.statuses?.[0]; + // Note: `in` narrows the HyperLiquid SDK discriminated union to the + // branch that has the property; `hasProperty` types the property as + // `unknown`, losing downstream access to `.oid`, `.totalSz`, `.avgPx`. + /* eslint-disable no-restricted-syntax */ const restingOrder = isStatusObject(status) && 'resting' in status ? status.resting : null; const filledOrder = isStatusObject(status) && 'filled' in status ? status.filled : null; + /* eslint-enable no-restricted-syntax */ // Success - auto-rebalance excess funds if (isHip3Order && transferInfo && dexName) { @@ -4143,7 +4153,8 @@ export class HyperLiquidProvider implements PerpsProvider { const { statuses } = result.response.data; const successCount = statuses.filter( (stat) => - isStatusObject(stat) && ('filled' in stat || 'resting' in stat), + isStatusObject(stat) && + (hasProperty(stat, 'filled') || hasProperty(stat, 'resting')), ).length; const failureCount = statuses.length - successCount; @@ -4153,7 +4164,7 @@ export class HyperLiquidProvider implements PerpsProvider { const status = statuses[i]; const isSuccess = isStatusObject(status) && - ('filled' in status || 'resting' in status); + (hasProperty(status, 'filled') || hasProperty(status, 'resting')); if (isSuccess && hip3Transfers[i]) { const { sourceDex, freedMargin } = hip3Transfers[i]; @@ -4179,9 +4190,9 @@ export class HyperLiquidProvider implements PerpsProvider { symbol: positionsToClose[index].symbol, success: isStatusObject(status) && - ('filled' in status || 'resting' in status), + (hasProperty(status, 'filled') || hasProperty(status, 'resting')), error: - isStatusObject(status) && 'error' in status + isStatusObject(status) && hasProperty(status, 'error') ? String(status.error) : undefined, })), @@ -5998,7 +6009,7 @@ export class HyperLiquidProvider implements PerpsProvider { // Extract HIP-3 DEX names (filter out null which is main DEX) const hip3DexNames: string[] = []; allDexs.forEach((dex) => { - if (dex !== null && 'name' in dex) { + if (dex !== null && hasProperty(dex, 'name')) { hip3DexNames.push(dex.name); } }); diff --git a/packages/perps-controller/src/services/HyperLiquidClientService.ts b/packages/perps-controller/src/services/HyperLiquidClientService.ts index 68b22bdbd29..c65d3cb1628 100644 --- a/packages/perps-controller/src/services/HyperLiquidClientService.ts +++ b/packages/perps-controller/src/services/HyperLiquidClientService.ts @@ -19,6 +19,7 @@ import type { import type { HyperLiquidNetwork } from '../types/config'; import type { CandleData } from '../types/perps-types'; import { ensureError } from '../utils/errorUtils'; +import { getPerpsConnectionAttemptContext } from '../utils/perpsConnectionAttemptContext'; /** * Maximum number of reconnection attempts before giving up. @@ -120,6 +121,9 @@ export class HyperLiquidClientService { * @param wallet - The wallet parameters for signing typed data. */ public async initialize(wallet: HyperLiquidWalletParams): Promise { + const network = this.#isTestnet ? 'testnet' : 'mainnet'; + const attemptContext = getPerpsConnectionAttemptContext(); + try { this.#updateConnectionState(WebSocketConnectionState.Connecting); this.#createTransports(); @@ -184,21 +188,32 @@ export class HyperLiquidClientService { ); this.#updateConnectionState(WebSocketConnectionState.Disconnected); - // Log to Sentry: initialization failure blocks all Perps functionality - this.#deps.logger.error(errorInstance, { - tags: { - feature: PERPS_CONSTANTS.FeatureName, - service: 'HyperLiquidClientService', - network: this.#isTestnet ? 'testnet' : 'mainnet', - }, - context: { - name: 'sdk_initialization', - data: { - operation: 'initialize', - isTestnet: this.#isTestnet, + if (attemptContext?.suppressError) { + this.#deps.debugLogger.log( + 'HyperLiquid initialize failed during suppressed startup attempt', + { + error: errorInstance.message, + network, + source: attemptContext.source, }, - }, - }); + ); + } else { + this.#deps.logger.error(errorInstance, { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + service: 'HyperLiquidClientService', + network, + }, + context: { + name: 'sdk_initialization', + data: { + operation: 'initialize', + isTestnet: this.#isTestnet, + source: attemptContext?.source ?? 'unspecified', + }, + }, + }); + } throw error; } @@ -456,6 +471,7 @@ export class HyperLiquidClientService { * @param options.interval - The candle interval (e.g., "1m", "5m", "15m", "1h", "1d"). * @param options.limit - Number of candles to fetch (default: 100). * @param options.endTime - End timestamp in milliseconds (default: now). + * @param options.signal - Optional AbortSignal to cancel the fetch. * @returns The historical candle data, or null if no data is available. */ public async fetchHistoricalCandles(options: { @@ -463,8 +479,9 @@ export class HyperLiquidClientService { interval: ValidCandleInterval; limit?: number; endTime?: number; + signal?: AbortSignal; }): Promise { - const { symbol, interval, limit = 100, endTime } = options; + const { symbol, interval, limit = 100, endTime, signal } = options; this.ensureInitialized(); try { @@ -476,12 +493,15 @@ export class HyperLiquidClientService { // Use the SDK's InfoClient to fetch candle data // HyperLiquid SDK uses 'coin' terminology const infoClient = this.getInfoClient(); - const data = await infoClient.candleSnapshot({ - coin: symbol, // Map to HyperLiquid SDK's 'coin' parameter - interval, - startTime, - endTime: now, - }); + const data = await infoClient.candleSnapshot( + { + coin: symbol, // Map to HyperLiquid SDK's 'coin' parameter + interval, + startTime, + endTime: now, + }, + signal, + ); // Transform API response to match expected format if (Array.isArray(data) && data.length > 0) { @@ -567,6 +587,10 @@ export class HyperLiquidClientService { // This fixes a race condition where component unmounts before subscription resolves let subscriptionPromise: Promise<{ unsubscribe: () => void }> | null = null; + // AbortController to cancel in-flight REST calls (candleSnapshot) on cleanup. + // Prevents rate limit exhaustion when rapidly switching markets (#28141). + const abortController = new AbortController(); + // Calculate initial fetch size dynamically based on duration and interval // Match main branch behavior: up to 500 candles initially const initialLimit = duration @@ -581,6 +605,7 @@ export class HyperLiquidClientService { symbol, interval, limit: initialLimit, + signal: abortController.signal, }); // Don't proceed if already unsubscribed @@ -683,6 +708,11 @@ export class HyperLiquidClientService { onError?.(errorInstance); } } catch (error) { + // Skip logging and notification for intentional abort (user navigated away) + if (abortController.signal.aborted) { + return; + } + const errorInstance = ensureError( error, 'HyperLiquidClientService.subscribeToCandles', @@ -720,6 +750,8 @@ export class HyperLiquidClientService { // Return cleanup function return () => { isUnsubscribed = true; + // Cancel any in-flight REST calls (candleSnapshot) to conserve rate limit budget (#28141) + abortController.abort(); if (wsUnsubscribe) { // Subscription already resolved - unsubscribe directly wsUnsubscribe(); diff --git a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts index a56c1a70f08..5ed9355b9e8 100644 --- a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts +++ b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts @@ -1,4 +1,5 @@ import type { CaipAccountId } from '@metamask/utils'; +import { hasProperty } from '@metamask/utils'; import type { ISubscription, AllMidsWsEvent, @@ -117,8 +118,18 @@ export class HyperLiquidSubscriptionService { readonly #globalActiveAssetSubscriptions = new Map(); + // Track in-progress activeAssetCtx subscription promises to prevent leaks + // when cleanup fires before the async subscription resolves (#28141) + readonly #pendingActiveAssetPromises = new Map< + string, + Promise + >(); + readonly #globalBboSubscriptions = new Map(); + // Track in-progress BBO subscription promises to prevent leaks (#28141) + readonly #pendingBboPromises = new Map>(); + // Order fill subscriptions keyed by accountId (normalized: undefined -> 'default') readonly #orderFillSubscriptions = new Map(); @@ -2543,8 +2554,11 @@ export class HyperLiquidSubscriptionService { const currentCount = this.#symbolSubscriberCounts.get(symbol) ?? 0; this.#symbolSubscriberCounts.set(symbol, currentCount + 1); - // If subscription already exists, just return - if (this.#globalActiveAssetSubscriptions.has(symbol)) { + // If subscription already exists or is being created, just return + if ( + this.#globalActiveAssetSubscriptions.has(symbol) || + this.#pendingActiveAssetPromises.has(symbol) + ) { return; } @@ -2559,7 +2573,7 @@ export class HyperLiquidSubscriptionService { startTime: Date.now(), }; - subscriptionClient + const promise = subscriptionClient .activeAssetCtx( { coin: symbol }, (data: ActiveAssetCtxWsEvent | ActiveSpotAssetCtxWsEvent) => { @@ -2570,9 +2584,9 @@ export class HyperLiquidSubscriptionService { const isPerpsContext = ( event: ActiveAssetCtxWsEvent | ActiveSpotAssetCtxWsEvent, ): event is ActiveAssetCtxWsEvent => - 'funding' in event.ctx && - 'openInterest' in event.ctx && - 'oraclePx' in event.ctx; + hasProperty(event.ctx, 'funding') && + hasProperty(event.ctx, 'openInterest') && + hasProperty(event.ctx, 'oraclePx'); const { ctx } = data; @@ -2621,6 +2635,22 @@ export class HyperLiquidSubscriptionService { }, ) .then((sub) => { + // Only clear pending ref if this is still the current promise. + // A rapid away-and-back can replace the pending promise; blindly + // deleting would remove the *newer* reference (#28141). + if (this.#pendingActiveAssetPromises.get(symbol) === promise) { + this.#pendingActiveAssetPromises.delete(symbol); + } + // Stale subscription: cleanup was called while pending, a newer + // subscription already won the race, OR a different pending promise + // exists (rapid away-and-back before this one resolved). (#28141) + if ( + (this.#symbolSubscriberCounts.get(symbol) ?? 0) <= 0 || + this.#globalActiveAssetSubscriptions.has(symbol) || + this.#pendingActiveAssetPromises.has(symbol) + ) { + return sub.unsubscribe(); + } this.#globalActiveAssetSubscriptions.set(symbol, sub); this.#deps.debugLogger.log( `HyperLiquid: Market data subscription established for ${symbol}`, @@ -2628,6 +2658,9 @@ export class HyperLiquidSubscriptionService { return undefined; }) .catch((error) => { + if (this.#pendingActiveAssetPromises.get(symbol) === promise) { + this.#pendingActiveAssetPromises.delete(symbol); + } this.#logErrorUnlessClearing( ensureError( error, @@ -2636,6 +2669,8 @@ export class HyperLiquidSubscriptionService { this.#getErrorContext('ensureActiveAssetSubscription', { symbol }), ); }); + + this.#pendingActiveAssetPromises.set(symbol, promise); } /** @@ -2647,6 +2682,8 @@ export class HyperLiquidSubscriptionService { const currentCount = this.#symbolSubscriberCounts.get(symbol) ?? 0; if (currentCount <= 1) { // Last subscriber, cleanup subscription + this.#symbolSubscriberCounts.delete(symbol); + const subscription = this.#globalActiveAssetSubscriptions.get(symbol); if (subscription && typeof subscription.unsubscribe === 'function') { const unsubscribeResult = Promise.resolve(subscription.unsubscribe()); @@ -2655,13 +2692,17 @@ export class HyperLiquidSubscriptionService { // Ignore errors during cleanup }); this.#globalActiveAssetSubscriptions.delete(symbol); - this.#symbolSubscriberCounts.delete(symbol); } else if (subscription) { // Subscription exists but unsubscribe is not a function or doesn't return a Promise // Just clean up the reference this.#globalActiveAssetSubscriptions.delete(symbol); - this.#symbolSubscriberCounts.delete(symbol); } + + // If subscription is still pending (async), the .then() handler in + // #ensureActiveAssetSubscription will check symbolSubscriberCounts + // and unsubscribe immediately when it resolves (#28141) + // Clean up the pending promise reference + this.#pendingActiveAssetPromises.delete(symbol); } else { // Still has subscribers, just decrement count this.#symbolSubscriberCounts.set(symbol, currentCount - 1); @@ -2770,6 +2811,11 @@ export class HyperLiquidSubscriptionService { this.#allMidsSnapshots.set(dex, data.mids as Record); }) .then((sub) => { + // If a newer subscription already won the race, discard this one (#28141) + if (this.#dexAllMidsSubscriptions.has(dex)) { + resolve(); + return sub.unsubscribe(); + } this.#dexAllMidsSubscriptions.set(dex, sub); this.#deps.debugLogger.log( `allMids subscription established for DEX: ${dex}`, @@ -2859,7 +2905,7 @@ export class HyperLiquidSubscriptionService { // Use cached meta to map ctxs array indices to symbols (no REST API call!) validatedMeta.universe.forEach((asset, index) => { const ctx = data.ctxs[index]; - if (ctx && 'funding' in ctx) { + if (ctx && hasProperty(ctx, 'funding')) { // This is a perps context const ctxPrice = ctx.midPx ?? ctx.markPx; const openInterestUSD = calculateOpenInterestUSD( @@ -2943,6 +2989,11 @@ export class HyperLiquidSubscriptionService { subscribeWithRetry() .then((sub) => { + // If a newer subscription already won the race, discard this one (#28141) + if (this.#assetCtxsSubscriptions.has(dexKey)) { + resolve(); + return sub.unsubscribe(); + } this.#assetCtxsSubscriptions.set(dexKey, sub); this.#deps.debugLogger.log( `assetCtxs subscription established for ${ @@ -3044,7 +3095,11 @@ export class HyperLiquidSubscriptionService { * @param symbol - The trading pair symbol to subscribe to BBO for. */ #ensureBboSubscription(symbol: string): void { - if (this.#globalBboSubscriptions.has(symbol)) { + // Skip if subscription already exists or is being created + if ( + this.#globalBboSubscriptions.has(symbol) || + this.#pendingBboPromises.has(symbol) + ) { return; } @@ -3053,7 +3108,7 @@ export class HyperLiquidSubscriptionService { return; } - subscriptionClient + const promise = subscriptionClient .bbo({ coin: symbol }, (data: BboWsEvent) => { processBboData({ symbol, @@ -3065,6 +3120,20 @@ export class HyperLiquidSubscriptionService { }); }) .then((sub) => { + // Only clear pending ref if this is still the current promise (#28141). + if (this.#pendingBboPromises.get(symbol) === promise) { + this.#pendingBboPromises.delete(symbol); + } + // Stale subscription: cleanup was called while pending, a newer + // subscription already won the race, OR a different pending promise + // exists (rapid away-and-back before this one resolved). (#28141) + if ( + (this.#orderBookSubscribers.get(symbol)?.size ?? 0) <= 0 || + this.#globalBboSubscriptions.has(symbol) || + this.#pendingBboPromises.has(symbol) + ) { + return sub.unsubscribe(); + } this.#globalBboSubscriptions.set(symbol, sub); this.#deps.debugLogger.log( `HyperLiquid: BBO subscription established for ${symbol}`, @@ -3072,6 +3141,9 @@ export class HyperLiquidSubscriptionService { return undefined; }) .catch((error) => { + if (this.#pendingBboPromises.get(symbol) === promise) { + this.#pendingBboPromises.delete(symbol); + } this.#logErrorUnlessClearing( ensureError( error, @@ -3080,6 +3152,8 @@ export class HyperLiquidSubscriptionService { this.#getErrorContext('ensureBboSubscription', { symbol }), ); }); + + this.#pendingBboPromises.set(symbol, promise); } /** @@ -3108,6 +3182,11 @@ export class HyperLiquidSubscriptionService { this.#globalBboSubscriptions.delete(symbol); this.#orderBookCache.delete(symbol); } + + // If subscription is still pending (async), the .then() handler in + // #ensureBboSubscription will check orderBookSubscribers and + // unsubscribe immediately when it resolves (#28141) + this.#pendingBboPromises.delete(symbol); } /** @@ -3408,6 +3487,7 @@ export class HyperLiquidSubscriptionService { if (this.#marketDataSubscribers.size > 0) { // Clear existing subscriptions (they're dead after reconnection) this.#globalActiveAssetSubscriptions.clear(); + this.#pendingActiveAssetPromises.clear(); // Clear reference counts to prevent double-counting after reconnection this.#symbolSubscriberCounts.clear(); @@ -3424,6 +3504,7 @@ export class HyperLiquidSubscriptionService { if (this.#orderBookSubscribers.size > 0) { // Clear existing subscriptions (they're dead after reconnection) this.#globalBboSubscriptions.clear(); + this.#pendingBboPromises.clear(); // Re-establish subscriptions for all symbols with order book subscribers const symbolsNeedingOrderBook = Array.from( @@ -3600,7 +3681,9 @@ export class HyperLiquidSubscriptionService { this.#globalAllMidsSubscription = undefined; this.#globalAllMidsPromise = undefined; this.#globalActiveAssetSubscriptions.clear(); + this.#pendingActiveAssetPromises.clear(); this.#globalBboSubscriptions.clear(); + this.#pendingBboPromises.clear(); this.#webData3Subscriptions.clear(); this.#webData3SubscriptionPromise = undefined; diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index 5758bfade21..87bcf56a6d3 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -1516,7 +1516,6 @@ export class TradingService { this.#deps.debugLogger.log('[closePositions] Batch method check', { providerType: provider.protocolId, - hasBatchMethod: 'closePositions' in provider, providerKeys: Object.keys(provider).filter((key) => key.includes('close'), ), diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 9dcfb312a78..3acf26349b1 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1,3 +1,4 @@ +import { hasProperty } from '@metamask/utils'; import type { CaipAccountId, CaipChainId, @@ -1643,8 +1644,8 @@ export function isVersionGatedFeatureFlag( return ( typeof value === 'object' && value !== null && - 'enabled' in value && - 'minimumVersion' in value && + hasProperty(value, 'enabled') && + hasProperty(value, 'minimumVersion') && typeof (value as { enabled: unknown }).enabled === 'boolean' && typeof (value as { minimumVersion: unknown }).minimumVersion === 'string' ); diff --git a/packages/perps-controller/src/types/transactionTypes.ts b/packages/perps-controller/src/types/transactionTypes.ts index f06c0ecfdab..de7fe0bcd1e 100644 --- a/packages/perps-controller/src/types/transactionTypes.ts +++ b/packages/perps-controller/src/types/transactionTypes.ts @@ -2,6 +2,7 @@ * Shared transaction types for Perps deposits and withdrawals * Provides a unified structure while maintaining separate use cases */ +import { hasProperty } from '@metamask/utils'; /** * Base type with core properties shared between all transaction results @@ -62,7 +63,7 @@ export type TransactionRecord = { export function isTransactionRecord( result: LastTransactionResult | TransactionRecord, ): result is TransactionRecord { - return 'id' in result && 'status' in result; + return hasProperty(result, 'id') && hasProperty(result, 'status'); } /** @@ -74,5 +75,5 @@ export function isTransactionRecord( export function isLastTransactionResult( result: LastTransactionResult | TransactionRecord, ): result is LastTransactionResult { - return !('id' in result) || !('status' in result); + return !hasProperty(result, 'id') || !hasProperty(result, 'status'); } diff --git a/packages/perps-controller/src/utils/errorUtils.ts b/packages/perps-controller/src/utils/errorUtils.ts index 074d4324ee9..bd14f541ebe 100644 --- a/packages/perps-controller/src/utils/errorUtils.ts +++ b/packages/perps-controller/src/utils/errorUtils.ts @@ -2,6 +2,7 @@ * Utility functions for error handling across the application. * These are general-purpose utilities, not domain-specific. */ +import { hasProperty } from '@metamask/utils'; /** * Ensures we have a proper Error object for logging. @@ -26,7 +27,7 @@ export function ensureError(error: unknown, context?: string): Error { return new Error(error); } return new Error( - typeof error === 'object' && error !== null && 'message' in error + typeof error === 'object' && error !== null && hasProperty(error, 'message') ? String((error as { message: unknown }).message) : 'Unknown error', ); diff --git a/packages/perps-controller/src/utils/hyperLiquidAdapter.ts b/packages/perps-controller/src/utils/hyperLiquidAdapter.ts index 8cbe2a2614f..0f1cc005926 100644 --- a/packages/perps-controller/src/utils/hyperLiquidAdapter.ts +++ b/packages/perps-controller/src/utils/hyperLiquidAdapter.ts @@ -1,4 +1,4 @@ -import { Hex, isHexString } from '@metamask/utils'; +import { hasProperty, Hex, isHexString } from '@metamask/utils'; import { countSignificantFigures, @@ -438,10 +438,13 @@ export function adaptHyperLiquidLedgerUpdateToUserHistoryItem( let amount = '0'; let asset = 'USDC'; - if ('usdc' in update.delta && update.delta.usdc) { + if (hasProperty(update.delta, 'usdc') && update.delta.usdc) { amount = Math.abs(parseFloat(update.delta.usdc)).toString(); } - if ('coin' in update.delta && typeof update.delta.coin === 'string') { + if ( + hasProperty(update.delta, 'coin') && + typeof update.delta.coin === 'string' + ) { asset = update.delta.coin; } diff --git a/packages/perps-controller/src/utils/hyperLiquidValidation.ts b/packages/perps-controller/src/utils/hyperLiquidValidation.ts index a73e87d4666..f3d80dacdeb 100644 --- a/packages/perps-controller/src/utils/hyperLiquidValidation.ts +++ b/packages/perps-controller/src/utils/hyperLiquidValidation.ts @@ -371,6 +371,10 @@ export function applyPathFilters( }); } + // Note: `in` is the idiomatic TypeScript way to narrow a string to + // `keyof typeof` for indexed access; `hasProperty` types the indexed + // result as `unknown` and loses the `{ testnet, mainnet }` shape. + /* eslint-disable-next-line no-restricted-syntax */ if (params.symbol && params.symbol in HYPERLIQUID_ASSET_CONFIGS) { const config = HYPERLIQUID_ASSET_CONFIGS[ diff --git a/packages/perps-controller/src/utils/index.ts b/packages/perps-controller/src/utils/index.ts index 4e8b98e9053..46742615a7c 100644 --- a/packages/perps-controller/src/utils/index.ts +++ b/packages/perps-controller/src/utils/index.ts @@ -24,7 +24,6 @@ export * from './hyperLiquidOrderBookProcessor'; export * from './hyperLiquidValidation'; export * from './idUtils'; export * from './marketDataTransform'; -export * from './myxAdapter'; export * from './marketUtils'; export * from './orderCalculations'; export * from './rewardsUtils'; diff --git a/packages/perps-controller/src/utils/marketDataTransform.ts b/packages/perps-controller/src/utils/marketDataTransform.ts index 428a645c134..790eaa70889 100644 --- a/packages/perps-controller/src/utils/marketDataTransform.ts +++ b/packages/perps-controller/src/utils/marketDataTransform.ts @@ -4,6 +4,8 @@ * Portable: no mobile-specific imports. * Formatters are injected via MarketDataFormatters interface. */ +import { hasProperty } from '@metamask/utils'; + import { parseAssetName } from './hyperLiquidAdapter'; import { HYPERLIQUID_CONFIG } from '../constants/hyperLiquidConfig'; import { PERPS_CONSTANTS } from '../constants/perpsConfig'; @@ -221,7 +223,7 @@ export function transformMarketData( // Get current funding rate from assetCtx - this is the actual current funding rate let fundingRate: number | undefined; - if (assetCtx && 'funding' in assetCtx) { + if (assetCtx && hasProperty(assetCtx, 'funding')) { fundingRate = parseFloat(assetCtx.funding); } diff --git a/packages/perps-controller/src/utils/perpsConnectionAttemptContext.ts b/packages/perps-controller/src/utils/perpsConnectionAttemptContext.ts new file mode 100644 index 00000000000..6e96042223a --- /dev/null +++ b/packages/perps-controller/src/utils/perpsConnectionAttemptContext.ts @@ -0,0 +1,24 @@ +export type PerpsConnectionAttemptContext = { + source: string; + suppressError: boolean; +}; + +let currentAttemptContext: PerpsConnectionAttemptContext | null = null; + +export function getPerpsConnectionAttemptContext(): PerpsConnectionAttemptContext | null { + return currentAttemptContext; +} + +export async function withPerpsConnectionAttemptContext( + context: PerpsConnectionAttemptContext, + callback: () => Promise, +): Promise { + const previousContext = currentAttemptContext; + currentAttemptContext = context; + + try { + return await callback(); + } finally { + currentAttemptContext = previousContext; + } +} diff --git a/yarn.lock b/yarn.lock index 64f1fc4e2f4..52edcac51b3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4823,6 +4823,9 @@ __metadata: typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" + dependenciesMeta: + "@myx-trade/sdk": + optional: true languageName: unknown linkType: soft