From 4649d2ee026dfaad9b09fa478ca4da4250f8c3f8 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Sat, 1 Aug 2026 21:02:35 +0800 Subject: [PATCH 1/5] fix(perps): hydrate cancel readiness and map account-mode exchange errors Reorder cancelOrder to rebuild the asset map before coin validation so service-worker restarts no longer surface ORDER_UNKNOWN_COIN for valid markets, and map HyperLiquid multi-sig/nonce rejections to stable codes. Co-authored-by: Cursor --- packages/perps-controller/CHANGELOG.md | 3 + .../perps-controller/src/perpsErrorCodes.ts | 4 + .../src/providers/HyperLiquidProvider.ts | 81 +++++++++++++++---- .../HyperLiquidSubscriptionService.ts | 12 +++ ...HyperLiquidProvider.error-handling.test.ts | 76 +++++++++++++++++ .../HyperLiquidProvider.trading.test.ts | 52 ++++++++++++ 6 files changed, 214 insertions(+), 14 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index ca168a87c7..fa53fcd73c 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Add `EXCHANGE_ACCOUNT_NOT_FOUND` to `PERPS_ERROR_CODES`, returned by `HyperLiquidProvider.placeOrder` when the wallet has no HyperLiquid account yet (TAT-3343) ([#9709](https://github.com/MetaMask/core/pull/9709)) - This widens the exported `PerpsErrorCode` union, so consumers that key an exhaustive `Record` stop compiling until they add an entry for the new code. Both first-party clients do: Mobile's `app/components/UI/Perps/utils/translatePerpsError.ts` and Extension's `ui/components/app/perps/utils/translate-perps-error.ts`. - To migrate: add a translation entry for `EXCHANGE_ACCOUNT_NOT_FOUND`. It signals that the wallet has no HyperLiquid account yet, so the message should direct the user to fund the account before trading. +- Add `EXCHANGE_MULTI_SIG_REQUIRED` and `EXCHANGE_INVALID_NONCE` to `PERPS_ERROR_CODES` for HyperLiquid exchange rejections that previously surfaced as raw `"multi-sig required"` / `"invalid nonce"` strings (TAT-3633) ### Changed @@ -51,6 +52,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Hydrate trading readiness before coin validation in `HyperLiquidProvider.cancelOrder`, so a cold start with an empty prefetch asset map self-heals instead of returning `ORDER_UNKNOWN_COIN` for valid markets (TAT-3633) +- Map HyperLiquid `"multi-sig required"` and `"invalid nonce"` exchange rejections to `EXCHANGE_MULTI_SIG_REQUIRED` and `EXCHANGE_INVALID_NONCE`, attaching cached abstraction mode to account-mode error log context (TAT-3633) - **BREAKING:** `OrderParams.timeInForce` now controls the HyperLiquid time-in-force for plain limit orders instead of being ignored; `GTC`, `IOC`, and post-only `ALO` map to their corresponding SDK values ([#9674](https://github.com/MetaMask/core/pull/9674)) - Order shapes that cannot carry a time in force — market orders and trigger placements, whose execution is decided when they fire — now reject it with `ORDER_TIME_IN_FORCE_NOT_SUPPORTED`, where previously the field was accepted and ignored for every order type. Callers passing `timeInForce` on anything other than a `limit` order must drop it. - The rejection happens in `validateOrderParams`, before `placeOrder` changes leverage on-chain or moves margin to a HIP-3 DEX, so a rejected order leaves no side effects behind. diff --git a/packages/perps-controller/src/perpsErrorCodes.ts b/packages/perps-controller/src/perpsErrorCodes.ts index a8ef9aecd5..6f0344ce67 100644 --- a/packages/perps-controller/src/perpsErrorCodes.ts +++ b/packages/perps-controller/src/perpsErrorCodes.ts @@ -68,6 +68,10 @@ export const PERPS_ERROR_CODES = { // server-side on the first USDC credit). Actionable: the user must fund the // account before any order can be placed. EXCHANGE_ACCOUNT_NOT_FOUND: 'EXCHANGE_ACCOUNT_NOT_FOUND', + // HyperLiquid exchange rejects agent-signed writes for multi-sig accounts + // without a multi-sig wrapper, or when the action nonce is stale/reused. + EXCHANGE_MULTI_SIG_REQUIRED: 'EXCHANGE_MULTI_SIG_REQUIRED', + EXCHANGE_INVALID_NONCE: 'EXCHANGE_INVALID_NONCE', // Transfer/swap errors TRANSFER_FAILED: 'TRANSFER_FAILED', SWAP_FAILED: 'SWAP_FAILED', diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 0ab544fb19..f3c526ca0b 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -511,6 +511,8 @@ export class HyperLiquidProvider implements PerpsProvider { 'isolated position does not have sufficient margin available to decrease leverage': PERPS_ERROR_CODES.ORDER_LEVERAGE_REDUCTION_FAILED, 'could not immediately match': PERPS_ERROR_CODES.IOC_CANCEL, + 'multi-sig required': PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, + 'invalid nonce': PERPS_ERROR_CODES.EXCHANGE_INVALID_NONCE, }; // Track whether clients have been initialized (lazy initialization) @@ -2393,6 +2395,39 @@ export class HyperLiquidProvider implements PerpsProvider { }; } + #isMappedAccountModeExchangeError(error: Error): boolean { + return ( + error.message === PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED || + error.message === PERPS_ERROR_CODES.EXCHANGE_INVALID_NONCE + ); + } + + async #getTradingErrorContext( + method: string, + error: Error, + extra?: Record, + ): Promise<{ + tags?: Record; + context?: { name: string; data: Record }; + extras?: Record; + }> { + const contextExtra = { ...extra }; + if (this.#isMappedAccountModeExchangeError(error)) { + try { + const userAddress = + await this.#walletService.getUserAddressWithDefault(); + const abstractionMode = + this.#subscriptionService.getCachedAbstractionMode(userAddress); + if (abstractionMode) { + contextExtra[PERPS_EVENT_PROPERTY.ABSTRACTION_MODE] = abstractionMode; + } + } catch { + // Best-effort context enrichment only. + } + } + return this.#getErrorContext(method, contextExtra); + } + /** * Get supported deposit routes with complete asset and routing information * @@ -3441,6 +3476,7 @@ export class HyperLiquidProvider implements PerpsProvider { */ #handleOrderError(params: HandleOrderErrorParams): OrderResult { const { error, symbol, orderType, isBuy } = params; + const mappedError = this.#mapError(error); // A wallet with no Hyperliquid account is an expected pre-account state, // not an app defect — same policy already applied to every other @@ -3453,17 +3489,32 @@ export class HyperLiquidProvider implements PerpsProvider { { symbol, orderType, isBuy }, ); } else { + const contextExtra: Record = { + symbol, + orderType, + isBuy, + }; + if (this.#isMappedAccountModeExchangeError(mappedError)) { + try { + const userAddress = this.#walletService.getUserAddress(); + if (userAddress) { + const abstractionMode = + this.#subscriptionService.getCachedAbstractionMode(userAddress); + if (abstractionMode) { + contextExtra[PERPS_EVENT_PROPERTY.ABSTRACTION_MODE] = + abstractionMode; + } + } + } catch { + // Best-effort context enrichment only. + } + } this.#deps.logger.error( - ensureError(error, 'HyperLiquidProvider.handleOrderError'), - this.#getErrorContext('placeOrder', { - symbol, - orderType, - isBuy, - }), + mappedError, + this.#getErrorContext('placeOrder', contextExtra), ); } - const mappedError = this.#mapError(error); return createErrorResult(mappedError, { success: false }); } @@ -4061,7 +4112,11 @@ export class HyperLiquidProvider implements PerpsProvider { try { this.#deps.debugLogger.log('Canceling order:', params); - // Validate coin exists + // Hydrate clients and asset mapping before coin validation so a cold + // start (e.g. service-worker restart with an empty prefetch map) can + // self-heal instead of surfacing ORDER_UNKNOWN_COIN prematurely. + await this.#ensureReadyForTrading(); + const coinValidation = validateCoinExists( params.symbol, this.#symbolToAssetId, @@ -4070,9 +4125,6 @@ export class HyperLiquidProvider implements PerpsProvider { throw new Error(coinValidation.error); } - // Ensure provider is ready for trading (includes signing operations) - await this.#ensureReadyForTrading(); - const exchangeClient = this.#clientService.getExchangeClient(); const asset = await this.#getAssetIdWithRepair({ symbol: params.symbol, @@ -4096,14 +4148,15 @@ export class HyperLiquidProvider implements PerpsProvider { error: success ? undefined : 'Order cancellation failed', }; } catch (error) { + const mappedError = this.#mapError(error); this.#deps.logger.error( - ensureError(error, 'HyperLiquidProvider.cancelOrder'), - this.#getErrorContext('cancelOrder', { + mappedError, + await this.#getTradingErrorContext('cancelOrder', mappedError, { orderId: params.orderId, coin: params.symbol, }), ); - return createErrorResult(error, { success: false }); + return createErrorResult(mappedError, { success: false }); } } diff --git a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts index 326a1eb9d4..a034ba1b78 100644 --- a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts +++ b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts @@ -1291,6 +1291,18 @@ export class HyperLiquidSubscriptionService { }; } + /** + * Return the cached HL abstraction mode for the given user address. + * + * @param userAddress - The EVM address to look up. + * @returns Cached abstraction mode, or null when unresolved. + */ + public getCachedAbstractionMode( + userAddress: string, + ): HyperLiquidAbstractionMode | null { + return this.#getAbstractionModeForUser(userAddress); + } + /** * Record a user's resolved abstraction mode and immediately re-aggregate. * Call after the provider has confirmed the on-chain mode (already-enabled diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.error-handling.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.error-handling.test.ts index bcecab2ca2..e670132ad1 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.error-handling.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.error-handling.test.ts @@ -456,6 +456,7 @@ describe('HyperLiquidProvider', () => { getOrdersCacheIfInitialized: jest.fn().mockReturnValue(null), // Abstraction-mode resolved-mode setter (unified account migration) setUserAbstractionMode: jest.fn(), + getCachedAbstractionMode: jest.fn().mockReturnValue(null), } as Partial as jest.Mocked; // Mock constructors @@ -2155,6 +2156,81 @@ describe('HyperLiquidProvider', () => { expect(result.success).toBe(false); expect(result.error).toBe('Order cancellation failed'); }); + + it.each([ + ['multi-sig required', PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED], + ['invalid nonce', PERPS_ERROR_CODES.EXCHANGE_INVALID_NONCE], + ])( + 'maps HyperLiquid "%s" cancel rejection to %s with abstraction-mode context', + async (exchangeMessage, expectedCode) => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + cancel: jest.fn().mockRejectedValue(new Error(exchangeMessage)), + }), + ); + mockSubscriptionService.getCachedAbstractionMode.mockReturnValue( + 'dexAbstraction', + ); + + const result = await provider.cancelOrder({ + orderId: '123', + symbol: 'BTC', + }); + + expect(result.success).toBe(false); + expect(result.error).toBe(expectedCode); + expect(mockPlatformDependencies.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ message: expectedCode }), + expect.objectContaining({ + context: expect.objectContaining({ + data: expect.objectContaining({ + abstraction_mode: 'dexAbstraction', + }), + }), + }), + ); + }, + ); + }); + + describe('account-mode exchange error mapping', () => { + it.each([ + ['multi-sig required', PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED], + ['invalid nonce', PERPS_ERROR_CODES.EXCHANGE_INVALID_NONCE], + ])( + 'maps HyperLiquid "%s" order rejection to %s with abstraction-mode context', + async (exchangeMessage, expectedCode) => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + order: jest.fn().mockRejectedValue(new Error(exchangeMessage)), + }), + ); + mockSubscriptionService.getCachedAbstractionMode.mockReturnValue( + 'unifiedAccount', + ); + + const result = await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe(expectedCode); + expect(mockPlatformDependencies.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ message: expectedCode }), + expect.objectContaining({ + context: expect.objectContaining({ + data: expect.objectContaining({ + abstraction_mode: 'unifiedAccount', + }), + }), + }), + ); + }, + ); }); describe('calculateFees', () => { 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 f3c2673ba8..137af84cc7 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts @@ -959,6 +959,58 @@ describe('HyperLiquidProvider', () => { expect(result.success).toBe(true); }); + it('self-heals an empty prefetch asset map before validating the coin on cancel', async () => { + const { validateCoinExists: realValidateCoinExists } = jest.requireActual( + '../../../src/utils/hyperLiquidValidation', + ); + mockValidateCoinExists.mockImplementation(realValidateCoinExists); + + provider = createTestProvider(); + + const result = await provider.cancelOrder({ + orderId: '123', + symbol: 'BTC', + }); + + expect(result.success).toBe(true); + expect(mockClientService.getExchangeClient().cancel).toHaveBeenCalled(); + }); + + it('still rejects a genuinely unknown coin after cancel hydration', async () => { + const { validateCoinExists: realValidateCoinExists } = jest.requireActual( + '../../../src/utils/hyperLiquidValidation', + ); + mockValidateCoinExists.mockImplementation(realValidateCoinExists); + + provider = createTestProvider(); + + const result = await provider.cancelOrder({ + orderId: '123', + symbol: 'NOT_A_REAL_COIN', + }); + + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_UNKNOWN_COIN); + }); + + it('propagates unrelated cancel failures unchanged', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + cancel: jest + .fn() + .mockRejectedValue(new Error('Insufficient margin to cancel')), + }), + ); + + const result = await provider.cancelOrder({ + orderId: '123', + symbol: 'BTC', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Insufficient margin'); + }); + it('retries USD-based order when rejected for $10 minimum with adjusted amount', async () => { // Create provider with PUMP in the asset mapping provider = createTestProvider({ From 814ec9535ae7c4063bb51242e238392c0862205c Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Sat, 1 Aug 2026 21:56:35 +0800 Subject: [PATCH 2/5] fix(perps): address CI changelog and build errors for TAT-3633 Link changelog entries to #9750 and route placeOrder error logging through Co-authored-by: Cursor #getTradingErrorContext so getUserAddressWithDefault is awaited correctly. --- packages/perps-controller/CHANGELOG.md | 6 ++-- .../src/providers/HyperLiquidProvider.ts | 34 ++++++------------- 2 files changed, 13 insertions(+), 27 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index fa53fcd73c..93881682ac 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -31,7 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Add `EXCHANGE_ACCOUNT_NOT_FOUND` to `PERPS_ERROR_CODES`, returned by `HyperLiquidProvider.placeOrder` when the wallet has no HyperLiquid account yet (TAT-3343) ([#9709](https://github.com/MetaMask/core/pull/9709)) - This widens the exported `PerpsErrorCode` union, so consumers that key an exhaustive `Record` stop compiling until they add an entry for the new code. Both first-party clients do: Mobile's `app/components/UI/Perps/utils/translatePerpsError.ts` and Extension's `ui/components/app/perps/utils/translate-perps-error.ts`. - To migrate: add a translation entry for `EXCHANGE_ACCOUNT_NOT_FOUND`. It signals that the wallet has no HyperLiquid account yet, so the message should direct the user to fund the account before trading. -- Add `EXCHANGE_MULTI_SIG_REQUIRED` and `EXCHANGE_INVALID_NONCE` to `PERPS_ERROR_CODES` for HyperLiquid exchange rejections that previously surfaced as raw `"multi-sig required"` / `"invalid nonce"` strings (TAT-3633) +- Add `EXCHANGE_MULTI_SIG_REQUIRED` and `EXCHANGE_INVALID_NONCE` to `PERPS_ERROR_CODES` for HyperLiquid exchange rejections that previously surfaced as raw `"multi-sig required"` / `"invalid nonce"` strings (TAT-3633) ([#9750](https://github.com/MetaMask/core/pull/9750)) ### Changed @@ -52,8 +52,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Hydrate trading readiness before coin validation in `HyperLiquidProvider.cancelOrder`, so a cold start with an empty prefetch asset map self-heals instead of returning `ORDER_UNKNOWN_COIN` for valid markets (TAT-3633) -- Map HyperLiquid `"multi-sig required"` and `"invalid nonce"` exchange rejections to `EXCHANGE_MULTI_SIG_REQUIRED` and `EXCHANGE_INVALID_NONCE`, attaching cached abstraction mode to account-mode error log context (TAT-3633) +- Hydrate trading readiness before coin validation in `HyperLiquidProvider.cancelOrder`, so a cold start with an empty prefetch asset map self-heals instead of returning `ORDER_UNKNOWN_COIN` for valid markets (TAT-3633) ([#9750](https://github.com/MetaMask/core/pull/9750)) +- Map HyperLiquid `"multi-sig required"` and `"invalid nonce"` exchange rejections to `EXCHANGE_MULTI_SIG_REQUIRED` and `EXCHANGE_INVALID_NONCE`, attaching cached abstraction mode to account-mode error log context (TAT-3633) ([#9750](https://github.com/MetaMask/core/pull/9750)) - **BREAKING:** `OrderParams.timeInForce` now controls the HyperLiquid time-in-force for plain limit orders instead of being ignored; `GTC`, `IOC`, and post-only `ALO` map to their corresponding SDK values ([#9674](https://github.com/MetaMask/core/pull/9674)) - Order shapes that cannot carry a time in force — market orders and trigger placements, whose execution is decided when they fire — now reject it with `ORDER_TIME_IN_FORCE_NOT_SUPPORTED`, where previously the field was accepted and ignored for every order type. Callers passing `timeInForce` on anything other than a `limit` order must drop it. - The rejection happens in `validateOrderParams`, before `placeOrder` changes leverage on-chain or moves margin to a HIP-3 DEX, so a rejected order leaves no side effects behind. diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index f3c526ca0b..0d6a15ed56 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -3474,7 +3474,9 @@ export class HyperLiquidProvider implements PerpsProvider { * @param params - The operation parameters. * @returns The result of the operation. */ - #handleOrderError(params: HandleOrderErrorParams): OrderResult { + async #handleOrderError( + params: HandleOrderErrorParams, + ): Promise { const { error, symbol, orderType, isBuy } = params; const mappedError = this.#mapError(error); @@ -3489,29 +3491,13 @@ export class HyperLiquidProvider implements PerpsProvider { { symbol, orderType, isBuy }, ); } else { - const contextExtra: Record = { - symbol, - orderType, - isBuy, - }; - if (this.#isMappedAccountModeExchangeError(mappedError)) { - try { - const userAddress = this.#walletService.getUserAddress(); - if (userAddress) { - const abstractionMode = - this.#subscriptionService.getCachedAbstractionMode(userAddress); - if (abstractionMode) { - contextExtra[PERPS_EVENT_PROPERTY.ABSTRACTION_MODE] = - abstractionMode; - } - } - } catch { - // Best-effort context enrichment only. - } - } this.#deps.logger.error( mappedError, - this.#getErrorContext('placeOrder', contextExtra), + await this.#getTradingErrorContext('placeOrder', mappedError, { + symbol, + orderType, + isBuy, + }), ); } @@ -3762,7 +3748,7 @@ export class HyperLiquidProvider implements PerpsProvider { adjustedUsdAmount = (estimatedUsd * 1.015).toFixed(2); } else { // No price information available - cannot retry - return this.#handleOrderError({ + return await this.#handleOrderError({ error, symbol: params.symbol, orderType: params.orderType, @@ -3788,7 +3774,7 @@ export class HyperLiquidProvider implements PerpsProvider { ); } - return this.#handleOrderError({ + return await this.#handleOrderError({ error, symbol: params.symbol, orderType: params.orderType, From 154418e54813ccafbe7f82b93cdb6eab68a48bc8 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Sat, 1 Aug 2026 21:58:25 +0800 Subject: [PATCH 3/5] fix(perps): hydrate asset map before cancel validation only Run #ensureReady() before coin validation and defer #ensureReadyForTrading() until after, matching placeOrder/editOrder. Avoids signature prompts on invalid cancels while preserving cold-start self-heal for valid markets. Co-authored-by: Cursor --- .../src/providers/HyperLiquidProvider.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 0d6a15ed56..746577162a 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -4098,10 +4098,12 @@ export class HyperLiquidProvider implements PerpsProvider { try { this.#deps.debugLogger.log('Canceling order:', params); - // Hydrate clients and asset mapping before coin validation so a cold - // start (e.g. service-worker restart with an empty prefetch map) can - // self-heal instead of surfacing ORDER_UNKNOWN_COIN prematurely. - await this.#ensureReadyForTrading(); + // Hydrate the asset map before coin validation so a cold start (e.g. + // service-worker restart with an empty prefetch map) can self-heal + // without signature prompts on invalid cancels. Trading setup (builder + // fee, referral, unified account) runs only after validation passes, + // matching placeOrder / editOrder. + await this.#ensureReady(); const coinValidation = validateCoinExists( params.symbol, @@ -4111,6 +4113,8 @@ export class HyperLiquidProvider implements PerpsProvider { throw new Error(coinValidation.error); } + await this.#ensureReadyForTrading(); + const exchangeClient = this.#clientService.getExchangeClient(); const asset = await this.#getAssetIdWithRepair({ symbol: params.symbol, From b3bce249132282aa9ca65e06e66e547309b373d6 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 3 Aug 2026 19:44:25 +0800 Subject: [PATCH 4/5] fix(perps): map non-thrown HyperLiquid cancel status errors Single cancelOrder only checked for a 'success' status and returned a hard-coded 'Order cancellation failed' string, so the EXCHANGE_MULTI_SIG_REQUIRED and EXCHANGE_INVALID_NONCE mappings never applied to the shape HyperLiquid actually returns most of the time: a resolved status object carrying the raw exchange error. Read that error and pipe it through #mapError, as cancelOrders already does for its per-status errors. Give the cancelOrders catch path the same treatment (#mapError plus #getTradingErrorContext), and label the new error codes BREAKING in the changelog since they widen the exported PerpsErrorCode union. --- packages/perps-controller/CHANGELOG.md | 8 ++- .../src/providers/HyperLiquidProvider.ts | 31 +++++++--- ...HyperLiquidProvider.error-handling.test.ts | 60 +++++++++++++++++++ .../HyperLiquidProvider.trading.test.ts | 19 ++++++ 4 files changed, 108 insertions(+), 10 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 93881682ac..37cf7d67a1 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -31,7 +31,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Add `EXCHANGE_ACCOUNT_NOT_FOUND` to `PERPS_ERROR_CODES`, returned by `HyperLiquidProvider.placeOrder` when the wallet has no HyperLiquid account yet (TAT-3343) ([#9709](https://github.com/MetaMask/core/pull/9709)) - This widens the exported `PerpsErrorCode` union, so consumers that key an exhaustive `Record` stop compiling until they add an entry for the new code. Both first-party clients do: Mobile's `app/components/UI/Perps/utils/translatePerpsError.ts` and Extension's `ui/components/app/perps/utils/translate-perps-error.ts`. - To migrate: add a translation entry for `EXCHANGE_ACCOUNT_NOT_FOUND`. It signals that the wallet has no HyperLiquid account yet, so the message should direct the user to fund the account before trading. -- Add `EXCHANGE_MULTI_SIG_REQUIRED` and `EXCHANGE_INVALID_NONCE` to `PERPS_ERROR_CODES` for HyperLiquid exchange rejections that previously surfaced as raw `"multi-sig required"` / `"invalid nonce"` strings (TAT-3633) ([#9750](https://github.com/MetaMask/core/pull/9750)) +- **BREAKING:** Add `EXCHANGE_MULTI_SIG_REQUIRED` and `EXCHANGE_INVALID_NONCE` to `PERPS_ERROR_CODES` for HyperLiquid exchange rejections that previously surfaced as raw `"multi-sig required"` / `"invalid nonce"` strings (TAT-3633) ([#9750](https://github.com/MetaMask/core/pull/9750)) + - Like `EXCHANGE_ACCOUNT_NOT_FOUND` above, this widens the exported `PerpsErrorCode` union, so consumers that key an exhaustive `Record` stop compiling until they add entries for both new codes — including Mobile's `app/components/UI/Perps/utils/translatePerpsError.ts` and Extension's `ui/components/app/perps/utils/translate-perps-error.ts`. + - To migrate: add translation entries for both codes before bumping. `EXCHANGE_MULTI_SIG_REQUIRED` means the account requires a multi-sig wrapper for exchange writes; `EXCHANGE_INVALID_NONCE` means the action nonce was stale or reused and the request should be retried. ### Changed @@ -53,7 +55,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Hydrate trading readiness before coin validation in `HyperLiquidProvider.cancelOrder`, so a cold start with an empty prefetch asset map self-heals instead of returning `ORDER_UNKNOWN_COIN` for valid markets (TAT-3633) ([#9750](https://github.com/MetaMask/core/pull/9750)) -- Map HyperLiquid `"multi-sig required"` and `"invalid nonce"` exchange rejections to `EXCHANGE_MULTI_SIG_REQUIRED` and `EXCHANGE_INVALID_NONCE`, attaching cached abstraction mode to account-mode error log context (TAT-3633) ([#9750](https://github.com/MetaMask/core/pull/9750)) +- Map HyperLiquid `"multi-sig required"` and `"invalid nonce"` exchange rejections to `EXCHANGE_MULTI_SIG_REQUIRED` and `EXCHANGE_INVALID_NONCE` across `placeOrder`, `cancelOrder`, and `cancelOrders`, attaching cached abstraction mode to account-mode error log context (TAT-3633) ([#9750](https://github.com/MetaMask/core/pull/9750)) + - `cancelOrder` now reads the per-status error HyperLiquid returns when it rejects a cancel without throwing, so `CancelOrderResult.error` carries the mapped code instead of the generic `'Order cancellation failed'` string. That generic string is still returned when the status entry carries no error text. + - `cancelOrders` maps thrown batch failures the same way, so `CancelOrdersResult.results[].error` carries a `PerpsErrorCode` rather than the raw exchange message for recognized rejections. - **BREAKING:** `OrderParams.timeInForce` now controls the HyperLiquid time-in-force for plain limit orders instead of being ignored; `GTC`, `IOC`, and post-only `ALO` map to their corresponding SDK values ([#9674](https://github.com/MetaMask/core/pull/9674)) - Order shapes that cannot carry a time in force — market orders and trigger placements, whose execution is decided when they fire — now reject it with `ORDER_TIME_IN_FORCE_NOT_SUPPORTED`, where previously the field was accepted and ignored for every order type. Callers passing `timeInForce` on anything other than a `limit` order must drop it. - The rejection happens in `validateOrderParams`, before `placeOrder` changes leverage on-chain or moves margin to a HIP-3 DEX, so a rejected order leaves no side effects behind. diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 746577162a..24aff18448 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -4130,13 +4130,27 @@ export class HyperLiquidProvider implements PerpsProvider { ], }); - const success = result.response?.data?.statuses?.[0] === 'success'; + const status = result.response?.data?.statuses?.[0]; + if (status === 'success') { + return { + success: true, + orderId: params.orderId, + }; + } - return { - success, + // HyperLiquid usually rejects a cancel without throwing: the status entry + // carries the raw exchange string (e.g. "multi-sig required"). Map it the + // same way as a thrown rejection so callers get a standardized code + // instead of a generic message. The SDK types every status as 'success', + // so the rejection shape needs the same cast cancelOrders already uses. + const rawError = + (status as { error?: string } | undefined)?.error ?? + 'Order cancellation failed'; + + return createErrorResult(this.#mapError(new Error(rawError)), { + success: false, orderId: params.orderId, - error: success ? undefined : 'Order cancellation failed', - }; + }); } catch (error) { const mappedError = this.#mapError(error); this.#deps.logger.error( @@ -4220,9 +4234,10 @@ export class HyperLiquidProvider implements PerpsProvider { })), }; } catch (error) { + const mappedError = this.#mapError(error); this.#deps.logger.error( - ensureError(error, 'HyperLiquidProvider.cancelOrders'), - this.#getErrorContext('cancelOrders', { + mappedError, + await this.#getTradingErrorContext('cancelOrders', mappedError, { orderCount: params.length, }), ); @@ -4237,7 +4252,7 @@ export class HyperLiquidProvider implements PerpsProvider { success: false, error: error instanceof Error - ? error.message + ? mappedError.message : PERPS_ERROR_CODES.BATCH_CANCEL_FAILED, })), }; diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.error-handling.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.error-handling.test.ts index e670132ad1..9c34057436 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.error-handling.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.error-handling.test.ts @@ -2191,6 +2191,66 @@ describe('HyperLiquidProvider', () => { ); }, ); + + it.each([ + ['multi-sig required', PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED], + ['invalid nonce', PERPS_ERROR_CODES.EXCHANGE_INVALID_NONCE], + ])( + 'maps a non-thrown "%s" cancel status rejection to %s', + async (exchangeMessage, expectedCode) => { + // HyperLiquid usually rejects a cancel by resolving with a status + // object rather than throwing, so this is the common failure shape. + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { statuses: [{ error: exchangeMessage }] }, + }, + }), + }), + ); + + const result = await provider.cancelOrder({ + orderId: '123', + symbol: 'BTC', + }); + + expect(result.success).toBe(false); + expect(result.orderId).toBe('123'); + expect(result.error).toBe(expectedCode); + }, + ); + + it('preserves an unmapped cancel status error string', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + { + error: + 'cancel 0: Order was never placed, already canceled, or filled. asset=4', + }, + ], + }, + }, + }), + }), + ); + + const result = await provider.cancelOrder({ + orderId: '123', + symbol: 'BTC', + }); + + expect(result.success).toBe(false); + expect(result.error).toBe( + 'cancel 0: Order was never placed, already canceled, or filled. asset=4', + ); + }); }); describe('account-mode exchange error mapping', () => { 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 137af84cc7..bae723bd92 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts @@ -3001,6 +3001,25 @@ describe('HyperLiquidProvider', () => { expect(result.results[0].success).toBe(false); expect(result.results[0].error).toBe('API error'); }); + + it('maps recognized batch cancel rejections to a standardized code', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + cancel: jest + .fn() + .mockRejectedValue(new Error('multi-sig required')), + }), + ); + + const result = await provider.cancelOrders([ + { orderId: '123', symbol: 'BTC' }, + ]); + + expect(result.success).toBe(false); + expect(result.results[0].error).toBe( + PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, + ); + }); }); describe('closePositions', () => { From 26e43134812481d362e89fdfdc670fc3e2881318 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 3 Aug 2026 21:59:56 +0800 Subject: [PATCH 5/5] fix(perps): map per-status batch cancel rejections cancelOrders returned HyperLiquid's raw per-status error strings while cancelOrder maps that same non-thrown shape, so a batch cancel could surface an unmapped "multi-sig required" that single cancel standardizes. Route each per-status rejection through #mapError, keeping undefined when a status carries no error text. --- packages/perps-controller/CHANGELOG.md | 2 +- .../src/providers/HyperLiquidProvider.ts | 23 +++++++++------- .../HyperLiquidProvider.trading.test.ts | 26 +++++++++++++++++++ 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 37cf7d67a1..cb06c75398 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -57,7 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Hydrate trading readiness before coin validation in `HyperLiquidProvider.cancelOrder`, so a cold start with an empty prefetch asset map self-heals instead of returning `ORDER_UNKNOWN_COIN` for valid markets (TAT-3633) ([#9750](https://github.com/MetaMask/core/pull/9750)) - Map HyperLiquid `"multi-sig required"` and `"invalid nonce"` exchange rejections to `EXCHANGE_MULTI_SIG_REQUIRED` and `EXCHANGE_INVALID_NONCE` across `placeOrder`, `cancelOrder`, and `cancelOrders`, attaching cached abstraction mode to account-mode error log context (TAT-3633) ([#9750](https://github.com/MetaMask/core/pull/9750)) - `cancelOrder` now reads the per-status error HyperLiquid returns when it rejects a cancel without throwing, so `CancelOrderResult.error` carries the mapped code instead of the generic `'Order cancellation failed'` string. That generic string is still returned when the status entry carries no error text. - - `cancelOrders` maps thrown batch failures the same way, so `CancelOrdersResult.results[].error` carries a `PerpsErrorCode` rather than the raw exchange message for recognized rejections. + - `cancelOrders` maps both thrown batch failures and per-status rejections the same way, so `CancelOrdersResult.results[].error` carries a `PerpsErrorCode` rather than the raw exchange message for recognized rejections. - **BREAKING:** `OrderParams.timeInForce` now controls the HyperLiquid time-in-force for plain limit orders instead of being ignored; `GTC`, `IOC`, and post-only `ALO` map to their corresponding SDK values ([#9674](https://github.com/MetaMask/core/pull/9674)) - Order shapes that cannot carry a time in force — market orders and trigger placements, whose execution is decided when they fire — now reject it with `ORDER_TIME_IN_FORCE_NOT_SUPPORTED`, where previously the field was accepted and ignored for every order type. Callers passing `timeInForce` on anything other than a `limit` order must drop it. - The rejection happens in `validateOrderParams`, before `placeOrder` changes leverage on-chain or moves margin to a HIP-3 DEX, so a rejected order leaves no side effects behind. diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 24aff18448..9b52dfdfd6 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -4223,15 +4223,20 @@ export class HyperLiquidProvider implements PerpsProvider { success: successCount > 0, successCount, failureCount, - results: statuses.map((status, index) => ({ - orderId: params[index].orderId, - symbol: params[index].symbol, - success: status === 'success', - error: - status === 'success' - ? undefined - : (status as { error: string }).error, - })), + results: statuses.map((status, index) => { + // Map each per-status rejection the same way cancelOrder does, so a + // batch cancel reports standardized codes rather than raw exchange + // strings for the rejections this provider recognizes. + const statusError = (status as { error?: string } | undefined)?.error; + return { + orderId: params[index].orderId, + symbol: params[index].symbol, + success: status === 'success', + error: statusError + ? this.#mapError(new Error(statusError)).message + : undefined, + }; + }), }; } catch (error) { const mappedError = this.#mapError(error); 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 bae723bd92..1b91658f22 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts @@ -3002,6 +3002,32 @@ describe('HyperLiquidProvider', () => { expect(result.results[0].error).toBe('API error'); }); + it('maps recognized per-status batch cancel rejections to a standardized code', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + cancel: jest.fn().mockResolvedValue({ + response: { + data: { + statuses: ['success', { error: 'multi-sig required' }], + }, + }, + }), + }), + ); + + const result = await provider.cancelOrders([ + { orderId: '123', symbol: 'BTC' }, + { orderId: '456', symbol: 'ETH' }, + ]); + + expect(result.successCount).toBe(1); + expect(result.failureCount).toBe(1); + expect(result.results[0].error).toBeUndefined(); + expect(result.results[1].error).toBe( + PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, + ); + }); + it('maps recognized batch cancel rejections to a standardized code', async () => { mockClientService.getExchangeClient = jest.fn().mockReturnValue( createMockExchangeClient({