From f879bdf438af6a3db4c2873be0964de35400eddd Mon Sep 17 00:00:00 2001 From: geositta Date: Tue, 6 Oct 2026 20:18:24 -0500 Subject: [PATCH 1/9] 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 | 4 + .../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, 369 insertions(+), 11 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index b8d36c8df31..23505c8bd58 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 trade and withdrawal signing while transient detection failures remain retryable. ([TAT-3752](https://consensyssoftware.atlassian.net/browse/TAT-3752)) + ### Changed - Bump `reselect` from `^5.1.1` to `^5.3.0` ([#10532](https://github.com/MetaMask/core/pull/10532)) 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 73e07901e53..55c37b75fc6 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 5ade0442ce2..6ebf4112503 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -103,6 +103,7 @@ import type { FeeCalculationResult, Funding, GetAccountStateParams, + PerpsAccountSupport, GetAvailableDexsParams, GetFundingParams, GetHistoricalPortfolioParams, @@ -1638,6 +1639,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(); @@ -2861,20 +2869,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, @@ -2891,7 +2900,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); } } @@ -3096,8 +3155,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 }, @@ -3108,9 +3167,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, @@ -3553,6 +3612,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 @@ -13487,6 +13547,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 @@ -15079,6 +15149,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'); @@ -16527,6 +16598,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 7620444e127..6ba3cb936fc 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -2447,6 +2447,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; @@ -2460,6 +2483,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 e5d4e3c2de7557d624206187790fb6a3c4fea0ed Mon Sep 17 00:00:00 2001 From: geositta Date: Thu, 8 Oct 2026 19:14:08 -0500 Subject: [PATCH 2/9] 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 6ebf4112503..e8f11861bff 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -2905,22 +2905,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 @@ -2948,7 +2961,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); } @@ -3155,7 +3168,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', @@ -13554,7 +13571,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 a60d5d533370fb6a7b7e6b10913429d15a0d1f0a Mon Sep 17 00:00:00 2001 From: geositta Date: Thu, 8 Oct 2026 19:22:06 -0500 Subject: [PATCH 3/9] 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 5de48db91ce8b688fe5b72a9839159fcdfc7a866 Mon Sep 17 00:00:00 2001 From: geositta Date: Thu, 8 Oct 2026 19:42:41 -0500 Subject: [PATCH 4/9] 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 23505c8bd58..8cfbe1a4baf 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 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 85e4604c6b3a4f87b54a06dda57ab753233dc190 Mon Sep 17 00:00:00 2001 From: geositta Date: Thu, 8 Oct 2026 19:57:14 -0500 Subject: [PATCH 5/9] 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 8cfbe1a4baf..a1e2ca96f8b 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 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 87cc851b5f5d645ef9f60fa0eac04c2831b0da6b Mon Sep 17 00:00:00 2001 From: geositta Date: Fri, 9 Oct 2026 11:21:16 -0500 Subject: [PATCH 6/9] 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 a1e2ca96f8b..7b4d3d23199 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 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 e8f11861bff..69307a3e99e 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -749,6 +749,12 @@ type BuilderFeeSetupContext = { builderAddress: string; }; +type AccountSupportContext = { + lifecycleGeneration: number; + network: 'testnet' | 'mainnet'; + userAddress: string; +}; + /** * Classify one entry of a cancel response. * @@ -2882,10 +2888,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; @@ -2904,6 +2911,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 @@ -2924,7 +2949,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; @@ -2939,32 +2964,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; } /** @@ -3285,6 +3353,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, @@ -3629,12 +3701,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. @@ -3696,6 +3769,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', @@ -12253,6 +12327,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. @@ -12301,6 +12376,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, @@ -15166,7 +15242,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'); @@ -15231,6 +15307,7 @@ export class HyperLiquidProvider implements PerpsProvider { timestamp: new Date().toISOString(), }); + await this.#assertAccountContextCurrent(accountContext, 'Withdrawal'); const result = await exchangeClient.withdraw3({ destination, amount: params.amount, @@ -15353,14 +15430,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() @@ -15374,6 +15450,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 534141eee246e2b9a4cd9f406784396d056bb6cc Mon Sep 17 00:00:00 2001 From: geositta Date: Fri, 9 Oct 2026 19:36:29 -0500 Subject: [PATCH 7/9] 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 69307a3e99e..00099d8835b 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -3015,6 +3015,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. @@ -3707,7 +3730,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. @@ -3769,7 +3795,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', @@ -15244,6 +15273,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'); @@ -15307,7 +15337,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 d90332d8c42fec011b19351b0cd68de1d3937a03 Mon Sep 17 00:00:00 2001 From: geositta Date: Fri, 9 Oct 2026 21:24:46 -0500 Subject: [PATCH 8/9] 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 00099d8835b..0589a563679 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -755,6 +755,15 @@ type AccountSupportContext = { userAddress: string; }; +type TradingActionContext = { + accountContext: AccountSupportContext; + builderFeeSetupContext?: BuilderFeeSetupContext; +}; + +type BuilderFeeTradingActionContext = TradingActionContext & { + builderFeeSetupContext: BuilderFeeSetupContext; +}; + /** * Classify one entry of a cancel response. * @@ -938,6 +947,7 @@ type PrepareAssetForTradingParams = { assetId: number; leverage?: number; marginMode?: OrderParams['marginMode']; + accountContext: AccountSupportContext; }; type Hip3TransferInfo = { @@ -946,6 +956,7 @@ type Hip3TransferInfo = { }; type Hip3TransferContext = { + accountContext: AccountSupportContext; dexName: string; transferInfo: Hip3TransferInfo; }; @@ -1021,6 +1032,7 @@ type HyperLiquidProviderOptions = { }; type HandleHip3PreOrderParams = { + accountContext: AccountSupportContext; dexName: string; symbol: string; orderPrice: number; @@ -1043,6 +1055,7 @@ type SubmitOrderWithRollbackParams = { symbol: string; assetId: number; chargesMetamaskBuilderFee: boolean; + accountContext: AccountSupportContext; builderFeeSetupContext?: BuilderFeeSetupContext; }; @@ -1069,6 +1082,7 @@ type GetOrFetchPriceParams = { * drift apart on validation, readiness, or leverage. */ type StrategyPlacementContext = { + accountContext: AccountSupportContext; assetId: number; szDecimals: number; formattedSize: string; @@ -1142,6 +1156,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; @@ -3042,14 +3057,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) { @@ -3069,10 +3090,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 @@ -3092,8 +3116,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 @@ -3197,6 +3226,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' || @@ -3305,7 +3340,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, }); @@ -3421,6 +3463,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 @@ -3608,16 +3659,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 = { @@ -3655,7 +3714,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); } @@ -3701,19 +3760,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; @@ -3721,15 +3776,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', @@ -3762,7 +3824,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, @@ -3793,7 +3855,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, @@ -3804,7 +3870,10 @@ export class HyperLiquidProvider implements PerpsProvider { '[ensureReadyForTrading] Trading setup complete', ); - return builderFeeSetupContext; + return { + accountContext, + ...(builderFeeSetupContext && { builderFeeSetupContext }), + }; } /** @@ -5235,11 +5304,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, @@ -5318,10 +5391,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 @@ -5344,6 +5424,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; @@ -5655,14 +5741,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 }); @@ -5710,11 +5799,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. @@ -5755,6 +5850,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 */ @@ -5763,12 +5860,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 @@ -5815,11 +5914,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', { @@ -5971,13 +6076,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 }); @@ -6014,11 +6120,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) { @@ -6108,10 +6217,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( @@ -6124,11 +6234,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( @@ -6354,7 +6467,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; @@ -6370,6 +6483,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, @@ -6430,8 +6547,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 @@ -6478,6 +6602,7 @@ export class HyperLiquidProvider implements PerpsProvider { const transferInfo = await this.#autoTransferForHip3Order({ targetDex: dexName, requiredMargin: requiredMarginWithBuffer, + accountContext, }); return { transferInfo }; } catch (transferError) { @@ -6512,7 +6637,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; @@ -6526,6 +6655,10 @@ export class HyperLiquidProvider implements PerpsProvider { }); try { + await this.#assertNoKnownUnsupportedAccount( + params.accountContext, + 'Order submission', + ); const result = await exchangeClient.order({ orders: this.#applySubscriptionCloid(orders), grouping, @@ -6549,7 +6682,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 { @@ -6569,7 +6706,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; } @@ -6743,9 +6884,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()); @@ -6812,6 +6954,7 @@ export class HyperLiquidProvider implements PerpsProvider { assetId, leverage: params.leverage, marginMode: params.marginMode, + accountContext, }); // 6. Handle HIP-3 balance management (if applicable) @@ -6821,6 +6964,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, @@ -6865,6 +7009,7 @@ export class HyperLiquidProvider implements PerpsProvider { symbol: params.symbol, assetId, chargesMetamaskBuilderFee, + accountContext, builderFeeSetupContext, }); } catch (error) { @@ -7041,14 +7186,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; } @@ -7060,7 +7210,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; } @@ -7074,7 +7228,13 @@ export class HyperLiquidProvider implements PerpsProvider { { symbol: params.symbol, ...(dexName && transferInfo - ? { hip3Transfer: { dexName, transferInfo } } + ? { + hip3Transfer: { + accountContext, + dexName, + transferInfo, + }, + } : {}), }, ); @@ -7090,7 +7250,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; } } @@ -7104,7 +7268,11 @@ export class HyperLiquidProvider implements PerpsProvider { } if (dexName && transferInfo) { - await this.#handleHip3PostOrderRebalance({ dexName, transferInfo }); + await this.#handleHip3PostOrderRebalance({ + accountContext, + dexName, + transferInfo, + }); } return result; @@ -7201,9 +7369,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, @@ -7216,15 +7385,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) { @@ -7233,6 +7400,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, @@ -7245,6 +7413,7 @@ export class HyperLiquidProvider implements PerpsProvider { } return { + accountContext, assetId, szDecimals: assetInfo.szDecimals, formattedSize, @@ -7351,7 +7520,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); @@ -7373,6 +7542,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, @@ -7474,7 +7647,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. @@ -7524,6 +7697,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', @@ -7775,7 +7952,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. @@ -7834,6 +8012,7 @@ export class HyperLiquidProvider implements PerpsProvider { reduceOnly: params.reduceOnly ?? false, builder, marksSubscriptionCloid, + accountContext, exchangeClient: placingClient, }); break; @@ -7872,6 +8051,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, @@ -8034,6 +8214,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: { @@ -8044,8 +8226,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( [ @@ -8448,8 +8635,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; @@ -8730,6 +8916,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; @@ -9292,10 +9484,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, @@ -9339,6 +9532,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, @@ -9378,7 +9575,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, @@ -9393,6 +9592,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), @@ -9512,7 +9715,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 @@ -10336,13 +10556,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: @@ -10458,14 +10684,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: [ { @@ -10586,8 +10818,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({ @@ -10600,6 +10833,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 { @@ -10699,7 +10937,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 @@ -10780,9 +11020,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( @@ -10941,13 +11178,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', @@ -10986,6 +11240,7 @@ export class HyperLiquidProvider implements PerpsProvider { // Non-blocking: Transfer freed margin back to main DEX await this.#autoTransferBackAfterClose({ + accountContext, sourceDex, freedMargin, }); @@ -11135,6 +11390,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'); @@ -11631,12 +11891,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; @@ -12158,7 +12424,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 @@ -12263,6 +12531,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, @@ -12302,6 +12574,7 @@ export class HyperLiquidProvider implements PerpsProvider { // Non-blocking: Transfer freed margin back to main DEX await this.#autoTransferBackAfterClose({ + accountContext, sourceDex: hip3Dex, freedMargin, }); @@ -12405,7 +12678,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, @@ -15272,7 +15548,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'); @@ -15442,8 +15721,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', { @@ -15462,13 +15746,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( @@ -15480,7 +15763,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, @@ -15534,6 +15821,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 * @@ -15851,6 +16161,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( @@ -15865,13 +16180,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, @@ -15887,10 +16202,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()) { @@ -17017,20 +17336,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()) { @@ -17109,6 +17437,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', @@ -17122,7 +17456,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 @@ -17139,7 +17479,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', @@ -17187,6 +17527,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, @@ -17266,20 +17615,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, }); @@ -17310,10 +17662,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 78e0dc43d9048b3503bef16f7eebee48bc870738 Mon Sep 17 00:00:00 2001 From: geositta Date: Fri, 9 Oct 2026 21:30:49 -0500 Subject: [PATCH 9/9] 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 0589a563679..5ea329dd17c 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -9176,9 +9176,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 }), + ], + ]); }); });