From bab572cecc8f8f8e0f025a70fde5b6d2703cefab Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 30 Sep 2026 02:11:10 +0800 Subject: [PATCH 1/5] fix(perps): keep order submit working during a WebSocket reconnect Orders submitted while the Perps connection reconnects failed with CLIENT_NOT_INITIALIZED although the HTTP exchange client stays available. - Read order-path data over HTTP: margin-mode lock, HIP-3 balances and spot metadata, unified-account and referral setup, asset-map rebuilds, and the #ensureReady gate. getMaxLeverage no longer requires the WebSocket clients, which forced a 3x cap during a reconnect. - When an action waited on a disconnect or reinitialization, wait up to ConnectionTimeoutMs for the client's follow-up init() instead of failing in the gap between disconnect() and init(). Refs: TAT-4041 --- packages/perps-controller/CHANGELOG.md | 7 + .../perps-controller/src/PerpsController.ts | 48 ++++ .../src/providers/HyperLiquidProvider.ts | 34 +-- .../src/PerpsController.lifecycle.test.ts | 118 ++++++++-- .../HyperLiquidProvider.trading.test.ts | 214 ++++++++++++++++++ 5 files changed, 390 insertions(+), 31 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 9111b3475a9..fbc1c3942aa 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -16,6 +16,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `getMarginMode(symbol)` and `saveMarginMode(symbol, marginMode)` methods, exposed as the `PerpsController:getMarginMode` and `PerpsController:saveMarginMode` messenger actions (`PerpsControllerGetMarginModeAction`, `PerpsControllerSaveMarginModeAction`). `saveMarginMode` ignores values other than `isolated` or `cross`. - Add the `selectMarginMode(state, symbol)` selector and an optional `marginMode` field on `TradeConfiguration`. +### Fixed + +- Keep HyperLiquid order placement working while the WebSocket reconnects, instead of failing with `CLIENT_NOT_INITIALIZED` or a 3x leverage cap ([#TBD](https://github.com/MetaMask/core/pull/TBD)) + - 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, and asset-map rebuilds. + - `getMaxLeverage` no longer requires the WebSocket clients, so orders are validated against the market's maximum leverage rather than the conservative fallback. +- 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` ([#TBD](https://github.com/MetaMask/core/pull/TBD)) + ## [18.0.1] ### Fixed diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 1396298f145..7eda2196c00 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -1031,6 +1031,9 @@ export class PerpsController extends BaseController< #initializationPromise: Promise | 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 | null = null; @@ -2177,9 +2180,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 { + let notifyStarted = (): void => undefined; + const started = new Promise((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(). @@ -2691,15 +2718,19 @@ export class PerpsController extends BaseController< * @returns The active provider once initialization completes. */ async #getActiveProviderWhenReady(): Promise { + let awaitedLifecycleOperation = false; + let awaitedInitializationStart = false; while (true) { const pendingDisconnect = this.#disconnectOperationPromise; if (pendingDisconnect) { + awaitedLifecycleOperation = true; await pendingDisconnect; continue; } const pendingReinitialization = this.#reinitializationOperationPromise; if (pendingReinitialization) { + awaitedLifecycleOperation = true; await pendingReinitialization; continue; } @@ -2713,6 +2744,23 @@ 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 ( + awaitedLifecycleOperation && + !awaitedInitializationStart && + !this.isInitialized && + !pendingInitialization + ) { + awaitedInitializationStart = true; + await this.#waitForInitializationStart( + PERPS_CONSTANTS.ConnectionTimeoutMs, + ); + continue; + } + return this.getActiveProvider(); } } diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 5ddc0295f91..bf3b1733258 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -2136,7 +2136,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, @@ -2181,7 +2181,7 @@ export class HyperLiquidProvider implements PerpsProvider { */ async #isHyperliquidMultiSigAccount(userAddress: string): Promise { try { - const infoClient = this.#clientService.getInfoClient(); + const infoClient = this.#clientService.getInfoClient({ useHttp: true }); const signers = await infoClient.userToMultiSigSigners({ user: userAddress, }); @@ -2329,7 +2329,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({ @@ -2630,8 +2630,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) { @@ -3080,7 +3082,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(); @@ -3473,7 +3475,7 @@ export class HyperLiquidProvider implements PerpsProvider { } const lifecycleGeneration = this.#lifecycleGeneration; - const infoClient = this.#clientService.getInfoClient(); + const infoClient = this.#clientService.getInfoClient({ useHttp: true }); const spotMeta = await infoClient.spotMeta(); if ( @@ -3853,7 +3855,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) @@ -4591,7 +4593,7 @@ export class HyperLiquidProvider implements PerpsProvider { async #getBalanceForDex(params: { dex: string | null }): Promise { 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 } @@ -5257,7 +5259,7 @@ 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 }), @@ -8801,7 +8803,8 @@ export class HyperLiquidProvider implements PerpsProvider { dexName: string | null; }): Promise { const userAddress = await this.#walletService.getUserAddressWithDefault(); - return await this.#clientService.getInfoClient().frontendOpenOrders({ + const infoClient = this.#clientService.getInfoClient({ useHttp: true }); + return await infoClient.frontendOpenOrders({ user: userAddress, dex: params.dexName ?? undefined, }); @@ -14390,9 +14393,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); @@ -15427,7 +15431,7 @@ export class HyperLiquidProvider implements PerpsProvider { */ async #isReferralCodeReady(): Promise { 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); @@ -15477,7 +15481,7 @@ export class HyperLiquidProvider implements PerpsProvider { */ async #checkReferralSet(): Promise { 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 diff --git a/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts b/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts index 53b92a6a07a..80e05af1591 100644 --- a/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts @@ -15,7 +15,10 @@ import { jest.mock('@nktkas/hyperliquid', () => ({})); -import { PERPS_DISK_CACHE_MARKETS } from '../../src/constants/perpsConfig.js'; +import { + PERPS_CONSTANTS, + PERPS_DISK_CACHE_MARKETS, +} from '../../src/constants/perpsConfig.js'; import { PerpsController, getDefaultPerpsControllerState, @@ -1282,23 +1285,106 @@ describe('PerpsController', () => { return { success: true }; }); - const disconnectPromise = controller.disconnect(); - await disconnectStarted.promise; - const orderPromise = controller.placeOrder({ - symbol: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'market', + jest.useFakeTimers(); + try { + const disconnectPromise = controller.disconnect(); + await disconnectStarted.promise; + const orderPromise = controller.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }); + const orderRejection = expect(orderPromise).rejects.toThrow( + PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED, + ); + await Promise.resolve(); + + expect(mockTradingServiceInstance.placeOrder).not.toHaveBeenCalled(); + pendingDisconnect.resolve(); + await disconnectPromise; + // No init follows this disconnect: the order fails once the bounded + // wait for a reconnect runs out. + await jest.advanceTimersByTimeAsync( + PERPS_CONSTANTS.ConnectionTimeoutMs, + ); + await orderRejection; + expect(mockTradingServiceInstance.placeOrder).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); + + describe('TAT-4041: order submitted during a client reconnect', () => { + it('places an order submitted while a disconnect is followed by init', async () => { + await controller.init(); + const disconnectStarted = createDeferred(); + const pendingDisconnect = createDeferred(); + mockProvider.disconnect.mockImplementationOnce(async () => { + disconnectStarted.resolve(); + await pendingDisconnect.promise; + return { success: true }; + }); + jest + .spyOn(mockTradingServiceInstance, 'placeOrder') + .mockResolvedValue({ success: true, orderId: '123' }); + + // Clients reconnect with disconnect() then init(), with async work + // (for example a cleanup delay) between the two calls. + const reconnect = (async (): Promise => { + await controller.disconnect(); + await new Promise((resolve) => setTimeout(resolve, 50)); + await controller.init(); + })(); + await disconnectStarted.promise; + const orderPromise = controller.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }); + pendingDisconnect.resolve(); + await reconnect; + + await expect(orderPromise).resolves.toStrictEqual( + expect.objectContaining({ success: true, orderId: '123' }), + ); + expect(mockTradingServiceInstance.placeOrder).toHaveBeenCalledTimes(1); }); - await Promise.resolve(); - expect(mockTradingServiceInstance.placeOrder).not.toHaveBeenCalled(); - pendingDisconnect.resolve(); - await disconnectPromise; - await expect(orderPromise).rejects.toThrow( - PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED, - ); - expect(mockTradingServiceInstance.placeOrder).not.toHaveBeenCalled(); + it('bounds the wait for init after a disconnect', async () => { + await controller.init(); + jest.useFakeTimers(); + try { + const disconnectPromise = controller.disconnect(); + const orderPromise = controller.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }); + let settled = false; + orderPromise + .catch(() => undefined) + .finally(() => { + settled = true; + }); + await disconnectPromise; + + await jest.advanceTimersByTimeAsync( + PERPS_CONSTANTS.ConnectionTimeoutMs - 1, + ); + expect(settled).toBe(false); + + await jest.advanceTimersByTimeAsync(1); + await expect(orderPromise).rejects.toThrow( + PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED, + ); + expect(mockTradingServiceInstance.placeOrder).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); }); it('keeps init queued when disconnect starts during reinitialization', async () => { diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts index 7ae9ad787b2..6e2bf7e088f 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts @@ -5015,4 +5015,218 @@ describe('HyperLiquidProvider', () => { }); }); }); + describe('TAT-4041: order path while the WebSocket reconnects', () => { + // HyperLiquidClientService drops its WebSocket clients for the whole + // reconnect but keeps the HTTP exchange and info clients. Model that state: + // any WebSocket-backed access fails the way the real service does. + const simulateWebSocketReconnect = ( + httpInfoClient: ReturnType, + ): void => { + mockClientService.isInitialized.mockReturnValue(false); + mockClientService.ensureInitialized.mockImplementation(() => { + throw new Error(PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED); + }); + mockClientService.getInfoClient.mockImplementation(((options?: { + useHttp?: boolean; + }) => { + if (options?.useHttp) { + return httpInfoClient; + } + throw new Error(PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED); + }) as unknown as HyperLiquidClientService['getInfoClient']); + }; + + const expectOnlyHttpInfoReads = (): void => { + expect(mockClientService.getInfoClient).toHaveBeenCalled(); + mockClientService.getInfoClient.mock.calls.forEach(([options]) => { + expect(options).toStrictEqual({ useHttp: true }); + }); + }; + + it('places an order with an explicit margin mode over HTTP', async () => { + const twapHistory = jest.fn().mockResolvedValue([]); + const activeAssetData = jest + .fn() + .mockResolvedValue({ leverage: { type: 'isolated', value: 5 } }); + const httpInfoClient = createMockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ assetPositions: [] }), + frontendOpenOrders: jest.fn().mockResolvedValue([{ coin: 'BTC' }]), + twapHistory, + activeAssetData, + userToMultiSigSigners: jest.fn().mockResolvedValue(null), + }); + simulateWebSocketReconnect(httpInfoClient); + + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'limit', + price: '49000', + leverage: 5, + marginMode: 'isolated', + }); + + expect(result).toStrictEqual(expect.objectContaining({ success: true })); + expect(httpInfoClient.frontendOpenOrders).toHaveBeenCalled(); + expect(twapHistory).toHaveBeenCalled(); + expect(activeAssetData).toHaveBeenCalledWith({ + user: '0x1234567890123456789012345678901234567890', + coin: 'BTC', + }); + expect(mockClientService.getExchangeClient().order).toHaveBeenCalled(); + expectOnlyHttpInfoReads(); + }); + + it('validates leverage against the market maximum instead of the fallback', async () => { + const httpInfoClient = createMockInfoClient({ + userToMultiSigSigners: jest.fn().mockResolvedValue(null), + }); + simulateWebSocketReconnect(httpInfoClient); + + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + leverage: 20, + }); + + expect(result).toStrictEqual(expect.objectContaining({ success: true })); + expect( + mockClientService.getExchangeClient().updateLeverage, + ).toHaveBeenCalledWith({ asset: 0, isCross: false, leverage: 20 }); + expectOnlyHttpInfoReads(); + }); + + it('runs first-trade account setup over HTTP', async () => { + const userToMultiSigSigners = jest.fn().mockResolvedValue(null); + const httpInfoClient = createMockInfoClient({ + // A wallet still in the default mode runs the unified-account migration. + userAbstraction: jest.fn().mockResolvedValue('default'), + userToMultiSigSigners, + referral: jest.fn().mockResolvedValue({ + referrerState: { stage: 'ready', data: { code: 'MMCSI' } }, + referredBy: { code: 'MMCSI' }, + }), + }); + simulateWebSocketReconnect(httpInfoClient); + + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }); + + expect(result).toStrictEqual(expect.objectContaining({ success: true })); + expect(httpInfoClient.userAbstraction).toHaveBeenCalledWith({ + user: '0x1234567890123456789012345678901234567890', + }); + expect(userToMultiSigSigners).toHaveBeenCalledWith({ + user: '0x1234567890123456789012345678901234567890', + }); + expect(httpInfoClient.referral).toHaveBeenCalledWith({ + user: '0x1234567890123456789012345678901234567890', + }); + expect(mockClientService.getExchangeClient().order).toHaveBeenCalled(); + expectOnlyHttpInfoReads(); + }); + + it('places a HIP-3 order that needs a margin transfer over HTTP', async () => { + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + useUnifiedAccount: false, + initialAssetMapping: [ + ['BTC', 0], + ['xyz:STOCK1', 110000], + ], + }); + const accountState = (withdrawable: string): Record => ({ + marginSummary: { totalMarginUsed: '0', accountValue: withdrawable }, + withdrawable, + assetPositions: [], + crossMarginSummary: { + accountValue: withdrawable, + totalMarginUsed: '0', + }, + }); + const clearinghouseState = jest + .fn() + .mockImplementation((params?: { dex?: string }) => + Promise.resolve(accountState(params?.dex === 'xyz' ? '0' : '10000')), + ); + const xyzMeta = { + universe: [{ name: 'xyz:STOCK1', szDecimals: 2, maxLeverage: 20 }], + collateralToken: 0, + }; + const httpInfoClient = createMockInfoClient({ + clearinghouseState, + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + meta: jest.fn().mockImplementation((params?: { dex?: string }) => + Promise.resolve( + params?.dex === 'xyz' + ? xyzMeta + : { + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }, + ), + ), + metaAndAssetCtxs: jest + .fn() + .mockImplementation((params?: { dex?: string }) => + Promise.resolve( + params?.dex === 'xyz' + ? [xyzMeta, [{ markPx: '100', midPx: '100', oraclePx: '100' }]] + : [ + { + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + ], + }, + [{ markPx: '50000', midPx: '50000', oraclePx: '50000' }], + ], + ), + ), + allMids: jest + .fn() + .mockImplementation((params?: { dex?: string }) => + Promise.resolve( + params?.dex === 'xyz' + ? { 'xyz:STOCK1': '100' } + : { BTC: '50000' }, + ), + ), + userToMultiSigSigners: jest.fn().mockResolvedValue(null), + }); + simulateWebSocketReconnect(httpInfoClient); + + const result = await hip3Provider.placeOrder({ + symbol: 'xyz:STOCK1', + isBuy: true, + size: '1', + orderType: 'market', + leverage: 5, + }); + + expect(result).toStrictEqual(expect.objectContaining({ success: true })); + expect(clearinghouseState).toHaveBeenCalledWith({ + user: '0x1234567890123456789012345678901234567890', + dex: 'xyz', + }); + expect(httpInfoClient.spotMeta).toHaveBeenCalled(); + expect( + mockClientService.getExchangeClient().sendAsset, + ).toHaveBeenCalled(); + expect(mockClientService.getExchangeClient().order).toHaveBeenCalledWith( + expect.objectContaining({ + orders: [expect.objectContaining({ a: 110000 })], + }), + ); + expectOnlyHttpInfoReads(); + }); + }); }); From ecbc4e9be44c78eea489a67f9120966810d1de74 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 30 Sep 2026 02:23:12 +0800 Subject: [PATCH 2/5] fix: address self-review feedback (TAT-4041) --- packages/perps-controller/CHANGELOG.md | 3 +- .../perps-controller/src/PerpsController.ts | 29 ++++++++ .../src/providers/HyperLiquidProvider.ts | 2 +- .../src/PerpsController.lifecycle.test.ts | 73 +++++++++++++++++++ .../HyperLiquidProvider.trading.test.ts | 18 +++++ 5 files changed, 123 insertions(+), 2 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index fbc1c3942aa..d0c03ef02b6 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -19,9 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Keep HyperLiquid order placement working while the WebSocket reconnects, instead of failing with `CLIENT_NOT_INITIALIZED` or a 3x leverage cap ([#TBD](https://github.com/MetaMask/core/pull/TBD)) - - 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, and asset-map rebuilds. + - 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. - 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` ([#TBD](https://github.com/MetaMask/core/pull/TBD)) + - 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] diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 7eda2196c00..67964a733e4 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -2718,11 +2718,17 @@ export class PerpsController extends BaseController< * @returns The active provider once initialization completes. */ async #getActiveProviderWhenReady(): Promise { + // 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 awaitedLifecycleOperation = false; let awaitedInitializationStart = false; while (true) { const pendingDisconnect = this.#disconnectOperationPromise; if (pendingDisconnect) { + awaitedDisconnect = true; awaitedLifecycleOperation = true; await pendingDisconnect; continue; @@ -2761,10 +2767,33 @@ export class PerpsController extends BaseController< 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 { + let address: string | undefined; + try { + address = getSelectedEvmAccountFromMessenger(this.messenger)?.address; + } catch { + address = undefined; + } + 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 diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index bf3b1733258..efed80dc39a 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -9866,7 +9866,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(); diff --git a/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts b/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts index 80e05af1591..ad31088b8cb 100644 --- a/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts @@ -1385,6 +1385,79 @@ describe('PerpsController', () => { jest.useRealTimers(); } }); + + it.each([ + [ + 'account', + (): void => { + const call = mockMessenger.call as unknown as jest.Mock; + const baseCall = call.getMockImplementation(); + call.mockImplementation((action: string, ...args: unknown[]) => + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ? [ + { + address: '0x9999999999999999999999999999999999999999', + type: 'eip155:eoa', + id: 'account-2', + options: {}, + scopes: ['eip155:1'], + methods: [], + metadata: { + name: 'Other', + importTime: 0, + keyring: { type: 'HD Key Tree' }, + }, + }, + ] + : baseCall?.(action, ...args), + ); + }, + ], + [ + 'network', + (): void => { + controller.testUpdate((state) => { + state.isTestnet = !state.isTestnet; + }); + }, + ], + ])( + 'refuses an order parked across a reconnect that switched the %s', + async (_context, switchContext) => { + await controller.init(); + const disconnectStarted = createDeferred(); + const pendingDisconnect = createDeferred(); + mockProvider.disconnect.mockImplementationOnce(async () => { + disconnectStarted.resolve(); + await pendingDisconnect.promise; + return { success: true }; + }); + jest + .spyOn(mockTradingServiceInstance, 'placeOrder') + .mockResolvedValue({ success: true, orderId: '123' }); + + const reconnect = (async (): Promise => { + await controller.disconnect(); + switchContext(); + await controller.init(); + })(); + await disconnectStarted.promise; + const orderPromise = controller.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }); + pendingDisconnect.resolve(); + await reconnect; + + await expect(orderPromise).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + ); + expect(mockTradingServiceInstance.placeOrder).not.toHaveBeenCalled(); + }, + ); }); it('keeps init queued when disconnect starts during reinitialization', async () => { diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts index 6e2bf7e088f..c504fbd1760 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts @@ -5133,6 +5133,24 @@ describe('HyperLiquidProvider', () => { expectOnlyHttpInfoReads(); }); + it('updates position TP/SL over HTTP', async () => { + const httpInfoClient = createMockInfoClient({ + userToMultiSigSigners: jest.fn().mockResolvedValue(null), + }); + simulateWebSocketReconnect(httpInfoClient); + + const result = await provider.updatePositionTPSL({ + symbol: 'ETH', + takeProfitPrice: '3500', + stopLossPrice: '2500', + }); + + expect(result).toStrictEqual(expect.objectContaining({ success: true })); + expect(httpInfoClient.frontendOpenOrders).toHaveBeenCalled(); + expect(mockClientService.getExchangeClient().order).toHaveBeenCalled(); + expectOnlyHttpInfoReads(); + }); + it('places a HIP-3 order that needs a margin transfer over HTTP', async () => { const hip3Provider = createTestProvider({ hip3Enabled: true, From 9eb852d00c313df7072091aabba34a2b4a79806d Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 30 Sep 2026 02:33:42 +0800 Subject: [PATCH 3/5] fix: address self-review feedback (TAT-4041) Retry rate-limited pre-order HTTP reads with jittered backoff. --- packages/perps-controller/CHANGELOG.md | 1 + .../src/providers/HyperLiquidProvider.ts | 40 ++++++----- .../src/utils/rateLimitRetry.ts | 53 +++++++++++++++ .../HyperLiquidProvider.trading.test.ts | 42 ++++++++++++ .../tests/src/utils/rateLimitRetry.test.ts | 66 +++++++++++++++++++ 5 files changed, 186 insertions(+), 16 deletions(-) create mode 100644 packages/perps-controller/src/utils/rateLimitRetry.ts create mode 100644 packages/perps-controller/tests/src/utils/rateLimitRetry.test.ts diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index d0c03ef02b6..e4d8093752f 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Keep HyperLiquid order placement working while the WebSocket reconnects, instead of failing with `CLIENT_NOT_INITIALIZED` or a 3x leverage cap ([#TBD](https://github.com/MetaMask/core/pull/TBD)) - 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` ([#TBD](https://github.com/MetaMask/core/pull/TBD)) - 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. diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index efed80dc39a..22928706918 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -237,6 +237,7 @@ import { resolvePositionTriggerSummaryPrice, toSDKTimeInForce, } from '../utils/orderTypes.js'; +import { withRateLimitRetry } from '../utils/rateLimitRetry.js'; import { createStandaloneInfoClient, queryStandaloneClearinghouseStates, @@ -2889,8 +2890,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'); @@ -3239,7 +3240,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)) { @@ -3476,7 +3479,7 @@ export class HyperLiquidProvider implements PerpsProvider { const lifecycleGeneration = this.#lifecycleGeneration; const infoClient = this.#clientService.getInfoClient({ useHttp: true }); - const spotMeta = await infoClient.spotMeta(); + const spotMeta = await withRateLimitRetry(() => infoClient.spotMeta()); if ( this.#isCacheWriteLifecycleCurrent( @@ -4599,7 +4602,9 @@ export class HyperLiquidProvider implements PerpsProvider { ? { 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); } @@ -5262,7 +5267,7 @@ export class HyperLiquidProvider implements PerpsProvider { 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 @@ -5298,10 +5303,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' }; } @@ -8804,10 +8808,12 @@ export class HyperLiquidProvider implements PerpsProvider { }): Promise { const userAddress = await this.#walletService.getUserAddressWithDefault(); const infoClient = this.#clientService.getInfoClient({ useHttp: true }); - return await infoClient.frontendOpenOrders({ - user: userAddress, - dex: params.dexName ?? undefined, - }); + return await withRateLimitRetry(() => + infoClient.frontendOpenOrders({ + user: userAddress, + dex: params.dexName ?? undefined, + }), + ); } /** @@ -11185,8 +11191,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); diff --git a/packages/perps-controller/src/utils/rateLimitRetry.ts b/packages/perps-controller/src/utils/rateLimitRetry.ts new file mode 100644 index 00000000000..0ea8ea56cf7 --- /dev/null +++ b/packages/perps-controller/src/utils/rateLimitRetry.ts @@ -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( + read: () => Promise, +): Promise { + 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); + } + } +} diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts index c504fbd1760..cdf5990a3a2 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts @@ -5151,6 +5151,48 @@ describe('HyperLiquidProvider', () => { expectOnlyHttpInfoReads(); }); + it('retries a rate-limited pre-order read with backoff', async () => { + jest.spyOn(Math, 'random').mockReturnValue(0); + const rateLimited = Object.assign(new Error('429 Too Many Requests'), { + name: 'HttpRequestError', + response: { status: 429 }, + }); + const twapHistory = jest + .fn() + .mockRejectedValueOnce(rateLimited) + .mockResolvedValue([]); + const frontendOpenOrders = jest + .fn() + .mockRejectedValueOnce(rateLimited) + .mockResolvedValue([]); + const clearinghouseState = jest + .fn() + .mockRejectedValueOnce(rateLimited) + .mockResolvedValue({ assetPositions: [] }); + const httpInfoClient = createMockInfoClient({ + clearinghouseState, + frontendOpenOrders, + twapHistory, + userToMultiSigSigners: jest.fn().mockResolvedValue(null), + }); + simulateWebSocketReconnect(httpInfoClient); + + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'limit', + price: '49000', + leverage: 5, + marginMode: 'isolated', + }); + + expect(result).toStrictEqual(expect.objectContaining({ success: true })); + expect(twapHistory).toHaveBeenCalledTimes(2); + expect(frontendOpenOrders).toHaveBeenCalledTimes(2); + expect(mockClientService.getExchangeClient().order).toHaveBeenCalled(); + }); + it('places a HIP-3 order that needs a margin transfer over HTTP', async () => { const hip3Provider = createTestProvider({ hip3Enabled: true, diff --git a/packages/perps-controller/tests/src/utils/rateLimitRetry.test.ts b/packages/perps-controller/tests/src/utils/rateLimitRetry.test.ts new file mode 100644 index 00000000000..4c0300729d7 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/rateLimitRetry.test.ts @@ -0,0 +1,66 @@ +import { + isRateLimitError, + withRateLimitRetry, +} from '../../../src/utils/rateLimitRetry.js'; +import { wait } from '../../../src/utils/wait.js'; + +jest.mock('../../../src/utils/wait', () => ({ + wait: jest.fn().mockResolvedValue(undefined), +})); + +const rateLimited = Object.assign(new Error('429 Too Many Requests'), { + response: { status: 429 }, +}); + +describe('isRateLimitError', () => { + it.each([ + ['an SDK error with a 429 response', rateLimited], + ['a 429 message without a response', new Error('429 client error')], + ['a Too Many Requests message', new Error('Too Many Requests')], + ])('recognizes %s', (_label, error) => { + expect(isRateLimitError(error)).toBe(true); + }); + + it.each([ + ['another HTTP status', { response: { status: 500 }, message: '500' }], + ['a network error', new Error('The operation was aborted.')], + ['a value that is not an error', undefined], + ])('ignores %s', (_label, error) => { + expect(isRateLimitError(error)).toBe(false); + }); +}); + +describe('withRateLimitRetry', () => { + beforeEach(() => { + jest.mocked(wait).mockResolvedValue(undefined); + jest.spyOn(Math, 'random').mockReturnValue(0.5); + }); + + it('retries a rate-limited read with jittered exponential backoff', async () => { + const read = jest + .fn() + .mockRejectedValueOnce(rateLimited) + .mockRejectedValueOnce(rateLimited) + .mockResolvedValue('ok'); + + expect(await withRateLimitRetry(read)).toBe('ok'); + expect(read).toHaveBeenCalledTimes(3); + expect(jest.mocked(wait).mock.calls).toStrictEqual([[250], [500]]); + }); + + it('rethrows the 429 once the retries are spent', async () => { + const read = jest.fn().mockRejectedValue(rateLimited); + + await expect(withRateLimitRetry(read)).rejects.toBe(rateLimited); + expect(read).toHaveBeenCalledTimes(3); + }); + + it('does not retry other errors', async () => { + const failure = new Error('offline'); + const read = jest.fn().mockRejectedValue(failure); + + await expect(withRateLimitRetry(read)).rejects.toBe(failure); + expect(read).toHaveBeenCalledTimes(1); + expect(wait).not.toHaveBeenCalled(); + }); +}); From 914904223d0fb09f5a27ab5cd2991330810eb002 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 30 Sep 2026 08:26:49 +0800 Subject: [PATCH 4/5] fix(perps): narrow TAT-4041 to the controller reconnect wait Keep only the controller change: an action waiting on disconnect() waits a bounded time for the client's follow-up init(), and fails with PROVIDER_LIFECYCLE_STALE if the account, network or provider changed. The HTTP order-path reads, the 429 retry and the getMaxLeverage change move to a stacked follow-up so the transport choice can be validated separately. Refs: TAT-4041 --- packages/perps-controller/CHANGELOG.md | 6 +- .../perps-controller/src/PerpsController.ts | 12 +- .../src/providers/HyperLiquidProvider.ts | 74 ++--- .../src/utils/rateLimitRetry.ts | 53 ---- .../HyperLiquidProvider.trading.test.ts | 274 ------------------ .../tests/src/utils/rateLimitRetry.test.ts | 66 ----- 6 files changed, 34 insertions(+), 451 deletions(-) delete mode 100644 packages/perps-controller/src/utils/rateLimitRetry.ts delete mode 100644 packages/perps-controller/tests/src/utils/rateLimitRetry.test.ts diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index e4d8093752f..399b0ecc7f7 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -18,11 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Keep HyperLiquid order placement working while the WebSocket reconnects, instead of failing with `CLIENT_NOT_INITIALIZED` or a 3x leverage cap ([#TBD](https://github.com/MetaMask/core/pull/TBD)) - - 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` ([#TBD](https://github.com/MetaMask/core/pull/TBD)) +- 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] diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 67964a733e4..f8c6519bad6 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -2723,20 +2723,17 @@ export class PerpsController extends BaseController< // not carry the action into the new context. const issuedContext = this.#getActionContext(); let awaitedDisconnect = false; - let awaitedLifecycleOperation = false; let awaitedInitializationStart = false; while (true) { const pendingDisconnect = this.#disconnectOperationPromise; if (pendingDisconnect) { awaitedDisconnect = true; - awaitedLifecycleOperation = true; await pendingDisconnect; continue; } const pendingReinitialization = this.#reinitializationOperationPromise; if (pendingReinitialization) { - awaitedLifecycleOperation = true; await pendingReinitialization; continue; } @@ -2755,7 +2752,7 @@ export class PerpsController extends BaseController< // window to start rather than failing an action the reconnect will // serve. Nothing here starts a connection the client did not ask for. if ( - awaitedLifecycleOperation && + awaitedDisconnect && !awaitedInitializationStart && !this.isInitialized && !pendingInitialization @@ -2781,12 +2778,7 @@ export class PerpsController extends BaseController< * @returns A key that changes when any of them changes. */ #getActionContext(): string { - let address: string | undefined; - try { - address = getSelectedEvmAccountFromMessenger(this.messenger)?.address; - } catch { - address = undefined; - } + const address = getSelectedEvmAccountFromMessenger(this.messenger)?.address; return [ address?.toLowerCase() ?? '', this.state.isTestnet ? 'testnet' : 'mainnet', diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 22928706918..5ddc0295f91 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -237,7 +237,6 @@ import { resolvePositionTriggerSummaryPrice, toSDKTimeInForce, } from '../utils/orderTypes.js'; -import { withRateLimitRetry } from '../utils/rateLimitRetry.js'; import { createStandaloneInfoClient, queryStandaloneClearinghouseStates, @@ -2137,7 +2136,7 @@ export class HyperLiquidProvider implements PerpsProvider { } try { - const infoClient = this.#clientService.getInfoClient({ useHttp: true }); + const infoClient = this.#clientService.getInfoClient(); const ledger = await infoClient.userNonFundingLedgerUpdates({ user: userAddress, startTime: 0, @@ -2182,7 +2181,7 @@ export class HyperLiquidProvider implements PerpsProvider { */ async #isHyperliquidMultiSigAccount(userAddress: string): Promise { try { - const infoClient = this.#clientService.getInfoClient({ useHttp: true }); + const infoClient = this.#clientService.getInfoClient(); const signers = await infoClient.userToMultiSigSigners({ user: userAddress, }); @@ -2330,7 +2329,7 @@ export class HyperLiquidProvider implements PerpsProvider { return; } - const infoClient = this.#clientService.getInfoClient({ useHttp: true }); + const infoClient = this.#clientService.getInfoClient(); // Check current abstraction mode on-chain currentMode = await infoClient.userAbstraction({ @@ -2631,10 +2630,8 @@ export class HyperLiquidProvider implements PerpsProvider { // This awaits WebSocket transport.ready() to ensure connection is established await this.#ensureClientsInitialized(); - // 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 }); + // Verify clients are properly initialized + this.#clientService.ensureInitialized(); // Build asset mapping on first call, or retry if DEX discovery previously failed if (this.#symbolToAssetId.size === 0 || !this.#dexDiscoveryComplete) { @@ -2890,8 +2887,8 @@ export class HyperLiquidProvider implements PerpsProvider { { symbol }, ); const infoClient = this.#clientService.getInfoClient({ useHttp: true }); - const mids = await withRateLimitRetry(() => - infoClient.allMids(dexName ? { dex: dexName } : undefined), + const mids = await infoClient.allMids( + dexName ? { dex: dexName } : undefined, ); const price = parseFloat(mids[symbol] || '0'); @@ -3083,7 +3080,7 @@ export class HyperLiquidProvider implements PerpsProvider { } // Fetch all available DEXs from HyperLiquid - const infoClient = this.#clientService.getInfoClient({ useHttp: true }); + const infoClient = this.#clientService.getInfoClient(); let allDexs; try { allDexs = await infoClient.perpDexs(); @@ -3240,9 +3237,7 @@ 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 withRateLimitRetry(() => - infoClient.meta(dexName ? { dex: dexName } : undefined), - ); + const meta = await infoClient.meta(dexName ? { dex: dexName } : undefined); // Defensive validation before caching if (!meta?.universe || !Array.isArray(meta.universe)) { @@ -3478,8 +3473,8 @@ export class HyperLiquidProvider implements PerpsProvider { } const lifecycleGeneration = this.#lifecycleGeneration; - const infoClient = this.#clientService.getInfoClient({ useHttp: true }); - const spotMeta = await withRateLimitRetry(() => infoClient.spotMeta()); + const infoClient = this.#clientService.getInfoClient(); + const spotMeta = await infoClient.spotMeta(); if ( this.#isCacheWriteLifecycleCurrent( @@ -3858,7 +3853,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({ useHttp: true }); + const infoClient = this.#clientService.getInfoClient(); const allMetas = await Promise.allSettled( dexsToMap.map((dex) => { // Check if already cached (e.g., by getMarketDataWithPrices running in parallel) @@ -4596,15 +4591,13 @@ export class HyperLiquidProvider implements PerpsProvider { async #getBalanceForDex(params: { dex: string | null }): Promise { const { dex } = params; const userAddress = await this.#walletService.getUserAddressWithDefault(); - const infoClient = this.#clientService.getInfoClient({ useHttp: true }); + const infoClient = this.#clientService.getInfoClient(); const queryParams = dex ? { user: userAddress, dex } : { user: userAddress }; - const accountState = await withRateLimitRetry(() => - infoClient.clearinghouseState(queryParams), - ); + const accountState = await infoClient.clearinghouseState(queryParams); const adapted = adaptAccountStateFromSDK(accountState); return parseFloat(adapted.withdrawableBalance); } @@ -5264,10 +5257,10 @@ export class HyperLiquidProvider implements PerpsProvider { if (position) { return { marginMode: position.leverage.type, reason: 'position' }; } - const infoClient = this.#clientService.getInfoClient({ useHttp: true }); + const infoClient = this.#clientService.getInfoClient(); const [orders, twapHistory] = await Promise.all([ this.#fetchOpenOrders({ dexName }), - withRateLimitRetry(() => infoClient.twapHistory({ user })), + infoClient.twapHistory({ user }), ]); await assertSameAccount(); // Native TWAP schedules are absent from frontendOpenOrders before a slice @@ -5303,9 +5296,10 @@ export class HyperLiquidProvider implements PerpsProvider { } }); if (orders.some((order) => order.coin === symbol) || hasActiveTwap) { - const asset = await withRateLimitRetry(() => - infoClient.activeAssetData({ user, coin: symbol }), - ); + const asset = await infoClient.activeAssetData({ + user, + coin: symbol, + }); await assertSameAccount(); return { marginMode: asset.leverage.type, reason: 'open_order' }; } @@ -8807,13 +8801,10 @@ export class HyperLiquidProvider implements PerpsProvider { dexName: string | null; }): Promise { const userAddress = await this.#walletService.getUserAddressWithDefault(); - const infoClient = this.#clientService.getInfoClient({ useHttp: true }); - return await withRateLimitRetry(() => - infoClient.frontendOpenOrders({ - user: userAddress, - dex: params.dexName ?? undefined, - }), - ); + return await this.#clientService.getInfoClient().frontendOpenOrders({ + user: userAddress, + dex: params.dexName ?? undefined, + }); } /** @@ -9872,7 +9863,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({ useHttp: true }); + const infoClient = this.#clientService.getInfoClient(); const exchangeClient = this.#clientService.getExchangeClient(); const userAddress = await this.#walletService.getUserAddressWithDefault(); @@ -11191,10 +11182,8 @@ export class HyperLiquidProvider implements PerpsProvider { await this.#ensureClientsInitialized(); const infoClient = this.#clientService.getInfoClient({ useHttp: true }); const userAddress = await this.#walletService.getUserAddressWithDefault(); - const state = await withRateLimitRetry(() => - infoClient.clearinghouseState( - dexName ? { user: userAddress, dex: dexName } : { user: userAddress }, - ), + const state = await infoClient.clearinghouseState( + dexName ? { user: userAddress, dex: dexName } : { user: userAddress }, ); if (!Array.isArray(state.assetPositions)) { throw new Error(PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE); @@ -14401,10 +14390,9 @@ 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). - // Metadata is read over HTTP, so a WebSocket reconnect must not force the - // conservative default leverage onto an order. + // (no DEX abstraction, referral, or builder fee needed for metadata) await this.#ensureClientsInitialized(); + this.#clientService.ensureInitialized(); // Extract DEX name for API calls (main DEX = null) const { dex: dexName } = parseAssetName(asset); @@ -15439,7 +15427,7 @@ export class HyperLiquidProvider implements PerpsProvider { */ async #isReferralCodeReady(): Promise { try { - const infoClient = this.#clientService.getInfoClient({ useHttp: true }); + const infoClient = this.#clientService.getInfoClient(); const isTestnet = this.#clientService.isTestnetMode(); const code = this.#getReferralCode(isTestnet); const referrerAddr = this.#getBuilderAddress(isTestnet); @@ -15489,7 +15477,7 @@ export class HyperLiquidProvider implements PerpsProvider { */ async #checkReferralSet(): Promise { try { - const infoClient = this.#clientService.getInfoClient({ useHttp: true }); + const infoClient = this.#clientService.getInfoClient(); const userAddress = await this.#walletService.getUserAddressWithDefault(); // Call HyperLiquid API to check if user has a referral set diff --git a/packages/perps-controller/src/utils/rateLimitRetry.ts b/packages/perps-controller/src/utils/rateLimitRetry.ts deleted file mode 100644 index 0ea8ea56cf7..00000000000 --- a/packages/perps-controller/src/utils/rateLimitRetry.ts +++ /dev/null @@ -1,53 +0,0 @@ -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( - read: () => Promise, -): Promise { - 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); - } - } -} diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts index cdf5990a3a2..7ae9ad787b2 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts @@ -5015,278 +5015,4 @@ describe('HyperLiquidProvider', () => { }); }); }); - describe('TAT-4041: order path while the WebSocket reconnects', () => { - // HyperLiquidClientService drops its WebSocket clients for the whole - // reconnect but keeps the HTTP exchange and info clients. Model that state: - // any WebSocket-backed access fails the way the real service does. - const simulateWebSocketReconnect = ( - httpInfoClient: ReturnType, - ): void => { - mockClientService.isInitialized.mockReturnValue(false); - mockClientService.ensureInitialized.mockImplementation(() => { - throw new Error(PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED); - }); - mockClientService.getInfoClient.mockImplementation(((options?: { - useHttp?: boolean; - }) => { - if (options?.useHttp) { - return httpInfoClient; - } - throw new Error(PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED); - }) as unknown as HyperLiquidClientService['getInfoClient']); - }; - - const expectOnlyHttpInfoReads = (): void => { - expect(mockClientService.getInfoClient).toHaveBeenCalled(); - mockClientService.getInfoClient.mock.calls.forEach(([options]) => { - expect(options).toStrictEqual({ useHttp: true }); - }); - }; - - it('places an order with an explicit margin mode over HTTP', async () => { - const twapHistory = jest.fn().mockResolvedValue([]); - const activeAssetData = jest - .fn() - .mockResolvedValue({ leverage: { type: 'isolated', value: 5 } }); - const httpInfoClient = createMockInfoClient({ - clearinghouseState: jest.fn().mockResolvedValue({ assetPositions: [] }), - frontendOpenOrders: jest.fn().mockResolvedValue([{ coin: 'BTC' }]), - twapHistory, - activeAssetData, - userToMultiSigSigners: jest.fn().mockResolvedValue(null), - }); - simulateWebSocketReconnect(httpInfoClient); - - const result = await provider.placeOrder({ - symbol: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'limit', - price: '49000', - leverage: 5, - marginMode: 'isolated', - }); - - expect(result).toStrictEqual(expect.objectContaining({ success: true })); - expect(httpInfoClient.frontendOpenOrders).toHaveBeenCalled(); - expect(twapHistory).toHaveBeenCalled(); - expect(activeAssetData).toHaveBeenCalledWith({ - user: '0x1234567890123456789012345678901234567890', - coin: 'BTC', - }); - expect(mockClientService.getExchangeClient().order).toHaveBeenCalled(); - expectOnlyHttpInfoReads(); - }); - - it('validates leverage against the market maximum instead of the fallback', async () => { - const httpInfoClient = createMockInfoClient({ - userToMultiSigSigners: jest.fn().mockResolvedValue(null), - }); - simulateWebSocketReconnect(httpInfoClient); - - const result = await provider.placeOrder({ - symbol: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'market', - leverage: 20, - }); - - expect(result).toStrictEqual(expect.objectContaining({ success: true })); - expect( - mockClientService.getExchangeClient().updateLeverage, - ).toHaveBeenCalledWith({ asset: 0, isCross: false, leverage: 20 }); - expectOnlyHttpInfoReads(); - }); - - it('runs first-trade account setup over HTTP', async () => { - const userToMultiSigSigners = jest.fn().mockResolvedValue(null); - const httpInfoClient = createMockInfoClient({ - // A wallet still in the default mode runs the unified-account migration. - userAbstraction: jest.fn().mockResolvedValue('default'), - userToMultiSigSigners, - referral: jest.fn().mockResolvedValue({ - referrerState: { stage: 'ready', data: { code: 'MMCSI' } }, - referredBy: { code: 'MMCSI' }, - }), - }); - simulateWebSocketReconnect(httpInfoClient); - - const result = await provider.placeOrder({ - symbol: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'market', - }); - - expect(result).toStrictEqual(expect.objectContaining({ success: true })); - expect(httpInfoClient.userAbstraction).toHaveBeenCalledWith({ - user: '0x1234567890123456789012345678901234567890', - }); - expect(userToMultiSigSigners).toHaveBeenCalledWith({ - user: '0x1234567890123456789012345678901234567890', - }); - expect(httpInfoClient.referral).toHaveBeenCalledWith({ - user: '0x1234567890123456789012345678901234567890', - }); - expect(mockClientService.getExchangeClient().order).toHaveBeenCalled(); - expectOnlyHttpInfoReads(); - }); - - it('updates position TP/SL over HTTP', async () => { - const httpInfoClient = createMockInfoClient({ - userToMultiSigSigners: jest.fn().mockResolvedValue(null), - }); - simulateWebSocketReconnect(httpInfoClient); - - const result = await provider.updatePositionTPSL({ - symbol: 'ETH', - takeProfitPrice: '3500', - stopLossPrice: '2500', - }); - - expect(result).toStrictEqual(expect.objectContaining({ success: true })); - expect(httpInfoClient.frontendOpenOrders).toHaveBeenCalled(); - expect(mockClientService.getExchangeClient().order).toHaveBeenCalled(); - expectOnlyHttpInfoReads(); - }); - - it('retries a rate-limited pre-order read with backoff', async () => { - jest.spyOn(Math, 'random').mockReturnValue(0); - const rateLimited = Object.assign(new Error('429 Too Many Requests'), { - name: 'HttpRequestError', - response: { status: 429 }, - }); - const twapHistory = jest - .fn() - .mockRejectedValueOnce(rateLimited) - .mockResolvedValue([]); - const frontendOpenOrders = jest - .fn() - .mockRejectedValueOnce(rateLimited) - .mockResolvedValue([]); - const clearinghouseState = jest - .fn() - .mockRejectedValueOnce(rateLimited) - .mockResolvedValue({ assetPositions: [] }); - const httpInfoClient = createMockInfoClient({ - clearinghouseState, - frontendOpenOrders, - twapHistory, - userToMultiSigSigners: jest.fn().mockResolvedValue(null), - }); - simulateWebSocketReconnect(httpInfoClient); - - const result = await provider.placeOrder({ - symbol: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'limit', - price: '49000', - leverage: 5, - marginMode: 'isolated', - }); - - expect(result).toStrictEqual(expect.objectContaining({ success: true })); - expect(twapHistory).toHaveBeenCalledTimes(2); - expect(frontendOpenOrders).toHaveBeenCalledTimes(2); - expect(mockClientService.getExchangeClient().order).toHaveBeenCalled(); - }); - - it('places a HIP-3 order that needs a margin transfer over HTTP', async () => { - const hip3Provider = createTestProvider({ - hip3Enabled: true, - allowlistMarkets: ['xyz:*'], - useUnifiedAccount: false, - initialAssetMapping: [ - ['BTC', 0], - ['xyz:STOCK1', 110000], - ], - }); - const accountState = (withdrawable: string): Record => ({ - marginSummary: { totalMarginUsed: '0', accountValue: withdrawable }, - withdrawable, - assetPositions: [], - crossMarginSummary: { - accountValue: withdrawable, - totalMarginUsed: '0', - }, - }); - const clearinghouseState = jest - .fn() - .mockImplementation((params?: { dex?: string }) => - Promise.resolve(accountState(params?.dex === 'xyz' ? '0' : '10000')), - ); - const xyzMeta = { - universe: [{ name: 'xyz:STOCK1', szDecimals: 2, maxLeverage: 20 }], - collateralToken: 0, - }; - const httpInfoClient = createMockInfoClient({ - clearinghouseState, - perpDexs: jest - .fn() - .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), - meta: jest.fn().mockImplementation((params?: { dex?: string }) => - Promise.resolve( - params?.dex === 'xyz' - ? xyzMeta - : { - universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], - }, - ), - ), - metaAndAssetCtxs: jest - .fn() - .mockImplementation((params?: { dex?: string }) => - Promise.resolve( - params?.dex === 'xyz' - ? [xyzMeta, [{ markPx: '100', midPx: '100', oraclePx: '100' }]] - : [ - { - universe: [ - { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, - ], - }, - [{ markPx: '50000', midPx: '50000', oraclePx: '50000' }], - ], - ), - ), - allMids: jest - .fn() - .mockImplementation((params?: { dex?: string }) => - Promise.resolve( - params?.dex === 'xyz' - ? { 'xyz:STOCK1': '100' } - : { BTC: '50000' }, - ), - ), - userToMultiSigSigners: jest.fn().mockResolvedValue(null), - }); - simulateWebSocketReconnect(httpInfoClient); - - const result = await hip3Provider.placeOrder({ - symbol: 'xyz:STOCK1', - isBuy: true, - size: '1', - orderType: 'market', - leverage: 5, - }); - - expect(result).toStrictEqual(expect.objectContaining({ success: true })); - expect(clearinghouseState).toHaveBeenCalledWith({ - user: '0x1234567890123456789012345678901234567890', - dex: 'xyz', - }); - expect(httpInfoClient.spotMeta).toHaveBeenCalled(); - expect( - mockClientService.getExchangeClient().sendAsset, - ).toHaveBeenCalled(); - expect(mockClientService.getExchangeClient().order).toHaveBeenCalledWith( - expect.objectContaining({ - orders: [expect.objectContaining({ a: 110000 })], - }), - ); - expectOnlyHttpInfoReads(); - }); - }); }); diff --git a/packages/perps-controller/tests/src/utils/rateLimitRetry.test.ts b/packages/perps-controller/tests/src/utils/rateLimitRetry.test.ts deleted file mode 100644 index 4c0300729d7..00000000000 --- a/packages/perps-controller/tests/src/utils/rateLimitRetry.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { - isRateLimitError, - withRateLimitRetry, -} from '../../../src/utils/rateLimitRetry.js'; -import { wait } from '../../../src/utils/wait.js'; - -jest.mock('../../../src/utils/wait', () => ({ - wait: jest.fn().mockResolvedValue(undefined), -})); - -const rateLimited = Object.assign(new Error('429 Too Many Requests'), { - response: { status: 429 }, -}); - -describe('isRateLimitError', () => { - it.each([ - ['an SDK error with a 429 response', rateLimited], - ['a 429 message without a response', new Error('429 client error')], - ['a Too Many Requests message', new Error('Too Many Requests')], - ])('recognizes %s', (_label, error) => { - expect(isRateLimitError(error)).toBe(true); - }); - - it.each([ - ['another HTTP status', { response: { status: 500 }, message: '500' }], - ['a network error', new Error('The operation was aborted.')], - ['a value that is not an error', undefined], - ])('ignores %s', (_label, error) => { - expect(isRateLimitError(error)).toBe(false); - }); -}); - -describe('withRateLimitRetry', () => { - beforeEach(() => { - jest.mocked(wait).mockResolvedValue(undefined); - jest.spyOn(Math, 'random').mockReturnValue(0.5); - }); - - it('retries a rate-limited read with jittered exponential backoff', async () => { - const read = jest - .fn() - .mockRejectedValueOnce(rateLimited) - .mockRejectedValueOnce(rateLimited) - .mockResolvedValue('ok'); - - expect(await withRateLimitRetry(read)).toBe('ok'); - expect(read).toHaveBeenCalledTimes(3); - expect(jest.mocked(wait).mock.calls).toStrictEqual([[250], [500]]); - }); - - it('rethrows the 429 once the retries are spent', async () => { - const read = jest.fn().mockRejectedValue(rateLimited); - - await expect(withRateLimitRetry(read)).rejects.toBe(rateLimited); - expect(read).toHaveBeenCalledTimes(3); - }); - - it('does not retry other errors', async () => { - const failure = new Error('offline'); - const read = jest.fn().mockRejectedValue(failure); - - await expect(withRateLimitRetry(read)).rejects.toBe(failure); - expect(read).toHaveBeenCalledTimes(1); - expect(wait).not.toHaveBeenCalled(); - }); -}); From d9ad4e210d6b3e20e238aa46a44a0f1a8fe525b5 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 30 Sep 2026 08:34:29 +0800 Subject: [PATCH 5/5] chore(perps): merge changelog entry into the Unreleased Fixed section --- packages/perps-controller/CHANGELOG.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index c2af49b95e8..adda3d3675d 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -51,9 +51,6 @@ 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)) - -### Fixed - - 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.