diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index ca168a87c7..cb06c75398 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -31,6 +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. +- **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 @@ -51,6 +54,10 @@ 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` 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 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/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..9b52dfdfd6 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 * @@ -3439,8 +3474,11 @@ 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); // A wallet with no Hyperliquid account is an expected pre-account state, // not an app defect — same policy already applied to every other @@ -3454,8 +3492,8 @@ export class HyperLiquidProvider implements PerpsProvider { ); } else { this.#deps.logger.error( - ensureError(error, 'HyperLiquidProvider.handleOrderError'), - this.#getErrorContext('placeOrder', { + mappedError, + await this.#getTradingErrorContext('placeOrder', mappedError, { symbol, orderType, isBuy, @@ -3463,7 +3501,6 @@ export class HyperLiquidProvider implements PerpsProvider { ); } - const mappedError = this.#mapError(error); return createErrorResult(mappedError, { success: false }); } @@ -3711,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, @@ -3737,7 +3774,7 @@ export class HyperLiquidProvider implements PerpsProvider { ); } - return this.#handleOrderError({ + return await this.#handleOrderError({ error, symbol: params.symbol, orderType: params.orderType, @@ -4061,7 +4098,13 @@ export class HyperLiquidProvider implements PerpsProvider { try { this.#deps.debugLogger.log('Canceling order:', params); - // Validate coin exists + // 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, this.#symbolToAssetId, @@ -4070,7 +4113,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(); @@ -4088,22 +4130,37 @@ 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, + }; + } + + // 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 { - success, + 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( - 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 }); } } @@ -4166,20 +4223,26 @@ 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); this.#deps.logger.error( - ensureError(error, 'HyperLiquidProvider.cancelOrders'), - this.#getErrorContext('cancelOrders', { + mappedError, + await this.#getTradingErrorContext('cancelOrders', mappedError, { orderCount: params.length, }), ); @@ -4194,7 +4257,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/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..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 @@ -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,141 @@ 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', + }), + }), + }), + ); + }, + ); + + 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', () => { + 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..1b91658f22 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({ @@ -2949,6 +3001,51 @@ describe('HyperLiquidProvider', () => { expect(result.results[0].success).toBe(false); 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({ + 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', () => {