diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 1e16dc40b7..15055a3e42 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 6cb437da97..70d869cb63 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -698,6 +698,16 @@ export type PerpsControllerClearAttributionContextAction = { handler: PerpsController['clearAttributionContext']; }; +/** + * 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`; + handler: PerpsController['setTradingWalletOverride']; +}; + /** * Toggle between testnet and mainnet * @@ -917,6 +927,17 @@ export type PerpsControllerApproveSubscriptionBuilderFeeAction = { handler: PerpsController['approveSubscriptionBuilderFee']; }; +/** + * 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`; + handler: PerpsController['prepareTradingWallet']; +}; + /** * Drop the cached subscription benefits snapshot. * @@ -1404,6 +1425,7 @@ export type PerpsControllerMethodActions = | PerpsControllerSetAttributionContextAction | PerpsControllerGetAttributionContextAction | PerpsControllerClearAttributionContextAction + | PerpsControllerSetTradingWalletOverrideAction | PerpsControllerToggleTestnetAction | PerpsControllerSwitchProviderAction | PerpsControllerGetCurrentNetworkAction @@ -1423,6 +1445,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 1cade00cac..2971068c51 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 = { @@ -955,6 +965,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'markTutorialCompleted', 'placeOrder', 'previewPositionModify', + 'prepareTradingWallet', 'reconnect', 'recordMarketViewed', 'refreshEligibility', @@ -975,6 +986,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'setLiveDataConfig', 'setSelectedPaymentToken', 'setVisibleCandleCount', + 'setTradingWalletOverride', 'startEligibilityMonitoring', 'startMarketDataPreload', 'stopEligibilityMonitoring', @@ -1150,6 +1162,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 +1203,7 @@ export class PerpsController extends BaseController< clientConfig = {}, infrastructure, deferEligibilityCheck = false, + getAgentSigner, }: PerpsControllerOptions) { super({ name: 'PerpsController', @@ -1194,6 +1213,7 @@ export class PerpsController extends BaseController< }); this.#eligibilityCheckDeferred = deferEligibilityCheck; + this.#getAgentSigner = getAgentSigner; // Store options for dependency injection this.#options = { @@ -1774,6 +1794,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 +2331,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 +5113,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 * @@ -5798,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 f29c824716..a8257159da 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, @@ -714,6 +715,7 @@ export { // Services (only externally consumed items) export { TradingReadinessCache } from './services/TradingReadinessCache.js'; export type { ServiceContext } from './services/ServiceContext.js'; +export type { AgentSigner } from './services/HyperLiquidWalletService.js'; export { AggregatedOrderBookConnection, processAggregatedOrderBook, diff --git a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts index 576a041ea5..6534bef829 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 f2132dd484..e8fa60b9ea 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,21 @@ 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(); + + // 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; @@ -1523,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; @@ -1924,39 +1945,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 +1970,112 @@ 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. + * + * 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. + */ + async #buildWallet( + signer?: AgentSigner | null, + ): Promise { + const effectiveSigner = + signer === undefined ? this.#agentSignerOverride : signer; + 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(); + // 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; + } + + 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. * @@ -13880,6 +13981,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/services/HyperLiquidClientService.ts b/packages/perps-controller/src/services/HyperLiquidClientService.ts index 4837427bf3..b4c8fc3714 100644 --- a/packages/perps-controller/src/services/HyperLiquidClientService.ts +++ b/packages/perps-controller/src/services/HyperLiquidClientService.ts @@ -40,24 +40,35 @@ 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. + * + * `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 = { - signTypedData: (params: { - domain: { - name: string; - version: string; - chainId: number; - verifyingContract: Hex; - }; - types: { - [key: string]: { name: string; type: string }[]; - }; - primaryType: string; - message: Record; - }) => Promise; + address?: Hex; + signTypedData: (params: HyperLiquidSignTypedDataParams) => Promise; getChainId?: () => Promise; }; @@ -392,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 ab0a7dad88..192b127355 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, @@ -27,6 +31,31 @@ 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 (positional ethers-style + * arguments; `types` must not include `EIP712Domain`). + * + * @param domain - The EIP-712 domain. + * @param types - The EIP-712 type definitions (without `EIP712Domain`). + * @param value - The message payload to sign. + * @returns The hex signature. + */ + signTypedData( + domain: HyperLiquidSignTypedDataParams['domain'], + types: HyperLiquidSignTypedDataParams['types'], + value: Record, + ): Promise; +}; + /** * Service for MetaMask wallet integration with HyperLiquid SDK * Provides wallet adapter that implements AbstractWindowEthereum interface @@ -101,28 +130,132 @@ export class HyperLiquidWalletService { } /** - * Create wallet adapter that implements AbstractViemJsonRpcAccount interface - * Required by @nktkas/hyperliquid SDK for signing transactions + * 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 signing request the SDK passed to the adapter. + * @returns The signature string. + */ + async #signWithMaster(params: HyperLiquidSignTypedDataParams): 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 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 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 L1 actions to. + * @returns The agent wallet adapter. + */ + 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: HyperLiquidSignTypedDataParams, + ): Promise => { + 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), + }; + } + + /** + * Create the master-keyring wallet adapter for the HyperLiquid SDK. + * + * 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 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); @@ -134,56 +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 => { - // 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; - }, + signTypedData: async ( + params: HyperLiquidSignTypedDataParams, + ): Promise => this.#signWithMaster(params), getChainId: async (): Promise => parseInt(getChainId(this.#isTestnet), 10), }; diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 6fb5dce406..743d3cbfa5 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 c4ccf99fe1..f21674c875 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 { 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 0000000000..548d3ef4e0 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading-wallet-override.test.ts @@ -0,0 +1,208 @@ +/* eslint-disable */ +/** + * Unit tests for HyperLiquidProvider.setTradingWalletOverride and the + * getAgentSigner lookup on first client initialization. + */ + +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 createMockAdapter = (address: string, signature: string) => ({ + address, + signTypedData: jest.fn().mockResolvedValue(signature), + getChainId: jest.fn().mockResolvedValue(42161), +}); + +const createAgentSigner = (signature = '0xagentsig'): AgentSigner => ({ + address: AGENT_ADDRESS, + signTypedData: jest.fn().mockResolvedValue(signature), +}); + +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(() => + createMockAdapter(MASTER_ADDRESS, '0xmastersig'), + ), + createAgentWalletAdapter: jest.fn(() => + createMockAdapter(AGENT_ADDRESS, '0xagentsig'), + ), + setTestnetMode: jest.fn(), + } as unknown as jest.Mocked; + + mockSubscriptionService = { + clearAll: jest.fn(), + } as unknown as jest.Mocked; + + MockedHyperLiquidClientService.mockImplementation(() => mockClientService); + MockedHyperLiquidWalletService.mockImplementation(() => mockWalletService); + MockedHyperLiquidSubscriptionService.mockImplementation( + () => mockSubscriptionService, + ); + }); + + 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).toBeUndefined(); + }); + + it('uses getAgentSigner on first initialize when no override is set', async () => { + const agentSigner = createAgentSigner(); + 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(createAgentSigner()); + 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 () => { + const agentSigner = createAgentSigner(); + const provider = createTestProvider(); + mockWalletService.createAgentWalletAdapter = jest.fn(() => + createMockAdapter(AGENT_ADDRESS, '0xagentsig'), + ); + + 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 = createAgentSigner(); + 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 = createAgentSigner('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/HyperLiquidWalletService.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts index 72fb99734b..2bcc6217b9 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; @@ -203,7 +237,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', @@ -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); + }); }); });