Skip to content
Draft
6 changes: 6 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Before, they failed with the SDK's "Failed to sign the typed data using the wallet" message, or with `TPSL_UPDATE_FAILED` for a TP/SL update whose builder fee was not approved yet
- Covers orders, edits, single and batch cancels (TWAP, scale and chase cancels included), position closes, TP/SL updates and clears, margin updates, withdrawals and transfers between DEXs, including the HIP-3 transfers around an order
- HyperLiquid `cancelOrders` reports each order of a batch with its own result when an entry fails: orders the venue cancelled are no longer reported as failed with the batch's error ([#10559](https://github.com/MetaMask/core/pull/10559))
- Keep HyperLiquid order placement working while the WebSocket reconnects, instead of failing with `CLIENT_NOT_INITIALIZED` or a 3x leverage cap ([#10590](https://github.com/MetaMask/core/pull/10590))
- Pre-order reads now go over HTTP, which stays available during a reconnect: the margin-mode lock (open orders, TWAP history, asset data), HIP-3 DEX balances and spot metadata, unified-account and referral setup, asset-map rebuilds, and the open-order read in `updatePositionTPSL`.
- `getMaxLeverage` no longer requires the WebSocket clients, so orders are validated against the market's maximum leverage rather than the conservative fallback.
- Retry pre-order HTTP reads (market metadata and prices, spot metadata, positions and balances, open orders, TWAP history and asset data) up to twice with jittered exponential backoff when HyperLiquid answers 429, instead of failing the order on a transient rate limit.
- Wait up to `PERPS_CONSTANTS.ConnectionTimeoutMs` for the client's follow-up `init()` when a controller action (such as `placeOrder`) was waiting on a `disconnect()`, so an order submitted during a disconnect-then-init reconnect is placed instead of failing with `CLIENT_NOT_INITIALIZED` ([#10589](https://github.com/MetaMask/core/pull/10589))
- If that reconnect switched the selected account, the network or the active provider, the action fails with `PROVIDER_LIFECYCLE_STALE` instead of running under the new context.

## [18.0.1]

Expand Down
69 changes: 69 additions & 0 deletions packages/perps-controller/src/PerpsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1039,6 +1039,9 @@ export class PerpsController extends BaseController<

#initializationPromise: Promise<void> | null = null;

// Actions that saw a disconnect wait here for the client's follow-up init().
readonly #initializationStartWaiters = new Set<() => void>();

#isReinitializing = false;

#reinitializationOperationPromise: Promise<void> | null = null;
Expand Down Expand Up @@ -2192,9 +2195,33 @@ export class PerpsController extends BaseController<
}

this.#initializationPromise = this.#performInitialization();
this.#initializationStartWaiters.forEach((notifyStarted) =>
notifyStarted(),
);
return this.#initializationPromise;
}

/**
* Resolve once a new initialization starts, or after the timeout.
*
* @param timeoutMs - Longest time to wait for init() to be called.
* @returns A promise that resolves when init starts or the timeout elapses.
*/
async #waitForInitializationStart(timeoutMs: number): Promise<void> {
let notifyStarted = (): void => undefined;
const started = new Promise<void>((resolve) => {
notifyStarted = resolve;
});
this.#initializationStartWaiters.add(notifyStarted);
const timeout = setTimeout(notifyStarted, timeoutMs);
try {
await started;
} finally {
clearTimeout(timeout);
this.#initializationStartWaiters.delete(notifyStarted);
}
}

/**
* Track a network or provider reinitialization so disconnect can serialize
* behind the whole operation, including work before and after init().
Expand Down Expand Up @@ -2717,9 +2744,16 @@ export class PerpsController extends BaseController<
* @returns The active provider once initialization completes.
*/
async #getActiveProviderWhenReady(): Promise<PerpsProvider> {
// The context the action was issued under. A client reconnect
// (disconnect then init) that switches account, network or provider must
// not carry the action into the new context.
const issuedContext = this.#getActionContext();
let awaitedDisconnect = false;
let awaitedInitializationStart = false;
while (true) {
const pendingDisconnect = this.#disconnectOperationPromise;
if (pendingDisconnect) {
awaitedDisconnect = true;
await pendingDisconnect;
continue;
}
Expand All @@ -2739,10 +2773,45 @@ export class PerpsController extends BaseController<
continue;
}

// Clients reconnect with disconnect() followed by init(), and the
// disconnect settles before init() is called. Give that init a bounded
// window to start rather than failing an action the reconnect will
// serve. Nothing here starts a connection the client did not ask for.
if (
awaitedDisconnect &&
!awaitedInitializationStart &&
!this.isInitialized &&
!pendingInitialization
) {
awaitedInitializationStart = true;
await this.#waitForInitializationStart(
PERPS_CONSTANTS.ConnectionTimeoutMs,
);
continue;
}

if (awaitedDisconnect && this.#getActionContext() !== issuedContext) {
throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE);
}

return this.getActiveProvider();
}
}

/**
* Identify the account, network and provider an action runs under.
*
* @returns A key that changes when any of them changes.
*/
#getActionContext(): string {
const address = getSelectedEvmAccountFromMessenger(this.messenger)?.address;
return [
address?.toLowerCase() ?? '',
this.state.isTestnet ? 'testnet' : 'mainnet',
this.state.activeProvider,
].join('|');
}

/**
* Get the currently active provider, returning null if not available
* Use this method when the caller can gracefully handle a missing provider
Expand Down
74 changes: 43 additions & 31 deletions packages/perps-controller/src/providers/HyperLiquidProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ import {
resolvePositionTriggerSummaryPrice,
toSDKTimeInForce,
} from '../utils/orderTypes.js';
import { withRateLimitRetry } from '../utils/rateLimitRetry.js';
import {
createStandaloneInfoClient,
queryStandaloneClearinghouseStates,
Expand Down Expand Up @@ -2420,7 +2421,7 @@ export class HyperLiquidProvider implements PerpsProvider {
}

try {
const infoClient = this.#clientService.getInfoClient();
const infoClient = this.#clientService.getInfoClient({ useHttp: true });
const ledger = await infoClient.userNonFundingLedgerUpdates({
user: userAddress,
startTime: 0,
Expand Down Expand Up @@ -2465,7 +2466,7 @@ export class HyperLiquidProvider implements PerpsProvider {
*/
async #isHyperliquidMultiSigAccount(userAddress: string): Promise<boolean> {
try {
const infoClient = this.#clientService.getInfoClient();
const infoClient = this.#clientService.getInfoClient({ useHttp: true });
const signers = await infoClient.userToMultiSigSigners({
user: userAddress,
});
Expand Down Expand Up @@ -2613,7 +2614,7 @@ export class HyperLiquidProvider implements PerpsProvider {
return;
}

const infoClient = this.#clientService.getInfoClient();
const infoClient = this.#clientService.getInfoClient({ useHttp: true });

// Check current abstraction mode on-chain
currentMode = await infoClient.userAbstraction({
Expand Down Expand Up @@ -2915,8 +2916,10 @@ export class HyperLiquidProvider implements PerpsProvider {
// This awaits WebSocket transport.ready() to ensure connection is established
await this.#ensureClientsInitialized();

// Verify clients are properly initialized
this.#clientService.ensureInitialized();
// Verify the clients are initialized. Setup reads and exchange writes go
// over HTTP, which survives a WebSocket reconnect, so do not require the
// WebSocket clients here: that fails orders the exchange can still take.
this.#clientService.getInfoClient({ useHttp: true });

// Build asset mapping on first call, or retry if DEX discovery previously failed
if (this.#symbolToAssetId.size === 0 || !this.#dexDiscoveryComplete) {
Expand Down Expand Up @@ -3217,8 +3220,8 @@ export class HyperLiquidProvider implements PerpsProvider {
{ symbol },
);
const infoClient = this.#clientService.getInfoClient({ useHttp: true });
const mids = await infoClient.allMids(
dexName ? { dex: dexName } : undefined,
const mids = await withRateLimitRetry(() =>
infoClient.allMids(dexName ? { dex: dexName } : undefined),
);
const price = parseFloat(mids[symbol] || '0');

Expand Down Expand Up @@ -3410,7 +3413,7 @@ export class HyperLiquidProvider implements PerpsProvider {
}

// Fetch all available DEXs from HyperLiquid
const infoClient = this.#clientService.getInfoClient();
const infoClient = this.#clientService.getInfoClient({ useHttp: true });
let allDexs;
try {
allDexs = await infoClient.perpDexs();
Expand Down Expand Up @@ -3567,7 +3570,9 @@ export class HyperLiquidProvider implements PerpsProvider {
const infoClient = this.#clientService.getInfoClient({ useHttp: true });
// Pass dex only for HIP-3 DEXs; omit for main DEX (empty string).
// Testnet API returns null when dex="" is explicitly sent.
const meta = await infoClient.meta(dexName ? { dex: dexName } : undefined);
const meta = await withRateLimitRetry(() =>
infoClient.meta(dexName ? { dex: dexName } : undefined),
);

// Defensive validation before caching
if (!meta?.universe || !Array.isArray(meta.universe)) {
Expand Down Expand Up @@ -3803,8 +3808,8 @@ export class HyperLiquidProvider implements PerpsProvider {
}

const lifecycleGeneration = this.#lifecycleGeneration;
const infoClient = this.#clientService.getInfoClient();
const spotMeta = await infoClient.spotMeta();
const infoClient = this.#clientService.getInfoClient({ useHttp: true });
const spotMeta = await withRateLimitRetry(() => infoClient.spotMeta());

if (
this.#isCacheWriteLifecycleCurrent(
Expand Down Expand Up @@ -4183,7 +4188,7 @@ export class HyperLiquidProvider implements PerpsProvider {
// Fetch metadata for each DEX in parallel using metaAndAssetCtxs
// Optimization: Check cache first - getMarketDataWithPrices may have already fetched
// If not cached, fetch via metaAndAssetCtxs and populate cache for other methods
const infoClient = this.#clientService.getInfoClient();
const infoClient = this.#clientService.getInfoClient({ useHttp: true });
const allMetas = await Promise.allSettled(
dexsToMap.map((dex) => {
// Check if already cached (e.g., by getMarketDataWithPrices running in parallel)
Expand Down Expand Up @@ -4956,13 +4961,15 @@ export class HyperLiquidProvider implements PerpsProvider {
async #getBalanceForDex(params: { dex: string | null }): Promise<number> {
const { dex } = params;
const userAddress = await this.#walletService.getUserAddressWithDefault();
const infoClient = this.#clientService.getInfoClient();
const infoClient = this.#clientService.getInfoClient({ useHttp: true });

const queryParams = dex
? { user: userAddress, dex }
: { user: userAddress };

const accountState = await infoClient.clearinghouseState(queryParams);
const accountState = await withRateLimitRetry(() =>
infoClient.clearinghouseState(queryParams),
);
const adapted = adaptAccountStateFromSDK(accountState);
return parseFloat(adapted.withdrawableBalance);
}
Expand Down Expand Up @@ -5640,10 +5647,10 @@ export class HyperLiquidProvider implements PerpsProvider {
if (position) {
return { marginMode: position.leverage.type, reason: 'position' };
}
const infoClient = this.#clientService.getInfoClient();
const infoClient = this.#clientService.getInfoClient({ useHttp: true });
const [orders, twapHistory] = await Promise.all([
this.#fetchOpenOrders({ dexName }),
infoClient.twapHistory({ user }),
withRateLimitRetry(() => infoClient.twapHistory({ user })),
]);
await assertSameAccount();
// Native TWAP schedules are absent from frontendOpenOrders before a slice
Expand Down Expand Up @@ -5679,10 +5686,9 @@ export class HyperLiquidProvider implements PerpsProvider {
}
});
if (orders.some((order) => order.coin === symbol) || hasActiveTwap) {
const asset = await infoClient.activeAssetData({
user,
coin: symbol,
});
const asset = await withRateLimitRetry(() =>
infoClient.activeAssetData({ user, coin: symbol }),
);
await assertSameAccount();
return { marginMode: asset.leverage.type, reason: 'open_order' };
}
Expand Down Expand Up @@ -9278,10 +9284,13 @@ export class HyperLiquidProvider implements PerpsProvider {
dexName: string | null;
}): Promise<FrontendOrder[]> {
const userAddress = await this.#walletService.getUserAddressWithDefault();
return await this.#clientService.getInfoClient().frontendOpenOrders({
user: userAddress,
dex: params.dexName ?? undefined,
});
const infoClient = this.#clientService.getInfoClient({ useHttp: true });
return await withRateLimitRetry(() =>
infoClient.frontendOpenOrders({
user: userAddress,
dex: params.dexName ?? undefined,
}),
);
}

/**
Expand Down Expand Up @@ -10356,7 +10365,7 @@ export class HyperLiquidProvider implements PerpsProvider {
// Get clients for API calls (#ensureReady already called at method start).
// Holding the exchange client reference is not itself a write; it is only
// used below, after the trading setup has run.
const infoClient = this.#clientService.getInfoClient();
const infoClient = this.#clientService.getInfoClient({ useHttp: true });
const exchangeClient = this.#clientService.getExchangeClient();
const userAddress = await this.#walletService.getUserAddressWithDefault();

Expand Down Expand Up @@ -11728,8 +11737,10 @@ export class HyperLiquidProvider implements PerpsProvider {
await this.#ensureClientsInitialized();
const infoClient = this.#clientService.getInfoClient({ useHttp: true });
const userAddress = await this.#walletService.getUserAddressWithDefault();
const state = await infoClient.clearinghouseState(
dexName ? { user: userAddress, dex: dexName } : { user: userAddress },
const state = await withRateLimitRetry(() =>
infoClient.clearinghouseState(
dexName ? { user: userAddress, dex: dexName } : { user: userAddress },
),
);
if (!Array.isArray(state.assetPositions)) {
throw new Error(PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE);
Expand Down Expand Up @@ -15059,9 +15070,10 @@ export class HyperLiquidProvider implements PerpsProvider {
}

// Read-only operation: only need client initialization, not full ensureReady()
// (no DEX abstraction, referral, or builder fee needed for metadata)
// (no DEX abstraction, referral, or builder fee needed for metadata).
// Metadata is read over HTTP, so a WebSocket reconnect must not force the
// conservative default leverage onto an order.
await this.#ensureClientsInitialized();
this.#clientService.ensureInitialized();

// Extract DEX name for API calls (main DEX = null)
const { dex: dexName } = parseAssetName(asset);
Expand Down Expand Up @@ -16111,7 +16123,7 @@ export class HyperLiquidProvider implements PerpsProvider {
*/
async #getReferralCodeStatus(): Promise<'ready' | 'pending' | 'failed'> {
try {
const infoClient = this.#clientService.getInfoClient();
const infoClient = this.#clientService.getInfoClient({ useHttp: true });
const isTestnet = this.#clientService.isTestnetMode();
const code = this.#getReferralCode(isTestnet);
const referrerAddr = this.#getBuilderAddress(isTestnet);
Expand Down Expand Up @@ -16161,7 +16173,7 @@ export class HyperLiquidProvider implements PerpsProvider {
*/
async #checkReferralSet(): Promise<boolean> {
try {
const infoClient = this.#clientService.getInfoClient();
const infoClient = this.#clientService.getInfoClient({ useHttp: true });
const userAddress = await this.#walletService.getUserAddressWithDefault();

// Call HyperLiquid API to check if user has a referral set
Expand Down
53 changes: 53 additions & 0 deletions packages/perps-controller/src/utils/rateLimitRetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { hasProperty, isObject } from '@metamask/utils';

import { ensureError } from './errorUtils.js';
import { wait } from './wait.js';

// Charts, history and pre-order reads share HyperLiquid's per-IP REST weight
// budget, so a burst elsewhere can rate-limit the reads an order depends on.
// A short, jittered retry lets the order go through once the budget refills
// instead of failing on a transient 429.
const MAX_RETRIES = 2;
const BASE_DELAY_MS = 500;

/**
* Detect a HyperLiquid rate-limit rejection. The SDK's HttpRequestError
* carries the response; other layers only keep the "429 ..." message.
*
* @param error - The caught error.
* @returns True when the request was rejected with HTTP 429.
*/
export function isRateLimitError(error: unknown): boolean {
if (
isObject(error) &&
hasProperty(error, 'response') &&
isObject(error.response) &&
error.response.status === 429
) {
return true;
}
const lower = ensureError(error).message.toLowerCase();
return /\b429\b/u.test(lower) || lower.includes('too many requests');
}

/**
* Run a read and retry it with full-jitter exponential backoff while it is
* rate-limited. Any other error, or a 429 after the last retry, is rethrown.
*
* @param read - The request to run.
* @returns The read's result.
*/
export async function withRateLimitRetry<Result>(
read: () => Promise<Result>,
): Promise<Result> {
for (let attempt = 0; ; attempt++) {
try {
return await read();
} catch (error) {
if (attempt >= MAX_RETRIES || !isRateLimitError(error)) {
throw error;
}
await wait(Math.random() * BASE_DELAY_MS * 2 ** attempt);
}
}
}
Loading
Loading