From 920215e2e1c6fe6327483cfd4869aa1db3d0f85c Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Tue, 1 Sep 2026 20:25:27 +0800 Subject: [PATCH 1/6] feat(perps-controller): agent-signer seam and trading-wallet override --- .../PerpsController-method-action-types.ts | 16 ++ .../perps-controller/src/PerpsController.ts | 41 ++++ packages/perps-controller/src/index.ts | 4 + .../src/providers/HyperLiquidProvider.ts | 132 +++++++--- .../src/services/HyperLiquidClientService.ts | 5 + .../HyperLiquidSubscriptionService.ts | 23 +- .../src/services/HyperLiquidWalletService.ts | 141 ++++++++++- ...idProvider.trading-wallet-override.test.ts | 175 ++++++++++++++ ...perLiquidSubscriptionService.cache.test.ts | 2 +- ...erLiquidWalletService.agent-signer.test.ts | 228 ++++++++++++++++++ .../services/HyperLiquidWalletService.test.ts | 18 +- 11 files changed, 733 insertions(+), 52 deletions(-) create mode 100644 packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading-wallet-override.test.ts create mode 100644 packages/perps-controller/tests/src/services/HyperLiquidWalletService.agent-signer.test.ts diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index 6cb437da976..46c9b715f18 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -698,6 +698,21 @@ export type PerpsControllerClearAttributionContextAction = { handler: PerpsController['clearAttributionContext']; }; +/** + * Override (or clear) the trading wallet the HyperLiquid provider signs with. + * + * The host app calls this with a local agent signer when an agent wallet + * activates, and with `null` when the keyring locks (restoring the master + * keyring path). Throws when the HyperLiquid provider is not (yet) + * registered — callers should treat that as best-effort. + * + * @param signer - The agent signer to sign with, or null for the master path. + */ +export type PerpsControllerSetTradingWalletOverrideAction = { + type: `PerpsController:setTradingWalletOverride`; + handler: PerpsController['setTradingWalletOverride']; +}; + /** * Toggle between testnet and mainnet * @@ -1404,6 +1419,7 @@ export type PerpsControllerMethodActions = | PerpsControllerSetAttributionContextAction | PerpsControllerGetAttributionContextAction | PerpsControllerClearAttributionContextAction + | PerpsControllerSetTradingWalletOverrideAction | PerpsControllerToggleTestnetAction | PerpsControllerSwitchProviderAction | PerpsControllerGetCurrentNetworkAction diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 1cade00cacc..d8f56547803 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -56,6 +56,7 @@ import { DataLakeService } from './services/DataLakeService.js'; import { DepositService } from './services/DepositService.js'; import { EligibilityService } from './services/EligibilityService.js'; import { FeatureFlagConfigurationService } from './services/FeatureFlagConfigurationService.js'; +import type { AgentSigner } from './services/HyperLiquidWalletService.js'; import { MarketDataService } from './services/MarketDataService.js'; import { RewardsIntegrationService } from './services/RewardsIntegrationService.js'; import type { ServiceContext } from './services/ServiceContext.js'; @@ -869,6 +870,15 @@ export type PerpsControllerOptions = { * geolocation fetch from firing during wallet onboarding (privacy compliance). */ deferEligibilityCheck?: boolean; + /** + * Resolves the local agent signer for a master account address, or null + * when no agent is active or the wallet is locked. When provided, HyperLiquid + * signing switches to the agent key without touching the keyring; use + * `setTradingWalletOverride` to switch while running. + */ + getAgentSigner?: ( + masterAccountAddress: string, + ) => Promise; }; type BlockedRegionList = { @@ -975,6 +985,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'setLiveDataConfig', 'setSelectedPaymentToken', 'setVisibleCandleCount', + 'setTradingWalletOverride', 'startEligibilityMonitoring', 'startMarketDataPreload', 'stopEligibilityMonitoring', @@ -1150,6 +1161,12 @@ export class PerpsController extends BaseController< #userDiskWrite: Promise = Promise.resolve(); + // Resolves the local agent signer for the selected master account, or null. + // Passed through to the HyperLiquid provider for the agent signing seam. + readonly #getAgentSigner: + | ((masterAccountAddress: string) => Promise) + | undefined = undefined; + // Store options for dependency injection (allows core package to inject platform-specific services) readonly #options: PerpsControllerOptions; @@ -1185,6 +1202,7 @@ export class PerpsController extends BaseController< clientConfig = {}, infrastructure, deferEligibilityCheck = false, + getAgentSigner, }: PerpsControllerOptions) { super({ name: 'PerpsController', @@ -1194,6 +1212,7 @@ export class PerpsController extends BaseController< }); this.#eligibilityCheckDeferred = deferEligibilityCheck; + this.#getAgentSigner = getAgentSigner; // Store options for dependency injection this.#options = { @@ -1774,6 +1793,7 @@ export class PerpsController extends BaseController< priceDeviationLimit: this.#priceDeviationLimit, platformDependencies: this.#options.infrastructure, messenger: this.messenger, + getAgentSigner: this.#getAgentSigner, builderAddressTestnet: this.#options.clientConfig?.providerCredentials?.hyperliquid ?.builderAddressTestnet, @@ -2310,6 +2330,7 @@ export class PerpsController extends BaseController< priceDeviationLimit: this.#priceDeviationLimit, platformDependencies: this.#options.infrastructure, messenger: this.messenger, + getAgentSigner: this.#getAgentSigner, builderAddressTestnet: this.#options.clientConfig?.providerCredentials?.hyperliquid ?.builderAddressTestnet, @@ -5091,6 +5112,26 @@ export class PerpsController extends BaseController< return { ...utm, ...properties }; } + /** + * Override (or clear) the trading wallet the HyperLiquid provider signs with. + * + * The host app calls this with a local agent signer when an agent wallet + * activates, and with `null` when the keyring locks (restoring the master + * keyring path). Throws when the HyperLiquid provider is not (yet) + * registered — callers should treat that as best-effort. + * + * @param signer - The agent signer to sign with, or null for the master path. + * @returns A promise that resolves when the clients have re-initialized with + * the new wallet. + */ + async setTradingWalletOverride(signer: AgentSigner | null): Promise { + const provider = this.providers.get('hyperliquid'); + if (!(provider instanceof HyperLiquidProvider)) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE); + } + return provider.setTradingWalletOverride(signer); + } + /** * Toggle between testnet and mainnet * diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index f29c824716c..810aea24880 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -714,6 +714,10 @@ export { // Services (only externally consumed items) export { TradingReadinessCache } from './services/TradingReadinessCache.js'; export type { ServiceContext } from './services/ServiceContext.js'; +export type { + AgentSigner, + HyperLiquidWalletServiceOptions, +} from './services/HyperLiquidWalletService.js'; export { AggregatedOrderBookConnection, processAggregatedOrderBook, diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index f2132dd4848..ecc4bebb2d8 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -53,8 +53,10 @@ import { HyperLiquidClientService, WebSocketConnectionState, } from '../services/HyperLiquidClientService.js'; +import type { HyperLiquidWalletParams } from '../services/HyperLiquidClientService.js'; import { HyperLiquidSubscriptionService } from '../services/HyperLiquidSubscriptionService.js'; import { HyperLiquidWalletService } from '../services/HyperLiquidWalletService.js'; +import type { AgentSigner } from '../services/HyperLiquidWalletService.js'; import { TradingReadinessCache, PerpsSigningCache, @@ -833,6 +835,9 @@ type HyperLiquidProviderOptions = { subscriptionBuilderAddressTestnet?: string; subscriptionBuilderAddressMainnet?: string; onChaseOrderMaxDistanceReached?: ChaseOrderMaxDistanceReachedHandler; + getAgentSigner?: ( + masterAccountAddress: string, + ) => Promise; }; type HandleHip3PreOrderParams = { @@ -1490,6 +1495,14 @@ export class HyperLiquidProvider implements PerpsProvider { // Promise-based lock to prevent race conditions in concurrent initialization #initializationPromise: Promise | null = null; + // Active agent signer override; consulted by every wallet (re)build so the + // override survives lazy initializations and network toggles. + #agentSignerOverride?: AgentSigner | undefined; + + // Serializes setTradingWalletOverride calls so a lock event cannot interleave + // with an activation event mid disconnect/initialize. + #overridePromise: Promise = Promise.resolve(); + readonly #messenger: PerpsControllerMessengerBase; readonly #builderAddressTestnet?: string; @@ -1550,6 +1563,7 @@ export class HyperLiquidProvider implements PerpsProvider { this.#messenger, { isTestnet, + getAgentSigner: options.getAgentSigner, }, ); this.#subscriptionService = new HyperLiquidSubscriptionService( @@ -1924,39 +1938,13 @@ export class HyperLiquidProvider implements PerpsProvider { throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); } - const wallet = this.#walletService.createWalletAdapter(); + const wallet = await this.#buildWallet(); await this.#clientService.initialize(wallet); if (this.#disconnectOperationsInFlight > 0) { throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); } - // Set termination callback for logging when WebSocket terminates - // Note: Do NOT restore subscriptions here - termination means connection failed permanently - this.#clientService.setOnTerminateCallback((error: Error) => { - this.#deps.debugLogger.log( - '[HyperLiquidProvider] WebSocket terminated', - { - error: error.message, - }, - ); - }); - - // Set reconnection callback to restore subscriptions after successful reconnection - // This is called in handleConnectionDrop() after the WebSocket reconnects successfully - this.#clientService.setOnReconnectCallback(async () => { - try { - this.#deps.debugLogger.log( - '[HyperLiquidProvider] WebSocket reconnected, restoring subscriptions', - ); - await this.#subscriptionService.restoreSubscriptions(); - this.#deps.streamManager.clearAllChannels(); - } catch (restoreError) { - this.#deps.debugLogger.log( - '[HyperLiquidProvider] Failed to restore subscriptions', - restoreError, - ); - } - }); + this.#registerConnectionCallbacks(); // Only set flag AFTER successful initialization this.#clientsInitialized = true; @@ -1975,6 +1963,94 @@ export class HyperLiquidProvider implements PerpsProvider { } } + /** + * Register the WebSocket terminate/reconnect callbacks on the client + * service. Shared by lazy initialization and the trading-wallet override + * re-initialization so subscriptions are restored after either path. + */ + #registerConnectionCallbacks(): void { + // Set termination callback for logging when WebSocket terminates + // Note: Do NOT restore subscriptions here - termination means connection failed permanently + this.#clientService.setOnTerminateCallback((error: Error) => { + this.#deps.debugLogger.log('[HyperLiquidProvider] WebSocket terminated', { + error: error.message, + }); + }); + + // Set reconnection callback to restore subscriptions after successful reconnection + // This is called in handleConnectionDrop() after the WebSocket reconnects successfully + this.#clientService.setOnReconnectCallback(async () => { + try { + this.#deps.debugLogger.log( + '[HyperLiquidProvider] WebSocket reconnected, restoring subscriptions', + ); + await this.#subscriptionService.restoreSubscriptions(); + this.#deps.streamManager.clearAllChannels(); + } catch (restoreError) { + this.#deps.debugLogger.log( + '[HyperLiquidProvider] Failed to restore subscriptions', + restoreError, + ); + } + }); + } + + /** + * Build the wallet the SDK clients should sign with. + * + * With an active agent override the agent adapter is used; otherwise the + * master keyring adapter is created (unchanged behavior). + * + * @param signer - Optional explicit signer; defaults to the stored override. + * @returns The wallet adapter for the client service. + */ + async #buildWallet( + signer?: AgentSigner | null, + ): Promise { + const effectiveSigner = + signer === undefined ? this.#agentSignerOverride : signer; + if (effectiveSigner) { + return this.#walletService.createAgentWalletAdapter(effectiveSigner); + } + return this.#walletService.createWalletAdapter(); + } + + /** + * Override (or clear) the trading wallet used for signing. + * + * Called when an agent wallet activates (pass the local agent signer) and + * when the keyring locks (pass null, restoring the master path). Reconnects + * the SDK clients so every subsequent action signs with the new wallet. + * Calls are serialized so concurrent events cannot interleave the + * disconnect/initialize pair. + * + * @param signer - The agent signer to sign with, or null to restore the master path. + */ + async setTradingWalletOverride(signer: AgentSigner | null): Promise { + const run = this.#overridePromise.then(() => + this.#applyTradingWalletOverride(signer), + ); + this.#overridePromise = run.catch(() => undefined); + await run; + } + + /** + * Apply a trading wallet override: store it, disconnect, and re-initialize + * the client service with an adapter built from it. + * + * @param signer - The agent signer to sign with, or null for the master path. + */ + async #applyTradingWalletOverride(signer: AgentSigner | null): Promise { + this.#agentSignerOverride = signer ?? undefined; + await this.#clientService.disconnect(); + const wallet = await this.#buildWallet(signer); + await this.#clientService.initialize(wallet); + this.#registerConnectionCallbacks(); + // Mark initialized so the lazy path does not rebuild (and discard) the + // override wallet on the next action. + this.#clientsInitialized = true; + } + /** * Decide whether the wallet has a Hyperliquid account. * diff --git a/packages/perps-controller/src/services/HyperLiquidClientService.ts b/packages/perps-controller/src/services/HyperLiquidClientService.ts index 4837427bf3c..8cb6305e928 100644 --- a/packages/perps-controller/src/services/HyperLiquidClientService.ts +++ b/packages/perps-controller/src/services/HyperLiquidClientService.ts @@ -43,8 +43,13 @@ export type ValidCandleInterval = CandlePeriod; /** * Wallet interface for HyperLiquid SDK operations. * Extracted for reuse across initialize(), toggleTestnet(), and ensureSubscriptionClient() methods. + * + * `address` is optional but recommended: the SDK's viem local-account + * dispatch reads it to determine the signing address (e.g. for agent + * wallets, whose address differs from the selected master account). */ export type HyperLiquidWalletParams = { + address?: Hex; signTypedData: (params: { domain: { name: string; diff --git a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts index 517f52ebef1..6f92fb14553 100644 --- a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts +++ b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts @@ -1620,7 +1620,7 @@ export class HyperLiquidSubscriptionService { // instance subscribeToAccount can race ahead of the webData3 path, // so initialize here first — subsequent calls are no-ops. await this.#clientService.ensureSubscriptionClient( - this.#walletService.createWalletAdapter(), + await this.#walletService.createWalletAdapter(), ); // Don't bail here even if generation has bumped (e.g. WS spot snapshot @@ -1744,7 +1744,7 @@ export class HyperLiquidSubscriptionService { const promise = (async (): Promise => { await this.#clientService.ensureSubscriptionClient( - this.#walletService.createWalletAdapter(), + await this.#walletService.createWalletAdapter(), ); const subscriptionClient = this.#clientService.getSubscriptionClient(); if (!subscriptionClient) { @@ -1860,7 +1860,7 @@ export class HyperLiquidSubscriptionService { }); await this.#clientService.ensureSubscriptionClient( - this.#walletService.createWalletAdapter(), + await this.#walletService.createWalletAdapter(), ); const subscriptionClient = this.#clientService.getSubscriptionClient(); @@ -2030,7 +2030,7 @@ export class HyperLiquidSubscriptionService { */ async #createUserDataSubscription(accountId?: CaipAccountId): Promise { await this.#clientService.ensureSubscriptionClient( - this.#walletService.createWalletAdapter(), + await this.#walletService.createWalletAdapter(), ); const subscriptionClient = this.#clientService.getSubscriptionClient(); @@ -2281,7 +2281,7 @@ export class HyperLiquidSubscriptionService { dexName: string, ): Promise { await this.#clientService.ensureSubscriptionClient( - this.#walletService.createWalletAdapter(), + await this.#walletService.createWalletAdapter(), ); const subscriptionClient = this.#clientService.getSubscriptionClient(); if (!subscriptionClient) { @@ -2452,7 +2452,7 @@ export class HyperLiquidSubscriptionService { dexName: string, ): Promise { await this.#clientService.ensureSubscriptionClient( - this.#walletService.createWalletAdapter(), + await this.#walletService.createWalletAdapter(), ); const subscriptionClient = this.#clientService.getSubscriptionClient(); if (!subscriptionClient) { @@ -2916,7 +2916,7 @@ export class HyperLiquidSubscriptionService { const subscriptionClient = this.#clientService.getSubscriptionClient(); if (!subscriptionClient) { await this.#clientService.ensureSubscriptionClient( - this.#walletService.createWalletAdapter(), + await this.#walletService.createWalletAdapter(), ); const client = this.#clientService.getSubscriptionClient(); if (!client) { @@ -4215,7 +4215,7 @@ export class HyperLiquidSubscriptionService { */ async #createDexAllMidsSubscription(dex: string): Promise { await this.#clientService.ensureSubscriptionClient( - this.#walletService.createWalletAdapter(), + await this.#walletService.createWalletAdapter(), ); const subscriptionClient = this.#clientService.getSubscriptionClient(); @@ -4270,7 +4270,7 @@ export class HyperLiquidSubscriptionService { */ async #createAssetCtxsSubscription(dex: string): Promise { await this.#clientService.ensureSubscriptionClient( - this.#walletService.createWalletAdapter(), + await this.#walletService.createWalletAdapter(), ); const subscriptionClient = this.#clientService.getSubscriptionClient(); @@ -4653,8 +4653,9 @@ export class HyperLiquidSubscriptionService { onError, } = params; - this.#clientService - .ensureSubscriptionClient(this.#walletService.createWalletAdapter()) + this.#walletService + .createWalletAdapter() + .then((wallet) => this.#clientService.ensureSubscriptionClient(wallet)) .catch(() => { // Handled by getSubscriptionClient check below }); diff --git a/packages/perps-controller/src/services/HyperLiquidWalletService.ts b/packages/perps-controller/src/services/HyperLiquidWalletService.ts index ab0a7dad883..ddb1c7373e8 100644 --- a/packages/perps-controller/src/services/HyperLiquidWalletService.ts +++ b/packages/perps-controller/src/services/HyperLiquidWalletService.ts @@ -27,6 +27,55 @@ const HARDWARE_KEYRING_TYPES = new Set([ 'QR Hardware Wallet Device', ]); +/** + * A local signer for a HyperLiquid agent account, in the SDK-native + * `AbstractEthersV6Signer` shape (positional EIP-712 arguments, message as + * the signed value). Provided by the client (e.g. a local key stored in + * memory) so agent actions never touch the keyring. + */ +export type AgentSigner = { + /** The agent account address used as the actor for signed actions. */ + address: `0x${string}`; + /** + * Sign EIP-712 typed data with the agent key. + * + * @param domain - The EIP-712 domain. + * @param domain.name - The domain name. + * @param domain.version - The domain version. + * @param domain.chainId - The domain chain ID. + * @param domain.verifyingContract - The verifying contract address. + * @param types - The EIP-712 type definitions (without `EIP712Domain`). + * @param value - The message payload to sign. + * @returns The 65-byte hex signature. + */ + signTypedData( + domain: { + name: string; + version: string; + chainId: number; + verifyingContract: `0x${string}`; + }, + types: { + [key: string]: { name: string; type: string }[]; + }, + value: Record, + ): Promise; +}; + +/** Options bag for {@link HyperLiquidWalletService}. */ +export type HyperLiquidWalletServiceOptions = { + isTestnet?: boolean; + /** + * Resolves the local agent signer for a master account address, or null + * when the account has no active agent or the wallet is locked. When it + * returns a signer, wallet adapters sign with the agent key instead of + * contacting the keyring. + */ + getAgentSigner?: ( + masterAccountAddress: string, + ) => Promise; +}; + /** * Service for MetaMask wallet integration with HyperLiquid SDK * Provides wallet adapter that implements AbstractWindowEthereum interface @@ -39,14 +88,19 @@ export class HyperLiquidWalletService { readonly #messenger: PerpsControllerMessengerBase; + readonly #getAgentSigner?: + | ((masterAccountAddress: string) => Promise) + | undefined; + constructor( deps: PerpsPlatformDependencies, messenger: PerpsControllerMessengerBase, - options: { isTestnet?: boolean } = {}, + options: HyperLiquidWalletServiceOptions = {}, ) { this.#deps = deps; this.#messenger = messenger; this.#isTestnet = options.isTestnet ?? false; + this.#getAgentSigner = options.getAgentSigner; } /** @@ -100,13 +154,84 @@ export class HyperLiquidWalletService { ); } + /** + * Create a wallet adapter backed by a local agent signer. + * + * The returned adapter keeps the params-style `signTypedData` shape the SDK + * already accepts (viem local account), but delegates directly to the + * injected signer — no keyring messenger call is ever made. The SDK's viem + * adapters inject an `EIP712Domain` entry into `types` before calling + * params-style wallets; ethers-style signers reject that entry, so it is + * stripped before delegation. + * + * @param agentSigner - The local agent signer to delegate to. + * @returns The agent wallet adapter. + */ + public createAgentWalletAdapter(agentSigner: AgentSigner): { + address: Hex; + signTypedData: (params: { + domain: { + name: string; + version: string; + chainId: number; + verifyingContract: Hex; + }; + types: { + [key: string]: { name: string; type: string }[]; + }; + primaryType: string; + message: Record; + }) => Promise; + getChainId?: () => Promise; + } { + return { + address: agentSigner.address, + signTypedData: async (params: { + domain: { + name: string; + version: string; + chainId: number; + verifyingContract: Hex; + }; + types: { + [key: string]: { name: string; type: string }[]; + }; + primaryType: string; + message: Record; + }): Promise => { + const { EIP712Domain: _eip712Domain, ...types } = params.types; + + this.#deps.debugLogger.log( + 'HyperLiquidWalletService: Signing typed data (agent mode)', + { + address: agentSigner.address, + primaryType: params.primaryType, + }, + ); + + return (await agentSigner.signTypedData( + params.domain, + types, + params.message, + )) as Hex; + }, + getChainId: async (): Promise => + parseInt(getChainId(this.#isTestnet), 10), + }; + } + /** * Create wallet adapter that implements AbstractViemJsonRpcAccount interface * Required by @nktkas/hyperliquid SDK for signing transactions * + * When the injected `getAgentSigner` resolves a signer for the selected + * master account, the returned adapter signs with that local agent key and + * never contacts the keyring. Otherwise the master keyring path is used, + * unchanged. + * * @returns The wallet adapter with address, signTypedData, and getChainId methods. */ - public createWalletAdapter(): { + public async createWalletAdapter(): Promise<{ address: Hex; signTypedData: (params: { domain: { @@ -122,7 +247,7 @@ export class HyperLiquidWalletService { message: Record; }) => Promise; getChainId?: () => Promise; - } { + }> { // Get current EVM account via DI messenger const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); @@ -132,6 +257,16 @@ export class HyperLiquidWalletService { const address = evmAccount.address as Hex; + // Agent mode: a local signer for this master account takes over signing + // entirely. The unlocked-vault gate for agent signing is the in-memory + // plaintext key (null while locked), not the keyring. + const agentSigner = this.#getAgentSigner + ? await this.#getAgentSigner(address) + : null; + if (agentSigner) { + return this.createAgentWalletAdapter(agentSigner); + } + return { address, signTypedData: async (params: { diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading-wallet-override.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading-wallet-override.test.ts new file mode 100644 index 00000000000..feb74aea6d5 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading-wallet-override.test.ts @@ -0,0 +1,175 @@ +/* eslint-disable */ +/** + * Unit tests for HyperLiquidProvider.setTradingWalletOverride and the + * getAgentSigner pass-through to the wallet service. + */ + +jest.mock('@nktkas/hyperliquid', () => ({})); + +import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider.js'; +import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService.js'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService.js'; +import type { AgentSigner } from '../../../src/services/HyperLiquidWalletService.js'; +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; +import type { PerpsPlatformDependencies } from '../../../src/types/index.js'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks.js'; + +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidWalletService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); + +const MockedHyperLiquidClientService = + HyperLiquidClientService as jest.MockedClass; +const MockedHyperLiquidWalletService = + HyperLiquidWalletService as jest.MockedClass; +const MockedHyperLiquidSubscriptionService = + HyperLiquidSubscriptionService as jest.MockedClass< + typeof HyperLiquidSubscriptionService + >; + +const AGENT_ADDRESS = '0x2222222222222222222222222222222222222222' as const; +const MASTER_ADDRESS = '0x1234567890123456789012345678901234567890' as const; + +const createMockAgentAdapter = () => ({ + address: AGENT_ADDRESS, + signTypedData: jest.fn().mockResolvedValue('0xagentsig'), + getChainId: jest.fn().mockResolvedValue(42161), +}); + +const createMockMasterAdapter = () => ({ + address: MASTER_ADDRESS, + signTypedData: jest.fn().mockResolvedValue('0xmastersig'), + getChainId: jest.fn().mockResolvedValue(42161), +}); + +describe('HyperLiquidProvider trading wallet override', () => { + const mockPlatformDependencies: PerpsPlatformDependencies = + createMockInfrastructure(); + const mockMessenger = createMockMessenger(); + + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionService: jest.Mocked; + + const createTestProvider = (options: { getAgentSigner?: unknown } = {}) => + new HyperLiquidProvider({ + platformDependencies: mockPlatformDependencies, + messenger: mockMessenger, + ...(options as object), + }); + + beforeEach(() => { + jest.clearAllMocks(); + + mockClientService = { + initialize: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn().mockResolvedValue(undefined), + isInitialized: jest.fn().mockReturnValue(false), + isTestnetMode: jest.fn().mockReturnValue(false), + setTestnetMode: jest.fn(), + setOnTerminateCallback: jest.fn(), + setOnReconnectCallback: jest.fn(), + getConnectionState: jest.fn().mockReturnValue('disconnected'), + getSubscriptionClient: jest.fn(), + } as unknown as jest.Mocked; + + mockWalletService = { + createWalletAdapter: jest.fn(() => createMockMasterAdapter()), + createAgentWalletAdapter: jest.fn(() => createMockAgentAdapter()), + setTestnetMode: jest.fn(), + } as unknown as jest.Mocked; + + mockSubscriptionService = {} as jest.Mocked; + + MockedHyperLiquidClientService.mockImplementation(() => mockClientService); + MockedHyperLiquidWalletService.mockImplementation(() => mockWalletService); + MockedHyperLiquidSubscriptionService.mockImplementation( + () => mockSubscriptionService, + ); + }); + + it('passes getAgentSigner through to the wallet service constructor', () => { + const getAgentSigner = jest.fn(); + createTestProvider({ getAgentSigner }); + + const constructorArgs = MockedHyperLiquidWalletService.mock.calls[0]; + const options = constructorArgs?.[2] as { getAgentSigner?: unknown }; + expect(options.getAgentSigner).toBe(getAgentSigner); + }); + + it('reinitializes the client service with the agent adapter', async () => { + const agentSigner: AgentSigner = { + address: AGENT_ADDRESS, + signTypedData: jest.fn().mockResolvedValue('0xagentsig'), + }; + const provider = createTestProvider(); + mockWalletService.createAgentWalletAdapter = jest.fn(() => + createMockAgentAdapter(), + ); + + await provider.setTradingWalletOverride(agentSigner); + + expect(mockClientService.disconnect).toHaveBeenCalledTimes(1); + expect(mockWalletService.createAgentWalletAdapter).toHaveBeenCalledWith( + agentSigner, + ); + expect(mockClientService.initialize).toHaveBeenCalledTimes(1); + const wallet = mockClientService.initialize.mock.calls[0]?.[0]; + expect(wallet?.address).toBe(AGENT_ADDRESS); + }); + + it('reinitializes the client service with the master adapter when the override is cleared', async () => { + const provider = createTestProvider(); + + await provider.setTradingWalletOverride(null); + + expect(mockClientService.disconnect).toHaveBeenCalledTimes(1); + expect(mockWalletService.createWalletAdapter).toHaveBeenCalledTimes(1); + expect(mockClientService.initialize).toHaveBeenCalledTimes(1); + const wallet = mockClientService.initialize.mock.calls[0]?.[0]; + expect(wallet?.address).toBe(MASTER_ADDRESS); + }); + + it('keeps the override for rebuilds after a network toggle', async () => { + const agentSigner: AgentSigner = { + address: AGENT_ADDRESS, + signTypedData: jest.fn().mockResolvedValue('0xagentsig'), + }; + const provider = createTestProvider(); + + await provider.setTradingWalletOverride(agentSigner); + + // toggleTestnet resets the initialized flag so the next action rebuilds. + mockClientService.initialize.mockClear(); + await provider.toggleTestnet(); + await provider.initialize(); + + expect(mockClientService.initialize).toHaveBeenCalledTimes(1); + const wallet = mockClientService.initialize.mock.calls[0]?.[0]; + expect(wallet?.address).toBe(AGENT_ADDRESS); + }); + + it('applies concurrent overrides sequentially', async () => { + const firstSigner: AgentSigner = { + address: AGENT_ADDRESS, + signTypedData: jest.fn().mockResolvedValue('0xfirst'), + }; + const provider = createTestProvider(); + + await Promise.all([ + provider.setTradingWalletOverride(firstSigner), + provider.setTradingWalletOverride(null), + ]); + + expect(mockClientService.disconnect).toHaveBeenCalledTimes(2); + expect(mockClientService.initialize).toHaveBeenCalledTimes(2); + const lastWallet = + mockClientService.initialize.mock.calls[ + mockClientService.initialize.mock.calls.length - 1 + ]?.[0]; + expect(lastWallet?.address).toBe(MASTER_ADDRESS); + }); +}); diff --git a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts index 1da14bc23e9..1fc42557039 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts @@ -467,7 +467,7 @@ describe('HyperLiquidSubscriptionService', () => { // Mock wallet service mockWalletService = { - createWalletAdapter: jest.fn(() => mockWalletAdapter), + createWalletAdapter: jest.fn().mockResolvedValue(mockWalletAdapter), getUserAddressWithDefault: jest.fn().mockResolvedValue('0x123' as Hex), } as any; diff --git a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.agent-signer.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.agent-signer.test.ts new file mode 100644 index 00000000000..63ee88dc67c --- /dev/null +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.agent-signer.test.ts @@ -0,0 +1,228 @@ +/* eslint-disable */ +/** + * Unit tests for the HyperLiquidWalletService agent-signer seam. + * + * Agent mode: when the injected `getAgentSigner` returns a signer for the + * selected master account, `createWalletAdapter` returns an adapter whose + * address is the agent's and whose `signTypedData` delegates directly to the + * local signer — the keyring messenger is never contacted. + * + * Master mode: when no signer is available, the existing keyring-backed + * adapter is returned unchanged. + */ + +// Mock keyring-api to avoid import issues with definePattern +jest.mock('@metamask/keyring-api', () => ({ + isEvmAccountType: jest.fn((accountType: string) => + accountType?.startsWith('eip155:'), + ), +})); + +jest.mock('@metamask/utils', () => ({ + hasProperty: jest.fn((object: object, property: string) => + Object.prototype.hasOwnProperty.call(object, property), + ), + parseCaipAccountId: jest.fn((accountId: string) => { + const parts = accountId.split(':'); + return { + chainNamespace: parts[0], + chainReference: parts[1], + address: parts[2], + }; + }), + isValidHexAddress: jest.fn((address: string) => + /^0x[0-9a-fA-F]{40}$/.test(address), + ), +})); + +jest.mock('../../../src/constants/hyperLiquidConfig', () => ({ + getChainId: jest.fn((isTestnet: boolean) => (isTestnet ? '421614' : '42161')), +})); + +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; +import type { AgentSigner } from '../../../src/services/HyperLiquidWalletService.js'; +import { + createMockInfrastructure, + createMockEvmAccount, + createMockMessenger, +} from '../../helpers/serviceMocks.js'; + +const AGENT_ADDRESS = '0x2222222222222222222222222222222222222222' as const; + +const typedDataParams = { + domain: { + name: 'HyperLiquid', + version: '1', + chainId: 42161, + verifyingContract: + '0x0000000000000000000000000000000000000000' as `0x${string}`, + }, + types: { + // The SDK's viem adapters inject this before calling params-style + // wallets; an ethers-style local signer must not receive it. + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + Agent: [ + { name: 'agentAddress', type: 'address' }, + { name: 'nonce', type: 'uint64' }, + ], + }, + primaryType: 'Agent', + message: { + agentAddress: AGENT_ADDRESS, + nonce: 0, + }, +}; + +describe('HyperLiquidWalletService agent signer seam', () => { + let mockDeps: ReturnType; + let mockMessenger: ReturnType; + const mockEvmAccount = createMockEvmAccount(); + + beforeEach(() => { + jest.clearAllMocks(); + mockDeps = createMockInfrastructure(); + mockMessenger = createMockMessenger(); + }); + + describe('agent mode', () => { + it('returns an adapter whose address is the agent address', async () => { + const agentSigner: AgentSigner = { + address: AGENT_ADDRESS, + signTypedData: jest.fn().mockResolvedValue('0xagentsig'), + }; + const service = new HyperLiquidWalletService(mockDeps, mockMessenger, { + getAgentSigner: jest.fn().mockResolvedValue(agentSigner), + }); + + const adapter = await service.createWalletAdapter(); + + expect(adapter.address).toBe(AGENT_ADDRESS); + }); + + it('passes the selected master account address to getAgentSigner', async () => { + const getAgentSigner = jest.fn().mockResolvedValue(null); + const service = new HyperLiquidWalletService(mockDeps, mockMessenger, { + getAgentSigner, + }); + + await service.createWalletAdapter(); + + expect(getAgentSigner).toHaveBeenCalledWith(mockEvmAccount.address); + }); + + it('delegates signing directly to the injected signer with no keyring call', async () => { + const signTypedData = jest.fn().mockResolvedValue('0xagentsig'); + const agentSigner: AgentSigner = { + address: AGENT_ADDRESS, + signTypedData, + }; + const service = new HyperLiquidWalletService(mockDeps, mockMessenger, { + getAgentSigner: jest.fn().mockResolvedValue(agentSigner), + }); + + const adapter = await service.createWalletAdapter(); + const signature = await adapter.signTypedData(typedDataParams); + + expect(signature).toBe('0xagentsig'); + expect(signTypedData).toHaveBeenCalledTimes(1); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + expect.anything(), + expect.anything(), + ); + }); + + it('strips the injected EIP712Domain type before delegating', async () => { + const signTypedData = jest.fn().mockResolvedValue('0xagentsig'); + const agentSigner: AgentSigner = { + address: AGENT_ADDRESS, + signTypedData, + }; + const service = new HyperLiquidWalletService(mockDeps, mockMessenger, { + getAgentSigner: jest.fn().mockResolvedValue(agentSigner), + }); + + const adapter = await service.createWalletAdapter(); + await adapter.signTypedData(typedDataParams); + + const [domain, types, value] = signTypedData.mock.calls[0]; + expect(domain).toBe(typedDataParams.domain); + expect(types).toEqual({ Agent: typedDataParams.types.Agent }); + expect(value).toEqual(typedDataParams.message); + }); + + it('signs with the agent adapter even when the keyring reports locked', async () => { + const signTypedData = jest.fn().mockResolvedValue('0xagentsig'); + const agentSigner: AgentSigner = { + address: AGENT_ADDRESS, + signTypedData, + }; + const service = new HyperLiquidWalletService(mockDeps, mockMessenger, { + getAgentSigner: jest.fn().mockResolvedValue(agentSigner), + }); + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [mockEvmAccount]; + } + if (action === 'KeyringController:getState') { + return { isUnlocked: false }; + } + return undefined; + }); + + const adapter = await service.createWalletAdapter(); + const signature = await adapter.signTypedData(typedDataParams); + + expect(signature).toBe('0xagentsig'); + }); + }); + + describe('master mode', () => { + it('returns the keyring-backed adapter when getAgentSigner returns null', async () => { + const service = new HyperLiquidWalletService(mockDeps, mockMessenger, { + getAgentSigner: jest.fn().mockResolvedValue(null), + }); + + const adapter = await service.createWalletAdapter(); + + expect(adapter.address).toBe(mockEvmAccount.address); + const signature = await adapter.signTypedData(typedDataParams); + expect(signature).toBe('0xSignatureResult'); + expect(mockMessenger.call).toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + { + from: mockEvmAccount.address, + data: { + domain: typedDataParams.domain, + types: typedDataParams.types, + primaryType: typedDataParams.primaryType, + message: typedDataParams.message, + }, + }, + 'V4', + ); + }); + + it('uses the master path when no getAgentSigner option is provided', async () => { + const service = new HyperLiquidWalletService(mockDeps, mockMessenger); + + const adapter = await service.createWalletAdapter(); + + expect(adapter.address).toBe(mockEvmAccount.address); + const signature = await adapter.signTypedData(typedDataParams); + expect(signature).toBe('0xSignatureResult'); + expect(mockMessenger.call).toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + expect.anything(), + 'V4', + ); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts index 72fb99734bc..1a64b8d8bce 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts @@ -131,8 +131,8 @@ describe('HyperLiquidWalletService', () => { getChainId?: () => Promise; }; - beforeEach(() => { - walletAdapter = service.createWalletAdapter(); + beforeEach(async () => { + walletAdapter = await service.createWalletAdapter(); }); it('should create wallet adapter with signTypedData method', () => { @@ -159,7 +159,7 @@ describe('HyperLiquidWalletService', () => { mockMessenger, { isTestnet: true }, ); - const testnetAdapter = testnetService.createWalletAdapter(); + const testnetAdapter = await testnetService.createWalletAdapter(); expect(testnetAdapter.getChainId).toBeDefined(); const chainId = await testnetAdapter.getChainId?.(); @@ -246,7 +246,7 @@ describe('HyperLiquidWalletService', () => { ); // Creating wallet adapter should throw when no account - expect(() => service.createWalletAdapter()).toThrow( + await expect(service.createWalletAdapter()).rejects.toThrow( 'NO_ACCOUNT_SELECTED', ); }); @@ -271,7 +271,7 @@ describe('HyperLiquidWalletService', () => { ); // Need to recreate the adapter after changing the mock - const freshAdapter = service.createWalletAdapter(); + const freshAdapter = await service.createWalletAdapter(); await expect( freshAdapter.signTypedData(mockTypedDataParams), @@ -431,7 +431,7 @@ describe('HyperLiquidWalletService', () => { }); it('should throw KEYRING_LOCKED when keyring is locked', async () => { - const walletAdapter = service.createWalletAdapter(); + const walletAdapter = await service.createWalletAdapter(); (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { if ( action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' @@ -483,7 +483,7 @@ describe('HyperLiquidWalletService', () => { }); it('should handle keyring controller initialization errors', async () => { - const walletAdapter = service.createWalletAdapter(); + const walletAdapter = await service.createWalletAdapter(); (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { if ( action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' @@ -522,7 +522,7 @@ describe('HyperLiquidWalletService', () => { describe('Integration Scenarios', () => { it('should handle full wallet adapter workflow', async () => { - const walletAdapter = service.createWalletAdapter(); + const walletAdapter = await service.createWalletAdapter(); // Get chain ID expect(walletAdapter.getChainId).toBeDefined(); @@ -552,7 +552,7 @@ describe('HyperLiquidWalletService', () => { }); it('should maintain consistency between wallet adapter and service methods', async () => { - const walletAdapter = service.createWalletAdapter(); + const walletAdapter = await service.createWalletAdapter(); // Get chain ID through wallet adapter expect(walletAdapter.getChainId).toBeDefined(); From ac0b2ebaf59204c519452d0bf37f259864895f75 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Wed, 2 Sep 2026 13:49:50 +0800 Subject: [PATCH 2/6] fix(perps-controller): route user-signed actions to master wallet in agent mode --- .../src/services/HyperLiquidWalletService.ts | 184 ++++++++++++------ ...erLiquidWalletService.agent-signer.test.ts | 171 ++++++++++++++++ .../services/HyperLiquidWalletService.test.ts | 2 +- 3 files changed, 294 insertions(+), 63 deletions(-) diff --git a/packages/perps-controller/src/services/HyperLiquidWalletService.ts b/packages/perps-controller/src/services/HyperLiquidWalletService.ts index ddb1c7373e8..7498b37b430 100644 --- a/packages/perps-controller/src/services/HyperLiquidWalletService.ts +++ b/packages/perps-controller/src/services/HyperLiquidWalletService.ts @@ -154,17 +154,104 @@ export class HyperLiquidWalletService { ); } + /** + * Sign typed data with the master account via the keyring, resolving the + * selected account fresh so account switches cannot race the adapter. + * + * @param params - The typed data params the SDK passed to the adapter. + * @param params.domain - The EIP-712 domain. + * @param params.domain.name - The domain name. + * @param params.domain.version - The domain version. + * @param params.domain.chainId - The domain chain ID. + * @param params.domain.verifyingContract - The verifying contract address. + * @param params.types - The EIP-712 type definitions. + * @param params.primaryType - The EIP-712 primary type. + * @param params.message - The message payload to sign. + * @returns The signature string. + */ + async #signWithMaster( + params: { + domain: { + name: string; + version: string; + chainId: number; + verifyingContract: Hex; + }; + types: { + [key: string]: { name: string; type: string }[]; + }; + primaryType: string; + message: Record; + }, + ): Promise { + const currentEvmAccount = getSelectedEvmAccountFromMessenger( + this.#messenger, + ); + + if (!currentEvmAccount?.address) { + throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); + } + + const currentAddress = currentEvmAccount.address as Hex; + + this.#deps.debugLogger.log( + 'HyperLiquidWalletService: Signing typed data (master fallback)', + { + address: currentAddress, + primaryType: params.primaryType, + domain: params.domain, + }, + ); + + const signature = await this.#signTypedMessage({ + from: currentAddress, + data: { + domain: params.domain, + types: params.types, + primaryType: params.primaryType, + message: params.message, + }, + }); + + return signature as Hex; + } + + /** + * Whether a typed-data signing request is an L1 (phantom-agent) action. + * + * The HyperLiquid SDK signs L1 actions (order/cancel/modify/TWAP/leverage/ + * margin/agentSetAbstraction) with EIP-712 primaryType `Agent` over domain + * `{ name: "Exchange", version: "1", chainId: 1337 }`. Only these may be + * signed by an agent key; every other shape (e.g. `approveBuilderFee`, + * `userSetAbstraction`, `sendAsset`, `withdraw3` over the + * `HyperliquidSignTransaction` domain) is a user-signed action that + * authorizes the master account and must be signed by the master wallet. + * + * @param primaryType - The EIP-712 primary type of the signing request. + * @param domainName - The EIP-712 domain name of the signing request. + * @returns True when the request belongs to the agent-signable L1 class. + */ + #isL1AgentAction(primaryType: string, domainName?: string): boolean { + return primaryType === 'Agent' || domainName === 'Exchange'; + } + /** * Create a wallet adapter backed by a local agent signer. * * The returned adapter keeps the params-style `signTypedData` shape the SDK - * already accepts (viem local account), but delegates directly to the - * injected signer — no keyring messenger call is ever made. The SDK's viem - * adapters inject an `EIP712Domain` entry into `types` before calling + * already accepts (viem local account), but routes by typed-data shape: + * L1 (phantom-agent) actions — primaryType `Agent` over the `Exchange` + * domain — are signed directly by the injected local signer with no keyring + * contact; user-signed actions (`approveBuilderFee`, `userSetAbstraction`, + * `sendAsset`, `withdraw3`, … over the `HyperliquidSignTransaction` domain) + * are master-account authorizations and fall through to the master keyring + * signing path, so hardware users get the normal device prompt. The SDK's + * viem adapters inject an `EIP712Domain` entry into `types` before calling * params-style wallets; ethers-style signers reject that entry, so it is - * stripped before delegation. + * stripped before delegating to the agent signer (the master path passes + * `types` through unchanged, matching the pre-seam master adapter). * - * @param agentSigner - The local agent signer to delegate to. + * @param agentSigner - The local agent signer to delegate L1 actions to. * @returns The agent wallet adapter. */ public createAgentWalletAdapter(agentSigner: AgentSigner): { @@ -185,6 +272,9 @@ export class HyperLiquidWalletService { getChainId?: () => Promise; } { return { + // The agent address is returned for identity purposes: the SDK only uses + // it for local lock/nonce keying (`getWalletAddress`), never inside the + // signed payload — HyperLiquid recovers the signer from the signature. address: agentSigner.address, signTypedData: async (params: { domain: { @@ -199,21 +289,27 @@ export class HyperLiquidWalletService { primaryType: string; message: Record; }): Promise => { - const { EIP712Domain: _eip712Domain, ...types } = params.types; - - this.#deps.debugLogger.log( - 'HyperLiquidWalletService: Signing typed data (agent mode)', - { - address: agentSigner.address, - primaryType: params.primaryType, - }, - ); - - return (await agentSigner.signTypedData( - params.domain, - types, - params.message, - )) as Hex; + if (this.#isL1AgentAction(params.primaryType, params.domain?.name)) { + const { EIP712Domain: _eip712Domain, ...types } = params.types; + + this.#deps.debugLogger.log( + 'HyperLiquidWalletService: Signing typed data (agent mode)', + { + address: agentSigner.address, + primaryType: params.primaryType, + }, + ); + + return (await agentSigner.signTypedData( + params.domain, + types, + params.message, + )) as Hex; + } + + // User-signed action: master-account authorization, must be signed + // by the master wallet (device prompt on hardware). + return this.#signWithMaster(params); }, getChainId: async (): Promise => parseInt(getChainId(this.#isTestnet), 10), @@ -225,8 +321,9 @@ export class HyperLiquidWalletService { * Required by @nktkas/hyperliquid SDK for signing transactions * * When the injected `getAgentSigner` resolves a signer for the selected - * master account, the returned adapter signs with that local agent key and - * never contacts the keyring. Otherwise the master keyring path is used, + * master account, the returned adapter signs L1 (phantom-agent) actions + * with that local agent key and routes user-signed actions to the master + * keyring path. Otherwise the master keyring path is used for everything, * unchanged. * * @returns The wallet adapter with address, signTypedData, and getChainId methods. @@ -257,8 +354,8 @@ export class HyperLiquidWalletService { const address = evmAccount.address as Hex; - // Agent mode: a local signer for this master account takes over signing - // entirely. The unlocked-vault gate for agent signing is the in-memory + // Agent mode: a local signer for this master account takes over signing. + // The unlocked-vault gate for agent signing is the in-memory // plaintext key (null while locked), not the keyring. const agentSigner = this.#getAgentSigner ? await this.#getAgentSigner(address) @@ -281,44 +378,7 @@ export class HyperLiquidWalletService { }; primaryType: string; message: Record; - }): Promise => { - // Get FRESH account on every sign to handle account switches - // This prevents race conditions where wallet adapter was created with old account - const currentEvmAccount = getSelectedEvmAccountFromMessenger( - this.#messenger, - ); - - if (!currentEvmAccount?.address) { - throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); - } - - const currentAddress = currentEvmAccount.address as Hex; - - // Construct EIP-712 typed data - const typedData = { - domain: params.domain, - types: params.types, - primaryType: params.primaryType, - message: params.message, - }; - - this.#deps.debugLogger.log( - 'HyperLiquidWalletService: Signing typed data', - { - address: currentAddress, - primaryType: params.primaryType, - domain: params.domain, - }, - ); - - // Use messenger to sign typed data - const signature = await this.#signTypedMessage({ - from: currentAddress, - data: typedData, - }); - - return signature as Hex; - }, + }): Promise => this.#signWithMaster(params), getChainId: async (): Promise => parseInt(getChainId(this.#isTestnet), 10), }; diff --git a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.agent-signer.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.agent-signer.test.ts index 63ee88dc67c..3e099f02ece 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.agent-signer.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.agent-signer.test.ts @@ -184,6 +184,146 @@ describe('HyperLiquidWalletService agent signer seam', () => { }); }); + describe('agent mode: user-signed action routing', () => { + // L1 action shape produced by the SDK's `signL1Action`: domain + // { name: "Exchange", ... } with primaryType "Agent". These are the only + // signatures the agent key may produce. + const l1ActionParams = { + domain: { + name: 'Exchange', + version: '1', + chainId: 1337, + verifyingContract: + '0x0000000000000000000000000000000000000000' as `0x${string}`, + }, + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + Agent: [ + { name: 'source', type: 'string' }, + { name: 'connectionId', type: 'bytes32' }, + ], + }, + primaryType: 'Agent', + message: { source: 'a', connectionId: '0xabc123' }, + }; + + // User-signed action shape produced by the SDK's `signUserSignedAction` + // (e.g. `approveBuilderFee`): domain { name: + // "HyperliquidSignTransaction", ... }. These are master-account + // authorizations and must fall through to the master keyring path. + const userSignedParams = { + domain: { + name: 'HyperliquidSignTransaction', + version: '1', + chainId: 42161, + verifyingContract: + '0x0000000000000000000000000000000000000000' as `0x${string}`, + }, + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + 'HyperliquidTransaction:ApproveBuilderFee': [ + { name: 'hyperliquidChain', type: 'string' }, + { name: 'maxFeeRate', type: 'string' }, + { name: 'builder', type: 'address' }, + { name: 'nonce', type: 'uint64' }, + ], + }, + primaryType: 'HyperliquidTransaction:ApproveBuilderFee', + message: { + hyperliquidChain: 'Mainnet', + maxFeeRate: '0.01%', + builder: '0x3333333333333333333333333333333333333333', + nonce: 1700000000000, + }, + }; + + const createAgentModeService = (signTypedData: jest.Mock) => + new HyperLiquidWalletService(mockDeps, mockMessenger, { + getAgentSigner: jest.fn().mockResolvedValue({ + address: AGENT_ADDRESS, + signTypedData, + }), + }); + + it('signs Exchange-domain Agent actions with the agent signer and zero keyring calls', async () => { + const signTypedData = jest.fn().mockResolvedValue('0xagentsig'); + const service = createAgentModeService(signTypedData); + + const adapter = await service.createWalletAdapter(); + const signature = await adapter.signTypedData(l1ActionParams); + + expect(signature).toBe('0xagentsig'); + expect(signTypedData).toHaveBeenCalledTimes(1); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + expect.anything(), + expect.anything(), + ); + }); + + it('routes HyperliquidSignTransaction domain actions to the master keyring path', async () => { + const signTypedData = jest.fn().mockResolvedValue('0xagentsig'); + const service = createAgentModeService(signTypedData); + + const adapter = await service.createWalletAdapter(); + const signature = await adapter.signTypedData(userSignedParams); + + expect(signature).toBe('0xSignatureResult'); + expect(signTypedData).not.toHaveBeenCalled(); + expect(mockMessenger.call).toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + { + from: mockEvmAccount.address, + data: { + domain: userSignedParams.domain, + types: userSignedParams.types, + primaryType: userSignedParams.primaryType, + message: userSignedParams.message, + }, + }, + 'V4', + ); + }); + + it('signs unknown Exchange-domain shapes with the agent signer', async () => { + const signTypedData = jest.fn().mockResolvedValue('0xagentsig'); + const service = createAgentModeService(signTypedData); + + const adapter = await service.createWalletAdapter(); + await adapter.signTypedData({ + ...l1ActionParams, + primaryType: 'UsdClassTransfer', + message: { source: 'a' }, + }); + + expect(signTypedData).toHaveBeenCalledTimes(1); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + expect.anything(), + expect.anything(), + ); + }); + + it('keeps the agent address on the adapter for user-signed actions', async () => { + const signTypedData = jest.fn().mockResolvedValue('0xagentsig'); + const service = createAgentModeService(signTypedData); + + const adapter = await service.createWalletAdapter(); + + expect(adapter.address).toBe(AGENT_ADDRESS); + }); + }); + describe('master mode', () => { it('returns the keyring-backed adapter when getAgentSigner returns null', async () => { const service = new HyperLiquidWalletService(mockDeps, mockMessenger, { @@ -224,5 +364,36 @@ describe('HyperLiquidWalletService agent signer seam', () => { 'V4', ); }); + + it('routes Exchange-domain Agent actions through the keyring in master mode', async () => { + const service = new HyperLiquidWalletService(mockDeps, mockMessenger); + + const adapter = await service.createWalletAdapter(); + const signature = await adapter.signTypedData({ + ...typedDataParams, + domain: { + name: 'Exchange', + version: '1', + chainId: 1337, + verifyingContract: + '0x0000000000000000000000000000000000000000' as `0x${string}`, + }, + message: { source: 'a', connectionId: '0xabc123' }, + types: { + ...typedDataParams.types, + Agent: [ + { name: 'source', type: 'string' }, + { name: 'connectionId', type: 'bytes32' }, + ], + }, + }); + + expect(signature).toBe('0xSignatureResult'); + expect(mockMessenger.call).toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + expect.anything(), + 'V4', + ); + }); }); }); diff --git a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts index 1a64b8d8bce..4c0c5453403 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts @@ -203,7 +203,7 @@ describe('HyperLiquidWalletService', () => { expect(result).toBe('0xSignatureResult'); expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( - 'HyperLiquidWalletService: Signing typed data', + 'HyperLiquidWalletService: Signing typed data (master fallback)', { address: mockEvmAccount.address, primaryType: 'Order', From 1739879b16752e5fa73ecd0dd888d86ec2828f06 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Wed, 2 Sep 2026 15:10:28 +0800 Subject: [PATCH 3/6] feat(perps-controller): expose trading-readiness preparation for agent setup --- .../PerpsController-method-action-types.ts | 20 ++++++ .../perps-controller/src/PerpsController.ts | 20 ++++++ packages/perps-controller/src/index.ts | 1 + .../src/providers/AggregatedPerpsProvider.ts | 8 +++ .../src/providers/HyperLiquidProvider.ts | 20 ++++++ packages/perps-controller/src/types/index.ts | 8 +++ .../HyperLiquidProvider.builder-fees.test.ts | 65 +++++++++++++++++++ 7 files changed, 142 insertions(+) diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index 46c9b715f18..c8fd7eec429 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -707,6 +707,8 @@ export type PerpsControllerClearAttributionContextAction = { * registered — callers should treat that as best-effort. * * @param signer - The agent signer to sign with, or null for the master path. + * @returns A promise that resolves when the clients have re-initialized with + * the new wallet. */ export type PerpsControllerSetTradingWalletOverrideAction = { type: `PerpsController:setTradingWalletOverride`; @@ -932,6 +934,23 @@ export type PerpsControllerApproveSubscriptionBuilderFeeAction = { handler: PerpsController['approveSubscriptionBuilderFee']; }; +/** + * Run the deferred trading-readiness steps for the active provider + * (unified account enablement with user signing, builder fee approval) so + * any required master signature surfaces during a guided session (e.g. + * agent wallet setup) instead of as a surprise prompt on the first order. + * + * Reuses the provider's own trading-readiness sequence, which is cached: + * an already-ready wallet completes without any signature. Providers + * without deferred setup make this a no-op. Best-effort by design of the + * underlying sequence: builder-fee failures are swallowed there and retried + * at order time; only initialization/migration errors propagate. + */ +export type PerpsControllerPrepareTradingWalletAction = { + type: `PerpsController:prepareTradingWallet`; + handler: PerpsController['prepareTradingWallet']; +}; + /** * Drop the cached subscription benefits snapshot. * @@ -1439,6 +1458,7 @@ export type PerpsControllerMethodActions = | PerpsControllerSetLiveDataConfigAction | PerpsControllerCalculateFeesAction | PerpsControllerApproveSubscriptionBuilderFeeAction + | PerpsControllerPrepareTradingWalletAction | PerpsControllerInvalidateSubscriptionBenefitsAction | PerpsControllerDisconnectAction | PerpsControllerStartEligibilityMonitoringAction diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index d8f56547803..2971068c517 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -965,6 +965,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'markTutorialCompleted', 'placeOrder', 'previewPositionModify', + 'prepareTradingWallet', 'reconnect', 'recordMarketViewed', 'refreshEligibility', @@ -5839,6 +5840,25 @@ export class PerpsController extends BaseController< : false; } + /** + * Run the deferred trading-readiness steps for the active provider + * (unified account enablement with user signing, builder fee approval) so + * any required master signature surfaces during a guided session (e.g. + * agent wallet setup) instead of as a surprise prompt on the first order. + * + * Reuses the provider's own trading-readiness sequence, which is cached: + * an already-ready wallet completes without any signature. Providers + * without deferred setup make this a no-op. Best-effort by design of the + * underlying sequence: builder-fee failures are swallowed there and retried + * at order time; only initialization/migration errors propagate. + */ + async prepareTradingWallet(): Promise { + const provider = await this.#getActiveProviderWhenReady(); + if (provider.prepareTradingWallet) { + await provider.prepareTradingWallet(); + } + } + /** * Drop the cached subscription benefits snapshot. * diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 810aea24880..536688da9fc 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -126,6 +126,7 @@ export type { PerpsControllerMarkFirstOrderCompletedAction, PerpsControllerMarkTutorialCompletedAction, PerpsControllerPlaceOrderAction, + PerpsControllerPrepareTradingWalletAction, PerpsControllerReconnectAction, PerpsControllerRecordMarketViewedAction, PerpsControllerRefreshEligibilityAction, diff --git a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts index 576a041ea5e..6534bef829e 100644 --- a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts +++ b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts @@ -1004,6 +1004,14 @@ export class AggregatedPerpsProvider implements PerpsProvider { : false; } + async prepareTradingWallet(): Promise { + const provider = + this.#providers.get('hyperliquid') ?? this.#getDefaultProvider(); + if (provider.prepareTradingWallet) { + await provider.prepareTradingWallet(); + } + } + // ============================================================================ // Lifecycle (Delegate to default provider) // ============================================================================ diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index ecc4bebb2d8..f4c0924a8a4 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -13956,6 +13956,26 @@ export class HyperLiquidProvider implements PerpsProvider { }); } + /** + * Drive the deferred trading-readiness steps ahead of the first order. + * + * Runs the exact sequence the order path uses (see `#ensureReadyForTrading`): + * unified account enablement with user signing allowed, builder fee approval, + * and referral setup. Calling this before the first order means hardware + * wallet users complete every master device prompt in one guided session + * (e.g. during agent wallet setup) instead of hitting a surprise prompt on + * their first trade. + * + * Safe to call repeatedly: readiness results are cached (globally and per + * session), so an already-approved wallet never sees a second prompt. + * Builder-fee failures are swallowed by the underlying sequence (trading + * retries at order time), but initialization and migration errors propagate + * to the caller. + */ + async prepareTradingWallet(): Promise { + await this.#ensureReadyForTrading({ requiresBuilderFee: true }); + } + /** * Calculate liquidation price using HyperLiquid's formula * Formula: liq_price = price - side * margin_available / position_size / (1 - maintenanceMarginRatio * side) diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 6fb5dce406a..743d3cbfa50 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -2067,6 +2067,14 @@ export type PerpsProvider = { setUserFeeResolution?(resolution: PerpsFeeResolution | undefined): void; /** Approve the dedicated subscription builder outside order submission. */ approveSubscriptionBuilderFee?(): Promise; + /** + * Drive the deferred trading-readiness steps (unified account enablement + * with user signing, builder fee approval) ahead of the first order, so any + * required master signature surfaces in a guided session rather than at + * order time. Optional for backward compatibility; providers without + * deferred setup simply omit it. + */ + prepareTradingWallet?(): Promise; // HIP-3 (Builder-deployed DEXs) operations - optional for backward compatibility /** diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts index c4ccf99fe16..f21674c8754 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts @@ -1552,6 +1552,71 @@ describe('HyperLiquidProvider', () => { }); }); + describe('prepareTradingWallet', () => { + it('runs the trading-readiness sequence with user signing allowed and approves the builder fee when not yet approved', async () => { + // Unified account already unified (no migration signing needed), but + // builder fee NOT approved on-chain: the readiness pass must sign it. + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + maxBuilderFee: jest + .fn() + // First call: status check (not approved). Second call: + // post-approval verification (now approved). + .mockResolvedValueOnce(0) + .mockResolvedValue(0.001), + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: REFERRAL_CONFIG.MainnetCode }, + }, + referredBy: { code: 'EXISTING_REFERRAL' }, + }), + }), + ); + + await provider.prepareTradingWallet(); + + // The builder-fee approval (a master signature for hardware wallets) + // was driven by the readiness call — the exact prompt the first order + // would otherwise surface as a surprise. + expect( + mockClientService.getExchangeClient().approveBuilderFee, + ).toHaveBeenCalledWith({ + builder: expect.any(String), + maxFeeRate: expect.stringContaining('%'), + }); + // The unified-account readiness leg ran with user signing allowed (the + // on-chain abstraction mode was queried rather than skipped). + const mockInfoClient = mockClientService.getInfoClient(); + expect(mockInfoClient.userAbstraction).toHaveBeenCalled(); + }); + + it('does not prompt for a signature when the builder fee is already approved on-chain', async () => { + const approveBuilderFee = jest.fn().mockResolvedValue({ status: 'ok' }); + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ approveBuilderFee }), + ); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + maxBuilderFee: jest.fn().mockResolvedValue(1), // Already approved + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: REFERRAL_CONFIG.MainnetCode }, + }, + referredBy: { code: 'EXISTING_REFERRAL' }, + }), + }), + ); + + await provider.prepareTradingWallet(); + + expect(approveBuilderFee).not.toHaveBeenCalled(); + }); + }); + // TODO: Refactor to test through public API — ES # private fields prevent direct access describe.skip('Builder Fee Global Cache (PR #25334)', () => { interface ProviderWithBuilderFee { From 53c2135ca5faac735101a27681240b4be5c243c9 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Wed, 2 Sep 2026 18:00:57 +0800 Subject: [PATCH 4/6] fix: revert async --- .../src/providers/HyperLiquidProvider.ts | 27 +++- .../HyperLiquidSubscriptionService.ts | 23 ++- .../src/services/HyperLiquidWalletService.ts | 40 +----- ...idProvider.trading-wallet-override.test.ts | 53 ++++++- ...perLiquidSubscriptionService.cache.test.ts | 2 +- ...erLiquidWalletService.agent-signer.test.ts | 134 +++++------------- .../services/HyperLiquidWalletService.test.ts | 2 +- 7 files changed, 132 insertions(+), 149 deletions(-) diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index f4c0924a8a4..e7b4662a4dc 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -1503,6 +1503,13 @@ export class HyperLiquidProvider implements PerpsProvider { // with an activation event mid disconnect/initialize. #overridePromise: Promise = Promise.resolve(); + // Resolves an already-active agent on first client init when the host has + // not yet called setTradingWalletOverride. Explicit override clears skip + // this lookup so a lock event cannot pick the agent back up. + readonly #getAgentSigner: + | ((masterAccountAddress: string) => Promise) + | undefined; + readonly #messenger: PerpsControllerMessengerBase; readonly #builderAddressTestnet?: string; @@ -1536,6 +1543,7 @@ export class HyperLiquidProvider implements PerpsProvider { options.subscriptionBuilderAddressMainnet; this.#onChaseOrderMaxDistanceReached = options.onChaseOrderMaxDistanceReached; + this.#getAgentSigner = options.getAgentSigner; this.#priceDeviationLimit = options.priceDeviationLimit ?? HYPERLIQUID_CONFIG.OraclePriceDeviationLimit; @@ -1563,7 +1571,6 @@ export class HyperLiquidProvider implements PerpsProvider { this.#messenger, { isTestnet, - getAgentSigner: options.getAgentSigner, }, ); this.#subscriptionService = new HyperLiquidSubscriptionService( @@ -1998,8 +2005,10 @@ export class HyperLiquidProvider implements PerpsProvider { /** * Build the wallet the SDK clients should sign with. * - * With an active agent override the agent adapter is used; otherwise the - * master keyring adapter is created (unchanged behavior). + * An explicit `signer` (including `null` to restore the master path) wins. + * Otherwise the stored override is used. On lazy init with neither, an + * already-active agent is resolved via `getAgentSigner` so the first + * initialize does not wait for `setTradingWalletOverride`. * * @param signer - Optional explicit signer; defaults to the stored override. * @returns The wallet adapter for the client service. @@ -2012,6 +2021,18 @@ export class HyperLiquidProvider implements PerpsProvider { if (effectiveSigner) { return this.#walletService.createAgentWalletAdapter(effectiveSigner); } + + // Explicit clear (`null`) must restore the master path and not re-query + // getAgentSigner; a lock event can race the in-memory key still being set. + if (signer === undefined && this.#getAgentSigner) { + const masterWallet = this.#walletService.createWalletAdapter(); + const agentSigner = await this.#getAgentSigner(masterWallet.address); + if (agentSigner) { + return this.#walletService.createAgentWalletAdapter(agentSigner); + } + return masterWallet; + } + return this.#walletService.createWalletAdapter(); } diff --git a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts index 6f92fb14553..517f52ebef1 100644 --- a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts +++ b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts @@ -1620,7 +1620,7 @@ export class HyperLiquidSubscriptionService { // instance subscribeToAccount can race ahead of the webData3 path, // so initialize here first — subsequent calls are no-ops. await this.#clientService.ensureSubscriptionClient( - await this.#walletService.createWalletAdapter(), + this.#walletService.createWalletAdapter(), ); // Don't bail here even if generation has bumped (e.g. WS spot snapshot @@ -1744,7 +1744,7 @@ export class HyperLiquidSubscriptionService { const promise = (async (): Promise => { await this.#clientService.ensureSubscriptionClient( - await this.#walletService.createWalletAdapter(), + this.#walletService.createWalletAdapter(), ); const subscriptionClient = this.#clientService.getSubscriptionClient(); if (!subscriptionClient) { @@ -1860,7 +1860,7 @@ export class HyperLiquidSubscriptionService { }); await this.#clientService.ensureSubscriptionClient( - await this.#walletService.createWalletAdapter(), + this.#walletService.createWalletAdapter(), ); const subscriptionClient = this.#clientService.getSubscriptionClient(); @@ -2030,7 +2030,7 @@ export class HyperLiquidSubscriptionService { */ async #createUserDataSubscription(accountId?: CaipAccountId): Promise { await this.#clientService.ensureSubscriptionClient( - await this.#walletService.createWalletAdapter(), + this.#walletService.createWalletAdapter(), ); const subscriptionClient = this.#clientService.getSubscriptionClient(); @@ -2281,7 +2281,7 @@ export class HyperLiquidSubscriptionService { dexName: string, ): Promise { await this.#clientService.ensureSubscriptionClient( - await this.#walletService.createWalletAdapter(), + this.#walletService.createWalletAdapter(), ); const subscriptionClient = this.#clientService.getSubscriptionClient(); if (!subscriptionClient) { @@ -2452,7 +2452,7 @@ export class HyperLiquidSubscriptionService { dexName: string, ): Promise { await this.#clientService.ensureSubscriptionClient( - await this.#walletService.createWalletAdapter(), + this.#walletService.createWalletAdapter(), ); const subscriptionClient = this.#clientService.getSubscriptionClient(); if (!subscriptionClient) { @@ -2916,7 +2916,7 @@ export class HyperLiquidSubscriptionService { const subscriptionClient = this.#clientService.getSubscriptionClient(); if (!subscriptionClient) { await this.#clientService.ensureSubscriptionClient( - await this.#walletService.createWalletAdapter(), + this.#walletService.createWalletAdapter(), ); const client = this.#clientService.getSubscriptionClient(); if (!client) { @@ -4215,7 +4215,7 @@ export class HyperLiquidSubscriptionService { */ async #createDexAllMidsSubscription(dex: string): Promise { await this.#clientService.ensureSubscriptionClient( - await this.#walletService.createWalletAdapter(), + this.#walletService.createWalletAdapter(), ); const subscriptionClient = this.#clientService.getSubscriptionClient(); @@ -4270,7 +4270,7 @@ export class HyperLiquidSubscriptionService { */ async #createAssetCtxsSubscription(dex: string): Promise { await this.#clientService.ensureSubscriptionClient( - await this.#walletService.createWalletAdapter(), + this.#walletService.createWalletAdapter(), ); const subscriptionClient = this.#clientService.getSubscriptionClient(); @@ -4653,9 +4653,8 @@ export class HyperLiquidSubscriptionService { onError, } = params; - this.#walletService - .createWalletAdapter() - .then((wallet) => this.#clientService.ensureSubscriptionClient(wallet)) + this.#clientService + .ensureSubscriptionClient(this.#walletService.createWalletAdapter()) .catch(() => { // Handled by getSubscriptionClient check below }); diff --git a/packages/perps-controller/src/services/HyperLiquidWalletService.ts b/packages/perps-controller/src/services/HyperLiquidWalletService.ts index 7498b37b430..f61ce00fd98 100644 --- a/packages/perps-controller/src/services/HyperLiquidWalletService.ts +++ b/packages/perps-controller/src/services/HyperLiquidWalletService.ts @@ -65,15 +65,6 @@ export type AgentSigner = { /** Options bag for {@link HyperLiquidWalletService}. */ export type HyperLiquidWalletServiceOptions = { isTestnet?: boolean; - /** - * Resolves the local agent signer for a master account address, or null - * when the account has no active agent or the wallet is locked. When it - * returns a signer, wallet adapters sign with the agent key instead of - * contacting the keyring. - */ - getAgentSigner?: ( - masterAccountAddress: string, - ) => Promise; }; /** @@ -88,10 +79,6 @@ export class HyperLiquidWalletService { readonly #messenger: PerpsControllerMessengerBase; - readonly #getAgentSigner?: - | ((masterAccountAddress: string) => Promise) - | undefined; - constructor( deps: PerpsPlatformDependencies, messenger: PerpsControllerMessengerBase, @@ -100,7 +87,6 @@ export class HyperLiquidWalletService { this.#deps = deps; this.#messenger = messenger; this.#isTestnet = options.isTestnet ?? false; - this.#getAgentSigner = options.getAgentSigner; } /** @@ -317,18 +303,16 @@ export class HyperLiquidWalletService { } /** - * Create wallet adapter that implements AbstractViemJsonRpcAccount interface - * Required by @nktkas/hyperliquid SDK for signing transactions + * Create the master-keyring wallet adapter for the HyperLiquid SDK. * - * When the injected `getAgentSigner` resolves a signer for the selected - * master account, the returned adapter signs L1 (phantom-agent) actions - * with that local agent key and routes user-signed actions to the master - * keyring path. Otherwise the master keyring path is used for everything, - * unchanged. + * This factory is synchronous and always signs via the selected master + * account. Agent wallets are built separately with + * {@link createAgentWalletAdapter}; the provider selects which adapter the + * SDK clients use. * * @returns The wallet adapter with address, signTypedData, and getChainId methods. */ - public async createWalletAdapter(): Promise<{ + public createWalletAdapter(): { address: Hex; signTypedData: (params: { domain: { @@ -344,7 +328,7 @@ export class HyperLiquidWalletService { message: Record; }) => Promise; getChainId?: () => Promise; - }> { + } { // Get current EVM account via DI messenger const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); @@ -354,16 +338,6 @@ export class HyperLiquidWalletService { const address = evmAccount.address as Hex; - // Agent mode: a local signer for this master account takes over signing. - // The unlocked-vault gate for agent signing is the in-memory - // plaintext key (null while locked), not the keyring. - const agentSigner = this.#getAgentSigner - ? await this.#getAgentSigner(address) - : null; - if (agentSigner) { - return this.createAgentWalletAdapter(agentSigner); - } - return { address, signTypedData: async (params: { diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading-wallet-override.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading-wallet-override.test.ts index feb74aea6d5..74292b8d5cd 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading-wallet-override.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading-wallet-override.test.ts @@ -1,7 +1,7 @@ /* eslint-disable */ /** * Unit tests for HyperLiquidProvider.setTradingWalletOverride and the - * getAgentSigner pass-through to the wallet service. + * getAgentSigner lookup on first client initialization. */ jest.mock('@nktkas/hyperliquid', () => ({})); @@ -82,7 +82,9 @@ describe('HyperLiquidProvider trading wallet override', () => { setTestnetMode: jest.fn(), } as unknown as jest.Mocked; - mockSubscriptionService = {} as jest.Mocked; + mockSubscriptionService = { + clearAll: jest.fn(), + } as unknown as jest.Mocked; MockedHyperLiquidClientService.mockImplementation(() => mockClientService); MockedHyperLiquidWalletService.mockImplementation(() => mockWalletService); @@ -91,13 +93,56 @@ describe('HyperLiquidProvider trading wallet override', () => { ); }); - it('passes getAgentSigner through to the wallet service constructor', () => { + it('does not pass getAgentSigner to the wallet service', () => { const getAgentSigner = jest.fn(); createTestProvider({ getAgentSigner }); const constructorArgs = MockedHyperLiquidWalletService.mock.calls[0]; const options = constructorArgs?.[2] as { getAgentSigner?: unknown }; - expect(options.getAgentSigner).toBe(getAgentSigner); + expect(options?.getAgentSigner).toBeUndefined(); + }); + + it('uses getAgentSigner on first initialize when no override is set', async () => { + const agentSigner: AgentSigner = { + address: AGENT_ADDRESS, + signTypedData: jest.fn().mockResolvedValue('0xagentsig'), + }; + const getAgentSigner = jest.fn().mockResolvedValue(agentSigner); + const provider = createTestProvider({ getAgentSigner }); + + await provider.initialize(); + + expect(getAgentSigner).toHaveBeenCalledWith(MASTER_ADDRESS); + expect(mockWalletService.createAgentWalletAdapter).toHaveBeenCalledWith( + agentSigner, + ); + const wallet = mockClientService.initialize.mock.calls[0]?.[0]; + expect(wallet?.address).toBe(AGENT_ADDRESS); + }); + + it('uses the master adapter when getAgentSigner returns null', async () => { + const getAgentSigner = jest.fn().mockResolvedValue(null); + const provider = createTestProvider({ getAgentSigner }); + + await provider.initialize(); + + expect(mockWalletService.createWalletAdapter).toHaveBeenCalledTimes(1); + expect(mockWalletService.createAgentWalletAdapter).not.toHaveBeenCalled(); + const wallet = mockClientService.initialize.mock.calls[0]?.[0]; + expect(wallet?.address).toBe(MASTER_ADDRESS); + }); + + it('does not re-query getAgentSigner when the override is cleared', async () => { + const getAgentSigner = jest.fn().mockResolvedValue({ + address: AGENT_ADDRESS, + signTypedData: jest.fn().mockResolvedValue('0xagentsig'), + }); + const provider = createTestProvider({ getAgentSigner }); + + await provider.setTradingWalletOverride(null); + + expect(getAgentSigner).not.toHaveBeenCalled(); + expect(mockWalletService.createWalletAdapter).toHaveBeenCalledTimes(1); }); it('reinitializes the client service with the agent adapter', async () => { diff --git a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts index 1fc42557039..1da14bc23e9 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts @@ -467,7 +467,7 @@ describe('HyperLiquidSubscriptionService', () => { // Mock wallet service mockWalletService = { - createWalletAdapter: jest.fn().mockResolvedValue(mockWalletAdapter), + createWalletAdapter: jest.fn(() => mockWalletAdapter), getUserAddressWithDefault: jest.fn().mockResolvedValue('0x123' as Hex), } as any; diff --git a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.agent-signer.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.agent-signer.test.ts index 3e099f02ece..c30f7a8ee6a 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.agent-signer.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.agent-signer.test.ts @@ -2,13 +2,13 @@ /** * Unit tests for the HyperLiquidWalletService agent-signer seam. * - * Agent mode: when the injected `getAgentSigner` returns a signer for the - * selected master account, `createWalletAdapter` returns an adapter whose - * address is the agent's and whose `signTypedData` delegates directly to the - * local signer — the keyring messenger is never contacted. + * Agent mode: `createAgentWalletAdapter` returns an adapter whose address is + * the agent's and whose `signTypedData` delegates L1 actions to the local + * signer — the keyring messenger is never contacted for those. * - * Master mode: when no signer is available, the existing keyring-backed - * adapter is returned unchanged. + * Master mode: `createWalletAdapter` is a synchronous master-keyring factory. + * It does not look up an agent signer; the provider selects the agent adapter + * via `#buildWallet` / `setTradingWalletOverride`. */ // Mock keyring-api to avoid import issues with definePattern @@ -89,43 +89,30 @@ describe('HyperLiquidWalletService agent signer seam', () => { mockMessenger = createMockMessenger(); }); - describe('agent mode', () => { - it('returns an adapter whose address is the agent address', async () => { - const agentSigner: AgentSigner = { - address: AGENT_ADDRESS, - signTypedData: jest.fn().mockResolvedValue('0xagentsig'), - }; - const service = new HyperLiquidWalletService(mockDeps, mockMessenger, { - getAgentSigner: jest.fn().mockResolvedValue(agentSigner), - }); + const createAgentAdapter = (signTypedData: jest.Mock = jest.fn()) => { + const agentSigner: AgentSigner = { + address: AGENT_ADDRESS, + signTypedData, + }; + const service = new HyperLiquidWalletService(mockDeps, mockMessenger); + return { + service, + agentSigner, + adapter: service.createAgentWalletAdapter(agentSigner), + }; + }; - const adapter = await service.createWalletAdapter(); + describe('agent mode', () => { + it('returns an adapter whose address is the agent address', () => { + const { adapter } = createAgentAdapter(); expect(adapter.address).toBe(AGENT_ADDRESS); }); - it('passes the selected master account address to getAgentSigner', async () => { - const getAgentSigner = jest.fn().mockResolvedValue(null); - const service = new HyperLiquidWalletService(mockDeps, mockMessenger, { - getAgentSigner, - }); - - await service.createWalletAdapter(); - - expect(getAgentSigner).toHaveBeenCalledWith(mockEvmAccount.address); - }); - it('delegates signing directly to the injected signer with no keyring call', async () => { const signTypedData = jest.fn().mockResolvedValue('0xagentsig'); - const agentSigner: AgentSigner = { - address: AGENT_ADDRESS, - signTypedData, - }; - const service = new HyperLiquidWalletService(mockDeps, mockMessenger, { - getAgentSigner: jest.fn().mockResolvedValue(agentSigner), - }); + const { adapter } = createAgentAdapter(signTypedData); - const adapter = await service.createWalletAdapter(); const signature = await adapter.signTypedData(typedDataParams); expect(signature).toBe('0xagentsig'); @@ -139,15 +126,8 @@ describe('HyperLiquidWalletService agent signer seam', () => { it('strips the injected EIP712Domain type before delegating', async () => { const signTypedData = jest.fn().mockResolvedValue('0xagentsig'); - const agentSigner: AgentSigner = { - address: AGENT_ADDRESS, - signTypedData, - }; - const service = new HyperLiquidWalletService(mockDeps, mockMessenger, { - getAgentSigner: jest.fn().mockResolvedValue(agentSigner), - }); + const { adapter } = createAgentAdapter(signTypedData); - const adapter = await service.createWalletAdapter(); await adapter.signTypedData(typedDataParams); const [domain, types, value] = signTypedData.mock.calls[0]; @@ -158,13 +138,7 @@ describe('HyperLiquidWalletService agent signer seam', () => { it('signs with the agent adapter even when the keyring reports locked', async () => { const signTypedData = jest.fn().mockResolvedValue('0xagentsig'); - const agentSigner: AgentSigner = { - address: AGENT_ADDRESS, - signTypedData, - }; - const service = new HyperLiquidWalletService(mockDeps, mockMessenger, { - getAgentSigner: jest.fn().mockResolvedValue(agentSigner), - }); + const { adapter } = createAgentAdapter(signTypedData); (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { if ( action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' @@ -177,7 +151,6 @@ describe('HyperLiquidWalletService agent signer seam', () => { return undefined; }); - const adapter = await service.createWalletAdapter(); const signature = await adapter.signTypedData(typedDataParams); expect(signature).toBe('0xagentsig'); @@ -247,19 +220,10 @@ describe('HyperLiquidWalletService agent signer seam', () => { }, }; - const createAgentModeService = (signTypedData: jest.Mock) => - new HyperLiquidWalletService(mockDeps, mockMessenger, { - getAgentSigner: jest.fn().mockResolvedValue({ - address: AGENT_ADDRESS, - signTypedData, - }), - }); - it('signs Exchange-domain Agent actions with the agent signer and zero keyring calls', async () => { const signTypedData = jest.fn().mockResolvedValue('0xagentsig'); - const service = createAgentModeService(signTypedData); + const { adapter } = createAgentAdapter(signTypedData); - const adapter = await service.createWalletAdapter(); const signature = await adapter.signTypedData(l1ActionParams); expect(signature).toBe('0xagentsig'); @@ -273,9 +237,8 @@ describe('HyperLiquidWalletService agent signer seam', () => { it('routes HyperliquidSignTransaction domain actions to the master keyring path', async () => { const signTypedData = jest.fn().mockResolvedValue('0xagentsig'); - const service = createAgentModeService(signTypedData); + const { adapter } = createAgentAdapter(signTypedData); - const adapter = await service.createWalletAdapter(); const signature = await adapter.signTypedData(userSignedParams); expect(signature).toBe('0xSignatureResult'); @@ -297,9 +260,8 @@ describe('HyperLiquidWalletService agent signer seam', () => { it('signs unknown Exchange-domain shapes with the agent signer', async () => { const signTypedData = jest.fn().mockResolvedValue('0xagentsig'); - const service = createAgentModeService(signTypedData); + const { adapter } = createAgentAdapter(signTypedData); - const adapter = await service.createWalletAdapter(); await adapter.signTypedData({ ...l1ActionParams, primaryType: 'UsdClassTransfer', @@ -314,49 +276,31 @@ describe('HyperLiquidWalletService agent signer seam', () => { ); }); - it('keeps the agent address on the adapter for user-signed actions', async () => { - const signTypedData = jest.fn().mockResolvedValue('0xagentsig'); - const service = createAgentModeService(signTypedData); - - const adapter = await service.createWalletAdapter(); + it('keeps the agent address on the adapter for user-signed actions', () => { + const { adapter } = createAgentAdapter( + jest.fn().mockResolvedValue('0xagentsig'), + ); expect(adapter.address).toBe(AGENT_ADDRESS); }); }); describe('master mode', () => { - it('returns the keyring-backed adapter when getAgentSigner returns null', async () => { - const service = new HyperLiquidWalletService(mockDeps, mockMessenger, { - getAgentSigner: jest.fn().mockResolvedValue(null), - }); + it('returns the keyring-backed adapter synchronously', () => { + const service = new HyperLiquidWalletService(mockDeps, mockMessenger); - const adapter = await service.createWalletAdapter(); + const adapter = service.createWalletAdapter(); + expect(adapter).not.toBeInstanceOf(Promise); expect(adapter.address).toBe(mockEvmAccount.address); - const signature = await adapter.signTypedData(typedDataParams); - expect(signature).toBe('0xSignatureResult'); - expect(mockMessenger.call).toHaveBeenCalledWith( - 'KeyringController:signTypedMessage', - { - from: mockEvmAccount.address, - data: { - domain: typedDataParams.domain, - types: typedDataParams.types, - primaryType: typedDataParams.primaryType, - message: typedDataParams.message, - }, - }, - 'V4', - ); }); - it('uses the master path when no getAgentSigner option is provided', async () => { + it('uses the master path when creating the default wallet adapter', async () => { const service = new HyperLiquidWalletService(mockDeps, mockMessenger); - const adapter = await service.createWalletAdapter(); - - expect(adapter.address).toBe(mockEvmAccount.address); + const adapter = service.createWalletAdapter(); const signature = await adapter.signTypedData(typedDataParams); + expect(signature).toBe('0xSignatureResult'); expect(mockMessenger.call).toHaveBeenCalledWith( 'KeyringController:signTypedMessage', @@ -368,7 +312,7 @@ describe('HyperLiquidWalletService agent signer seam', () => { it('routes Exchange-domain Agent actions through the keyring in master mode', async () => { const service = new HyperLiquidWalletService(mockDeps, mockMessenger); - const adapter = await service.createWalletAdapter(); + const adapter = service.createWalletAdapter(); const signature = await adapter.signTypedData({ ...typedDataParams, domain: { diff --git a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts index 4c0c5453403..4aa94ade472 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts @@ -246,7 +246,7 @@ describe('HyperLiquidWalletService', () => { ); // Creating wallet adapter should throw when no account - await expect(service.createWalletAdapter()).rejects.toThrow( + expect(() => service.createWalletAdapter()).toThrow( 'NO_ACCOUNT_SELECTED', ); }); From 4229e90f06d84a42f16a05b450e2b91dd760f945 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Wed, 2 Sep 2026 19:11:40 +0800 Subject: [PATCH 5/6] fix: revert await --- .../services/HyperLiquidWalletService.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts index 4aa94ade472..0bbca55525d 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts @@ -131,8 +131,8 @@ describe('HyperLiquidWalletService', () => { getChainId?: () => Promise; }; - beforeEach(async () => { - walletAdapter = await service.createWalletAdapter(); + beforeEach(() => { + walletAdapter = service.createWalletAdapter(); }); it('should create wallet adapter with signTypedData method', () => { @@ -159,7 +159,7 @@ describe('HyperLiquidWalletService', () => { mockMessenger, { isTestnet: true }, ); - const testnetAdapter = await testnetService.createWalletAdapter(); + const testnetAdapter = testnetService.createWalletAdapter(); expect(testnetAdapter.getChainId).toBeDefined(); const chainId = await testnetAdapter.getChainId?.(); @@ -271,7 +271,7 @@ describe('HyperLiquidWalletService', () => { ); // Need to recreate the adapter after changing the mock - const freshAdapter = await service.createWalletAdapter(); + const freshAdapter = service.createWalletAdapter(); await expect( freshAdapter.signTypedData(mockTypedDataParams), @@ -431,7 +431,7 @@ describe('HyperLiquidWalletService', () => { }); it('should throw KEYRING_LOCKED when keyring is locked', async () => { - const walletAdapter = await service.createWalletAdapter(); + const walletAdapter = service.createWalletAdapter(); (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { if ( action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' @@ -483,7 +483,7 @@ describe('HyperLiquidWalletService', () => { }); it('should handle keyring controller initialization errors', async () => { - const walletAdapter = await service.createWalletAdapter(); + const walletAdapter = service.createWalletAdapter(); (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { if ( action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' @@ -522,7 +522,7 @@ describe('HyperLiquidWalletService', () => { describe('Integration Scenarios', () => { it('should handle full wallet adapter workflow', async () => { - const walletAdapter = await service.createWalletAdapter(); + const walletAdapter = service.createWalletAdapter(); // Get chain ID expect(walletAdapter.getChainId).toBeDefined(); @@ -552,7 +552,7 @@ describe('HyperLiquidWalletService', () => { }); it('should maintain consistency between wallet adapter and service methods', async () => { - const walletAdapter = await service.createWalletAdapter(); + const walletAdapter = service.createWalletAdapter(); // Get chain ID through wallet adapter expect(walletAdapter.getChainId).toBeDefined(); From 00a181c6cd5ce3ffe7703b1a541571d3a65a5840 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Wed, 2 Sep 2026 19:43:35 +0800 Subject: [PATCH 6/6] fix: refactor to simplify --- packages/perps-controller/CHANGELOG.md | 5 + .../PerpsController-method-action-types.ts | 27 +- packages/perps-controller/src/index.ts | 5 +- .../src/providers/HyperLiquidProvider.ts | 10 +- .../src/services/HyperLiquidClientService.ts | 38 +- .../src/services/HyperLiquidWalletService.ts | 127 ++----- ...idProvider.trading-wallet-override.test.ts | 48 +-- ...erLiquidWalletService.agent-signer.test.ts | 343 ------------------ .../services/HyperLiquidWalletService.test.ts | 256 +++++++++++++ 9 files changed, 338 insertions(+), 521 deletions(-) delete mode 100644 packages/perps-controller/tests/src/services/HyperLiquidWalletService.agent-signer.test.ts diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 1e16dc40b71..15055a3e42b 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add an agent-wallet signing seam for HyperLiquid: the optional `PerpsControllerOptions.getAgentSigner` callback resolves a local agent signer (exported `AgentSigner` type) whose key never touches the keyring, and `PerpsController:setTradingWalletOverride` switches (or clears) the HyperLiquid signing wallet at runtime. L1 actions are signed by the agent key; user-signed actions (`approveBuilderFee`, `sendAsset`, `withdraw3`, …) always fall back to the master wallet ([#10075](https://github.com/MetaMask/core/pull/10075)) +- Add `PerpsController:prepareTradingWallet` and the optional `PerpsProvider.prepareTradingWallet` hook to run deferred trading-readiness steps (unified account enablement with user signing, builder fee approval) ahead of the first order, so required master signatures surface in a guided session rather than at order time ([#10075](https://github.com/MetaMask/core/pull/10075)) + ### Removed - **BREAKING:** Remove all MYX protocol support ([#10038](https://github.com/MetaMask/core/pull/10038)) diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index c8fd7eec429..70d869cb631 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -699,16 +699,9 @@ export type PerpsControllerClearAttributionContextAction = { }; /** - * Override (or clear) the trading wallet the HyperLiquid provider signs with. - * - * The host app calls this with a local agent signer when an agent wallet - * activates, and with `null` when the keyring locks (restoring the master - * keyring path). Throws when the HyperLiquid provider is not (yet) - * registered — callers should treat that as best-effort. - * - * @param signer - The agent signer to sign with, or null for the master path. - * @returns A promise that resolves when the clients have re-initialized with - * the new wallet. + * Override (or clear) the trading wallet the HyperLiquid provider signs with + * (agent signer on activation, `null` on keyring lock to restore the master + * path). See {@link PerpsController.setTradingWalletOverride}. */ export type PerpsControllerSetTradingWalletOverrideAction = { type: `PerpsController:setTradingWalletOverride`; @@ -935,16 +928,10 @@ export type PerpsControllerApproveSubscriptionBuilderFeeAction = { }; /** - * Run the deferred trading-readiness steps for the active provider - * (unified account enablement with user signing, builder fee approval) so - * any required master signature surfaces during a guided session (e.g. - * agent wallet setup) instead of as a surprise prompt on the first order. - * - * Reuses the provider's own trading-readiness sequence, which is cached: - * an already-ready wallet completes without any signature. Providers - * without deferred setup make this a no-op. Best-effort by design of the - * underlying sequence: builder-fee failures are swallowed there and retried - * at order time; only initialization/migration errors propagate. + * Run the deferred trading-readiness steps (unified account enablement with + * user signing, builder fee approval) ahead of the first order, so any + * required master signature surfaces in a guided session rather than at + * order time. See {@link PerpsController.prepareTradingWallet}. */ export type PerpsControllerPrepareTradingWalletAction = { type: `PerpsController:prepareTradingWallet`; diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 536688da9fc..a8257159da7 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -715,10 +715,7 @@ export { // Services (only externally consumed items) export { TradingReadinessCache } from './services/TradingReadinessCache.js'; export type { ServiceContext } from './services/ServiceContext.js'; -export type { - AgentSigner, - HyperLiquidWalletServiceOptions, -} from './services/HyperLiquidWalletService.js'; +export type { AgentSigner } from './services/HyperLiquidWalletService.js'; export { AggregatedOrderBookConnection, processAggregatedOrderBook, diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index e7b4662a4dc..e8fa60b9ead 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -2026,9 +2026,13 @@ export class HyperLiquidProvider implements PerpsProvider { // getAgentSigner; a lock event can race the in-memory key still being set. if (signer === undefined && this.#getAgentSigner) { const masterWallet = this.#walletService.createWalletAdapter(); - const agentSigner = await this.#getAgentSigner(masterWallet.address); - if (agentSigner) { - return this.#walletService.createAgentWalletAdapter(agentSigner); + // The master adapter always carries the selected account address; if + // it is somehow absent, fall through to the master path. + if (masterWallet.address) { + const agentSigner = await this.#getAgentSigner(masterWallet.address); + if (agentSigner) { + return this.#walletService.createAgentWalletAdapter(agentSigner); + } } return masterWallet; } diff --git a/packages/perps-controller/src/services/HyperLiquidClientService.ts b/packages/perps-controller/src/services/HyperLiquidClientService.ts index 8cb6305e928..b4c8fc3714c 100644 --- a/packages/perps-controller/src/services/HyperLiquidClientService.ts +++ b/packages/perps-controller/src/services/HyperLiquidClientService.ts @@ -40,6 +40,24 @@ const maxReconnectionAttempts = 10; */ export type ValidCandleInterval = CandlePeriod; +/** + * Params-style EIP-712 typed-data signing request, as passed by the + * HyperLiquid SDK to wallet adapters (viem local-account shape). + */ +export type HyperLiquidSignTypedDataParams = { + domain: { + name: string; + version: string; + chainId: number; + verifyingContract: Hex; + }; + types: { + [key: string]: { name: string; type: string }[]; + }; + primaryType: string; + message: Record; +}; + /** * Wallet interface for HyperLiquid SDK operations. * Extracted for reuse across initialize(), toggleTestnet(), and ensureSubscriptionClient() methods. @@ -50,19 +68,7 @@ export type ValidCandleInterval = CandlePeriod; */ export type HyperLiquidWalletParams = { address?: Hex; - signTypedData: (params: { - domain: { - name: string; - version: string; - chainId: number; - verifyingContract: Hex; - }; - types: { - [key: string]: { name: string; type: string }[]; - }; - primaryType: string; - message: Record; - }) => Promise; + signTypedData: (params: HyperLiquidSignTypedDataParams) => Promise; getChainId?: () => Promise; }; @@ -397,9 +403,9 @@ export class HyperLiquidClientService { public isInitialized(): boolean { return Boolean( this.#exchangeClient && - this.#infoClient && - this.#infoClientHttp && - this.#subscriptionClient, + this.#infoClient && + this.#infoClientHttp && + this.#subscriptionClient, ); } diff --git a/packages/perps-controller/src/services/HyperLiquidWalletService.ts b/packages/perps-controller/src/services/HyperLiquidWalletService.ts index f61ce00fd98..192b127355f 100644 --- a/packages/perps-controller/src/services/HyperLiquidWalletService.ts +++ b/packages/perps-controller/src/services/HyperLiquidWalletService.ts @@ -7,6 +7,10 @@ import type { CaipAccountId, Hex } from '@metamask/utils'; import { getChainId } from '../constants/hyperLiquidConfig.js'; import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; +import type { + HyperLiquidSignTypedDataParams, + HyperLiquidWalletParams, +} from './HyperLiquidClientService.js'; import type { PerpsPlatformDependencies, PerpsTypedMessageParams, @@ -37,36 +41,21 @@ export type AgentSigner = { /** The agent account address used as the actor for signed actions. */ address: `0x${string}`; /** - * Sign EIP-712 typed data with the agent key. + * Sign EIP-712 typed data with the agent key (positional ethers-style + * arguments; `types` must not include `EIP712Domain`). * * @param domain - The EIP-712 domain. - * @param domain.name - The domain name. - * @param domain.version - The domain version. - * @param domain.chainId - The domain chain ID. - * @param domain.verifyingContract - The verifying contract address. * @param types - The EIP-712 type definitions (without `EIP712Domain`). * @param value - The message payload to sign. - * @returns The 65-byte hex signature. + * @returns The hex signature. */ signTypedData( - domain: { - name: string; - version: string; - chainId: number; - verifyingContract: `0x${string}`; - }, - types: { - [key: string]: { name: string; type: string }[]; - }, + domain: HyperLiquidSignTypedDataParams['domain'], + types: HyperLiquidSignTypedDataParams['types'], value: Record, ): Promise; }; -/** Options bag for {@link HyperLiquidWalletService}. */ -export type HyperLiquidWalletServiceOptions = { - isTestnet?: boolean; -}; - /** * Service for MetaMask wallet integration with HyperLiquid SDK * Provides wallet adapter that implements AbstractWindowEthereum interface @@ -82,7 +71,7 @@ export class HyperLiquidWalletService { constructor( deps: PerpsPlatformDependencies, messenger: PerpsControllerMessengerBase, - options: HyperLiquidWalletServiceOptions = {}, + options: { isTestnet?: boolean } = {}, ) { this.#deps = deps; this.#messenger = messenger; @@ -144,32 +133,10 @@ export class HyperLiquidWalletService { * Sign typed data with the master account via the keyring, resolving the * selected account fresh so account switches cannot race the adapter. * - * @param params - The typed data params the SDK passed to the adapter. - * @param params.domain - The EIP-712 domain. - * @param params.domain.name - The domain name. - * @param params.domain.version - The domain version. - * @param params.domain.chainId - The domain chain ID. - * @param params.domain.verifyingContract - The verifying contract address. - * @param params.types - The EIP-712 type definitions. - * @param params.primaryType - The EIP-712 primary type. - * @param params.message - The message payload to sign. + * @param params - The typed-data signing request the SDK passed to the adapter. * @returns The signature string. */ - async #signWithMaster( - params: { - domain: { - name: string; - version: string; - chainId: number; - verifyingContract: Hex; - }; - types: { - [key: string]: { name: string; type: string }[]; - }; - primaryType: string; - message: Record; - }, - ): Promise { + async #signWithMaster(params: HyperLiquidSignTypedDataParams): Promise { const currentEvmAccount = getSelectedEvmAccountFromMessenger( this.#messenger, ); @@ -240,41 +207,17 @@ export class HyperLiquidWalletService { * @param agentSigner - The local agent signer to delegate L1 actions to. * @returns The agent wallet adapter. */ - public createAgentWalletAdapter(agentSigner: AgentSigner): { - address: Hex; - signTypedData: (params: { - domain: { - name: string; - version: string; - chainId: number; - verifyingContract: Hex; - }; - types: { - [key: string]: { name: string; type: string }[]; - }; - primaryType: string; - message: Record; - }) => Promise; - getChainId?: () => Promise; - } { + public createAgentWalletAdapter( + agentSigner: AgentSigner, + ): HyperLiquidWalletParams { return { // The agent address is returned for identity purposes: the SDK only uses // it for local lock/nonce keying (`getWalletAddress`), never inside the // signed payload — HyperLiquid recovers the signer from the signature. address: agentSigner.address, - signTypedData: async (params: { - domain: { - name: string; - version: string; - chainId: number; - verifyingContract: Hex; - }; - types: { - [key: string]: { name: string; type: string }[]; - }; - primaryType: string; - message: Record; - }): Promise => { + signTypedData: async ( + params: HyperLiquidSignTypedDataParams, + ): Promise => { if (this.#isL1AgentAction(params.primaryType, params.domain?.name)) { const { EIP712Domain: _eip712Domain, ...types } = params.types; @@ -312,23 +255,7 @@ export class HyperLiquidWalletService { * * @returns The wallet adapter with address, signTypedData, and getChainId methods. */ - public createWalletAdapter(): { - address: Hex; - signTypedData: (params: { - domain: { - name: string; - version: string; - chainId: number; - verifyingContract: Hex; - }; - types: { - [key: string]: { name: string; type: string }[]; - }; - primaryType: string; - message: Record; - }) => Promise; - getChainId?: () => Promise; - } { + public createWalletAdapter(): HyperLiquidWalletParams { // Get current EVM account via DI messenger const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); @@ -340,19 +267,9 @@ export class HyperLiquidWalletService { return { address, - signTypedData: async (params: { - domain: { - name: string; - version: string; - chainId: number; - verifyingContract: Hex; - }; - types: { - [key: string]: { name: string; type: string }[]; - }; - primaryType: string; - message: Record; - }): Promise => this.#signWithMaster(params), + signTypedData: async ( + params: HyperLiquidSignTypedDataParams, + ): Promise => this.#signWithMaster(params), getChainId: async (): Promise => parseInt(getChainId(this.#isTestnet), 10), }; diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading-wallet-override.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading-wallet-override.test.ts index 74292b8d5cd..548d3ef4e0e 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading-wallet-override.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading-wallet-override.test.ts @@ -33,16 +33,15 @@ const MockedHyperLiquidSubscriptionService = const AGENT_ADDRESS = '0x2222222222222222222222222222222222222222' as const; const MASTER_ADDRESS = '0x1234567890123456789012345678901234567890' as const; -const createMockAgentAdapter = () => ({ - address: AGENT_ADDRESS, - signTypedData: jest.fn().mockResolvedValue('0xagentsig'), +const createMockAdapter = (address: string, signature: string) => ({ + address, + signTypedData: jest.fn().mockResolvedValue(signature), getChainId: jest.fn().mockResolvedValue(42161), }); -const createMockMasterAdapter = () => ({ - address: MASTER_ADDRESS, - signTypedData: jest.fn().mockResolvedValue('0xmastersig'), - getChainId: jest.fn().mockResolvedValue(42161), +const createAgentSigner = (signature = '0xagentsig'): AgentSigner => ({ + address: AGENT_ADDRESS, + signTypedData: jest.fn().mockResolvedValue(signature), }); describe('HyperLiquidProvider trading wallet override', () => { @@ -77,8 +76,12 @@ describe('HyperLiquidProvider trading wallet override', () => { } as unknown as jest.Mocked; mockWalletService = { - createWalletAdapter: jest.fn(() => createMockMasterAdapter()), - createAgentWalletAdapter: jest.fn(() => createMockAgentAdapter()), + createWalletAdapter: jest.fn(() => + createMockAdapter(MASTER_ADDRESS, '0xmastersig'), + ), + createAgentWalletAdapter: jest.fn(() => + createMockAdapter(AGENT_ADDRESS, '0xagentsig'), + ), setTestnetMode: jest.fn(), } as unknown as jest.Mocked; @@ -103,10 +106,7 @@ describe('HyperLiquidProvider trading wallet override', () => { }); it('uses getAgentSigner on first initialize when no override is set', async () => { - const agentSigner: AgentSigner = { - address: AGENT_ADDRESS, - signTypedData: jest.fn().mockResolvedValue('0xagentsig'), - }; + const agentSigner = createAgentSigner(); const getAgentSigner = jest.fn().mockResolvedValue(agentSigner); const provider = createTestProvider({ getAgentSigner }); @@ -133,10 +133,7 @@ describe('HyperLiquidProvider trading wallet override', () => { }); it('does not re-query getAgentSigner when the override is cleared', async () => { - const getAgentSigner = jest.fn().mockResolvedValue({ - address: AGENT_ADDRESS, - signTypedData: jest.fn().mockResolvedValue('0xagentsig'), - }); + const getAgentSigner = jest.fn().mockResolvedValue(createAgentSigner()); const provider = createTestProvider({ getAgentSigner }); await provider.setTradingWalletOverride(null); @@ -146,13 +143,10 @@ describe('HyperLiquidProvider trading wallet override', () => { }); it('reinitializes the client service with the agent adapter', async () => { - const agentSigner: AgentSigner = { - address: AGENT_ADDRESS, - signTypedData: jest.fn().mockResolvedValue('0xagentsig'), - }; + const agentSigner = createAgentSigner(); const provider = createTestProvider(); mockWalletService.createAgentWalletAdapter = jest.fn(() => - createMockAgentAdapter(), + createMockAdapter(AGENT_ADDRESS, '0xagentsig'), ); await provider.setTradingWalletOverride(agentSigner); @@ -179,10 +173,7 @@ describe('HyperLiquidProvider trading wallet override', () => { }); it('keeps the override for rebuilds after a network toggle', async () => { - const agentSigner: AgentSigner = { - address: AGENT_ADDRESS, - signTypedData: jest.fn().mockResolvedValue('0xagentsig'), - }; + const agentSigner = createAgentSigner(); const provider = createTestProvider(); await provider.setTradingWalletOverride(agentSigner); @@ -198,10 +189,7 @@ describe('HyperLiquidProvider trading wallet override', () => { }); it('applies concurrent overrides sequentially', async () => { - const firstSigner: AgentSigner = { - address: AGENT_ADDRESS, - signTypedData: jest.fn().mockResolvedValue('0xfirst'), - }; + const firstSigner = createAgentSigner('0xfirst'); const provider = createTestProvider(); await Promise.all([ diff --git a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.agent-signer.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.agent-signer.test.ts deleted file mode 100644 index c30f7a8ee6a..00000000000 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.agent-signer.test.ts +++ /dev/null @@ -1,343 +0,0 @@ -/* eslint-disable */ -/** - * Unit tests for the HyperLiquidWalletService agent-signer seam. - * - * Agent mode: `createAgentWalletAdapter` returns an adapter whose address is - * the agent's and whose `signTypedData` delegates L1 actions to the local - * signer — the keyring messenger is never contacted for those. - * - * Master mode: `createWalletAdapter` is a synchronous master-keyring factory. - * It does not look up an agent signer; the provider selects the agent adapter - * via `#buildWallet` / `setTradingWalletOverride`. - */ - -// Mock keyring-api to avoid import issues with definePattern -jest.mock('@metamask/keyring-api', () => ({ - isEvmAccountType: jest.fn((accountType: string) => - accountType?.startsWith('eip155:'), - ), -})); - -jest.mock('@metamask/utils', () => ({ - hasProperty: jest.fn((object: object, property: string) => - Object.prototype.hasOwnProperty.call(object, property), - ), - parseCaipAccountId: jest.fn((accountId: string) => { - const parts = accountId.split(':'); - return { - chainNamespace: parts[0], - chainReference: parts[1], - address: parts[2], - }; - }), - isValidHexAddress: jest.fn((address: string) => - /^0x[0-9a-fA-F]{40}$/.test(address), - ), -})); - -jest.mock('../../../src/constants/hyperLiquidConfig', () => ({ - getChainId: jest.fn((isTestnet: boolean) => (isTestnet ? '421614' : '42161')), -})); - -import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; -import type { AgentSigner } from '../../../src/services/HyperLiquidWalletService.js'; -import { - createMockInfrastructure, - createMockEvmAccount, - createMockMessenger, -} from '../../helpers/serviceMocks.js'; - -const AGENT_ADDRESS = '0x2222222222222222222222222222222222222222' as const; - -const typedDataParams = { - domain: { - name: 'HyperLiquid', - version: '1', - chainId: 42161, - verifyingContract: - '0x0000000000000000000000000000000000000000' as `0x${string}`, - }, - types: { - // The SDK's viem adapters inject this before calling params-style - // wallets; an ethers-style local signer must not receive it. - EIP712Domain: [ - { name: 'name', type: 'string' }, - { name: 'version', type: 'string' }, - { name: 'chainId', type: 'uint256' }, - { name: 'verifyingContract', type: 'address' }, - ], - Agent: [ - { name: 'agentAddress', type: 'address' }, - { name: 'nonce', type: 'uint64' }, - ], - }, - primaryType: 'Agent', - message: { - agentAddress: AGENT_ADDRESS, - nonce: 0, - }, -}; - -describe('HyperLiquidWalletService agent signer seam', () => { - let mockDeps: ReturnType; - let mockMessenger: ReturnType; - const mockEvmAccount = createMockEvmAccount(); - - beforeEach(() => { - jest.clearAllMocks(); - mockDeps = createMockInfrastructure(); - mockMessenger = createMockMessenger(); - }); - - const createAgentAdapter = (signTypedData: jest.Mock = jest.fn()) => { - const agentSigner: AgentSigner = { - address: AGENT_ADDRESS, - signTypedData, - }; - const service = new HyperLiquidWalletService(mockDeps, mockMessenger); - return { - service, - agentSigner, - adapter: service.createAgentWalletAdapter(agentSigner), - }; - }; - - describe('agent mode', () => { - it('returns an adapter whose address is the agent address', () => { - const { adapter } = createAgentAdapter(); - - expect(adapter.address).toBe(AGENT_ADDRESS); - }); - - it('delegates signing directly to the injected signer with no keyring call', async () => { - const signTypedData = jest.fn().mockResolvedValue('0xagentsig'); - const { adapter } = createAgentAdapter(signTypedData); - - const signature = await adapter.signTypedData(typedDataParams); - - expect(signature).toBe('0xagentsig'); - expect(signTypedData).toHaveBeenCalledTimes(1); - expect(mockMessenger.call).not.toHaveBeenCalledWith( - 'KeyringController:signTypedMessage', - expect.anything(), - expect.anything(), - ); - }); - - it('strips the injected EIP712Domain type before delegating', async () => { - const signTypedData = jest.fn().mockResolvedValue('0xagentsig'); - const { adapter } = createAgentAdapter(signTypedData); - - await adapter.signTypedData(typedDataParams); - - const [domain, types, value] = signTypedData.mock.calls[0]; - expect(domain).toBe(typedDataParams.domain); - expect(types).toEqual({ Agent: typedDataParams.types.Agent }); - expect(value).toEqual(typedDataParams.message); - }); - - it('signs with the agent adapter even when the keyring reports locked', async () => { - const signTypedData = jest.fn().mockResolvedValue('0xagentsig'); - const { adapter } = createAgentAdapter(signTypedData); - (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { - if ( - action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return [mockEvmAccount]; - } - if (action === 'KeyringController:getState') { - return { isUnlocked: false }; - } - return undefined; - }); - - const signature = await adapter.signTypedData(typedDataParams); - - expect(signature).toBe('0xagentsig'); - }); - }); - - describe('agent mode: user-signed action routing', () => { - // L1 action shape produced by the SDK's `signL1Action`: domain - // { name: "Exchange", ... } with primaryType "Agent". These are the only - // signatures the agent key may produce. - const l1ActionParams = { - domain: { - name: 'Exchange', - version: '1', - chainId: 1337, - verifyingContract: - '0x0000000000000000000000000000000000000000' as `0x${string}`, - }, - types: { - EIP712Domain: [ - { name: 'name', type: 'string' }, - { name: 'version', type: 'string' }, - { name: 'chainId', type: 'uint256' }, - { name: 'verifyingContract', type: 'address' }, - ], - Agent: [ - { name: 'source', type: 'string' }, - { name: 'connectionId', type: 'bytes32' }, - ], - }, - primaryType: 'Agent', - message: { source: 'a', connectionId: '0xabc123' }, - }; - - // User-signed action shape produced by the SDK's `signUserSignedAction` - // (e.g. `approveBuilderFee`): domain { name: - // "HyperliquidSignTransaction", ... }. These are master-account - // authorizations and must fall through to the master keyring path. - const userSignedParams = { - domain: { - name: 'HyperliquidSignTransaction', - version: '1', - chainId: 42161, - verifyingContract: - '0x0000000000000000000000000000000000000000' as `0x${string}`, - }, - types: { - EIP712Domain: [ - { name: 'name', type: 'string' }, - { name: 'version', type: 'string' }, - { name: 'chainId', type: 'uint256' }, - { name: 'verifyingContract', type: 'address' }, - ], - 'HyperliquidTransaction:ApproveBuilderFee': [ - { name: 'hyperliquidChain', type: 'string' }, - { name: 'maxFeeRate', type: 'string' }, - { name: 'builder', type: 'address' }, - { name: 'nonce', type: 'uint64' }, - ], - }, - primaryType: 'HyperliquidTransaction:ApproveBuilderFee', - message: { - hyperliquidChain: 'Mainnet', - maxFeeRate: '0.01%', - builder: '0x3333333333333333333333333333333333333333', - nonce: 1700000000000, - }, - }; - - it('signs Exchange-domain Agent actions with the agent signer and zero keyring calls', async () => { - const signTypedData = jest.fn().mockResolvedValue('0xagentsig'); - const { adapter } = createAgentAdapter(signTypedData); - - const signature = await adapter.signTypedData(l1ActionParams); - - expect(signature).toBe('0xagentsig'); - expect(signTypedData).toHaveBeenCalledTimes(1); - expect(mockMessenger.call).not.toHaveBeenCalledWith( - 'KeyringController:signTypedMessage', - expect.anything(), - expect.anything(), - ); - }); - - it('routes HyperliquidSignTransaction domain actions to the master keyring path', async () => { - const signTypedData = jest.fn().mockResolvedValue('0xagentsig'); - const { adapter } = createAgentAdapter(signTypedData); - - const signature = await adapter.signTypedData(userSignedParams); - - expect(signature).toBe('0xSignatureResult'); - expect(signTypedData).not.toHaveBeenCalled(); - expect(mockMessenger.call).toHaveBeenCalledWith( - 'KeyringController:signTypedMessage', - { - from: mockEvmAccount.address, - data: { - domain: userSignedParams.domain, - types: userSignedParams.types, - primaryType: userSignedParams.primaryType, - message: userSignedParams.message, - }, - }, - 'V4', - ); - }); - - it('signs unknown Exchange-domain shapes with the agent signer', async () => { - const signTypedData = jest.fn().mockResolvedValue('0xagentsig'); - const { adapter } = createAgentAdapter(signTypedData); - - await adapter.signTypedData({ - ...l1ActionParams, - primaryType: 'UsdClassTransfer', - message: { source: 'a' }, - }); - - expect(signTypedData).toHaveBeenCalledTimes(1); - expect(mockMessenger.call).not.toHaveBeenCalledWith( - 'KeyringController:signTypedMessage', - expect.anything(), - expect.anything(), - ); - }); - - it('keeps the agent address on the adapter for user-signed actions', () => { - const { adapter } = createAgentAdapter( - jest.fn().mockResolvedValue('0xagentsig'), - ); - - expect(adapter.address).toBe(AGENT_ADDRESS); - }); - }); - - describe('master mode', () => { - it('returns the keyring-backed adapter synchronously', () => { - const service = new HyperLiquidWalletService(mockDeps, mockMessenger); - - const adapter = service.createWalletAdapter(); - - expect(adapter).not.toBeInstanceOf(Promise); - expect(adapter.address).toBe(mockEvmAccount.address); - }); - - it('uses the master path when creating the default wallet adapter', async () => { - const service = new HyperLiquidWalletService(mockDeps, mockMessenger); - - const adapter = service.createWalletAdapter(); - const signature = await adapter.signTypedData(typedDataParams); - - expect(signature).toBe('0xSignatureResult'); - expect(mockMessenger.call).toHaveBeenCalledWith( - 'KeyringController:signTypedMessage', - expect.anything(), - 'V4', - ); - }); - - it('routes Exchange-domain Agent actions through the keyring in master mode', async () => { - const service = new HyperLiquidWalletService(mockDeps, mockMessenger); - - const adapter = service.createWalletAdapter(); - const signature = await adapter.signTypedData({ - ...typedDataParams, - domain: { - name: 'Exchange', - version: '1', - chainId: 1337, - verifyingContract: - '0x0000000000000000000000000000000000000000' as `0x${string}`, - }, - message: { source: 'a', connectionId: '0xabc123' }, - types: { - ...typedDataParams.types, - Agent: [ - { name: 'source', type: 'string' }, - { name: 'connectionId', type: 'bytes32' }, - ], - }, - }); - - expect(signature).toBe('0xSignatureResult'); - expect(mockMessenger.call).toHaveBeenCalledWith( - 'KeyringController:signTypedMessage', - expect.anything(), - 'V4', - ); - }); - }); -}); diff --git a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts index 0bbca55525d..2bcc6217b9e 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts @@ -47,12 +47,46 @@ jest.mock( import type { CaipAccountId } from '@metamask/utils'; import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; +import type { AgentSigner } from '../../../src/services/HyperLiquidWalletService.js'; import { createMockInfrastructure, createMockEvmAccount, createMockMessenger, } from '../../helpers/serviceMocks.js'; +const AGENT_ADDRESS = '0x2222222222222222222222222222222222222222' as const; + +// The SDK's viem adapters inject this before calling params-style wallets; an +// ethers-style local signer must not receive it. +const EIP712_DOMAIN_TYPES = [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, +]; + +const typedDataParams = { + domain: { + name: 'HyperLiquid', + version: '1', + chainId: 42161, + verifyingContract: + '0x0000000000000000000000000000000000000000' as `0x${string}`, + }, + types: { + EIP712Domain: EIP712_DOMAIN_TYPES, + Agent: [ + { name: 'agentAddress', type: 'address' }, + { name: 'nonce', type: 'uint64' }, + ], + }, + primaryType: 'Agent', + message: { + agentAddress: AGENT_ADDRESS, + nonce: 0, + }, +}; + describe('HyperLiquidWalletService', () => { let service: HyperLiquidWalletService; let mockDeps: ReturnType; @@ -277,6 +311,228 @@ describe('HyperLiquidWalletService', () => { freshAdapter.signTypedData(mockTypedDataParams), ).rejects.toThrow('Signing failed'); }); + + it('routes Exchange-domain Agent actions through the keyring in master mode', async () => { + const signature = await walletAdapter.signTypedData({ + ...typedDataParams, + domain: { + name: 'Exchange', + version: '1', + chainId: 1337, + verifyingContract: + '0x0000000000000000000000000000000000000000' as `0x${string}`, + }, + message: { source: 'a', connectionId: '0xabc123' }, + types: { + ...typedDataParams.types, + Agent: [ + { name: 'source', type: 'string' }, + { name: 'connectionId', type: 'bytes32' }, + ], + }, + }); + + expect(signature).toBe('0xSignatureResult'); + expect(mockMessenger.call).toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + expect.anything(), + 'V4', + ); + }); + }); + }); + + describe('agent signer seam', () => { + const createAgentAdapter = (signTypedData: jest.Mock = jest.fn()) => { + const agentSigner: AgentSigner = { + address: AGENT_ADDRESS, + signTypedData, + }; + const agentService = new HyperLiquidWalletService( + mockDeps, + mockMessenger, + ); + return { + service: agentService, + agentSigner, + adapter: agentService.createAgentWalletAdapter(agentSigner), + }; + }; + + describe('agent mode', () => { + it('returns an adapter whose address is the agent address', () => { + const { adapter } = createAgentAdapter(); + + expect(adapter.address).toBe(AGENT_ADDRESS); + }); + + it('delegates signing directly to the injected signer with no keyring call', async () => { + const signTypedData = jest.fn().mockResolvedValue('0xagentsig'); + const { adapter } = createAgentAdapter(signTypedData); + + const signature = await adapter.signTypedData(typedDataParams); + + expect(signature).toBe('0xagentsig'); + expect(signTypedData).toHaveBeenCalledTimes(1); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + expect.anything(), + expect.anything(), + ); + }); + + it('strips the injected EIP712Domain type before delegating', async () => { + const signTypedData = jest.fn().mockResolvedValue('0xagentsig'); + const { adapter } = createAgentAdapter(signTypedData); + + await adapter.signTypedData(typedDataParams); + + const [domain, types, value] = signTypedData.mock.calls[0]; + expect(domain).toBe(typedDataParams.domain); + expect(types).toEqual({ Agent: typedDataParams.types.Agent }); + expect(value).toEqual(typedDataParams.message); + }); + + it('signs with the agent adapter even when the keyring reports locked', async () => { + const signTypedData = jest.fn().mockResolvedValue('0xagentsig'); + const { adapter } = createAgentAdapter(signTypedData); + (mockMessenger.call as jest.Mock).mockImplementation( + (action: string) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [mockEvmAccount]; + } + if (action === 'KeyringController:getState') { + return { isUnlocked: false }; + } + return undefined; + }, + ); + + const signature = await adapter.signTypedData(typedDataParams); + + expect(signature).toBe('0xagentsig'); + }); + }); + + describe('agent mode: user-signed action routing', () => { + // L1 action shape produced by the SDK's `signL1Action`: domain + // { name: "Exchange", ... } with primaryType "Agent". These are the only + // signatures the agent key may produce. + const l1ActionParams = { + domain: { + name: 'Exchange', + version: '1', + chainId: 1337, + verifyingContract: + '0x0000000000000000000000000000000000000000' as `0x${string}`, + }, + types: { + EIP712Domain: EIP712_DOMAIN_TYPES, + Agent: [ + { name: 'source', type: 'string' }, + { name: 'connectionId', type: 'bytes32' }, + ], + }, + primaryType: 'Agent', + message: { source: 'a', connectionId: '0xabc123' }, + }; + + // User-signed action shape produced by the SDK's `signUserSignedAction` + // (e.g. `approveBuilderFee`): domain { name: + // "HyperliquidSignTransaction", ... }. These are master-account + // authorizations and must fall through to the master keyring path. + const userSignedParams = { + domain: { + name: 'HyperliquidSignTransaction', + version: '1', + chainId: 42161, + verifyingContract: + '0x0000000000000000000000000000000000000000' as `0x${string}`, + }, + types: { + EIP712Domain: EIP712_DOMAIN_TYPES, + 'HyperliquidTransaction:ApproveBuilderFee': [ + { name: 'hyperliquidChain', type: 'string' }, + { name: 'maxFeeRate', type: 'string' }, + { name: 'builder', type: 'address' }, + { name: 'nonce', type: 'uint64' }, + ], + }, + primaryType: 'HyperliquidTransaction:ApproveBuilderFee', + message: { + hyperliquidChain: 'Mainnet', + maxFeeRate: '0.01%', + builder: '0x3333333333333333333333333333333333333333', + nonce: 1700000000000, + }, + }; + + it('signs Exchange-domain Agent actions with the agent signer and zero keyring calls', async () => { + const signTypedData = jest.fn().mockResolvedValue('0xagentsig'); + const { adapter } = createAgentAdapter(signTypedData); + + const signature = await adapter.signTypedData(l1ActionParams); + + expect(signature).toBe('0xagentsig'); + expect(signTypedData).toHaveBeenCalledTimes(1); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + expect.anything(), + expect.anything(), + ); + }); + + it('routes HyperliquidSignTransaction domain actions to the master keyring path', async () => { + const signTypedData = jest.fn().mockResolvedValue('0xagentsig'); + const { adapter } = createAgentAdapter(signTypedData); + + const signature = await adapter.signTypedData(userSignedParams); + + expect(signature).toBe('0xSignatureResult'); + expect(signTypedData).not.toHaveBeenCalled(); + expect(mockMessenger.call).toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + { + from: mockEvmAccount.address, + data: { + domain: userSignedParams.domain, + types: userSignedParams.types, + primaryType: userSignedParams.primaryType, + message: userSignedParams.message, + }, + }, + 'V4', + ); + }); + + it('signs unknown Exchange-domain shapes with the agent signer', async () => { + const signTypedData = jest.fn().mockResolvedValue('0xagentsig'); + const { adapter } = createAgentAdapter(signTypedData); + + await adapter.signTypedData({ + ...l1ActionParams, + primaryType: 'UsdClassTransfer', + message: { source: 'a' }, + }); + + expect(signTypedData).toHaveBeenCalledTimes(1); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + expect.anything(), + expect.anything(), + ); + }); + + it('keeps the agent address on the adapter for user-signed actions', () => { + const { adapter } = createAgentAdapter( + jest.fn().mockResolvedValue('0xagentsig'), + ); + + expect(adapter.address).toBe(AGENT_ADDRESS); + }); }); });