From 64315102649ffab392307955cd09a44383836bc8 Mon Sep 17 00:00:00 2001 From: geositta Date: Tue, 6 Oct 2026 20:18:24 -0500 Subject: [PATCH 01/17] feat: block unsupported Hyperliquid multisig accounts Expose provider-owned account support checks and prevent unsupported accounts from signing Perps trades or withdrawals. --- packages/perps-controller/CHANGELOG.md | 1 + .../PerpsController-method-action-types.ts | 14 +++ .../perps-controller/src/PerpsController.ts | 23 ++++ packages/perps-controller/src/index.ts | 4 + .../src/providers/AggregatedPerpsProvider.ts | 18 +++ .../src/providers/HyperLiquidProvider.ts | 92 ++++++++++++-- packages/perps-controller/src/types/index.ts | 31 +++++ .../tests/helpers/providerMocks.ts | 1 + .../src/PerpsController.operations.test.ts | 28 +++++ .../providers/AggregatedPerpsProvider.test.ts | 47 +++++++ .../HyperLiquidProvider.account-mode.test.ts | 118 +++++++++++++++++- 11 files changed, 366 insertions(+), 11 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index f8c9759ac4b..ff2447fd13f 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `createErrorResult` copies `errorCode` and `errorDetails` when the failure is a structured controller error. HyperLiquid mapped rejections, including an immediate-match rejection (`IOC_CANCEL`) and `EXCHANGE_ACCOUNT_NOT_FOUND`, now set `errorCode`; `OrderResult.error` stays the same message - A USD-derived order whose price moved past `maxSlippageBps` (or the default market slippage) reports `PRICE_MOVED`. The message still starts with `Price moved too much:`; the expected and current prices in it use HyperLiquid price formatting - Add `PERPS_EVENT_PROPERTY.PRICE_DELTA_BPS` (`price_delta_bps`) and export `PerpsMaxSlippageSource`. Trade and close analytics now include `failure_reason` from `errorCode` when one is present, `price_delta_bps` for a `PRICE_MOVED` rejection, and `max_slippage_pct`, `max_slippage_source`, and `estimated_slippage_pct` from the caller's tracking data. Raw `error_message` is still sent. Failed close events also include `close_type`, derived from the requested size, including when the provider throws. That property previously appeared only on executed closes ([#10385](https://github.com/MetaMask/core/pull/10385)) +- Add `getAccountSupport` to the controller, messenger, and optional provider contract. Hyperliquid detects native multi-signature accounts, caches successful checks per account/network session, and blocks trade and withdrawal signing while transient detection failures remain retryable. ([TAT-3752](https://consensyssoftware.atlassian.net/browse/TAT-3752)) ### Changed diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index e5356b2fda6..4c72f41096a 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -73,6 +73,19 @@ export type PerpsControllerGetActiveProviderAction = { handler: PerpsController['getActiveProvider']; }; +/** + * Return whether the selected account can submit actions through the active + * provider route. Providers without an account-support check remain + * supported for backward compatibility. + * + * @param params - Optional provider route. + * @returns The provider-owned account support result. + */ +export type PerpsControllerGetAccountSupportAction = { + type: `PerpsController:getAccountSupport`; + handler: PerpsController['getAccountSupport']; +}; + /** * Get the currently active provider, returning null if not available * Use this method when the caller can gracefully handle a missing provider @@ -1596,6 +1609,7 @@ export type PerpsControllerMethodActions = | PerpsControllerGetUserDataSnapshotAction | PerpsControllerInitAction | PerpsControllerGetActiveProviderAction + | PerpsControllerGetAccountSupportAction | PerpsControllerGetActiveProviderOrNullAction | PerpsControllerGetOrderCapabilitiesAction | PerpsControllerGetMarginModeLockAction diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 9586ce4b58b..f0808999ab5 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -97,6 +97,7 @@ import type { FlipPositionParams, Funding, GetAccountStateParams, + GetAccountSupportParams, GetAvailableDexsParams, GetFundingParams, GetMarketDataWithPricesParams, @@ -161,6 +162,7 @@ import type { PerpsAgentSigner, PerpsPlatformDependencies, PerpsActiveProviderMode, + PerpsAccountSupport, PerpsAnalyticsProperties, PerpsAttributionContext, PerpsProviderType, @@ -976,6 +978,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'fetchHistoricalCandles', 'flipPosition', 'getAccountState', + 'getAccountSupport', 'getActiveProvider', 'getActiveProviderOrNull', 'getAttributionContext', @@ -2906,6 +2909,26 @@ export class PerpsController extends BaseController< } } + /** + * Return whether the selected account can submit actions through the active + * provider route. Providers without an account-support check remain + * supported for backward compatibility. + * + * @param params - Optional provider route. + * @returns The provider-owned account support result. + */ + async getAccountSupport( + params?: GetAccountSupportParams, + ): Promise { + const provider = await this.#getActiveProviderWhenReady(); + if (this.#hasConflictingProviderRoute(params?.providerId, provider)) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_NOT_FOUND); + } + return provider.getAccountSupport + ? provider.getAccountSupport(params) + : { isSupported: true }; + } + /** * Identify the account, network and provider an action runs under. * diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 4ba23fd4bb6..e3392bb46c4 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -81,6 +81,7 @@ export type { PerpsControllerFetchHistoricalCandlesAction, PerpsControllerFlipPositionAction, PerpsControllerGetAccountStateAction, + PerpsControllerGetAccountSupportAction, PerpsControllerGetActiveProviderAction, PerpsControllerGetActiveProviderOrNullAction, PerpsControllerGetAttributionContextAction, @@ -323,6 +324,9 @@ export type { PositionModifyPreviewNone, FeeCalculationParams, FeeCalculationResult, + GetAccountSupportParams, + PerpsAccountSupport, + PerpsAccountUnsupportedReason, GetOrderCapabilitiesParams, GetMarginModeLockParams, MarginModeLockReason, diff --git a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts index bfd58f8a682..c6a68fdb85b 100644 --- a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts +++ b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts @@ -46,6 +46,7 @@ import type { FeeCalculationResult, Funding, GetAccountStateParams, + GetAccountSupportParams, GetAvailableDexsParams, GetFundingParams, GetHistoricalPortfolioParams, @@ -74,6 +75,7 @@ import type { OrderParams, OrderResult, PerpsMarketData, + PerpsAccountSupport, PerpsOrderCapabilities, PerpsMarginModeLock, PerpsScalePriceLadder, @@ -304,6 +306,22 @@ export class AggregatedPerpsProvider implements PerpsProvider { return this.#getDefaultProvider().getWithdrawalRoutes(params); } + /** + * Route account support through the same explicit-provider/default-provider + * selection used by writes. + * + * @param params - Optional provider route. + * @returns The selected provider's account support result. + */ + async getAccountSupport( + params?: GetAccountSupportParams, + ): Promise { + const [, provider] = this.#getProviderOrDefault(params?.providerId); + return provider.getAccountSupport + ? provider.getAccountSupport(params) + : { isSupported: true }; + } + /** * Resolve capabilities with the same explicit-provider/default-provider * selection used by order placement. Unknown routes return a typed diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index e35d3f1e001..1edec72b8c1 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -104,6 +104,7 @@ import type { FeeCalculationResult, Funding, GetAccountStateParams, + PerpsAccountSupport, GetAvailableDexsParams, GetFundingParams, GetHistoricalPortfolioParams, @@ -1639,6 +1640,13 @@ export class HyperLiquidProvider implements PerpsProvider { // Key: `network:userAddress`, Value: true if referral is set readonly #referralCheckCache = new Map(); + // Session-scoped, account/network-keyed support probes. Provider instances + // are discarded on reconnect; rejected probes are not retained. + readonly #accountSupportByContext = new Map< + string, + Promise + >(); + // Session cache for builder fee approval state (cleared on disconnect/reconnect) // Key: `network:userAddress`, Value: true if builder fee is approved readonly #builderFeeCheckCache = new Map(); @@ -2862,20 +2870,21 @@ export class HyperLiquidProvider implements PerpsProvider { * multi-sig account with `ApiRequestError: Multi-sig required`, so the * unified-account migration must not be attempted for those accounts. * - * If the probe throws (transient network), returns `false` — fail open so - * one bad probe never blocks migration for a normal single-signer account. + * If the probe throws (transient network), returns `undefined` so callers + * can fail open without caching the transient result. * The `isHyperLiquidMultiSigRequiredError` fallback in the write's catch * block remains the safety net. * * @param userAddress - The wallet address to check. * @param infoClient - Optional captured reader for an operation-scoped probe. - * @returns True only when Hyperliquid reports a multi-sig signer set. + * @returns True for multi-sig, false for single-signer, or undefined when + * the probe could not complete. * @private */ async #isHyperliquidMultiSigAccount( userAddress: string, infoClient = this.#clientService.getInfoClient(), - ): Promise { + ): Promise { try { const signers = await infoClient.userToMultiSigSigners({ user: userAddress, @@ -2892,7 +2901,57 @@ export class HyperLiquidProvider implements PerpsProvider { ).message, }, ); - return false; + return undefined; + } + } + + /** + * Read support for the active account without initializing the provider. + * Successful results are stable for the provider session and coalesced by + * account and network. Failed probes fail open and remain retryable. + * + * @returns Whether the active account can submit Hyperliquid actions. + */ + async #getAccountSupportForCurrentContext(): Promise { + const userAddress = await this.#walletService.getUserAddressWithDefault(); + const network = this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet'; + const cacheKey = `${network}:${userAddress.toLowerCase()}`; + const cachedSupport = this.#accountSupportByContext.get(cacheKey); + if (cachedSupport) { + return cachedSupport; + } + + const probe = this.#isHyperliquidMultiSigAccount(userAddress); + const support = probe.then( + (isMultiSig): PerpsAccountSupport => + isMultiSig + ? { isSupported: false, reason: 'multi_sig_account' } + : { isSupported: true }, + ); + this.#accountSupportByContext.set(cacheKey, support); + + probe + .then((isMultiSig) => { + if ( + isMultiSig === undefined && + this.#accountSupportByContext.get(cacheKey) === support + ) { + this.#accountSupportByContext.delete(cacheKey); + } + }) + .catch(() => undefined); + + return support; + } + + /** + * Stop an action before signing when Hyperliquid reports a native multi-sig + * signer set for the active account. + */ + async #assertAccountSupported(): Promise { + const support = await this.#getAccountSupportForCurrentContext(); + if (!support.isSupported) { + throw new Error(PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED); } } @@ -3097,8 +3156,8 @@ export class HyperLiquidProvider implements PerpsProvider { // surfaced on the Perps tab on every entry (TAT-3214). Probe right // before the write so accounts that never reach one (already compatible, // deferred, unknown mode) do not pay the extra round trip. - const isMultiSig = await this.#isHyperliquidMultiSigAccount(userAddress); - if (isMultiSig) { + const accountSupport = await this.#getAccountSupportForCurrentContext(); + if (!accountSupport.isSupported) { this.#deps.debugLogger.log( '[ensureUnifiedAccountEnabled] Multi-sig account, skipping unified account migration', { user: userAddress, network, mode: currentMode }, @@ -3109,9 +3168,9 @@ export class HyperLiquidProvider implements PerpsProvider { PERPS_EVENT_VALUE.STATUS.NOT_APPLICABLE, [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: 'multi_sig_account', }); - // Final state: the write can never succeed for this account, so cache - // it as attempted with unified mode off. Perps keeps working through - // the programmatic collateral-transfer fallback. + // Final state: the migration can never succeed for this account, so + // cache it as attempted with unified mode off. Action readiness blocks + // this account before any later exchange write. TradingReadinessCache.set(network, userAddress, { attempted: true, enabled: false, @@ -3554,6 +3613,7 @@ export class HyperLiquidProvider implements PerpsProvider { }): Promise { // First ensure basic initialization is complete await this.#ensureReady(); + await this.#assertAccountSupported(); // The migration was deferred during init to avoid a signing prompt on // Perps section open. Drive it here, gated by its own cache so @@ -13499,6 +13559,16 @@ export class HyperLiquidProvider implements PerpsProvider { } } + /** + * Return whether the active wallet can submit Hyperliquid actions. + * + * @returns The provider-owned account support result. + */ + async getAccountSupport(): Promise { + await this.#ensureReady(); + return this.#getAccountSupportForCurrentContext(); + } + /** * Resolve the provider's currently active CAIP account identifier. * Used by the MarketDataService REST coalesce layer so cached payloads @@ -15091,6 +15161,7 @@ export class HyperLiquidProvider implements PerpsProvider { // Step 4: Ensure client is ready this.#deps.debugLogger.log('HyperLiquidProvider: ENSURING CLIENT READY'); await this.#ensureReady(); + await this.#assertAccountSupported(); await this.#ensureUnifiedAccountEnabled({ allowUserSigning: true }); const exchangeClient = this.#clientService.getExchangeClient(); this.#deps.debugLogger.log('HyperLiquidProvider: CLIENT READY'); @@ -16539,6 +16610,7 @@ export class HyperLiquidProvider implements PerpsProvider { // Clear session caches. Capability and fee reads use the lifecycle // generation above to discard any old response that resolves later. this.clearFeeCache(); + this.#accountSupportByContext.clear(); this.#referralCheckCache.clear(); this.#builderFeeCheckCache.clear(); this.#builderFeeRefusals.clear(); diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index ee5a492eb30..2d432b424c4 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -2459,6 +2459,29 @@ export type Funding = { transactionHash?: string; // Optional transaction hash }; +/** + * A provider-owned reason that the selected account cannot submit Perps + * actions through a route. + */ +export type PerpsAccountUnsupportedReason = 'multi_sig_account'; + +/** + * Whether the selected account can submit Perps actions through a provider. + */ +export type PerpsAccountSupport = + | { isSupported: true } + | { + isSupported: false; + reason: PerpsAccountUnsupportedReason; + }; + +/** + * Selects the provider route whose account support should be checked. + */ +export type GetAccountSupportParams = { + providerId?: PerpsProviderType; +}; + export type PerpsProvider = { /** Local durable Scale inventory; unsupported providers omit the methods. */ getScaleOrderGroups?(): Promise; @@ -2472,6 +2495,14 @@ export type PerpsProvider = { /** Whether this provider routes individual requests by `providerId`. */ readonly routesOrdersByProviderId?: boolean; + /** + * Return whether the selected account can submit actions through this + * provider. Providers that omit this hook are treated as supported. + */ + getAccountSupport?( + params?: GetAccountSupportParams, + ): Promise; + /** * Return order capabilities for the provider/market route. Providers may * omit this hook; the controller then reports capabilities as unavailable. diff --git a/packages/perps-controller/tests/helpers/providerMocks.ts b/packages/perps-controller/tests/helpers/providerMocks.ts index 9e204100437..771788de83c 100644 --- a/packages/perps-controller/tests/helpers/providerMocks.ts +++ b/packages/perps-controller/tests/helpers/providerMocks.ts @@ -22,6 +22,7 @@ export const createMockHyperLiquidProvider = totalBalance30dAgo: '9000', }), getMarkets: jest.fn(), + getAccountSupport: jest.fn().mockResolvedValue({ isSupported: true }), getOrderCapabilities: jest.fn().mockResolvedValue({ status: 'ready', providerId: 'hyperliquid', diff --git a/packages/perps-controller/tests/src/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts index db6b397861a..c50a12eed13 100644 --- a/packages/perps-controller/tests/src/PerpsController.operations.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.operations.test.ts @@ -579,6 +579,34 @@ describe('PerpsController', () => { (mockInfrastructure.logger.error as jest.Mock).mockClear(); (mockInfrastructure.debugLogger.log as jest.Mock).mockClear(); }); + + describe('account support', () => { + it('returns the active provider account support result', async () => { + const accountSupport = { + isSupported: false, + reason: 'multi_sig_account', + } as const; + mockProvider.getAccountSupport.mockResolvedValue(accountSupport); + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + const result = await controller.getAccountSupport(); + + expect(result).toEqual(accountSupport); + expect(mockProvider.getAccountSupport).toHaveBeenCalledWith(undefined); + }); + + it('treats providers without an account support hook as supported', async () => { + mockProvider.getAccountSupport = undefined; + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + const result = await controller.getAccountSupport(); + + expect(result).toEqual({ isSupported: true }); + }); + }); + describe('validation methods', () => { it('validates close position', async () => { const closeParams = { diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts index 8fc12c21b92..dacd8897c8c 100644 --- a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -48,6 +48,7 @@ const createMockProvider = ( }), // Read operations + getAccountSupport: jest.fn().mockResolvedValue({ isSupported: true }), getPositions: jest.fn().mockResolvedValue([]), getAccountState: jest.fn().mockResolvedValue({ spendableBalance: '10000', @@ -638,6 +639,52 @@ describe('AggregatedPerpsProvider', () => { }); }); + describe('Read Operations - getAccountSupport', () => { + it('routes to the default provider', async () => { + const unsupportedResult = { + isSupported: false, + reason: 'multi_sig_account', + } as const; + mockHLProvider.getAccountSupport?.mockResolvedValue(unsupportedResult); + + const result = await aggregatedProvider.getAccountSupport(); + + expect(result).toEqual(unsupportedResult); + expect(mockHLProvider.getAccountSupport).toHaveBeenCalledWith(undefined); + expect(mockLighterProvider.getAccountSupport).not.toHaveBeenCalled(); + }); + + it('routes to the explicitly selected provider', async () => { + const unsupportedResult = { + isSupported: false, + reason: 'multi_sig_account', + } as const; + mockLighterProvider.getAccountSupport?.mockResolvedValue( + unsupportedResult, + ); + + const result = await aggregatedProvider.getAccountSupport({ + providerId: 'lighter', + }); + + expect(result).toEqual(unsupportedResult); + expect(mockLighterProvider.getAccountSupport).toHaveBeenCalledWith({ + providerId: 'lighter', + }); + expect(mockHLProvider.getAccountSupport).not.toHaveBeenCalled(); + }); + + it('treats a provider without an account support hook as supported', async () => { + delete mockLighterProvider.getAccountSupport; + + const result = await aggregatedProvider.getAccountSupport({ + providerId: 'lighter', + }); + + expect(result).toEqual({ isSupported: true }); + }); + }); + describe('Read Operations - getMarketDataWithPrices', () => { it('aggregates market data from all providers', async () => { mockHLProvider.getMarketDataWithPrices.mockResolvedValue([ diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts index 0916b055fe7..d13bb7701e7 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts @@ -174,6 +174,7 @@ describe('HyperLiquidProvider', () => { let mockClientService: jest.Mocked; let mockWalletService: jest.Mocked; let mockSubscriptionService: jest.Mocked; + let mockInfoClient: ReturnType; beforeEach(() => { // Reset all mocks @@ -211,13 +212,14 @@ describe('HyperLiquidProvider', () => { }; // Create mocked service instances using factory functions + mockInfoClient = createMockInfoClient(); mockClientService = { initialize: jest.fn(), isInitialized: jest.fn().mockReturnValue(true), isTestnetMode: jest.fn().mockReturnValue(false), ensureInitialized: jest.fn(), getExchangeClient: jest.fn().mockReturnValue(createMockExchangeClient()), - getInfoClient: jest.fn().mockReturnValue(createMockInfoClient()), + getInfoClient: jest.fn().mockReturnValue(mockInfoClient), fetchHistoricalOrders: jest.fn().mockResolvedValue([]), disconnect: jest.fn().mockResolvedValue(undefined), toggleTestnet: jest.fn(), @@ -337,6 +339,120 @@ describe('HyperLiquidProvider', () => { ], }); }); + + describe('getAccountSupport', () => { + it('returns supported for a standard Hyperliquid account', async () => { + mockInfoClient.userToMultiSigSigners.mockResolvedValue(null); + + const result = await provider.getAccountSupport(); + + expect(result).toEqual({ isSupported: true }); + }); + + it('returns the multi-signature reason for an unsupported account', async () => { + mockInfoClient.userToMultiSigSigners.mockResolvedValue({ + authorizedUsers: ['0x1234567890123456789012345678901234567890'], + threshold: 1, + }); + + const result = await provider.getAccountSupport(); + + expect(result).toEqual({ + isSupported: false, + reason: 'multi_sig_account', + }); + }); + + it('fails open when Hyperliquid cannot report account support', async () => { + mockInfoClient.userToMultiSigSigners.mockRejectedValue( + new Error('Network unavailable'), + ); + + const result = await provider.getAccountSupport(); + + expect(result).toEqual({ isSupported: true }); + }); + + it('coalesces and caches successful support checks', async () => { + mockInfoClient.userToMultiSigSigners.mockResolvedValue(null); + + const results = await Promise.all([ + provider.getAccountSupport(), + provider.getAccountSupport(), + ]); + const cachedResult = await provider.getAccountSupport(); + + expect(results).toEqual([{ isSupported: true }, { isSupported: true }]); + expect(cachedResult).toEqual({ isSupported: true }); + expect(mockInfoClient.userToMultiSigSigners).toHaveBeenCalledTimes(1); + }); + + it('retries support checks after a transient failure', async () => { + mockInfoClient.userToMultiSigSigners + .mockRejectedValueOnce(new Error('Network unavailable')) + .mockResolvedValueOnce({ + authorizedUsers: ['0x1234567890123456789012345678901234567890'], + threshold: 1, + }); + + const firstResult = await provider.getAccountSupport(); + const secondResult = await provider.getAccountSupport(); + + expect(firstResult).toEqual({ isSupported: true }); + expect(secondResult).toEqual({ + isSupported: false, + reason: 'multi_sig_account', + }); + expect(mockInfoClient.userToMultiSigSigners).toHaveBeenCalledTimes(2); + }); + + it('blocks order signing for an unsupported account', async () => { + const exchangeClient = createMockExchangeClient(); + mockClientService.getExchangeClient.mockReturnValue(exchangeClient); + mockInfoClient.userToMultiSigSigners.mockResolvedValue({ + authorizedUsers: ['0x1234567890123456789012345678901234567890'], + threshold: 1, + }); + + await expect( + provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }), + ).resolves.toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, + }); + + expect(exchangeClient.order).not.toHaveBeenCalled(); + }); + + it('blocks withdrawal signing for an unsupported account', async () => { + const exchangeClient = createMockExchangeClient(); + mockClientService.getExchangeClient.mockReturnValue(exchangeClient); + mockInfoClient.userToMultiSigSigners.mockResolvedValue({ + authorizedUsers: ['0x1234567890123456789012345678901234567890'], + threshold: 1, + }); + + await expect( + provider.withdraw({ + amount: '100', + destination: '0x1234567890123456789012345678901234567890' as Hex, + assetId: + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/usdc' as CaipAssetId, + }), + ).resolves.toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, + }); + + expect(exchangeClient.withdraw3).not.toHaveBeenCalled(); + }); + }); + describe('getUserNonFundingLedgerUpdates', () => { it('returns non-funding ledger updates', async () => { // Arrange From 35f6ef36cc247e48b19cd274343ce7bfcae6750f Mon Sep 17 00:00:00 2001 From: geositta Date: Thu, 8 Oct 2026 19:14:08 -0500 Subject: [PATCH 02/17] fix: preserve account context during support checks Co-authored-by: Cursor --- .../src/providers/HyperLiquidProvider.ts | 35 +++++++++---- .../HyperLiquidProvider.account-mode.test.ts | 51 +++++++++++++++++++ 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 1edec72b8c1..91b6121a6da 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -2906,22 +2906,35 @@ export class HyperLiquidProvider implements PerpsProvider { } /** - * Read support for the active account without initializing the provider. + * Read support for an account context without initializing the provider. * Successful results are stable for the provider session and coalesced by * account and network. Failed probes fail open and remain retryable. * - * @returns Whether the active account can submit Hyperliquid actions. + * @param context - An operation-scoped account context. Defaults to the + * currently active account and network. + * @returns Whether the account can submit Hyperliquid actions. */ - async #getAccountSupportForCurrentContext(): Promise { - const userAddress = await this.#walletService.getUserAddressWithDefault(); - const network = this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet'; + async #getAccountSupportForContext(context?: { + userAddress: string; + network: 'mainnet' | 'testnet'; + infoClient?: InfoClient; + }): Promise { + const userAddress = + context?.userAddress ?? + (await this.#walletService.getUserAddressWithDefault()); + const network = + context?.network ?? + (this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet'); const cacheKey = `${network}:${userAddress.toLowerCase()}`; const cachedSupport = this.#accountSupportByContext.get(cacheKey); if (cachedSupport) { return cachedSupport; } - const probe = this.#isHyperliquidMultiSigAccount(userAddress); + const probe = this.#isHyperliquidMultiSigAccount( + userAddress, + context?.infoClient, + ); const support = probe.then( (isMultiSig): PerpsAccountSupport => isMultiSig @@ -2949,7 +2962,7 @@ export class HyperLiquidProvider implements PerpsProvider { * signer set for the active account. */ async #assertAccountSupported(): Promise { - const support = await this.#getAccountSupportForCurrentContext(); + const support = await this.#getAccountSupportForContext(); if (!support.isSupported) { throw new Error(PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED); } @@ -3156,7 +3169,11 @@ export class HyperLiquidProvider implements PerpsProvider { // surfaced on the Perps tab on every entry (TAT-3214). Probe right // before the write so accounts that never reach one (already compatible, // deferred, unknown mode) do not pay the extra round trip. - const accountSupport = await this.#getAccountSupportForCurrentContext(); + const accountSupport = await this.#getAccountSupportForContext({ + userAddress, + network, + infoClient, + }); if (!accountSupport.isSupported) { this.#deps.debugLogger.log( '[ensureUnifiedAccountEnabled] Multi-sig account, skipping unified account migration', @@ -13566,7 +13583,7 @@ export class HyperLiquidProvider implements PerpsProvider { */ async getAccountSupport(): Promise { await this.#ensureReady(); - return this.#getAccountSupportForCurrentContext(); + return this.#getAccountSupportForContext(); } /** diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts index d13bb7701e7..b0d5a26e85a 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts @@ -1647,6 +1647,57 @@ describe('HyperLiquidProvider', () => { ); }); + it('checks support for the account captured by unified account setup', async () => { + const switchedAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + const mockExchangeClient = createMockExchangeClient(); + const userToMultiSigSigners = jest + .fn() + .mockImplementation(({ user }: { user: string }) => + Promise.resolve( + user === USER_ADDRESS + ? { + authorizedUsers: [ + '0xabc0000000000000000000000000000000000001', + ], + threshold: 2, + } + : null, + ), + ); + const userAbstraction = jest.fn().mockImplementation(async () => { + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + switchedAddress, + ); + return 'default'; + }); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction, + userToMultiSigSigners, + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + await provider.getMarketDataWithPrices(); + + expect(userToMultiSigSigners).toHaveBeenCalledWith({ + user: USER_ADDRESS, + }); + expect(userToMultiSigSigners).not.toHaveBeenCalledWith({ + user: switchedAddress, + }); + expect(mockExchangeClient.agentSetAbstraction).not.toHaveBeenCalled(); + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).toHaveBeenCalledWith('mainnet', USER_ADDRESS, { + attempted: true, + enabled: false, + }); + }); + it('caches attempted-but-not-enabled readiness for Hyperliquid multi-sig accounts', async () => { // Arrange const mockCompleteInFlight = jest.fn(); From 58303644ad25e842b38acaf4f280cf1dcd01914a Mon Sep 17 00:00:00 2001 From: geositta Date: Thu, 8 Oct 2026 19:22:06 -0500 Subject: [PATCH 03/17] test: preserve provider mock types Co-authored-by: Cursor --- .../src/PerpsController.operations.test.ts | 9 ++++-- .../providers/AggregatedPerpsProvider.test.ts | 28 +++++++++++++------ .../HyperLiquidProvider.account-mode.test.ts | 8 ++++-- 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/packages/perps-controller/tests/src/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts index c50a12eed13..8eeb9eaf81f 100644 --- a/packages/perps-controller/tests/src/PerpsController.operations.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.operations.test.ts @@ -597,9 +597,14 @@ describe('PerpsController', () => { }); it('treats providers without an account support hook as supported', async () => { - mockProvider.getAccountSupport = undefined; + const { + getAccountSupport: _getAccountSupport, + ...providerWithoutAccountSupport + } = mockProvider; markControllerAsInitialized(); - controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testSetProviders( + new Map([['hyperliquid', providerWithoutAccountSupport]]), + ); const result = await controller.getAccountSupport(); diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts index dacd8897c8c..383fbca1588 100644 --- a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -26,11 +26,17 @@ import { createMockInfrastructure, } from '../../helpers/serviceMocks.js'; +type MockPerpsProvider = jest.Mocked & { + getAccountSupport: jest.MockedFunction< + NonNullable + >; +}; + // Create a comprehensive mock provider const createMockProvider = ( providerId: PerpsProviderType, -): jest.Mocked => { - const mockProvider: jest.Mocked = { +): MockPerpsProvider => { + const mockProvider: MockPerpsProvider = { protocolId: providerId, // Asset routes @@ -199,8 +205,8 @@ const createMockOrder = (orderId: string, symbol: string): Order => describe('AggregatedPerpsProvider', () => { let aggregatedProvider: AggregatedPerpsProvider; let routedProvider: PerpsProvider; - let mockHLProvider: jest.Mocked; - let mockLighterProvider: jest.Mocked; + let mockHLProvider: ReturnType; + let mockLighterProvider: ReturnType; let mockInfrastructure: ReturnType; beforeEach(() => { @@ -675,7 +681,11 @@ describe('AggregatedPerpsProvider', () => { }); it('treats a provider without an account support hook as supported', async () => { - delete mockLighterProvider.getAccountSupport; + const { + getAccountSupport: _getAccountSupport, + ...providerWithoutAccountSupport + } = mockLighterProvider; + aggregatedProvider.addProvider('lighter', providerWithoutAccountSupport); const result = await aggregatedProvider.getAccountSupport({ providerId: 'lighter', @@ -2456,7 +2466,7 @@ describe('AggregatedPerpsProvider', () => { it('delegates getWebSocketConnectionState to default provider', () => { // Arrange ( - mockHLProvider as jest.Mocked & { + mockHLProvider as MockPerpsProvider & { getWebSocketConnectionState: jest.Mock; } ).getWebSocketConnectionState = jest @@ -2490,7 +2500,7 @@ describe('AggregatedPerpsProvider', () => { // Arrange const unsubscribe = jest.fn(); ( - mockHLProvider as jest.Mocked & { + mockHLProvider as MockPerpsProvider & { subscribeToConnectionState: jest.Mock; } ).subscribeToConnectionState = jest.fn().mockReturnValue(unsubscribe); @@ -2527,7 +2537,7 @@ describe('AggregatedPerpsProvider', () => { it('delegates reconnect to default provider', async () => { // Arrange ( - mockHLProvider as jest.Mocked & { + mockHLProvider as MockPerpsProvider & { reconnect: jest.Mock; } ).reconnect = jest.fn().mockResolvedValue(undefined); @@ -2538,7 +2548,7 @@ describe('AggregatedPerpsProvider', () => { // Assert expect( ( - mockHLProvider as jest.Mocked & { + mockHLProvider as MockPerpsProvider & { reconnect: jest.Mock; } ).reconnect, diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts index b0d5a26e85a..9a38f985c4b 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts @@ -408,7 +408,9 @@ describe('HyperLiquidProvider', () => { it('blocks order signing for an unsupported account', async () => { const exchangeClient = createMockExchangeClient(); - mockClientService.getExchangeClient.mockReturnValue(exchangeClient); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); mockInfoClient.userToMultiSigSigners.mockResolvedValue({ authorizedUsers: ['0x1234567890123456789012345678901234567890'], threshold: 1, @@ -431,7 +433,9 @@ describe('HyperLiquidProvider', () => { it('blocks withdrawal signing for an unsupported account', async () => { const exchangeClient = createMockExchangeClient(); - mockClientService.getExchangeClient.mockReturnValue(exchangeClient); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); mockInfoClient.userToMultiSigSigners.mockResolvedValue({ authorizedUsers: ['0x1234567890123456789012345678901234567890'], threshold: 1, From 68dfbb9eb0dab2d674a007e00e5beee217e153b1 Mon Sep 17 00:00:00 2001 From: geositta Date: Thu, 8 Oct 2026 19:42:41 -0500 Subject: [PATCH 04/17] docs: link Perps changelog to pull request Co-authored-by: Cursor --- packages/perps-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index ff2447fd13f..ae288ec9b90 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `createErrorResult` copies `errorCode` and `errorDetails` when the failure is a structured controller error. HyperLiquid mapped rejections, including an immediate-match rejection (`IOC_CANCEL`) and `EXCHANGE_ACCOUNT_NOT_FOUND`, now set `errorCode`; `OrderResult.error` stays the same message - A USD-derived order whose price moved past `maxSlippageBps` (or the default market slippage) reports `PRICE_MOVED`. The message still starts with `Price moved too much:`; the expected and current prices in it use HyperLiquid price formatting - Add `PERPS_EVENT_PROPERTY.PRICE_DELTA_BPS` (`price_delta_bps`) and export `PerpsMaxSlippageSource`. Trade and close analytics now include `failure_reason` from `errorCode` when one is present, `price_delta_bps` for a `PRICE_MOVED` rejection, and `max_slippage_pct`, `max_slippage_source`, and `estimated_slippage_pct` from the caller's tracking data. Raw `error_message` is still sent. Failed close events also include `close_type`, derived from the requested size, including when the provider throws. That property previously appeared only on executed closes ([#10385](https://github.com/MetaMask/core/pull/10385)) -- Add `getAccountSupport` to the controller, messenger, and optional provider contract. Hyperliquid detects native multi-signature accounts, caches successful checks per account/network session, and blocks trade and withdrawal signing while transient detection failures remain retryable. ([TAT-3752](https://consensyssoftware.atlassian.net/browse/TAT-3752)) +- Add `getAccountSupport` to the controller, messenger, and optional provider contract. Hyperliquid detects native multi-signature accounts, caches successful checks per account/network session, and blocks trade and withdrawal signing while transient detection failures remain retryable. ([#10693](https://github.com/MetaMask/core/pull/10693), [TAT-3752](https://consensyssoftware.atlassian.net/browse/TAT-3752)) ### Changed From aaf300d591de7c33ab0a56204d2e7413ae663413 Mon Sep 17 00:00:00 2001 From: geositta Date: Thu, 8 Oct 2026 19:57:14 -0500 Subject: [PATCH 05/17] docs: use parseable Perps changelog link Co-authored-by: Cursor --- packages/perps-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index ae288ec9b90..d180bd76316 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `createErrorResult` copies `errorCode` and `errorDetails` when the failure is a structured controller error. HyperLiquid mapped rejections, including an immediate-match rejection (`IOC_CANCEL`) and `EXCHANGE_ACCOUNT_NOT_FOUND`, now set `errorCode`; `OrderResult.error` stays the same message - A USD-derived order whose price moved past `maxSlippageBps` (or the default market slippage) reports `PRICE_MOVED`. The message still starts with `Price moved too much:`; the expected and current prices in it use HyperLiquid price formatting - Add `PERPS_EVENT_PROPERTY.PRICE_DELTA_BPS` (`price_delta_bps`) and export `PerpsMaxSlippageSource`. Trade and close analytics now include `failure_reason` from `errorCode` when one is present, `price_delta_bps` for a `PRICE_MOVED` rejection, and `max_slippage_pct`, `max_slippage_source`, and `estimated_slippage_pct` from the caller's tracking data. Raw `error_message` is still sent. Failed close events also include `close_type`, derived from the requested size, including when the provider throws. That property previously appeared only on executed closes ([#10385](https://github.com/MetaMask/core/pull/10385)) -- Add `getAccountSupport` to the controller, messenger, and optional provider contract. Hyperliquid detects native multi-signature accounts, caches successful checks per account/network session, and blocks trade and withdrawal signing while transient detection failures remain retryable. ([#10693](https://github.com/MetaMask/core/pull/10693), [TAT-3752](https://consensyssoftware.atlassian.net/browse/TAT-3752)) +- Add `getAccountSupport` to the controller, messenger, and optional provider contract. Hyperliquid detects native multi-signature accounts, caches successful checks per account/network session, and blocks trade and withdrawal signing while transient detection failures remain retryable. ([#10693](https://github.com/MetaMask/core/pull/10693)) ### Changed From e2ac56479dee5e1cb1912cadfc998010c9333c9d Mon Sep 17 00:00:00 2001 From: geositta Date: Fri, 9 Oct 2026 11:21:16 -0500 Subject: [PATCH 06/17] fix: enforce Hyperliquid account support guard Co-authored-by: Cursor --- packages/perps-controller/CHANGELOG.md | 2 +- .../src/providers/HyperLiquidProvider.ts | 117 ++++++++++++++--- .../HyperLiquidProvider.account-mode.test.ts | 123 ++++++++++++++++++ 3 files changed, 221 insertions(+), 21 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index d180bd76316..847e1793b37 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `createErrorResult` copies `errorCode` and `errorDetails` when the failure is a structured controller error. HyperLiquid mapped rejections, including an immediate-match rejection (`IOC_CANCEL`) and `EXCHANGE_ACCOUNT_NOT_FOUND`, now set `errorCode`; `OrderResult.error` stays the same message - A USD-derived order whose price moved past `maxSlippageBps` (or the default market slippage) reports `PRICE_MOVED`. The message still starts with `Price moved too much:`; the expected and current prices in it use HyperLiquid price formatting - Add `PERPS_EVENT_PROPERTY.PRICE_DELTA_BPS` (`price_delta_bps`) and export `PerpsMaxSlippageSource`. Trade and close analytics now include `failure_reason` from `errorCode` when one is present, `price_delta_bps` for a `PRICE_MOVED` rejection, and `max_slippage_pct`, `max_slippage_source`, and `estimated_slippage_pct` from the caller's tracking data. Raw `error_message` is still sent. Failed close events also include `close_type`, derived from the requested size, including when the provider throws. That property previously appeared only on executed closes ([#10385](https://github.com/MetaMask/core/pull/10385)) -- Add `getAccountSupport` to the controller, messenger, and optional provider contract. Hyperliquid detects native multi-signature accounts, caches successful checks per account/network session, and blocks trade and withdrawal signing while transient detection failures remain retryable. ([#10693](https://github.com/MetaMask/core/pull/10693)) +- Add `getAccountSupport` to the controller, messenger, and optional provider contract. Hyperliquid detects native multi-signature accounts, caches successful checks per account/network session, and blocks exchange mutations for unsupported accounts. Transient detection failures remain retryable, authoritative exchange rejections correct cached support, and actions reject stale account, network, or provider contexts before signing. ([#10693](https://github.com/MetaMask/core/pull/10693)) ### Changed diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 91b6121a6da..241dc5b1d85 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -750,6 +750,12 @@ type BuilderFeeSetupContext = { builderAddress: string; }; +type AccountSupportContext = { + lifecycleGeneration: number; + network: 'testnet' | 'mainnet'; + userAddress: string; +}; + /** * Classify one entry of a cancel response. * @@ -2883,10 +2889,11 @@ export class HyperLiquidProvider implements PerpsProvider { */ async #isHyperliquidMultiSigAccount( userAddress: string, - infoClient = this.#clientService.getInfoClient(), + infoClient?: InfoClient, ): Promise { try { - const signers = await infoClient.userToMultiSigSigners({ + const reader = infoClient ?? this.#clientService.getInfoClient(); + const signers = await reader.userToMultiSigSigners({ user: userAddress, }); return signers !== null && signers !== undefined; @@ -2905,6 +2912,24 @@ export class HyperLiquidProvider implements PerpsProvider { } } + #getAccountSupportCacheKey( + network: 'mainnet' | 'testnet', + userAddress: string, + ): string { + return `${network}:${userAddress.toLowerCase()}`; + } + + #recordAccountSupport( + network: 'mainnet' | 'testnet', + userAddress: string, + support: PerpsAccountSupport, + ): void { + this.#accountSupportByContext.set( + this.#getAccountSupportCacheKey(network, userAddress), + Promise.resolve(support), + ); + } + /** * Read support for an account context without initializing the provider. * Successful results are stable for the provider session and coalesced by @@ -2925,7 +2950,7 @@ export class HyperLiquidProvider implements PerpsProvider { const network = context?.network ?? (this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet'); - const cacheKey = `${network}:${userAddress.toLowerCase()}`; + const cacheKey = this.#getAccountSupportCacheKey(network, userAddress); const cachedSupport = this.#accountSupportByContext.get(cacheKey); if (cachedSupport) { return cachedSupport; @@ -2940,32 +2965,75 @@ export class HyperLiquidProvider implements PerpsProvider { isMultiSig ? { isSupported: false, reason: 'multi_sig_account' } : { isSupported: true }, + (): PerpsAccountSupport => ({ isSupported: true }), ); this.#accountSupportByContext.set(cacheKey, support); probe - .then((isMultiSig) => { - if ( - isMultiSig === undefined && - this.#accountSupportByContext.get(cacheKey) === support - ) { - this.#accountSupportByContext.delete(cacheKey); - } - }) + .then( + (isMultiSig) => { + if ( + isMultiSig === undefined && + this.#accountSupportByContext.get(cacheKey) === support + ) { + this.#accountSupportByContext.delete(cacheKey); + } + }, + () => { + if (this.#accountSupportByContext.get(cacheKey) === support) { + this.#accountSupportByContext.delete(cacheKey); + } + }, + ) .catch(() => undefined); return support; } + async #assertAccountContextCurrent( + context: AccountSupportContext, + operation: string, + ): Promise { + this.#assertProviderLifecycleCurrent( + context.lifecycleGeneration, + operation, + ); + const currentAddress = await this.#walletService + .getUserAddressWithDefault() + .catch(() => undefined); + this.#assertProviderLifecycleCurrent( + context.lifecycleGeneration, + operation, + ); + const currentNetwork = this.#clientService.isTestnetMode() + ? 'testnet' + : 'mainnet'; + if ( + currentAddress?.toLowerCase() !== context.userAddress.toLowerCase() || + currentNetwork !== context.network + ) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + } + /** * Stop an action before signing when Hyperliquid reports a native multi-sig * signer set for the active account. + * + * @returns The account context whose support was checked. */ - async #assertAccountSupported(): Promise { - const support = await this.#getAccountSupportForContext(); + async #assertAccountSupported(): Promise { + const context: AccountSupportContext = { + lifecycleGeneration: this.#lifecycleGeneration, + network: this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet', + userAddress: await this.#walletService.getUserAddressWithDefault(), + }; + const support = await this.#getAccountSupportForContext(context); + await this.#assertAccountContextCurrent(context, 'Account support check'); if (!support.isSupported) { throw new Error(PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED); } + return context; } /** @@ -3286,6 +3354,10 @@ export class HyperLiquidProvider implements PerpsProvider { '[ensureUnifiedAccountEnabled] Multi-sig account (race/probe fallback), skipping unified account migration', { user: userAddress, network, mode: currentMode }, ); + this.#recordAccountSupport(network, userAddress, { + isSupported: false, + reason: 'multi_sig_account', + }); this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.AccountSetup, { ...(currentMode && { [PERPS_EVENT_PROPERTY.PREVIOUS_ABSTRACTION_MODE]: currentMode, @@ -3630,12 +3702,13 @@ export class HyperLiquidProvider implements PerpsProvider { }): Promise { // First ensure basic initialization is complete await this.#ensureReady(); - await this.#assertAccountSupported(); + const accountContext = await this.#assertAccountSupported(); // The migration was deferred during init to avoid a signing prompt on // Perps section open. Drive it here, gated by its own cache so // already-migrated users are not re-prompted. await this.#ensureUnifiedAccountEnabled({ allowUserSigning: true }); + await this.#assertAccountContextCurrent(accountContext, 'Trading setup'); // Reset right before the check, with no await in between, so a failure // above does not leave the setup for an order to run. @@ -3697,6 +3770,7 @@ export class HyperLiquidProvider implements PerpsProvider { const builderFeeSetupContext = options.requiresBuilderFee ? await this.#ensureBuilderFeeSetup(options.builderFeeApprovalFailureCode) : undefined; + await this.#assertAccountContextCurrent(accountContext, 'Trading setup'); this.#deps.debugLogger.log( '[ensureReadyForTrading] Trading setup complete', @@ -12265,6 +12339,7 @@ export class HyperLiquidProvider implements PerpsProvider { // Ensure provider is ready await this.#ensureReady(); + const accountContext = await this.#assertAccountSupported(); // Use the target DEX's current slice or one targeted HTTP read before // deriving the position side. @@ -12313,6 +12388,7 @@ export class HyperLiquidProvider implements PerpsProvider { } // Call SDK to update isolated margin + await this.#assertAccountContextCurrent(accountContext, 'Margin update'); const exchangeClient = this.#clientService.getExchangeClient(); const result = await exchangeClient.updateIsolatedMargin({ asset: assetId, @@ -15178,7 +15254,7 @@ export class HyperLiquidProvider implements PerpsProvider { // Step 4: Ensure client is ready this.#deps.debugLogger.log('HyperLiquidProvider: ENSURING CLIENT READY'); await this.#ensureReady(); - await this.#assertAccountSupported(); + const accountContext = await this.#assertAccountSupported(); await this.#ensureUnifiedAccountEnabled({ allowUserSigning: true }); const exchangeClient = this.#clientService.getExchangeClient(); this.#deps.debugLogger.log('HyperLiquidProvider: CLIENT READY'); @@ -15243,6 +15319,7 @@ export class HyperLiquidProvider implements PerpsProvider { timestamp: new Date().toISOString(), }); + await this.#assertAccountContextCurrent(accountContext, 'Withdrawal'); const result = await exchangeClient.withdraw3({ destination, amount: params.amount, @@ -15365,14 +15442,13 @@ export class HyperLiquidProvider implements PerpsProvider { throw new Error('Source and destination DEX must be different'); } - // Get user address - const userAddress = await this.#walletService.getUserAddressWithDefault(); + // Ensure client ready + await this.#ensureReady(); + const accountContext = await this.#assertAccountSupported(); + const { userAddress } = accountContext; this.#deps.debugLogger.log('HyperLiquidProvider: USER ADDRESS', { userAddress, }); - - // Ensure client ready - await this.#ensureReady(); const exchangeClient = this.#clientService.getExchangeClient(); // Execute transfer using SDK sendAsset() @@ -15386,6 +15462,7 @@ export class HyperLiquidProvider implements PerpsProvider { }, ); + await this.#assertAccountContextCurrent(accountContext, 'DEX transfer'); const result = await exchangeClient.sendAsset({ destination: userAddress, sourceDex: params.sourceDex, diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts index 9a38f985c4b..55d7352faef 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts @@ -406,6 +406,44 @@ describe('HyperLiquidProvider', () => { expect(mockInfoClient.userToMultiSigSigners).toHaveBeenCalledTimes(2); }); + it('retries support checks after the info client becomes available', async () => { + const reconnectingProvider = createTestProvider({ + hip3Enabled: true, + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ], + }); + await reconnectingProvider.getAccountSupport(); + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + ); + const recoveredInfoClient = createMockInfoClient({ + userToMultiSigSigners: jest.fn().mockResolvedValue({ + authorizedUsers: ['0x1234567890123456789012345678901234567890'], + threshold: 1, + }), + }); + mockClientService.getInfoClient = jest + .fn() + .mockImplementationOnce(() => { + throw new Error(PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED); + }) + .mockReturnValue(recoveredInfoClient); + + const firstResult = await reconnectingProvider.getAccountSupport(); + const secondResult = await reconnectingProvider.getAccountSupport(); + + expect(firstResult).toEqual({ isSupported: true }); + expect(secondResult).toEqual({ + isSupported: false, + reason: 'multi_sig_account', + }); + expect(recoveredInfoClient.userToMultiSigSigners).toHaveBeenCalledTimes( + 1, + ); + }); + it('blocks order signing for an unsupported account', async () => { const exchangeClient = createMockExchangeClient(); mockClientService.getExchangeClient = jest @@ -455,6 +493,75 @@ describe('HyperLiquidProvider', () => { expect(exchangeClient.withdraw3).not.toHaveBeenCalled(); }); + + it('blocks an action when the selected account changes during its support check', async () => { + const switchedAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + const exchangeClient = createMockExchangeClient(); + let resolveProbe!: (value: null) => void; + let markProbeStarted!: () => void; + const probeResult = new Promise((resolve) => { + resolveProbe = resolve; + }); + const probeStarted = new Promise((resolve) => { + markProbeStarted = resolve; + }); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + mockInfoClient.userToMultiSigSigners.mockImplementation(() => { + markProbeStarted(); + return probeResult; + }); + + const withdrawal = provider.withdraw({ + amount: '100', + destination: '0x1234567890123456789012345678901234567890' as Hex, + assetId: + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/usdc' as CaipAssetId, + }); + await probeStarted; + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + switchedAddress, + ); + resolveProbe(null); + + await expect(withdrawal).resolves.toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + }); + expect(exchangeClient.withdraw3).not.toHaveBeenCalled(); + }); + + it('blocks margin and DEX-transfer signing for an unsupported account', async () => { + const exchangeClient = createMockExchangeClient(); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + mockInfoClient.userToMultiSigSigners.mockResolvedValue({ + authorizedUsers: ['0x1234567890123456789012345678901234567890'], + threshold: 1, + }); + + await expect( + provider.updateMargin({ symbol: 'BTC', amount: '-1' }), + ).resolves.toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, + }); + await expect( + provider.transferBetweenDexs({ + sourceDex: '', + destinationDex: 'xyz', + amount: '10', + }), + ).resolves.toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, + }); + + expect(exchangeClient.updateIsolatedMargin).not.toHaveBeenCalled(); + expect(exchangeClient.sendAsset).not.toHaveBeenCalled(); + }); }); describe('getUserNonFundingLedgerUpdates', () => { @@ -1787,6 +1894,22 @@ describe('HyperLiquidProvider', () => { attempted: true, enabled: false, }); + await expect(provider.getAccountSupport()).resolves.toEqual({ + isSupported: false, + reason: 'multi_sig_account', + }); + await expect( + provider.withdraw({ + amount: '100', + destination: USER_ADDRESS as Hex, + assetId: + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/usdc' as CaipAssetId, + }), + ).resolves.toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, + }); + expect(mockExchangeClient.withdraw3).not.toHaveBeenCalled(); }); it('still migrates single-signer accounts when the multi-sig probe fails', async () => { From 16427c54e0ac6eda4c57d35ab13a462f5ded7f25 Mon Sep 17 00:00:00 2001 From: geositta Date: Fri, 9 Oct 2026 19:36:29 -0500 Subject: [PATCH 07/17] fix: block actions after support correction Co-authored-by: Cursor --- .../src/providers/HyperLiquidProvider.ts | 36 +++++- .../HyperLiquidProvider.account-mode.test.ts | 112 ++++++++++++++++++ 2 files changed, 145 insertions(+), 3 deletions(-) diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 241dc5b1d85..9785d122531 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -3016,6 +3016,29 @@ export class HyperLiquidProvider implements PerpsProvider { } } + /** + * Block an action when another step has authoritatively corrected support + * for its captured account. A missing cache entry remains fail-open: this + * fence must not turn a transient probe failure into another network probe. + * + * @param context - The account, network, and lifecycle captured by the action. + * @param operation - Operation name included in lifecycle diagnostics. + */ + async #assertNoKnownUnsupportedAccount( + context: AccountSupportContext, + operation: string, + ): Promise { + await this.#assertAccountContextCurrent(context, operation); + const cachedSupport = this.#accountSupportByContext.get( + this.#getAccountSupportCacheKey(context.network, context.userAddress), + ); + const support = cachedSupport ? await cachedSupport : undefined; + await this.#assertAccountContextCurrent(context, operation); + if (support && !support.isSupported) { + throw new Error(PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED); + } + } + /** * Stop an action before signing when Hyperliquid reports a native multi-sig * signer set for the active account. @@ -3708,7 +3731,10 @@ export class HyperLiquidProvider implements PerpsProvider { // Perps section open. Drive it here, gated by its own cache so // already-migrated users are not re-prompted. await this.#ensureUnifiedAccountEnabled({ allowUserSigning: true }); - await this.#assertAccountContextCurrent(accountContext, 'Trading setup'); + await this.#assertNoKnownUnsupportedAccount( + accountContext, + 'Trading setup', + ); // Reset right before the check, with no await in between, so a failure // above does not leave the setup for an order to run. @@ -3770,7 +3796,10 @@ export class HyperLiquidProvider implements PerpsProvider { const builderFeeSetupContext = options.requiresBuilderFee ? await this.#ensureBuilderFeeSetup(options.builderFeeApprovalFailureCode) : undefined; - await this.#assertAccountContextCurrent(accountContext, 'Trading setup'); + await this.#assertNoKnownUnsupportedAccount( + accountContext, + 'Trading setup', + ); this.#deps.debugLogger.log( '[ensureReadyForTrading] Trading setup complete', @@ -15256,6 +15285,7 @@ export class HyperLiquidProvider implements PerpsProvider { await this.#ensureReady(); const accountContext = await this.#assertAccountSupported(); await this.#ensureUnifiedAccountEnabled({ allowUserSigning: true }); + await this.#assertNoKnownUnsupportedAccount(accountContext, 'Withdrawal'); const exchangeClient = this.#clientService.getExchangeClient(); this.#deps.debugLogger.log('HyperLiquidProvider: CLIENT READY'); @@ -15319,7 +15349,7 @@ export class HyperLiquidProvider implements PerpsProvider { timestamp: new Date().toISOString(), }); - await this.#assertAccountContextCurrent(accountContext, 'Withdrawal'); + await this.#assertNoKnownUnsupportedAccount(accountContext, 'Withdrawal'); const result = await exchangeClient.withdraw3({ destination, amount: params.amount, diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts index 55d7352faef..4f9e1f7e644 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts @@ -562,6 +562,118 @@ describe('HyperLiquidProvider', () => { expect(exchangeClient.updateIsolatedMargin).not.toHaveBeenCalled(); expect(exchangeClient.sendAsset).not.toHaveBeenCalled(); }); + + it('blocks order signing when action-time setup detects multi-signature support', async () => { + mockWalletService.requiresSignatureConfirmation.mockReturnValue(true); + const exchangeClient = createMockExchangeClient(); + const userToMultiSigSigners = jest + .fn() + .mockRejectedValueOnce(new Error('Network unavailable')) + .mockResolvedValueOnce({ + authorizedUsers: ['0x1234567890123456789012345678901234567890'], + threshold: 1, + }); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + userToMultiSigSigners, + }), + ); + + await expect( + provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }), + ).resolves.toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, + }); + + expect(userToMultiSigSigners).toHaveBeenCalledTimes(2); + expect(exchangeClient.agentSetAbstraction).not.toHaveBeenCalled(); + expect(exchangeClient.approveBuilderFee).not.toHaveBeenCalled(); + expect(exchangeClient.order).not.toHaveBeenCalled(); + }); + + it('blocks withdrawal signing when action-time setup gets an authoritative multi-signature rejection', async () => { + mockWalletService.requiresSignatureConfirmation.mockReturnValue(true); + const exchangeClient = createMockExchangeClient({ + agentSetAbstraction: jest + .fn() + .mockRejectedValue(new Error('ApiRequestError: Multi-sig required')), + }); + const userToMultiSigSigners = jest + .fn() + .mockRejectedValueOnce(new Error('Network unavailable')) + .mockResolvedValueOnce(null); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + userToMultiSigSigners, + }), + ); + + await expect( + provider.withdraw({ + amount: '100', + destination: '0x1234567890123456789012345678901234567890' as Hex, + assetId: + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/usdc' as CaipAssetId, + }), + ).resolves.toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, + }); + + expect(userToMultiSigSigners).toHaveBeenCalledTimes(2); + expect(exchangeClient.agentSetAbstraction).toHaveBeenCalledWith({ + abstraction: 'u', + }); + expect(exchangeClient.withdraw3).not.toHaveBeenCalled(); + expect( + mockPlatformDependencies.metrics.trackPerpsEvent, + ).not.toHaveBeenCalledWith( + 'Perp Account Setup', + expect.objectContaining({ status: 'failed' }), + ); + }); + + it('does not re-probe a transient support failure when setup learns nothing new', async () => { + const exchangeClient = createMockExchangeClient(); + const userToMultiSigSigners = jest + .fn() + .mockRejectedValue(new Error('Network unavailable')); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + userToMultiSigSigners, + }), + ); + + await expect( + provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }), + ).resolves.toMatchObject({ success: true }); + + expect(userToMultiSigSigners).toHaveBeenCalledTimes(1); + expect(exchangeClient.order).toHaveBeenCalledTimes(1); + }); }); describe('getUserNonFundingLedgerUpdates', () => { From a66938200529a7461303d47c5c08dbb485371ea7 Mon Sep 17 00:00:00 2001 From: geositta Date: Fri, 9 Oct 2026 21:24:46 -0500 Subject: [PATCH 08/17] fix: bind trading writes to checked account context Co-authored-by: Cursor --- .../src/providers/HyperLiquidProvider.ts | 614 ++++++++++++++---- .../HyperLiquidProvider.account-mode.test.ts | 121 ++++ ...yperLiquidProvider.strategy-orders.test.ts | 12 +- 3 files changed, 616 insertions(+), 131 deletions(-) diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 9785d122531..6303ebc8235 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -756,6 +756,15 @@ type AccountSupportContext = { userAddress: string; }; +type TradingActionContext = { + accountContext: AccountSupportContext; + builderFeeSetupContext?: BuilderFeeSetupContext; +}; + +type BuilderFeeTradingActionContext = TradingActionContext & { + builderFeeSetupContext: BuilderFeeSetupContext; +}; + /** * Classify one entry of a cancel response. * @@ -939,6 +948,7 @@ type PrepareAssetForTradingParams = { assetId: number; leverage?: number; marginMode?: OrderParams['marginMode']; + accountContext: AccountSupportContext; }; type Hip3TransferInfo = { @@ -947,6 +957,7 @@ type Hip3TransferInfo = { }; type Hip3TransferContext = { + accountContext: AccountSupportContext; dexName: string; transferInfo: Hip3TransferInfo; }; @@ -1022,6 +1033,7 @@ type HyperLiquidProviderOptions = { }; type HandleHip3PreOrderParams = { + accountContext: AccountSupportContext; dexName: string; symbol: string; orderPrice: number; @@ -1044,6 +1056,7 @@ type SubmitOrderWithRollbackParams = { symbol: string; assetId: number; chargesMetamaskBuilderFee: boolean; + accountContext: AccountSupportContext; builderFeeSetupContext?: BuilderFeeSetupContext; }; @@ -1070,6 +1083,7 @@ type GetOrFetchPriceParams = { * drift apart on validation, readiness, or leverage. */ type StrategyPlacementContext = { + accountContext: AccountSupportContext; assetId: number; szDecimals: number; formattedSize: string; @@ -1143,6 +1157,7 @@ const createScaleOrderIdentity = (count: number): ScaleOrderIdentity => { * A running chase: the order currently resting, and the loop re-pricing it. */ type ChaseSession = { + accountContext: AccountSupportContext; symbol: string; assetId: number; isBuy: boolean; @@ -3043,14 +3058,20 @@ export class HyperLiquidProvider implements PerpsProvider { * Stop an action before signing when Hyperliquid reports a native multi-sig * signer set for the active account. * + * @param expectedContext - Existing operation context to validate, or omit + * to capture the currently selected account and network. * @returns The account context whose support was checked. */ - async #assertAccountSupported(): Promise { - const context: AccountSupportContext = { - lifecycleGeneration: this.#lifecycleGeneration, - network: this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet', - userAddress: await this.#walletService.getUserAddressWithDefault(), - }; + async #assertAccountSupported( + expectedContext?: AccountSupportContext, + ): Promise { + const context = + expectedContext ?? + ({ + lifecycleGeneration: this.#lifecycleGeneration, + network: this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet', + userAddress: await this.#walletService.getUserAddressWithDefault(), + } satisfies AccountSupportContext); const support = await this.#getAccountSupportForContext(context); await this.#assertAccountContextCurrent(context, 'Account support check'); if (!support.isSupported) { @@ -3070,10 +3091,13 @@ export class HyperLiquidProvider implements PerpsProvider { * * @param options - Optional configuration. * @param options.allowUserSigning - When true, runs the migration for `default` / `disabled` accounts. Defaults to false so init does not surface a signing prompt; action-time entry points (trading, withdraw) pass true. + * @param options.accountContext - Account/network/lifecycle captured by the + * action that is allowed to authorize the migration. * @private */ async #ensureUnifiedAccountEnabled(options?: { allowUserSigning?: boolean; + accountContext?: AccountSupportContext; }): Promise { // Without an agent, the migration is signed by the main wallet, which can // prompt (hardware wallets). Init calls with allowUserSigning=false so @@ -3093,8 +3117,13 @@ export class HyperLiquidProvider implements PerpsProvider { return; // Feature disabled } - const userAddress = await this.#walletService.getUserAddressWithDefault(); - const network = this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet'; + const { accountContext } = options ?? {}; + const userAddress = + accountContext?.userAddress ?? + (await this.#walletService.getUserAddressWithDefault()); + const network = + accountContext?.network ?? + (this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet'); // Check global cache first to avoid repeated signing requests // This is CRITICAL for hardware wallets to prevent repeated signing prompts @@ -3198,6 +3227,12 @@ export class HyperLiquidProvider implements PerpsProvider { currentMode = await infoClient.userAbstraction({ user: userAddress, }); + if (accountContext) { + await this.#assertNoKnownUnsupportedAccount( + accountContext, + 'Unified account mode lookup', + ); + } if ( currentMode === 'unifiedAccount' || @@ -3306,7 +3341,14 @@ export class HyperLiquidProvider implements PerpsProvider { }, ); - await this.#clientService.getExchangeClient().agentSetAbstraction({ + if (accountContext) { + await this.#assertNoKnownUnsupportedAccount( + accountContext, + 'Unified account migration', + ); + } + const exchangeClient = this.#clientService.getExchangeClient(); + await exchangeClient.agentSetAbstraction({ abstraction: HL_ABSTRACTION_WIRE.unifiedAccount, }); @@ -3422,6 +3464,15 @@ export class HyperLiquidProvider implements PerpsProvider { return; } + if ( + accountContext && + ensureError(error, 'HyperLiquidProvider.ensureUnifiedAccountEnabled') + .message === PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE + ) { + completeInFlight(); + throw error; + } + // Agent-path failures and read-only userAbstraction lookup failures // are not final: signal #ensureReady to drop its memoized promise and // retry on the next entry instead of pinning the user in the @@ -3609,16 +3660,24 @@ export class HyperLiquidProvider implements PerpsProvider { * code (the approval is otherwise non-blocking). * @param options.reportRefusal - Throw the venue's refusal, even without an * approval failure code. + * @param accountContext - Account/network/lifecycle captured by the action + * that is allowed to authorize this approval. * @returns The account, network, and configured builder for the action. */ async #ensureBuilderFeeSetup( approvalFailureCode?: PerpsErrorCode, options: { reportSignerFailure?: boolean; reportRefusal?: boolean } = {}, + accountContext?: AccountSupportContext, ): Promise { - const lifecycleGeneration = this.#lifecycleGeneration; - const isTestnet = this.#clientService.isTestnetMode(); - const network = isTestnet ? 'testnet' : 'mainnet'; - const userAddress = await this.#walletService.getUserAddressWithDefault(); + const lifecycleGeneration = + accountContext?.lifecycleGeneration ?? this.#lifecycleGeneration; + const network = + accountContext?.network ?? + (this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet'); + const isTestnet = network === 'testnet'; + const userAddress = + accountContext?.userAddress ?? + (await this.#walletService.getUserAddressWithDefault()); const cacheKey = this.#getCacheKey(network, userAddress); const builderAddress = this.#getBuilderAddress(isTestnet); const context: BuilderFeeSetupContext = { @@ -3656,7 +3715,7 @@ export class HyperLiquidProvider implements PerpsProvider { let pendingApproval = this.#builderFeeSetupPromises.get(setupKey); if (!pendingApproval) { - pendingApproval = this.#ensureBuilderFeeApproval(context); + pendingApproval = this.#ensureBuilderFeeApproval(context, accountContext); this.#builderFeeSetupPromises.set(setupKey, pendingApproval); } @@ -3702,19 +3761,15 @@ export class HyperLiquidProvider implements PerpsProvider { requiresBuilderFee: true; builderFeeApprovalFailureCode?: PerpsErrorCode; recheckPendingReferral?: boolean; - }): Promise; - - #ensureReadyForTrading(options: { - requiresBuilderFee: false; - builderFeeApprovalFailureCode?: PerpsErrorCode; - recheckPendingReferral?: boolean; - }): Promise; + accountContext?: AccountSupportContext; + }): Promise; #ensureReadyForTrading(options: { requiresBuilderFee: boolean; builderFeeApprovalFailureCode?: PerpsErrorCode; recheckPendingReferral?: boolean; - }): Promise; + accountContext?: AccountSupportContext; + }): Promise; async #ensureReadyForTrading(options: { requiresBuilderFee: boolean; @@ -3722,15 +3777,22 @@ export class HyperLiquidProvider implements PerpsProvider { // Run the shared setup again to check a builder referral code that was // not ready. Only preparation asks for it; orders do not. recheckPendingReferral?: boolean; - }): Promise { + // Bind setup to a context captured before operation-specific reads. + accountContext?: AccountSupportContext; + }): Promise { // First ensure basic initialization is complete await this.#ensureReady(); - const accountContext = await this.#assertAccountSupported(); + const accountContext = await this.#assertAccountSupported( + options.accountContext, + ); // The migration was deferred during init to avoid a signing prompt on // Perps section open. Drive it here, gated by its own cache so // already-migrated users are not re-prompted. - await this.#ensureUnifiedAccountEnabled({ allowUserSigning: true }); + await this.#ensureUnifiedAccountEnabled({ + allowUserSigning: true, + accountContext, + }); await this.#assertNoKnownUnsupportedAccount( accountContext, 'Trading setup', @@ -3763,7 +3825,7 @@ export class HyperLiquidProvider implements PerpsProvider { } // Set up referral code independently from builder-fee applicability. - await this.#ensureReferralSet(); + await this.#ensureReferralSet(accountContext); this.#assertProviderLifecycleCurrent( lifecycleGeneration, @@ -3794,7 +3856,11 @@ export class HyperLiquidProvider implements PerpsProvider { } const builderFeeSetupContext = options.requiresBuilderFee - ? await this.#ensureBuilderFeeSetup(options.builderFeeApprovalFailureCode) + ? await this.#ensureBuilderFeeSetup( + options.builderFeeApprovalFailureCode, + {}, + accountContext, + ) : undefined; await this.#assertNoKnownUnsupportedAccount( accountContext, @@ -3805,7 +3871,10 @@ export class HyperLiquidProvider implements PerpsProvider { '[ensureReadyForTrading] Trading setup complete', ); - return builderFeeSetupContext; + return { + accountContext, + ...(builderFeeSetupContext && { builderFeeSetupContext }), + }; } /** @@ -5239,11 +5308,15 @@ export class HyperLiquidProvider implements PerpsProvider { * @param params.network - HyperLiquid network for the approval. * @param params.userAddress - Account that owns the approval. * @param params.builderAddress - Builder address being approved. + * @param accountContext - Account/network/lifecycle captured by the action + * that is allowed to authorize this approval. */ async #ensureBuilderFeeApproval( params: BuilderFeeSetupContext, + accountContext?: AccountSupportContext, ): Promise { - const lifecycleGeneration = this.#lifecycleGeneration; + const lifecycleGeneration = + accountContext?.lifecycleGeneration ?? this.#lifecycleGeneration; const { network, userAddress, builderAddress } = params; this.#assertProviderLifecycleCurrent( lifecycleGeneration, @@ -5322,10 +5395,17 @@ export class HyperLiquidProvider implements PerpsProvider { builderAddress, userAddress, ); - this.#assertProviderLifecycleCurrent( - lifecycleGeneration, - 'Builder fee approval', - ); + if (accountContext) { + await this.#assertNoKnownUnsupportedAccount( + accountContext, + 'Builder fee approval lookup', + ); + } else { + this.#assertProviderLifecycleCurrent( + lifecycleGeneration, + 'Builder fee approval', + ); + } if (isApproved) { // User already has approval on-chain @@ -5348,6 +5428,12 @@ export class HyperLiquidProvider implements PerpsProvider { { builder: builderAddress, requiredDecimal }, ); + if (accountContext) { + await this.#assertNoKnownUnsupportedAccount( + accountContext, + 'Builder fee approval', + ); + } const exchangeClient = this.#clientService.getExchangeClient(); const maxFeeRate = BUILDER_FEE_CONFIG.MaxFeeRate; @@ -5659,14 +5745,17 @@ export class HyperLiquidProvider implements PerpsProvider { * @param params - Transfer parameters * @param params.targetDex - HIP-3 DEX name (e.g., 'xyz') * @param params.requiredMargin - Required margin with buffer + * @param params.accountContext - Account/network/lifecycle captured by the + * order that may authorize a transfer. * @returns Transfer info for rollback, or null if no transfer needed * @private */ async #autoTransferForHip3Order(params: { targetDex: string; requiredMargin: number; + accountContext?: AccountSupportContext; }): Promise<{ amount: number; sourceDex: string } | null> { - const { targetDex, requiredMargin } = params; + const { targetDex, requiredMargin, accountContext } = params; // Check target DEX balance const targetBalance = await this.#getBalanceForDex({ dex: targetDex }); @@ -5714,11 +5803,17 @@ export class HyperLiquidProvider implements PerpsProvider { }, ); - const result = await this.transferBetweenDexs({ + const transferParams = { sourceDex: source.sourceDex, destinationDex: targetDex, amount: transferAmount, - }); + }; + const result = accountContext + ? await this.#transferBetweenDexsForContext( + transferParams, + accountContext, + ) + : await this.transferBetweenDexs(transferParams); if (!result.success) { // The signer could not sign the transfer: retryable, not a defect. @@ -5759,6 +5854,8 @@ export class HyperLiquidProvider implements PerpsProvider { * @param params.freedMargin - Amount of margin released from position close * @param params.transferAll - (Future) Transfer all available balance instead * @param params.skipTransfer - (Future) Skip auto-transfer if disabled + * @param params.accountContext - Original action context to preserve across + * the balance read and transfer. * @returns Transfer info if successful, null if skipped/failed * @private */ @@ -5767,12 +5864,14 @@ export class HyperLiquidProvider implements PerpsProvider { freedMargin: number; transferAll?: boolean; skipTransfer?: boolean; + accountContext?: AccountSupportContext; }): Promise<{ amount: number; destinationDex: string } | null> { const { sourceDex, freedMargin, transferAll = false, skipTransfer = false, + accountContext, } = params; // Future: Check user preference to skip auto-transfer @@ -5819,11 +5918,17 @@ export class HyperLiquidProvider implements PerpsProvider { }); // Execute transfer back to main DEX (empty string '' represents main DEX) - const result = await this.transferBetweenDexs({ + const transferParams = { sourceDex, destinationDex: '', amount: transferAmount.toFixed(USDC_DECIMALS), - }); + }; + const result = accountContext + ? await this.#transferBetweenDexsForContext( + transferParams, + accountContext, + ) + : await this.transferBetweenDexs(transferParams); if (!result.success) { this.#deps.debugLogger.log('❌ Auto-transfer back failed', { @@ -5975,13 +6080,14 @@ export class HyperLiquidProvider implements PerpsProvider { * @param params.transferInfo - The transfer information. * @param params.transferInfo.amount - The amount value. * @param params.transferInfo.sourceDex - The source DEX for the transfer. + * @param params.accountContext - Original action context for any rebalance. * @returns Whether the balance check and any required transfer succeeded. * @private */ async #handleHip3PostOrderRebalance( params: Hip3TransferContext, ): Promise { - const { dexName, transferInfo } = params; + const { accountContext, dexName, transferInfo } = params; try { const postOrderBalance = await this.#getBalanceForDex({ dex: dexName }); @@ -6018,11 +6124,14 @@ export class HyperLiquidProvider implements PerpsProvider { }, ); - const transferResult = await this.transferBetweenDexs({ - sourceDex: dexName, - destinationDex: transferInfo.sourceDex, - amount: excessAmount.toFixed(USDC_DECIMALS), - }); + const transferResult = await this.#transferBetweenDexsForContext( + { + sourceDex: dexName, + destinationDex: transferInfo.sourceDex, + amount: excessAmount.toFixed(USDC_DECIMALS), + }, + accountContext, + ); if (!transferResult.success) { // The signer could not sign the transfer: retryable, not a defect. if (transferResult.error === PERPS_ERROR_CODES.KEYRING_LOCKED) { @@ -6112,10 +6221,11 @@ export class HyperLiquidProvider implements PerpsProvider { * @param params.transferInfo - The transfer information. * @param params.transferInfo.amount - The amount value. * @param params.transferInfo.sourceDex - The source DEX for the transfer. + * @param params.accountContext - Original action context for the rollback. * @private */ async #handleHip3OrderRollback(params: Hip3TransferContext): Promise { - const { dexName, transferInfo } = params; + const { accountContext, dexName, transferInfo } = params; try { this.#deps.debugLogger.log( @@ -6128,11 +6238,14 @@ export class HyperLiquidProvider implements PerpsProvider { }, ); - const rollbackResult = await this.transferBetweenDexs({ - sourceDex: dexName, // From HIP-3 DEX - destinationDex: transferInfo.sourceDex, // Back to source - amount: transferInfo.amount.toFixed(USDC_DECIMALS), - }); + const rollbackResult = await this.#transferBetweenDexsForContext( + { + sourceDex: dexName, // From HIP-3 DEX + destinationDex: transferInfo.sourceDex, // Back to source + amount: transferInfo.amount.toFixed(USDC_DECIMALS), + }, + accountContext, + ); if (rollbackResult.success) { this.#deps.debugLogger.log( @@ -6358,7 +6471,7 @@ export class HyperLiquidProvider implements PerpsProvider { async #prepareAssetForTrading( params: PrepareAssetForTradingParams, ): Promise { - const { symbol, assetId, leverage } = params; + const { symbol, assetId, leverage, accountContext } = params; if (!leverage) { return; @@ -6374,6 +6487,10 @@ export class HyperLiquidProvider implements PerpsProvider { leverageType: marginMode, }); + await this.#assertNoKnownUnsupportedAccount( + accountContext, + 'Leverage update', + ); const exchangeClient = this.#clientService.getExchangeClient(); const leverageResult = await exchangeClient.updateLeverage({ asset: assetId, @@ -6434,8 +6551,15 @@ export class HyperLiquidProvider implements PerpsProvider { async #handleHip3PreOrder( params: HandleHip3PreOrderParams, ): Promise { - const { dexName, symbol, orderPrice, positionSize, leverage, isBuy } = - params; + const { + accountContext, + dexName, + symbol, + orderPrice, + positionSize, + leverage, + isBuy, + } = params; // TAT-3304: Only USDC-collateral HIP-3 DEXs are supported for trading. // Following the USDH sunset, reject orders on any non-USDC-collateral @@ -6482,6 +6606,7 @@ export class HyperLiquidProvider implements PerpsProvider { const transferInfo = await this.#autoTransferForHip3Order({ targetDex: dexName, requiredMargin: requiredMarginWithBuffer, + accountContext, }); return { transferInfo }; } catch (transferError) { @@ -6516,7 +6641,11 @@ export class HyperLiquidProvider implements PerpsProvider { const builder = params.chargesMetamaskBuilderFee ? await this.#getBuilderOrderContext( params.builderFeeSetupContext ?? - (await this.#ensureBuilderFeeSetup()), + (await this.#ensureBuilderFeeSetup( + undefined, + {}, + params.accountContext, + )), ) : undefined; @@ -6530,6 +6659,10 @@ export class HyperLiquidProvider implements PerpsProvider { }); try { + await this.#assertNoKnownUnsupportedAccount( + params.accountContext, + 'Order submission', + ); const result = await exchangeClient.order({ orders: this.#applySubscriptionCloid(orders), grouping, @@ -6553,7 +6686,11 @@ export class HyperLiquidProvider implements PerpsProvider { // Success - auto-rebalance excess funds if (isHip3Order && transferInfo && dexName) { - await this.#handleHip3PostOrderRebalance({ dexName, transferInfo }); + await this.#handleHip3PostOrderRebalance({ + accountContext: params.accountContext, + dexName, + transferInfo, + }); } return { @@ -6573,7 +6710,11 @@ export class HyperLiquidProvider implements PerpsProvider { } catch (orderError) { // Failure - rollback transfer if (transferInfo && dexName) { - await this.#handleHip3OrderRollback({ dexName, transferInfo }); + await this.#handleHip3OrderRollback({ + accountContext: params.accountContext, + dexName, + transferInfo, + }); } throw orderError; } @@ -6747,9 +6888,10 @@ export class HyperLiquidProvider implements PerpsProvider { // Kept after validation so invalid orders never trigger signature prompts // (builder-fee approval, DEX abstraction enablement, etc.). const { chargesMetamaskBuilderFee } = this.#resolveOrderFeePolicy(params); - const builderFeeSetupContext = await this.#ensureReadyForTrading({ - requiresBuilderFee: chargesMetamaskBuilderFee, - }); + const { accountContext, builderFeeSetupContext } = + await this.#ensureReadyForTrading({ + requiresBuilderFee: chargesMetamaskBuilderFee, + }); // Debug: Log asset map state before order placement const allMapKeys = Array.from(this.#symbolToAssetId.keys()); @@ -6816,6 +6958,7 @@ export class HyperLiquidProvider implements PerpsProvider { assetId, leverage: params.leverage, marginMode: params.marginMode, + accountContext, }); // 6. Handle HIP-3 balance management (if applicable) @@ -6825,6 +6968,7 @@ export class HyperLiquidProvider implements PerpsProvider { if (isHip3Order && dexName) { const effectiveLeverage = params.leverage ?? assetInfo.maxLeverage ?? 1; const hip3Result = await this.#handleHip3PreOrder({ + accountContext, dexName, symbol: params.symbol, orderPrice, @@ -6869,6 +7013,7 @@ export class HyperLiquidProvider implements PerpsProvider { symbol: params.symbol, assetId, chargesMetamaskBuilderFee, + accountContext, builderFeeSetupContext, }); } catch (error) { @@ -7053,14 +7198,19 @@ export class HyperLiquidProvider implements PerpsProvider { context: StrategyPlacementContext, submit: () => Promise, ): Promise { - const { dexName, network, transferInfo, userAddress } = context; + const { accountContext, dexName, network, transferInfo, userAddress } = + context; let result: OrderResult; try { result = await submit(); } catch (error) { if (dexName && transferInfo) { - await this.#handleHip3OrderRollback({ dexName, transferInfo }); + await this.#handleHip3OrderRollback({ + accountContext, + dexName, + transferInfo, + }); } throw error; } @@ -7072,7 +7222,11 @@ export class HyperLiquidProvider implements PerpsProvider { (result.childOrderIds?.length ?? 0) > 0 || (resultFilledSize.isFinite() && resultFilledSize.gt(0)); if (dexName && transferInfo && !hasVenueExposure) { - await this.#handleHip3OrderRollback({ dexName, transferInfo }); + await this.#handleHip3OrderRollback({ + accountContext, + dexName, + transferInfo, + }); return result; } @@ -7086,7 +7240,13 @@ export class HyperLiquidProvider implements PerpsProvider { { symbol: params.symbol, ...(dexName && transferInfo - ? { hip3Transfer: { dexName, transferInfo } } + ? { + hip3Transfer: { + accountContext, + dexName, + transferInfo, + }, + } : {}), }, ); @@ -7102,7 +7262,11 @@ export class HyperLiquidProvider implements PerpsProvider { ) { const session = this.#chaseSessions.get(result.orderId); if (session) { - session.hip3Transfer = { dexName, transferInfo }; + session.hip3Transfer = { + accountContext, + dexName, + transferInfo, + }; return result; } } @@ -7116,7 +7280,11 @@ export class HyperLiquidProvider implements PerpsProvider { } if (dexName && transferInfo) { - await this.#handleHip3PostOrderRebalance({ dexName, transferInfo }); + await this.#handleHip3PostOrderRebalance({ + accountContext, + dexName, + transferInfo, + }); } return result; @@ -7213,9 +7381,10 @@ export class HyperLiquidProvider implements PerpsProvider { // Kept after validation so an invalid strategy order never triggers the // signature prompts in trading setup — same ordering as `placeOrder`. const { chargesMetamaskBuilderFee } = this.#resolveOrderFeePolicy(params); - const builderFeeSetupContext = chargesMetamaskBuilderFee + const tradingActionContext = chargesMetamaskBuilderFee ? await this.#ensureReadyForTrading({ requiresBuilderFee: true }) : await this.#ensureReadyForTrading({ requiresBuilderFee: false }); + const { accountContext, builderFeeSetupContext } = tradingActionContext; const assetId = await this.#getAssetIdWithRepair({ symbol: params.symbol, @@ -7228,15 +7397,13 @@ export class HyperLiquidProvider implements PerpsProvider { assetId, leverage: params.leverage, marginMode: params.marginMode, + accountContext, }); const builder = builderFeeSetupContext ? await this.#getBuilderOrderContext(builderFeeSetupContext) : undefined; - const network = this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet'; - const userAddress = - builderFeeSetupContext?.userAddress ?? - (await this.#walletService.getUserAddressWithDefault()); + const { network, userAddress } = accountContext; let transferInfo: Hip3TransferInfo | null = null; if (dexName) { @@ -7245,6 +7412,7 @@ export class HyperLiquidProvider implements PerpsProvider { ? effectivePrice : Math.max(...ladder.prices.map(Number.parseFloat)); const hip3Result = await this.#handleHip3PreOrder({ + accountContext, dexName, symbol: params.symbol, orderPrice, @@ -7257,6 +7425,7 @@ export class HyperLiquidProvider implements PerpsProvider { } return { + accountContext, assetId, szDecimals: assetInfo.szDecimals, formattedSize, @@ -7363,7 +7532,7 @@ export class HyperLiquidProvider implements PerpsProvider { context: StrategyPlacementContext, generation: number, ): Promise { - const { assetId, formattedSize } = context; + const { accountContext, assetId, formattedSize } = context; const durationMinutes = params.twapDuration; if (durationMinutes === undefined) { throw new Error(PERPS_ERROR_CODES.ORDER_TWAP_DURATION_REQUIRED); @@ -7385,6 +7554,10 @@ export class HyperLiquidProvider implements PerpsProvider { randomize: params.twapRandomize ?? false, }); + await this.#assertNoKnownUnsupportedAccount( + accountContext, + 'TWAP order submission', + ); const result = await exchangeClient.twapOrder({ twap: { a: assetId, @@ -7486,7 +7659,7 @@ export class HyperLiquidProvider implements PerpsProvider { context: StrategyPlacementContext, generation: number, ): Promise { - const { assetId, formattedSize, ladder, builder } = context; + const { accountContext, assetId, formattedSize, ladder, builder } = context; // Built and validated in `#prepareStrategyPlacement`, before anything was // signed, so what is submitted here is exactly what the minimums were // applied to. @@ -7536,6 +7709,10 @@ export class HyperLiquidProvider implements PerpsProvider { let result: ScaleBulkOrderResponse; let thrownBulkOrderError: HyperliquidError | undefined; try { + await this.#assertNoKnownUnsupportedAccount( + accountContext, + 'Scale order submission', + ); result = await exchangeClient.order({ orders, grouping: 'na', @@ -7787,7 +7964,8 @@ export class HyperLiquidProvider implements PerpsProvider { context: StrategyPlacementContext, generation: number, ): Promise { - const { assetId, szDecimals, formattedSize, builder } = context; + const { accountContext, assetId, szDecimals, formattedSize, builder } = + context; // Captured now, alongside the builder fee and for the same reason: the fee // resolution behind it is cleared when the caller's `placeOrder` returns, // which for a chase is before any replacement runs. @@ -7846,6 +8024,7 @@ export class HyperLiquidProvider implements PerpsProvider { reduceOnly: params.reduceOnly ?? false, builder, marksSubscriptionCloid, + accountContext, exchangeClient: placingClient, }); break; @@ -7884,6 +8063,7 @@ export class HyperLiquidProvider implements PerpsProvider { params.chaseIntervalMs ?? CHASE_ORDER_CONFIG.DefaultIntervalMs; const sessionId = generatePerpsId('chase'); const session: ChaseSession = { + accountContext, symbol: params.symbol, assetId, isBuy: params.isBuy, @@ -8046,6 +8226,8 @@ export class HyperLiquidProvider implements PerpsProvider { * than looked up here so a first placement can keep the instance it signed * with, which is the only one that can take the order back once `disconnect` * has dropped the service's reference. + * @param params.accountContext - Account/network/lifecycle captured by the + * action that owns the chase. * @returns The resting order's exchange ID. */ async #restChaseOrder(params: { @@ -8056,8 +8238,13 @@ export class HyperLiquidProvider implements PerpsProvider { reduceOnly: boolean; builder?: BuilderOrderContext; marksSubscriptionCloid?: boolean; + accountContext: AccountSupportContext; exchangeClient: ExchangeClient; }): Promise { + await this.#assertNoKnownUnsupportedAccount( + params.accountContext, + 'Chase order submission', + ); const result = await params.exchangeClient.order({ orders: this.#applySubscriptionCloid( [ @@ -8460,8 +8647,7 @@ export class HyperLiquidProvider implements PerpsProvider { reduceOnly: session.reduceOnly, builder: session.builder, marksSubscriptionCloid: session.marksSubscriptionCloid, - // A running session is on a live provider, so the current client is - // the right one; only the first placement has a teardown to survive. + accountContext: session.accountContext, exchangeClient: this.#clientService.getExchangeClient(), }); session.size = remaining; @@ -8742,6 +8928,12 @@ export class HyperLiquidProvider implements PerpsProvider { // Looked up only once there is something to cancel, so a session with // nothing resting still answers on a provider whose client is already gone. + if (!placingClient) { + await this.#assertNoKnownUnsupportedAccount( + session.accountContext, + 'Chase cancellation', + ); + } const exchangeClient = placingClient ?? this.#clientService.getExchangeClient(); let result; @@ -9304,10 +9496,11 @@ export class HyperLiquidProvider implements PerpsProvider { throw new Error(PERPS_ERROR_CODES.ORDER_STRATEGY_HANDLE_UNKNOWN); } - await this.#ensureReadyForTrading({ requiresBuilderFee: false }); + const { accountContext } = await this.#ensureReadyForTrading({ + requiresBuilderFee: false, + }); - const network = this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet'; - const userAddress = await this.#walletService.getUserAddressWithDefault(); + const { network, userAddress } = accountContext; const trackingKey = getTwapOrderScopeKey({ network, userAddress, @@ -9351,6 +9544,10 @@ export class HyperLiquidProvider implements PerpsProvider { dexName: parseAssetName(params.symbol).dex, }); + await this.#assertNoKnownUnsupportedAccount( + accountContext, + 'TWAP cancellation', + ); const result = await this.#clientService.getExchangeClient().twapCancel({ a: assetId, t: twapId, @@ -9390,7 +9587,9 @@ export class HyperLiquidProvider implements PerpsProvider { throw new Error(PERPS_ERROR_CODES.ORDER_STRATEGY_HANDLE_UNKNOWN); } - await this.#ensureReadyForTrading({ requiresBuilderFee: false }); + const { accountContext } = await this.#ensureReadyForTrading({ + requiresBuilderFee: false, + }); const assetId = await this.#getAssetIdWithRepair({ symbol: group.symbol, @@ -9405,6 +9604,10 @@ export class HyperLiquidProvider implements PerpsProvider { asset: assetId, cloid: clientOrderId, })); + await this.#assertNoKnownUnsupportedAccount( + accountContext, + 'Scale cancellation', + ); const [orderCancellation, cloidCancellation] = await Promise.all([ this.#cancelOrderRequestBatch(exchangeClient, cancelRequests), this.#cancelOrderCloidRequestBatch(exchangeClient, cancelByCloidRequests), @@ -9524,7 +9727,24 @@ export class HyperLiquidProvider implements PerpsProvider { return { success: true, orderId: params.orderId }; } - await this.#ensureReadyForTrading({ requiresBuilderFee: false }); + this.#assertProviderLifecycleCurrent( + session.accountContext.lifecycleGeneration, + 'Chase cancellation setup', + ); + const currentAddress = + await this.#walletService.getUserAddressWithDefault(); + if ( + currentAddress.toLowerCase() !== + session.accountContext.userAddress.toLowerCase() || + (this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet') !== + session.accountContext.network + ) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + await this.#ensureReadyForTrading({ + requiresBuilderFee: false, + accountContext: session.accountContext, + }); // Only a refusal leaves an order behind. A child that had already filled or // been cancelled is reported as a rejection too, but nothing of it is // resting — treating that as a failure would pin the handle open forever on @@ -10348,13 +10568,19 @@ export class HyperLiquidProvider implements PerpsProvider { // Every refusal is behind us, so shared trading readiness can run now. // HyperLiquid's modify action has no builder field and must not request a // builder-fee approval. - await this.#ensureReadyForTrading({ requiresBuilderFee: false }); + const { accountContext } = await this.#ensureReadyForTrading({ + requiresBuilderFee: false, + }); // Submit modification via SDK. The cloid is deliberately left unmarked: // `modify` carries no builder field, as the readiness call above records, // so no MetaMask fee is charged on this action and marking it would tell // the fill fan-out a reduction applied to an order that paid nothing. // The replacement inherits the resting order's own attribution. + await this.#assertNoKnownUnsupportedAccount( + accountContext, + 'Order modification', + ); const exchangeClient = this.#clientService.getExchangeClient(); const result = await exchangeClient.modify({ oid: @@ -10470,14 +10696,20 @@ export class HyperLiquidProvider implements PerpsProvider { throw new Error(coinValidation.error); } - await this.#ensureReadyForTrading({ requiresBuilderFee: false }); + const { accountContext } = await this.#ensureReadyForTrading({ + requiresBuilderFee: false, + }); - const exchangeClient = this.#clientService.getExchangeClient(); const asset = await this.#getAssetIdWithRepair({ symbol: params.symbol, dexName: parseAssetName(params.symbol).dex, }); + await this.#assertNoKnownUnsupportedAccount( + accountContext, + 'Order cancellation', + ); + const exchangeClient = this.#clientService.getExchangeClient(); const result = await exchangeClient.cancel({ cancels: [ { @@ -10598,8 +10830,9 @@ export class HyperLiquidProvider implements PerpsProvider { if (ordinaryOrders.length > 0) { // Cancellation carries no builder context, so it must not prompt for a // fee approval that the action cannot use. - await this.#ensureReadyForTrading({ requiresBuilderFee: false }); - const exchangeClient = this.#clientService.getExchangeClient(); + const { accountContext } = await this.#ensureReadyForTrading({ + requiresBuilderFee: false, + }); const cancelRequests = await Promise.all( ordinaryOrders.map(async ({ order }) => { const asset = await this.#getAssetIdWithRepair({ @@ -10612,6 +10845,11 @@ export class HyperLiquidProvider implements PerpsProvider { }; }), ); + await this.#assertNoKnownUnsupportedAccount( + accountContext, + 'Batch order cancellation', + ); + const exchangeClient = this.#clientService.getExchangeClient(); let statuses: unknown[] | undefined; let answer: unknown; try { @@ -10711,7 +10949,9 @@ export class HyperLiquidProvider implements PerpsProvider { try { // Batch preparation needs trading readiness but not builder approval yet. // The provider-owned market policy is resolved from the positions below. - await this.#ensureReadyForTrading({ requiresBuilderFee: false }); + const { accountContext } = await this.#ensureReadyForTrading({ + requiresBuilderFee: false, + }); // Selected symbols only need the DEXes they belong to. Loading every // market group would refuse a BTC close when an unrelated HIP-3 DEX @@ -10792,9 +11032,6 @@ export class HyperLiquidProvider implements PerpsProvider { }; } - // Get exchange client for order submission - const exchangeClient = this.#clientService.getExchangeClient(); - // Pre-fetch meta for all unique DEXs to avoid N API calls in loop const uniqueDexs = [ ...new Set( @@ -10953,13 +11190,30 @@ export class HyperLiquidProvider implements PerpsProvider { (context) => this.#resolveOrderFeePolicy(context).chargesMetamaskBuilderFee, ); - const builder = chargesMetamaskBuilderFee - ? await this.#getBuilderOrderContext( - await this.#ensureReadyForTrading({ requiresBuilderFee: true }), - ) - : undefined; + let builder: BuilderOrderContext | undefined; + if (chargesMetamaskBuilderFee) { + await this.#assertNoKnownUnsupportedAccount( + accountContext, + 'Batch close setup', + ); + const builderFeeSetupContext = await this.#ensureBuilderFeeSetup( + undefined, + {}, + accountContext, + ); + await this.#assertNoKnownUnsupportedAccount( + accountContext, + 'Batch close setup', + ); + builder = await this.#getBuilderOrderContext(builderFeeSetupContext); + } // Single batch API call + await this.#assertNoKnownUnsupportedAccount( + accountContext, + 'Batch close submission', + ); + const exchangeClient = this.#clientService.getExchangeClient(); const result = await exchangeClient.order({ orders: this.#applySubscriptionCloid(orders), grouping: 'na', @@ -10998,6 +11252,7 @@ export class HyperLiquidProvider implements PerpsProvider { // Non-blocking: Transfer freed margin back to main DEX await this.#autoTransferBackAfterClose({ + accountContext, sourceDex, freedMargin, }); @@ -11147,6 +11402,11 @@ export class HyperLiquidProvider implements PerpsProvider { const lifecycle = this.#lifecycleGeneration; const isTestnet = this.#clientService.isTestnetMode(); const userAddress = await this.#walletService.getUserAddressWithDefault(); + const accountContext: AccountSupportContext = { + lifecycleGeneration: lifecycle, + network: isTestnet ? 'testnet' : 'mainnet', + userAddress, + }; const assertScope = async (): Promise => { const current = await this.#walletService.getUserAddressWithDefault(); this.#assertProviderLifecycleCurrent(lifecycle, 'updatePositionTPSL'); @@ -11643,12 +11903,18 @@ export class HyperLiquidProvider implements PerpsProvider { // Approval and builder-context resolution both finish before the // pre-cancel. A failure here therefore leaves the old protection intact. - const builderFeeSetupContext = requiresBuilderFee + await assertScope(); + const tradingActionContext = requiresBuilderFee ? await this.#ensureReadyForTrading({ requiresBuilderFee: true, builderFeeApprovalFailureCode: PERPS_ERROR_CODES.TPSL_UPDATE_FAILED, + accountContext, }) - : await this.#ensureReadyForTrading({ requiresBuilderFee: false }); + : await this.#ensureReadyForTrading({ + requiresBuilderFee: false, + accountContext, + }); + const { builderFeeSetupContext } = tradingActionContext; const builderOrderContext = builderFeeSetupContext ? await this.#getBuilderOrderContext(builderFeeSetupContext) : undefined; @@ -12170,7 +12436,9 @@ export class HyperLiquidProvider implements PerpsProvider { // The delegated placeOrder call resolves the builder-fee policy for the // concrete close order after validation. - await this.#ensureReadyForTrading({ requiresBuilderFee: false }); + const { accountContext } = await this.#ensureReadyForTrading({ + requiresBuilderFee: false, + }); // A caller snapshot is never authoritative for a reduce-only close. Use // the current DEX slice or its HTTP fallback, and fail closed if neither @@ -12275,6 +12543,10 @@ export class HyperLiquidProvider implements PerpsProvider { const isFullClose = closeSizeNum >= absPositionSize; // Execute position close with consistent slippage handling + await this.#assertNoKnownUnsupportedAccount( + accountContext, + 'Position close', + ); const result = await this.placeOrder( { symbol: params.symbol, @@ -12314,6 +12586,7 @@ export class HyperLiquidProvider implements PerpsProvider { // Non-blocking: Transfer freed margin back to main DEX await this.#autoTransferBackAfterClose({ + accountContext, sourceDex: hip3Dex, freedMargin, }); @@ -12417,7 +12690,10 @@ export class HyperLiquidProvider implements PerpsProvider { } // Call SDK to update isolated margin - await this.#assertAccountContextCurrent(accountContext, 'Margin update'); + await this.#assertNoKnownUnsupportedAccount( + accountContext, + 'Margin update', + ); const exchangeClient = this.#clientService.getExchangeClient(); const result = await exchangeClient.updateIsolatedMargin({ asset: assetId, @@ -15284,7 +15560,10 @@ export class HyperLiquidProvider implements PerpsProvider { this.#deps.debugLogger.log('HyperLiquidProvider: ENSURING CLIENT READY'); await this.#ensureReady(); const accountContext = await this.#assertAccountSupported(); - await this.#ensureUnifiedAccountEnabled({ allowUserSigning: true }); + await this.#ensureUnifiedAccountEnabled({ + allowUserSigning: true, + accountContext, + }); await this.#assertNoKnownUnsupportedAccount(accountContext, 'Withdrawal'); const exchangeClient = this.#clientService.getExchangeClient(); this.#deps.debugLogger.log('HyperLiquidProvider: CLIENT READY'); @@ -15454,8 +15733,13 @@ export class HyperLiquidProvider implements PerpsProvider { * amount: '10' * }); */ + transferBetweenDexs( + params: TransferBetweenDexsParams, + ): Promise; + async transferBetweenDexs( params: TransferBetweenDexsParams, + expectedContext?: AccountSupportContext, ): Promise { try { this.#deps.debugLogger.log('HyperLiquidProvider: STARTING DEX TRANSFER', { @@ -15474,13 +15758,12 @@ export class HyperLiquidProvider implements PerpsProvider { // Ensure client ready await this.#ensureReady(); - const accountContext = await this.#assertAccountSupported(); + const accountContext = + await this.#assertAccountSupported(expectedContext); const { userAddress } = accountContext; this.#deps.debugLogger.log('HyperLiquidProvider: USER ADDRESS', { userAddress, }); - const exchangeClient = this.#clientService.getExchangeClient(); - // Execute transfer using SDK sendAsset() // Note: SDK docs say "testnet-only" but it works on mainnet (verified via Phantom) this.#deps.debugLogger.log( @@ -15492,7 +15775,11 @@ export class HyperLiquidProvider implements PerpsProvider { }, ); - await this.#assertAccountContextCurrent(accountContext, 'DEX transfer'); + await this.#assertNoKnownUnsupportedAccount( + accountContext, + 'DEX transfer', + ); + const exchangeClient = this.#clientService.getExchangeClient(); const result = await exchangeClient.sendAsset({ destination: userAddress, sourceDex: params.sourceDex, @@ -15546,6 +15833,29 @@ export class HyperLiquidProvider implements PerpsProvider { } } + /** + * Invoke the public transfer path with an operation context that is hidden + * from the provider contract. + * + * The cast reaches the implementation-only second parameter while preserving + * the one-parameter public declaration. Calling through the public method + * also keeps existing instrumentation and test spies on that boundary. + * + * @param params - Transfer parameters. + * @param accountContext - Original action context for the transfer. + * @returns The transfer result. + */ + async #transferBetweenDexsForContext( + params: TransferBetweenDexsParams, + accountContext: AccountSupportContext, + ): Promise { + const transfer = this.transferBetweenDexs.bind(this) as unknown as ( + transferParams: TransferBetweenDexsParams, + expectedContext: AccountSupportContext, + ) => Promise; + return await transfer(params, accountContext); + } + /** * Subscribe to live price updates * @@ -15863,6 +16173,11 @@ export class HyperLiquidProvider implements PerpsProvider { try { const lifecycleGeneration = this.#lifecycleGeneration; const userAddress = await this.#walletService.getUserAddressWithDefault(); + const preparationContext: AccountSupportContext = { + lifecycleGeneration, + network: this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet', + userAddress, + }; // The result is only for the provider and account it started with. const assertPreparationCurrent = async (): Promise => { this.#assertProviderLifecycleCurrent( @@ -15877,13 +16192,13 @@ export class HyperLiquidProvider implements PerpsProvider { throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); } }; - await this.#ensureReadyForTrading({ + const { accountContext } = await this.#ensureReadyForTrading({ requiresBuilderFee: false, recheckPendingReferral: true, + accountContext: preparationContext, }); - const network = this.#clientService.isTestnetMode() - ? 'testnet' - : 'mainnet'; + await assertPreparationCurrent(); + const { network } = accountContext; const isRegistered = await this.#isWalletOnHyperliquid( userAddress, network, @@ -15899,10 +16214,14 @@ export class HyperLiquidProvider implements PerpsProvider { : PERPS_ERROR_CODES.KEYRING_LOCKED, }; } - await this.#ensureBuilderFeeSetup(undefined, { - reportSignerFailure: true, - reportRefusal: true, - }); + await this.#ensureBuilderFeeSetup( + undefined, + { + reportSignerFailure: true, + reportRefusal: true, + }, + accountContext, + ); // The builder fee setup ends quietly when the provider disconnects. await assertPreparationCurrent(); if (!this.#walletService.isMainAccountSignerReady()) { @@ -17029,20 +17348,29 @@ export class HyperLiquidProvider implements PerpsProvider { * * Note: This is network-specific - testnet and mainnet have separate referral states * Note: Non-blocking - failures are logged to Sentry but don't prevent trading + * + * @param accountContext - Account/network/lifecycle captured by the action + * that is allowed to authorize a referral write. */ - async #ensureReferralSet(): Promise { + async #ensureReferralSet( + accountContext?: AccountSupportContext, + ): Promise { this.#referralSetupNeedsRetry = false; this.#referralAwaitsBuilderCode = false; - const isTestnet = this.#clientService.isTestnetMode(); - const network = isTestnet ? 'testnet' : 'mainnet'; + const network = + accountContext?.network ?? + (this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet'); + const isTestnet = network === 'testnet'; const expectedReferralCode = this.#getReferralCode(isTestnet); const referrerAddress = this.#getBuilderAddress(isTestnet); - let userAddress: string; - try { - userAddress = await this.#walletService.getUserAddressWithDefault(); - } catch { - return; // Can't proceed without address + let userAddress = accountContext?.userAddress; + if (!userAddress) { + try { + userAddress = await this.#walletService.getUserAddressWithDefault(); + } catch { + return; // Can't proceed without address + } } if (userAddress.toLowerCase() === referrerAddress.toLowerCase()) { @@ -17121,6 +17449,12 @@ export class HyperLiquidProvider implements PerpsProvider { } const codeStatus = await this.#getReferralCodeStatus(); + if (accountContext) { + await this.#assertNoKnownUnsupportedAccount( + accountContext, + 'Referral code lookup', + ); + } if (codeStatus !== 'ready') { this.#deps.debugLogger.log( '[ensureReferralSet] Builder referral not ready, skipping', @@ -17134,7 +17468,13 @@ export class HyperLiquidProvider implements PerpsProvider { } // Check if user already has a referral on-chain - const hasReferral = await this.#checkReferralSet(); + const hasReferral = await this.#checkReferralSet(userAddress); + if (accountContext) { + await this.#assertNoKnownUnsupportedAccount( + accountContext, + 'Referral account lookup', + ); + } if (hasReferral) { // Already has referral on-chain @@ -17151,7 +17491,7 @@ export class HyperLiquidProvider implements PerpsProvider { '[ensureReferralSet] Setting referral (will show signing request)', { network, referralCode: expectedReferralCode }, ); - const result = await this.#setReferralCode(); + const result = await this.#setReferralCode(accountContext); if (result) { this.#deps.debugLogger.log( '[ensureReferralSet] Referral set successfully', @@ -17199,6 +17539,15 @@ export class HyperLiquidProvider implements PerpsProvider { return; } + if ( + accountContext && + ensureError(error, 'HyperLiquidProvider.ensureReferralSet').message === + PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE + ) { + completeInFlight(); + throw error; + } + // Cache failure to prevent retries PerpsSigningCache.setReferral(network, userAddress, { attempted: true, @@ -17278,20 +17627,23 @@ export class HyperLiquidProvider implements PerpsProvider { /** * Check if user has a referral code set with HyperLiquid * + * @param userAddress - Account to inspect, or the currently selected account + * when omitted. * @returns Promise resolving to true if referral is set, false otherwise */ - async #checkReferralSet(): Promise { + async #checkReferralSet(userAddress?: string): Promise { try { const infoClient = this.#clientService.getInfoClient(); - const userAddress = await this.#walletService.getUserAddressWithDefault(); + const referralUserAddress = + userAddress ?? (await this.#walletService.getUserAddressWithDefault()); // Call HyperLiquid API to check if user has a referral set const referralData = await infoClient.referral({ - user: userAddress, + user: referralUserAddress, }); this.#deps.debugLogger.log('Referral check result:', { - userAddress, + userAddress: referralUserAddress, referralData, }); @@ -17322,10 +17674,20 @@ export class HyperLiquidProvider implements PerpsProvider { /** * Set MetaMask as the user's referrer on HyperLiquid * + * @param accountContext - Account/network/lifecycle captured by the action + * that is allowed to authorize this write. * @returns A promise that resolves to the boolean result. */ - async #setReferralCode(): Promise { + async #setReferralCode( + accountContext?: AccountSupportContext, + ): Promise { try { + if (accountContext) { + await this.#assertNoKnownUnsupportedAccount( + accountContext, + 'Referral setup', + ); + } const exchangeClient = this.#clientService.getExchangeClient(); const referralCode = this.#getReferralCode( this.#clientService.isTestnetMode(), diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts index 4f9e1f7e644..376af5ef175 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts @@ -532,6 +532,127 @@ describe('HyperLiquidProvider', () => { expect(exchangeClient.withdraw3).not.toHaveBeenCalled(); }); + it('does not migrate the account selected after a delayed abstraction read', async () => { + const switchedAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + const exchangeClient = createMockExchangeClient(); + let resolveAbstraction!: (value: 'default') => void; + let markAbstractionReadStarted!: () => void; + const delayedAbstraction = new Promise<'default'>((resolve) => { + resolveAbstraction = resolve; + }); + const abstractionReadStarted = new Promise((resolve) => { + markAbstractionReadStarted = resolve; + }); + const userAbstraction = jest + .fn() + // Browsing initialization observes the legacy mode and defers. + .mockResolvedValueOnce('default') + // Action-time setup is the read whose result must stay bound to A. + .mockImplementationOnce(() => { + markAbstractionReadStarted(); + return delayedAbstraction; + }); + mockWalletService.requiresSignatureConfirmation.mockReturnValue(true); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction, + userToMultiSigSigners: jest.fn().mockResolvedValue(null), + }), + ); + + const order = provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }); + await abstractionReadStarted; + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + switchedAddress, + ); + resolveAbstraction('default'); + + await expect(order).resolves.toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + }); + expect(exchangeClient.agentSetAbstraction).not.toHaveBeenCalled(); + expect(exchangeClient.approveBuilderFee).not.toHaveBeenCalled(); + expect(exchangeClient.order).not.toHaveBeenCalled(); + }); + + it('does not update leverage or place an order after a delayed account read switches accounts', async () => { + const switchedAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + const exchangeClient = createMockExchangeClient(); + let resolvePositions!: ( + value: Awaited>, + ) => void; + let markPositionsReadStarted!: () => void; + const positionsReadStarted = new Promise((resolve) => { + markPositionsReadStarted = resolve; + }); + const delayedPositions = new Promise< + Awaited> + >((resolve) => { + resolvePositions = resolve; + }); + const readyState = await mockInfoClient.clearinghouseState({ + user: '0x1234567890123456789012345678901234567890', + }); + const guardedInfoClient = createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + userToMultiSigSigners: jest.fn().mockResolvedValue(null), + }); + guardedInfoClient.clearinghouseState + // Default-margin-mode resolution after trading readiness. + .mockImplementationOnce(() => { + markPositionsReadStarted(); + return delayedPositions; + }); + ( + TradingReadinessCache as jest.Mocked + ).get.mockReturnValue({ + attempted: true, + enabled: true, + timestamp: Date.now(), + }); + ( + TradingReadinessCache as jest.Mocked + ).getReferral.mockReturnValue({ attempted: true, success: true }); + ( + TradingReadinessCache as jest.Mocked + ).getBuilderFee.mockReturnValue({ attempted: true, success: true }); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(guardedInfoClient); + + const order = provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + leverage: 5, + orderType: 'market', + }); + await positionsReadStarted; + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + switchedAddress, + ); + resolvePositions(readyState); + + await expect(order).resolves.toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + }); + expect(exchangeClient.updateLeverage).not.toHaveBeenCalled(); + expect(exchangeClient.order).not.toHaveBeenCalled(); + }); + it('blocks margin and DEX-transfer signing for an unsupported account', async () => { const exchangeClient = createMockExchangeClient(); mockClientService.getExchangeClient = jest diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts index 4b5922f3249..2d229b16c85 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts @@ -6358,7 +6358,9 @@ describe('HyperLiquidProvider - strategy order types', () => { success: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); - expect(transfer.mock.calls).toStrictEqual([[PRE_ORDER_TRANSFER]]); + expect(transfer.mock.calls).toStrictEqual([ + [PRE_ORDER_TRANSFER, expect.any(Object)], + ]); expect(order).not.toHaveBeenCalled(); expect(reportedErrors()).toStrictEqual([]); }); @@ -6389,8 +6391,8 @@ describe('HyperLiquidProvider - strategy order types', () => { expect(result).toStrictEqual({ success: false, error: ORDER_FAILURE }); expect(transfer.mock.calls).toStrictEqual([ - [PRE_ORDER_TRANSFER], - [ROLLBACK_TRANSFER], + [PRE_ORDER_TRANSFER, expect.any(Object)], + [ROLLBACK_TRANSFER, expect.any(Object)], ]); expect(reportedErrors()).toStrictEqual(reported); expect(unsignedTransferNotes()).toStrictEqual(notes); @@ -6434,8 +6436,8 @@ describe('HyperLiquidProvider - strategy order types', () => { averagePrice: '3000', }); expect(transfer.mock.calls).toStrictEqual([ - [PRE_ORDER_TRANSFER], - [REBALANCE_TRANSFER], + [PRE_ORDER_TRANSFER, expect.any(Object)], + [REBALANCE_TRANSFER, expect.any(Object)], ]); expect(reportedErrors()).toStrictEqual(reported); expect(unsignedTransferNotes()).toStrictEqual(notes); From d554bd65dc0a5dc2b5d021c5df048cae620ad039 Mon Sep 17 00:00:00 2001 From: geositta Date: Fri, 9 Oct 2026 21:30:49 -0500 Subject: [PATCH 09/17] fix: refresh persisted TWAP cleanup context Co-authored-by: Cursor --- .../src/providers/HyperLiquidProvider.ts | 13 +++++++-- ...yperLiquidProvider.strategy-orders.test.ts | 29 +++++++++++-------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 6303ebc8235..754aa138bee 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -9188,9 +9188,16 @@ export class HyperLiquidProvider implements PerpsProvider { let { rebalancePromise } = trackedOrder; if (!rebalancePromise) { - rebalancePromise = this.#handleHip3PostOrderRebalance( - trackedOrder.hip3Transfer, - ); + const { hip3Transfer } = trackedOrder; + rebalancePromise = this.#handleHip3PostOrderRebalance({ + ...hip3Transfer, + accountContext: { + ...hip3Transfer.accountContext, + // TWAP tracking deliberately survives reconnects. Keep its stable + // account/network scope, but bind the cleanup write to this lifecycle. + lifecycleGeneration: this.#lifecycleGeneration, + }, + }); trackedOrder.rebalancePromise = rebalancePromise; } diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts index 2d229b16c85..e97d6b01909 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts @@ -4505,7 +4505,7 @@ describe('HyperLiquidProvider - strategy order types', () => { expect(transfer).toHaveBeenCalledTimes(2); }); - it('retries a failed HIP-3 collateral rebalance after provider recreation', async () => { + it('retries a failed HIP-3 collateral rebalance with fresh context after reconnect', async () => { let twapPlaced = false; const clearinghouseState = jest.fn().mockImplementation(({ dex }) => { let withdrawable = '1000'; @@ -4575,26 +4575,31 @@ describe('HyperLiquidProvider - strategy order types', () => { ).toMatchObject({ success: true, orderId: '987' }); expect(initialTransfer).toHaveBeenCalledTimes(1); await provider.disconnect(); - - const recreatedProvider = createTestProvider({ - hip3Enabled: true, - allowlistMarkets: ['xyz:*'], - useUnifiedAccount: false, - initialAssetMapping: [['xyz:TSLA', 110000]], - }); + initialTransfer.mockRestore(); + expect(await provider.initialize()).toMatchObject({ success: true }); const rebalance = jest - .spyOn(recreatedProvider, 'transferBetweenDexs') + .spyOn(provider, 'transferBetweenDexs') .mockResolvedValueOnce({ success: false, error: 'transfer failed' }) .mockResolvedValue({ success: true }); - await recreatedProvider.getTwapOrders(); + await provider.getTwapOrders(); expect(rebalance).toHaveBeenCalledTimes(1); - await recreatedProvider.getTwapOrders(); + await provider.getTwapOrders(); expect(rebalance).toHaveBeenCalledTimes(2); - await recreatedProvider.getTwapOrders(); + await provider.getTwapOrders(); expect(rebalance).toHaveBeenCalledTimes(2); + expect(rebalance.mock.calls).toStrictEqual([ + [ + expect.any(Object), + expect.objectContaining({ lifecycleGeneration: 1 }), + ], + [ + expect.any(Object), + expect.objectContaining({ lifecycleGeneration: 1 }), + ], + ]); }); }); From 2c26b0f84e05c0ea253da2004a21d9ad2c9402dc Mon Sep 17 00:00:00 2001 From: geositta Date: Mon, 12 Oct 2026 14:37:39 -0500 Subject: [PATCH 10/17] fix: correct account support after write rejection Co-authored-by: Cursor --- .../src/providers/HyperLiquidProvider.ts | 250 +++++++++++++++--- .../HyperLiquidProvider.account-mode.test.ts | 228 ++++++++++++++++ 2 files changed, 437 insertions(+), 41 deletions(-) diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 754aa138bee..3397e2ec77b 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -2945,6 +2945,42 @@ export class HyperLiquidProvider implements PerpsProvider { ); } + /** + * Replace an optimistic support result after the venue authoritatively + * rejects a write from the account that supplied its signature. + * + * The captured context, rather than the currently selected account, owns the + * rejection because selection may change while the request is in flight. + * + * @param error - The failed write or mapped account-mode error. + * @param accountContext - Account and network that authorized the write. + * @returns Whether the rejection requires Hyperliquid multi-signature. + */ + #recordUnsupportedAccountFromError( + error: unknown, + accountContext: AccountSupportContext, + ): boolean { + const isMultiSigRequired = + isHyperLiquidMultiSigRequiredError(error) || + ensureError( + error, + 'HyperLiquidProvider.recordUnsupportedAccountFromError', + ).message === PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED; + if (!isMultiSigRequired) { + return false; + } + + this.#recordAccountSupport( + accountContext.network, + accountContext.userAddress, + { + isSupported: false, + reason: 'multi_sig_account', + }, + ); + return true; + } + /** * Read support for an account context without initializing the provider. * Successful results are stable for the provider session and coalesced by @@ -3726,6 +3762,12 @@ export class HyperLiquidProvider implements PerpsProvider { '[ensureBuilderFeeSetup] Builder fee approval failed', error, ); + if ( + accountContext && + this.#recordUnsupportedAccountFromError(error, accountContext) + ) { + throw new Error(PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED); + } // A signer that could not sign is retryable, not an approval failure. if (approvalFailureCode || options.reportSignerFailure) { const signerFailure = await this.#classifySignerFailure(error); @@ -5318,6 +5360,11 @@ export class HyperLiquidProvider implements PerpsProvider { const lifecycleGeneration = accountContext?.lifecycleGeneration ?? this.#lifecycleGeneration; const { network, userAddress, builderAddress } = params; + const supportContext: AccountSupportContext = accountContext ?? { + lifecycleGeneration, + network, + userAddress, + }; this.#assertProviderLifecycleCurrent( lifecycleGeneration, 'Builder fee approval', @@ -5437,10 +5484,20 @@ export class HyperLiquidProvider implements PerpsProvider { const exchangeClient = this.#clientService.getExchangeClient(); const maxFeeRate = BUILDER_FEE_CONFIG.MaxFeeRate; - await exchangeClient.approveBuilderFee({ + const approvalResult = await exchangeClient.approveBuilderFee({ builder: builderAddress, maxFeeRate, }); + if (approvalResult.status !== 'ok') { + const approvalError = new Error( + `Builder fee approval failed: ${JSON.stringify(approvalResult)}`, + ); + this.#recordUnsupportedAccountFromError( + approvalError, + supportContext, + ); + throw approvalError; + } // Verify approval was successful before caching const afterApprovalDecimal = await this.#checkBuilderFeeApproval( @@ -5485,6 +5542,7 @@ export class HyperLiquidProvider implements PerpsProvider { } completeInFlight(); } catch (error) { + this.#recordUnsupportedAccountFromError(error, supportContext); if ( ensureError(error, 'HyperLiquidProvider.ensureBuilderFeeApproval') .message === PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE @@ -6491,17 +6549,22 @@ export class HyperLiquidProvider implements PerpsProvider { accountContext, 'Leverage update', ); - const exchangeClient = this.#clientService.getExchangeClient(); - const leverageResult = await exchangeClient.updateLeverage({ - asset: assetId, - isCross: marginMode === 'cross', - leverage, - }); + try { + const exchangeClient = this.#clientService.getExchangeClient(); + const leverageResult = await exchangeClient.updateLeverage({ + asset: assetId, + isCross: marginMode === 'cross', + leverage, + }); - if (leverageResult.status !== 'ok') { - throw new Error( - `Failed to update leverage: ${JSON.stringify(leverageResult)}`, - ); + if (leverageResult.status !== 'ok') { + throw new Error( + `Failed to update leverage: ${JSON.stringify(leverageResult)}`, + ); + } + } catch (error) { + this.#recordUnsupportedAccountFromError(error, accountContext); + throw error; } this.#deps.debugLogger.log('Leverage updated successfully:', { @@ -6708,6 +6771,10 @@ export class HyperLiquidProvider implements PerpsProvider { averagePrice: filledOrder?.avgPx, }; } catch (orderError) { + this.#recordUnsupportedAccountFromError( + orderError, + params.accountContext, + ); // Failure - rollback transfer if (transferInfo && dexName) { await this.#handleHip3OrderRollback({ @@ -7205,6 +7272,7 @@ export class HyperLiquidProvider implements PerpsProvider { try { result = await submit(); } catch (error) { + this.#recordUnsupportedAccountFromError(error, accountContext); if (dexName && transferInfo) { await this.#handleHip3OrderRollback({ accountContext, @@ -7214,6 +7282,12 @@ export class HyperLiquidProvider implements PerpsProvider { } throw error; } + if (!result.success && result.error) { + this.#recordUnsupportedAccountFromError( + new Error(result.error), + accountContext, + ); + } const resultFilledSize = new BigNumber(result.filledSize ?? 0); const hasVenueExposure = @@ -10082,9 +10156,14 @@ export class HyperLiquidProvider implements PerpsProvider { * Apply both signer invalidation and account-shape classification to writes. * * @param error - Failed protection write. + * @param accountContext - Account and network that authorized the write. * @returns Actionable error retaining the venue message when unmapped. */ - async #classifyProtectionWriteError(error: unknown): Promise { + async #classifyProtectionWriteError( + error: unknown, + accountContext: AccountSupportContext, + ): Promise { + this.#recordUnsupportedAccountFromError(error, accountContext); return ( (await this.#classifySignerFailure(error)) ?? (isHyperLiquidMultiSigRequiredError(error) @@ -10589,14 +10668,23 @@ export class HyperLiquidProvider implements PerpsProvider { 'Order modification', ); const exchangeClient = this.#clientService.getExchangeClient(); - const result = await exchangeClient.modify({ - oid: - typeof params.orderId === 'string' ? params.orderId : params.orderId, - order: newOrder, - }); + try { + const result = await exchangeClient.modify({ + oid: + typeof params.orderId === 'string' + ? params.orderId + : params.orderId, + order: newOrder, + }); - if (result.status !== 'ok') { - throw new Error(`Order modification failed: ${JSON.stringify(result)}`); + if (result.status !== 'ok') { + throw new Error( + `Order modification failed: ${JSON.stringify(result)}`, + ); + } + } catch (error) { + this.#recordUnsupportedAccountFromError(error, accountContext); + throw error; } // `params.orderId` is the order that was just REPLACED, so returning it @@ -10951,14 +11039,15 @@ export class HyperLiquidProvider implements PerpsProvider { ): Promise { // Declare outside try block so it's accessible in catch block let positionsToClose: Position[] = []; + let accountContext: AccountSupportContext | undefined; const unavailableResults: ClosePositionsResult['results'] = []; try { // Batch preparation needs trading readiness but not builder approval yet. // The provider-owned market policy is resolved from the positions below. - const { accountContext } = await this.#ensureReadyForTrading({ + ({ accountContext } = await this.#ensureReadyForTrading({ requiresBuilderFee: false, - }); + })); // Selected symbols only need the DEXes they belong to. Loading every // market group would refuse a BTC close when an unrelated HIP-3 DEX @@ -11229,6 +11318,14 @@ export class HyperLiquidProvider implements PerpsProvider { // Parse response statuses (one per order) const { statuses } = result.response.data; + for (const status of statuses) { + if (hasProperty(status, 'error') && typeof status.error === 'string') { + this.#recordUnsupportedAccountFromError( + createStatusError(status.error, result), + accountContext, + ); + } + } const successCount = statuses.filter( (stat) => isStatusObject(stat) && @@ -11303,6 +11400,9 @@ export class HyperLiquidProvider implements PerpsProvider { ], }; } catch (error) { + if (accountContext) { + this.#recordUnsupportedAccountFromError(error, accountContext); + } const signerFailure = await this.#handleSignerFailure( error, 'closePositions', @@ -11982,7 +12082,10 @@ export class HyperLiquidProvider implements PerpsProvider { }); statuses = result.response?.data?.statuses ?? []; } catch (error) { - const failure = await this.#classifyProtectionWriteError(error); + const failure = await this.#classifyProtectionWriteError( + error, + accountContext, + ); restorationFailure = failure; statuses = this.#readProtectionErrorStatuses(error) ?? @@ -12064,10 +12167,16 @@ export class HyperLiquidProvider implements PerpsProvider { cancelRequests, async (error) => { if (cancelGuardFailure !== undefined) { - throw await this.#classifyProtectionWriteError(cancelGuardFailure); + throw await this.#classifyProtectionWriteError( + cancelGuardFailure, + accountContext, + ); } if (isHyperLiquidMultiSigRequiredError(error)) { - throw await this.#classifyProtectionWriteError(error); + throw await this.#classifyProtectionWriteError( + error, + accountContext, + ); } }, ); @@ -12195,7 +12304,10 @@ export class HyperLiquidProvider implements PerpsProvider { responseAccepted = result.status === 'ok'; } catch (error) { // Drop and report a rejected agent before restoration signs. - placementFailure = await this.#classifyProtectionWriteError(error); + placementFailure = await this.#classifyProtectionWriteError( + error, + accountContext, + ); await assertScope(); const statuses = this.#readProtectionErrorStatuses(error); if (statuses !== undefined) { @@ -12337,7 +12449,10 @@ export class HyperLiquidProvider implements PerpsProvider { ) { // Old protection has already changed. Classification must not // throw away this batch's still-live replacement identities. - cleanupFailure = await this.#classifyProtectionWriteError(error); + cleanupFailure = await this.#classifyProtectionWriteError( + error, + accountContext, + ); } }, ); @@ -12641,6 +12756,7 @@ export class HyperLiquidProvider implements PerpsProvider { * - ntli: Amount in micro-units (amount * 1e6) */ async updateMargin(params: UpdateMarginParams): Promise { + let accountContext: AccountSupportContext | undefined; try { this.#deps.debugLogger.log('Updating position margin:', params); @@ -12648,7 +12764,7 @@ export class HyperLiquidProvider implements PerpsProvider { // Ensure provider is ready await this.#ensureReady(); - const accountContext = await this.#assertAccountSupported(); + accountContext = await this.#assertAccountSupported(); // Use the target DEX's current slice or one targeted HTTP read before // deriving the position side. @@ -12718,6 +12834,9 @@ export class HyperLiquidProvider implements PerpsProvider { success: true, }; } catch (error) { + const isMultiSigRequired = accountContext + ? this.#recordUnsupportedAccountFromError(error, accountContext) + : false; const signerFailure = await this.#handleSignerFailure( error, 'updateMargin', @@ -12728,7 +12847,9 @@ export class HyperLiquidProvider implements PerpsProvider { if (signerFailure) { return { success: false, error: signerFailure.message }; } - const safeError = ensureError(error, 'HyperLiquidProvider.updateMargin'); + const safeError = isMultiSigRequired + ? new Error(PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED) + : ensureError(error, 'HyperLiquidProvider.updateMargin'); this.#deps.logger.error( safeError, this.#getErrorContext( @@ -15471,6 +15592,7 @@ export class HyperLiquidProvider implements PerpsProvider { * @returns Result with txHash (HyperLiquid internal) and withdrawal ID */ async withdraw(params: WithdrawParams): Promise { + let accountContext: AccountSupportContext | undefined; try { this.#deps.debugLogger.log('HyperLiquidProvider: STARTING WITHDRAWAL', { params, @@ -15566,7 +15688,7 @@ export class HyperLiquidProvider implements PerpsProvider { // Step 4: Ensure client is ready this.#deps.debugLogger.log('HyperLiquidProvider: ENSURING CLIENT READY'); await this.#ensureReady(); - const accountContext = await this.#assertAccountSupported(); + accountContext = await this.#assertAccountSupported(); await this.#ensureUnifiedAccountEnabled({ allowUserSigning: true, accountContext, @@ -15673,7 +15795,16 @@ export class HyperLiquidProvider implements PerpsProvider { }; } - const errorMessage = `Withdrawal failed: ${String(result.status)}`; + const rejection = new Error( + `Withdrawal failed: ${JSON.stringify(result)}`, + ); + const isMultiSigRequired = this.#recordUnsupportedAccountFromError( + rejection, + accountContext, + ); + const errorMessage = isMultiSigRequired + ? PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED + : `Withdrawal failed: ${String(result.status)}`; this.#deps.debugLogger.log('HyperLiquidProvider: WITHDRAWAL FAILED', { error: errorMessage, status: result.status, @@ -15685,16 +15816,18 @@ export class HyperLiquidProvider implements PerpsProvider { error: errorMessage, }; } catch (error) { + const isMultiSigRequired = accountContext + ? this.#recordUnsupportedAccountFromError(error, accountContext) + : false; const signerFailure = await this.#handleSignerFailure(error, 'withdraw', { assetId: params.assetId, }); if (signerFailure) { return createErrorResult(signerFailure, { success: false }); } - const safeError = ensureError( - error, - 'HyperLiquidProvider.initiateWithdrawal', - ); + const safeError = isMultiSigRequired + ? new Error(PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED) + : ensureError(error, 'HyperLiquidProvider.initiateWithdrawal'); this.#deps.debugLogger.log('HyperLiquidProvider: WITHDRAWAL EXCEPTION', { error: safeError.message, errorType: safeError.name, @@ -15717,7 +15850,7 @@ export class HyperLiquidProvider implements PerpsProvider { }, ), ); - return createErrorResult(error, { success: false }); + return createErrorResult(safeError, { success: false }); } } @@ -15748,6 +15881,7 @@ export class HyperLiquidProvider implements PerpsProvider { params: TransferBetweenDexsParams, expectedContext?: AccountSupportContext, ): Promise { + let accountContext: AccountSupportContext | undefined; try { this.#deps.debugLogger.log('HyperLiquidProvider: STARTING DEX TRANSFER', { params, @@ -15765,8 +15899,7 @@ export class HyperLiquidProvider implements PerpsProvider { // Ensure client ready await this.#ensureReady(); - const accountContext = - await this.#assertAccountSupported(expectedContext); + accountContext = await this.#assertAccountSupported(expectedContext); const { userAddress } = accountContext; this.#deps.debugLogger.log('HyperLiquidProvider: USER ADDRESS', { userAddress, @@ -15811,8 +15944,19 @@ export class HyperLiquidProvider implements PerpsProvider { }; } + if ( + this.#recordUnsupportedAccountFromError( + new Error(`DEX transfer failed: ${JSON.stringify(result)}`), + accountContext, + ) + ) { + throw new Error(PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED); + } throw new Error(PERPS_ERROR_CODES.TRANSFER_FAILED); } catch (error) { + const isMultiSigRequired = accountContext + ? this.#recordUnsupportedAccountFromError(error, accountContext) + : false; const signerFailure = await this.#handleSignerFailure( error, 'transferBetweenDexs', @@ -15821,10 +15965,9 @@ export class HyperLiquidProvider implements PerpsProvider { if (signerFailure) { return { success: false, error: signerFailure.message }; } - const safeError = ensureError( - error, - 'HyperLiquidProvider.transferToSpot', - ); + const safeError = isMultiSigRequired + ? new Error(PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED) + : ensureError(error, 'HyperLiquidProvider.transferToSpot'); this.#deps.debugLogger.log('❌ HyperLiquidProvider: TRANSFER FAILED', { error: safeError.message, params, @@ -17533,6 +17676,14 @@ export class HyperLiquidProvider implements PerpsProvider { return; } + if ( + accountContext && + this.#recordUnsupportedAccountFromError(error, accountContext) + ) { + completeInFlight(); + return; + } + // Safety net: wallet looked registered but the SDK still rejects with // "User or API Wallet 0x... does not exist." Do not forward to Sentry. // The walletRegistered cache stores positive observations only, so no @@ -17715,8 +17866,25 @@ export class HyperLiquidProvider implements PerpsProvider { result, ); + if ( + result?.status !== 'ok' && + accountContext && + this.#recordUnsupportedAccountFromError( + new Error(`Referral setup failed: ${JSON.stringify(result)}`), + accountContext, + ) + ) { + return false; + } + return result?.status === 'ok'; } catch (error) { + if ( + accountContext && + this.#recordUnsupportedAccountFromError(error, accountContext) + ) { + throw error; + } // Retryable (a locked keyring, an unavailable agent, or one the venue // rejected): `#ensureReferralSet` retries at the next entry, so it is // not an error to report. diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts index 376af5ef175..6886e5d2b6a 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts @@ -387,6 +387,177 @@ describe('HyperLiquidProvider', () => { expect(mockInfoClient.userToMultiSigSigners).toHaveBeenCalledTimes(1); }); + it('corrects cached support after an authoritative order rejection', async () => { + const exchangeClient = createMockExchangeClient({ + order: jest + .fn() + .mockRejectedValue(new Error('ApiRequestError: Multi-sig required')), + }); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + mockInfoClient.userToMultiSigSigners.mockResolvedValue(null); + ( + TradingReadinessCache as jest.Mocked + ).get.mockReturnValue({ + attempted: true, + enabled: true, + timestamp: Date.now(), + }); + ( + TradingReadinessCache as jest.Mocked + ).getReferral.mockReturnValue({ attempted: true, success: true }); + ( + TradingReadinessCache as jest.Mocked + ).getBuilderFee.mockReturnValue({ attempted: true, success: true }); + + await expect(provider.getAccountSupport()).resolves.toEqual({ + isSupported: true, + }); + await expect( + provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }), + ).resolves.toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, + }); + + await expect(provider.getAccountSupport()).resolves.toEqual({ + isSupported: false, + reason: 'multi_sig_account', + }); + await expect( + provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }), + ).resolves.toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, + }); + expect(exchangeClient.order).toHaveBeenCalledTimes(1); + expect(mockInfoClient.userToMultiSigSigners).toHaveBeenCalledTimes(1); + }); + + it('attributes a delayed rejection to the account that signed the write', async () => { + const switchedAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + let rejectOrder!: (reason: Error) => void; + let markOrderStarted!: () => void; + const orderStarted = new Promise((resolve) => { + markOrderStarted = resolve; + }); + const rejectedOrder = new Promise((_resolve, reject) => { + rejectOrder = reject; + }); + const exchangeClient = createMockExchangeClient({ + order: jest.fn().mockImplementation(() => { + markOrderStarted(); + return rejectedOrder; + }), + }); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + mockInfoClient.userToMultiSigSigners.mockResolvedValue(null); + ( + TradingReadinessCache as jest.Mocked + ).get.mockReturnValue({ + attempted: true, + enabled: true, + timestamp: Date.now(), + }); + ( + TradingReadinessCache as jest.Mocked + ).getReferral.mockReturnValue({ attempted: true, success: true }); + ( + TradingReadinessCache as jest.Mocked + ).getBuilderFee.mockReturnValue({ attempted: true, success: true }); + + await provider.getAccountSupport(); + const order = provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }); + await orderStarted; + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + switchedAddress, + ); + rejectOrder(new Error('ApiRequestError: Multi-sig required')); + + await expect(order).resolves.toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, + }); + await expect(provider.getAccountSupport()).resolves.toEqual({ + isSupported: true, + }); + + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + '0x1234567890123456789012345678901234567890', + ); + await expect(provider.getAccountSupport()).resolves.toEqual({ + isSupported: false, + reason: 'multi_sig_account', + }); + expect(mockInfoClient.userToMultiSigSigners).toHaveBeenCalledTimes(2); + }); + + it('corrects cached support after a status-based withdrawal rejection', async () => { + const exchangeClient = createMockExchangeClient({ + withdraw3: jest.fn().mockResolvedValue({ + status: 'err', + response: 'Multi-sig required', + }), + }); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + mockInfoClient.userToMultiSigSigners.mockResolvedValue(null); + ( + TradingReadinessCache as jest.Mocked + ).get.mockReturnValue({ + attempted: true, + enabled: true, + timestamp: Date.now(), + }); + ( + TradingReadinessCache as jest.Mocked + ).getReferral.mockReturnValue({ attempted: true, success: true }); + + await expect(provider.getAccountSupport()).resolves.toEqual({ + isSupported: true, + }); + const withdrawal = { + amount: '100', + destination: '0x1234567890123456789012345678901234567890' as Hex, + assetId: + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/usdc' as CaipAssetId, + }; + await expect(provider.withdraw(withdrawal)).resolves.toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, + }); + + await expect(provider.getAccountSupport()).resolves.toEqual({ + isSupported: false, + reason: 'multi_sig_account', + }); + await expect(provider.withdraw(withdrawal)).resolves.toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, + }); + expect(exchangeClient.withdraw3).toHaveBeenCalledTimes(1); + expect(mockInfoClient.userToMultiSigSigners).toHaveBeenCalledTimes(1); + }); + it('retries support checks after a transient failure', async () => { mockInfoClient.userToMultiSigSigners .mockRejectedValueOnce(new Error('Network unavailable')) @@ -722,6 +893,63 @@ describe('HyperLiquidProvider', () => { expect(exchangeClient.order).not.toHaveBeenCalled(); }); + it('stops the same action after referral setup reveals multi-signature support', async () => { + const exchangeClient = createMockExchangeClient({ + setReferrer: jest + .fn() + .mockRejectedValue(new Error('ApiRequestError: Multi-sig required')), + }); + const referral = jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: REFERRAL_CONFIG.MainnetCode }, + }, + referredBy: null, + }); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + referral, + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + userToMultiSigSigners: jest.fn().mockResolvedValue(null), + }), + ); + ( + TradingReadinessCache as jest.Mocked + ).get.mockReturnValue({ + attempted: true, + enabled: true, + timestamp: Date.now(), + }); + ( + TradingReadinessCache as jest.Mocked + ).getBuilderFee.mockReturnValue({ attempted: true, success: true }); + + await expect(provider.getAccountSupport()).resolves.toEqual({ + isSupported: true, + }); + await expect( + provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }), + ).resolves.toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, + }); + await expect(provider.getAccountSupport()).resolves.toEqual({ + isSupported: false, + reason: 'multi_sig_account', + }); + expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); + expect(exchangeClient.approveBuilderFee).not.toHaveBeenCalled(); + expect(exchangeClient.order).not.toHaveBeenCalled(); + }); + it('blocks withdrawal signing when action-time setup gets an authoritative multi-signature rejection', async () => { mockWalletService.requiresSignatureConfirmation.mockReturnValue(true); const exchangeClient = createMockExchangeClient({ From c22a548707e30bda968b4575a01c6652f1f4db20 Mon Sep 17 00:00:00 2001 From: geositta Date: Mon, 12 Oct 2026 14:41:41 -0500 Subject: [PATCH 11/17] fix: learn account support from status rejections Co-authored-by: Cursor --- .../src/providers/HyperLiquidProvider.ts | 43 ++++++--- .../HyperLiquidProvider.account-mode.test.ts | 95 +++++++++++++++++++ 2 files changed, 127 insertions(+), 11 deletions(-) diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 3397e2ec77b..fa22a967f56 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -5184,10 +5184,18 @@ export class HyperLiquidProvider implements PerpsProvider { * * @param message - The status entry's error. * @param answer - The venue answer with the entry. + * @param accountContext - Account and network that authorized the write. * @returns The mapped error. */ - async #mapStatusError(message: string, answer: unknown): Promise { + async #mapStatusError( + message: string, + answer: unknown, + accountContext?: AccountSupportContext, + ): Promise { const error = createStatusError(message, answer); + if (accountContext) { + this.#recordUnsupportedAccountFromError(error, accountContext); + } return (await this.#classifySignerFailure(error)) ?? this.#mapError(error); } @@ -6737,6 +6745,9 @@ export class HyperLiquidProvider implements PerpsProvider { } const status = result.response?.data?.statuses?.[0]; + if (hasProperty(status, 'error') && typeof status.error === 'string') { + throw createStatusError(status.error, result); + } // Note: `in` narrows the HyperLiquid SDK discriminated union to the // branch that has the property; `hasProperty` types the property as // `unknown`, losing downstream access to `.oid`, `.totalSz`, `.avgPx`. @@ -9648,10 +9659,13 @@ export class HyperLiquidProvider implements PerpsProvider { isStatusObject(status) && typeof status.error === 'string' ? status.error : 'TWAP cancellation failed'; - return createErrorResult(await this.#mapStatusError(rawError, result), { - success: false, - orderId: params.orderId, - }); + return createErrorResult( + await this.#mapStatusError(rawError, result, accountContext), + { + success: false, + orderId: params.orderId, + }, + ); } /** @@ -10762,6 +10776,7 @@ export class HyperLiquidProvider implements PerpsProvider { session.orderId === params.orderId || session.replacingOrderId === params.orderId, ); + let accountContext: AccountSupportContext | undefined; try { if (owningChase) { this.#stopChaseSession(owningChase[0]); @@ -10791,9 +10806,9 @@ export class HyperLiquidProvider implements PerpsProvider { throw new Error(coinValidation.error); } - const { accountContext } = await this.#ensureReadyForTrading({ + ({ accountContext } = await this.#ensureReadyForTrading({ requiresBuilderFee: false, - }); + })); const asset = await this.#getAssetIdWithRepair({ symbol: params.symbol, @@ -10831,11 +10846,17 @@ export class HyperLiquidProvider implements PerpsProvider { ? status.error : 'Order cancellation failed'; - return createErrorResult(await this.#mapStatusError(rawError, result), { - success: false, - orderId: params.orderId, - }); + return createErrorResult( + await this.#mapStatusError(rawError, result, accountContext), + { + success: false, + orderId: params.orderId, + }, + ); } catch (error) { + if (accountContext) { + this.#recordUnsupportedAccountFromError(error, accountContext); + } const mappedError = await this.#reportWriteError( error, 'cancelOrder', diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts index 6886e5d2b6a..e145a4fcce0 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts @@ -445,6 +445,53 @@ describe('HyperLiquidProvider', () => { expect(mockInfoClient.userToMultiSigSigners).toHaveBeenCalledTimes(1); }); + it('corrects cached support after a status-based order rejection', async () => { + const exchangeClient = createMockExchangeClient({ + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { statuses: [{ error: 'Multi-sig required' }] }, + }, + }), + }); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + mockInfoClient.userToMultiSigSigners.mockResolvedValue(null); + ( + TradingReadinessCache as jest.Mocked + ).get.mockReturnValue({ + attempted: true, + enabled: true, + timestamp: Date.now(), + }); + ( + TradingReadinessCache as jest.Mocked + ).getReferral.mockReturnValue({ attempted: true, success: true }); + ( + TradingReadinessCache as jest.Mocked + ).getBuilderFee.mockReturnValue({ attempted: true, success: true }); + + await provider.getAccountSupport(); + await expect( + provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }), + ).resolves.toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, + }); + await expect(provider.getAccountSupport()).resolves.toEqual({ + isSupported: false, + reason: 'multi_sig_account', + }); + expect(exchangeClient.order).toHaveBeenCalledTimes(1); + expect(mockInfoClient.userToMultiSigSigners).toHaveBeenCalledTimes(1); + }); + it('attributes a delayed rejection to the account that signed the write', async () => { const switchedAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; let rejectOrder!: (reason: Error) => void; @@ -558,6 +605,54 @@ describe('HyperLiquidProvider', () => { expect(mockInfoClient.userToMultiSigSigners).toHaveBeenCalledTimes(1); }); + it('corrects cached support after a status-based cancellation rejection', async () => { + const exchangeClient = createMockExchangeClient({ + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { statuses: [{ error: 'Multi-sig required' }] }, + }, + }), + }); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + mockInfoClient.userToMultiSigSigners.mockResolvedValue(null); + ( + TradingReadinessCache as jest.Mocked + ).get.mockReturnValue({ + attempted: true, + enabled: true, + timestamp: Date.now(), + }); + ( + TradingReadinessCache as jest.Mocked + ).getReferral.mockReturnValue({ attempted: true, success: true }); + + await expect(provider.getAccountSupport()).resolves.toEqual({ + isSupported: true, + }); + const cancellation = { + orderId: '123', + symbol: 'BTC', + }; + await expect(provider.cancelOrder(cancellation)).resolves.toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, + }); + + await expect(provider.getAccountSupport()).resolves.toEqual({ + isSupported: false, + reason: 'multi_sig_account', + }); + await expect(provider.cancelOrder(cancellation)).resolves.toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, + }); + expect(exchangeClient.cancel).toHaveBeenCalledTimes(1); + expect(mockInfoClient.userToMultiSigSigners).toHaveBeenCalledTimes(1); + }); + it('retries support checks after a transient failure', async () => { mockInfoClient.userToMultiSigSigners .mockRejectedValueOnce(new Error('Network unavailable')) From 163322dc4ca63655a9c9ced5dafc825a5139d4eb Mon Sep 17 00:00:00 2001 From: geositta Date: Tue, 13 Oct 2026 09:46:02 -0500 Subject: [PATCH 12/17] fix: close account support race gaps Co-authored-by: Cursor --- .../src/providers/HyperLiquidProvider.ts | 241 +++++++++++++++-- .../HyperLiquidProvider.account-mode.test.ts | 253 ++++++++++++++++-- ...yperLiquidProvider.strategy-orders.test.ts | 94 ++++++- 3 files changed, 533 insertions(+), 55 deletions(-) diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index fa22a967f56..b108e4a1163 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -2981,6 +2981,30 @@ export class HyperLiquidProvider implements PerpsProvider { return true; } + /** + * Correct support from errors returned as resolved per-entry statuses. + * + * @param statuses - Venue status entries. + * @param answer - Full venue answer that owns the statuses. + * @param accountContext - Account and network that authorized the write. + * @returns Whether any status requires Hyperliquid multi-signature. + */ + #recordUnsupportedAccountFromStatuses( + statuses: unknown[], + answer: unknown, + accountContext: AccountSupportContext, + ): boolean { + return statuses.some( + (status) => + isStatusObject(status) && + typeof status.error === 'string' && + this.#recordUnsupportedAccountFromError( + createStatusError(status.error, answer), + accountContext, + ), + ); + } + /** * Read support for an account context without initializing the provider. * Successful results are stable for the provider session and coalesced by @@ -3131,10 +3155,17 @@ export class HyperLiquidProvider implements PerpsProvider { * action that is allowed to authorize the migration. * @private */ - async #ensureUnifiedAccountEnabled(options?: { - allowUserSigning?: boolean; - accountContext?: AccountSupportContext; - }): Promise { + async #ensureUnifiedAccountEnabled( + options?: + | { + allowUserSigning: true; + accountContext: AccountSupportContext; + } + | { + allowUserSigning?: false; + accountContext?: never; + }, + ): Promise { // Without an agent, the migration is signed by the main wallet, which can // prompt (hardware wallets). Init calls with allowUserSigning=false so // viewing the Perps section never surfaces a signing dialog. Trading and @@ -3383,7 +3414,15 @@ export class HyperLiquidProvider implements PerpsProvider { 'Unified account migration', ); } - const exchangeClient = this.#clientService.getExchangeClient(); + const exchangeClient = this.#clientService.getExchangeClient( + accountContext + ? async (): Promise => + await this.#assertNoKnownUnsupportedAccount( + accountContext, + 'Unified account migration dispatch', + ) + : undefined, + ); await exchangeClient.agentSetAbstraction({ abstraction: HL_ABSTRACTION_WIRE.unifiedAccount, }); @@ -3606,11 +3645,27 @@ export class HyperLiquidProvider implements PerpsProvider { // over. The memoized promise is dropped while the cooldown is active // (see below), so a later entry retries. Action-time callers are not // gated. - if (Date.now() >= this.#unifiedAccountTransportRetryAt) { - await this.#ensureUnifiedAccountEnabled({ - allowUserSigning: - !this.#walletService.requiresSignatureConfirmation(), - }); + if ( + !this.#isDisconnected && + Date.now() >= this.#unifiedAccountTransportRetryAt + ) { + if (this.#walletService.requiresSignatureConfirmation()) { + await this.#ensureUnifiedAccountEnabled({ + allowUserSigning: false, + }); + } else { + const accountContext = { + lifecycleGeneration: this.#lifecycleGeneration, + network: this.#clientService.isTestnetMode() + ? 'testnet' + : 'mainnet', + userAddress: await this.#walletService.getUserAddressWithDefault(), + } satisfies AccountSupportContext; + await this.#ensureUnifiedAccountEnabled({ + allowUserSigning: true, + accountContext, + }); + } } })(); @@ -6866,6 +6921,26 @@ export class HyperLiquidProvider implements PerpsProvider { input: OrderParams, retryCount = 0, reportFailure = true, + ): Promise { + return await this.#placeOrder(input, retryCount, reportFailure); + } + + /** + * Place an order while preserving one account context across parent actions + * and minimum-value retries. + * + * @param input - Order parameters. + * @param retryCount - Internal retry counter. + * @param reportFailure - Whether this call owns provider-level reporting. + * @param expectedAccountContext - Context captured by a parent action or the + * first minimum-value attempt. + * @returns The order result. + */ + async #placeOrder( + input: OrderParams, + retryCount: number, + reportFailure: boolean, + expectedAccountContext?: AccountSupportContext, ): Promise { const deferProviderErrorReport = input.deferProviderErrorReport === true; const params = { ...input }; @@ -6880,6 +6955,7 @@ export class HyperLiquidProvider implements PerpsProvider { // Hoisted so the retry path in the catch block can use the fetched price // even when the caller (e.g. flipPosition) omits currentPrice from params. let effectivePrice: number | undefined; + let actionAccountContext = expectedAccountContext; try { this.#deps.debugLogger.log('Placing order via HyperLiquid SDK:', params); @@ -6909,7 +6985,11 @@ export class HyperLiquidProvider implements PerpsProvider { // array, the HIP-3 transfer, and the atomic single-order submit, none of // which describe what a TWAP, a ladder, or a chase does. if (isStrategyOrderType(params.orderType)) { - return await this.#placeStrategyOrder(params, params.orderType); + return await this.#placeStrategyOrder( + params, + params.orderType, + expectedAccountContext, + ); } // Extract DEX name for API calls (main DEX = null) @@ -6969,7 +7049,9 @@ export class HyperLiquidProvider implements PerpsProvider { const { accountContext, builderFeeSetupContext } = await this.#ensureReadyForTrading({ requiresBuilderFee: chargesMetamaskBuilderFee, + accountContext: actionAccountContext, }); + actionAccountContext = accountContext; // Debug: Log asset map state before order placement const allMapKeys = Array.from(this.#symbolToAssetId.keys()); @@ -7157,7 +7239,7 @@ export class HyperLiquidProvider implements PerpsProvider { }, ); - return this.placeOrder( + return this.#placeOrder( { ...params, usdAmount: adjustedUsdAmount, @@ -7172,6 +7254,7 @@ export class HyperLiquidProvider implements PerpsProvider { }, 1, // Retry count = 1, prevents further retries shouldReportFailure, + actionAccountContext, ); } @@ -7190,11 +7273,13 @@ export class HyperLiquidProvider implements PerpsProvider { * * @param params - Order parameters, already validated. * @param orderType - The strategy type, narrowed by the caller. + * @param expectedAccountContext - Context captured by a parent action. * @returns A promise that resolves to the result. */ async #placeStrategyOrder( params: OrderParams, orderType: StrategyOrderType, + expectedAccountContext?: AccountSupportContext, ): Promise { // Captured before the shared preamble, not after it: that preamble awaits // asset info, validation, trading readiness and a leverage update, and a @@ -7202,7 +7287,10 @@ export class HyperLiquidProvider implements PerpsProvider { const generation = this.#strategyGeneration; if (orderType !== 'chase') { - const context = await this.#prepareStrategyPlacement(params); + const context = await this.#prepareStrategyPlacement( + params, + expectedAccountContext, + ); return await this.#submitPreparedStrategy(params, context, async () => { if (generation !== this.#strategyGeneration) { throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); @@ -7242,7 +7330,10 @@ export class HyperLiquidProvider implements PerpsProvider { try { await this.#chaseTickQueue; - const context = await this.#prepareStrategyPlacement(params); + const context = await this.#prepareStrategyPlacement( + params, + expectedAccountContext, + ); return await this.#submitPreparedStrategy( params, context, @@ -7383,10 +7474,12 @@ export class HyperLiquidProvider implements PerpsProvider { * so a strategy order is held to the same rules as an ordinary one. * * @param params - Order parameters. + * @param expectedAccountContext - Context captured by a parent action. * @returns The asset and sizing context the strategy handlers submit with. */ async #prepareStrategyPlacement( params: OrderParams, + expectedAccountContext?: AccountSupportContext, ): Promise { const { dex: dexName } = parseAssetName(params.symbol); @@ -7467,8 +7560,14 @@ export class HyperLiquidProvider implements PerpsProvider { // signature prompts in trading setup — same ordering as `placeOrder`. const { chargesMetamaskBuilderFee } = this.#resolveOrderFeePolicy(params); const tradingActionContext = chargesMetamaskBuilderFee - ? await this.#ensureReadyForTrading({ requiresBuilderFee: true }) - : await this.#ensureReadyForTrading({ requiresBuilderFee: false }); + ? await this.#ensureReadyForTrading({ + requiresBuilderFee: true, + accountContext: expectedAccountContext, + }) + : await this.#ensureReadyForTrading({ + requiresBuilderFee: false, + accountContext: expectedAccountContext, + }); const { accountContext, builderFeeSetupContext } = tradingActionContext; const assetId = await this.#getAssetIdWithRepair({ @@ -7814,6 +7913,11 @@ export class HyperLiquidProvider implements PerpsProvider { const rawStatuses = result.response?.data?.statuses; const statuses = Array.isArray(rawStatuses) ? rawStatuses : []; + const isMultiSigRequired = this.#recordUnsupportedAccountFromStatuses( + statuses, + result, + accountContext, + ); const outcomes = Array.from({ length: count }, (_unused, index) => parseScaleBulkOrderStatus(statuses[index]), ); @@ -8022,7 +8126,11 @@ export class HyperLiquidProvider implements PerpsProvider { }); } - throw new Error(PERPS_ERROR_CODES.ORDER_REJECTED); + throw new Error( + isMultiSigRequired + ? PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED + : PERPS_ERROR_CODES.ORDER_REJECTED, + ); } this.#scaleOrderGroups.set(groupId, { @@ -8383,6 +8491,21 @@ export class HyperLiquidProvider implements PerpsProvider { this.#chaseTickQueue = this.#chaseTickQueue .then(() => this.#runChaseTick(sessionId)) .catch(async (error: unknown) => { + const failedSession = this.#chaseSessions.get(sessionId); + const accountUnsupported = failedSession + ? this.#recordUnsupportedAccountFromError( + error, + failedSession.accountContext, + ) + : false; + if (accountUnsupported) { + this.#stopChaseSession(sessionId); + this.#deps.debugLogger.log( + 'Chase stopped after account support correction', + { sessionId }, + ); + return; + } // A rejected agent is dropped so the next tick asks for another. await this.#evictRejectedAgent(error); // Resolve the shared queue after every failure. Otherwise one @@ -9027,6 +9150,7 @@ export class HyperLiquidProvider implements PerpsProvider { cancels: [{ a: session.assetId, o: parseInt(session.orderId, 10) }], }); } catch (error) { + this.#recordUnsupportedAccountFromError(error, session.accountContext); const message = ensureError( error, 'HyperLiquidProvider.cancelChaseChild', @@ -9040,6 +9164,12 @@ export class HyperLiquidProvider implements PerpsProvider { } const status: unknown = result.response?.data?.statuses?.[0]; + if (isStatusObject(status) && typeof status.error === 'string') { + this.#recordUnsupportedAccountFromError( + createStatusError(status.error, result), + session.accountContext, + ); + } const signerFailure = await this.#classifyStatusSignerFailure( [status], result, @@ -9704,8 +9834,16 @@ export class HyperLiquidProvider implements PerpsProvider { 'Scale cancellation', ); const [orderCancellation, cloidCancellation] = await Promise.all([ - this.#cancelOrderRequestBatch(exchangeClient, cancelRequests), - this.#cancelOrderCloidRequestBatch(exchangeClient, cancelByCloidRequests), + this.#cancelOrderRequestBatch(exchangeClient, cancelRequests, (error) => { + this.#recordUnsupportedAccountFromError(error, accountContext); + }), + this.#cancelOrderCloidRequestBatch( + exchangeClient, + cancelByCloidRequests, + (error) => { + this.#recordUnsupportedAccountFromError(error, accountContext); + }, + ), ]); const remaining = orderCancellation.remainingOrderIds.map(String); const { remainingClientOrderIds } = cloidCancellation; @@ -9896,14 +10034,21 @@ export class HyperLiquidProvider implements PerpsProvider { * * @param exchangeClient - Client that owns the orders. * @param requests - Venue cancel-by-CLOID requests. + * @param onError - Optional operation-specific failure classifier. * @returns Client order IDs that may still be resting. */ async #cancelOrderCloidRequests( exchangeClient: ExchangeClient, requests: ExchangeCancelByCloidRequest[], + onError?: (error: unknown) => void | Promise, ): Promise { - return (await this.#cancelOrderCloidRequestBatch(exchangeClient, requests)) - .remainingClientOrderIds; + return ( + await this.#cancelOrderCloidRequestBatch( + exchangeClient, + requests, + onError, + ) + ).remainingClientOrderIds; } /** @@ -9912,12 +10057,14 @@ export class HyperLiquidProvider implements PerpsProvider { * * @param exchangeClient - Client that owns the orders. * @param requests - Venue cancel-by-CLOID requests. + * @param onError - Optional operation-specific failure classifier. * @returns Client order IDs that may still be resting, and the signer * failure when the signer could not sign the request. */ async #cancelOrderCloidRequestBatch( exchangeClient: ExchangeClient, requests: ExchangeCancelByCloidRequest[], + onError?: (error: unknown) => void | Promise, ): Promise<{ remainingClientOrderIds: Hex[]; signerFailure?: Error }> { if (requests.length === 0) { return { remainingClientOrderIds: [] }; @@ -9927,6 +10074,11 @@ export class HyperLiquidProvider implements PerpsProvider { statuses: unknown[], answer: unknown, ): Promise<{ remainingClientOrderIds: Hex[]; signerFailure?: Error }> => { + for (const status of statuses) { + if (isStatusObject(status) && typeof status.error === 'string') { + await onError?.(createStatusError(status.error, answer)); + } + } // A signer failure is classified (and reported) once for the request; // entries the venue cancelled are not resting either way. const signerFailure = await this.#classifyStatusSignerFailure( @@ -9956,6 +10108,7 @@ export class HyperLiquidProvider implements PerpsProvider { return await classifyStatuses(statuses, result); } catch (error) { + await onError?.(error); // The SDK throws when any entry failed, with every entry's status. const statuses = getCancelStatusesFromError(error, requests.length); if (statuses) { @@ -10008,6 +10161,11 @@ export class HyperLiquidProvider implements PerpsProvider { statuses: unknown[], answer: unknown, ): Promise => { + for (const status of statuses) { + if (isStatusObject(status) && typeof status.error === 'string') { + await onError?.(createStatusError(status.error, answer)); + } + } // A signer failure is classified (and reported) once for the request; // entries the venue cancelled still count as cancelled. const signerFailure = await this.#classifyStatusSignerFailure( @@ -11007,6 +11165,10 @@ export class HyperLiquidProvider implements PerpsProvider { : undefined; let error: string = PERPS_ERROR_CODES.BATCH_CANCEL_FAILED; if (statusError) { + this.#recordUnsupportedAccountFromError( + statusError, + accountContext, + ); error = signerFailure && (await this.#isSignerFailure(statusError)) ? signerFailure.message @@ -12102,6 +12264,11 @@ export class HyperLiquidProvider implements PerpsProvider { builderOrderContext && { builder: builderOrderContext }), }); statuses = result.response?.data?.statuses ?? []; + this.#recordUnsupportedAccountFromStatuses( + statuses, + result, + accountContext, + ); } catch (error) { const failure = await this.#classifyProtectionWriteError( error, @@ -12322,6 +12489,11 @@ export class HyperLiquidProvider implements PerpsProvider { builderOrderContext && { builder: builderOrderContext }), }); placementStatuses = result.response?.data?.statuses ?? []; + this.#recordUnsupportedAccountFromStatuses( + placementStatuses, + result, + accountContext, + ); responseAccepted = result.status === 'ok'; } catch (error) { // Drop and report a rejected agent before restoration signs. @@ -12690,7 +12862,7 @@ export class HyperLiquidProvider implements PerpsProvider { accountContext, 'Position close', ); - const result = await this.placeOrder( + const result = await this.#placeOrder( { symbol: params.symbol, isBuy, @@ -12714,6 +12886,7 @@ export class HyperLiquidProvider implements PerpsProvider { }, 0, false, + accountContext, ); // Return freed margin using native abstraction or programmatic transfer @@ -14113,7 +14286,14 @@ export class HyperLiquidProvider implements PerpsProvider { */ async getAccountSupport(): Promise { await this.#ensureReady(); - return this.#getAccountSupportForContext(); + const context = { + lifecycleGeneration: this.#lifecycleGeneration, + network: this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet', + userAddress: await this.#walletService.getUserAddressWithDefault(), + } satisfies AccountSupportContext; + const support = await this.#getAccountSupportForContext(context); + await this.#assertAccountContextCurrent(context, 'Account support read'); + return support; } /** @@ -15683,6 +15863,17 @@ export class HyperLiquidProvider implements PerpsProvider { assetId: params.assetId, }); + const lifecycleGeneration = this.#lifecycleGeneration; + const network = this.#clientService.isTestnetMode() + ? 'testnet' + : 'mainnet'; + const userAddress = await this.#walletService.getUserAddressWithDefault(); + accountContext = { + lifecycleGeneration, + network, + userAddress, + }; + // Step 3: Determine destination address this.#deps.debugLogger.log( 'HyperLiquidProvider: DETERMINING DESTINATION ADDRESS', @@ -15697,7 +15888,7 @@ export class HyperLiquidProvider implements PerpsProvider { }, ); } else { - destination = await this.#walletService.getUserAddressWithDefault(); + destination = userAddress; this.#deps.debugLogger.log( 'HyperLiquidProvider: USING USER WALLET ADDRESS', { @@ -15709,7 +15900,7 @@ export class HyperLiquidProvider implements PerpsProvider { // Step 4: Ensure client is ready this.#deps.debugLogger.log('HyperLiquidProvider: ENSURING CLIENT READY'); await this.#ensureReady(); - accountContext = await this.#assertAccountSupported(); + accountContext = await this.#assertAccountSupported(accountContext); await this.#ensureUnifiedAccountEnabled({ allowUserSigning: true, accountContext, diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts index e145a4fcce0..bf0eddf93dd 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts @@ -349,6 +349,27 @@ describe('HyperLiquidProvider', () => { expect(result).toEqual({ isSupported: true }); }); + it('rejects a support result after the selected account changes', async () => { + const switchedAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + ( + TradingReadinessCache as jest.Mocked + ).get.mockReturnValue({ + attempted: true, + enabled: true, + timestamp: Date.now(), + }); + mockInfoClient.userToMultiSigSigners.mockImplementation(async () => { + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + switchedAddress, + ); + return null; + }); + + await expect(provider.getAccountSupport()).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + ); + }); + it('returns the multi-signature reason for an unsupported account', async () => { mockInfoClient.userToMultiSigSigners.mockResolvedValue({ authorizedUsers: ['0x1234567890123456789012345678901234567890'], @@ -653,6 +674,37 @@ describe('HyperLiquidProvider', () => { expect(mockInfoClient.userToMultiSigSigners).toHaveBeenCalledTimes(1); }); + it('corrects cached support after batch cancellation status rejections', async () => { + const exchangeClient = createMockExchangeClient({ + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + { error: 'Multi-sig required' }, + { error: 'Multi-sig required' }, + ], + }, + }, + }), + }); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + mockInfoClient.userToMultiSigSigners.mockResolvedValue(null); + + const result = await provider.cancelOrders([ + { orderId: '123', symbol: 'BTC' }, + { orderId: '456', symbol: 'ETH' }, + ]); + + expect(result.success).toBe(false); + await expect(provider.getAccountSupport()).resolves.toEqual({ + isSupported: false, + reason: 'multi_sig_account', + }); + }); + it('retries support checks after a transient failure', async () => { mockInfoClient.userToMultiSigSigners .mockRejectedValueOnce(new Error('Network unavailable')) @@ -798,6 +850,38 @@ describe('HyperLiquidProvider', () => { expect(exchangeClient.withdraw3).not.toHaveBeenCalled(); }); + it('does not withdraw the switched account to the previous default destination', async () => { + const switchedAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + const exchangeClient = createMockExchangeClient(); + mockWalletService.requiresSignatureConfirmation.mockReturnValue(true); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockImplementation(async () => { + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + switchedAddress, + ); + return 'unifiedAccount'; + }), + userToMultiSigSigners: jest.fn().mockResolvedValue(null), + }), + ); + + const withdrawal = provider.withdraw({ + amount: '100', + assetId: + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/usdc' as CaipAssetId, + }); + + await expect(withdrawal).resolves.toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + }); + expect(exchangeClient.withdraw3).not.toHaveBeenCalled(); + }); + it('does not migrate the account selected after a delayed abstraction read', async () => { const switchedAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; const exchangeClient = createMockExchangeClient(); @@ -919,6 +1003,77 @@ describe('HyperLiquidProvider', () => { expect(exchangeClient.order).not.toHaveBeenCalled(); }); + it('does not close the switched account using the previous account position', async () => { + const switchedAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + const exchangeClient = createMockExchangeClient(); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + mockInfoClient.userToMultiSigSigners.mockResolvedValue(null); + mockSubscriptionService.getCachedPositionsForDex.mockImplementation( + () => { + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + switchedAddress, + ); + return [ + { + symbol: 'BTC', + size: '1', + marginUsed: '100', + leverage: { type: 'isolated', value: 10 }, + }, + ]; + }, + ); + + await expect( + provider.closePosition({ + symbol: 'BTC', + size: '0.1', + currentPrice: 50000, + }), + ).resolves.toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + }); + expect(exchangeClient.order).not.toHaveBeenCalled(); + }); + + it('does not retry a minimum-value rejection with the switched account', async () => { + const switchedAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + const exchangeClient = createMockExchangeClient(); + exchangeClient.order = jest + .fn() + .mockImplementationOnce(async () => { + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + switchedAddress, + ); + throw new Error('Order must have minimum value of $10'); + }) + .mockResolvedValueOnce({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 456 } }] } }, + }); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + mockInfoClient.userToMultiSigSigners.mockResolvedValue(null); + + await expect( + provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }), + ).resolves.toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + }); + expect(exchangeClient.order).toHaveBeenCalledTimes(1); + }); + it('blocks margin and DEX-transfer signing for an unsupported account', async () => { const exchangeClient = createMockExchangeClient(); mockClientService.getExchangeClient = jest @@ -2314,23 +2469,13 @@ describe('HyperLiquidProvider', () => { ); }); - it('checks support for the account captured by unified account setup', async () => { + it('does not migrate or poison support when the account switches during init setup', async () => { const switchedAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; const mockExchangeClient = createMockExchangeClient(); - const userToMultiSigSigners = jest + mockExchangeClient.agentSetAbstraction = jest .fn() - .mockImplementation(({ user }: { user: string }) => - Promise.resolve( - user === USER_ADDRESS - ? { - authorizedUsers: [ - '0xabc0000000000000000000000000000000000001', - ], - threshold: 2, - } - : null, - ), - ); + .mockRejectedValue(new Error('ApiRequestError: Multi-sig required')); + const userToMultiSigSigners = jest.fn().mockResolvedValue(null); const userAbstraction = jest.fn().mockImplementation(async () => { mockWalletService.getUserAddressWithDefault.mockResolvedValue( switchedAddress, @@ -2347,19 +2492,54 @@ describe('HyperLiquidProvider', () => { .fn() .mockReturnValue(mockExchangeClient); - await provider.getMarketDataWithPrices(); + await expect(provider.getMarketDataWithPrices()).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + ); - expect(userToMultiSigSigners).toHaveBeenCalledWith({ - user: USER_ADDRESS, - }); - expect(userToMultiSigSigners).not.toHaveBeenCalledWith({ - user: switchedAddress, - }); + expect(userToMultiSigSigners).not.toHaveBeenCalled(); expect(mockExchangeClient.agentSetAbstraction).not.toHaveBeenCalled(); expect( (TradingReadinessCache as jest.Mocked) .set, - ).toHaveBeenCalledWith('mainnet', USER_ADDRESS, { + ).not.toHaveBeenCalledWith('mainnet', USER_ADDRESS, { + attempted: true, + enabled: false, + }); + }); + + it('blocks init migration dispatch when its signer account switches', async () => { + const switchedAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + const dispatch = jest.fn(); + const mockExchangeClient = createMockExchangeClient(); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + userToMultiSigSigners: jest.fn().mockResolvedValue(null), + }), + ); + mockClientService.getExchangeClient = jest.fn().mockImplementation( + (beforeDispatch?: () => Promise) => + ({ + ...mockExchangeClient, + agentSetAbstraction: jest.fn().mockImplementation(async () => { + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + switchedAddress, + ); + await beforeDispatch?.(); + dispatch(); + }), + }) as ReturnType, + ); + + await expect(provider.getMarketDataWithPrices()).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + ); + + expect(dispatch).not.toHaveBeenCalled(); + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).not.toHaveBeenCalledWith('mainnet', USER_ADDRESS, { attempted: true, enabled: false, }); @@ -2599,6 +2779,33 @@ describe('HyperLiquidProvider', () => { }, ); + it('keeps account-switching hardware-wallet init non-signing', async () => { + const switchedAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + mockWalletService.requiresSignatureConfirmation.mockReturnValue(true); + const mockExchangeClient = createMockExchangeClient(); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockImplementation(async () => { + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + switchedAddress, + ); + return 'default'; + }), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + await expect(provider.getMarketDataWithPrices()).resolves.toBeDefined(); + + expect(mockExchangeClient.agentSetAbstraction).not.toHaveBeenCalled(); + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).not.toHaveBeenCalled(); + }); + it('does NOT call setUserAbstractionMode when migration fails', async () => { const mockExchangeClient = createMockExchangeClient(); mockExchangeClient.agentSetAbstraction = jest @@ -3019,6 +3226,7 @@ describe('HyperLiquidProvider', () => { await provider.getMarketDataWithPrices(); await provider.disconnect(); + await provider.initialize(); await provider.getMarketDataWithPrices(); expect(userAbstraction).toHaveBeenCalledTimes(2); @@ -3056,6 +3264,7 @@ describe('HyperLiquidProvider', () => { await disconnecting; await loading; + await provider.initialize(); await provider.getMarketDataWithPrices(); expect(userAbstraction).toHaveBeenCalledTimes(2); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts index e97d6b01909..4095e81c07f 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts @@ -4912,6 +4912,42 @@ describe('HyperLiquidProvider - strategy order types', () => { expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_REJECTED); }); + it('corrects account support when every rung is rejected for multi-signature', async () => { + useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + { error: 'Multi-sig required' }, + { error: 'Multi-sig required' }, + { error: 'Multi-sig required' }, + ], + }, + }, + }), + }, + }); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + expect(result).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, + }); + expect(await provider.getAccountSupport()).toStrictEqual({ + isSupported: false, + reason: 'multi_sig_account', + }); + }); + it.each([ ['missing', undefined], ['non-array', { resting: { oid: 11 } }], @@ -4945,7 +4981,7 @@ describe('HyperLiquidProvider - strategy order types', () => { .mockResolvedValueOnce({ status: 'ok', response: { - data: { statuses: [{ error: 'multi-sig required' }] }, + data: { statuses: [{ error: 'cancel refused' }] }, }, }) .mockResolvedValueOnce({ @@ -5043,6 +5079,48 @@ describe('HyperLiquidProvider - strategy order types', () => { expect(exchangeClient.approveBuilderFee).not.toHaveBeenCalled(); }); + it('corrects account support when Scale cancellation requires multi-signature', async () => { + useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue(scaleStatuses), + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + { error: 'Multi-sig required' }, + { error: 'Multi-sig required' }, + { error: 'Multi-sig required' }, + ], + }, + }, + }), + }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + const result = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'scale', + }); + + expect(result).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.ORDER_STRATEGY_CANCEL_INCOMPLETE, + }); + expect(await provider.getAccountSupport()).toStrictEqual({ + isSupported: false, + reason: 'multi_sig_account', + }); + }); + it('does not restore a canceled group from a stale open-order cache', async () => { const { exchangeClient } = useStrategyClients({ exchange: { @@ -5273,7 +5351,7 @@ describe('HyperLiquidProvider - strategy order types', () => { status: 'ok', response: { data: { - statuses: ['success', { error: 'multi-sig required' }, 'success'], + statuses: ['success', { error: 'cancel refused' }, 'success'], }, }, }) @@ -6043,7 +6121,7 @@ describe('HyperLiquidProvider - strategy order types', () => { .fn() .mockResolvedValueOnce({ status: 'ok', - response: { data: { statuses: [{ error: 'multi-sig required' }] } }, + response: { data: { statuses: [{ error: 'cancel refused' }] } }, }) .mockResolvedValue({ status: 'ok', @@ -6087,7 +6165,7 @@ describe('HyperLiquidProvider - strategy order types', () => { jest.useFakeTimers(); const cancel = jest.fn().mockResolvedValue({ status: 'ok', - response: { data: { statuses: [{ error: 'multi-sig required' }] } }, + response: { data: { statuses: [{ error: 'cancel refused' }] } }, }); useStrategyClients({ exchange: { order: jest.fn().mockResolvedValue(chaseRested), cancel }, @@ -7887,7 +7965,7 @@ describe('HyperLiquidProvider - strategy order types', () => { return { status: 'ok', response: { - data: { statuses: [{ error: 'multi-sig required' }] }, + data: { statuses: [{ error: 'cancel refused' }] }, }, }; } @@ -7896,7 +7974,7 @@ describe('HyperLiquidProvider - strategy order types', () => { ? { status: 'ok', response: { - data: { statuses: [{ error: 'multi-sig required' }] }, + data: { statuses: [{ error: 'cancel refused' }] }, }, } : { @@ -10267,7 +10345,7 @@ describe('HyperLiquidProvider - strategy order types', () => { }), cancel: jest.fn().mockResolvedValue({ status: 'ok', - response: { data: { statuses: [{ error: 'multi-sig required' }] } }, + response: { data: { statuses: [{ error: 'cancel refused' }] } }, }), }, }); @@ -10308,7 +10386,7 @@ describe('HyperLiquidProvider - strategy order types', () => { .fn() .mockResolvedValueOnce({ status: 'ok', - response: { data: { statuses: [{ error: 'multi-sig required' }] } }, + response: { data: { statuses: [{ error: 'cancel refused' }] } }, }) .mockResolvedValue({ status: 'ok', From 9714edf05282403824b36c63565afead9e57b15a Mon Sep 17 00:00:00 2001 From: geositta Date: Tue, 13 Oct 2026 10:05:06 -0500 Subject: [PATCH 13/17] fix: guard strategy rollback context Co-authored-by: Cursor --- .../src/providers/HyperLiquidProvider.ts | 53 +++-- ...yperLiquidProvider.strategy-orders.test.ts | 212 ++++++++---------- 2 files changed, 127 insertions(+), 138 deletions(-) diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index b108e4a1163..49a9b4c007c 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -7778,6 +7778,10 @@ export class HyperLiquidProvider implements PerpsProvider { if (generation !== this.#strategyGeneration) { let remainsLive = true; try { + await this.#assertNoKnownUnsupportedAccount( + accountContext, + 'Stale TWAP placement rollback', + ); const cancelResult = await exchangeClient.twapCancel({ a: assetId, t: running.twapId, @@ -8012,6 +8016,27 @@ export class HyperLiquidProvider implements PerpsProvider { orderIds: string[]; clientOrderIds: Hex[]; }> => { + try { + await this.#assertNoKnownUnsupportedAccount( + accountContext, + 'Scale placement rollback', + ); + } catch (error) { + this.#deps.debugLogger.log( + 'Scale placement rollback not signed after its account context changed', + { + error: ensureError( + error, + 'HyperLiquidProvider.placeScaleOrder.rollback', + ).message, + groupId, + }, + ); + return { + orderIds: restingChildOrderIds, + clientOrderIds: cleanupClientOrderIds, + }; + } const [orderIds, pendingClientOrderIds] = await Promise.all([ this.#cancelOrderRequests( exchangeClient, @@ -16085,11 +16110,20 @@ export class HyperLiquidProvider implements PerpsProvider { * amount: '10' * }); */ - transferBetweenDexs( + async transferBetweenDexs( params: TransferBetweenDexsParams, - ): Promise; + ): Promise { + return await this.#transferBetweenDexs(params); + } - async transferBetweenDexs( + /** + * Transfer collateral with an optional context captured by a parent action. + * + * @param params - Transfer parameters. + * @param expectedContext - Context captured by the parent action. + * @returns The transfer result. + */ + async #transferBetweenDexs( params: TransferBetweenDexsParams, expectedContext?: AccountSupportContext, ): Promise { @@ -16196,12 +16230,7 @@ export class HyperLiquidProvider implements PerpsProvider { } /** - * Invoke the public transfer path with an operation context that is hidden - * from the provider contract. - * - * The cast reaches the implementation-only second parameter while preserving - * the one-parameter public declaration. Calling through the public method - * also keeps existing instrumentation and test spies on that boundary. + * Transfer collateral using a context captured by a parent action. * * @param params - Transfer parameters. * @param accountContext - Original action context for the transfer. @@ -16211,11 +16240,7 @@ export class HyperLiquidProvider implements PerpsProvider { params: TransferBetweenDexsParams, accountContext: AccountSupportContext, ): Promise { - const transfer = this.transferBetweenDexs.bind(this) as unknown as ( - transferParams: TransferBetweenDexsParams, - expectedContext: AccountSupportContext, - ) => Promise; - return await transfer(params, accountContext); + return await this.#transferBetweenDexs(params, accountContext); } /** diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts index 4095e81c07f..7bc055df157 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts @@ -24,6 +24,7 @@ import type { PerpsPlatformDependencies, OrderParams, OrderResult, + TransferBetweenDexsParams, } from '../../../src/types/index.js'; import type { OrderType } from '../../../src/types/perps-types.js'; import { createGuardedHyperLiquidClient } from '../../../src/utils/guardedHyperLiquidClient.js'; @@ -4429,8 +4430,18 @@ describe('HyperLiquidProvider - strategy order types', () => { } return Promise.resolve(createClearinghouseBalance(withdrawable)); }); + const cleanupStarted = createDeferred(); + const pendingCleanup = createDeferred<{ status: 'ok' }>(); + const sendAsset = jest + .fn() + .mockResolvedValueOnce({ status: 'ok' }) + .mockImplementationOnce(() => { + cleanupStarted.resolve(); + return pendingCleanup.promise; + }); useStrategyClients({ exchange: { + sendAsset, twapOrder: jest.fn().mockImplementation(async () => { twapPlaced = true; return { @@ -4477,15 +4488,6 @@ describe('HyperLiquidProvider - strategy order types', () => { useUnifiedAccount: false, initialAssetMapping: [['xyz:TSLA', 110000]], }); - const cleanupStarted = createDeferred(); - const pendingCleanup = createDeferred<{ success: boolean }>(); - const transfer = jest - .spyOn(provider, 'transferBetweenDexs') - .mockResolvedValueOnce({ success: true }) - .mockImplementationOnce(() => { - cleanupStarted.resolve(); - return pendingCleanup.promise; - }); expect( await provider.placeOrder({ @@ -4498,11 +4500,11 @@ describe('HyperLiquidProvider - strategy order types', () => { const reads = [provider.getTwapOrders(), provider.getTwapOrders()]; await cleanupStarted.promise; - expect(transfer).toHaveBeenCalledTimes(2); - pendingCleanup.resolve({ success: true }); + expect(sendAsset).toHaveBeenCalledTimes(2); + pendingCleanup.resolve({ status: 'ok' }); await Promise.all(reads); - expect(transfer).toHaveBeenCalledTimes(2); + expect(sendAsset).toHaveBeenCalledTimes(2); }); it('retries a failed HIP-3 collateral rebalance with fresh context after reconnect', async () => { @@ -4524,8 +4526,9 @@ describe('HyperLiquidProvider - strategy order types', () => { }, }; }); + const sendAsset = jest.fn().mockResolvedValue({ status: 'ok' }); useStrategyClients({ - exchange: { twapOrder }, + exchange: { sendAsset, twapOrder }, info: { clearinghouseState, perpDexs: jest.fn().mockResolvedValue([null, { name: 'xyz' }]), @@ -4561,9 +4564,6 @@ describe('HyperLiquidProvider - strategy order types', () => { useUnifiedAccount: false, initialAssetMapping: [['xyz:TSLA', 110000]], }); - const initialTransfer = jest - .spyOn(provider, 'transferBetweenDexs') - .mockResolvedValue({ success: true }); expect( await provider.placeOrder({ @@ -4573,33 +4573,22 @@ describe('HyperLiquidProvider - strategy order types', () => { twapDuration: 30, } satisfies OrderParams), ).toMatchObject({ success: true, orderId: '987' }); - expect(initialTransfer).toHaveBeenCalledTimes(1); + expect(sendAsset).toHaveBeenCalledTimes(1); await provider.disconnect(); - initialTransfer.mockRestore(); expect(await provider.initialize()).toMatchObject({ success: true }); - const rebalance = jest - .spyOn(provider, 'transferBetweenDexs') - .mockResolvedValueOnce({ success: false, error: 'transfer failed' }) - .mockResolvedValue({ success: true }); + sendAsset + .mockReset() + .mockResolvedValueOnce({ status: 'err' }) + .mockResolvedValue({ status: 'ok' }); await provider.getTwapOrders(); - expect(rebalance).toHaveBeenCalledTimes(1); + expect(sendAsset).toHaveBeenCalledTimes(1); await provider.getTwapOrders(); - expect(rebalance).toHaveBeenCalledTimes(2); + expect(sendAsset).toHaveBeenCalledTimes(2); await provider.getTwapOrders(); - expect(rebalance).toHaveBeenCalledTimes(2); - expect(rebalance.mock.calls).toStrictEqual([ - [ - expect.any(Object), - expect.objectContaining({ lifecycleGeneration: 1 }), - ], - [ - expect.any(Object), - expect.objectContaining({ lifecycleGeneration: 1 }), - ], - ]); + expect(sendAsset).toHaveBeenCalledTimes(2); }); }); @@ -5848,12 +5837,18 @@ describe('HyperLiquidProvider - strategy order types', () => { } return Promise.resolve(createClearinghouseBalance(withdrawable)); }); + const sendAsset = jest + .fn() + .mockResolvedValueOnce({ status: 'ok' }) + .mockResolvedValueOnce({ status: 'err' }) + .mockResolvedValue({ status: 'ok' }); useStrategyClients({ exchange: { order: jest.fn().mockImplementation(async () => { chasePlaced = true; return chaseRested; }), + sendAsset, }, info: { clearinghouseState, @@ -5871,11 +5866,6 @@ describe('HyperLiquidProvider - strategy order types', () => { useUnifiedAccount: false, initialAssetMapping: [['xyz:TSLA', 110000]], }); - const transfer = jest - .spyOn(provider, 'transferBetweenDexs') - .mockResolvedValueOnce({ success: true }) - .mockResolvedValueOnce({ success: false, error: 'Transfer failed' }) - .mockResolvedValue({ success: true }); const placed = await provider.placeOrder({ ...baseOrder, @@ -5884,21 +5874,21 @@ describe('HyperLiquidProvider - strategy order types', () => { } satisfies OrderParams); expect(placed.success).toBe(true); - expect(transfer).toHaveBeenCalledTimes(1); + expect(sendAsset).toHaveBeenCalledTimes(1); await provider.suspendChaseOrders(); - expect(transfer).toHaveBeenCalledTimes(1); + expect(sendAsset).toHaveBeenCalledTimes(1); await provider.cancelOrder({ orderId: placed.orderId, symbol: 'xyz:TSLA', orderType: 'chase', }); - expect(transfer).toHaveBeenCalledTimes(2); + expect(sendAsset).toHaveBeenCalledTimes(2); await provider.getChaseOrders(); - expect(transfer).toHaveBeenCalledTimes(3); + expect(sendAsset).toHaveBeenCalledTimes(3); await provider.getChaseOrders(); - expect(transfer).toHaveBeenCalledTimes(3); + expect(sendAsset).toHaveBeenCalledTimes(3); }); it('reports a backgrounded child that later fills as filled', async () => { @@ -6301,7 +6291,7 @@ describe('HyperLiquidProvider - strategy order types', () => { const useHip3MarketOrder = ( orderResponse: Record, ): { - transfer: jest.SpyInstance; + transfer: jest.Mock; order: jest.Mock; exchangeClient: MockClient; } => { @@ -6338,7 +6328,7 @@ describe('HyperLiquidProvider - strategy order types', () => { initialAssetMapping: [['xyz:TSLA', 110000]], }); return { - transfer: jest.spyOn(provider, 'transferBetweenDexs'), + transfer: exchangeClient.sendAsset, order, exchangeClient, }; @@ -6373,6 +6363,22 @@ describe('HyperLiquidProvider - strategy order types', () => { const REBALANCE_NOT_SIGNED = 'HyperLiquidProvider: Auto-rebalance not signed - funds remain on HIP-3 DEX'; + const transferRequests = ( + transfer: jest.Mock, + ): TransferBetweenDexsParams[] => + transfer.mock.calls.map( + ([request]: [ + TransferBetweenDexsParams & { + destination: string; + token: string; + }, + ]) => ({ + sourceDex: request.sourceDex, + destinationDex: request.destinationDex, + amount: request.amount, + }), + ); + /** * The transfers noted as not signed, with their details. * @@ -6401,10 +6407,9 @@ describe('HyperLiquidProvider - strategy order types', () => { it('reads an omitted margin mode from the HIP-3 DEX position slice', async () => { const { transfer, exchangeClient } = useHip3MarketOrder(REFUSED_ORDER); - transfer.mockResolvedValueOnce({ - success: false, - error: PERPS_ERROR_CODES.KEYRING_LOCKED, - }); + transfer.mockRejectedValueOnce( + new Error(PERPS_ERROR_CODES.KEYRING_LOCKED), + ); // Only the xyz slice holds the position, so its mode proves the lookup // routes a HIP-3 symbol to its own DEX cache. mockSubscriptionService.getCachedPositionsForDex.mockImplementation( @@ -6430,10 +6435,9 @@ describe('HyperLiquidProvider - strategy order types', () => { it('fails a HIP-3 order with KEYRING_LOCKED, sending nothing, when its collateral transfer cannot be signed', async () => { const { transfer, order } = useHip3MarketOrder(REFUSED_ORDER); - transfer.mockResolvedValueOnce({ - success: false, - error: PERPS_ERROR_CODES.KEYRING_LOCKED, - }); + transfer.mockRejectedValueOnce( + new Error(PERPS_ERROR_CODES.KEYRING_LOCKED), + ); const result = await provider.placeOrder(HIP3_MARKET_ORDER); @@ -6441,9 +6445,7 @@ describe('HyperLiquidProvider - strategy order types', () => { success: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); - expect(transfer.mock.calls).toStrictEqual([ - [PRE_ORDER_TRANSFER, expect.any(Object)], - ]); + expect(transferRequests(transfer)).toStrictEqual([PRE_ORDER_TRANSFER]); expect(order).not.toHaveBeenCalled(); expect(reportedErrors()).toStrictEqual([]); }); @@ -6457,6 +6459,7 @@ describe('HyperLiquidProvider - strategy order types', () => { { transferError: 'transfer failed', reported: [ + ['transfer failed', 'transferBetweenDexs'], ['transfer failed', 'placeOrder:rollback'], [ORDER_FAILURE, 'placeOrder'], ], @@ -6467,15 +6470,15 @@ describe('HyperLiquidProvider - strategy order types', () => { async ({ transferError, reported, notes }) => { const { transfer } = useHip3MarketOrder(REFUSED_ORDER); transfer - .mockResolvedValueOnce({ success: true }) - .mockResolvedValueOnce({ success: false, error: transferError }); + .mockResolvedValueOnce({ status: 'ok' }) + .mockRejectedValueOnce(new Error(transferError)); const result = await provider.placeOrder(HIP3_MARKET_ORDER); expect(result).toStrictEqual({ success: false, error: ORDER_FAILURE }); - expect(transfer.mock.calls).toStrictEqual([ - [PRE_ORDER_TRANSFER, expect.any(Object)], - [ROLLBACK_TRANSFER, expect.any(Object)], + expect(transferRequests(transfer)).toStrictEqual([ + PRE_ORDER_TRANSFER, + ROLLBACK_TRANSFER, ]); expect(reportedErrors()).toStrictEqual(reported); expect(unsignedTransferNotes()).toStrictEqual(notes); @@ -6490,7 +6493,10 @@ describe('HyperLiquidProvider - strategy order types', () => { }, { transferError: 'transfer failed', - reported: [['transfer failed', 'placeOrder:autoRebalance']], + reported: [ + ['transfer failed', 'transferBetweenDexs'], + ['transfer failed', 'placeOrder:autoRebalance'], + ], notes: [], }, ])( @@ -6505,8 +6511,8 @@ describe('HyperLiquidProvider - strategy order types', () => { }, }); transfer - .mockResolvedValueOnce({ success: true }) - .mockResolvedValueOnce({ success: false, error: transferError }); + .mockResolvedValueOnce({ status: 'ok' }) + .mockRejectedValueOnce(new Error(transferError)); const result = await provider.placeOrder(HIP3_MARKET_ORDER); @@ -6518,9 +6524,9 @@ describe('HyperLiquidProvider - strategy order types', () => { submittedSize: '1', averagePrice: '3000', }); - expect(transfer.mock.calls).toStrictEqual([ - [PRE_ORDER_TRANSFER, expect.any(Object)], - [REBALANCE_TRANSFER, expect.any(Object)], + expect(transferRequests(transfer)).toStrictEqual([ + PRE_ORDER_TRANSFER, + REBALANCE_TRANSFER, ]); expect(reportedErrors()).toStrictEqual(reported); expect(unsignedTransferNotes()).toStrictEqual(notes); @@ -11848,7 +11854,7 @@ describe('HyperLiquidProvider - strategy order types', () => { }); describe('Strategy placement racing a disconnect', () => { - it('retracts a TWAP that finishes after teardown through its captured client', async () => { + it('does not sign a TWAP rollback after its account context becomes stale', async () => { let disconnected: Promise | undefined; const twapOrder = jest.fn().mockImplementation(async () => { disconnected = provider.disconnect(); @@ -11879,52 +11885,16 @@ describe('HyperLiquidProvider - strategy order types', () => { } satisfies OrderParams); await disconnected; - expect(exchangeClient.twapCancel).toHaveBeenCalledWith({ a: 1, t: 987 }); + expect(exchangeClient.twapCancel).not.toHaveBeenCalled(); expect(placed).toMatchObject({ success: false, error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, submittedSize: '1', }); - expect(placed.orderId).toBeUndefined(); + expect(placed.orderId).toBe('987'); }); - it('treats an already-finished stale TWAP as retracted', async () => { - let disconnected: Promise | undefined; - const twapOrder = jest.fn().mockImplementation(async () => { - disconnected = provider.disconnect(); - await Promise.resolve(); - return { - status: 'ok', - response: { - type: 'twapOrder', - data: { status: { running: { twapId: 987 } } }, - }, - }; - }); - const twapCancel = jest.fn().mockResolvedValue({ - status: 'ok', - response: { - type: 'twapCancel', - data: { status: { error: 'Twap not found' } }, - }, - }); - useStrategyClients({ exchange: { twapOrder, twapCancel } }); - - const placed = await provider.placeOrder({ - ...baseOrder, - orderType: 'twap', - twapDuration: 30, - } satisfies OrderParams); - await disconnected; - - expect(placed).toStrictEqual({ - success: false, - error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, - submittedSize: '1', - }); - }); - - it('retracts resting scale rungs and registers no stale group after teardown', async () => { + it('does not sign Scale rollbacks after their account context becomes stale', async () => { let disconnected: Promise | undefined; const order = jest.fn().mockImplementation(async () => { disconnected = provider.disconnect(); @@ -11962,19 +11932,14 @@ describe('HyperLiquidProvider - strategy order types', () => { } satisfies OrderParams); await disconnected; - expect(exchangeClient.cancel).toHaveBeenCalledWith({ - cancels: [ - { a: 1, o: 11 }, - { a: 1, o: 33 }, - ], - }); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); expect(placed).toMatchObject({ success: false, error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, submittedSize: '1', }); - expect(placed.orderId).toBeUndefined(); - expect(placed.childOrderIds).toStrictEqual([]); + expect(placed.orderId).toMatch(/^scale:/u); + expect(placed.childOrderIds).toStrictEqual(['11', '33']); expect(placed.acceptedChildren).toStrictEqual([ { orderId: '11', state: 'resting' }, { orderId: '22', state: 'filled' }, @@ -12155,9 +12120,14 @@ describe('HyperLiquidProvider - strategy order types', () => { response: { data: { statuses: [{ resting: { oid: 55 } }] } }, }; }); + const sendAsset = jest.fn().mockImplementation(async () => { + transferred = true; + return { status: 'ok' }; + }); useStrategyClients({ exchange: { order, + sendAsset, cancel: jest.fn().mockResolvedValue({ status: 'ok', response: { @@ -12189,12 +12159,6 @@ describe('HyperLiquidProvider - strategy order types', () => { useUnifiedAccount: false, initialAssetMapping: [['xyz:TSLA', 110000]], }); - const transfer = jest - .spyOn(provider, 'transferBetweenDexs') - .mockImplementation(async () => { - transferred = true; - return { success: true }; - }); const placed = await provider.placeOrder({ ...baseOrder, @@ -12208,7 +12172,7 @@ describe('HyperLiquidProvider - strategy order types', () => { error: PERPS_ERROR_CODES.ORDER_CHASE_ABANDONED, childOrderIds: ['55'], }); - expect(transfer).toHaveBeenCalledTimes(1); + expect(sendAsset).toHaveBeenCalledTimes(1); }); it('places nothing when the teardown lands during the book read', async () => { From e0be723dcfc50298c93964b061ad88747fc32689 Mon Sep 17 00:00:00 2001 From: geositta Date: Tue, 13 Oct 2026 10:35:00 -0500 Subject: [PATCH 14/17] test: satisfy account support type checks Co-authored-by: Cursor --- .../providers/HyperLiquidProvider.account-mode.test.ts | 9 ++++++--- .../HyperLiquidProvider.strategy-orders.test.ts | 3 +++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts index bf0eddf93dd..5b9bf533bb6 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts @@ -35,6 +35,7 @@ import { createStandaloneInfoClient } from '../../../src/utils/standaloneInfoCli import { createMockExchangeClient, createMockInfoClient, + createMockPosition, } from '../../helpers/providerMocks.js'; import { createMockInfrastructure, @@ -1016,12 +1017,12 @@ describe('HyperLiquidProvider', () => { switchedAddress, ); return [ - { + createMockPosition({ symbol: 'BTC', size: '1', marginUsed: '100', leverage: { type: 'isolated', value: 10 }, - }, + }), ]; }, ); @@ -2101,7 +2102,9 @@ describe('HyperLiquidProvider', () => { release(); }; }); - mockedCache.get.mockImplementation((network) => cache.get(network)); + mockedCache.get.mockImplementation((network, _userAddress) => + cache.get(network), + ); mockedCache.set.mockImplementation((network, _user, result) => { cache.set(network, result); }); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts index 7bc055df157..4cd8f214770 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts @@ -5094,6 +5094,9 @@ describe('HyperLiquidProvider - strategy order types', () => { scaleMaxPrice: '3000', scaleNumOrders: 3, } satisfies OrderParams); + if (!placed.orderId) { + throw new Error('Expected a Scale group handle'); + } const result = await provider.cancelOrder({ orderId: placed.orderId, symbol: 'ETH', From ae180786e815d34cda02fd08be8f3486602b0e49 Mon Sep 17 00:00:00 2001 From: geositta Date: Tue, 13 Oct 2026 10:47:53 -0500 Subject: [PATCH 15/17] test: align signing guard integration mocks Co-authored-by: Cursor --- ...ntroller.agent-signing.integration.test.ts | 36 +++++- ...yperLiquidProvider.strategy-signer.test.ts | 112 +++++++----------- 2 files changed, 75 insertions(+), 73 deletions(-) diff --git a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts index 1ac7954d10f..718d0235532 100644 --- a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts @@ -73,10 +73,26 @@ const mockVenue = { revokedAgents: new Set(), }; +type MockTransport = { + isTestnet: boolean; + request( + endpoint: 'info' | 'exchange', + payload: unknown, + signal?: AbortSignal, + ): Promise; +}; + class MockHttpTransport { + readonly isTestnet: boolean; + constructor({ isTestnet }: { isTestnet: boolean }) { + this.isTestnet = isTestnet; mockVenue.networks.push(isTestnet ? 'testnet' : 'mainnet'); } + + async request(): Promise { + return undefined as Result; + } } class MockWebSocketTransport { @@ -97,8 +113,18 @@ class MockWebSocketTransport { class MockExchangeClient { readonly #wallet: HyperLiquidWalletParams; - constructor({ wallet }: { wallet: HyperLiquidWalletParams }) { + readonly config_: { + wallet: HyperLiquidWalletParams; + transport: MockTransport; + }; + + constructor(options: { + wallet: HyperLiquidWalletParams; + transport: MockTransport; + }) { + const { wallet } = options; this.#wallet = wallet; + this.config_ = options; } async order(params: unknown): Promise { @@ -140,7 +166,12 @@ class MockExchangeClient { params: unknown, payload: PerpsTypedDataPayload, ): Promise { - const signer = SIGNERS.get(await signThroughWallet(this.#wallet, payload)); + const signature = await signThroughWallet(this.#wallet, payload); + await this.config_.transport.request('exchange', { + action: params, + signature: { r: signature }, + }); + const signer = SIGNERS.get(signature); mockVenue.writes.push({ write, params, signer }); if (signer && mockVenue.revokedAgents.has(signer)) { throw unknownWalletError(signer); @@ -173,6 +204,7 @@ jest.mock('@nktkas/hyperliquid', () => ({ }, ExchangeClient: function ExchangeClient(options: { wallet: HyperLiquidWalletParams; + transport: MockTransport; }): MockExchangeClient { return new MockExchangeClient(options); }, diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-signer.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-signer.test.ts index 210e06acaef..aaac3db25b4 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-signer.test.ts @@ -488,81 +488,51 @@ describe('HyperLiquidProvider with accountSigner: strategy cancels', () => { }, ); - it.each([ - [ - 'thrown', - (): Promise => - Promise.reject(unknownWalletError(AGENT_ADDRESS)), - ], - [ - 'in its status entry', - async (): Promise> => ({ + it('does not ask an agent to retract a TWAP after its context becomes stale', async () => { + const { + provider, + twapOrder, + twapCancel, + getAgentSigner, + onAgentRejected, + signL1Action, + } = createStrategyProvider('rejected'); + let disconnected: Promise | undefined; + // The provider is torn down while the TWAP is placed, so it + // retracts it. + twapOrder.mockImplementation(async () => { + await signL1Action(); + disconnected = provider.disconnect(); + return { status: 'ok', response: { - type: 'twapCancel', - data: { - status: { error: unknownWalletError(AGENT_ADDRESS).message }, - }, + type: 'twapOrder', + data: { status: { running: { twapId: 987 } } }, }, - }), - ], - ])( - 'drops an agent the venue rejects while retracting a stale TWAP (%s)', - async (_how, answerCancel) => { - const { - provider, - twapOrder, - twapCancel, - getAgentSigner, - onAgentRejected, - signL1Action, - } = createStrategyProvider('rejected'); - let disconnected: Promise | undefined; - // The provider is torn down while the TWAP is placed, so it - // retracts it. - twapOrder.mockImplementation(async () => { - await signL1Action(); - disconnected = provider.disconnect(); - return { - status: 'ok', - response: { - type: 'twapOrder', - data: { status: { running: { twapId: 987 } } }, - }, - }; - }); - twapCancel.mockImplementation(async () => { - await signL1Action(); - return await answerCancel(); - }); - - const placed = await provider.placeOrder({ - ...ETH_ORDER, - orderType: 'twap', - twapDuration: 30, - }); - await disconnected; - await signL1Action(); + }; + }); - // The retraction was refused, so the TWAP is reported as live. - expect(placed).toStrictEqual({ - success: false, - error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, - submittedSize: '1', - orderId: '987', - }); - expect(twapCancel.mock.calls).toStrictEqual([[{ a: 1, t: 987 }]]); - expect(onAgentRejected.mock.calls).toStrictEqual([ - [MAINNET_ACCOUNT, AGENT_ADDRESS], - ]); - // Dropped, so the next L1 action asks again. - expect(getAgentSigner.mock.calls).toStrictEqual([ - [MAINNET_ACCOUNT], - [MAINNET_ACCOUNT], - ]); - expect(loggerError).not.toHaveBeenCalled(); - }, - ); + const placed = await provider.placeOrder({ + ...ETH_ORDER, + orderType: 'twap', + twapDuration: 30, + }); + await disconnected; + await signL1Action(); + + // The stale context blocks the retraction before another signature, + // so the TWAP is reported as live and the agent remains cached. + expect(placed).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + submittedSize: '1', + orderId: '987', + }); + expect(twapCancel).not.toHaveBeenCalled(); + expect(onAgentRejected).not.toHaveBeenCalled(); + expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); + expect(loggerError).not.toHaveBeenCalled(); + }); it.each([ [ From 407121226bc4a4d56e93e1896e4e7e1fb034a090 Mon Sep 17 00:00:00 2001 From: geositta Date: Wed, 14 Oct 2026 09:12:37 -0500 Subject: [PATCH 16/17] fix: allow same-account strategy rollback Co-authored-by: Cursor --- .../src/providers/HyperLiquidProvider.ts | 48 ++++++-- ...yperLiquidProvider.strategy-orders.test.ts | 108 +++++++++++++++-- ...yperLiquidProvider.strategy-signer.test.ts | 112 +++++++++++------- 3 files changed, 209 insertions(+), 59 deletions(-) diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 49a9b4c007c..b21cd1c1ddb 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -3073,13 +3073,25 @@ export class HyperLiquidProvider implements PerpsProvider { context.lifecycleGeneration, operation, ); - const currentAddress = await this.#walletService - .getUserAddressWithDefault() - .catch(() => undefined); + await this.#assertAccountIdentityCurrent(context); this.#assertProviderLifecycleCurrent( context.lifecycleGeneration, operation, ); + } + + /** + * Confirm that the selected address and network still match an action, + * without requiring the provider lifecycle that created it to remain active. + * + * @param context - The account and network captured by the action. + */ + async #assertAccountIdentityCurrent( + context: AccountSupportContext, + ): Promise { + const currentAddress = await this.#walletService + .getUserAddressWithDefault() + .catch(() => undefined); const currentNetwork = this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet'; @@ -3091,6 +3103,26 @@ export class HyperLiquidProvider implements PerpsProvider { } } + /** + * Permit best-effort rollback after teardown only while it still belongs to + * the same address and network, and the account is not known unsupported. + * + * @param context - The account and network captured by the parent action. + */ + async #assertRollbackAccountWritable( + context: AccountSupportContext, + ): Promise { + await this.#assertAccountIdentityCurrent(context); + const cachedSupport = this.#accountSupportByContext.get( + this.#getAccountSupportCacheKey(context.network, context.userAddress), + ); + const support = cachedSupport ? await cachedSupport : undefined; + await this.#assertAccountIdentityCurrent(context); + if (support && !support.isSupported) { + throw new Error(PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED); + } + } + /** * Block an action when another step has authoritatively corrected support * for its captured account. A missing cache entry remains fail-open: this @@ -7778,10 +7810,7 @@ export class HyperLiquidProvider implements PerpsProvider { if (generation !== this.#strategyGeneration) { let remainsLive = true; try { - await this.#assertNoKnownUnsupportedAccount( - accountContext, - 'Stale TWAP placement rollback', - ); + await this.#assertRollbackAccountWritable(accountContext); const cancelResult = await exchangeClient.twapCancel({ a: assetId, t: running.twapId, @@ -8017,10 +8046,7 @@ export class HyperLiquidProvider implements PerpsProvider { clientOrderIds: Hex[]; }> => { try { - await this.#assertNoKnownUnsupportedAccount( - accountContext, - 'Scale placement rollback', - ); + await this.#assertRollbackAccountWritable(accountContext); } catch (error) { this.#deps.debugLogger.log( 'Scale placement rollback not signed after its account context changed', diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts index 4cd8f214770..1a3a8c4fb3d 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts @@ -11857,7 +11857,7 @@ describe('HyperLiquidProvider - strategy order types', () => { }); describe('Strategy placement racing a disconnect', () => { - it('does not sign a TWAP rollback after its account context becomes stale', async () => { + it('retracts a TWAP that finishes after same-account teardown', async () => { let disconnected: Promise | undefined; const twapOrder = jest.fn().mockImplementation(async () => { disconnected = provider.disconnect(); @@ -11888,16 +11888,62 @@ describe('HyperLiquidProvider - strategy order types', () => { } satisfies OrderParams); await disconnected; - expect(exchangeClient.twapCancel).not.toHaveBeenCalled(); + expect(exchangeClient.twapCancel).toHaveBeenCalledWith({ a: 1, t: 987 }); expect(placed).toMatchObject({ success: false, error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, submittedSize: '1', }); - expect(placed.orderId).toBe('987'); + expect(placed.orderId).toBeUndefined(); }); - it('does not sign Scale rollbacks after their account context becomes stale', async () => { + it.each(['account', 'network'] as const)( + 'does not retract a TWAP after its %s changes', + async (change) => { + let disconnected: Promise | undefined; + const twapOrder = jest.fn().mockImplementation(async () => { + if (change === 'account') { + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + ); + } else { + mockClientService.isTestnetMode.mockReturnValue(true); + } + disconnected = provider.disconnect(); + await Promise.resolve(); + return { + status: 'ok', + response: { + type: 'twapOrder', + data: { status: { running: { twapId: 987 } } }, + }, + }; + }); + const twapCancel = jest.fn().mockResolvedValue({ + status: 'ok', + response: { type: 'twapCancel', data: { status: 'success' } }, + }); + const { exchangeClient } = useStrategyClients({ + exchange: { twapOrder, twapCancel }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'twap', + twapDuration: 30, + } satisfies OrderParams); + await disconnected; + + expect(exchangeClient.twapCancel).not.toHaveBeenCalled(); + expect(placed).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + orderId: '987', + }); + }, + ); + + it('retracts resting Scale rungs after same-account teardown', async () => { let disconnected: Promise | undefined; const order = jest.fn().mockImplementation(async () => { disconnected = provider.disconnect(); @@ -11935,20 +11981,68 @@ describe('HyperLiquidProvider - strategy order types', () => { } satisfies OrderParams); await disconnected; - expect(exchangeClient.cancel).not.toHaveBeenCalled(); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [ + { a: 1, o: 11 }, + { a: 1, o: 33 }, + ], + }); expect(placed).toMatchObject({ success: false, error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, submittedSize: '1', }); - expect(placed.orderId).toMatch(/^scale:/u); - expect(placed.childOrderIds).toStrictEqual(['11', '33']); + expect(placed.orderId).toBeUndefined(); + expect(placed.childOrderIds).toStrictEqual([]); expect(placed.acceptedChildren).toStrictEqual([ { orderId: '11', state: 'resting' }, { orderId: '22', state: 'filled' }, { orderId: '33', state: 'resting' }, ]); }); + + it('does not retract Scale rungs after the venue marks their account unsupported', async () => { + let disconnected: Promise | undefined; + const order = jest.fn().mockImplementation(async () => { + disconnected = provider.disconnect(); + await disconnected; + return { + status: 'ok', + response: { + data: { + statuses: [ + { resting: { oid: 11 } }, + { error: 'Multi-sig required' }, + { resting: { oid: 33 } }, + ], + }, + }, + }; + }); + const cancel = jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success', 'success'] } }, + }); + const { exchangeClient } = useStrategyClients({ + exchange: { order, cancel }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + await disconnected; + + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + expect(placed).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + childOrderIds: ['11', '33'], + }); + }); }); describe('Chase placement racing a disconnect', () => { diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-signer.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-signer.test.ts index aaac3db25b4..210e06acaef 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-signer.test.ts @@ -488,51 +488,81 @@ describe('HyperLiquidProvider with accountSigner: strategy cancels', () => { }, ); - it('does not ask an agent to retract a TWAP after its context becomes stale', async () => { - const { - provider, - twapOrder, - twapCancel, - getAgentSigner, - onAgentRejected, - signL1Action, - } = createStrategyProvider('rejected'); - let disconnected: Promise | undefined; - // The provider is torn down while the TWAP is placed, so it - // retracts it. - twapOrder.mockImplementation(async () => { - await signL1Action(); - disconnected = provider.disconnect(); - return { + it.each([ + [ + 'thrown', + (): Promise => + Promise.reject(unknownWalletError(AGENT_ADDRESS)), + ], + [ + 'in its status entry', + async (): Promise> => ({ status: 'ok', response: { - type: 'twapOrder', - data: { status: { running: { twapId: 987 } } }, + type: 'twapCancel', + data: { + status: { error: unknownWalletError(AGENT_ADDRESS).message }, + }, }, - }; - }); + }), + ], + ])( + 'drops an agent the venue rejects while retracting a stale TWAP (%s)', + async (_how, answerCancel) => { + const { + provider, + twapOrder, + twapCancel, + getAgentSigner, + onAgentRejected, + signL1Action, + } = createStrategyProvider('rejected'); + let disconnected: Promise | undefined; + // The provider is torn down while the TWAP is placed, so it + // retracts it. + twapOrder.mockImplementation(async () => { + await signL1Action(); + disconnected = provider.disconnect(); + return { + status: 'ok', + response: { + type: 'twapOrder', + data: { status: { running: { twapId: 987 } } }, + }, + }; + }); + twapCancel.mockImplementation(async () => { + await signL1Action(); + return await answerCancel(); + }); - const placed = await provider.placeOrder({ - ...ETH_ORDER, - orderType: 'twap', - twapDuration: 30, - }); - await disconnected; - await signL1Action(); - - // The stale context blocks the retraction before another signature, - // so the TWAP is reported as live and the agent remains cached. - expect(placed).toStrictEqual({ - success: false, - error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, - submittedSize: '1', - orderId: '987', - }); - expect(twapCancel).not.toHaveBeenCalled(); - expect(onAgentRejected).not.toHaveBeenCalled(); - expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); - expect(loggerError).not.toHaveBeenCalled(); - }); + const placed = await provider.placeOrder({ + ...ETH_ORDER, + orderType: 'twap', + twapDuration: 30, + }); + await disconnected; + await signL1Action(); + + // The retraction was refused, so the TWAP is reported as live. + expect(placed).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + submittedSize: '1', + orderId: '987', + }); + expect(twapCancel.mock.calls).toStrictEqual([[{ a: 1, t: 987 }]]); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + // Dropped, so the next L1 action asks again. + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }, + ); it.each([ [ From 207f31b04aa9ead915dd3876ce195dd644d419b9 Mon Sep 17 00:00:00 2001 From: geositta Date: Wed, 14 Oct 2026 10:52:38 -0500 Subject: [PATCH 17/17] docs: keep account support changelog unreleased Co-authored-by: Cursor --- packages/perps-controller/CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 847e1793b37..1bad2b6f40b 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `getAccountSupport` to the controller, messenger, and optional provider contract. Hyperliquid detects native multi-signature accounts, caches successful checks per account/network session, and blocks exchange mutations for unsupported accounts. Transient detection failures remain retryable, authoritative exchange rejections correct cached support, and actions reject stale account, network, or provider contexts before signing. ([#10693](https://github.com/MetaMask/core/pull/10693)) + ## [20.1.0] ### Added @@ -17,7 +21,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `createErrorResult` copies `errorCode` and `errorDetails` when the failure is a structured controller error. HyperLiquid mapped rejections, including an immediate-match rejection (`IOC_CANCEL`) and `EXCHANGE_ACCOUNT_NOT_FOUND`, now set `errorCode`; `OrderResult.error` stays the same message - A USD-derived order whose price moved past `maxSlippageBps` (or the default market slippage) reports `PRICE_MOVED`. The message still starts with `Price moved too much:`; the expected and current prices in it use HyperLiquid price formatting - Add `PERPS_EVENT_PROPERTY.PRICE_DELTA_BPS` (`price_delta_bps`) and export `PerpsMaxSlippageSource`. Trade and close analytics now include `failure_reason` from `errorCode` when one is present, `price_delta_bps` for a `PRICE_MOVED` rejection, and `max_slippage_pct`, `max_slippage_source`, and `estimated_slippage_pct` from the caller's tracking data. Raw `error_message` is still sent. Failed close events also include `close_type`, derived from the requested size, including when the provider throws. That property previously appeared only on executed closes ([#10385](https://github.com/MetaMask/core/pull/10385)) -- Add `getAccountSupport` to the controller, messenger, and optional provider contract. Hyperliquid detects native multi-signature accounts, caches successful checks per account/network session, and blocks exchange mutations for unsupported accounts. Transient detection failures remain retryable, authoritative exchange rejections correct cached support, and actions reject stale account, network, or provider contexts before signing. ([#10693](https://github.com/MetaMask/core/pull/10693)) ### Changed