Skip to content
650 changes: 26 additions & 624 deletions eslint-suppressions.json

Large diffs are not rendered by default.

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": "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"
}
10 changes: 10 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion packages/perps-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
},
Expand Down
4 changes: 3 additions & 1 deletion packages/perps-controller/src/PerpsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/perps-controller/src/constants/eventNames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down
4 changes: 4 additions & 0 deletions packages/perps-controller/src/constants/perpsConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 4 additions & 9 deletions packages/perps-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 18 additions & 7 deletions packages/perps-controller/src/providers/HyperLiquidProvider.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;

Expand All @@ -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];
Expand All @@ -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,
})),
Expand Down Expand Up @@ -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);
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -120,6 +121,9 @@ export class HyperLiquidClientService {
* @param wallet - The wallet parameters for signing typed data.
*/
public async initialize(wallet: HyperLiquidWalletParams): Promise<void> {
const network = this.#isTestnet ? 'testnet' : 'mainnet';
const attemptContext = getPerpsConnectionAttemptContext();

try {
this.#updateConnectionState(WebSocketConnectionState.Connecting);
this.#createTransports();
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -456,15 +471,17 @@ 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: {
symbol: string;
interval: ValidCandleInterval;
limit?: number;
endTime?: number;
signal?: AbortSignal;
}): Promise<CandleData | null> {
const { symbol, interval, limit = 100, endTime } = options;
const { symbol, interval, limit = 100, endTime, signal } = options;
this.ensureInitialized();

try {
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -581,6 +605,7 @@ export class HyperLiquidClientService {
symbol,
interval,
limit: initialLimit,
signal: abortController.signal,
});

// Don't proceed if already unsubscribed
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading