diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 6bbc53115f0..e56c4aae29b 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -4494,14 +4494,6 @@ "count": 3 } }, - "packages/perps-controller/tests/src/services/LighterWalletService.test.ts": { - "typescript/no-unsafe-assignment": { - "count": 1 - }, - "typescript/unbound-method": { - "count": 1 - } - }, "packages/perps-controller/tests/src/services/TerminalMarketService.test.ts": { "no-unsafe-optional-chaining": { "count": 1 diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index d7064e7f221..001d4d4b4f5 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -12,6 +12,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Persist the Isolated/Cross margin-mode pick per market and network in `tradeConfigurations[network][symbol].marginMode`, so clients can restore it after the order form remounts and share it across Mobile and Extension ([#10464](https://github.com/MetaMask/core/pull/10464)) - Add `getMarginMode(symbol)` and `saveMarginMode(symbol, marginMode)` methods, exposed as the `PerpsController:getMarginMode` and `PerpsController:saveMarginMode` messenger actions (`PerpsControllerGetMarginModeAction`, `PerpsControllerSaveMarginModeAction`). `saveMarginMode` ignores values other than `isolated` or `cross`. - Add the `selectMarginMode(state, symbol)` selector and an optional `marginMode` field on `TradeConfiguration`. +- Add optional `accountSigner` to `PerpsPlatformDependencies` so clients without a `KeyringController` can sign through their own wallet ([#10559](https://github.com/MetaMask/core/pull/10559)) + - Export the new `PerpsAccountSigner` and `PerpsTypedDataPayload` types + - When set, HyperLiquid typed-data signing and Lighter `personal_sign` go through it and never call the `KeyringController:*` messenger actions; the signing address still comes from the messenger's selected account + - `isReady()` returning `false` fails signing with the existing `KEYRING_LOCKED` error code + - `requiresSignatureConfirmation()` defers HyperLiquid's optional init-time signing prompts like a hardware keyring does; when omitted, the selected account's keyring type decides +- Add HyperLiquid agent signing so orders, cancels and other L1 actions are signed by a host-owned agent key instead of prompting the main wallet ([#10559](https://github.com/MetaMask/core/pull/10559)) + - Add optional `providerCredentials.hyperliquid.getAgentSigner(account)`, which resolves the approved agent (new exported `PerpsAgentSigner` and `PerpsAgentAccount` types) when an L1 action is signed for that main account and network, including the unified-account migration the provider may sign while connecting + - An agent `getAgentSigner` returns is kept for the provider's lifetime or until `setAgentSigner`/`clearAgentSigners`; `null` and failures are asked again at the next L1 action + - An agent whose signing throws fails that action with `KEYRING_LOCKED` and stays in use, so a host calls `clearAgentSigners` when its agent key locks + - Add `PerpsController:setAgentSigner(account, agentSigner)` (`PerpsControllerSetAgentSignerAction`) to bind an agent to an explicit main account and network, or pin that account to the main wallet with `null`; the controller keeps bindings across provider re-creation; `setAgentSigner()` can be called on the controller before `init`, and the messenger action is available once `init` has run + - Add `PerpsController:clearAgentSigners` (`PerpsControllerClearAgentSignersAction`) to forget every agent, for example when the wallet locks, so the next L1 action asks `getAgentSigner` again + - Add optional `PerpsProvider.clearAgentSigners`, implemented by the HyperLiquid provider + - An agent the venue rejects as unknown (revoked or expired, for example after the user approves another unnamed agent) is dropped, together with a `setAgentSigner` binding to it, so the next L1 action asks `getAgentSigner` again; the rejected action fails with `KEYRING_LOCKED` instead of `EXCHANGE_ACCOUNT_NOT_FOUND` + - Add optional `providerCredentials.hyperliquid.onAgentRejected(account, agentAddress)`, called with the agent's address as the client supplied it for each write the venue rejects with that agent, so the client can re-check its approval + - The exported `HyperLiquidProvider` accepts the matching optional `getAgentSigner` and `onAgentRejected` constructor options and implements `clearAgentSigners` + - An agent only ever signs for the main account and network it was set or resolved for, and user-signed actions (builder fee, withdraw, the user-signed migration from `dexAbstraction`, ...) always stay on the main account; approving the agent remains the client's job + - Export `HYPERLIQUID_L1_ACTION_PRIMARY_TYPE` and `HYPERLIQUID_L1_ACTION_DOMAIN_NAME`, the EIP-712 shape that marks an L1 action +- Add `PerpsController:prepareTradingWallet` (`PerpsControllerPrepareTradingWalletAction`) and optional `PerpsProvider.prepareTradingWallet` to run the deferred trading setup before the first order, so its signatures happen in a guided session: account migration, builder fee and referral on HyperLiquid, venue-key registration on Lighter ([#10559](https://github.com/MetaMask/core/pull/10559)) + - The builder fee, the migration from `dexAbstraction` and the Lighter registration are signed by the main account; with an agent, the HyperLiquid referral and silent migration are signed by the agent + - Resolves a `ReadyToTradeResult` that is `ready: true` once an account is selected, the main-account signer is ready and none of these steps will need a signature again before the first order, and `ready: false` while one will be retried, including after an agent could not sign; `ready: false` carries `KEYRING_LOCKED` while the signer is not ready, `EXCHANGE_ACCOUNT_NOT_FOUND` for a wallet with no account on the venue yet, `NO_ACCOUNT_SELECTED`, `PROVIDER_LIFECYCLE_STALE` when the provider or account changed during setup, or the message of the logged error that stopped setup; the aggregated provider prepares every provider in turn + - A HyperLiquid referral whose MetaMask referral code is not ready yet does not hold the result back; the next `prepareTradingWallet` checks the code again, and orders do not + - Implemented by the exported `HyperLiquidProvider` and by the Lighter provider, which resolves `ready: true` at once when it is read-only (no signer bridge), an account is selected and the main-account signer is ready +- Add optional `isTestnet` to `AggregatedProviderConfig`, which tags the errors the aggregated provider logs with the network ([#10559](https://github.com/MetaMask/core/pull/10559)) + +### Removed + +- **BREAKING:** Remove the `LighterPersonalSigner` type and the `personalSigner` and `l1Address` fields of `LighterAuthConfig` ([#10559](https://github.com/MetaMask/core/pull/10559)) + - `PerpsController` never forwarded these fields to the Lighter provider, so they had no effect for controller clients + - To sign Lighter L1 messages without a `KeyringController`, set `PerpsPlatformDependencies.accountSigner.signPersonalMessage`; the L1 address comes from the messenger's selected account + +### Fixed + +- HyperLiquid writes that fail because the keyring is locked now fail with `KEYRING_LOCKED` and are no longer reported as errors by the provider or `TradingService` ([#10559](https://github.com/MetaMask/core/pull/10559)) + - Before, they failed with the SDK's "Failed to sign the typed data using the wallet" message, or with `TPSL_UPDATE_FAILED` for a TP/SL update whose builder fee was not approved yet + - Covers orders, edits, single and batch cancels (TWAP, scale and chase cancels included), position closes, TP/SL updates and clears, margin updates, withdrawals and transfers between DEXs, including the HIP-3 transfers around an order +- HyperLiquid `cancelOrders` reports each order of a batch with its own result when an entry fails: orders the venue cancelled are no longer reported as failed with the batch's error ([#10559](https://github.com/MetaMask/core/pull/10559)) ## [18.0.1] diff --git a/packages/perps-controller/README.md b/packages/perps-controller/README.md index 40d4ff06f34..1bf4e25ee94 100644 --- a/packages/perps-controller/README.md +++ b/packages/perps-controller/README.md @@ -66,6 +66,47 @@ an asset with an open position, resting order, or active native TWAP schedule, including schedules whose first slice has not filled. Orders in the same mode may increase or reduce the existing position. +## Signing without a `KeyringController` + +By default the controller signs through the `KeyringController:*` messenger +actions. A client without a keyring passes `accountSigner` in its platform +dependencies (`signTypedData`, `signPersonalMessage`, optional `isReady` and +`requiresSignatureConfirmation`); the signing address still comes from the +selected account, and a signer that is not ready fails with `KEYRING_LOCKED`. + +HyperLiquid L1 actions (orders, cancels, leverage, ...) can be signed by a +client-owned agent key: return it from +`providerCredentials.hyperliquid.getAgentSigner(account)`, or bind it to an +account and network with `PerpsController:setAgentSigner`. User-signed actions +(builder fee, withdrawals) stay on the main account, and approving the agent +is the client's job. When the venue rejects an agent (revoked or expired), the +write fails with `KEYRING_LOCKED`, the agent is dropped and +`providerCredentials.hyperliquid.onAgentRejected` is called. Call +`PerpsController:clearAgentSigners` when the agent key locks. + +`PerpsController:prepareTradingWallet` runs the setup that needs signatures +(HyperLiquid account migration, builder fee and referral; Lighter key +registration) before the first order, so a hardware or external wallet signs +it in one guided session. + +## Lighter trading keys + +Lighter orders are not signed by the wallet. A Lighter account (owned by the +wallet's address) holds trading keys, called API keys, in numbered slots. The +client's signer bridge generates the key for the slot set in +`providerCredentials.lighter.apiKeyIndex` (default `7`) and keeps its private +half on the device. The wallet signs one `personal_sign` message to register it +in that slot, during `PerpsController:prepareTradingWallet` or before the first +order; after that, orders are signed with the key and need no wallet prompt. + +A key only works where it was generated, so give each device or app instance +its own slot. When the slot already holds a key this signer did not create, +the provider stops with "Lighter API key slot N already contains a different +key" instead of replacing it, since that key may still be in use elsewhere. Use +a free slot instead: the Lighter API answers "api key not found" for +`GET /api/v1/apikeys?account_index=&api_key_index=` when the +slot is free. + ## Contributing This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index 1271dd001d6..da5658fc638 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -905,6 +905,75 @@ export type PerpsControllerCalculateFeesAction = { handler: PerpsController['calculateFees']; }; +/** + * Sign HyperLiquid L1 actions (orders, cancels, leverage, ...) for a main + * account on a network with an approved agent, or pin them to the main + * account with null (`getAgentSigner` is then not asked for that account and + * network until `clearAgentSigners`). User-signed actions stay on the main + * account, and the agent is never used for another account or network. The + * controller keeps the binding across provider re-creation (a provider or + * network switch, or re-initialization), so it can also be set before + * `init`. Like every controller action, it is available through the + * messenger once `init` has run. + * + * @param account - The main account and network the agent is approved for. + * @param agentSigner - The host-owned agent signer, or null to pin the main + * account. + */ +export type PerpsControllerSetAgentSignerAction = { + type: `PerpsController:setAgentSigner`; + handler: PerpsController['setAgentSigner']; +}; + +/** + * Forget every HyperLiquid agent, set or resolved, so the next L1 action + * asks `providerCredentials.hyperliquid.getAgentSigner` again; an answer + * still pending is discarded too. Call it when the wallet locks (with + * `getAgentSigner` returning null while locked) and nothing signs with an + * agent until it returns one again. Like every controller action, it is + * available through the messenger once `init` has run. + */ +export type PerpsControllerClearAgentSignersAction = { + type: `PerpsController:clearAgentSigners`; + handler: PerpsController['clearAgentSigners']; +}; + +/** + * Run the active provider's deferred trading setup ahead of the first order + * (HyperLiquid account migration, builder fee and referral; Lighter + * venue-key registration), so its signatures happen in one guided session, + * such as agent setup, instead of at order time. The builder fee, the + * migration from `dexAbstraction` and Lighter's registration are signed by + * the main account; with an agent, the referral and the silent migration + * are L1 actions the agent signs. + * + * @returns `ready: true` when none of these steps will need a signature + * again before the first order, and only while an account is selected and + * the main account can sign, whichever provider answered (including + * providers without deferred setup, for example in aggregated mode). A + * declined HyperLiquid migration is not asked again, and a HyperLiquid + * referral whose MetaMask referral code is not ready yet is checked again at + * the next call, not before orders, so neither holds it back. Otherwise + * `ready: false`, without an error while a step will be asked again (a + * declined builder fee or Lighter registration, or a step the agent could + * not sign), or with: + * - `KEYRING_LOCKED` when the main account cannot sign, before or during + * setup; + * - `EXCHANGE_ACCOUNT_NOT_FOUND` for a wallet with no account on the venue + * yet; + * - `NO_ACCOUNT_SELECTED` when no account is selected; + * - `PROVIDER_LIFECYCLE_STALE` when the provider disconnected or the account + * changed during setup; + * - otherwise the message of the error that stopped setup, which is logged. + * @throws Like the other provider-backed actions, `CLIENT_NOT_INITIALIZED` + * before `init`, and `CLIENT_REINITIALIZING` or `PROVIDER_NOT_AVAILABLE` + * when no active provider is available. + */ +export type PerpsControllerPrepareTradingWalletAction = { + type: `PerpsController:prepareTradingWallet`; + handler: PerpsController['prepareTradingWallet']; +}; + /** * Approve the dedicated subscription builder outside order submission. * @@ -1453,6 +1522,9 @@ export type PerpsControllerMethodActions = | PerpsControllerSubscribeToOICapsAction | PerpsControllerSetLiveDataConfigAction | PerpsControllerCalculateFeesAction + | PerpsControllerSetAgentSignerAction + | PerpsControllerClearAgentSignersAction + | PerpsControllerPrepareTradingWalletAction | PerpsControllerApproveSubscriptionBuilderFeeAction | PerpsControllerInvalidateSubscriptionBenefitsAction | PerpsControllerDisconnectAction diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index cb33e71c0c8..7e538d99f0e 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -39,7 +39,6 @@ import type { import { PERPS_CONSTANTS, MARKET_SORTING_CONFIG, - PROVIDER_CONFIG, buildProviderCacheKey, MAX_SLIPPAGE_BOUNDS, DEFAULT_PERPS_MODE, @@ -52,11 +51,14 @@ import { PERPS_ERROR_CODES } from './perpsErrorCodes.js'; import { AggregatedPerpsProvider } from './providers/AggregatedPerpsProvider.js'; import { HyperLiquidProvider } from './providers/HyperLiquidProvider.js'; import { AccountService } from './services/AccountService.js'; +import { isMainAccountSignerReady } from './services/accountSigner.js'; +import { AgentBindings } from './services/agentSigner.js'; 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 { MarketDataService } from './services/MarketDataService.js'; +import { isProviderOnTestnet } from './services/providerNetwork.js'; import { RewardsIntegrationService } from './services/RewardsIntegrationService.js'; import type { ServiceContext } from './services/ServiceContext.js'; import { TerminalMarketService } from './services/TerminalMarketService.js'; @@ -131,6 +133,7 @@ import type { SubscribeTwapOrdersParams, SubscribePositionsParams, SubscribePricesParams, + ReadyToTradeResult, SwitchProviderResult, ToggleTestnetResult, TwapOrder, @@ -141,6 +144,8 @@ import type { GetHistoricalPortfolioParams, HistoricalPortfolioResult, OrderType, + PerpsAgentAccount, + PerpsAgentSigner, PerpsPlatformDependencies, PerpsLogger, PerpsActiveProviderMode, @@ -896,6 +901,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'calculateMaintenanceMargin', 'cancelOrder', 'cancelOrders', + 'clearAgentSigners', 'clearAttributionContext', 'clearDepositResult', 'clearPendingTradeConfiguration', @@ -958,6 +964,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'markFirstOrderCompleted', 'markTutorialCompleted', 'placeOrder', + 'prepareTradingWallet', 'previewPositionModify', 'reconnect', 'recordMarketViewed', @@ -976,6 +983,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'saveOrderBookGrouping', 'savePendingTradeConfiguration', 'saveTradeConfiguration', + 'setAgentSigner', 'setAttributionContext', 'setLiveDataConfig', 'setSelectedPaymentToken', @@ -1127,6 +1135,10 @@ export class PerpsController extends BaseController< #handlersRegistered = false; + // HyperLiquid agent bindings made through setAgentSigner, kept across + // provider instances; they answer before the host's getAgentSigner. + readonly #agentBindings: AgentBindings; + #standaloneProviderIsTestnet: boolean | null = null; #standaloneProviderHip3Version: number | null = null; @@ -1207,6 +1219,9 @@ export class PerpsController extends BaseController< clientConfig, infrastructure, }; + this.#agentBindings = new AgentBindings( + clientConfig?.providerCredentials?.hyperliquid?.getAgentSigner, + ); // Instantiate services with platform dependencies // Services that need cross-controller access receive the messenger @@ -2361,6 +2376,14 @@ export class PerpsController extends BaseController< this.#options.clientConfig?.providerCredentials?.hyperliquid ?.subscriptionBuilderAddressMainnet, onChaseOrderMaxDistanceReached: this.#publishChaseOrderMaxDistanceReached, + getAgentSigner: this.#agentBindings.resolve, + onAgentRejected: (account, agentAddress): void => { + this.#agentBindings.release(account, agentAddress); + this.#options.clientConfig?.providerCredentials?.hyperliquid?.onAgentRejected?.( + account, + agentAddress, + ); + }, }); this.providers.set('hyperliquid', hyperLiquidProvider); @@ -2404,8 +2427,10 @@ export class PerpsController extends BaseController< signerBridge?: LighterSignerBridge; }) => PerpsProvider, ): void { - const lighterIsTestnet = - PROVIDER_CONFIG.LIGHTER_TESTNET_ONLY || this.state.isTestnet; + const lighterIsTestnet = isProviderOnTestnet( + 'lighter', + this.state.isTestnet, + ); const lighter = this.#options.clientConfig?.providerCredentials?.lighter ?? {}; const lighterProvider = new LighterProviderClass({ @@ -2469,6 +2494,7 @@ export class PerpsController extends BaseController< providers: this.providers, defaultProvider: 'hyperliquid', infrastructure: this.#options.infrastructure, + isTestnet: this.state.isTestnet, }); this.#debugLog( 'PerpsController: Using aggregated provider (multi-provider)', @@ -5852,6 +5878,126 @@ export class PerpsController extends BaseController< return this.#marketDataService.calculateFees({ provider, params, context }); } + /** + * Sign HyperLiquid L1 actions (orders, cancels, leverage, ...) for a main + * account on a network with an approved agent, or pin them to the main + * account with null (`getAgentSigner` is then not asked for that account and + * network until `clearAgentSigners`). User-signed actions stay on the main + * account, and the agent is never used for another account or network. The + * controller keeps the binding across provider re-creation (a provider or + * network switch, or re-initialization), so it can also be set before + * `init`. Like every controller action, it is available through the + * messenger once `init` has run. + * + * @param account - The main account and network the agent is approved for. + * @param agentSigner - The host-owned agent signer, or null to pin the main + * account. + */ + setAgentSigner( + account: PerpsAgentAccount, + agentSigner: PerpsAgentSigner | null, + ): void { + this.#agentBindings.set(account, agentSigner); + // Drop agents the providers already resolved so the binding applies to + // the next L1 action. + this.#clearProviderAgentSigners(); + } + + /** + * Forget every HyperLiquid agent, set or resolved, so the next L1 action + * asks `providerCredentials.hyperliquid.getAgentSigner` again; an answer + * still pending is discarded too. Call it when the wallet locks (with + * `getAgentSigner` returning null while locked) and nothing signs with an + * agent until it returns one again. Like every controller action, it is + * available through the messenger once `init` has run. + */ + clearAgentSigners(): void { + this.#agentBindings.clear(); + this.#clearProviderAgentSigners(); + } + + /** + * Drop the agents every provider resolved. + */ + #clearProviderAgentSigners(): void { + for (const provider of this.providers.values()) { + provider.clearAgentSigners?.(); + } + } + + /** + * Run the active provider's deferred trading setup ahead of the first order + * (HyperLiquid account migration, builder fee and referral; Lighter + * venue-key registration), so its signatures happen in one guided session, + * such as agent setup, instead of at order time. The builder fee, the + * migration from `dexAbstraction` and Lighter's registration are signed by + * the main account; with an agent, the referral and the silent migration + * are L1 actions the agent signs. + * + * @returns `ready: true` when none of these steps will need a signature + * again before the first order, and only while an account is selected and + * the main account can sign, whichever provider answered (including + * providers without deferred setup, for example in aggregated mode). A + * declined HyperLiquid migration is not asked again, and a HyperLiquid + * referral whose MetaMask referral code is not ready yet is checked again at + * the next call, not before orders, so neither holds it back. Otherwise + * `ready: false`, without an error while a step will be asked again (a + * declined builder fee or Lighter registration, or a step the agent could + * not sign), or with: + * - `KEYRING_LOCKED` when the main account cannot sign, before or during + * setup; + * - `EXCHANGE_ACCOUNT_NOT_FOUND` for a wallet with no account on the venue + * yet; + * - `NO_ACCOUNT_SELECTED` when no account is selected; + * - `PROVIDER_LIFECYCLE_STALE` when the provider disconnected or the account + * changed during setup; + * - otherwise the message of the error that stopped setup, which is logged. + * @throws Like the other provider-backed actions, `CLIENT_NOT_INITIALIZED` + * before `init`, and `CLIENT_REINITIALIZING` or `PROVIDER_NOT_AVAILABLE` + * when no active provider is available. + */ + async prepareTradingWallet(): Promise { + const provider = await this.#getActiveProviderWhenReady(); + // With nothing selected, the AccountsController answers an empty account. + const readSelectedAddress = (): string | undefined => { + const address = getSelectedEvmAccountFromMessenger( + this.messenger, + )?.address; + return address ? address.toLowerCase() : undefined; + }; + const addressAtStart = readSelectedAddress(); + const result = (await provider.prepareTradingWallet?.()) ?? { + ready: true, + }; + const address = readSelectedAddress(); + // The steps ran for the account selected when they started (in aggregated + // mode, one provider after another), so their result is not the current + // account's. + if (address !== addressAtStart) { + return { + ready: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + }; + } + if (!result.ready) { + return result; + } + // A provider with nothing to prepare, alone or aggregated, checks neither + // the signer nor the selected account. + if ( + !isMainAccountSignerReady( + this.#options.infrastructure.accountSigner, + () => this.messenger.call('KeyringController:getState').isUnlocked, + ) + ) { + return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; + } + if (!address) { + return { ready: false, error: PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED }; + } + return result; + } + /** * Approve the dedicated subscription builder outside order submission. * diff --git a/packages/perps-controller/src/constants/hyperLiquidConfig.ts b/packages/perps-controller/src/constants/hyperLiquidConfig.ts index 16f55ee8f1c..426b1843e98 100644 --- a/packages/perps-controller/src/constants/hyperLiquidConfig.ts +++ b/packages/perps-controller/src/constants/hyperLiquidConfig.ts @@ -182,6 +182,18 @@ export const HIP3_FEE_CONFIG = { FeeMultiplier: 2, } as const; +/** + * EIP-712 primary type of every HyperLiquid L1 action (orders, cancels, + * leverage, ...). Only L1 actions may be signed by an agent; every other + * request is a user-signed action for the main account. + */ +export const HYPERLIQUID_L1_ACTION_PRIMARY_TYPE = 'Agent'; + +/** + * EIP-712 domain name of every HyperLiquid L1 action. + */ +export const HYPERLIQUID_L1_ACTION_DOMAIN_NAME = 'Exchange'; + const BUILDER_FEE_MAX_FEE_DECIMAL = 0.001; // Builder fee configuration diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 75afd7d8060..1b299fd83ec 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -71,6 +71,7 @@ export type { PerpsControllerClearWithdrawResultAction, PerpsControllerClosePositionAction, PerpsControllerClosePositionsAction, + PerpsControllerClearAgentSignersAction, PerpsControllerClearAttributionContextAction, PerpsControllerCompleteWithdrawalFromHistoryAction, PerpsControllerDepositWithConfirmationAction, @@ -127,6 +128,7 @@ export type { PerpsControllerMarkFirstOrderCompletedAction, PerpsControllerMarkTutorialCompletedAction, PerpsControllerPlaceOrderAction, + PerpsControllerPrepareTradingWalletAction, PerpsControllerReconnectAction, PerpsControllerRecordMarketViewedAction, PerpsControllerRefreshEligibilityAction, @@ -142,6 +144,7 @@ export type { PerpsControllerSaveOrderBookGroupingAction, PerpsControllerSavePendingTradeConfigurationAction, PerpsControllerSaveTradeConfigurationAction, + PerpsControllerSetAgentSignerAction, PerpsControllerSetAttributionContextAction, PerpsControllerSetLiveDataConfigAction, PerpsControllerSetSelectedPaymentTokenAction, @@ -331,6 +334,10 @@ export type { PerpsPerformance, PerpsTracer, PerpsTypedMessageParams, + PerpsTypedDataPayload, + PerpsAccountSigner, + PerpsAgentAccount, + PerpsAgentSigner, PerpsTransactionParams, PerpsAddTransactionOptions, PerpsInternalAccount, @@ -457,6 +464,8 @@ export { FEE_RATES, HIP3_FEE_CONFIG, BUILDER_FEE_CONFIG, + HYPERLIQUID_L1_ACTION_DOMAIN_NAME, + HYPERLIQUID_L1_ACTION_PRIMARY_TYPE, REFERRAL_CONFIG, DEPOSIT_CONFIG, HYPERLIQUID_WITHDRAWAL_MINUTES, @@ -502,7 +511,6 @@ export type { LighterWebSocketLike, LighterWasmCall, LighterAuthConfig, - LighterPersonalSigner, } from './types/lighter-types.js'; export { PERPS_CONSTANTS, diff --git a/packages/perps-controller/src/perpsErrorCodes.ts b/packages/perps-controller/src/perpsErrorCodes.ts index 94a8f3bd889..0f599f78e63 100644 --- a/packages/perps-controller/src/perpsErrorCodes.ts +++ b/packages/perps-controller/src/perpsErrorCodes.ts @@ -94,6 +94,9 @@ export const PERPS_ERROR_CODES = { SUBSCRIPTION_CLIENT_NOT_AVAILABLE: 'SUBSCRIPTION_CLIENT_NOT_AVAILABLE', // Wallet/account errors NO_ACCOUNT_SELECTED: 'NO_ACCOUNT_SELECTED', + // The signer could not sign: a locked keyring or account signer, or, with + // HyperLiquid agent signing, an agent that is unavailable or that the venue + // rejected. Retryable. KEYRING_LOCKED: 'KEYRING_LOCKED', INVALID_ADDRESS_FORMAT: 'INVALID_ADDRESS_FORMAT', // Wallet has no account on the exchange yet (HyperLiquid creates accounts diff --git a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts index d6f94f9b18e..af62711fa00 100644 --- a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts +++ b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts @@ -17,8 +17,10 @@ import type { CaipAccountId } from '@metamask/utils'; import { SubscriptionMultiplexer } from '../aggregation/SubscriptionMultiplexer.js'; +import { PERPS_CONSTANTS } from '../constants/perpsConfig.js'; import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; import { ProviderRouter } from '../routing/ProviderRouter.js'; +import { isProviderOnTestnet } from '../services/providerNetwork.js'; import { WebSocketConnectionState } from '../types/index.js'; import type { AccountState, @@ -94,6 +96,7 @@ import type { PerpsReadOptions, PerpsFeeResolution, } from '../types/index.js'; +import { ensureError } from '../utils/errorUtils.js'; /** Error returned when only some providers suspend their Chase orders. */ export class ChaseOrderSuspensionError extends Error { @@ -156,6 +159,8 @@ export class AggregatedPerpsProvider implements PerpsProvider { readonly #deps: PerpsPlatformDependencies; + readonly #isTestnet: boolean | undefined; + readonly #router: ProviderRouter; readonly #subscriptionMux: SubscriptionMultiplexer; @@ -165,6 +170,7 @@ export class AggregatedPerpsProvider implements PerpsProvider { this.#defaultProvider = config.defaultProvider; this.#aggregationMode = config.aggregationMode ?? 'all'; this.#deps = config.infrastructure; + this.#isTestnet = config.isTestnet; // Initialize router with default provider this.#router = new ProviderRouter({ @@ -1047,6 +1053,53 @@ export class AggregatedPerpsProvider implements PerpsProvider { return this.#getDefaultProvider().isReadyToTrade(); } + /** + * Prepare every provider in turn, so a hardware wallet sees one prompt at a + * time. A provider that throws is logged with its provider ID and counts as + * not ready. + * + * @returns The not-ready result of the first provider, in registration + * order, that is not ready; else ready. + */ + async prepareTradingWallet(): Promise { + let notReady: ReadyToTradeResult | undefined; + for (const [providerId, provider] of this.#getActiveProviders()) { + let result: ReadyToTradeResult | undefined; + try { + result = await provider.prepareTradingWallet?.(); + } catch (caughtError) { + // Providers report their own failures, so a throw here is unexpected. + const error = ensureError( + caughtError, + 'AggregatedPerpsProvider.prepareTradingWallet', + ); + // The provider's own network: Lighter can be pinned to testnet. + const isProviderTestnet = isProviderOnTestnet( + providerId, + this.#isTestnet, + ); + this.#deps.logger.error(error, { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: providerId, + ...(isProviderTestnet !== undefined && { + network: isProviderTestnet ? 'testnet' : 'mainnet', + }), + }, + context: { + name: 'AggregatedPerpsProvider', + data: { method: 'prepareTradingWallet', providerId }, + }, + }); + result = { ready: false, error: error.message }; + } + if (result && !result.ready) { + notReady ??= result; + } + } + return notReady ?? { ready: true }; + } + async disconnect(): Promise { // Disconnect all providers const results = await Promise.allSettled( diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 1f6f35d168b..9618267ba05 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -48,6 +48,11 @@ import { import { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from '../constants/transactionsHistoryConfig.js'; import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; import type { PerpsErrorCode } from '../perpsErrorCodes.js'; +import { + AgentSignerUnavailableError, + getAgentAccountKey, + isAgentSignerUnavailableError, +} from '../services/agentSigner.js'; import { DexDiscoveryCacheManager } from '../services/DexDiscoveryCacheManager.js'; import { HyperLiquidClientService, @@ -107,7 +112,10 @@ import type { GetSupportedPathsParams, GetUserDataSnapshotParams, HistoricalPortfolioResult, + HyperLiquidCredentials, InitializeResult, + PerpsAgentAccount, + PerpsAgentSigner, PerpsPlatformDependencies, PerpsProvider, PerpsProviderType, @@ -434,6 +442,9 @@ const getCancelStatusesFromError = ( return response.data.statuses; }; +// The address in HyperLiquid's "User or API Wallet 0x... does not exist." +const UNKNOWN_WALLET_ADDRESS_PATTERN = /user or api wallet (0x[0-9a-f]{40})/iu; + /** * Exchange messages that mean a cancel was refused because the order is not on * the book any more. @@ -547,6 +558,9 @@ type CancelOrderBatchOutcome = { remainingOrderIds: number[]; cancelledOrderIds: number[]; responseComplete: boolean; + // KEYRING_LOCKED when the signer could not sign the request. The venue + // reports it per entry, so entries it did cancel still count as cancelled. + signerFailure?: Error; }; type OrderPlacementOutcome = { @@ -836,6 +850,9 @@ type HyperLiquidProviderOptions = { subscriptionBuilderAddressTestnet?: string; subscriptionBuilderAddressMainnet?: string; onChaseOrderMaxDistanceReached?: ChaseOrderMaxDistanceReachedHandler; + getAgentSigner?: HyperLiquidCredentials['getAgentSigner']; + // Told when the venue rejects a resolved agent (revoked or expired). + onAgentRejected?: HyperLiquidCredentials['onAgentRejected']; }; type HandleHip3PreOrderParams = { @@ -1536,6 +1553,34 @@ export class HyperLiquidProvider implements PerpsProvider { // Track whether clients have been initialized (lazy initialization) #clientsInitialized = false; + readonly #getAgentSigner: HyperLiquidCredentials['getAgentSigner']; + + // Incremented by clearAgentSigners so answers pending across a clear are + // discarded and asked again. + #agentSignersGeneration = 0; + + // The getAgentSigner answer per network and main account (see + // getAgentAccountKey), pending or non-null, so an agent is only used for its + // account and network, and the agent it resolved to. An entry is dropped + // when the venue rejects that agent. + readonly #agentSigners = new Map< + string, + { answer: Promise; agent?: PerpsAgentSigner } + >(); + + // The account and network each agent last signed an L1 action for, keyed by + // the lowercased address the venue reports. A rejection is attributed from + // it rather than from the selected account, which may have changed while + // the write was in flight; an L1 signature names no user, so one agent acts + // for one account. Kept across clearAgentSigners, so an agent replaced while + // its action was in flight is still recognized. + readonly #agentSignedFor = new Map< + string, + { key: string; account: PerpsAgentAccount; agentAddress: Hex } + >(); + + readonly #onAgentRejected: HyperLiquidProviderOptions['onAgentRejected']; + // Promise-based lock to prevent race conditions in concurrent initialization #initializationPromise: Promise | null = null; @@ -1570,6 +1615,8 @@ export class HyperLiquidProvider implements PerpsProvider { options.subscriptionBuilderAddressTestnet; this.#subscriptionBuilderAddressMainnet = options.subscriptionBuilderAddressMainnet; + this.#getAgentSigner = options.getAgentSigner; + this.#onAgentRejected = options.onAgentRejected; this.#onChaseOrderMaxDistanceReached = options.onChaseOrderMaxDistanceReached; this.#priceDeviationLimit = @@ -1599,6 +1646,10 @@ export class HyperLiquidProvider implements PerpsProvider { this.#messenger, { isTestnet, + resolveAgent: async ( + mainAddress: Hex, + ): Promise => + await this.#resolveAgentSigner(mainAddress), }, ); this.#subscriptionService = new HyperLiquidSubscriptionService( @@ -2024,6 +2075,239 @@ export class HyperLiquidProvider implements PerpsProvider { } } + /** + * Resolve the agent that signs L1 actions for a main account on the + * current network through `getAgentSigner`. A non-null answer is kept; + * null and failures are not, so the next L1 action asks again. An answer + * pending across clearAgentSigners is discarded and asked again. + * + * @param mainAddress - The selected main account. + * @returns The agent, or null to sign with the main account. + */ + async #resolveAgentSigner( + mainAddress: Hex, + ): Promise { + const account: PerpsAgentAccount = { + mainAddress, + isTestnet: this.#clientService.isTestnetMode(), + }; + const key = getAgentAccountKey(account); + const generation = this.#agentSignersGeneration; + let entry = this.#agentSigners.get(key); + if (!entry) { + if (!this.#getAgentSigner) { + return null; + } + let answer: Promise; + try { + answer = this.#getAgentSigner(account); + } catch (error) { + answer = Promise.reject(error); + } + entry = { answer }; + this.#agentSigners.set(key, entry); + } + + const pendingEntry = entry; + // A clear while this answer was pending wins, so it is asked again. Only a + // clear replaces a pending answer: every other removal happens once it + // settled, after all its callers resumed. + const isSuperseded = (): boolean => + generation !== this.#agentSignersGeneration; + + let agentSigner: PerpsAgentSigner | null; + try { + agentSigner = await pendingEntry.answer; + } catch (error) { + if (isSuperseded()) { + return await this.#resolveAgentSigner(mainAddress); + } + this.#agentSigners.delete(key); + this.#deps.debugLogger.log('HyperLiquidProvider: getAgentSigner failed', { + error: ensureError(error, 'HyperLiquidProvider.resolveAgentSigner') + .message, + }); + throw new AgentSignerUnavailableError(error); + } + + if (isSuperseded()) { + return await this.#resolveAgentSigner(mainAddress); + } + if (agentSigner) { + pendingEntry.agent = agentSigner; + // The wallet adapter resolves at every L1 signature, so this records + // the account the agent is about to sign for. + this.#agentSignedFor.set(agentSigner.address.toLowerCase(), { + key, + account, + agentAddress: agentSigner.address, + }); + } else { + this.#agentSigners.delete(key); + } + return agentSigner; + } + + /** + * Map and log a failed exchange write, unless the signer could not sign it + * (`KEYRING_LOCKED`: a locked keyring, or an unavailable or rejected + * agent), which the caller retries. + * + * @param error - The caught error. + * @param method - The write that failed. + * @param extra - Context for the log. + * @returns The mapped error. + */ + async #reportWriteError( + error: unknown, + method: string, + extra: Record, + ): Promise { + const signerFailure = this.#handleSignerFailure(error, method, extra); + if (signerFailure) { + return signerFailure; + } + const mappedError = this.#mapError(error); + this.#deps.logger.error( + mappedError, + await this.#getTradingErrorContext(method, mappedError, extra), + ); + return mappedError; + } + + /** + * The agent a venue rejection names, with the account and network it last + * signed for. HyperLiquid answers "User or API Wallet 0x... does not + * exist." with the signer's address, so for a revoked or expired agent it + * reads like a wallet with no account. + * + * @param error - The caught error. + * @returns The rejected agent, with its address as the host supplied it + * and the account it signed for, or undefined. + */ + #findRejectedAgent( + error: unknown, + ): + | { account: PerpsAgentAccount; key: string; agentAddress: Hex } + | undefined { + if ( + this.#agentSignedFor.size === 0 || + !isHyperLiquidUserNotFoundError(error) + ) { + return undefined; + } + const reportedAddress = UNKNOWN_WALLET_ADDRESS_PATTERN.exec( + ensureError(error, 'HyperLiquidProvider.findRejectedAgent').message, + )?.[1]; + return reportedAddress === undefined + ? undefined + : this.#agentSignedFor.get(reportedAddress.toLowerCase()); + } + + /** + * Handle a venue rejection of an agent: drop it if it is still the + * resolved one, so the next L1 action asks for one again, and tell the + * owner of the bindings. + * + * @param error - The caught error. + * @returns True when the error was the rejection of an agent. + */ + #evictRejectedAgent(error: unknown): boolean { + const rejected = this.#findRejectedAgent(error); + if (!rejected) { + return false; + } + const { account, key, agentAddress } = rejected; + if (this.#agentSigners.get(key)?.agent?.address === agentAddress) { + this.#agentSigners.delete(key); + } + this.#deps.debugLogger.log( + 'HyperLiquidProvider: agent rejected by the venue, asking again', + { agent: agentAddress }, + ); + try { + this.#onAgentRejected?.(account, agentAddress); + } catch (callbackError) { + this.#deps.debugLogger.log('HyperLiquidProvider: onAgentRejected threw', { + error: ensureError( + callbackError, + 'HyperLiquidProvider.evictRejectedAgent', + ).message, + }); + } + return true; + } + + /** + * Whether an exchange write failed because its signer could not sign it: + * a locked keyring, an unavailable agent, or an agent the venue rejected. + * It does not evict; the caller that reports the failure classifies it. + * + * @param error - The caught error. + * @returns True for a signer failure. + */ + #isSignerFailure(error: unknown): boolean { + return ( + isKeyringLockedError(error) || + isAgentSignerUnavailableError(error) || + this.#findRejectedAgent(error) !== undefined + ); + } + + /** + * Classify a failed exchange write whose signer could not sign it, evicting + * a rejected agent. The next attempt retries. + * + * @param error - The caught error. + * @returns `KEYRING_LOCKED` for a signer failure, else undefined. + */ + #classifySignerFailure(error: unknown): Error | undefined { + const rejectedAgent = this.#evictRejectedAgent(error); + return rejectedAgent || + isKeyringLockedError(error) || + isAgentSignerUnavailableError(error) + ? new Error(PERPS_ERROR_CODES.KEYRING_LOCKED) + : undefined; + } + + /** + * Classify a failed write whose signer could not sign it, and note it as + * retryable instead of reporting it. + * + * @param error - The caught error. + * @param method - The write that failed. + * @param extra - Context for the debug log. + * @returns `KEYRING_LOCKED` for a signer failure, else undefined. + */ + #handleSignerFailure( + error: unknown, + method: string, + extra: Record, + ): Error | undefined { + const signerFailure = this.#classifySignerFailure(error); + if (signerFailure) { + this.#logRetryableSignerFailure(method, extra); + } + return signerFailure; + } + + /** + * Note a write that failed because its signer could not sign it; the + * caller retries it, so it is not reported as an error. + * + * @param method - The write that failed. + * @param extra - Context for the debug log. + */ + #logRetryableSignerFailure( + method: string, + extra: Record, + ): void { + this.#deps.debugLogger.log( + `[${method}] Signer unavailable, the write can be retried`, + extra, + ); + } + /** * Decide whether the wallet has a Hyperliquid account. * @@ -2410,11 +2694,12 @@ export class HyperLiquidProvider implements PerpsProvider { ); completeInFlight(); } catch (error) { - // HyperLiquid wraps wallet signing failures and preserves KEYRING_LOCKED - // in `cause`, so classify the full chain and leave retry caches empty. - if (isKeyringLockedError(error)) { + // The signer could not sign (a locked keyring, or an unavailable or + // rejected agent; HyperLiquid keeps the cause in `cause`): leave the + // cache empty and retry later. + if (this.#classifySignerFailure(error)) { this.#deps.debugLogger.log( - '[ensureUnifiedAccountEnabled] Keyring locked, will retry later', + '[ensureUnifiedAccountEnabled] Signer unavailable, will retry later', ); this.#unifiedAccountSetupNeedsRetry = true; completeInFlight(); @@ -2579,7 +2864,7 @@ export class HyperLiquidProvider implements PerpsProvider { // the unified balance. Hardware wallets remain deferred to action time to // avoid repeated signing prompts while browsing. await this.#ensureUnifiedAccountEnabled({ - allowUserSigning: !this.#walletService.isSelectedHardwareWallet(), + allowUserSigning: !this.#walletService.requiresSignatureConfirmation(), }); })(); @@ -2625,6 +2910,15 @@ export class HyperLiquidProvider implements PerpsProvider { #tradingSetupComplete = false; + // Set when the referral's signer could not sign it, so trading setup is not + // marked complete and the referral is attempted again. + #referralSetupNeedsRetry = false; + + // Set when the builder's referral code was not ready. It is not the user's + // to fix, so it does not hold trading setup back: the next + // `prepareTradingWallet` checks it again, but orders do not. + #referralAwaitsBuilderCode = false; + readonly #builderFeeSetupPromises = new Map>(); /** @@ -2635,10 +2929,15 @@ export class HyperLiquidProvider implements PerpsProvider { * * @param approvalFailureCode - Operation-specific error to throw when * approval is unavailable or fails. + * @param options - Options. + * @param options.reportSignerFailure - Throw `KEYRING_LOCKED` when the + * signer could not sign the approval, even without an approval failure + * code (the approval is otherwise non-blocking). * @returns The account, network, and configured builder for the action. */ async #ensureBuilderFeeSetup( approvalFailureCode?: PerpsErrorCode, + options: { reportSignerFailure?: boolean } = {}, ): Promise { const isTestnet = this.#clientService.isTestnetMode(); const network = isTestnet ? 'testnet' : 'mainnet'; @@ -2665,6 +2964,14 @@ export class HyperLiquidProvider implements PerpsProvider { this.#builderFeeSetupPromises.set(setupKey, pendingApproval); } + // An approval the signer could not sign is retryable, not a failure. + const approvalFailure = (code: PerpsErrorCode): Error => + new Error( + this.#walletService.isMainAccountSignerReady() + ? code + : PERPS_ERROR_CODES.KEYRING_LOCKED, + ); + try { await pendingApproval; } catch (error) { @@ -2672,8 +2979,15 @@ export class HyperLiquidProvider implements PerpsProvider { '[ensureBuilderFeeSetup] Builder fee approval failed', error, ); + // A signer that could not sign is retryable, not an approval failure. + if (approvalFailureCode || options.reportSignerFailure) { + const signerFailure = this.#classifySignerFailure(error); + if (signerFailure) { + throw signerFailure; + } + } if (approvalFailureCode) { - throw new Error(approvalFailureCode); + throw approvalFailure(approvalFailureCode); } } finally { if (this.#builderFeeSetupPromises.get(setupKey) === pendingApproval) { @@ -2682,7 +2996,7 @@ export class HyperLiquidProvider implements PerpsProvider { } if (approvalFailureCode && !this.#builderFeeCheckCache.has(cacheKey)) { - throw new Error(approvalFailureCode); + throw approvalFailure(approvalFailureCode); } return context; @@ -2691,21 +3005,27 @@ export class HyperLiquidProvider implements PerpsProvider { #ensureReadyForTrading(options: { requiresBuilderFee: true; builderFeeApprovalFailureCode?: PerpsErrorCode; + recheckPendingReferral?: boolean; }): Promise; #ensureReadyForTrading(options: { requiresBuilderFee: false; builderFeeApprovalFailureCode?: PerpsErrorCode; + recheckPendingReferral?: boolean; }): Promise; #ensureReadyForTrading(options: { requiresBuilderFee: boolean; builderFeeApprovalFailureCode?: PerpsErrorCode; + recheckPendingReferral?: boolean; }): Promise; async #ensureReadyForTrading(options: { requiresBuilderFee: boolean; builderFeeApprovalFailureCode?: PerpsErrorCode; + // Run the shared setup again to check a builder referral code that was + // not ready. Only preparation asks for it; orders do not. + recheckPendingReferral?: boolean; }): Promise { // First ensure basic initialization is complete await this.#ensureReady(); @@ -2715,6 +3035,11 @@ export class HyperLiquidProvider implements PerpsProvider { // already-migrated or already-rejected users are not re-prompted. await this.#ensureUnifiedAccountEnabled({ allowUserSigning: true }); + // Reset right before the check, with no await in between, so a failure + // above does not leave the setup for an order to run. + if (options.recheckPendingReferral && this.#referralAwaitsBuilderCode) { + this.#tradingSetupComplete = false; + } if (!this.#tradingSetupComplete && !this.#tradingSetupPromise) { const lifecycleGeneration = this.#lifecycleGeneration; this.#deps.debugLogger.log( @@ -2744,8 +3069,13 @@ export class HyperLiquidProvider implements PerpsProvider { 'Trading setup completion', ); - // Only mark complete if keyring was unlocked (signing could actually happen) - if (this.#walletService.isKeyringUnlocked()) { + // Only mark complete if the main-account signer is ready (signing + // could actually happen) and the referral does not need another + // attempt. + if ( + this.#walletService.isMainAccountSignerReady() && + !this.#referralSetupNeedsRetry + ) { this.#tradingSetupComplete = true; } })(); @@ -4029,6 +4359,40 @@ export class HyperLiquidProvider implements PerpsProvider { return ensureError(error, 'HyperLiquidProvider.mapError'); } + /** + * Map a rejection the venue reported in a status entry rather than threw, + * handling a rejected agent the way a thrown rejection is. + * + * @param message - The status entry's error. + * @returns The mapped error. + */ + #mapStatusError(message: string): Error { + const error = new Error(message); + return this.#classifySignerFailure(error) ?? this.#mapError(error); + } + + /** + * The signer failure a venue reported in cancel status entries rather than + * threw. One signature covers the whole request, so it is classified (and + * reported to the host) once for all its entries. + * + * @param statuses - The status entries. + * @returns `KEYRING_LOCKED` for a signer failure, else undefined. + */ + #classifyStatusSignerFailure(statuses: unknown[]): Error | undefined { + for (const status of statuses) { + if (isStatusObject(status) && typeof status.error === 'string') { + const signerFailure = this.#classifySignerFailure( + new Error(status.error), + ); + if (signerFailure) { + return signerFailure; + } + } + } + return undefined; + } + /** * Get error context for logging with searchable tags and context. * Enables Sentry dashboard filtering by feature, provider, and network. @@ -4339,12 +4703,13 @@ export class HyperLiquidProvider implements PerpsProvider { // HyperLiquid wraps wallet signing failures and preserves KEYRING_LOCKED // in `cause`, so classify the full chain and leave retry caches empty. + // The caller reports it as retryable. if (isKeyringLockedError(error)) { this.#deps.debugLogger.log( '[ensureBuilderFeeApproval] Keyring locked, will retry later', ); completeInFlight(); - return; + throw error; } // Record failure — will be retried on next trading operation @@ -4650,6 +5015,10 @@ export class HyperLiquidProvider implements PerpsProvider { }); if (!result.success) { + // The signer could not sign the transfer: retryable, not a defect. + if (result.error === PERPS_ERROR_CODES.KEYRING_LOCKED) { + throw new Error(PERPS_ERROR_CODES.KEYRING_LOCKED); + } throw new Error( `Auto-transfer failed: ${result.error ?? 'Unknown error'}`, ); @@ -4949,6 +5318,14 @@ export class HyperLiquidProvider implements PerpsProvider { amount: excessAmount.toFixed(USDC_DECIMALS), }); if (!transferResult.success) { + // The signer could not sign the transfer: retryable, not a defect. + if (transferResult.error === PERPS_ERROR_CODES.KEYRING_LOCKED) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Auto-rebalance not signed - funds remain on HIP-3 DEX', + { dex: dexName, excessAmount }, + ); + return false; + } throw new Error( transferResult.error ?? PERPS_ERROR_CODES.TRANSFER_FAILED, ); @@ -5045,6 +5422,12 @@ export class HyperLiquidProvider implements PerpsProvider { returnedTo: transferInfo.sourceDex || 'main', }, ); + } else if (rollbackResult.error === PERPS_ERROR_CODES.KEYRING_LOCKED) { + // The signer could not sign the transfer: retryable, not a defect. + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Rollback not signed - funds remain on HIP-3 DEX', + { dex: dexName, amount: transferInfo.amount }, + ); } else { this.#deps.logger.error( new Error(rollbackResult.error ?? 'Rollback transfer failed'), @@ -5410,6 +5793,14 @@ export class HyperLiquidProvider implements PerpsProvider { params: HandleOrderErrorParams, ): Promise { const { error, symbol, orderType, isBuy } = params; + const signerFailure = this.#handleSignerFailure(error, 'placeOrder', { + symbol, + orderType, + isBuy, + }); + if (signerFailure) { + return createErrorResult(signerFailure, { success: false }); + } const mappedError = this.#mapError(error); // A wallet with no Hyperliquid account is an expected pre-account state, @@ -6203,10 +6594,19 @@ export class HyperLiquidProvider implements PerpsProvider { a: assetId, t: running.twapId, }); + const cancelStatus: unknown = cancelResult.response?.data?.status; + // A rejected agent is dropped and reported; the TWAP stays live. + if ( + isStatusObject(cancelStatus) && + typeof cancelStatus.error === 'string' + ) { + this.#evictRejectedAgent(new Error(cancelStatus.error)); + } remainsLive = - classifyCancelStatus(cancelResult.response?.data?.status) === - CancelChildOutcome.Refused; + classifyCancelStatus(cancelStatus) === CancelChildOutcome.Refused; } catch (error) { + // A rejected agent is dropped and reported; the TWAP stays live. + this.#evictRejectedAgent(error); this.#deps.debugLogger.log( 'Stale TWAP placement could not be retracted', { @@ -6878,6 +7278,8 @@ export class HyperLiquidProvider implements PerpsProvider { this.#chaseTickQueue = this.#chaseTickQueue .then(() => this.#runChaseTick(sessionId)) .catch((error: unknown) => { + // A rejected agent is dropped so the next tick asks for another. + this.#evictRejectedAgent(error); // Resolve the shared queue after every failure. Otherwise one // rejected tick prevents all later ticks and teardown from running. this.#deps.debugLogger.log('Chase tick failed', { @@ -7527,7 +7929,12 @@ export class HyperLiquidProvider implements PerpsProvider { throw error; } - return classifyCancelStatus(result.response?.data?.statuses?.[0]); + const status: unknown = result.response?.data?.statuses?.[0]; + const signerFailure = this.#classifyStatusSignerFailure([status]); + if (signerFailure) { + throw signerFailure; + } + return classifyCancelStatus(status); } /** @@ -7556,6 +7963,8 @@ export class HyperLiquidProvider implements PerpsProvider { }); return outcome; } catch (error) { + // A rejected agent is dropped and reported; the order stays resting. + this.#evictRejectedAgent(error); this.#deps.debugLogger.log('Could not retract abandoned chase order', { orderId: session.orderId, error: ensureError(error, 'HyperLiquidProvider.startChaseSession') @@ -8012,15 +8421,11 @@ export class HyperLiquidProvider implements PerpsProvider { } return await this.#cancelChaseOrder(params); } catch (error) { - const mappedError = this.#mapError(error); - this.#deps.logger.error( - mappedError, - await this.#getTradingErrorContext('cancelOrder', mappedError, { - orderId: params.orderId, - coin: params.symbol, - orderType: params.orderType, - }), - ); + const mappedError = await this.#reportWriteError(error, 'cancelOrder', { + orderId: params.orderId, + coin: params.symbol, + orderType: params.orderType, + }); return createErrorResult(mappedError, { success: false, orderId: params.orderId, @@ -8121,7 +8526,7 @@ export class HyperLiquidProvider implements PerpsProvider { isStatusObject(status) && typeof status.error === 'string' ? status.error : 'TWAP cancellation failed'; - return createErrorResult(this.#mapError(new Error(rawError)), { + return createErrorResult(this.#mapStatusError(rawError), { success: false, orderId: params.orderId, }); @@ -8156,11 +8561,12 @@ export class HyperLiquidProvider implements PerpsProvider { asset: assetId, cloid: clientOrderId, })); - const [remainingOrderIds, remainingClientOrderIds] = await Promise.all([ - this.#cancelOrderRequests(exchangeClient, cancelRequests), - this.#cancelOrderCloidRequests(exchangeClient, cancelByCloidRequests), + const [orderCancellation, cloidCancellation] = await Promise.all([ + this.#cancelOrderRequestBatch(exchangeClient, cancelRequests), + this.#cancelOrderCloidRequestBatch(exchangeClient, cancelByCloidRequests), ]); - const remaining = remainingOrderIds.map(String); + const remaining = orderCancellation.remainingOrderIds.map(String); + const { remainingClientOrderIds } = cloidCancellation; /* * A rung that filled or was cancelled individually comes back as a @@ -8182,6 +8588,19 @@ export class HyperLiquidProvider implements PerpsProvider { orderIds: remaining, clientOrderIds: remainingClientOrderIds, }); + // The signer could not sign a cancel: retryable, not a defect. + const signerFailure = + orderCancellation.signerFailure ?? cloidCancellation.signerFailure; + if (signerFailure) { + this.#logRetryableSignerFailure('cancelOrder', { + orderId: params.orderId, + orderType: params.orderType, + }); + return createErrorResult(signerFailure, { + success: false, + orderId: params.orderId, + }); + } this.#deps.debugLogger.log('Scale group cancel left children resting', { groupId: params.orderId, remainingOrderIds: remaining.length, @@ -8315,22 +8734,48 @@ export class HyperLiquidProvider implements PerpsProvider { * * @param exchangeClient - Client that owns the orders. * @param requests - Venue cancel-by-CLOID requests. - * @returns Client order IDs that may still be pending. + * @returns Client order IDs that may still be resting. */ async #cancelOrderCloidRequests( exchangeClient: ExchangeClient, requests: ExchangeCancelByCloidRequest[], ): Promise { + return (await this.#cancelOrderCloidRequestBatch(exchangeClient, requests)) + .remainingClientOrderIds; + } + + /** + * Cancel pending orders by client order ID, reporting a signer that could + * not sign the cancel. + * + * @param exchangeClient - Client that owns the orders. + * @param requests - Venue cancel-by-CLOID requests. + * @returns Client order IDs that may still be resting, and the signer + * failure when the signer could not sign the request. + */ + async #cancelOrderCloidRequestBatch( + exchangeClient: ExchangeClient, + requests: ExchangeCancelByCloidRequest[], + ): Promise<{ remainingClientOrderIds: Hex[]; signerFailure?: Error }> { if (requests.length === 0) { - return []; + return { remainingClientOrderIds: [] }; } - const getRemainingClientOrderIds = (statuses: unknown[]): Hex[] => - requests.flatMap((request, index) => - classifyCancelStatus(statuses[index]) === CancelChildOutcome.Refused - ? [request.cloid] - : [], - ); + const classifyStatuses = ( + statuses: unknown[], + ): { remainingClientOrderIds: Hex[]; signerFailure?: Error } => { + // A signer failure is classified (and reported) once for the request; + // entries the venue cancelled are not resting either way. + const signerFailure = this.#classifyStatusSignerFailure(statuses); + return { + remainingClientOrderIds: requests.flatMap((request, index) => + classifyCancelStatus(statuses[index]) === CancelChildOutcome.Refused + ? [request.cloid] + : [], + ), + ...(signerFailure && { signerFailure }), + }; + }; try { const result = await exchangeClient.cancelByCloid({ @@ -8338,14 +8783,25 @@ export class HyperLiquidProvider implements PerpsProvider { }); const statuses = result.response?.data?.statuses ?? []; if (result.status !== 'ok' || statuses.length !== requests.length) { - return requests.map((request) => request.cloid); + return { + remainingClientOrderIds: requests.map((request) => request.cloid), + }; } - return getRemainingClientOrderIds(statuses); + return classifyStatuses(statuses); } catch (error) { + // The SDK throws when any entry failed, with every entry's status. const statuses = getCancelStatusesFromError(error, requests.length); if (statuses) { - return getRemainingClientOrderIds(statuses); + return classifyStatuses(statuses); + } + // The signer could not sign, so nothing was cancelled. + const signerFailure = this.#classifySignerFailure(error); + if (signerFailure) { + return { + remainingClientOrderIds: requests.map((request) => request.cloid), + signerFailure, + }; } this.#deps.debugLogger.log('Order cancellation by CLOID failed', { error: ensureError( @@ -8354,7 +8810,9 @@ export class HyperLiquidProvider implements PerpsProvider { ).message, clientOrderIds: requests.map((request) => request.cloid), }); - return requests.map((request) => request.cloid); + return { + remainingClientOrderIds: requests.map((request) => request.cloid), + }; } } @@ -8379,6 +8837,9 @@ export class HyperLiquidProvider implements PerpsProvider { } const classifyStatuses = (statuses: unknown[]): CancelOrderBatchOutcome => { + // A signer failure is classified (and reported) once for the request; + // entries the venue cancelled still count as cancelled. + const signerFailure = this.#classifyStatusSignerFailure(statuses); const remainingOrderIds: number[] = []; const cancelledOrderIds: number[] = []; requests.forEach((request, index) => { @@ -8389,7 +8850,12 @@ export class HyperLiquidProvider implements PerpsProvider { cancelledOrderIds.push(request.o); } }); - return { remainingOrderIds, cancelledOrderIds, responseComplete: true }; + return { + remainingOrderIds, + cancelledOrderIds, + responseComplete: true, + ...(signerFailure && { signerFailure }), + }; }; try { @@ -8405,10 +8871,21 @@ export class HyperLiquidProvider implements PerpsProvider { return classifyStatuses(statuses); } catch (error) { + // The SDK throws when any entry failed, with every entry's status. const statuses = getCancelStatusesFromError(error, requests.length); if (statuses) { return classifyStatuses(statuses); } + // The signer could not sign, so nothing was cancelled. + const signerFailure = this.#classifySignerFailure(error); + if (signerFailure) { + return { + remainingOrderIds: requests.map((request) => request.o), + cancelledOrderIds: [], + responseComplete: false, + signerFailure, + }; + } this.#deps.debugLogger.log('Order cancellation batch failed', { error: ensureError(error, 'HyperLiquidProvider.cancelOrderRequests') .message, @@ -9016,6 +9493,12 @@ export class HyperLiquidProvider implements PerpsProvider { : { orderId: replacementOrderId }), }; } catch (error) { + const signerFailure = this.#handleSignerFailure(error, 'editOrder', { + orderId: params.orderId, + }); + if (signerFailure) { + return createErrorResult(signerFailure, { success: false }); + } this.#deps.logger.error( ensureError(error, 'HyperLiquidProvider.editOrder'), this.#getErrorContext('editOrder', { @@ -9114,19 +9597,15 @@ export class HyperLiquidProvider implements PerpsProvider { ? status.error : 'Order cancellation failed'; - return createErrorResult(this.#mapError(new Error(rawError)), { + return createErrorResult(this.#mapStatusError(rawError), { success: false, orderId: params.orderId, }); } catch (error) { - const mappedError = this.#mapError(error); - this.#deps.logger.error( - mappedError, - await this.#getTradingErrorContext('cancelOrder', mappedError, { - orderId: params.orderId, - coin: params.symbol, - }), - ); + const mappedError = await this.#reportWriteError(error, 'cancelOrder', { + orderId: params.orderId, + coin: params.symbol, + }); return createErrorResult(mappedError, { success: false, orderId: params.orderId, @@ -9218,46 +9697,56 @@ export class HyperLiquidProvider implements PerpsProvider { }; }), ); - const result = await exchangeClient.cancel({ - cancels: cancelRequests, - }); - const statuses = result.response?.data?.statuses ?? []; + let statuses: unknown[] | undefined; + try { + const result = await exchangeClient.cancel({ + cancels: cancelRequests, + }); + const returnedStatuses = result.response?.data?.statuses ?? []; + statuses = + result.status === 'ok' && + returnedStatuses.length === ordinaryOrders.length + ? returnedStatuses + : undefined; + } catch (error) { + // The SDK throws when any entry failed, with every entry's status. + statuses = getCancelStatusesFromError(error, ordinaryOrders.length); + if (!statuses) { + throw error; + } + } - if ( - result.status === 'ok' && - statuses.length === ordinaryOrders.length - ) { + if (statuses) { + // One signature covers the batch, so a signer failure is classified + // (and reported to the host) once. Each entry keeps its own result. + const signerFailure = this.#classifyStatusSignerFailure(statuses); ordinaryOrders.forEach(({ index, order }, statusIndex) => { const status: unknown = statuses[statusIndex]; const success = status === 'success'; const statusError = isStatusObject(status) && typeof status.error === 'string' - ? status.error + ? new Error(status.error) : undefined; + let error: string = PERPS_ERROR_CODES.BATCH_CANCEL_FAILED; + if (statusError) { + error = + signerFailure && this.#isSignerFailure(statusError) + ? signerFailure.message + : this.#mapError(statusError).message; + } results[index] = { orderId: order.orderId, symbol: order.symbol, success, - ...(success - ? {} - : { - error: - statusError === undefined - ? PERPS_ERROR_CODES.BATCH_CANCEL_FAILED - : this.#mapError(new Error(statusError)).message, - }), + ...(success ? {} : { error }), }; }); } } } catch (error) { - const mappedError = this.#mapError(error); - this.#deps.logger.error( - mappedError, - await this.#getTradingErrorContext('cancelOrders', mappedError, { - orderCount: params.length, - }), - ); + const mappedError = await this.#reportWriteError(error, 'cancelOrders', { + orderCount: params.length, + }); for (const result of results) { if ( !result.success && @@ -9620,16 +10109,20 @@ export class HyperLiquidProvider implements PerpsProvider { ], }; } catch (error) { - const safeError = ensureError( - error, - 'HyperLiquidProvider.closePositions', - ); - this.#deps.logger.error( - safeError, - this.#getErrorContext('closePositions', { - positionCount: positionsToClose.length, - }), - ); + const signerFailure = this.#handleSignerFailure(error, 'closePositions', { + positionCount: positionsToClose.length, + }); + const safeError = + signerFailure ?? + ensureError(error, 'HyperLiquidProvider.closePositions'); + if (!signerFailure) { + this.#deps.logger.error( + safeError, + this.#getErrorContext('closePositions', { + positionCount: positionsToClose.length, + }), + ); + } // Return all selected positions as failed, including unavailable DEXes. return { success: false, @@ -10203,16 +10696,25 @@ export class HyperLiquidProvider implements PerpsProvider { } } catch (error) { success = false; - this.#deps.logger.error( - ensureError( - error, - 'HyperLiquidProvider.updatePositionTPSL.restoreCancelledProtection', - ), - this.#getErrorContext( - 'updatePositionTPSL > restoreCancelledProtection', - { symbol, grouping: protection.grouping }, - ), - ); + // A signer that could not sign is retryable, not a defect; the + // caller still learns the protection was lost. + if ( + !this.#handleSignerFailure(error, 'updatePositionTPSL', { + symbol, + grouping: protection.grouping, + }) + ) { + this.#deps.logger.error( + ensureError( + error, + 'HyperLiquidProvider.updatePositionTPSL.restoreCancelledProtection', + ), + this.#getErrorContext( + 'updatePositionTPSL > restoreCancelledProtection', + { symbol, grouping: protection.grouping }, + ), + ); + } } } return { restoredOrderIds, success }; @@ -10226,14 +10728,31 @@ export class HyperLiquidProvider implements PerpsProvider { childOrderIds: [...new Set(survivingOrderIds)], }); + // Cancel before placing for both position-bound and standalone partial + // triggers. A place-first partial update leaves both trigger sets live + // during the cancellation round trip and can reduce more than requested. + const oldCancellation = await this.#cancelOrderRequestBatch( + exchangeClient, + cancelRequests, + ); + // The signer could not sign the cancel. With nothing cancelled the old + // protection is still in place, and a clear keeps only what it could + // not cancel, so the caller retries. A replacement that cancelled part + // of the old protection restores it below. + if ( + oldCancellation.signerFailure && + (orders.length === 0 || oldCancellation.cancelledOrderIds.length === 0) + ) { + this.#logRetryableSignerFailure('updatePositionTPSL', { symbol }); + return createErrorResult(oldCancellation.signerFailure, { + success: false, + }); + } + // Clearing has no replacement batch to preserve. A partial cancellation // is reported so the caller can retry the same clear operation. if (orders.length === 0) { - const remainingOrderIds = await this.#cancelOrderRequests( - exchangeClient, - cancelRequests, - ); - if (remainingOrderIds.length > 0) { + if (oldCancellation.remainingOrderIds.length > 0) { throw new Error(PERPS_ERROR_CODES.TPSL_UPDATE_FAILED); } this.#deps.debugLogger.log( @@ -10245,14 +10764,7 @@ export class HyperLiquidProvider implements PerpsProvider { }; } - // Cancel before placing for both position-bound and standalone partial - // triggers. A place-first partial update leaves both trigger sets live - // during the cancellation round trip and can reduce more than requested. const confirmedCancelledOldOrderIds = new Set(); - const oldCancellation = await this.#cancelOrderRequestBatch( - exchangeClient, - cancelRequests, - ); if (!oldCancellation.responseComplete) { const requestedOrderIds = new Set( cancelRequests.map((request) => request.o), @@ -10290,6 +10802,19 @@ export class HyperLiquidProvider implements PerpsProvider { ...restoration.restoredOrderIds, ]); } + const survivingOrderIds = [ + ...new Set([ + ...oldCancellation.remainingOrderIds.map(String), + ...restoration.restoredOrderIds, + ]), + ]; + if (oldCancellation.signerFailure) { + this.#logRetryableSignerFailure('updatePositionTPSL', { symbol }); + return createErrorResult(oldCancellation.signerFailure, { + success: false, + childOrderIds: survivingOrderIds, + }); + } const updateError = new Error(PERPS_ERROR_CODES.TPSL_UPDATE_FAILED); this.#deps.logger.error( updateError, @@ -10301,12 +10826,7 @@ export class HyperLiquidProvider implements PerpsProvider { ); return createErrorResult(updateError, { success: false, - childOrderIds: [ - ...new Set([ - ...oldCancellation.remainingOrderIds.map(String), - ...restoration.restoredOrderIds, - ]), - ], + childOrderIds: survivingOrderIds, }); } @@ -10319,12 +10839,24 @@ export class HyperLiquidProvider implements PerpsProvider { builderOrderContext && { builder: builderOrderContext }), }); } catch (error) { + // Classify first, so a rejected agent is dropped (and reported) before + // the restoration signs. + const signerFailure = this.#classifySignerFailure(error); const restoration = await restoreCancelledProtection( confirmedCancelledOldOrderIds, ); if (!restoration.success) { return createProtectionLostResult(restoration.restoredOrderIds); } + if (signerFailure) { + this.#logRetryableSignerFailure('updatePositionTPSL', { symbol }); + return createErrorResult(signerFailure, { + success: false, + ...(restoration.restoredOrderIds.length > 0 && { + childOrderIds: restoration.restoredOrderIds, + }), + }); + } throw error; } @@ -10416,6 +10948,14 @@ export class HyperLiquidProvider implements PerpsProvider { } throw new Error(PERPS_ERROR_CODES.TPSL_UPDATE_FAILED); } catch (error) { + const signerFailure = this.#handleSignerFailure( + error, + 'updatePositionTPSL', + { symbol: params.symbol }, + ); + if (signerFailure) { + return createErrorResult(signerFailure, { success: false }); + } this.#deps.logger.error( ensureError(error, 'HyperLiquidProvider.updatePositionTPSL'), this.#getErrorContext('updatePositionTPSL', { @@ -10703,6 +11243,12 @@ export class HyperLiquidProvider implements PerpsProvider { success: true, }; } catch (error) { + const signerFailure = this.#handleSignerFailure(error, 'updateMargin', { + symbol: params.symbol, + }); + if (signerFailure) { + return { success: false, error: signerFailure.message }; + } const safeError = ensureError(error, 'HyperLiquidProvider.updateMargin'); this.#deps.logger.error( safeError, @@ -13656,6 +14202,12 @@ export class HyperLiquidProvider implements PerpsProvider { error: errorMessage, }; } catch (error) { + const signerFailure = this.#handleSignerFailure(error, 'withdraw', { + assetId: params.assetId, + }); + if (signerFailure) { + return createErrorResult(signerFailure, { success: false }); + } const safeError = ensureError( error, 'HyperLiquidProvider.initiateWithdrawal', @@ -13763,6 +14315,14 @@ export class HyperLiquidProvider implements PerpsProvider { throw new Error(PERPS_ERROR_CODES.TRANSFER_FAILED); } catch (error) { + const signerFailure = this.#handleSignerFailure( + error, + 'transferBetweenDexs', + { sourceDex: params.sourceDex, destinationDex: params.destinationDex }, + ); + if (signerFailure) { + return { success: false, error: signerFailure.message }; + } const safeError = ensureError( error, 'HyperLiquidProvider.transferToSpot', @@ -14073,6 +14633,115 @@ export class HyperLiquidProvider implements PerpsProvider { } } + /** + * Forget every agent resolved through `getAgentSigner`, so the next L1 + * action asks again; an answer still pending is discarded too. + */ + clearAgentSigners(): void { + this.#agentSignersGeneration += 1; + this.#agentSigners.clear(); + } + + /** + * Run the deferred account migration, builder fee and referral setup ahead + * of the first order. Results are cached, so an already-ready account signs + * nothing. A declined migration is not asked again, and a referral whose + * MetaMask code is not ready yet is checked again by the next call rather + * than by orders. + * + * @returns The readiness result described on + * `PerpsController.prepareTradingWallet`. + */ + async prepareTradingWallet(): Promise { + // Nothing can be signed, so run no setup (and log nothing) until it can. + if (!this.#walletService.isMainAccountSignerReady()) { + return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; + } + try { + const lifecycleGeneration = this.#lifecycleGeneration; + const userAddress = await this.#walletService.getUserAddressWithDefault(); + // The result is only for the provider and account it started with. + const assertPreparationCurrent = async (): Promise => { + this.#assertProviderLifecycleCurrent( + lifecycleGeneration, + 'Trading wallet preparation', + ); + // A deselected account changed too. + const currentAddress = await this.#walletService + .getUserAddressWithDefault() + .catch(() => undefined); + if (currentAddress?.toLowerCase() !== userAddress.toLowerCase()) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + }; + await this.#ensureReadyForTrading({ + requiresBuilderFee: false, + recheckPendingReferral: true, + }); + const network = this.#clientService.isTestnetMode() + ? 'testnet' + : 'mainnet'; + const isRegistered = await this.#isWalletOnHyperliquid( + userAddress, + network, + ); + await assertPreparationCurrent(); + // The venue rejects every write from a wallet with no HyperLiquid account + // yet, so it is not asked to sign a builder fee approval either. + if (!isRegistered) { + return { + ready: false, + error: this.#walletService.isMainAccountSignerReady() + ? PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND + : PERPS_ERROR_CODES.KEYRING_LOCKED, + }; + } + await this.#ensureBuilderFeeSetup(undefined, { + reportSignerFailure: true, + }); + // The builder fee setup ends quietly when the provider disconnects. + await assertPreparationCurrent(); + if (!this.#walletService.isMainAccountSignerReady()) { + return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; + } + const ready = + this.#tradingSetupComplete && + !this.#unifiedAccountSetupNeedsRetry && + this.#builderFeeCheckCache.has(this.#getCacheKey(network, userAddress)); + return { ready }; + } catch (error) { + // A step failed because the signer could not sign it (or locked while + // it ran): retryable, not logged. + if ( + isKeyringLockedError(error) || + !this.#walletService.isMainAccountSignerReady() + ) { + return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; + } + const caughtError = ensureError( + error, + 'HyperLiquidProvider.prepareTradingWallet', + ); + if (caughtError.message === PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE) { + this.#deps.debugLogger.log( + '[prepareTradingWallet] Provider replaced during preparation', + ); + } else if ( + caughtError.message === PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED + ) { + this.#deps.debugLogger.log( + '[prepareTradingWallet] No account selected', + ); + } else { + this.#deps.logger.error( + caughtError, + this.#getErrorContext('prepareTradingWallet'), + ); + } + return { ready: false, error: caughtError.message }; + } + } + /** * Check if ready to trade * @@ -15141,6 +15810,8 @@ export class HyperLiquidProvider implements PerpsProvider { * Note: Non-blocking - failures are logged to Sentry but don't prevent trading */ async #ensureReferralSet(): Promise { + this.#referralSetupNeedsRetry = false; + this.#referralAwaitsBuilderCode = false; const isTestnet = this.#clientService.isTestnetMode(); const network = isTestnet ? 'testnet' : 'mainnet'; const expectedReferralCode = this.#getReferralCode(isTestnet); @@ -15187,19 +15858,26 @@ export class HyperLiquidProvider implements PerpsProvider { return; } - // Check if another provider is currently attempting this - const inFlightPromise = PerpsSigningCache.isInFlight( + // Wait while another provider attempts it. That attempt may end without + // caching a result (its signer could not sign), so take the lock once it + // is free and re-check the cache under it. The lock is checked and taken + // with no await in between, so only one of several waiters gets it. + let inFlightPromise = PerpsSigningCache.isInFlight( 'referral', network, userAddress, ); - if (inFlightPromise) { + while (inFlightPromise) { this.#deps.debugLogger.log( '[ensureReferralSet] Global in-flight, waiting...', { network }, ); await inFlightPromise; - return; + inFlightPromise = PerpsSigningCache.isInFlight( + 'referral', + network, + userAddress, + ); } // Set global in-flight lock @@ -15221,14 +15899,17 @@ export class HyperLiquidProvider implements PerpsProvider { return; } - const isReady = await this.#isReferralCodeReady(); - if (!isReady) { + const codeStatus = await this.#getReferralCodeStatus(); + if (codeStatus !== 'ready') { this.#deps.debugLogger.log( '[ensureReferralSet] Builder referral not ready, skipping', - { network }, + { network, codeStatus }, ); + // Don't cache. A failed lookup (already logged) waits for the next + // setup; a code that is not ready yet, for the next preparation. + this.#referralAwaitsBuilderCode = codeStatus === 'pending'; completeInFlight(); - return; // Don't cache - retry when ready + return; } // Check if user already has a referral on-chain @@ -15272,12 +15953,14 @@ export class HyperLiquidProvider implements PerpsProvider { } completeInFlight(); } catch (error) { - // HyperLiquid wraps wallet signing failures and preserves KEYRING_LOCKED - // in `cause`, so classify the full chain and leave retry caches empty. - if (isKeyringLockedError(error)) { + // The signer could not sign (a locked keyring, or an unavailable or + // rejected agent; HyperLiquid keeps the cause in `cause`): leave the + // cache empty and attempt the referral again at the next setup. + if (this.#classifySignerFailure(error)) { this.#deps.debugLogger.log( - '[ensureReferralSet] Keyring locked, will retry later', + '[ensureReferralSet] Signer unavailable, will retry later', ); + this.#referralSetupNeedsRetry = true; completeInFlight(); return; } @@ -15321,11 +16004,12 @@ export class HyperLiquidProvider implements PerpsProvider { } /** - * Check if the referral code is ready to be used + * Check whether the builder's referral code can be used. * - * @returns Promise resolving to true if referral code is ready + * @returns `ready`, `pending` while the builder's code is not ready yet, or + * `failed` when the lookup failed or the code on file does not match (logged). */ - async #isReferralCodeReady(): Promise { + async #getReferralCodeStatus(): Promise<'ready' | 'pending' | 'failed'> { try { const infoClient = this.#clientService.getInfoClient(); const isTestnet = this.#clientService.isTestnetMode(); @@ -15343,30 +16027,30 @@ export class HyperLiquidProvider implements PerpsProvider { `Ready for referrals but there is a config code mismatch ${onFile} vs ${code}`, ); } - return true; + return 'ready'; } // Not ready yet - log as debugLogger since this is expected during setup phase this.#deps.debugLogger.log( - '[isReferralCodeReady] Referral code not ready', + '[getReferralCodeStatus] Referral code not ready', { stage, code, referrerAddr, }, ); - return false; + return 'pending'; } catch (error) { this.#deps.logger.error( - ensureError(error, 'HyperLiquidProvider.isReferralCodeReady'), - this.#getErrorContext('isReferralCodeReady', { + ensureError(error, 'HyperLiquidProvider.getReferralCodeStatus'), + this.#getErrorContext('getReferralCodeStatus', { code: this.#getReferralCode(this.#clientService.isTestnetMode()), referrerAddress: this.#getBuilderAddress( this.#clientService.isTestnetMode(), ), }), ); - return false; + return 'failed'; } } @@ -15443,6 +16127,12 @@ export class HyperLiquidProvider implements PerpsProvider { return result?.status === 'ok'; } catch (error) { + // Retryable (a locked keyring, an unavailable agent, or one the venue + // rejected): `#ensureReferralSet` retries at the next entry, so it is + // not an error to report. + if (this.#isSignerFailure(error)) { + throw error; + } // Benign for unfunded wallets — downgrade and rethrow so the outer // `#ensureReferralSet` catch self-heals the walletRegistered gate // without forwarding to Sentry. diff --git a/packages/perps-controller/src/providers/LighterProvider.ts b/packages/perps-controller/src/providers/LighterProvider.ts index 194b0fccf08..8dd9b84da62 100644 --- a/packages/perps-controller/src/providers/LighterProvider.ts +++ b/packages/perps-controller/src/providers/LighterProvider.ts @@ -56,6 +56,7 @@ import { import { PERPS_CONSTANTS } from '../constants/perpsConfig.js'; import type { PerpsControllerMessenger } from '../PerpsController.js'; import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; +import { hasErrorInCauseChain } from '../services/causeChain.js'; import { convertKeysToCamelCase, LighterApiError, @@ -151,7 +152,7 @@ import type { LighterWsMarketStat, LighterWsMarketStatsMessage, } from '../types/lighter-types.js'; -import { ensureError } from '../utils/errorUtils.js'; +import { ensureError, isKeyringLockedError } from '../utils/errorUtils.js'; import { adaptAccountStateFromLighter, adaptAccountStateFromLighterUserStats, @@ -992,6 +993,39 @@ class LighterAccountNotFoundError extends Error { } } +/** + * Session-bound work stopped because the provider disconnected or the wallet + * switched accounts while it ran. + */ +class LighterSessionCancelledError extends Error { + constructor(reason: string) { + super(`Operation cancelled: ${reason}`); + this.name = 'LighterSessionCancelledError'; + } +} + +// EIP-1193 `userRejectedRequest` error code. +const USER_REJECTED_REQUEST_CODE = 4001; + +// How wallets word a declined signature when they set no code (the same +// wordings the controller's deposit flow treats as a cancellation). +const USER_REJECTED_MESSAGE_PATTERN = /user (rejected|denied|cancell?ed)/iu; + +/** + * Whether the user declined the venue-key signature; the order path asks + * again. + * + * @param error - The caught error. + * @returns True for a declined signature. + */ +const isDeclinedRegistration = (error: unknown): boolean => + hasErrorInCauseChain( + error, + (current) => + (current as { code?: unknown }).code === USER_REJECTED_REQUEST_CODE || + USER_REJECTED_MESSAGE_PATTERN.test(current.message), + ); + /** * Empty account state returned when reads fail or no account exists. */ @@ -1173,8 +1207,6 @@ export class LighterProvider implements PerpsProvider { this.#walletService = new LighterWalletService(this.#deps, { isTestnet: this.#isTestnet, messenger: options.messenger, - personalSigner: options.lighterAuthConfig?.personalSigner, - l1Address: options.lighterAuthConfig?.l1Address, }); this.#deps.debugLogger.log('[LighterProvider] Constructor complete', { @@ -1278,6 +1310,76 @@ export class LighterProvider implements PerpsProvider { }; } + /** + * Register the venue key ahead of the first order, so its main-account + * `personal_sign` happens in a guided session. A read-only provider (no + * signer bridge) has nothing to prepare and resolves `ready: true` while an + * account is selected and the main-account signer is ready. + * + * @returns The readiness result described on + * `PerpsController.prepareTradingWallet`. + */ + async prepareTradingWallet(): Promise { + if (!this.#walletService.isMainAccountSignerReady()) { + return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; + } + try { + this.#walletService.getUserAddress(); + } catch { + return { ready: false, error: PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED }; + } + if (!this.#signerBridge) { + return { ready: true }; + } + try { + await this.#ensureSignerReady(); + // The signer can lock while the venue key is being registered. + if (!this.#walletService.isMainAccountSignerReady()) { + return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; + } + return { ready: true }; + } catch (caughtError) { + // A locked signer, or one that locked while registration ran. + if ( + isKeyringLockedError(caughtError) || + !this.#walletService.isMainAccountSignerReady() + ) { + return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; + } + if (isDeclinedRegistration(caughtError)) { + return { ready: false }; + } + // Nothing can be registered before the wallet has a Lighter account. + if (caughtError instanceof LighterAccountNotFoundError) { + return { + ready: false, + error: PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, + }; + } + // The session moved on while registering; the next preparation + // starts over for the current account. + if (caughtError instanceof LighterSessionCancelledError) { + this.#deps.debugLogger.log( + '[prepareTradingWallet] Session changed during preparation', + { reason: caughtError.message }, + ); + return { + ready: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + }; + } + const error = ensureError( + caughtError, + 'LighterProvider.prepareTradingWallet', + ); + this.#deps.logger.error( + error, + this.#getErrorContext('prepareTradingWallet'), + ); + return { ready: false, error: error.message }; + } + } + async isReadyToTrade(): Promise { try { if (!this.#signerBridge) { @@ -4119,24 +4221,24 @@ export class LighterProvider implements PerpsProvider { */ readonly #assertSession = (generation: number): void => { if (this.#isDisconnected) { - throw new Error( - 'Operation cancelled: the Lighter provider was disconnected', + throw new LighterSessionCancelledError( + 'the Lighter provider was disconnected', ); } if (generation !== this.#sessionGeneration) { - throw new Error( - 'Operation cancelled: the wallet switched accounts (or the signer reset) while this operation was in flight', + throw new LighterSessionCancelledError( + 'the wallet switched accounts (or the signer reset) while this operation was in flight', ); } // The generation only advances when some provider call rebinds; also // notice a wallet switch nothing has observed yet. Account-bound work // must never run without a binding: every legitimate flow (including - // headless l1Address and configured-index setups) binds first, so a - // null binding here means the wallet was deselected — fail closed even - // when a configured account index could still resolve. + // configured-index setups) binds first, so a null binding here means the + // wallet was deselected — fail closed even when a configured account + // index could still resolve. if (this.#boundAddress === null) { - throw new Error( - 'Operation cancelled: no wallet account is bound to the venue session', + throw new LighterSessionCancelledError( + 'no wallet account is bound to the venue session', ); } let address: string | null = null; @@ -4156,8 +4258,8 @@ export class LighterProvider implements PerpsProvider { // the stale operation. this.#ensureSessionBinding(); } - throw new Error( - 'Operation cancelled: the wallet switched accounts (or the signer reset) while this operation was in flight', + throw new LighterSessionCancelledError( + 'the wallet switched accounts (or the signer reset) while this operation was in flight', ); } }; diff --git a/packages/perps-controller/src/services/HyperLiquidClientService.ts b/packages/perps-controller/src/services/HyperLiquidClientService.ts index 4837427bf3c..7549943d0fb 100644 --- a/packages/perps-controller/src/services/HyperLiquidClientService.ts +++ b/packages/perps-controller/src/services/HyperLiquidClientService.ts @@ -23,6 +23,7 @@ import { WebSocketConnectionState } from '../types/index.js'; import type { SubscribeCandlesParams, PerpsPlatformDependencies, + PerpsTypedDataPayload, } from '../types/index.js'; import type { CandleData } from '../types/perps-types.js'; import { coalescePerpsRestRequest } from '../utils/coalescePerpsRestRequest.js'; @@ -45,19 +46,9 @@ export type ValidCandleInterval = CandlePeriod; * Extracted for reuse across initialize(), toggleTestnet(), and ensureSubscriptionClient() methods. */ 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; + /** The main account; the SDK recognizes the wallet and keys nonces by it. */ + address: Hex; + signTypedData: (params: PerpsTypedDataPayload) => Promise; getChainId?: () => Promise; }; diff --git a/packages/perps-controller/src/services/HyperLiquidWalletService.ts b/packages/perps-controller/src/services/HyperLiquidWalletService.ts index ab0a7dad883..a58ad40bd63 100644 --- a/packages/perps-controller/src/services/HyperLiquidWalletService.ts +++ b/packages/perps-controller/src/services/HyperLiquidWalletService.ts @@ -5,10 +5,16 @@ import { } from '@metamask/utils'; import type { CaipAccountId, Hex } from '@metamask/utils'; -import { getChainId } from '../constants/hyperLiquidConfig.js'; +import { + getChainId, + HYPERLIQUID_L1_ACTION_DOMAIN_NAME, + HYPERLIQUID_L1_ACTION_PRIMARY_TYPE, +} from '../constants/hyperLiquidConfig.js'; import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; import type { + PerpsAgentSigner, PerpsPlatformDependencies, + PerpsTypedDataPayload, PerpsTypedMessageParams, } from '../types/index.js'; import type { PerpsControllerMessengerBase } from '../types/messenger.js'; @@ -16,6 +22,12 @@ import { getSelectedEvmAccountDetailsFromMessenger, getSelectedEvmAccountFromMessenger, } from '../utils/accountUtils.js'; +import { + isAccountSignerReady, + isMainAccountSignerReady, +} from './accountSigner.js'; +import { AgentSignerUnavailableError } from './agentSigner.js'; +import type { HyperLiquidWalletParams } from './HyperLiquidClientService.js'; // Mirrors KeyringTypes from @metamask/keyring-controller. Inlined to keep this // service portable between mobile and the core monorepo. @@ -27,6 +39,25 @@ const HARDWARE_KEYRING_TYPES = new Set([ 'QR Hardware Wallet Device', ]); +/** + * Returns the agent that signs L1 actions for a main account, or null to sign + * them with the main account. + */ +type AgentResolver = (mainAddress: Hex) => Promise; + +/** + * Whether a signing request is an L1 action. + * + * @param params - The typed data the SDK asked the wallet to sign. + * @returns True for L1 actions. + */ +function isL1Action(params: PerpsTypedDataPayload): boolean { + return ( + params.primaryType === HYPERLIQUID_L1_ACTION_PRIMARY_TYPE && + params.domain.name === HYPERLIQUID_L1_ACTION_DOMAIN_NAME + ); +} + /** * Service for MetaMask wallet integration with HyperLiquid SDK * Provides wallet adapter that implements AbstractWindowEthereum interface @@ -39,31 +70,47 @@ export class HyperLiquidWalletService { readonly #messenger: PerpsControllerMessengerBase; + readonly #resolveAgent: AgentResolver | undefined; + constructor( deps: PerpsPlatformDependencies, messenger: PerpsControllerMessengerBase, - options: { isTestnet?: boolean } = {}, + options: { isTestnet?: boolean; resolveAgent?: AgentResolver } = {}, ) { this.#deps = deps; this.#messenger = messenger; this.#isTestnet = options.isTestnet ?? false; + this.#resolveAgent = options.resolveAgent; } /** - * Check if the keyring is currently unlocked + * Check whether the main account can sign now: the injected account + * signer's readiness when one is set, else the keyring's unlock state. * - * @returns True if the keyring is unlocked and available for signing. + * @returns True when the main account is available for signing. */ - public isKeyringUnlocked(): boolean { - return this.#messenger.call('KeyringController:getState').isUnlocked; + public isMainAccountSignerReady(): boolean { + return isMainAccountSignerReady( + this.#deps.accountSigner, + () => this.#messenger.call('KeyringController:getState').isUnlocked, + ); } /** - * Check whether the selected EVM account is backed by hardware. + * Check whether every signature of the selected EVM account needs a user + * confirmation, as with hardware. The injected account signer's + * `requiresSignatureConfirmation()` decides when it answers; otherwise the + * selected account's keyring type does. * - * @returns True for MetaMask hardware keyrings; false for software accounts. + * @returns True when signatures need a confirmation; false otherwise. */ - public isSelectedHardwareWallet(): boolean { + public requiresSignatureConfirmation(): boolean { + const declared = + this.#deps.accountSigner?.requiresSignatureConfirmation?.(); + if (declared !== undefined) { + return declared; + } + const selectedEvmAccount = getSelectedEvmAccountDetailsFromMessenger( this.#messenger, ); @@ -86,7 +133,7 @@ export class HyperLiquidWalletService { * @returns The signature string. */ async #signTypedMessage(msgParams: PerpsTypedMessageParams): Promise { - if (!this.isKeyringUnlocked()) { + if (!this.isMainAccountSignerReady()) { throw new Error(PERPS_ERROR_CODES.KEYRING_LOCKED); } // Cast needed: PerpsTypedMessageParams uses loose `data: unknown` type @@ -101,88 +148,97 @@ export class HyperLiquidWalletService { } /** - * Create wallet adapter that implements AbstractViemJsonRpcAccount interface - * Required by @nktkas/hyperliquid SDK for signing transactions + * Resolve the selected main account. It is read on every signature so an + * account switch cannot race a cached adapter. * - * @returns The wallet adapter with address, signTypedData, and getChainId methods. + * @returns The selected main account address. */ - 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; - } { - // Get current EVM account via DI messenger + #getSelectedMainAddress(): Hex { const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); if (!evmAccount?.address) { throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); } - const address = evmAccount.address as Hex; + return evmAccount.address as Hex; + } - 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, - ); + /** + * Sign typed data with the main account: through the injected account + * signer when one is set, else through the keyring. + * + * @param mainAddress - The selected main account. + * @param params - The typed data the SDK asked the wallet to sign. + * @returns The signature. + */ + async #signWithMainAccount( + mainAddress: Hex, + params: PerpsTypedDataPayload, + ): Promise { + this.#deps.debugLogger.log('HyperLiquidWalletService: Signing typed data', { + address: mainAddress, + primaryType: params.primaryType, + domain: params.domain, + }); - if (!currentEvmAccount?.address) { - throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); + const { accountSigner } = this.#deps; + if (accountSigner) { + if (!isAccountSignerReady(accountSigner)) { + throw new Error(PERPS_ERROR_CODES.KEYRING_LOCKED); + } + try { + return await accountSigner.signTypedData(mainAddress, params); + } catch (error) { + // A signer that locked while signing throws its own error. + if (!isAccountSignerReady(accountSigner)) { + throw new Error(PERPS_ERROR_CODES.KEYRING_LOCKED, { cause: error }); } + throw error; + } + } - const currentAddress = currentEvmAccount.address as Hex; + const signature = await this.#signTypedMessage({ + from: mainAddress, + data: params, + }); - // Construct EIP-712 typed data - const typedData = { - domain: params.domain, - types: params.types, - primaryType: params.primaryType, - message: params.message, - }; + return signature as Hex; + } + /** + * Create the wallet adapter the HyperLiquid SDK signs with. + * + * Every signature is for the currently selected main account. When the + * agent resolver returns an agent for that account, L1 actions (orders, + * cancels, leverage, ...) are signed by the agent. User-signed actions + * (builder fee, withdraw, ...) authorize the main account itself, so the + * main account always signs them. + * + * @returns The wallet adapter with address, signTypedData, and getChainId methods. + */ + public createWalletAdapter(): HyperLiquidWalletParams { + return { + address: this.#getSelectedMainAddress(), + signTypedData: async (params: PerpsTypedDataPayload): Promise => { + const mainAddress = this.#getSelectedMainAddress(); + const agentSigner = + this.#resolveAgent && isL1Action(params) + ? await this.#resolveAgent(mainAddress) + : null; + if (!agentSigner) { + return await this.#signWithMainAccount(mainAddress, params); + } this.#deps.debugLogger.log( - 'HyperLiquidWalletService: Signing typed data', - { - address: currentAddress, - primaryType: params.primaryType, - domain: params.domain, - }, + 'HyperLiquidWalletService: Signing L1 action with agent', + { address: mainAddress, agent: agentSigner.address }, ); - - // Use messenger to sign typed data - const signature = await this.#signTypedMessage({ - from: currentAddress, - data: typedData, - }); - - return signature as Hex; + try { + return await agentSigner.signTypedData(params); + } catch (error) { + // The host could not sign with its agent key (for example it locked + // after resolving it). Retryable, like a locked keyring. + throw new AgentSignerUnavailableError(error); + } }, getChainId: async (): Promise => parseInt(getChainId(this.#isTestnet), 10), diff --git a/packages/perps-controller/src/services/LighterWalletService.ts b/packages/perps-controller/src/services/LighterWalletService.ts index 09498a5f77d..9f0dc8004e6 100644 --- a/packages/perps-controller/src/services/LighterWalletService.ts +++ b/packages/perps-controller/src/services/LighterWalletService.ts @@ -13,9 +13,9 @@ * 2. Venue-key (Schnorr/ECgFp5) signatures over L2 transactions, produced * inside the injected signer bridge from client-managed key material. * - * Signature routing goes through - * `KeyringController:signPersonalMessage` when a messenger is available, - * or through an injected `LighterPersonalSigner` for headless use. + * Signature routing goes through the injected `accountSigner` when one is + * set, else `KeyringController:signPersonalMessage`. The L1 address always + * comes from the messenger's selected account. */ import { bytesToHex } from '@metamask/utils'; @@ -24,11 +24,12 @@ import type { Hex } from '@metamask/utils'; import type { PerpsControllerMessenger } from '../PerpsController.js'; import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; import type { PerpsPlatformDependencies } from '../types/index.js'; -import type { - LighterNetwork, - LighterPersonalSigner, -} from '../types/lighter-types.js'; +import type { LighterNetwork } from '../types/lighter-types.js'; import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils.js'; +import { + isAccountSignerReady, + isMainAccountSignerReady, +} from './accountSigner.js'; export class LighterWalletService { #isTestnet: boolean; @@ -37,23 +38,15 @@ export class LighterWalletService { readonly #messenger: PerpsControllerMessenger | undefined; - readonly #personalSigner: LighterPersonalSigner | undefined; - - readonly #l1Address: string | undefined; - constructor( deps: PerpsPlatformDependencies, options: { isTestnet?: boolean; messenger?: PerpsControllerMessenger; - personalSigner?: LighterPersonalSigner; - l1Address?: string; } = {}, ) { this.#deps = deps; this.#messenger = options.messenger; - this.#personalSigner = options.personalSigner; - this.#l1Address = options.l1Address; this.#isTestnet = options.isTestnet ?? true; } @@ -67,29 +60,59 @@ export class LighterWalletService { * @returns The EVM address. */ getUserAddress(): string { - if (this.#messenger) { - const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); - if (!evmAccount?.address) { - throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); - } - return evmAccount.address; - } - if (this.#l1Address) { - return this.#l1Address; + const evmAccount = this.#messenger + ? getSelectedEvmAccountFromMessenger(this.#messenger) + : undefined; + if (!evmAccount?.address) { + throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); } - throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); + return evmAccount.address; + } + + /** + * Whether the main account can sign now: the injected account signer's + * readiness when one is set, else the keyring's unlock state. + * + * @returns True when the main account is available for signing. + */ + isMainAccountSignerReady(): boolean { + return isMainAccountSignerReady( + this.#deps.accountSigner, + () => + this.#messenger?.call('KeyringController:getState').isUnlocked ?? false, + ); } /** * Sign an EIP-191 personal message with the user's L1 account. * - * Routes through the keyring when a messenger is present, else the - * injected headless signer. + * Routes through the injected account signer when one is set, else the + * keyring. * * @param message - Plaintext message to sign. * @returns 65-byte signature as 0x-prefixed hex. */ async signPersonalMessage(message: string): Promise { + const { accountSigner } = this.#deps; + if (accountSigner) { + if (!isAccountSignerReady(accountSigner)) { + throw new Error(PERPS_ERROR_CODES.KEYRING_LOCKED); + } + const address = this.getUserAddress() as Hex; + this.#deps.debugLogger.log('LighterWalletService: personal_sign', { + address, + }); + try { + return await accountSigner.signPersonalMessage(address, message); + } catch (error) { + // A signer that locked while signing throws its own error. + if (!isAccountSignerReady(accountSigner)) { + throw new Error(PERPS_ERROR_CODES.KEYRING_LOCKED, { cause: error }); + } + throw error; + } + } + if (this.#messenger) { const { isUnlocked } = this.#messenger.call('KeyringController:getState'); if (!isUnlocked) { @@ -107,10 +130,6 @@ export class LighterWalletService { ); } - if (this.#personalSigner) { - return await this.#personalSigner(message); - } - throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); } diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index 388459527aa..6a0c587e132 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -8,6 +8,7 @@ import { import { isTPSLOrder } from '../constants/orderTypes.js'; import { PerpsMeasurementName } from '../constants/performanceMetrics.js'; import { PERPS_CONSTANTS } from '../constants/perpsConfig.js'; +import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; import { PerpsAnalyticsEvent, PerpsTraceNames, @@ -59,6 +60,18 @@ type AttributionTrackingData = Pick< 'entryPoint' | 'discoverySource' | 'perpDiscoverySource' | 'hlFeeRate' >; +/** + * Whether a write failed because the signer could not sign it (a locked + * keyring or account signer, or an unavailable or rejected agent). The user + * retries it; it is not an error to report. + * + * @param error - The provider result error. + * @returns True for `KEYRING_LOCKED`. + */ +function isSignerUnavailable(error: string | undefined): boolean { + return error === PERPS_ERROR_CODES.KEYRING_LOCKED; +} + /** * TradingService * @@ -1743,14 +1756,16 @@ export class TradingService { }, ); - this.#deps.logger.error( - ensureError(result.error, 'TradingService.cancelOrder'), - this.#getErrorContext('cancelOrder', { - symbol: params.symbol, - orderId: params.orderId, - providerError: result.error ?? 'Unknown error', - }), - ); + if (!isSignerUnavailable(result.error)) { + this.#deps.logger.error( + ensureError(result.error, 'TradingService.cancelOrder'), + this.#getErrorContext('cancelOrder', { + symbol: params.symbol, + orderId: params.orderId, + providerError: result.error ?? 'Unknown error', + }), + ); + } traceData = { success: false, error: result.error ?? 'Unknown error' }; } @@ -1931,13 +1946,12 @@ export class TradingService { }; }, ['orders']); // Disconnect orders stream during operation - if ( - provider.cancelOrders && - operationResult && - operationResult.failureCount > 0 - ) { - const failureSummary = operationResult.results - .filter((result) => !result.success) + // Signer failures are retryable, so only the other failures are reported. + const reportedFailures = operationResult.results.filter( + (result) => !result.success && !isSignerUnavailable(result.error), + ); + if (provider.cancelOrders && reportedFailures.length > 0) { + const failureSummary = reportedFailures .map( (result) => `${result.symbol}/${result.orderId}: ${result.error ?? 'Unknown error'}`, @@ -1946,11 +1960,12 @@ export class TradingService { this.#deps.logger.error( new Error( - `cancelOrders batch failure: ${operationResult.failureCount}/${operationResult.results.length} failed - ${failureSummary}`, + `cancelOrders batch failure: ${operationResult.failureCount}/${operationResult.results.length} failed (${reportedFailures.length} reported) - ${failureSummary}`, ), this.#getErrorContext('cancelOrders', { successCount: operationResult.successCount, failureCount: operationResult.failureCount, + reportedFailureCount: reportedFailures.length, cancelAll: params.cancelAll, }), ); @@ -2107,13 +2122,15 @@ export class TradingService { } else { traceData = { success: false, error: result.error ?? 'Unknown error' }; - this.#deps.logger.error( - ensureError(result.error, 'TradingService.closePosition'), - this.#getErrorContext('closePosition', { - symbol: params.symbol, - providerError: result.error ?? 'Unknown error', - }), - ); + if (!isSignerUnavailable(result.error)) { + this.#deps.logger.error( + ensureError(result.error, 'TradingService.closePosition'), + this.#getErrorContext('closePosition', { + symbol: params.symbol, + providerError: result.error ?? 'Unknown error', + }), + ); + } } // Track analytics (success or failure, includes partial fills) @@ -2308,13 +2325,12 @@ export class TradingService { }; } - if ( - provider.closePositions && - operationResult && - operationResult.failureCount > 0 - ) { - const failureSummary = operationResult.results - .filter((result) => !result.success) + // Signer failures are retryable, so only the other failures are reported. + const reportedFailures = operationResult.results.filter( + (result) => !result.success && !isSignerUnavailable(result.error), + ); + if (provider.closePositions && reportedFailures.length > 0) { + const failureSummary = reportedFailures .map( (result) => `${result.symbol}: ${result.error ?? 'Unknown error'}`, ) @@ -2322,11 +2338,12 @@ export class TradingService { this.#deps.logger.error( new Error( - `closePositions batch failure: ${operationResult.failureCount}/${operationResult.results.length} failed - ${failureSummary}`, + `closePositions batch failure: ${operationResult.failureCount}/${operationResult.results.length} failed (${reportedFailures.length} reported) - ${failureSummary}`, ), this.#getErrorContext('closePositions', { successCount: operationResult.successCount, failureCount: operationResult.failureCount, + reportedFailureCount: reportedFailures.length, symbols: params.symbols?.length ?? 0, closeAll: params.closeAll, }), diff --git a/packages/perps-controller/src/services/accountSigner.ts b/packages/perps-controller/src/services/accountSigner.ts new file mode 100644 index 00000000000..177e514ecb6 --- /dev/null +++ b/packages/perps-controller/src/services/accountSigner.ts @@ -0,0 +1,30 @@ +import type { PerpsAccountSigner } from '../types/index.js'; + +/** + * Whether an injected account signer can sign now. + * + * @param accountSigner - The client-provided account signer. + * @returns False only when the signer reports it is not ready. + */ +export function isAccountSignerReady( + accountSigner: PerpsAccountSigner, +): boolean { + return accountSigner.isReady?.() ?? true; +} + +/** + * Whether the main account can sign now: the injected account signer's + * readiness when one is set, else the keyring's unlock state. + * + * @param accountSigner - The client-provided account signer, if any. + * @param isKeyringUnlocked - Reads the keyring's unlock state. + * @returns True when the main account is available for signing. + */ +export function isMainAccountSignerReady( + accountSigner: PerpsAccountSigner | undefined, + isKeyringUnlocked: () => boolean, +): boolean { + return accountSigner + ? isAccountSignerReady(accountSigner) + : isKeyringUnlocked(); +} diff --git a/packages/perps-controller/src/services/agentSigner.ts b/packages/perps-controller/src/services/agentSigner.ts new file mode 100644 index 00000000000..83d95dcb1e9 --- /dev/null +++ b/packages/perps-controller/src/services/agentSigner.ts @@ -0,0 +1,106 @@ +import type { + HyperLiquidCredentials, + PerpsAgentAccount, + PerpsAgentSigner, +} from '../types/index.js'; +import { hasErrorInCauseChain } from './causeChain.js'; + +/** + * A HyperLiquid agent could not be resolved or could not sign. Like a locked + * keyring, it is retryable: the next L1 action tries again. + */ +export class AgentSignerUnavailableError extends Error { + constructor(cause: unknown) { + super('HyperLiquid agent signer unavailable', { cause }); + this.name = 'AgentSignerUnavailableError'; + } +} + +/** + * Whether an error, or any error in its cause chain, is an + * AgentSignerUnavailableError. The SDK wraps wallet failures in its own error. + * + * @param error - The caught error. + * @returns True when the agent signer was unavailable. + */ +export function isAgentSignerUnavailableError(error: unknown): boolean { + return hasErrorInCauseChain( + error, + (current) => current instanceof AgentSignerUnavailableError, + ); +} + +/** + * The key an agent is held under: its network and lowercased main account. + * + * @param account - The main account and network. + * @returns The key. + */ +export function getAgentAccountKey(account: PerpsAgentAccount): string { + return `${account.isTestnet ? 'testnet' : 'mainnet'}:${account.mainAddress.toLowerCase()}`; +} + +/** + * Explicit HyperLiquid agent bindings per network and main account, in front + * of the host's `getAgentSigner`: a binding wins, and null pins the main + * account. The owner keeps them across provider instances and drops the + * agents a provider already resolved whenever they change. + */ +export class AgentBindings { + readonly #bindings = new Map(); + + readonly #getAgentSigner: HyperLiquidCredentials['getAgentSigner']; + + constructor(getAgentSigner: HyperLiquidCredentials['getAgentSigner']) { + this.#getAgentSigner = getAgentSigner; + } + + /** + * Bind an agent to a main account and network, or pin that account to the + * main wallet with null. + * + * @param account - The main account and network. + * @param agentSigner - The agent, or null to pin the main account. + */ + set(account: PerpsAgentAccount, agentSigner: PerpsAgentSigner | null): void { + this.#bindings.set(getAgentAccountKey(account), agentSigner); + } + + /** Forget every binding, so `getAgentSigner` answers again. */ + clear(): void { + this.#bindings.clear(); + } + + /** + * Drop the binding of an agent the venue rejected (revoked or expired), so + * `getAgentSigner` answers for that account and network again. A binding + * to another agent, or a pin, is kept. + * + * @param account - The main account and network the agent signed for. + * @param agentAddress - The rejected agent's address. + */ + release(account: PerpsAgentAccount, agentAddress: string): void { + const key = getAgentAccountKey(account); + const bound = this.#bindings.get(key); + if (bound && bound.address.toLowerCase() === agentAddress.toLowerCase()) { + this.#bindings.delete(key); + } + } + + /** + * Resolve the agent for an L1 action: the binding when there is one, else + * the host's `getAgentSigner` answer. + * + * @param account - The main account and network of the L1 action. + * @returns The agent, or null to sign with the main account. + */ + readonly resolve = async ( + account: PerpsAgentAccount, + ): Promise => { + const key = getAgentAccountKey(account); + if (this.#bindings.has(key)) { + return this.#bindings.get(key) ?? null; + } + return this.#getAgentSigner ? await this.#getAgentSigner(account) : null; + }; +} diff --git a/packages/perps-controller/src/services/causeChain.ts b/packages/perps-controller/src/services/causeChain.ts new file mode 100644 index 00000000000..294909c0488 --- /dev/null +++ b/packages/perps-controller/src/services/causeChain.ts @@ -0,0 +1,23 @@ +/** + * Whether an error, or any error in its `cause` chain, matches. SDKs wrap + * wallet failures in their own errors and keep the original as `cause`. + * + * @param error - The caught error. + * @param predicate - The test for one error in the chain. + * @returns True when an error in the chain matches. + */ +export function hasErrorInCauseChain( + error: unknown, + predicate: (error: Error) => boolean, +): boolean { + let current: unknown = error; + const seen = new Set(); + while (current instanceof Error && !seen.has(current)) { + if (predicate(current)) { + return true; + } + seen.add(current); + current = current.cause; + } + return false; +} diff --git a/packages/perps-controller/src/services/providerNetwork.ts b/packages/perps-controller/src/services/providerNetwork.ts new file mode 100644 index 00000000000..bfc186c0f43 --- /dev/null +++ b/packages/perps-controller/src/services/providerNetwork.ts @@ -0,0 +1,21 @@ +import { PROVIDER_CONFIG } from '../constants/perpsConfig.js'; +import type { PerpsProviderType } from '../types/index.js'; + +/** + * Whether a provider runs on testnet. Lighter stays on testnet while + * `LIGHTER_TESTNET_ONLY` is set; every other provider follows the + * controller's network. + * + * @param providerId - The provider. + * @param isTestnet - The controller's network, when known. + * @returns True on testnet, false on mainnet, and undefined when the + * provider follows a network that is not known. + */ +export function isProviderOnTestnet( + providerId: PerpsProviderType, + isTestnet: IsTestnet, +): true | IsTestnet { + return providerId === 'lighter' && PROVIDER_CONFIG.LIGHTER_TESTNET_ONLY + ? true + : isTestnet; +} diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 0a4aecc4f1f..868c66a8b30 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1115,6 +1115,31 @@ export type HyperLiquidCredentials = { subscriptionBuilderAddressTestnet?: string; /** Dedicated subscription waiver builder for mainnet. */ subscriptionBuilderAddressMainnet?: string; + /** + * Resolves the agent approved for a main account on a network, or null to + * sign with the main account (for example while the wallet is locked). Asked + * when an L1 action (order, cancel, leverage, ...) is signed, unless the + * account and network are bound through `PerpsController:setAgentSigner`. + * An agent is kept until `setAgentSigner`, `clearAgentSigners` or a venue + * rejection; null and failures are asked again at the next L1 action. + * User-signed actions (builder fee, withdraw, ...) stay on the main account. + * An agent whose signing throws stays in use: call + * `PerpsController:clearAgentSigners` when its key locks. + */ + getAgentSigner?: ( + account: PerpsAgentAccount, + ) => Promise; + /** + * Called when the venue rejects an agent as unknown (revoked or expired, + * for example after the user approved another unnamed agent). The provider + * has dropped it, with a `setAgentSigner` binding to it, and the next L1 + * action asks `getAgentSigner` again, so re-check the approval before + * answering. The rejected action failed with `KEYRING_LOCKED`. It gets the + * agent's `address` as the `PerpsAgentSigner` supplied it. It is called + * once per rejected write, so writes already in flight with the same agent + * call it again: prompt the user at most once per agent. + */ + onAgentRejected?: (account: PerpsAgentAccount, agentAddress: Hex) => void; }; export type LighterCredentials = { @@ -2129,6 +2154,18 @@ export type PerpsProvider = { toggleTestnet(): Promise; initialize(): Promise; isReadyToTrade(): Promise; + /** + * Run the provider's deferred trading setup (for example account migration, + * builder fee, referral or venue-key registration) ahead of the first + * order. The result is described on `PerpsController.prepareTradingWallet`. + * Providers without such setup omit it. + */ + prepareTradingWallet?(): Promise; + /** + * Forget every agent the provider resolved, so the next L1 action asks its + * resolver again. Providers without agents omit it. + */ + clearAgentSigners?(): void; disconnect(): Promise; ping(timeoutMs?: number): Promise; // Lightweight WebSocket health check with configurable timeout getWebSocketConnectionState?(): WebSocketConnectionState; // Optional: get current WebSocket connection state @@ -2233,6 +2270,8 @@ export type AggregatedProviderConfig = { aggregationMode?: AggregationMode; /** Platform dependencies for logging, metrics, etc. */ infrastructure: PerpsPlatformDependencies; + /** Whether the providers run on testnet; tags the errors it logs. */ + isTestnet?: boolean; }; /** @@ -2572,6 +2611,100 @@ export type PerpsTypedMessageParams = { data: unknown; }; +/** + * EIP-712 payload produced by the HyperLiquid SDK, signed with + * `eth_signTypedData_v4` semantics. `types` includes the `EIP712Domain` + * entry derived from `domain`, as `eth_signTypedData_v4` expects. + */ +export type PerpsTypedDataPayload = { + domain: { + name: string; + version: string; + chainId: number; + verifyingContract: Hex; + }; + types: Record; + primaryType: string; + message: Record; +}; + +/** + * Client-implemented signer for the user's main EVM account. When provided, + * the wallet services sign through it and never call `KeyringController`. + * Clients that own a KeyringController omit it. The signing address still + * comes from the messenger's selected account. + */ +export type PerpsAccountSigner = { + /** + * Sign EIP-712 typed data as `address`, exactly as given. HyperLiquid's + * `domain.chainId` is not the wallet's connected chain: L1 actions signed + * without an agent use 1337, and user-signed actions (builder fee, + * withdraw, ...) use 1. A wallet that only signs for its connected chain + * (many EIP-1193 wallets) must route L1 actions through an agent (see + * `providerCredentials.hyperliquid.getAgentSigner`) and still has to sign + * user-signed actions with chain ID 1. + * + * @param address - The account that signs. + * @param payload - The typed data to sign. + * @returns A 65-byte 0x-prefixed signature. + */ + signTypedData(address: Hex, payload: PerpsTypedDataPayload): Promise; + + /** + * EIP-191 `personal_sign` as `address`. Required so a host that sets + * `accountSigner` never falls back to `KeyringController` (Lighter signs + * its venue-key registration this way). + * + * @param address - The account that signs. + * @param message - Plaintext message to sign. + * @returns A 65-byte 0x-prefixed signature. + */ + signPersonalMessage(address: Hex, message: string): Promise; + + /** + * False while the signer cannot sign (e.g. wallet disconnected). Signing + * then fails with `KEYRING_LOCKED`. Defaults to true. + */ + isReady?(): boolean; + + /** + * True when every signature needs a user confirmation (a hardware wallet, + * or an interactive wallet such as a browser extension). HyperLiquid then + * defers its optional init-time signing prompts to action time. When + * omitted, the selected account's keyring type decides. + */ + requiresSignatureConfirmation?(): boolean; +}; + +/** + * The main account and network an agent is approved for. + */ +export type PerpsAgentAccount = { + /** The main account the agent acts for. */ + mainAddress: Hex; + /** Whether the agent is approved on testnet rather than mainnet. */ + isTestnet: boolean; +}; + +/** + * Host-owned delegated signer for a venue agent (HyperLiquid API wallet). + * The host creates the key, gets it approved by the user's main account, and + * keeps it; Core only asks it to sign. A viem local account satisfies this + * shape. + */ +export type PerpsAgentSigner = { + /** The agent account address. */ + address: Hex; + + /** + * Sign EIP-712 typed data with the agent key. + * + * @param payload - The typed data to sign. + * @returns A 65-byte 0x-prefixed signature. + */ + signTypedData(payload: PerpsTypedDataPayload): Promise; +}; + /** * Minimal transaction params passed to TransactionController.addTransaction. * Only the fields PerpsController actually sets. @@ -2764,6 +2897,13 @@ export type PerpsPlatformDependencies = { */ registerTradingAddress?(caipAccountId: string): Promise; }; + + // === Account Signer (DI — for clients without a KeyringController) === + /** + * Optional signer for the user's main EVM account. When set, it takes + * precedence over the `KeyringController:*` messenger actions. + */ + accountSigner?: PerpsAccountSigner; }; /** diff --git a/packages/perps-controller/src/types/lighter-types.ts b/packages/perps-controller/src/types/lighter-types.ts index d980ed017e5..1d4cf982cc9 100644 --- a/packages/perps-controller/src/types/lighter-types.ts +++ b/packages/perps-controller/src/types/lighter-types.ts @@ -298,14 +298,6 @@ export type LighterTxResult = { // Auth Configuration // ============================================================================ -/** - * Signs an EIP-191 personal message and resolves with the 65-byte signature - * as a 0x-prefixed hex string. Injected for headless use; when a messenger - * is available the wallet service routes through - * `KeyringController:signPersonalMessage` instead. - */ -export type LighterPersonalSigner = (message: string) => Promise; - /** * Lighter auth/config passed at construction time. */ @@ -316,10 +308,6 @@ export type LighterAuthConfig = { accountIndex?: number; /** API key slot to register/use (0-254). */ apiKeyIndex?: number; - /** L1 address owning the Lighter account. */ - l1Address?: string; - /** Headless personal_sign implementation for L1 ChangePubKey approval. */ - personalSigner?: LighterPersonalSigner; }; // ============================================================================ diff --git a/packages/perps-controller/src/utils/errorUtils.ts b/packages/perps-controller/src/utils/errorUtils.ts index cca08597ac9..fb54e858cf1 100644 --- a/packages/perps-controller/src/utils/errorUtils.ts +++ b/packages/perps-controller/src/utils/errorUtils.ts @@ -5,6 +5,7 @@ import { hasProperty } from '@metamask/utils'; import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; +import { hasErrorInCauseChain } from '../services/causeChain.js'; /** * Detects expected cancellation/abort errors that should not be reported to Sentry. @@ -33,20 +34,10 @@ export function isAbortError(error: unknown): boolean { * @returns True if any error in the cause chain is KEYRING_LOCKED. */ export function isKeyringLockedError(error: unknown): boolean { - let current: unknown = error; - const seen = new Set(); - - while (current instanceof Error && !seen.has(current)) { - seen.add(current); - - if (current.message === PERPS_ERROR_CODES.KEYRING_LOCKED) { - return true; - } - - current = (current as { cause?: unknown }).cause; - } - - return false; + return hasErrorInCauseChain( + error, + (current) => current.message === PERPS_ERROR_CODES.KEYRING_LOCKED, + ); } /** diff --git a/packages/perps-controller/tests/e2e/lighter.e2e.ts b/packages/perps-controller/tests/e2e/lighter.e2e.ts index 9f1fbad0183..db4ce7d1a27 100644 --- a/packages/perps-controller/tests/e2e/lighter.e2e.ts +++ b/packages/perps-controller/tests/e2e/lighter.e2e.ts @@ -21,6 +21,12 @@ * process.exitCode = 1 on failure (advanced-orders e2e conventions). */ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; import { createHash } from 'node:crypto'; import { mkdir, writeFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; @@ -30,12 +36,16 @@ import { computeLighterMinOrderSize, LIGHTER_TESTNET_CHAIN_ID, } from '../../src/constants/lighterConfig.js'; +import type { PerpsControllerMessenger } from '../../src/PerpsController.js'; import { LighterProvider } from '../../src/providers/LighterProvider.js'; import { convertKeysToCamelCase, LighterClientService, } from '../../src/services/LighterClientService.js'; -import type { PerpsPlatformDependencies } from '../../src/types/index.js'; +import type { + PerpsAccountSigner, + PerpsPlatformDependencies, +} from '../../src/types/index.js'; import type { LighterSignerBridge } from '../../src/types/lighter-types.js'; import { createNodeWasmBridge } from './lighter/nodeWasmBridge.js'; @@ -110,6 +120,33 @@ async function createE2eSignerBridge(): Promise { }); } +/** + * Refuse to sign as any account other than the e2e viem account. + * + * @param address - The address the controller asked to sign as. + */ +function assertSignerAddress(address: string): void { + if (address.toLowerCase() !== viemAccount.address.toLowerCase()) { + throw new Error( + `accountSigner asked to sign as ${address}, expected ${viemAccount.address}`, + ); + } +} + +/** + * Signs as the e2e viem account, in place of a wallet's KeyringController. + */ +const accountSigner: PerpsAccountSigner = { + signTypedData: async (address, payload) => { + assertSignerAddress(address); + return await viemAccount.signTypedData(payload); + }, + signPersonalMessage: async (address, message) => { + assertSignerAddress(address); + return await viemAccount.signMessage({ message }); + }, +}; + /** * Minimal faithful PerpsPlatformDependencies (mirrors the mm-harness core * adapter's buildInfrastructure — read/write paths only touch loggers and @@ -164,17 +201,44 @@ function buildInfrastructure(): PerpsPlatformDependencies { removeItem: async () => undefined, }, rewards: { getPerpsDiscountForAccount: async () => null }, + accountSigner, } as unknown as PerpsPlatformDependencies; } /** - * Sign an EIP-191 personal message with the headless viem account. + * Build a PerpsController messenger whose selected account is the e2e viem + * account, the way a client without a KeyringController wires it. * - * @param message - Plaintext to sign. - * @returns 0x signature hex. + * @returns The PerpsController-namespaced messenger. */ -async function personalSigner(message: string): Promise { - return await viemAccount.signMessage({ message }); +function buildSelectedAccountMessenger(): PerpsControllerMessenger { + const root = new Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + const messenger: PerpsControllerMessenger = new Messenger({ + namespace: 'PerpsController', + parent: root, + }); + root.registerActionHandler('AccountsController:getSelectedAccount', () => ({ + id: 'lighter-e2e-account', + address: viemAccount.address, + type: 'eip155:eoa', + metadata: { + name: 'Lighter e2e', + importTime: 0, + keyring: { type: 'HD Key Tree' }, + }, + options: {}, + methods: [], + scopes: ['eip155:0'], + })); + root.delegate({ + actions: ['AccountsController:getSelectedAccount'], + messenger, + }); + return messenger; } /** @@ -336,12 +400,11 @@ async function phaseRegister(result: PhaseResult): Promise { const provider = new LighterProvider({ isTestnet: true, platformDependencies: buildInfrastructure(), + messenger: buildSelectedAccountMessenger(), signerBridge: bridge, lighterAuthConfig: { accountIndex: ACCOUNT_INDEX, apiKeyIndex: API_KEY_INDEX, - l1Address: viemAccount.address, - personalSigner, }, }); @@ -402,12 +465,11 @@ async function phaseOrderLifecycle(result: PhaseResult): Promise { const provider = new LighterProvider({ isTestnet: true, platformDependencies: buildInfrastructure(), + messenger: buildSelectedAccountMessenger(), signerBridge: bridge, lighterAuthConfig: { accountIndex: ACCOUNT_INDEX, apiKeyIndex: API_KEY_INDEX, - l1Address: viemAccount.address, - personalSigner, }, }); @@ -518,43 +580,10 @@ async function phaseOrderLifecycle(result: PhaseResult): Promise { */ async function phaseController(result: PhaseResult): Promise { const { PerpsController } = await import('../../src/PerpsController.js'); - const { Messenger, MOCK_ANY_NAMESPACE } = await import('@metamask/messenger'); - - const rootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE }); - const messenger = new Messenger({ - namespace: 'PerpsController', - parent: rootMessenger, - }); - rootMessenger.registerActionHandler( - 'AccountsController:getSelectedAccount', - () => ({ - id: 'lighter-e2e-account', - address: viemAccount.address, - type: 'eip155:eoa', - metadata: { keyring: { type: 'HD Key Tree' } }, - }), - ); - rootMessenger.registerActionHandler('KeyringController:getState', () => ({ - isUnlocked: true, - })); - rootMessenger.registerActionHandler( - 'KeyringController:signPersonalMessage', - async (msgParams: { from: string; data: string }) => { - const bytes = Buffer.from(msgParams.data.replace(/^0x/u, ''), 'hex'); - return await viemAccount.signMessage({ message: bytes.toString('utf8') }); - }, - ); - rootMessenger.delegate({ - actions: [ - 'AccountsController:getSelectedAccount', - 'KeyringController:getState', - 'KeyringController:signPersonalMessage', - ], - messenger, - }); + const messenger = buildSelectedAccountMessenger(); const controller = new PerpsController({ - messenger: messenger as never, + messenger, state: { isTestnet: true, activeProvider: 'aggregated' }, clientConfig: { providerCredentials: { @@ -673,11 +702,10 @@ async function phaseAccountStream(result: PhaseResult): Promise { const provider = new LighterProvider({ isTestnet: true, platformDependencies: buildInfrastructure(), + messenger: buildSelectedAccountMessenger(), lighterAuthConfig: { accountIndex: ACCOUNT_INDEX, apiKeyIndex: API_KEY_INDEX, - l1Address: viemAccount.address, - personalSigner, }, }); @@ -733,11 +761,10 @@ async function phasePositionsStream(result: PhaseResult): Promise { const provider = new LighterProvider({ isTestnet: true, platformDependencies: buildInfrastructure(), + messenger: buildSelectedAccountMessenger(), lighterAuthConfig: { accountIndex: ACCOUNT_INDEX, apiKeyIndex: API_KEY_INDEX, - l1Address: viemAccount.address, - personalSigner, }, }); @@ -788,12 +815,11 @@ async function phaseOrdersStream(result: PhaseResult): Promise { const provider = new LighterProvider({ isTestnet: true, platformDependencies: buildInfrastructure(), + messenger: buildSelectedAccountMessenger(), signerBridge: bridge, lighterAuthConfig: { accountIndex: ACCOUNT_INDEX, apiKeyIndex: API_KEY_INDEX, - l1Address: viemAccount.address, - personalSigner, }, }); await provider.initialize(); @@ -959,12 +985,11 @@ async function phaseClosePosition(result: PhaseResult): Promise { const provider = new LighterProvider({ isTestnet: true, platformDependencies: buildInfrastructure(), + messenger: buildSelectedAccountMessenger(), signerBridge: bridge, lighterAuthConfig: { accountIndex: ACCOUNT_INDEX, apiKeyIndex: API_KEY_INDEX, - l1Address: viemAccount.address, - personalSigner, }, }); await provider.initialize(); @@ -1174,12 +1199,11 @@ async function phaseEditOrder(result: PhaseResult): Promise { const provider = new LighterProvider({ isTestnet: true, platformDependencies: buildInfrastructure(), + messenger: buildSelectedAccountMessenger(), signerBridge: bridge, lighterAuthConfig: { accountIndex: ACCOUNT_INDEX, apiKeyIndex: API_KEY_INDEX, - l1Address: viemAccount.address, - personalSigner, }, }); await provider.initialize(); @@ -1393,12 +1417,11 @@ async function phaseHistoryReads(result: PhaseResult): Promise { const provider = new LighterProvider({ isTestnet: true, platformDependencies: buildInfrastructure(), + messenger: buildSelectedAccountMessenger(), signerBridge: bridge, lighterAuthConfig: { accountIndex: ACCOUNT_INDEX, apiKeyIndex: API_KEY_INDEX, - l1Address: viemAccount.address, - personalSigner, }, }); await provider.initialize(); @@ -1444,12 +1467,11 @@ async function phaseParityHistory(result: PhaseResult): Promise { const provider = new LighterProvider({ isTestnet: true, platformDependencies: buildInfrastructure(), + messenger: buildSelectedAccountMessenger(), signerBridge: bridge, lighterAuthConfig: { accountIndex: ACCOUNT_INDEX, apiKeyIndex: API_KEY_INDEX, - l1Address: viemAccount.address, - personalSigner, }, }); await provider.initialize(); @@ -1530,11 +1552,10 @@ async function phaseConnectionState(result: PhaseResult): Promise { const provider = new LighterProvider({ isTestnet: true, platformDependencies: buildInfrastructure(), + messenger: buildSelectedAccountMessenger(), lighterAuthConfig: { accountIndex: ACCOUNT_INDEX, apiKeyIndex: API_KEY_INDEX, - l1Address: viemAccount.address, - personalSigner, }, }); await provider.initialize(); @@ -1621,12 +1642,11 @@ async function phaseTpsl(result: PhaseResult): Promise { const provider = new LighterProvider({ isTestnet: true, platformDependencies: buildInfrastructure(), + messenger: buildSelectedAccountMessenger(), signerBridge: bridge, lighterAuthConfig: { accountIndex: ACCOUNT_INDEX, apiKeyIndex: API_KEY_INDEX, - l1Address: viemAccount.address, - personalSigner, }, }); await provider.initialize(); @@ -1877,12 +1897,11 @@ async function phaseMarginLeverage(result: PhaseResult): Promise { const provider = new LighterProvider({ isTestnet: true, platformDependencies: buildInfrastructure(), + messenger: buildSelectedAccountMessenger(), signerBridge: bridge, lighterAuthConfig: { accountIndex: ACCOUNT_INDEX, apiKeyIndex: API_KEY_INDEX, - l1Address: viemAccount.address, - personalSigner, }, }); await provider.initialize(); diff --git a/packages/perps-controller/tests/helpers/agentFixtures.ts b/packages/perps-controller/tests/helpers/agentFixtures.ts new file mode 100644 index 00000000000..0a03e2aeb29 --- /dev/null +++ b/packages/perps-controller/tests/helpers/agentFixtures.ts @@ -0,0 +1,177 @@ +import type { Hex } from '@metamask/utils'; + +import { BUILDER_FEE_CONFIG } from '../../src/constants/hyperLiquidConfig.js'; +import type { + PerpsAgentAccount, + PerpsTypedDataPayload, +} from '../../src/types/index.js'; +import { createMockEvmAccount } from './serviceMocks.js'; + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as const; + +// The payloads below spell out the SDK's domain names and primary types +// instead of reading HYPERLIQUID_L1_ACTION_DOMAIN_NAME or +// HYPERLIQUID_L1_ACTION_PRIMARY_TYPE, so the wallet adapter's routing tests +// fail if one of those constants drifts from what the SDK signs. Their +// messages only give each payload a realistic SDK shape. + +// The SDK adds the domain type to every payload it signs. +const EIP712_DOMAIN_TYPE = [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, +]; + +/** The mock main account, the one selected in the test messengers. */ +export const MAIN_ADDRESS = createMockEvmAccount().address; + +/** The main account on mainnet, as getAgentSigner is asked for it. */ +export const MAINNET_ACCOUNT: PerpsAgentAccount = { + mainAddress: MAIN_ADDRESS, + isTestnet: false, +}; + +/** The main account on testnet. */ +export const TESTNET_ACCOUNT: PerpsAgentAccount = { + mainAddress: MAIN_ADDRESS, + isTestnet: true, +}; + +/** A second main account, for account-switch and scoping cases. */ +export const OTHER_MAIN_ADDRESS = + '0x00000000000000000000000000000000000b0b01' as const; + +/** An agent address that is not the mock main account. */ +export const AGENT_ADDRESS = + '0x00000000000000000000000000000000000a9e17' as const; + +/** A second agent address, for rebinding and rejection cases. */ +export const OTHER_AGENT_ADDRESS = + '0x00000000000000000000000000000000000b0a7d' as const; + +/** A signature from the main account. */ +export const MAIN_SIGNATURE = `0x${'cd'.repeat(65)}` as const; + +/** A signature from an agent. */ +export const AGENT_SIGNATURE = `0x${'ef'.repeat(65)}` as const; + +/** A signature from the second agent. */ +export const OTHER_AGENT_SIGNATURE = `0x${'0b'.repeat(65)}` as const; + +/** + * A user-signed action as the HyperLiquid SDK builds it (the + * HyperliquidSignTransaction domain), for the mock main account. + */ +export const USER_SIGNED_PAYLOAD: PerpsTypedDataPayload = { + domain: { + name: 'HyperliquidSignTransaction', + version: '1', + chainId: 1, + verifyingContract: ZERO_ADDRESS, + }, + types: { + EIP712Domain: EIP712_DOMAIN_TYPE, + 'HyperliquidTransaction:UserSetAbstraction': [ + { name: 'hyperliquidChain', type: 'string' }, + { name: 'user', type: 'address' }, + { name: 'abstraction', type: 'string' }, + { name: 'nonce', type: 'uint64' }, + ], + }, + primaryType: 'HyperliquidTransaction:UserSetAbstraction', + message: { + hyperliquidChain: 'Mainnet', + user: MAIN_ADDRESS, + abstraction: 'unifiedAccount', + nonce: 1, + }, +}; + +/** + * A builder fee approval as the HyperLiquid SDK builds it for the mainnet + * builder and fee rate the provider requests: user-signed, like the + * migration, but a different action. + */ +export const APPROVE_BUILDER_FEE_PAYLOAD: PerpsTypedDataPayload = { + domain: USER_SIGNED_PAYLOAD.domain, + types: { + EIP712Domain: EIP712_DOMAIN_TYPE, + '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: BUILDER_FEE_CONFIG.MaxFeeRate, + builder: BUILDER_FEE_CONFIG.MainnetBuilder, + nonce: 1, + }, +}; + +/** An L1 action as the HyperLiquid SDK builds it (the Exchange domain). */ +export const L1_PAYLOAD: PerpsTypedDataPayload = { + domain: { + name: 'Exchange', + version: '1', + chainId: 1337, + verifyingContract: ZERO_ADDRESS, + }, + types: { + EIP712Domain: EIP712_DOMAIN_TYPE, + Agent: [ + { name: 'source', type: 'string' }, + { name: 'connectionId', type: 'bytes32' }, + ], + }, + primaryType: 'Agent', + message: { source: 'a', connectionId: `0x${'22'.repeat(32)}` }, +}; + +/** + * The error the HyperLiquid SDK throws when the wallet fails to sign, with + * the wallet's error as its cause. + * + * @param cause - The wallet's error. + * @returns The SDK error. + */ +export function sdkSigningError(cause: unknown): Error { + return new Error('Failed to sign the typed data using the wallet', { + cause, + }); +} + +/** + * HyperLiquid's rejection of a signer it does not know: a revoked or expired + * agent, or a wallet with no account yet. + * + * @param address - The signer the venue names. + * @returns The venue error. + */ +export function unknownWalletError(address: string): Error { + return new Error(`User or API Wallet ${address} does not exist.`); +} + +/** + * Sign through a wallet the way the HyperLiquid SDK does: a failure is + * wrapped with the wallet's error as its cause. + * + * @param wallet - The wallet the SDK was built with. + * @param wallet.signTypedData - Signs a typed-data payload. + * @param payload - The payload to sign. + * @returns The signature. + */ +export async function signThroughWallet( + wallet: { signTypedData: (payload: PerpsTypedDataPayload) => Promise }, + payload: PerpsTypedDataPayload, +): Promise { + try { + return await wallet.signTypedData(payload); + } catch (error) { + throw sdkSigningError(error); + } +} diff --git a/packages/perps-controller/tests/helpers/hyperLiquidAccountSignerFixture.ts b/packages/perps-controller/tests/helpers/hyperLiquidAccountSignerFixture.ts new file mode 100644 index 00000000000..4b9493970d6 --- /dev/null +++ b/packages/perps-controller/tests/helpers/hyperLiquidAccountSignerFixture.ts @@ -0,0 +1,427 @@ +/** + * The shared fixture of the HyperLiquidProvider account-signer tests: a real + * provider, wallet service and signing caches over mocked client and + * subscription services. + * + * By default the messenger has no KeyringController (the `keyring` option + * adds one), so every main-account signature must reach the injected + * accountSigner. The SDK exchange client is the mocked boundary: like the + * SDK, it signs through the wallet the provider initialized it with. + * + * Every test file that uses it must mock both services itself, since + * jest.mock is hoisted per file: + * + * jest.mock('../../../src/services/HyperLiquidClientService'); + * jest.mock('../../../src/services/HyperLiquidSubscriptionService'); + */ +import type { Hex } from '@metamask/utils'; +import { HyperliquidError } from '@nktkas/hyperliquid'; + +import { + BUILDER_FEE_CONFIG, + REFERRAL_CONFIG, +} from '../../src/constants/hyperLiquidConfig.js'; +import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider.js'; +import type { AgentBindings } from '../../src/services/agentSigner.js'; +import { HyperLiquidClientService } from '../../src/services/HyperLiquidClientService.js'; +import type { HyperLiquidWalletParams } from '../../src/services/HyperLiquidClientService.js'; +import { HyperLiquidSubscriptionService } from '../../src/services/HyperLiquidSubscriptionService.js'; +import { + PerpsSigningCache, + TradingReadinessCache, +} from '../../src/services/TradingReadinessCache.js'; +import { + HL_ABSTRACTION_WIRE, + HL_UNIFIED_ACCOUNT_MODE, +} from '../../src/types/hyperliquid-types.js'; +import type { + HyperLiquidCredentials, + PerpsAgentAccount, + PerpsAgentSigner, + PerpsPlatformDependencies, + PerpsTypedDataPayload, +} from '../../src/types/index.js'; +import { + AGENT_ADDRESS, + AGENT_SIGNATURE, + APPROVE_BUILDER_FEE_PAYLOAD, + L1_PAYLOAD, + MAIN_ADDRESS, + MAIN_SIGNATURE, + USER_SIGNED_PAYLOAD, + signThroughWallet, +} from './agentFixtures.js'; +import { + createMockExchangeClient, + createMockInfoClient, +} from './providerMocks.js'; +import { + createDeferred, + createKeyringMessenger, + createKeyringlessMessenger, + createMockInfrastructure, +} from './serviceMocks.js'; + +const CACHED_PRICES: Record = { BTC: '50000', ETH: '3000' }; + +const MockedHyperLiquidClientService = + HyperLiquidClientService as jest.MockedClass; +const MockedHyperLiquidSubscriptionService = + HyperLiquidSubscriptionService as jest.MockedClass< + typeof HyperLiquidSubscriptionService + >; + +// A fixed clock for cache timestamps. +export { NOW } from './serviceMocks.js'; + +// The ID of every order the mocked exchange places. +export const RESTING_ORDER_ID = 123; + +export const BTC_MARKET_ORDER = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, +} as const; + +// The SDK writes the provider makes for the selected account on mainnet. +export const MIGRATION_WRITE = [ + { user: MAIN_ADDRESS, abstraction: HL_UNIFIED_ACCOUNT_MODE }, +]; +export const SILENT_MIGRATION_WRITE = [ + { abstraction: HL_ABSTRACTION_WIRE.unifiedAccount }, +]; +export const REFERRAL_WRITE = [{ code: REFERRAL_CONFIG.MainnetCode }]; +export const BUILDER_REFERRAL_LOOKUP = [ + { user: BUILDER_FEE_CONFIG.MainnetBuilder }, +]; +export const BUILDER_FEE_WRITE = [ + { + builder: BUILDER_FEE_CONFIG.MainnetBuilder, + maxFeeRate: BUILDER_FEE_CONFIG.MaxFeeRate, + }, +]; + +// The two ways a cancel response reaches the provider, for it.each. +export const CANCEL_DELIVERIES = [ + { delivery: 'thrown', label: 'thrown by the SDK' }, + { delivery: 'returned', label: 'returned, which the SDK does not do' }, +] as const; + +/** + * A cancel the venue answered with one status per entry. The SDK (0.33.1) + * throws it as an `ApiRequestError` whenever an entry has an error, with a + * `cancel N: ` message per failed entry; `returned` hands the same + * response back instead, which the provider still accepts defensively. + * + * @param statuses - The entries' statuses. + * @param delivery - Whether the SDK throws the response or returns it. + * @returns The response, when it is returned. + */ +export function cancelStatusesResponse( + statuses: unknown[], + delivery: 'returned' | 'thrown', +): Record { + const response = { + status: 'ok', + response: { type: 'cancel', data: { statuses } }, + }; + if (delivery === 'returned') { + return response; + } + const failures = statuses.flatMap((status, index) => + typeof status === 'object' && status !== null && 'error' in status + ? [`cancel ${index}: ${String(status.error)}`] + : [], + ); + const error = new HyperliquidError(failures.join(', ')); + error.name = 'ApiRequestError'; + throw Object.assign(error, { response }); +} + +/** + * The order ID a placement returned. + * + * @param result - The placement result. + * @param result.orderId - Its order ID, if any. + * @returns The order ID. + */ +export function orderIdOf(result: { orderId?: string }): string { + if (result.orderId === undefined) { + throw new Error('The placement returned no order ID'); + } + return result.orderId; +} + +/** + * Whether the unified-account migration is recorded as attempted for the + * selected account on mainnet. + * + * @returns True once the migration result is cached. + */ +export function migrationAttempted(): boolean { + return TradingReadinessCache.get('mainnet', MAIN_ADDRESS)?.attempted ?? false; +} + +/** + * Whether the referral write is recorded as attempted for the selected + * account on mainnet. + * + * @returns True once the referral result is cached. + */ +export function referralAttempted(): boolean { + return ( + PerpsSigningCache.getReferral('mainnet', MAIN_ADDRESS)?.attempted ?? false + ); +} + +/** + * A getAgentSigner that stays pending until the test settles it, and + * signals when it is asked. + * + * @returns The resolver mock, its answer and the "asked" signal. + */ +export function createPendingResolver(): { + getAgentSigner: jest.Mock; + answer: ReturnType>; + asked: Promise; +} { + const answer = createDeferred(); + const asked = createDeferred(); + const getAgentSigner = jest.fn(async () => { + asked.resolve(); + return await answer.promise; + }); + return { getAgentSigner, answer, asked: asked.promise }; +} + +/** + * Bind an agent the way PerpsController.setAgentSigner does: record the + * binding, then drop the agents the provider already resolved. + * + * @param provider - The provider signing L1 actions. + * @param bindings - The bindings its resolver reads. + * @param account - The main account and network. + * @param agentSigner - The agent, or null to pin the main account. + */ +export function bind( + provider: HyperLiquidProvider, + bindings: AgentBindings, + account: PerpsAgentAccount, + agentSigner: PerpsAgentSigner | null, +): void { + bindings.set(account, agentSigner); + provider.clearAgentSigners(); +} + +type AccountSignerSuite = { + mockClientService: jest.Mocked; + loggerError: jest.SpyInstance; + trackPerpsEvent: jest.SpyInstance; +}; + +let suite: + | { + mockClientService: jest.Mocked; + mockPlatformDependencies: PerpsPlatformDependencies; + } + | undefined; + +/** + * Reset the signing caches and the mocked client and subscription services + * for one test. Call it from each test file's beforeEach. + * + * @returns The mocks the tests assert on. + */ +export function setUpAccountSignerSuite(): AccountSignerSuite { + TradingReadinessCache.clearAll(); + const mockPlatformDependencies = createMockInfrastructure(); + const loggerError = jest.spyOn(mockPlatformDependencies.logger, 'error'); + const trackPerpsEvent = jest.spyOn( + mockPlatformDependencies.metrics, + 'trackPerpsEvent', + ); + const mockClientService = { + initialize: jest.fn(), + isInitialized: jest.fn().mockReturnValue(true), + isTestnetMode: jest.fn().mockReturnValue(false), + ensureInitialized: jest.fn(), + getExchangeClient: jest.fn().mockReturnValue(createMockExchangeClient()), + getInfoClient: jest.fn().mockReturnValue(createMockInfoClient()), + fetchHistoricalOrders: jest.fn().mockResolvedValue([]), + disconnect: jest.fn().mockResolvedValue(undefined), + toggleTestnet: jest.fn(), + setTestnetMode: jest.fn(), + getNetwork: jest.fn().mockReturnValue('mainnet'), + ensureSubscriptionClient: jest.fn().mockResolvedValue(undefined), + getSubscriptionClient: jest.fn(), + setOnReconnectCallback: jest.fn(), + setOnTerminateCallback: jest.fn(), + getConnectionState: jest.fn().mockReturnValue('connected'), + } as Partial as jest.Mocked; + const mockSubscriptionService = { + subscribeToPrices: jest.fn().mockResolvedValue(jest.fn()), + subscribeToPositions: jest.fn().mockReturnValue(jest.fn()), + subscribeToOrderFills: jest.fn().mockReturnValue(jest.fn()), + clearAll: jest.fn(), + isPositionsCacheInitialized: jest.fn().mockReturnValue(false), + getCachedPositionsForDex: jest.fn().mockReturnValue(null), + getFreshPositionsForAllDexs: jest.fn().mockReturnValue(null), + getCachedPositions: jest.fn().mockReturnValue([]), + updateFeatureFlags: jest.fn().mockResolvedValue(undefined), + setDexMetaCache: jest.fn(), + setDexAssetCtxsCache: jest.fn(), + getDexAssetCtxsCache: jest.fn().mockReturnValue(undefined), + getCachedPrice: jest.fn((symbol: string) => CACHED_PRICES[symbol]), + getLastAllMidsSnapshot: jest.fn().mockReturnValue(null), + isOrdersCacheInitialized: jest.fn().mockReturnValue(false), + getCachedOrders: jest.fn().mockReturnValue([]), + getOrdersCacheIfInitialized: jest.fn().mockReturnValue(null), + setUserAbstractionMode: jest.fn(), + } as Partial as jest.Mocked; + MockedHyperLiquidClientService.mockImplementation(() => mockClientService); + MockedHyperLiquidSubscriptionService.mockImplementation( + () => mockSubscriptionService, + ); + suite = { mockClientService, mockPlatformDependencies }; + return { mockClientService, loggerError, trackPerpsEvent }; +} + +type AccountSignerOptions = { + signer?: { + isReady?: () => boolean; + requiresSignatureConfirmation?: () => boolean; + }; + abstraction?: 'dexAbstraction' | 'default' | 'unifiedAccount'; + getAgentSigner?: HyperLiquidCredentials['getAgentSigner']; + onAgentRejected?: jest.Mock; + // Sign through a KeyringController instead of accountSigner. + keyring?: boolean; + // Build the provider for testnet. + isTestnet?: boolean; + // Extra SDK client methods, for the strategy order endpoints. + exchange?: Record; + info?: Record; +}; + +export type AccountSignerFixture = { + accountSignerProvider: HyperLiquidProvider; + accountSigner: { + signTypedData: jest.Mock; + signPersonalMessage: jest.Mock; + }; + agentSigner: { address: Hex; signTypedData: jest.Mock }; + call: jest.SpyInstance; + exchangeClient: ReturnType; + infoClient: ReturnType; + initialize: jest.Mock, [HyperLiquidWalletParams]>; + // The wallet the provider last initialized the SDK clients with. + sdkWallet: () => HyperLiquidWalletParams; + selectAccount: (address: Hex) => void; + deselectAccount: () => void; +}; + +/** + * Build a provider on the suite's mocks, whose SDK exchange client signs its + * writes through the wallet the provider initializes it with. + * + * @param options - How the host and the venue are set up. + * @returns The provider and its mocks. + */ +export function createAccountSignerProvider( + options: AccountSignerOptions = {}, +): AccountSignerFixture { + if (!suite) { + throw new Error('Call setUpAccountSignerSuite in beforeEach first'); + } + const { mockClientService, mockPlatformDependencies } = suite; + const accountSigner = { + signTypedData: jest.fn().mockResolvedValue(MAIN_SIGNATURE), + signPersonalMessage: jest.fn(), + ...options.signer, + }; + const agentSigner = { + address: AGENT_ADDRESS, + signTypedData: jest.fn().mockResolvedValue(AGENT_SIGNATURE), + }; + const { messenger, call, selectAccount, deselectAccount } = options.keyring + ? createKeyringMessenger(MAIN_SIGNATURE) + : createKeyringlessMessenger(); + let wallet: HyperLiquidWalletParams | undefined; + const initialize = jest.fn(async (initialized: HyperLiquidWalletParams) => { + wallet = initialized; + }); + const sdkWallet = (): HyperLiquidWalletParams => { + if (!wallet) { + throw new Error('SDK used before initialize'); + } + return wallet; + }; + const signThroughSdkWallet = + ( + payload: PerpsTypedDataPayload, + response: Record = { status: 'ok' }, + ): (() => Promise>) => + async () => { + await signThroughWallet(sdkWallet(), payload); + return response; + }; + const exchangeClient = createMockExchangeClient({ + userSetAbstraction: jest.fn(signThroughSdkWallet(USER_SIGNED_PAYLOAD)), + agentSetAbstraction: jest.fn(signThroughSdkWallet(L1_PAYLOAD)), + setReferrer: jest.fn(signThroughSdkWallet(L1_PAYLOAD)), + approveBuilderFee: jest.fn( + signThroughSdkWallet(APPROVE_BUILDER_FEE_PAYLOAD), + ), + order: jest.fn( + signThroughSdkWallet(L1_PAYLOAD, { + status: 'ok', + response: { + data: { statuses: [{ resting: { oid: RESTING_ORDER_ID } }] }, + }, + }), + ), + ...options.exchange, + }); + const infoClient = createMockInfoClient({ + userAbstraction: jest + .fn() + .mockResolvedValue(options.abstraction ?? 'dexAbstraction'), + ...options.info, + }); + // Each provider gets its own client service (and so its own SDK clients + // and wallet); the rest is shared with the suite's mock. + const clientService = { + ...mockClientService, + initialize, + getExchangeClient: jest.fn().mockReturnValue(exchangeClient), + getInfoClient: jest.fn().mockReturnValue(infoClient), + } as Partial as jest.Mocked; + MockedHyperLiquidClientService.mockImplementationOnce(() => clientService); + const accountSignerProvider = new HyperLiquidProvider({ + platformDependencies: options.keyring + ? mockPlatformDependencies + : { ...mockPlatformDependencies, accountSigner }, + messenger, + isTestnet: options.isTestnet, + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ], + getAgentSigner: options.getAgentSigner, + onAgentRejected: options.onAgentRejected, + }); + return { + accountSignerProvider, + accountSigner, + agentSigner, + call, + exchangeClient, + infoClient, + initialize, + sdkWallet, + selectAccount, + deselectAccount, + }; +} diff --git a/packages/perps-controller/tests/helpers/providerMocks.ts b/packages/perps-controller/tests/helpers/providerMocks.ts index 7f821295a68..1880bde4a16 100644 --- a/packages/perps-controller/tests/helpers/providerMocks.ts +++ b/packages/perps-controller/tests/helpers/providerMocks.ts @@ -5,6 +5,8 @@ */ import { type HyperLiquidProvider } from '@metamask/perps-controller'; +import { REFERRAL_CONFIG } from '../../src/constants/hyperLiquidConfig.js'; + export const createMockHyperLiquidProvider = (): jest.Mocked => ({ @@ -118,3 +120,225 @@ export const createMockPosition = (overrides = {}) => ({ timestamp: Date.now(), ...overrides, }); + +// HyperLiquid SDK info and exchange client mocks for provider tests. +/** + * An order as HyperLiquid's `frontendOpenOrders` returns it. + * + * @param overrides - Fields that differ from a resting BTC limit buy. + * @returns The open order. + */ +export function createFrontendOpenOrder( + overrides: Record = {}, +): Record { + return { + coin: 'BTC', + side: 'B', + limitPx: '49000', + sz: '0.1', + origSz: '0.1', + oid: 123, + timestamp: 1, + orderType: 'Limit', + tif: 'Gtc', + isTrigger: false, + triggerPx: '0', + triggerCondition: 'N/A', + reduceOnly: false, + isPositionTpsl: false, + cloid: null, + children: [], + ...overrides, + }; +} + +export const createMockInfoClient = ( + overrides: Record = {}, +) => ({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.1', + entryPx: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + maxLeverage: 50, + returnOnEquity: '20', + cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + }, + type: 'oneWay', + }, + { + position: { + coin: 'ETH', + szi: '1.5', + entryPx: '3000', + positionValue: '4500', + unrealizedPnl: '50', + marginUsed: '450', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '2700', + maxLeverage: 50, + returnOnEquity: '10', + cumFunding: { allTime: '5', sinceOpen: '2', sinceChange: '1' }, + }, + type: 'oneWay', + }, + ], + crossMarginSummary: { + accountValue: '10000', + totalMarginUsed: '5000', + }, + }), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', hold: '1000', total: '10000' }], + }), + // Mode-aware fold gate reads userAbstraction; default to unifiedAccount + // so tests that predated the gate still see spot folded into spendable/withdrawable. + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + // Single-signer account by default; Hyperliquid returns null when the user + // has no multi-sig signer set. + userToMultiSigSigners: jest.fn().mockResolvedValue(null), + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + { + funding: '0.0001', + openInterest: '500', + prevDayPx: '2900', + dayNtlVlm: '500000', + markPx: '3000', + midPx: '3000', + oraclePx: '3000', + }, + ], + ]), + perpDexs: jest.fn().mockResolvedValue([null]), + allMids: jest.fn().mockResolvedValue({ BTC: '50000', ETH: '3000' }), + frontendOpenOrders: jest.fn().mockResolvedValue([]), + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: REFERRAL_CONFIG.MainnetCode }, + }, + }), + maxBuilderFee: jest.fn().mockResolvedValue(1), + userFees: jest.fn().mockResolvedValue({ + feeSchedule: { + cross: '0.00030', + add: '0.00010', + spotCross: '0.00040', + spotAdd: '0.00020', + }, + dailyUserVlm: [], + }), + userNonFundingLedgerUpdates: jest.fn().mockResolvedValue([ + { + delta: { type: 'deposit', usdc: '100' }, + time: Date.now(), + hash: '0x123abc', + }, + { + delta: { type: 'withdraw', usdc: '50' }, + time: Date.now() - 3600000, + hash: '0x456def', + }, + ]), + portfolio: jest.fn().mockResolvedValue([ + null, + [ + null, + { + accountValueHistory: [ + [Date.now() - 86400000, '10000'], // 24h ago + [Date.now() - 172800000, '9500'], // 48h ago + [Date.now() - 259200000, '9000'], // 72h ago + ], + }, + ], + ]), + spotMeta: jest.fn().mockResolvedValue({ + tokens: [ + { name: 'USDC', tokenId: '0xdef456', index: 0 }, + { name: 'USDT', tokenId: '0x789abc', index: 1 }, + ], + universe: [], + }), + historicalOrders: jest.fn().mockResolvedValue([]), + userFills: jest.fn().mockResolvedValue([]), + userFillsByTime: jest.fn().mockResolvedValue([]), + userFunding: jest.fn().mockResolvedValue([]), + ...overrides, +}); + +export const createMockExchangeClient = ( + overrides: Record = {}, +) => ({ + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 123 } }] } }, + }), + modify: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: '123' } }] } }, + }), + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }), + withdraw3: jest.fn().mockResolvedValue({ + status: 'ok', + }), + updateLeverage: jest.fn().mockResolvedValue({ + status: 'ok', + }), + updateIsolatedMargin: jest.fn().mockResolvedValue({ + status: 'ok', + }), + approveBuilderFee: jest.fn().mockResolvedValue({ + status: 'ok', + }), + setReferrer: jest.fn().mockResolvedValue({ + status: 'ok', + }), + sendAsset: jest.fn().mockResolvedValue({ + status: 'ok', + }), + agentSetAbstraction: jest.fn().mockResolvedValue({ + status: 'ok', + }), + userSetAbstraction: jest.fn().mockResolvedValue({ + status: 'ok', + }), + ...overrides, +}); diff --git a/packages/perps-controller/tests/helpers/serviceMocks.ts b/packages/perps-controller/tests/helpers/serviceMocks.ts index 7c9243fa7ac..7328ceefa5e 100644 --- a/packages/perps-controller/tests/helpers/serviceMocks.ts +++ b/packages/perps-controller/tests/helpers/serviceMocks.ts @@ -4,6 +4,12 @@ * Provides reusable mock implementations for ServiceContext and related types */ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; import { type ServiceContext, type PerpsControllerState, @@ -12,6 +18,9 @@ import { type PerpsPlatformDependencies, } from '@metamask/perps-controller'; +/** A fixed clock, in milliseconds, for tests that pin `Date.now()`. */ +export const NOW = 1_700_000_000_000; + export type Deferred = { promise: Promise; resolve: (value: T | PromiseLike) => void; @@ -284,3 +293,137 @@ export const createMockMessenger = ( ...overrides, } as unknown as jest.Mocked; }; + +// The keyring type of a software (non-hardware) account. +const HD_KEYRING_TYPE = 'HD Key Tree'; + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +type AccountMessenger = { + messenger: PerpsControllerMessenger; + // The host side, to answer and delegate more of the host's actions. + rootMessenger: RootMessenger; + call: jest.SpyInstance; + selectAccount: (address: `0x${string}`) => void; + // Leave no account selected. + deselectAccount: () => void; +}; + +/** + * Create a real PerpsController messenger that delegates + * `AccountsController:getSelectedAccount`, and the unlocked + * `KeyringController` actions when a keyring signature is given. + * + * @param keyringType - Keyring type reported in the selected account metadata. + * @param keyringSignature - Signature the keyring returns; omit for a host + * without a KeyringController. + * @param isUnlocked - Whether the keyring reports it is unlocked. + * @returns The messenger, its host root messenger, a spy on its `call`, and + * ways to switch or clear the selected account. + */ +const createAccountMessenger = ( + keyringType: string, + keyringSignature?: string, + isUnlocked = true, +): AccountMessenger => { + const account = createMockEvmAccount(); + // Empty when no account is selected. + let selectedAddress: string = account.address; + const root: RootMessenger = new Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + const messenger: PerpsControllerMessenger = new Messenger({ + namespace: 'PerpsController', + parent: root, + }); + root.registerActionHandler('AccountsController:getSelectedAccount', () => ({ + ...account, + address: selectedAddress, + scopes: ['eip155:0'], + metadata: { ...account.metadata, keyring: { type: keyringType } }, + })); + if (keyringSignature === undefined) { + root.delegate({ + actions: ['AccountsController:getSelectedAccount'], + messenger, + }); + } else { + root.registerActionHandler('KeyringController:getState', () => ({ + isUnlocked, + keyrings: [], + })); + root.registerActionHandler( + 'KeyringController:signTypedMessage', + async () => keyringSignature, + ); + root.registerActionHandler( + 'KeyringController:signPersonalMessage', + async () => keyringSignature, + ); + root.delegate({ + actions: [ + 'AccountsController:getSelectedAccount', + 'KeyringController:getState', + 'KeyringController:signTypedMessage', + 'KeyringController:signPersonalMessage', + ], + messenger, + }); + } + return { + messenger, + rootMessenger: root, + call: jest.spyOn(messenger, 'call'), + selectAccount: (address): void => { + selectedAddress = address; + }, + deselectAccount: (): void => { + selectedAddress = ''; + }, + }; +}; + +/** + * Create a real PerpsController messenger for a host without a + * KeyringController: only `AccountsController:getSelectedAccount` is + * delegated, so any `KeyringController:*` call throws. + * + * @param keyringType - Keyring type reported in the selected account metadata. + * @returns The messenger, its host root messenger, a spy on its `call`, and + * ways to switch or clear the selected account. + */ +export const createKeyringlessMessenger = ( + keyringType = HD_KEYRING_TYPE, +): AccountMessenger => createAccountMessenger(keyringType); + +/** + * Create a real PerpsController messenger for a host with a KeyringController + * that returns `signature` for typed data and personal messages. + * + * @param signature - Signature the keyring returns. + * @param isUnlocked - Whether the keyring reports it is unlocked. + * @returns The messenger, its host root messenger, a spy on its `call`, and + * ways to switch or clear the selected account. + */ +export const createKeyringMessenger = ( + signature: string, + isUnlocked = true, +): AccountMessenger => + createAccountMessenger(HD_KEYRING_TYPE, signature, isUnlocked); + +/** + * Names of the `KeyringController:*` actions a messenger spy saw. + * + * @param call - Spy on a messenger's `call`. + * @returns The KeyringController action names, in call order. + */ +export const keyringCalls = (call: jest.SpyInstance): string[] => + call.mock.calls + .map(([action]: [unknown]) => String(action)) + .filter((action) => action.startsWith('KeyringController:')); diff --git a/packages/perps-controller/tests/public-api.test.ts b/packages/perps-controller/tests/public-api.test.ts new file mode 100644 index 00000000000..944219f21b9 --- /dev/null +++ b/packages/perps-controller/tests/public-api.test.ts @@ -0,0 +1,79 @@ +// Checks the account-signer and agent surface through the package entrypoint, +// the way a client imports it, so a dropped or renamed export fails here. +import { + HYPERLIQUID_L1_ACTION_DOMAIN_NAME, + HYPERLIQUID_L1_ACTION_PRIMARY_TYPE, + PerpsController, +} from '../src/index.js'; +import type { + PerpsAccountSigner, + PerpsAgentAccount, + PerpsAgentSigner, + PerpsControllerClearAgentSignersAction, + PerpsControllerPrepareTradingWalletAction, + PerpsControllerSetAgentSignerAction, + PerpsTypedDataPayload, +} from '../src/index.js'; + +// The SDK ships ES modules only, which Jest cannot load below Node 24.9; the +// entrypoint only needs its error class to be defined. +jest.mock('@nktkas/hyperliquid', () => ({ + HyperliquidError: class MockHyperliquidError extends Error {}, +})); + +describe('@metamask/perps-controller public API', () => { + it('exports the EIP-712 shape of a HyperLiquid L1 action', () => { + expect(HYPERLIQUID_L1_ACTION_DOMAIN_NAME).toBe('Exchange'); + expect(HYPERLIQUID_L1_ACTION_PRIMARY_TYPE).toBe('Agent'); + }); + + it('exposes the agent and preparation methods on PerpsController', () => { + expect(typeof PerpsController.prototype.setAgentSigner).toBe('function'); + expect(typeof PerpsController.prototype.clearAgentSigners).toBe('function'); + expect(typeof PerpsController.prototype.prepareTradingWallet).toBe( + 'function', + ); + }); + + it('exports the signer types and action types', () => { + const payload: PerpsTypedDataPayload = { + domain: { + name: HYPERLIQUID_L1_ACTION_DOMAIN_NAME, + version: '1', + chainId: 1337, + verifyingContract: '0x0000000000000000000000000000000000000000', + }, + types: {}, + primaryType: HYPERLIQUID_L1_ACTION_PRIMARY_TYPE, + message: {}, + }; + const accountSigner: PerpsAccountSigner = { + signTypedData: async () => '0x', + signPersonalMessage: async () => '0x', + }; + const agentSigner: PerpsAgentSigner = { + address: '0x0000000000000000000000000000000000000001', + signTypedData: async () => '0x', + }; + const account: PerpsAgentAccount = { + mainAddress: '0x0000000000000000000000000000000000000002', + isTestnet: true, + }; + const actionTypes: [ + PerpsControllerSetAgentSignerAction['type'], + PerpsControllerClearAgentSignersAction['type'], + PerpsControllerPrepareTradingWalletAction['type'], + ] = [ + 'PerpsController:setAgentSigner', + 'PerpsController:clearAgentSigners', + 'PerpsController:prepareTradingWallet', + ]; + + expect([payload, accountSigner, agentSigner, account]).toHaveLength(4); + expect(actionTypes).toStrictEqual([ + 'PerpsController:setAgentSigner', + 'PerpsController:clearAgentSigners', + 'PerpsController:prepareTradingWallet', + ]); + }); +}); diff --git a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts new file mode 100644 index 00000000000..087d91f05fb --- /dev/null +++ b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts @@ -0,0 +1,824 @@ +import type { Hex } from '@metamask/utils'; + +import { + BUILDER_FEE_CONFIG, + REFERRAL_CONFIG, +} from '../../src/constants/hyperLiquidConfig.js'; +import { + getDefaultPerpsControllerState, + PerpsController, +} from '../../src/PerpsController.js'; +import type { PerpsControllerMessenger } from '../../src/PerpsController.js'; +import { PERPS_ERROR_CODES } from '../../src/perpsErrorCodes.js'; +import type { HyperLiquidWalletParams } from '../../src/services/HyperLiquidClientService.js'; +import { TradingReadinessCache } from '../../src/services/TradingReadinessCache.js'; +import { HL_ABSTRACTION_WIRE } from '../../src/types/hyperliquid-types.js'; +import type { + HyperLiquidCredentials, + OrderResult, + PerpsAccountSigner, + PerpsAgentAccount, + PerpsAgentSigner, + PerpsTypedDataPayload, +} from '../../src/types/index.js'; +import { + AGENT_ADDRESS, + AGENT_SIGNATURE, + APPROVE_BUILDER_FEE_PAYLOAD, + L1_PAYLOAD, + MAIN_ADDRESS, + MAIN_SIGNATURE, + MAINNET_ACCOUNT, + OTHER_AGENT_ADDRESS, + OTHER_AGENT_SIGNATURE, + signThroughWallet, + TESTNET_ACCOUNT, + unknownWalletError, + USER_SIGNED_PAYLOAD, +} from '../helpers/agentFixtures.js'; +import { createMockInfoClient } from '../helpers/providerMocks.js'; +import { + createKeyringlessMessenger, + createKeyringMessenger, + createMockInfrastructure, + keyringCalls, +} from '../helpers/serviceMocks.js'; + +// The venue recovers each signer from its signature. +const SIGNERS = new Map([ + [MAIN_SIGNATURE, MAIN_ADDRESS], + [AGENT_SIGNATURE, AGENT_ADDRESS], + [OTHER_AGENT_SIGNATURE, OTHER_AGENT_ADDRESS], +]); +const OK_RESPONSE = { status: 'ok' } as const; +// What the controller returns for an order the fake venue rests, unfilled. +const PLACED_ORDER: OrderResult = { + success: true, + orderId: '7', + submittedSize: '0.1', + filledSize: undefined, + averagePrice: undefined, +}; + +type VenueWrite = { + write: string; + params: unknown; + signer: Hex | undefined; +}; + +// What the fake venue saw, and the agents it no longer knows. +const mockVenue = { + infoClient: createMockInfoClient(), + networks: [] as string[], + writes: [] as VenueWrite[], + revokedAgents: new Set(), +}; + +class MockHttpTransport { + constructor({ isTestnet }: { isTestnet: boolean }) { + mockVenue.networks.push(isTestnet ? 'testnet' : 'mainnet'); + } +} + +class MockWebSocketTransport { + readonly socket = { addEventListener: (): void => undefined }; + + async ready(): Promise { + // Connected at once. + } + + close(): void { + // Nothing to release. + } +} + +// Every write signs its action through the wallet adapter the client was +// built with, as the SDK does, and the venue rejects a revoked agent's +// signature as an unknown wallet. +class MockExchangeClient { + readonly #wallet: HyperLiquidWalletParams; + + constructor({ wallet }: { wallet: HyperLiquidWalletParams }) { + this.#wallet = wallet; + } + + async order(params: unknown): Promise { + await this.#write('order', params, L1_PAYLOAD); + return { + status: 'ok', + response: { + type: 'order', + data: { statuses: [{ resting: { oid: 7 } }] }, + }, + }; + } + + async cancel(params: unknown): Promise { + await this.#write('cancel', params, L1_PAYLOAD); + return { + status: 'ok', + response: { type: 'cancel', data: { statuses: ['success'] } }, + }; + } + + async setReferrer(params: unknown): Promise { + await this.#write('setReferrer', params, L1_PAYLOAD); + return OK_RESPONSE; + } + + async agentSetAbstraction(params: unknown): Promise { + await this.#write('agentSetAbstraction', params, L1_PAYLOAD); + return OK_RESPONSE; + } + + async userSetAbstraction(params: unknown): Promise { + await this.#write('userSetAbstraction', params, USER_SIGNED_PAYLOAD); + return OK_RESPONSE; + } + + async approveBuilderFee(params: unknown): Promise { + await this.#write('approveBuilderFee', params, APPROVE_BUILDER_FEE_PAYLOAD); + return OK_RESPONSE; + } + + async #write( + write: string, + params: unknown, + payload: PerpsTypedDataPayload, + ): Promise { + const signer = SIGNERS.get(await signThroughWallet(this.#wallet, payload)); + mockVenue.writes.push({ write, params, signer }); + if (signer && mockVenue.revokedAgents.has(signer)) { + throw unknownWalletError(signer); + } + } +} + +// The controller builds a real HyperLiquidProvider, wallet service, client +// service and subscription service; only the SDK is faked. Nothing in these +// flows subscribes, so the fake SubscriptionClient has no methods. Jest +// hoists this above the imports; the fakes are only built once a test runs. +jest.mock('@nktkas/hyperliquid', () => ({ + // The provider tells SDK errors apart with instanceof. + HyperliquidError: class HyperliquidError extends Error {}, + HttpTransport: function HttpTransport(options: { + isTestnet: boolean; + }): MockHttpTransport { + return new MockHttpTransport(options); + }, + WebSocketTransport: function WebSocketTransport(): MockWebSocketTransport { + return new MockWebSocketTransport(); + }, + InfoClient: function InfoClient(): typeof mockVenue.infoClient { + return mockVenue.infoClient; + }, + SubscriptionClient: function SubscriptionClient(options: { + transport: MockWebSocketTransport; + }): { config_: { transport: MockWebSocketTransport } } { + return { config_: options }; + }, + ExchangeClient: function ExchangeClient(options: { + wallet: HyperLiquidWalletParams; + }): MockExchangeClient { + return new MockExchangeClient(options); + }, +})); + +describe('PerpsController agent signing with a real HyperLiquid provider', () => { + let accountSigner: { + signTypedData: jest.Mock; + signPersonalMessage: jest.Mock; + }; + let agentSigner: PerpsAgentSigner & { signTypedData: jest.Mock }; + let getAgentSigner: jest.Mock; + let onAgentRejected: jest.Mock; + let infrastructure: ReturnType; + let loggerError: jest.SpyInstance; + + beforeEach(() => { + TradingReadinessCache.clearAll(); + // An account already on the unified account, with the builder fee + // approved and the referral set, so only the tested write signs. + mockVenue.infoClient = createMockInfoClient({ + // MetaMask's referral code is ready on each network. + referral: jest.fn(async ({ user }: { user: string }) => ({ + referredBy: { code: REFERRAL_CONFIG.MainnetCode }, + referrerState: { + stage: 'ready', + data: { + code: + user === BUILDER_FEE_CONFIG.TestnetBuilder + ? REFERRAL_CONFIG.TestnetCode + : REFERRAL_CONFIG.MainnetCode, + }, + }, + })), + }); + mockVenue.networks = []; + mockVenue.writes = []; + mockVenue.revokedAgents.clear(); + accountSigner = { + signTypedData: jest.fn().mockResolvedValue(MAIN_SIGNATURE), + signPersonalMessage: jest.fn(), + }; + agentSigner = createAgent(AGENT_ADDRESS, AGENT_SIGNATURE); + getAgentSigner = jest.fn().mockResolvedValue(agentSigner); + onAgentRejected = jest.fn(); + infrastructure = createMockInfrastructure(); + loggerError = jest.spyOn(infrastructure.logger, 'error'); + }); + + /** + * A real host messenger. It delegates only what the host answers: the + * selected account, an empty remote feature flag state (so the controller + * reads its defaults), the network the fee discount looks up, no synced + * watchlist, no data-lake session (so orders are not reported) and, with + * `keyring`, the KeyringController that signs as the main account. Any + * other action throws. + * + * @param keyring - The KeyringController the host exposes, if any. + * @param keyring.isUnlocked - Whether the keyring is unlocked. + * @returns The messenger and a spy on its `call`. + */ + function createHost(keyring?: { isUnlocked: boolean }): { + messenger: PerpsControllerMessenger; + call: jest.SpyInstance; + } { + const { messenger, rootMessenger, call } = keyring + ? createKeyringMessenger(MAIN_SIGNATURE, keyring.isUnlocked) + : createKeyringlessMessenger(); + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + () => ({ remoteFeatureFlags: {}, cacheTimestamp: 0 }), + ); + rootMessenger.registerActionHandler( + 'NetworkController:getState', + jest.fn().mockReturnValue({ selectedNetworkClientId: 'mainnet' }), + ); + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + jest.fn().mockReturnValue({ configuration: { chainId: '0x1' } }), + ); + rootMessenger.registerActionHandler( + 'AuthenticatedUserStorageService:getNotificationPreferences', + async () => null, + ); + rootMessenger.registerActionHandler( + 'AuthenticationController:getBearerToken', + async () => '', + ); + rootMessenger.delegate({ + actions: [ + 'RemoteFeatureFlagController:getState', + 'NetworkController:getState', + 'NetworkController:getNetworkClientById', + 'AuthenticatedUserStorageService:getNotificationPreferences', + 'AuthenticationController:getBearerToken', + ], + events: [ + 'RemoteFeatureFlagController:stateChange', + 'AccountsController:selectedAccountChange', + 'AccountTreeController:selectedAccountGroupChange', + ], + messenger, + }); + return { messenger, call }; + } + + /** + * Build a controller whose host signs with `accountSigner` and resolves + * agents with `getAgentSigner`, unless told otherwise. + * + * @param options - What the host provides. + * @param options.signer - The host's account signer; null for a host that + * signs through its KeyringController. + * @param options.hyperliquid - The host's HyperLiquid credentials. + * @param options.host - The host's messenger. + * @returns The controller and a spy on the host messenger's `call`. + */ + function createController({ + signer = accountSigner, + hyperliquid = { getAgentSigner, onAgentRejected }, + host = createHost(), + }: { + signer?: PerpsAccountSigner | null; + hyperliquid?: HyperLiquidCredentials; + host?: ReturnType; + } = {}): { controller: PerpsController; call: jest.SpyInstance } { + const controller = new PerpsController({ + messenger: host.messenger, + state: getDefaultPerpsControllerState(), + clientConfig: { providerCredentials: { hyperliquid } }, + infrastructure: signer + ? { ...infrastructure, accountSigner: signer } + : infrastructure, + deferEligibilityCheck: true, + }); + return { controller, call: host.call }; + } + + /** + * Assert the KeyringController actions a flow called, and that it reported + * no error. + * + * @param call - A spy on the host messenger's `call`. + * @param keyringActions - The KeyringController actions called; a host with + * an account signer has none. + */ + function expectQuietHost( + call: jest.SpyInstance, + keyringActions: string[] = [], + ): void { + expect(keyringCalls(call)).toStrictEqual(keyringActions); + expect(loggerError).not.toHaveBeenCalled(); + } + + /** + * Assert what a host with both agent callbacks saw during a flow: the + * accounts `getAgentSigner` was asked for, the agents reported to + * `onAgentRejected`, the KeyringController actions called, and no reported + * error. + * + * @param call - A spy on the host messenger's `call`. + * @param expected - What the host saw. + * @param expected.agentRequests - The accounts `getAgentSigner` was asked + * for, in order. + * @param expected.rejectedAgents - The account and agent of each rejection. + * @param expected.keyringActions - The KeyringController actions called; a + * host with an account signer has none. + */ + function expectHostSaw( + call: jest.SpyInstance, + { + agentRequests, + rejectedAgents = [], + keyringActions = [], + }: { + agentRequests: PerpsAgentAccount[]; + rejectedAgents?: [PerpsAgentAccount, Hex][]; + keyringActions?: string[]; + }, + ): void { + expect(getAgentSigner.mock.calls).toStrictEqual( + agentRequests.map((account) => [account]), + ); + expect(onAgentRejected.mock.calls).toStrictEqual(rejectedAgents); + expectQuietHost(call, keyringActions); + } + + /** + * An agent that signs with its own recognizable signature. + * + * @param address - The agent's address. + * @param signature - The signature it returns. + * @returns The agent signer. + */ + function createAgent( + address: Hex, + signature: Hex, + ): PerpsAgentSigner & { signTypedData: jest.Mock } { + return { + address, + signTypedData: jest.fn().mockResolvedValue(signature), + }; + } + + /** + * Place a BTC market buy through the controller. + * + * @param controller - The initialized controller. + * @returns The order result. + */ + async function placeOrder(controller: PerpsController): Promise { + return await controller.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }); + } + + /** + * The writes the venue saw, with who signed each. + * + * @returns The write names and signers, in order. + */ + function signedWrites(): [string, Hex | undefined][] { + return mockVenue.writes.map(({ write, signer }) => [write, signer]); + } + + it("signs L1 actions with the host's agent and user-signed actions with the main account", async () => { + // The builder fee is not approved yet, so the first order approves it. + mockVenue.infoClient.maxBuilderFee.mockResolvedValueOnce(0); + const { controller, call } = createController(); + await controller.init(); + + const placed = await placeOrder(controller); + + expect(placed).toStrictEqual(PLACED_ORDER); + expect(signedWrites()).toStrictEqual([ + ['approveBuilderFee', MAIN_ADDRESS], + ['order', AGENT_ADDRESS], + ]); + expect(agentSigner.signTypedData.mock.calls).toStrictEqual([[L1_PAYLOAD]]); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [MAIN_ADDRESS, APPROVE_BUILDER_FEE_PAYLOAD], + ]); + expectHostSaw(call, { agentRequests: [MAINNET_ACCOUNT] }); + }); + + it('signs the silent unified-account migration with the agent, and nothing with the main account', async () => { + // An account in default mode, which the agent migrates without a prompt. + mockVenue.infoClient.userAbstraction.mockResolvedValue('default'); + const { controller, call } = createController(); + await controller.init(); + + const result = await controller.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: true }); + expect(mockVenue.writes).toStrictEqual([ + { + write: 'agentSetAbstraction', + params: { abstraction: HL_ABSTRACTION_WIRE.unifiedAccount }, + signer: AGENT_ADDRESS, + }, + ]); + expect(agentSigner.signTypedData.mock.calls).toStrictEqual([[L1_PAYLOAD]]); + expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + expectHostSaw(call, { agentRequests: [MAINNET_ACCOUNT] }); + }); + + it('signs L1 actions with the main account when the host has no getAgentSigner', async () => { + const { controller, call } = createController({ hyperliquid: {} }); + await controller.init(); + + const placed = await placeOrder(controller); + + expect(placed).toStrictEqual(PLACED_ORDER); + expect(signedWrites()).toStrictEqual([['order', MAIN_ADDRESS]]); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [MAIN_ADDRESS, L1_PAYLOAD], + ]); + // The host has no agent callbacks to observe. + expectQuietHost(call); + }); + + it('signs L1 actions with the agent and user-signed actions through KeyringController for a host without accountSigner', async () => { + // The builder fee is not approved yet, so the first order approves it. + mockVenue.infoClient.maxBuilderFee.mockResolvedValueOnce(0); + const { controller, call } = createController({ + signer: null, + host: createHost({ isUnlocked: true }), + }); + await controller.init(); + + const placed = await placeOrder(controller); + + expect(placed).toStrictEqual(PLACED_ORDER); + expect(signedWrites()).toStrictEqual([ + ['approveBuilderFee', MAIN_ADDRESS], + ['order', AGENT_ADDRESS], + ]); + expect( + call.mock.calls.filter( + ([action]) => action === 'KeyringController:signTypedMessage', + ), + ).toStrictEqual([ + [ + 'KeyringController:signTypedMessage', + { from: MAIN_ADDRESS, data: APPROVE_BUILDER_FEE_PAYLOAD }, + 'V4', + ], + ]); + expect(agentSigner.signTypedData.mock.calls).toStrictEqual([[L1_PAYLOAD]]); + expectHostSaw(call, { + agentRequests: [MAINNET_ACCOUNT], + keyringActions: [ + 'KeyringController:getState', + 'KeyringController:getState', + 'KeyringController:signTypedMessage', + ], + }); + }); + + it('prepares nothing and reports KEYRING_LOCKED while the host keyring is locked', async () => { + const { controller, call } = createController({ + signer: null, + host: createHost({ isUnlocked: false }), + }); + await controller.init(); + + const result = await controller.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(mockVenue.networks).toStrictEqual([]); + expect(mockVenue.writes).toStrictEqual([]); + expectHostSaw(call, { + agentRequests: [], + keyringActions: ['KeyringController:getState'], + }); + }); + + it('pins L1 actions to the main account with setAgentSigner(null) until clearAgentSigners', async () => { + const { controller, call } = createController(); + await controller.init(); + + controller.setAgentSigner(MAINNET_ACCOUNT, null); + const pinnedPlaced = await placeOrder(controller); + controller.clearAgentSigners(); + const clearedPlaced = await placeOrder(controller); + + expect([pinnedPlaced, clearedPlaced]).toStrictEqual([ + PLACED_ORDER, + PLACED_ORDER, + ]); + expect(signedWrites()).toStrictEqual([ + ['order', MAIN_ADDRESS], + ['order', AGENT_ADDRESS], + ]); + expectHostSaw(call, { agentRequests: [MAINNET_ACCOUNT] }); + }); + + it('keeps a setAgentSigner binding when the HyperLiquid provider is re-created', async () => { + getAgentSigner.mockResolvedValue(null); + const boundAgent = createAgent(OTHER_AGENT_ADDRESS, OTHER_AGENT_SIGNATURE); + const { controller, call } = createController(); + await controller.init(); + controller.setAgentSigner(MAINNET_ACCOUNT, boundAgent); + + const placedBefore = await placeOrder(controller); + await controller.toggleTestnet(); + const placedOnTestnet = await placeOrder(controller); + await controller.toggleTestnet(); + const placedAfter = await placeOrder(controller); + + expect([placedBefore, placedOnTestnet, placedAfter]).toStrictEqual([ + PLACED_ORDER, + PLACED_ORDER, + PLACED_ORDER, + ]); + // Each provider built its own SDK clients. + expect(mockVenue.networks).toStrictEqual(['mainnet', 'testnet', 'mainnet']); + // The binding is for mainnet only: on testnet the host has no agent, so + // the main account signs. + expect(signedWrites()).toStrictEqual([ + ['order', OTHER_AGENT_ADDRESS], + ['order', MAIN_ADDRESS], + ['order', OTHER_AGENT_ADDRESS], + ]); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [MAIN_ADDRESS, L1_PAYLOAD], + ]); + expectHostSaw(call, { + agentRequests: [TESTNET_ACCOUNT], + }); + }); + + it('honors a setAgentSigner binding made before init', async () => { + getAgentSigner.mockResolvedValue(null); + const boundAgent = createAgent(OTHER_AGENT_ADDRESS, OTHER_AGENT_SIGNATURE); + const { controller, call } = createController(); + + controller.setAgentSigner(MAINNET_ACCOUNT, boundAgent); + await controller.init(); + const placed = await placeOrder(controller); + + expect(placed).toStrictEqual(PLACED_ORDER); + expect(signedWrites()).toStrictEqual([['order', OTHER_AGENT_ADDRESS]]); + expectHostSaw(call, { agentRequests: [] }); + }); + + it('forgets a setAgentSigner binding cleared before init', async () => { + const boundAgent = createAgent(OTHER_AGENT_ADDRESS, OTHER_AGENT_SIGNATURE); + const { controller, call } = createController(); + controller.setAgentSigner(MAINNET_ACCOUNT, boundAgent); + + controller.clearAgentSigners(); + await controller.init(); + const placed = await placeOrder(controller); + + expect(placed).toStrictEqual(PLACED_ORDER); + expect(signedWrites()).toStrictEqual([['order', AGENT_ADDRESS]]); + expect(boundAgent.signTypedData).not.toHaveBeenCalled(); + expectHostSaw(call, { agentRequests: [MAINNET_ACCOUNT] }); + }); + + it('signs with the agent bound through setAgentSigner after another was resolved', async () => { + const reboundAgent = createAgent( + OTHER_AGENT_ADDRESS, + OTHER_AGENT_SIGNATURE, + ); + const { controller, call } = createController(); + await controller.init(); + + const resolvedPlaced = await placeOrder(controller); + controller.setAgentSigner(MAINNET_ACCOUNT, reboundAgent); + const reboundPlaced = await placeOrder(controller); + controller.setAgentSigner(MAINNET_ACCOUNT, null); + const pinnedPlaced = await placeOrder(controller); + + expect([resolvedPlaced, reboundPlaced, pinnedPlaced]).toStrictEqual([ + PLACED_ORDER, + PLACED_ORDER, + PLACED_ORDER, + ]); + expect(signedWrites()).toStrictEqual([ + ['order', AGENT_ADDRESS], + ['order', OTHER_AGENT_ADDRESS], + ['order', MAIN_ADDRESS], + ]); + expectHostSaw(call, { agentRequests: [MAINNET_ACCOUNT] }); + }); + + it('tells the host about an agent the venue rejects and asks for another', async () => { + const replacementAgent = createAgent( + OTHER_AGENT_ADDRESS, + OTHER_AGENT_SIGNATURE, + ); + getAgentSigner + .mockResolvedValueOnce(agentSigner) + .mockResolvedValueOnce(replacementAgent); + mockVenue.revokedAgents.add(AGENT_ADDRESS); + const { controller, call } = createController(); + await controller.init(); + + const cancelled = await controller.cancelOrder({ + orderId: '1', + symbol: 'BTC', + }); + const placed = await placeOrder(controller); + + expect(cancelled).toStrictEqual({ + success: false, + orderId: '1', + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(placed).toStrictEqual(PLACED_ORDER); + expect(signedWrites()).toStrictEqual([ + ['cancel', AGENT_ADDRESS], + ['order', OTHER_AGENT_ADDRESS], + ]); + expectHostSaw(call, { + agentRequests: [MAINNET_ACCOUNT, MAINNET_ACCOUNT], + rejectedAgents: [[MAINNET_ACCOUNT, AGENT_ADDRESS]], + }); + }); + + it('releases a setAgentSigner binding to an agent the venue rejects', async () => { + const boundAgent = createAgent(OTHER_AGENT_ADDRESS, OTHER_AGENT_SIGNATURE); + mockVenue.revokedAgents.add(OTHER_AGENT_ADDRESS); + const { controller, call } = createController(); + await controller.init(); + controller.setAgentSigner(MAINNET_ACCOUNT, boundAgent); + + const cancelled = await controller.cancelOrder({ + orderId: '1', + symbol: 'BTC', + }); + const placed = await placeOrder(controller); + + expect(cancelled).toStrictEqual({ + success: false, + orderId: '1', + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + // The binding is gone, so the host's getAgentSigner answers. + expect(placed).toStrictEqual(PLACED_ORDER); + expect(signedWrites()).toStrictEqual([ + ['cancel', OTHER_AGENT_ADDRESS], + ['order', AGENT_ADDRESS], + ]); + expectHostSaw(call, { + agentRequests: [MAINNET_ACCOUNT], + rejectedAgents: [[MAINNET_ACCOUNT, OTHER_AGENT_ADDRESS]], + }); + }); + + // Hosts whose onAgentRejected does not take the rejection: it throws, or + // there is none. + const REJECTION_HOSTS: { + host: string; + credentials: () => HyperLiquidCredentials; + rejectedAgents: [PerpsAgentAccount, Hex][]; + }[] = [ + { + host: 'whose onAgentRejected throws', + credentials: (): HyperLiquidCredentials => ({ + getAgentSigner, + onAgentRejected: onAgentRejected.mockImplementation(() => { + throw new Error('host callback failed'); + }), + }), + rejectedAgents: [[MAINNET_ACCOUNT, OTHER_AGENT_ADDRESS]], + }, + { + host: 'without onAgentRejected', + credentials: (): HyperLiquidCredentials => ({ getAgentSigner }), + // The suite's onAgentRejected is not wired, so it stays uncalled. + rejectedAgents: [], + }, + ]; + + it.each(REJECTION_HOSTS)( + 'releases a binding to an agent the venue rejects for a host $host', + async ({ credentials, rejectedAgents }) => { + const boundAgent = createAgent( + OTHER_AGENT_ADDRESS, + OTHER_AGENT_SIGNATURE, + ); + mockVenue.revokedAgents.add(OTHER_AGENT_ADDRESS); + const { controller, call } = createController({ + hyperliquid: credentials(), + }); + await controller.init(); + controller.setAgentSigner(MAINNET_ACCOUNT, boundAgent); + + const cancelled = await controller.cancelOrder({ + orderId: '1', + symbol: 'BTC', + }); + const placed = await placeOrder(controller); + + expect(cancelled).toStrictEqual({ + success: false, + orderId: '1', + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + // The binding is gone, so the host's getAgentSigner answers. + expect(placed).toStrictEqual(PLACED_ORDER); + expect(signedWrites()).toStrictEqual([ + ['cancel', OTHER_AGENT_ADDRESS], + ['order', AGENT_ADDRESS], + ]); + expectHostSaw(call, { + agentRequests: [MAINNET_ACCOUNT], + rejectedAgents, + }); + }, + ); + + it('prepares nothing and reports KEYRING_LOCKED while the account signer is not ready', async () => { + const { controller, call } = createController({ + signer: { ...accountSigner, isReady: (): boolean => false }, + }); + await controller.init(); + + const result = await controller.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(mockVenue.networks).toStrictEqual([]); + expect(mockVenue.writes).toStrictEqual([]); + expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + expectHostSaw(call, { agentRequests: [] }); + }); + + it('prepares the migration and builder fee on the main account and the referral on the agent', async () => { + // A legacy account with no referral that has not approved the builder + // fee yet. + mockVenue.infoClient = createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('dexAbstraction'), + maxBuilderFee: jest.fn().mockResolvedValueOnce(0).mockResolvedValue(1), + }); + const { controller, call } = createController(); + await controller.init(); + + const result = await controller.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: true }); + // The user-signed migration and approval stay on the main account; the + // referral is an L1 action, so the agent signs it. + expect(mockVenue.writes).toStrictEqual([ + { + write: 'userSetAbstraction', + params: { user: MAIN_ADDRESS, abstraction: 'unifiedAccount' }, + signer: MAIN_ADDRESS, + }, + { + write: 'setReferrer', + params: { code: REFERRAL_CONFIG.MainnetCode }, + signer: AGENT_ADDRESS, + }, + { + write: 'approveBuilderFee', + params: { + builder: BUILDER_FEE_CONFIG.MainnetBuilder, + maxFeeRate: BUILDER_FEE_CONFIG.MaxFeeRate, + }, + signer: MAIN_ADDRESS, + }, + ]); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [MAIN_ADDRESS, USER_SIGNED_PAYLOAD], + [MAIN_ADDRESS, APPROVE_BUILDER_FEE_PAYLOAD], + ]); + expect(agentSigner.signTypedData.mock.calls).toStrictEqual([[L1_PAYLOAD]]); + expectHostSaw(call, { agentRequests: [MAINNET_ACCOUNT] }); + }); +}); diff --git a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts index 75c37524b8a..28539123e4d 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -6,13 +6,22 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { + AGENT_ADDRESS, + MAIN_SIGNATURE, + MAINNET_ACCOUNT, + OTHER_MAIN_ADDRESS, +} from '../helpers/agentFixtures.js'; import { createMockHyperLiquidProvider, createMockPosition, } from '../helpers/providerMocks.js'; import { + createKeyringMessenger, + createMockEvmAccount, createMockInfrastructure, createMockMessenger, + keyringCalls, } from '../helpers/serviceMocks.js'; jest.mock('@nktkas/hyperliquid', () => ({})); @@ -25,6 +34,7 @@ import { PERPS_CONSTANTS, PERPS_DISK_CACHE_MARKETS, PERPS_DISK_CACHE_USER_DATA, + PROVIDER_CONFIG, } from '../../src/constants/perpsConfig.js'; import { PerpsController, @@ -33,11 +43,14 @@ import { } from '../../src/PerpsController.js'; import type { PerpsControllerState } from '../../src/PerpsController.js'; import { PERPS_ERROR_CODES } from '../../src/perpsErrorCodes.js'; +import * as AggregatedPerpsProviderModule from '../../src/providers/AggregatedPerpsProvider.js'; import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider.js'; +import { LighterProvider } from '../../src/providers/LighterProvider.js'; import type { ServiceContext } from '../../src/services/ServiceContext.js'; import type { AccountState, GetAvailableDexsParams, + HyperLiquidCredentials, PerpsProvider, PerpsPlatformDependencies, PerpsMarketData, @@ -878,6 +891,51 @@ describe('PerpsController', () => { ); }); + it('registerLighterProvider builds Lighter on testnet with its testnet account while Lighter is pinned to testnet', () => { + jest.replaceProperty( + PROVIDER_CONFIG as { LIGHTER_TESTNET_ONLY: boolean }, + 'LIGHTER_TESTNET_ONLY', + true, + ); + const MockLighterConstructor = jest.fn(() => + createMockHyperLiquidProvider(), + ); + const messenger = createMockMessenger(); + controller = new TestablePerpsController({ + messenger, + state: getDefaultPerpsControllerState(), + clientConfig: { + providerCredentials: { + lighter: { accountIndexMainnet: 1, accountIndexTestnet: 2 }, + }, + }, + infrastructure: mockInfrastructure, + }); + + controller.testRegisterLighterProvider( + MockLighterConstructor as unknown as new ( + opts: Record, + ) => PerpsProvider, + ); + + expect(controller.state.isTestnet).toBe(false); + expect(MockLighterConstructor.mock.calls).toStrictEqual([ + [ + { + isTestnet: true, + platformDependencies: mockInfrastructure, + messenger, + signerBridge: undefined, + lighterAuthConfig: { + enabled: undefined, + accountIndex: 2, + apiKeyIndex: undefined, + }, + }, + ], + ]); + }); + it('handleLighterImportError logs debug for MODULE_NOT_FOUND errors', () => { const moduleError = Object.assign( new Error('Cannot find module ./providers/LighterProvider'), @@ -909,6 +967,512 @@ describe('PerpsController', () => { }); }); + describe('account and agent signers', () => { + /** + * A host messenger `call` that answers the keyring state and the selected + * account, and nothing else. + * + * @param options - The host state. + * @param options.isUnlocked - Whether the keyring is unlocked. + * @param options.selectedAccount - The selected account, if any. + * @returns The `call` mock. + */ + function createHostCall({ + isUnlocked, + selectedAccount = createMockEvmAccount(), + }: { + isUnlocked: boolean; + // An account with an empty address stands for no selection. A getter + // answers with the account selected at each call. + selectedAccount?: + | { address: string } + | null + | (() => { address: string } | null); + }): jest.Mock { + return jest.fn().mockImplementation((action: string) => { + if (action === 'KeyringController:getState') { + return { isUnlocked }; + } + if (action === 'AccountsController:getSelectedAccount') { + const account = + typeof selectedAccount === 'function' + ? selectedAccount() + : selectedAccount; + return account ?? undefined; + } + return undefined; + }); + } + + it('hands infrastructure.accountSigner to the HyperLiquid and Lighter providers', async () => { + const accountSigner = { + signTypedData: jest.fn(), + signPersonalMessage: jest.fn(), + }; + const messenger = createMockMessenger(); + const infrastructure = { ...mockInfrastructure, accountSigner }; + controller = new TestablePerpsController({ + messenger, + state: getDefaultPerpsControllerState(), + infrastructure, + }); + + await controller.init(); + registerMockLighterProvider(controller); + + expect( + (HyperLiquidProvider as jest.MockedClass) + .mock.calls, + ).toStrictEqual([ + [ + { + isTestnet: false, + hip3Enabled: false, + allowlistMarkets: [], + blocklistMarkets: [], + priceDeviationLimit: undefined, + platformDependencies: infrastructure, + messenger, + builderAddressTestnet: undefined, + builderAddressMainnet: undefined, + subscriptionBuilderAddressTestnet: undefined, + subscriptionBuilderAddressMainnet: undefined, + onChaseOrderMaxDistanceReached: expect.any(Function), + getAgentSigner: expect.any(Function), + onAgentRejected: expect.any(Function), + }, + ], + ]); + expect( + (LighterProvider as jest.MockedClass).mock + .calls, + ).toStrictEqual([ + [ + { + isTestnet: false, + platformDependencies: infrastructure, + messenger, + signerBridge: undefined, + lighterAuthConfig: { + enabled: undefined, + accountIndex: undefined, + apiKeyIndex: undefined, + }, + }, + ], + ]); + }); + + const agentSigner = { + address: AGENT_ADDRESS, + signTypedData: jest.fn(), + } as const; + + /** + * The agent resolver the controller handed to the last HyperLiquid + * provider it created. + * + * @returns The resolver. + */ + function getProviderAgentResolver(): NonNullable< + HyperLiquidCredentials['getAgentSigner'] + > { + const { calls } = ( + HyperLiquidProvider as jest.MockedClass + ).mock; + const resolver = calls[calls.length - 1][0].getAgentSigner; + if (!resolver) { + throw new Error('No agent resolver handed to the provider'); + } + return resolver; + } + + /** + * Register the auto-mocked LighterProvider, which has Lighter's methods + * and so no clearAgentSigners. + * + * @param target - The initialized controller. + */ + function registerMockLighterProvider( + target: TestablePerpsController, + ): void { + target.testRegisterLighterProvider( + LighterProvider as unknown as new ( + opts: Record, + ) => PerpsProvider, + ); + } + + it('rejects trading wallet preparation before init like other provider actions', async () => { + await expect(controller.prepareTradingWallet()).rejects.toThrow( + PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED, + ); + }); + + it('runs the agent and preparation actions called through the messenger after init', async () => { + const getAgentSigner = jest.fn().mockResolvedValue(agentSigner); + // A keyring host, unlocked. + const { messenger, rootMessenger } = + createKeyringMessenger(MAIN_SIGNATURE); + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + () => ({ remoteFeatureFlags: {}, cacheTimestamp: 0 }), + ); + rootMessenger.delegate({ + actions: ['RemoteFeatureFlagController:getState'], + events: [ + 'RemoteFeatureFlagController:stateChange', + 'AccountsController:selectedAccountChange', + 'AccountTreeController:selectedAccountGroupChange', + ], + messenger, + }); + mockProvider.prepareTradingWallet = jest + .fn() + .mockResolvedValue({ ready: true }); + controller = new TestablePerpsController({ + messenger, + state: getDefaultPerpsControllerState(), + clientConfig: { + providerCredentials: { hyperliquid: { getAgentSigner } }, + }, + infrastructure: mockInfrastructure, + }); + await controller.init(); + const resolve = getProviderAgentResolver(); + + rootMessenger.call( + 'PerpsController:setAgentSigner', + MAINNET_ACCOUNT, + null, + ); + const pinned = await resolve(MAINNET_ACCOUNT); + rootMessenger.call('PerpsController:clearAgentSigners'); + const cleared = await resolve(MAINNET_ACCOUNT); + const readiness = await rootMessenger.call( + 'PerpsController:prepareTradingWallet', + ); + + expect(pinned).toBeNull(); + expect(cleared).toBe(agentSigner); + expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); + expect(readiness).toStrictEqual({ ready: true }); + expect(mockProvider.prepareTradingWallet.mock.calls).toStrictEqual([[]]); + }); + + it('drops resolved agents on every provider in aggregated mode, skipping one without clearAgentSigners', async () => { + mockProvider.clearAgentSigners = jest.fn(); + controller = new TestablePerpsController({ + messenger: createMockMessenger(), + state: { + ...getDefaultPerpsControllerState(), + activeProvider: 'aggregated', + }, + infrastructure: mockInfrastructure, + }); + await controller.init(); + registerMockLighterProvider(controller); + const providers = controller.testGetProviders(); + + controller.setAgentSigner(MAINNET_ACCOUNT, agentSigner); + controller.clearAgentSigners(); + + expect([...providers.keys()]).toStrictEqual(['hyperliquid', 'lighter']); + expect(providers.get('lighter')).not.toHaveProperty('clearAgentSigners'); + expect(mockProvider.clearAgentSigners.mock.calls).toStrictEqual([[], []]); + }); + + it("builds the aggregated provider on the controller's network and live provider map", async () => { + const RealAggregatedPerpsProvider = + AggregatedPerpsProviderModule.AggregatedPerpsProvider; + let providerIdsAtConstruction: string[] = []; + const aggregatedConstructor = jest + .spyOn(AggregatedPerpsProviderModule, 'AggregatedPerpsProvider') + .mockImplementation((config) => { + providerIdsAtConstruction = [...config.providers.keys()]; + return new RealAggregatedPerpsProvider(config); + }); + controller = new TestablePerpsController({ + messenger: createMockMessenger(), + state: { + ...getDefaultPerpsControllerState(), + activeProvider: 'aggregated', + isTestnet: true, + }, + infrastructure: mockInfrastructure, + }); + + await controller.init(); + registerMockLighterProvider(controller); + + expect(aggregatedConstructor.mock.calls).toStrictEqual([ + [ + { + providers: expect.any(Map), + defaultProvider: 'hyperliquid', + infrastructure: mockInfrastructure, + isTestnet: true, + }, + ], + ]); + // Lighter registered after the aggregated provider was built, into the + // map it shares with the controller. + expect(providerIdsAtConstruction).toStrictEqual(['hyperliquid']); + const [[constructedWith]] = aggregatedConstructor.mock.calls; + expect(constructedWith.providers).toBe(controller.testGetProviders()); + expect([...constructedWith.providers.keys()]).toStrictEqual([ + 'hyperliquid', + 'lighter', + ]); + }); + + it.each([ + { + signer: 'the account signer', + canSign: true, + expected: { ready: true }, + }, + { + signer: 'the account signer', + canSign: false, + expected: { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }, + }, + { signer: 'the keyring', canSign: true, expected: { ready: true } }, + { + signer: 'the keyring', + canSign: false, + expected: { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }, + }, + ])( + 'reports readiness from $signer when the provider has no deferred setup (can sign: $canSign)', + async ({ signer, canSign, expected }) => { + const usesKeyring = signer === 'the keyring'; + const call = createHostCall({ isUnlocked: canSign }); + controller = new TestablePerpsController({ + messenger: createMockMessenger({ call }), + state: getDefaultPerpsControllerState(), + infrastructure: usesKeyring + ? mockInfrastructure + : { + ...mockInfrastructure, + accountSigner: { + signTypedData: jest.fn(), + signPersonalMessage: jest.fn(), + isReady: (): boolean => canSign, + }, + }, + }); + await controller.init(); + call.mockClear(); + + const result = await controller.prepareTradingWallet(); + + expect(result).toStrictEqual(expected); + // Only a host without an account signer is asked for its keyring. + expect(keyringCalls(call)).toStrictEqual( + usesKeyring ? ['KeyringController:getState'] : [], + ); + }, + ); + + it.each([ + { signer: 'the account signer', usesKeyring: false }, + { signer: 'the keyring', usesKeyring: true }, + ])( + 'reports KEYRING_LOCKED when the provider reports ready while $signer cannot sign', + async ({ usesKeyring }) => { + // With an account signer, only the account signer is locked: the + // keyring would answer unlocked if it were asked. + const call = createHostCall({ isUnlocked: !usesKeyring }); + // For example an aggregated provider whose providers have nothing to + // prepare, so none of them checked the signer. + mockProvider.prepareTradingWallet = jest + .fn() + .mockResolvedValue({ ready: true }); + controller = new TestablePerpsController({ + messenger: createMockMessenger({ call }), + state: getDefaultPerpsControllerState(), + infrastructure: usesKeyring + ? mockInfrastructure + : { + ...mockInfrastructure, + accountSigner: { + signTypedData: jest.fn(), + signPersonalMessage: jest.fn(), + isReady: (): boolean => false, + }, + }, + }); + await controller.init(); + call.mockClear(); + + const result = await controller.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(mockProvider.prepareTradingWallet.mock.calls).toStrictEqual([ + [], + ]); + // Only a host without an account signer is asked for its keyring. + expect(keyringCalls(call)).toStrictEqual( + usesKeyring ? ['KeyringController:getState'] : [], + ); + }, + ); + + it.each([ + { selection: 'no account', selectedAccount: null }, + { + // What the AccountsController answers with nothing selected. + selection: 'an empty account', + selectedAccount: { ...createMockEvmAccount(), address: '' }, + }, + ])( + 'reports NO_ACCOUNT_SELECTED when the provider reports ready while $selection is selected', + async ({ selectedAccount }) => { + // For example a provider without deferred setup, which checks no + // account. + mockProvider.prepareTradingWallet = jest + .fn() + .mockResolvedValue({ ready: true }); + controller = new TestablePerpsController({ + messenger: createMockMessenger({ + call: createHostCall({ isUnlocked: true, selectedAccount }), + }), + state: getDefaultPerpsControllerState(), + infrastructure: mockInfrastructure, + }); + await controller.init(); + + const result = await controller.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED, + }); + expect(mockProvider.prepareTradingWallet.mock.calls).toStrictEqual([ + [], + ]); + }, + ); + + it.each([ + { + change: 'selected', + // No account is selected when the preparation starts. + startAccount: { ...createMockEvmAccount(), address: '' }, + nextAccount: createMockEvmAccount(), + providerResult: { ready: true }, + }, + { + change: 'switched', + startAccount: createMockEvmAccount(), + nextAccount: { ...createMockEvmAccount(), address: OTHER_MAIN_ADDRESS }, + providerResult: { ready: true }, + }, + { + change: 'deselected', + startAccount: createMockEvmAccount(), + nextAccount: { ...createMockEvmAccount(), address: '' }, + providerResult: { ready: true }, + }, + { + change: 'switched', + startAccount: createMockEvmAccount(), + nextAccount: { ...createMockEvmAccount(), address: OTHER_MAIN_ADDRESS }, + // A not-ready result was prepared for the previous account too. + providerResult: { + ready: false, + error: PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, + }, + }, + ])( + 'reports PROVIDER_LIFECYCLE_STALE when the account is $change while the provider prepares (provider ready: $providerResult.ready)', + async ({ startAccount, nextAccount, providerResult }) => { + let selectedAccount: { address: string } = startAccount; + const call = createHostCall({ + isUnlocked: true, + selectedAccount: () => selectedAccount, + }); + // For example an aggregated provider preparing one provider after + // another. + mockProvider.prepareTradingWallet = jest.fn(async () => { + selectedAccount = nextAccount; + return providerResult; + }); + controller = new TestablePerpsController({ + messenger: createMockMessenger({ call }), + state: getDefaultPerpsControllerState(), + infrastructure: mockInfrastructure, + }); + await controller.init(); + + const result = await controller.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + }); + }, + ); + + it('returns the prepared result when only the casing of the selected address changes', async () => { + const { address } = createMockEvmAccount(); + let selectedAccount = { ...createMockEvmAccount(), address }; + const call = createHostCall({ + isUnlocked: true, + selectedAccount: () => selectedAccount, + }); + const prepared = { ready: true }; + mockProvider.prepareTradingWallet = jest.fn(async () => { + selectedAccount = { + ...selectedAccount, + address: `0x${address.slice(2).toUpperCase()}`, + }; + return prepared; + }); + controller = new TestablePerpsController({ + messenger: createMockMessenger({ call }), + state: getDefaultPerpsControllerState(), + infrastructure: mockInfrastructure, + }); + await controller.init(); + + const result = await controller.prepareTradingWallet(); + + expect(result).toBe(prepared); + }); + + it('returns a provider result that is not ready for another reason unchanged, without asking the keyring', async () => { + const notReady = { + ready: false, + error: PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, + }; + mockProvider.prepareTradingWallet = jest.fn().mockResolvedValue(notReady); + // A locked keyring and no selected account, which would otherwise be + // reported instead. + const call = createHostCall({ isUnlocked: false, selectedAccount: null }); + controller = new TestablePerpsController({ + messenger: createMockMessenger({ call }), + state: getDefaultPerpsControllerState(), + infrastructure: mockInfrastructure, + }); + await controller.init(); + call.mockClear(); + + const result = await controller.prepareTradingWallet(); + + expect(result).toBe(notReady); + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, + }); + expect(keyringCalls(call)).toStrictEqual([]); + }); + }); + describe('getOpenOrders with standalone mode', () => { const mockUserAddress = '0xabcdef1234567890abcdef1234567890abcdef12'; const MockedHyperLiquidProvider = HyperLiquidProvider as jest.MockedClass< diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts index 2e3a61be89f..78ae7b3607e 100644 --- a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -1,4 +1,8 @@ import { CandlePeriod } from '../../../src/constants/chartConfig.js'; +import { + PERPS_CONSTANTS, + PROVIDER_CONFIG, +} from '../../../src/constants/perpsConfig.js'; import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; import { AggregatedPerpsProvider } from '../../../src/providers/AggregatedPerpsProvider.js'; import type { @@ -15,7 +19,10 @@ import type { import { WebSocketConnectionState } from '../../../src/types/index.js'; import { STRATEGY_ORDER_TYPES } from '../../../src/utils/orderTypes.js'; /* eslint-disable */ -import { createMockInfrastructure } from '../../helpers/serviceMocks.js'; +import { + createDeferred, + createMockInfrastructure, +} from '../../helpers/serviceMocks.js'; // Create a comprehensive mock provider const createMockProvider = ( @@ -1060,6 +1067,292 @@ describe('AggregatedPerpsProvider', () => { expect(mockHLProvider.isReadyToTrade).toHaveBeenCalled(); }); + it('prepares every provider and reports the first one not ready', async () => { + const prepareHyperLiquid = jest.fn().mockResolvedValue({ ready: true }); + const prepareLighter = jest.fn().mockResolvedValue({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + Object.assign(mockHLProvider, { + prepareTradingWallet: prepareHyperLiquid, + }); + Object.assign(mockLighterProvider, { + prepareTradingWallet: prepareLighter, + }); + + const result = await aggregatedProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(prepareHyperLiquid.mock.calls).toStrictEqual([[]]); + expect(prepareLighter.mock.calls).toStrictEqual([[]]); + }); + + it('reports ready when every provider is ready or has no deferred setup', async () => { + const prepareHyperLiquid = jest.fn().mockResolvedValue({ ready: true }); + Object.assign(mockHLProvider, { + prepareTradingWallet: prepareHyperLiquid, + }); + + const result = await aggregatedProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: true }); + expect(prepareHyperLiquid.mock.calls).toStrictEqual([[]]); + }); + + it('reports the first not-ready provider when several are not ready', async () => { + const prepareHyperLiquid = jest + .fn() + .mockResolvedValue({ ready: false, error: 'first' }); + const prepareLighter = jest + .fn() + .mockResolvedValue({ ready: false, error: 'second' }); + Object.assign(mockHLProvider, { + prepareTradingWallet: prepareHyperLiquid, + }); + Object.assign(mockLighterProvider, { + prepareTradingWallet: prepareLighter, + }); + + const result = await aggregatedProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: false, error: 'first' }); + // Lighter is still prepared after HyperLiquid reported not ready. + expect(prepareHyperLiquid.mock.calls).toStrictEqual([[]]); + expect(prepareLighter.mock.calls).toStrictEqual([[]]); + }); + + it('still prepares the other providers when one throws', async () => { + const crash = new Error('provider crashed'); + const prepareLighter = jest.fn().mockResolvedValue({ ready: true }); + Object.assign(mockHLProvider, { + prepareTradingWallet: jest.fn().mockRejectedValue(crash), + }); + Object.assign(mockLighterProvider, { + prepareTradingWallet: prepareLighter, + }); + + const result = await aggregatedProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: false, error: 'provider crashed' }); + expect(prepareLighter.mock.calls).toStrictEqual([[]]); + expect( + (mockInfrastructure.logger.error as jest.Mock).mock.calls, + ).toStrictEqual([ + [ + crash, + { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: 'hyperliquid', + }, + context: { + name: 'AggregatedPerpsProvider', + data: { + method: 'prepareTradingWallet', + providerId: 'hyperliquid', + }, + }, + }, + ], + ]); + }); + + it.each([ + [ + 'nothing', + undefined, + 'Unknown error (no details provided) [AggregatedPerpsProvider.prepareTradingWallet]', + ], + ['a string', 'provider crashed', 'provider crashed'], + ])( + 'reports and logs a provider that throws %s instead of an Error', + async (_thrown, thrown, message) => { + Object.assign(mockHLProvider, { + prepareTradingWallet: jest.fn().mockRejectedValue(thrown), + }); + Object.assign(mockLighterProvider, { + prepareTradingWallet: jest.fn().mockResolvedValue({ ready: true }), + }); + + const result = await aggregatedProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: false, error: message }); + expect( + (mockInfrastructure.logger.error as jest.Mock).mock.calls, + ).toStrictEqual([ + [ + new Error(message), + { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: 'hyperliquid', + }, + context: { + name: 'AggregatedPerpsProvider', + data: { + method: 'prepareTradingWallet', + providerId: 'hyperliquid', + }, + }, + }, + ], + ]); + }, + ); + + it.each([ + [true, 'testnet'], + [false, 'mainnet'], + ])( + 'tags a logged preparation failure with the network (isTestnet: %s)', + async (isTestnet, network) => { + const networkProvider = new AggregatedPerpsProvider({ + providers: new Map([['hyperliquid', mockHLProvider]]), + defaultProvider: 'hyperliquid', + infrastructure: mockInfrastructure, + isTestnet, + }); + const crash = new Error('provider crashed'); + Object.assign(mockHLProvider, { + prepareTradingWallet: jest.fn().mockRejectedValue(crash), + }); + + await networkProvider.prepareTradingWallet(); + + expect( + (mockInfrastructure.logger.error as jest.Mock).mock.calls, + ).toStrictEqual([ + [ + crash, + { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: 'hyperliquid', + network, + }, + context: { + name: 'AggregatedPerpsProvider', + data: { + method: 'prepareTradingWallet', + providerId: 'hyperliquid', + }, + }, + }, + ], + ]); + }, + ); + + it('tags a logged Lighter failure with the network while Lighter is not pinned to testnet', async () => { + jest.replaceProperty( + PROVIDER_CONFIG as { LIGHTER_TESTNET_ONLY: boolean }, + 'LIGHTER_TESTNET_ONLY', + false, + ); + const networkProvider = new AggregatedPerpsProvider({ + providers: new Map([['lighter', mockLighterProvider]]), + defaultProvider: 'lighter', + infrastructure: mockInfrastructure, + isTestnet: false, + }); + const crash = new Error('provider crashed'); + Object.assign(mockLighterProvider, { + prepareTradingWallet: jest.fn().mockRejectedValue(crash), + }); + + await networkProvider.prepareTradingWallet(); + + expect( + (mockInfrastructure.logger.error as jest.Mock).mock.calls, + ).toStrictEqual([ + [ + crash, + { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: 'lighter', + network: 'mainnet', + }, + context: { + name: 'AggregatedPerpsProvider', + data: { method: 'prepareTradingWallet', providerId: 'lighter' }, + }, + }, + ], + ]); + }); + + it('tags a logged Lighter failure with testnet while Lighter is pinned to testnet', async () => { + jest.replaceProperty( + PROVIDER_CONFIG as { LIGHTER_TESTNET_ONLY: boolean }, + 'LIGHTER_TESTNET_ONLY', + true, + ); + const networkProvider = new AggregatedPerpsProvider({ + providers: new Map([['lighter', mockLighterProvider]]), + defaultProvider: 'lighter', + infrastructure: mockInfrastructure, + isTestnet: false, + }); + const crash = new Error('provider crashed'); + Object.assign(mockLighterProvider, { + prepareTradingWallet: jest.fn().mockRejectedValue(crash), + }); + + await networkProvider.prepareTradingWallet(); + + expect( + (mockInfrastructure.logger.error as jest.Mock).mock.calls, + ).toStrictEqual([ + [ + crash, + { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: 'lighter', + network: 'testnet', + }, + context: { + name: 'AggregatedPerpsProvider', + data: { method: 'prepareTradingWallet', providerId: 'lighter' }, + }, + }, + ], + ]); + }); + + it('prepares the next provider only after the previous one settles', async () => { + const hyperLiquidAsked = createDeferred(); + const firstPreparation = createDeferred<{ ready: boolean }>(); + const prepareHyperLiquid = jest.fn(async () => { + hyperLiquidAsked.resolve(); + return await firstPreparation.promise; + }); + const prepareLighter = jest.fn().mockResolvedValue({ ready: true }); + Object.assign(mockHLProvider, { + prepareTradingWallet: prepareHyperLiquid, + }); + Object.assign(mockLighterProvider, { + prepareTradingWallet: prepareLighter, + }); + + const preparing = aggregatedProvider.prepareTradingWallet(); + await hyperLiquidAsked.promise; + const hyperLiquidCallsBeforeSettling = [...prepareHyperLiquid.mock.calls]; + const lighterCallsBeforeSettling = [...prepareLighter.mock.calls]; + firstPreparation.resolve({ ready: true }); + const result = await preparing; + + expect(hyperLiquidCallsBeforeSettling).toStrictEqual([[]]); + expect(lighterCallsBeforeSettling).toStrictEqual([]); + expect(prepareHyperLiquid.mock.calls).toStrictEqual([[]]); + expect(prepareLighter.mock.calls).toStrictEqual([[]]); + expect(result).toStrictEqual({ ready: true }); + }); + it('delegates toggleTestnet to default provider', async () => { mockHLProvider.toggleTestnet.mockResolvedValue({ success: true, diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts index 4cee4fe3db2..df6678d1c6e 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts @@ -32,6 +32,10 @@ import { validateWithdrawalParams, } from '../../../src/utils/hyperLiquidValidation.js'; import { createStandaloneInfoClient } from '../../../src/utils/standaloneInfoClient.js'; +import { + createMockExchangeClient, + createMockInfoClient, +} from '../../helpers/providerMocks.js'; import { createMockInfrastructure, createMockMessenger, @@ -134,192 +138,6 @@ const mockValidateBalance = validateBalance as jest.MockedFunction< typeof validateBalance >; -// Mock factory functions - defined once, reused everywhere -// These reduce duplication and make tests more maintainable -const createMockInfoClient = (overrides: Record = {}) => ({ - clearinghouseState: jest.fn().mockResolvedValue({ - marginSummary: { - totalMarginUsed: '500', - accountValue: '10500', - }, - withdrawable: '9500', - assetPositions: [ - { - position: { - coin: 'BTC', - szi: '0.1', - entryPx: '50000', - positionValue: '5000', - unrealizedPnl: '100', - marginUsed: '500', - leverage: { type: 'cross', value: 10 }, - liquidationPx: '45000', - maxLeverage: 50, - returnOnEquity: '20', - cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, - }, - type: 'oneWay', - }, - { - position: { - coin: 'ETH', - szi: '1.5', - entryPx: '3000', - positionValue: '4500', - unrealizedPnl: '50', - marginUsed: '450', - leverage: { type: 'cross', value: 10 }, - liquidationPx: '2700', - maxLeverage: 50, - returnOnEquity: '10', - cumFunding: { allTime: '5', sinceOpen: '2', sinceChange: '1' }, - }, - type: 'oneWay', - }, - ], - crossMarginSummary: { - accountValue: '10000', - totalMarginUsed: '5000', - }, - }), - spotClearinghouseState: jest.fn().mockResolvedValue({ - balances: [{ coin: 'USDC', hold: '1000', total: '10000' }], - }), - // Mode-aware fold gate reads userAbstraction; default to unifiedAccount - // so tests that predated the gate still see spot folded into spendable/withdrawable. - userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), - // Single-signer account by default; Hyperliquid returns null when the user - // has no multi-sig signer set. - userToMultiSigSigners: jest.fn().mockResolvedValue(null), - meta: jest.fn().mockResolvedValue({ - universe: [ - { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, - { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, - ], - }), - metaAndAssetCtxs: jest.fn().mockResolvedValue([ - { - universe: [ - { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, - { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, - ], - }, - [ - { - funding: '0.0001', - openInterest: '1000', - prevDayPx: '49000', - dayNtlVlm: '1000000', - markPx: '50000', - midPx: '50000', - oraclePx: '50000', - }, - { - funding: '0.0001', - openInterest: '500', - prevDayPx: '2900', - dayNtlVlm: '500000', - markPx: '3000', - midPx: '3000', - oraclePx: '3000', - }, - ], - ]), - perpDexs: jest.fn().mockResolvedValue([null]), - allMids: jest.fn().mockResolvedValue({ BTC: '50000', ETH: '3000' }), - frontendOpenOrders: jest.fn().mockResolvedValue([]), - referral: jest.fn().mockResolvedValue({ - referrerState: { - stage: 'ready', - data: { code: 'MMCSI' }, - }, - }), - maxBuilderFee: jest.fn().mockResolvedValue(1), - userFees: jest.fn().mockResolvedValue({ - feeSchedule: { - cross: '0.00030', - add: '0.00010', - spotCross: '0.00040', - spotAdd: '0.00020', - }, - dailyUserVlm: [], - }), - userNonFundingLedgerUpdates: jest.fn().mockResolvedValue([ - { - delta: { type: 'deposit', usdc: '100' }, - time: Date.now(), - hash: '0x123abc', - }, - { - delta: { type: 'withdraw', usdc: '50' }, - time: Date.now() - 3600000, - hash: '0x456def', - }, - ]), - portfolio: jest.fn().mockResolvedValue([ - null, - [ - null, - { - accountValueHistory: [ - [Date.now() - 86400000, '10000'], // 24h ago - [Date.now() - 172800000, '9500'], // 48h ago - [Date.now() - 259200000, '9000'], // 72h ago - ], - }, - ], - ]), - spotMeta: jest.fn().mockResolvedValue({ - tokens: [ - { name: 'USDC', tokenId: '0xdef456', index: 0 }, - { name: 'USDT', tokenId: '0x789abc', index: 1 }, - ], - universe: [], - }), - historicalOrders: jest.fn().mockResolvedValue([]), - userFills: jest.fn().mockResolvedValue([]), - userFillsByTime: jest.fn().mockResolvedValue([]), - userFunding: jest.fn().mockResolvedValue([]), - ...overrides, -}); - -const createMockExchangeClient = (overrides: Record = {}) => ({ - order: jest.fn().mockResolvedValue({ - status: 'ok', - response: { data: { statuses: [{ resting: { oid: 123 } }] } }, - }), - modify: jest.fn().mockResolvedValue({ - status: 'ok', - response: { data: { statuses: [{ resting: { oid: '123' } }] } }, - }), - cancel: jest.fn().mockResolvedValue({ - status: 'ok', - response: { data: { statuses: ['success'] } }, - }), - withdraw3: jest.fn().mockResolvedValue({ - status: 'ok', - }), - updateLeverage: jest.fn().mockResolvedValue({ - status: 'ok', - }), - approveBuilderFee: jest.fn().mockResolvedValue({ - status: 'ok', - }), - setReferrer: jest.fn().mockResolvedValue({ - status: 'ok', - }), - sendAsset: jest.fn().mockResolvedValue({ - status: 'ok', - }), - agentSetAbstraction: jest.fn().mockResolvedValue({ - status: 'ok', - }), - userSetAbstraction: jest.fn().mockResolvedValue({ - status: 'ok', - }), - ...overrides, -}); - // Create shared mock platform dependencies for provider tests const mockPlatformDependencies: PerpsPlatformDependencies = createMockInfrastructure(); @@ -430,8 +248,8 @@ describe('HyperLiquidProvider', () => { getUserAddressWithDefault: jest .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), - isKeyringUnlocked: jest.fn().mockReturnValue(true), - isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + isMainAccountSignerReady: jest.fn().mockReturnValue(true), + requiresSignatureConfirmation: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; mockSubscriptionService = { @@ -925,10 +743,8 @@ describe('HyperLiquidProvider', () => { attempted: true, success: true, }); - // Keyring is locked - ( - mockWalletService as unknown as { isKeyringUnlocked: jest.Mock } - ).isKeyringUnlocked.mockReturnValue(false); + // The main account cannot sign. + mockWalletService.isMainAccountSignerReady.mockReturnValue(false); // Act await testableProvider.ensureReadyForTrading(); @@ -1876,7 +1692,7 @@ describe('HyperLiquidProvider', () => { ).toHaveBeenCalledWith(USER_ADDRESS, 'unifiedAccount'); }); - it('records unifiedAccount mode after migrating software-wallet dexAbstraction on init', async () => { + it('records unifiedAccount mode after migrating dexAbstraction on init when signatures need no confirmation', async () => { mockClientService.getInfoClient = jest.fn().mockReturnValue( createMockInfoClient({ userAbstraction: jest.fn().mockResolvedValue('dexAbstraction'), @@ -1894,10 +1710,10 @@ describe('HyperLiquidProvider', () => { }); it.each(['dexAbstraction', 'default', 'disabled'] as const)( - 'defers %s migration on init for hardware wallets', + 'defers %s migration on init when every signature needs confirmation', async (currentMode) => { // Arrange - mockWalletService.isSelectedHardwareWallet.mockReturnValue(true); + mockWalletService.requiresSignatureConfirmation.mockReturnValue(true); const mockExchangeClient = createMockExchangeClient(); mockClientService.getInfoClient = jest.fn().mockReturnValue( createMockInfoClient({ diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts new file mode 100644 index 00000000000..e4d560c9117 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts @@ -0,0 +1,347 @@ +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '../../../src/constants/eventNames.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { PerpsSigningCache } from '../../../src/services/TradingReadinessCache.js'; +import { PerpsAnalyticsEvent } from '../../../src/types/index.js'; +import type { PerpsTypedDataPayload } from '../../../src/types/index.js'; +import { + APPROVE_BUILDER_FEE_PAYLOAD, + MAIN_ADDRESS, + MAIN_SIGNATURE, + USER_SIGNED_PAYLOAD, +} from '../../helpers/agentFixtures.js'; +import { + BTC_MARKET_ORDER, + BUILDER_FEE_WRITE, + MIGRATION_WRITE, + createAccountSignerProvider, + migrationAttempted, + referralAttempted, + setUpAccountSignerSuite, +} from '../../helpers/hyperLiquidAccountSignerFixture.js'; +import type { AccountSignerFixture } from '../../helpers/hyperLiquidAccountSignerFixture.js'; +import { keyringCalls } from '../../helpers/serviceMocks.js'; + +// The SDK ships ES modules only; the provider reaches it through the mocked +// client service, so the module itself is never loaded. The provider checks +// cancel errors against its error class. +jest.mock('@nktkas/hyperliquid', () => ({ + HyperliquidError: class MockHyperliquidError extends Error {}, +})); + +// The client and subscription services are mocked: they own the SDK's +// REST/exchange/info clients and the WebSocket subscriptions. The wallet +// service, the signing caches and the validation run for real. +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); + +describe('HyperLiquidProvider with accountSigner: main-account signing', () => { + let loggerError: jest.SpyInstance; + let trackPerpsEvent: jest.SpyInstance; + + beforeEach(() => { + ({ loggerError, trackPerpsEvent } = setUpAccountSignerSuite()); + }); + + it('signs the init-time unified-account migration through accountSigner', async () => { + const { accountSignerProvider, accountSigner, call, exchangeClient } = + createAccountSignerProvider(); + + await accountSignerProvider.getMarketDataWithPrices(); + + expect(exchangeClient.userSetAbstraction.mock.calls).toStrictEqual([ + MIGRATION_WRITE, + ]); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [MAIN_ADDRESS, USER_SIGNED_PAYLOAD], + ]); + expect(keyringCalls(call)).toStrictEqual([]); + }); + + it('defers the init-time migration when accountSigner requires signature confirmation', async () => { + const { + accountSignerProvider, + accountSigner, + call, + exchangeClient, + infoClient, + } = createAccountSignerProvider({ + signer: { requiresSignatureConfirmation: () => true }, + }); + + await accountSignerProvider.getMarketDataWithPrices(); + + // Connect reached the migration step and found the account needs one. + expect(infoClient.userAbstraction.mock.calls).toStrictEqual([ + [{ user: MAIN_ADDRESS }], + ]); + expect(exchangeClient.userSetAbstraction).not.toHaveBeenCalled(); + expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + expect(keyringCalls(call)).toStrictEqual([]); + }); + + it('treats a not-ready accountSigner as a locked keyring and caches nothing', async () => { + const { accountSignerProvider, accountSigner, call, exchangeClient } = + createAccountSignerProvider({ signer: { isReady: () => false } }); + + await accountSignerProvider.getMarketDataWithPrices(); + + expect(exchangeClient.userSetAbstraction.mock.calls).toStrictEqual([ + MIGRATION_WRITE, + ]); + expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + expect(migrationAttempted()).toBe(false); + expect(keyringCalls(call)).toStrictEqual([]); + expect(loggerError).not.toHaveBeenCalled(); + // The migration is only reported as required, never as failed. + expect(trackPerpsEvent.mock.calls).toStrictEqual([ + [ + PerpsAnalyticsEvent.AccountSetup, + { + [PERPS_EVENT_PROPERTY.ABSTRACTION_MODE]: 'dexAbstraction', + [PERPS_EVENT_PROPERTY.STATUS]: + PERPS_EVENT_VALUE.STATUS.MIGRATION_REQUIRED, + }, + ], + ]); + }); + + it('fails an order with KEYRING_LOCKED without logging while accountSigner is not ready', async () => { + const { accountSignerProvider, accountSigner } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + signer: { isReady: () => false }, + }); + + const order = await accountSignerProvider.placeOrder(BTC_MARKET_ORDER); + + expect(order).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + expect(referralAttempted()).toBe(false); + }); + + it('fails a TP/SL update with KEYRING_LOCKED without logging while the builder fee cannot be approved', async () => { + const { accountSignerProvider, accountSigner, exchangeClient } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + signer: { isReady: () => false }, + // Not approved yet, and the locked signer cannot approve it. + info: { maxBuilderFee: jest.fn().mockResolvedValue(0) }, + }); + + const result = await accountSignerProvider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '60000', + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + expect(exchangeClient.order).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('fails a TP/SL update with KEYRING_LOCKED without logging when the builder fee signature is rejected as locked', async () => { + const { accountSignerProvider, accountSigner, exchangeClient } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + // Not approved yet. + info: { maxBuilderFee: jest.fn().mockResolvedValue(0) }, + }); + // The signer reports ready, but rejects the approval as locked. + accountSigner.signTypedData.mockImplementation( + async (_address: string, payload: PerpsTypedDataPayload) => { + if (payload === APPROVE_BUILDER_FEE_PAYLOAD) { + throw new Error(PERPS_ERROR_CODES.KEYRING_LOCKED); + } + return MAIN_SIGNATURE; + }, + ); + + const result = await accountSignerProvider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '60000', + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(exchangeClient.approveBuilderFee.mock.calls).toStrictEqual([ + BUILDER_FEE_WRITE, + ]); + expect(exchangeClient.order).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('fails a TP/SL update with KEYRING_LOCKED without logging when the builder fee approval fails as the signer locks', async () => { + let signerReady = true; + const { accountSignerProvider, exchangeClient } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + signer: { isReady: () => signerReady }, + // Not approved yet. + info: { maxBuilderFee: jest.fn().mockResolvedValue(0) }, + }); + // The venue fails the approval for its own reason while the signer locks. + exchangeClient.approveBuilderFee.mockImplementation(async () => { + signerReady = false; + throw new Error('venue busy'); + }); + + const result = await accountSignerProvider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '60000', + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(exchangeClient.approveBuilderFee.mock.calls).toStrictEqual([ + BUILDER_FEE_WRITE, + ]); + expect(exchangeClient.order).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('fails a TP/SL update with KEYRING_LOCKED when the builder fee approval of another provider ended without one while the signer is locked', async () => { + const { accountSignerProvider, exchangeClient } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + signer: { isReady: () => false }, + // Not approved yet. + info: { maxBuilderFee: jest.fn().mockResolvedValue(0) }, + }); + // Another provider holds the approval and ends without caching one. + const release = PerpsSigningCache.setInFlight( + 'builderFee', + 'mainnet', + MAIN_ADDRESS, + ); + const isInFlight = PerpsSigningCache.isInFlight.bind(PerpsSigningCache); + jest + .spyOn(PerpsSigningCache, 'isInFlight') + .mockImplementation((operationType, network, userAddress) => { + const pending = isInFlight(operationType, network, userAddress); + if (operationType === 'builderFee' && pending) { + release(); + } + return pending; + }); + + let result; + try { + result = await accountSignerProvider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '60000', + }); + } finally { + release(); + } + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(exchangeClient.approveBuilderFee).not.toHaveBeenCalled(); + expect(exchangeClient.order).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + }); + + describe('when the signer locks before a user-signed write', () => { + /** + * A provider whose withdrawals and DEX transfers sign through the SDK + * wallet, with a switch that locks the signer. + * + * @returns The provider, the two endpoints and the lock switch. + */ + function createLockingProvider(): AccountSignerFixture & { + withdraw3: jest.Mock; + lock: () => void; + } { + let signerReady = true; + const fixture = createAccountSignerProvider({ + abstraction: 'unifiedAccount', + signer: { isReady: () => signerReady }, + }); + const signUserAction = async (): Promise> => { + await fixture.sdkWallet().signTypedData(USER_SIGNED_PAYLOAD); + return { status: 'ok' }; + }; + const withdraw3 = jest.fn(signUserAction); + Object.assign(fixture.exchangeClient, { withdraw3 }); + fixture.exchangeClient.sendAsset.mockImplementation(signUserAction); + return { + ...fixture, + withdraw3, + lock: (): void => { + signerReady = false; + }, + }; + } + + it('fails a withdrawal with KEYRING_LOCKED without logging it', async () => { + const { accountSignerProvider, accountSigner, withdraw3, lock } = + createLockingProvider(); + await accountSignerProvider.getMarketDataWithPrices(); + const [{ assetId }] = accountSignerProvider.getWithdrawalRoutes(); + lock(); + + const result = await accountSignerProvider.withdraw({ + amount: '10', + destination: MAIN_ADDRESS, + assetId, + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(withdraw3.mock.calls).toStrictEqual([ + [{ destination: MAIN_ADDRESS, amount: '10' }], + ]); + expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('fails a transfer between DEXs with KEYRING_LOCKED without logging it', async () => { + const { accountSignerProvider, accountSigner, exchangeClient, lock } = + createLockingProvider(); + await accountSignerProvider.getMarketDataWithPrices(); + lock(); + + const result = await accountSignerProvider.transferBetweenDexs({ + sourceDex: '', + destinationDex: 'xyz', + amount: '10', + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(exchangeClient.sendAsset.mock.calls).toStrictEqual([ + [ + { + destination: MAIN_ADDRESS, + sourceDex: '', + destinationDex: 'xyz', + token: 'USDC:0xdef456', + amount: '10', + }, + ], + ]); + expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.advanced-orders.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.advanced-orders.test.ts index 01daad4c5d4..9a974adb31c 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.advanced-orders.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.advanced-orders.test.ts @@ -438,8 +438,8 @@ describe('HyperLiquidProvider', () => { getUserAddressWithDefault: jest .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), - isKeyringUnlocked: jest.fn().mockReturnValue(true), - isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + isMainAccountSignerReady: jest.fn().mockReturnValue(true), + requiresSignatureConfirmation: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; mockSubscriptionService = { diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-rejection.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-rejection.test.ts new file mode 100644 index 00000000000..d768f770ef1 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-rejection.test.ts @@ -0,0 +1,1366 @@ +import { getChecksumAddress } from '@metamask/utils'; + +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '../../../src/constants/eventNames.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { PerpsAnalyticsEvent } from '../../../src/types/index.js'; +import { + AGENT_ADDRESS, + AGENT_SIGNATURE, + L1_PAYLOAD, + MAINNET_ACCOUNT, + MAIN_ADDRESS, + OTHER_AGENT_ADDRESS, + OTHER_AGENT_SIGNATURE, + OTHER_MAIN_ADDRESS, + unknownWalletError, +} from '../../helpers/agentFixtures.js'; +import { + BTC_MARKET_ORDER, + CANCEL_DELIVERIES, + REFERRAL_WRITE, + RESTING_ORDER_ID, + SILENT_MIGRATION_WRITE, + cancelStatusesResponse, + createAccountSignerProvider, + migrationAttempted, + referralAttempted, + setUpAccountSignerSuite, +} from '../../helpers/hyperLiquidAccountSignerFixture.js'; +import type { AccountSignerFixture } from '../../helpers/hyperLiquidAccountSignerFixture.js'; +import { createFrontendOpenOrder } from '../../helpers/providerMocks.js'; +import { createDeferred } from '../../helpers/serviceMocks.js'; + +// The SDK ships ES modules only; the provider reaches it through the mocked +// client service, so the module itself is never loaded. The provider checks +// cancel errors against its error class. +jest.mock('@nktkas/hyperliquid', () => ({ + HyperliquidError: class MockHyperliquidError extends Error {}, +})); + +// The client and subscription services are mocked: they own the SDK's +// REST/exchange/info clients and the WebSocket subscriptions. The wallet +// service, the signing caches and the validation run for real. +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); + +// The agent address as a host may supply it: EIP-55 checksummed, so in +// mixed case. +const CHECKSUMMED_AGENT_ADDRESS = getChecksumAddress(AGENT_ADDRESS); + +describe('HyperLiquidProvider with accountSigner: agent rejection', () => { + let loggerError: jest.SpyInstance; + let trackPerpsEvent: jest.SpyInstance; + + beforeEach(() => { + ({ loggerError, trackPerpsEvent } = setUpAccountSignerSuite()); + }); + + describe('with an agent', () => { + describe('when the venue rejects the agent', () => { + // The position's take profit, resting on the venue. + const TAKE_PROFIT_ORDER = createFrontendOpenOrder({ + side: 'A', + limitPx: '58000', + oid: 456, + orderType: 'Take Profit Market', + tif: null, + isTrigger: true, + triggerPx: '58000', + triggerCondition: 'Price above 58000', + reduceOnly: true, + isPositionTpsl: true, + }); + + // The position's stop loss, resting on the venue. + const STOP_LOSS_ORDER = createFrontendOpenOrder({ + side: 'A', + limitPx: '42000', + oid: 457, + orderType: 'Stop Market', + tif: null, + isTrigger: true, + triggerPx: '42000', + triggerCondition: 'Price below 42000', + reduceOnly: true, + isPositionTpsl: true, + }); + + // The agent getAgentSigner answers once the rejected one is dropped. + const REPLACEMENT_AGENT = { + address: OTHER_AGENT_ADDRESS, + signTypedData: jest.fn(), + }; + + beforeEach(() => { + REPLACEMENT_AGENT.signTypedData.mockResolvedValue( + OTHER_AGENT_SIGNATURE, + ); + }); + + /** + * A provider whose L1 writes are signed by the agent, then rejected + * by the venue as an unknown wallet. + * + * @param write - The exchange write that fails. + * @returns The provider, its mocks and the rejected agent. + */ + function createRejectingProvider( + write: + | 'order' + | 'cancel' + | 'modify' + | 'updateIsolatedMargin' + | 'agentSetAbstraction' + | 'setReferrer', + ): AccountSignerFixture & { + getAgentSigner: jest.Mock; + onAgentRejected: jest.Mock; + } { + const getAgentSigner = jest.fn(); + const onAgentRejected = jest.fn(); + const built = createAccountSignerProvider({ + abstraction: + write === 'agentSetAbstraction' ? 'default' : 'unifiedAccount', + getAgentSigner, + onAgentRejected, + }); + getAgentSigner.mockResolvedValue(built.agentSigner); + built.exchangeClient[write].mockImplementation(async () => { + await built.sdkWallet().signTypedData(L1_PAYLOAD); + throw unknownWalletError(built.agentSigner.address); + }); + return { ...built, getAgentSigner, onAgentRejected }; + } + + it('fails a cancel with KEYRING_LOCKED without logging it', async () => { + const { accountSignerProvider, onAgentRejected } = + createRejectingProvider('cancel'); + await accountSignerProvider.getMarketDataWithPrices(); + + const result = await accountSignerProvider.cancelOrder({ + orderId: '123', + symbol: 'BTC', + }); + + expect(result).toStrictEqual({ + success: false, + orderId: '123', + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('drops an agent the venue rejects in a cancel status entry', async () => { + const { + accountSignerProvider, + agentSigner, + exchangeClient, + getAgentSigner, + sdkWallet, + onAgentRejected, + } = createRejectingProvider('cancel'); + await accountSignerProvider.getMarketDataWithPrices(); + const wallet = sdkWallet(); + exchangeClient.cancel.mockImplementation(async () => { + await wallet.signTypedData(L1_PAYLOAD); + return { + status: 'ok', + response: { + data: { + statuses: [ + { error: unknownWalletError(agentSigner.address).message }, + ], + }, + }, + }; + }); + + const result = await accountSignerProvider.cancelOrder({ + orderId: '123', + symbol: 'BTC', + }); + await wallet.signTypedData(L1_PAYLOAD); + + expect(result).toStrictEqual({ + success: false, + orderId: '123', + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + // Dropped, so the next L1 action asks again. + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('drops an agent the venue rejects in batch cancel status entries, and reports it once', async () => { + const { + accountSignerProvider, + exchangeClient, + getAgentSigner, + sdkWallet, + onAgentRejected, + } = createRejectingProvider('cancel'); + await accountSignerProvider.getMarketDataWithPrices(); + const wallet = sdkWallet(); + exchangeClient.cancel.mockImplementation(async () => { + await wallet.signTypedData(L1_PAYLOAD); + return { + status: 'ok', + response: { + data: { + statuses: [ + { error: unknownWalletError(AGENT_ADDRESS).message }, + { error: unknownWalletError(AGENT_ADDRESS).message }, + ], + }, + }, + }; + }); + + const result = await accountSignerProvider.cancelOrders([ + { orderId: '123', symbol: 'BTC' }, + { orderId: '124', symbol: 'BTC' }, + ]); + await wallet.signTypedData(L1_PAYLOAD); + + expect(result).toStrictEqual({ + success: false, + successCount: 0, + failureCount: 2, + results: [ + { + orderId: '123', + symbol: 'BTC', + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }, + { + orderId: '124', + symbol: 'BTC', + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }, + ], + }); + // One signed write, so the host is told once. + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + // Dropped, so the next L1 action asks again. + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it.each(CANCEL_DELIVERIES)( + 'keeps the entries of a batch cancel that succeeded when another reports a rejected agent ($label)', + async ({ delivery }) => { + const { + accountSignerProvider, + exchangeClient, + sdkWallet, + onAgentRejected, + } = createRejectingProvider('cancel'); + await accountSignerProvider.getMarketDataWithPrices(); + const wallet = sdkWallet(); + exchangeClient.cancel.mockImplementation(async () => { + await wallet.signTypedData(L1_PAYLOAD); + return cancelStatusesResponse( + ['success', { error: unknownWalletError(AGENT_ADDRESS).message }], + delivery, + ); + }); + + const result = await accountSignerProvider.cancelOrders([ + { orderId: '123', symbol: 'BTC' }, + { orderId: '124', symbol: 'BTC' }, + ]); + + expect(result).toStrictEqual({ + success: true, + successCount: 1, + failureCount: 1, + results: [ + { orderId: '123', symbol: 'BTC', success: true }, + { + orderId: '124', + symbol: 'BTC', + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }, + ], + }); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }, + ); + + it('fails an order edit with KEYRING_LOCKED without logging it', async () => { + const { accountSignerProvider, infoClient, onAgentRejected } = + createRejectingProvider('modify'); + infoClient.frontendOpenOrders.mockResolvedValue([ + createFrontendOpenOrder(), + ]); + await accountSignerProvider.getMarketDataWithPrices(); + + const result = await accountSignerProvider.editOrder({ + orderId: '123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'limit', + price: '48000', + }, + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('fails closing positions with KEYRING_LOCKED without logging it', async () => { + const { accountSignerProvider, onAgentRejected } = + createRejectingProvider('order'); + await accountSignerProvider.getMarketDataWithPrices(); + + const result = await accountSignerProvider.closePositions({ + symbols: ['BTC'], + }); + + expect(result).toStrictEqual({ + success: false, + successCount: 0, + failureCount: 1, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + results: [ + { + symbol: 'BTC', + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }, + ], + }); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('fails a TP/SL update with KEYRING_LOCKED without logging it', async () => { + const { accountSignerProvider, onAgentRejected } = + createRejectingProvider('order'); + await accountSignerProvider.getMarketDataWithPrices(); + + const result = await accountSignerProvider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '60000', + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('keeps the old protection and fails with KEYRING_LOCKED when its cancel is rejected', async () => { + const { + accountSignerProvider, + exchangeClient, + infoClient, + onAgentRejected, + } = createRejectingProvider('cancel'); + infoClient.frontendOpenOrders.mockResolvedValue([TAKE_PROFIT_ORDER]); + await accountSignerProvider.getMarketDataWithPrices(); + + const result = await accountSignerProvider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '60000', + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(exchangeClient.cancel.mock.calls).toStrictEqual([ + [{ cancels: [{ a: 0, o: TAKE_PROFIT_ORDER.oid }] }], + ]); + expect(exchangeClient.order).not.toHaveBeenCalled(); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('keeps the protection and fails with KEYRING_LOCKED when clearing it is rejected', async () => { + const { + accountSignerProvider, + exchangeClient, + infoClient, + onAgentRejected, + } = createRejectingProvider('cancel'); + infoClient.frontendOpenOrders.mockResolvedValue([TAKE_PROFIT_ORDER]); + await accountSignerProvider.getMarketDataWithPrices(); + + const result = await accountSignerProvider.updatePositionTPSL({ + symbol: 'BTC', + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(exchangeClient.cancel.mock.calls).toStrictEqual([ + [{ cancels: [{ a: 0, o: TAKE_PROFIT_ORDER.oid }] }], + ]); + expect(exchangeClient.order).not.toHaveBeenCalled(); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it.each(CANCEL_DELIVERIES)( + 'keeps the old protection and fails with KEYRING_LOCKED when its cancel is rejected in a status entry ($label)', + async ({ delivery }) => { + const { + accountSignerProvider, + exchangeClient, + infoClient, + sdkWallet, + onAgentRejected, + } = createRejectingProvider('cancel'); + infoClient.frontendOpenOrders.mockResolvedValue([TAKE_PROFIT_ORDER]); + await accountSignerProvider.getMarketDataWithPrices(); + const wallet = sdkWallet(); + exchangeClient.cancel.mockImplementation(async () => { + await wallet.signTypedData(L1_PAYLOAD); + return cancelStatusesResponse( + [{ error: unknownWalletError(AGENT_ADDRESS).message }], + delivery, + ); + }); + + const result = await accountSignerProvider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '60000', + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(exchangeClient.cancel.mock.calls).toStrictEqual([ + [{ cancels: [{ a: 0, o: TAKE_PROFIT_ORDER.oid }] }], + ]); + expect(exchangeClient.order).not.toHaveBeenCalled(); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }, + ); + + it.each(CANCEL_DELIVERIES)( + 'fails only the batch cancel entries that name the rejected agent with KEYRING_LOCKED, and maps the others ($label)', + async ({ delivery }) => { + const { + accountSignerProvider, + exchangeClient, + sdkWallet, + onAgentRejected, + } = createRejectingProvider('cancel'); + await accountSignerProvider.getMarketDataWithPrices(); + const wallet = sdkWallet(); + exchangeClient.cancel.mockImplementation(async () => { + await wallet.signTypedData(L1_PAYLOAD); + return cancelStatusesResponse( + [ + { error: unknownWalletError(AGENT_ADDRESS).message }, + { error: 'multi-sig required' }, + ], + delivery, + ); + }); + + const result = await accountSignerProvider.cancelOrders([ + { orderId: '123', symbol: 'BTC' }, + { orderId: '124', symbol: 'BTC' }, + ]); + + expect(result).toStrictEqual({ + success: false, + successCount: 0, + failureCount: 2, + results: [ + { + orderId: '123', + symbol: 'BTC', + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }, + { + orderId: '124', + symbol: 'BTC', + success: false, + error: PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, + }, + ], + }); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }, + ); + + it.each(CANCEL_DELIVERIES)( + 'restores the leg it cancelled and fails with KEYRING_LOCKED when the venue cancels one leg and rejects the agent on the other ($label)', + async ({ delivery }) => { + const { + accountSignerProvider, + agentSigner, + exchangeClient, + getAgentSigner, + infoClient, + sdkWallet, + onAgentRejected, + } = createRejectingProvider('cancel'); + // Asked again once the rejected agent is dropped. + getAgentSigner + .mockResolvedValueOnce(agentSigner) + .mockResolvedValue(REPLACEMENT_AGENT); + infoClient.frontendOpenOrders.mockResolvedValue([ + TAKE_PROFIT_ORDER, + STOP_LOSS_ORDER, + ]); + await accountSignerProvider.getMarketDataWithPrices(); + const wallet = sdkWallet(); + // The take profit is cancelled; the stop loss entry names the agent. + exchangeClient.cancel.mockImplementation(async () => { + await wallet.signTypedData(L1_PAYLOAD); + return cancelStatusesResponse( + ['success', { error: unknownWalletError(AGENT_ADDRESS).message }], + delivery, + ); + }); + + const result = await accountSignerProvider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '60000', + stopLossPrice: '40000', + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + childOrderIds: [ + String(STOP_LOSS_ORDER.oid), + String(RESTING_ORDER_ID), + ], + }); + expect(exchangeClient.cancel.mock.calls).toStrictEqual([ + [ + { + cancels: [ + { a: 0, o: TAKE_PROFIT_ORDER.oid }, + { a: 0, o: STOP_LOSS_ORDER.oid }, + ], + }, + ], + ]); + // Only the cancelled take profit is placed again; no replacement. + expect( + exchangeClient.order.mock.calls.map( + ([request]: [ + { orders: { t: unknown }[]; grouping: string }, + ]) => ({ + triggers: request.orders.map((order) => order.t), + grouping: request.grouping, + }), + ), + ).toStrictEqual([ + { + triggers: [ + { + trigger: { isMarket: true, triggerPx: '58000', tpsl: 'tp' }, + }, + ], + grouping: 'positionTpsl', + }, + ]); + // The rejected agent signed the referral and the cancel; its + // replacement, the restoration. + expect(agentSigner.signTypedData.mock.calls).toStrictEqual([ + [L1_PAYLOAD], + [L1_PAYLOAD], + ]); + expect(REPLACEMENT_AGENT.signTypedData.mock.calls).toStrictEqual([ + [L1_PAYLOAD], + ]); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }, + ); + + it('reports the protection lost, without logging, when the leg the venue cancelled cannot be restored after the other names the rejected agent', async () => { + const { + accountSignerProvider, + exchangeClient, + getAgentSigner, + infoClient, + sdkWallet, + onAgentRejected, + } = createRejectingProvider('cancel'); + infoClient.frontendOpenOrders.mockResolvedValue([ + TAKE_PROFIT_ORDER, + STOP_LOSS_ORDER, + ]); + await accountSignerProvider.getMarketDataWithPrices(); + const wallet = sdkWallet(); + exchangeClient.cancel.mockImplementation(async () => { + await wallet.signTypedData(L1_PAYLOAD); + return cancelStatusesResponse( + ['success', { error: unknownWalletError(AGENT_ADDRESS).message }], + 'thrown', + ); + }); + // getAgentSigner answers the same agent, which the venue rejects again. + exchangeClient.order.mockImplementation(async () => { + await wallet.signTypedData(L1_PAYLOAD); + throw unknownWalletError(AGENT_ADDRESS); + }); + + const result = await accountSignerProvider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '60000', + stopLossPrice: '40000', + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.TPSL_PROTECTION_LOST, + childOrderIds: [String(STOP_LOSS_ORDER.oid)], + }); + expect(exchangeClient.cancel.mock.calls).toStrictEqual([ + [ + { + cancels: [ + { a: 0, o: TAKE_PROFIT_ORDER.oid }, + { a: 0, o: STOP_LOSS_ORDER.oid }, + ], + }, + ], + ]); + // Only the restoration of the cancelled take profit was attempted. + expect( + exchangeClient.order.mock.calls.map( + ([request]: [{ orders: { t: unknown }[] }]) => + request.orders.map((order) => order.t), + ), + ).toStrictEqual([ + [{ trigger: { isMarket: true, triggerPx: '58000', tpsl: 'tp' } }], + ]); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + // Asked again after each rejection dropped the agent. + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('restores the cancelled protection with a new agent and fails with KEYRING_LOCKED when the replacement order is rejected', async () => { + const { + accountSignerProvider, + agentSigner, + exchangeClient, + getAgentSigner, + infoClient, + sdkWallet, + onAgentRejected, + } = createRejectingProvider('order'); + getAgentSigner + .mockResolvedValueOnce(agentSigner) + .mockResolvedValue(REPLACEMENT_AGENT); + infoClient.frontendOpenOrders.mockResolvedValue([TAKE_PROFIT_ORDER]); + await accountSignerProvider.getMarketDataWithPrices(); + const wallet = sdkWallet(); + exchangeClient.cancel.mockImplementation(async () => { + await wallet.signTypedData(L1_PAYLOAD); + return cancelStatusesResponse(['success'], 'returned'); + }); + // The replacement is rejected; the restoration is placed. + exchangeClient.order.mockImplementationOnce(async () => { + await wallet.signTypedData(L1_PAYLOAD); + throw unknownWalletError(AGENT_ADDRESS); + }); + exchangeClient.order.mockImplementationOnce(async () => { + await wallet.signTypedData(L1_PAYLOAD); + return { + status: 'ok', + response: { + data: { statuses: [{ resting: { oid: RESTING_ORDER_ID } }] }, + }, + }; + }); + + const result = await accountSignerProvider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '60000', + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + childOrderIds: [String(RESTING_ORDER_ID)], + }); + expect( + exchangeClient.order.mock.calls.map( + ([request]: [{ orders: { t: unknown }[] }]) => + request.orders.map((order) => order.t), + ), + ).toStrictEqual([ + [{ trigger: { isMarket: false, triggerPx: '60000', tpsl: 'tp' } }], + [{ trigger: { isMarket: true, triggerPx: '58000', tpsl: 'tp' } }], + ]); + // The rejected agent signed the referral, the cancel and the + // replacement; the new agent, the restoration. + expect(agentSigner.signTypedData.mock.calls).toStrictEqual([ + [L1_PAYLOAD], + [L1_PAYLOAD], + [L1_PAYLOAD], + ]); + expect(REPLACEMENT_AGENT.signTypedData.mock.calls).toStrictEqual([ + [L1_PAYLOAD], + ]); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('reports the protection lost, without logging, when the rejected agent cannot restore it either', async () => { + const { + accountSignerProvider, + exchangeClient, + getAgentSigner, + infoClient, + sdkWallet, + onAgentRejected, + } = createRejectingProvider('order'); + infoClient.frontendOpenOrders.mockResolvedValue([TAKE_PROFIT_ORDER]); + await accountSignerProvider.getMarketDataWithPrices(); + const wallet = sdkWallet(); + exchangeClient.cancel.mockImplementation(async () => { + await wallet.signTypedData(L1_PAYLOAD); + return cancelStatusesResponse(['success'], 'returned'); + }); + + const result = await accountSignerProvider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '60000', + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.TPSL_PROTECTION_LOST, + childOrderIds: [], + }); + // The replacement and the restoration are both rejected: the agent is + // asked again after the first rejection and answers the same agent. + expect( + exchangeClient.order.mock.calls.map( + ([request]: [{ orders: { t: unknown }[] }]) => + request.orders.map((order) => order.t), + ), + ).toStrictEqual([ + [{ trigger: { isMarket: false, triggerPx: '60000', tpsl: 'tp' } }], + [{ trigger: { isMarket: true, triggerPx: '58000', tpsl: 'tp' } }], + ]); + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('still drops the agent and fails with KEYRING_LOCKED when onAgentRejected throws', async () => { + const { + accountSignerProvider, + getAgentSigner, + sdkWallet, + onAgentRejected, + } = createRejectingProvider('cancel'); + onAgentRejected.mockImplementation(() => { + throw new Error('host callback failed'); + }); + await accountSignerProvider.getMarketDataWithPrices(); + + const result = await accountSignerProvider.cancelOrder({ + orderId: '123', + symbol: 'BTC', + }); + await sdkWallet().signTypedData(L1_PAYLOAD); + + expect(result).toStrictEqual({ + success: false, + orderId: '123', + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + // Dropped despite the throw, so the next L1 action asks again. + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('fails a margin update with KEYRING_LOCKED without logging it', async () => { + const { accountSignerProvider, onAgentRejected } = + createRejectingProvider('updateIsolatedMargin'); + await accountSignerProvider.getMarketDataWithPrices(); + + const result = await accountSignerProvider.updateMargin({ + symbol: 'BTC', + amount: '10', + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('attributes a rejection to the account the agent signed for after an account switch', async () => { + const getAgentSigner = jest.fn(); + const onAgentRejected = jest.fn(); + const { + accountSignerProvider, + agentSigner, + exchangeClient, + sdkWallet, + selectAccount, + } = createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + onAgentRejected, + }); + getAgentSigner.mockResolvedValue(agentSigner); + await accountSignerProvider.getMarketDataWithPrices(); + const wallet = sdkWallet(); + const signed = createDeferred(); + const venue = createDeferred(); + exchangeClient.cancel.mockImplementation(async () => { + await wallet.signTypedData(L1_PAYLOAD); + signed.resolve(); + await venue.promise; + throw unknownWalletError(agentSigner.address); + }); + + const cancelling = accountSignerProvider.cancelOrder({ + orderId: '123', + symbol: 'BTC', + }); + await signed.promise; + selectAccount(OTHER_MAIN_ADDRESS); + venue.resolve(); + const result = await cancelling; + selectAccount(MAIN_ADDRESS); + await wallet.signTypedData(L1_PAYLOAD); + + expect(result).toStrictEqual({ + success: false, + orderId: '123', + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, agentSigner.address], + ]); + // The signing account's agent was dropped, so it is asked again. + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); + }); + + it('recognizes the rejection of an agent replaced while its action was in flight, and keeps its replacement', async () => { + const getAgentSigner = jest.fn(); + const onAgentRejected = jest.fn(); + const { + accountSignerProvider, + agentSigner, + exchangeClient, + sdkWallet, + } = createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + onAgentRejected, + }); + const replacement = { + address: OTHER_AGENT_ADDRESS, + signTypedData: jest.fn().mockResolvedValue(OTHER_AGENT_SIGNATURE), + }; + getAgentSigner.mockResolvedValue(agentSigner); + await accountSignerProvider.getMarketDataWithPrices(); + const wallet = sdkWallet(); + const signed = createDeferred(); + const venue = createDeferred(); + exchangeClient.order.mockImplementation(async () => { + await wallet.signTypedData(L1_PAYLOAD); + signed.resolve(); + await venue.promise; + throw unknownWalletError(agentSigner.address); + }); + + const ordering = accountSignerProvider.placeOrder(BTC_MARKET_ORDER); + await signed.promise; + // A binding change (setAgentSigner) drops the resolved agents, and the + // next L1 action resolves the replacement. + accountSignerProvider.clearAgentSigners(); + getAgentSigner.mockResolvedValue(replacement); + await wallet.signTypedData(L1_PAYLOAD); + venue.resolve(); + const order = await ordering; + await wallet.signTypedData(L1_PAYLOAD); + + expect(order).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + // The replacement stays resolved: it signs again without a new ask. + expect(replacement.signTypedData.mock.calls).toStrictEqual([ + [L1_PAYLOAD], + [L1_PAYLOAD], + ]); + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('keeps the agent when the venue rejects the main account as unknown', async () => { + const getAgentSigner = jest.fn(); + const onAgentRejected = jest.fn(); + const { + accountSignerProvider, + agentSigner, + exchangeClient, + sdkWallet, + } = createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + onAgentRejected, + }); + getAgentSigner.mockResolvedValue(agentSigner); + await accountSignerProvider.getMarketDataWithPrices(); + const wallet = sdkWallet(); + exchangeClient.order.mockImplementation(async () => { + await wallet.signTypedData(L1_PAYLOAD); + throw unknownWalletError(MAIN_ADDRESS); + }); + + const order = await accountSignerProvider.placeOrder(BTC_MARKET_ORDER); + await wallet.signTypedData(L1_PAYLOAD); + + expect(order).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, + }); + expect(onAgentRejected).not.toHaveBeenCalled(); + // The referral set up for the first order, the order, then the next + // L1 action, all with the one resolved agent. + expect(agentSigner.signTypedData.mock.calls).toStrictEqual([ + [L1_PAYLOAD], + [L1_PAYLOAD], + [L1_PAYLOAD], + ]); + expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); + }); + + it('fails every in-flight write the venue rejects with KEYRING_LOCKED, after the first drops the agent', async () => { + const getAgentSigner = jest.fn(); + const onAgentRejected = jest.fn(); + const { + accountSignerProvider, + agentSigner, + exchangeClient, + sdkWallet, + } = createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + onAgentRejected, + }); + getAgentSigner.mockResolvedValue(agentSigner); + await accountSignerProvider.getMarketDataWithPrices(); + const wallet = sdkWallet(); + const bothSigned = createDeferred(); + const venue = createDeferred(); + let signedCancels = 0; + exchangeClient.cancel.mockImplementation(async () => { + await wallet.signTypedData(L1_PAYLOAD); + signedCancels += 1; + if (signedCancels === 2) { + bothSigned.resolve(); + } + await venue.promise; + throw unknownWalletError(agentSigner.address); + }); + + const cancelling = [ + accountSignerProvider.cancelOrder({ orderId: '123', symbol: 'BTC' }), + accountSignerProvider.cancelOrder({ orderId: '124', symbol: 'BTC' }), + ]; + await bothSigned.promise; + venue.resolve(); + const results = await Promise.all(cancelling); + await wallet.signTypedData(L1_PAYLOAD); + + expect(results).toStrictEqual([ + { + success: false, + orderId: '123', + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }, + { + success: false, + orderId: '124', + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }, + ]); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + // Dropped, so the next L1 action asks again. + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('recognizes a rejected agent whatever the case of its address', async () => { + const getAgentSigner = jest.fn(); + const onAgentRejected = jest.fn(); + const { accountSignerProvider, exchangeClient, sdkWallet } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + onAgentRejected, + }); + // The host returns a mixed-case address; the venue names it lowercased. + const mixedCaseAgent = { + address: CHECKSUMMED_AGENT_ADDRESS, + signTypedData: jest.fn().mockResolvedValue(AGENT_SIGNATURE), + }; + getAgentSigner.mockResolvedValue(mixedCaseAgent); + await accountSignerProvider.getMarketDataWithPrices(); + const wallet = sdkWallet(); + exchangeClient.cancel.mockImplementation(async () => { + await wallet.signTypedData(L1_PAYLOAD); + throw unknownWalletError(CHECKSUMMED_AGENT_ADDRESS.toLowerCase()); + }); + + const result = await accountSignerProvider.cancelOrder({ + orderId: '123', + symbol: 'BTC', + }); + await wallet.signTypedData(L1_PAYLOAD); + + expect(result).toStrictEqual({ + success: false, + orderId: '123', + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + // The host gets its agent's address as it supplied it. + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, CHECKSUMMED_AGENT_ADDRESS], + ]); + // Dropped, so the next L1 action asks again. + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); + }); + + it('keeps the agent when the venue reports an unknown wallet without an address', async () => { + const getAgentSigner = jest.fn(); + const onAgentRejected = jest.fn(); + const { + accountSignerProvider, + agentSigner, + exchangeClient, + sdkWallet, + } = createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + onAgentRejected, + }); + getAgentSigner.mockResolvedValue(agentSigner); + await accountSignerProvider.getMarketDataWithPrices(); + const wallet = sdkWallet(); + exchangeClient.cancel.mockImplementation(async () => { + await wallet.signTypedData(L1_PAYLOAD); + throw new Error('User or API Wallet does not exist.'); + }); + + const result = await accountSignerProvider.cancelOrder({ + orderId: '123', + symbol: 'BTC', + }); + await wallet.signTypedData(L1_PAYLOAD); + + expect(result).toStrictEqual({ + success: false, + orderId: '123', + error: PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, + }); + expect(onAgentRejected).not.toHaveBeenCalled(); + // Kept, so the next L1 action does not ask again. + expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); + }); + + it('fails a batch cancel with KEYRING_LOCKED without logging it', async () => { + const { accountSignerProvider, onAgentRejected } = + createRejectingProvider('cancel'); + await accountSignerProvider.getMarketDataWithPrices(); + + const result = await accountSignerProvider.cancelOrders([ + { orderId: '123', symbol: 'BTC' }, + { orderId: '124', symbol: 'BTC' }, + ]); + + expect(result).toStrictEqual({ + success: false, + successCount: 0, + failureCount: 2, + results: [ + { + orderId: '123', + symbol: 'BTC', + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }, + { + orderId: '124', + symbol: 'BTC', + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }, + ], + }); + // One batch, so one rejection. + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('evicts only the agent of the account that signed the rejected action', async () => { + const getAgentSigner = jest.fn(); + const onAgentRejected = jest.fn(); + const { + accountSignerProvider, + agentSigner, + exchangeClient, + sdkWallet, + selectAccount, + } = createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + onAgentRejected, + }); + // The same agent is approved for both accounts. + getAgentSigner.mockResolvedValue(agentSigner); + await accountSignerProvider.getMarketDataWithPrices(); + const wallet = sdkWallet(); + selectAccount(OTHER_MAIN_ADDRESS); + await wallet.signTypedData(L1_PAYLOAD); + selectAccount(MAIN_ADDRESS); + exchangeClient.cancel.mockImplementation(async () => { + await wallet.signTypedData(L1_PAYLOAD); + throw unknownWalletError(agentSigner.address); + }); + + const result = await accountSignerProvider.cancelOrder({ + orderId: '123', + symbol: 'BTC', + }); + selectAccount(OTHER_MAIN_ADDRESS); + await wallet.signTypedData(L1_PAYLOAD); + selectAccount(MAIN_ADDRESS); + await wallet.signTypedData(L1_PAYLOAD); + + expect(result).toStrictEqual({ + success: false, + orderId: '123', + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(loggerError).not.toHaveBeenCalled(); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + // The other account's agent stays cached, so it is not asked again; + // the signing account's was dropped, so it is. + expect(getAgentSigner.mock.calls).toStrictEqual([ + [{ mainAddress: OTHER_MAIN_ADDRESS, isTestnet: false }], + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); + }); + + it('fails the order with KEYRING_LOCKED, drops the agent and asks again', async () => { + const { + accountSignerProvider, + getAgentSigner, + onAgentRejected, + sdkWallet, + } = createRejectingProvider('order'); + await accountSignerProvider.getMarketDataWithPrices(); + + const order = await accountSignerProvider.placeOrder(BTC_MARKET_ORDER); + await sdkWallet().signTypedData(L1_PAYLOAD); + + expect(order).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('retries the silent migration instead of recording no HyperLiquid account', async () => { + const { accountSignerProvider, onAgentRejected, exchangeClient } = + createRejectingProvider('agentSetAbstraction'); + + await accountSignerProvider.getMarketDataWithPrices(); + await accountSignerProvider.getMarketDataWithPrices(); + + expect(exchangeClient.agentSetAbstraction.mock.calls).toStrictEqual([ + SILENT_MIGRATION_WRITE, + SILENT_MIGRATION_WRITE, + ]); + // Each connect retries the migration, and the venue rejects it again. + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + expect(trackPerpsEvent.mock.calls).toStrictEqual([ + [ + PerpsAnalyticsEvent.AccountSetup, + { + [PERPS_EVENT_PROPERTY.ABSTRACTION_MODE]: 'default', + [PERPS_EVENT_PROPERTY.STATUS]: + PERPS_EVENT_VALUE.STATUS.MIGRATION_REQUIRED, + }, + ], + [ + PerpsAnalyticsEvent.AccountSetup, + { + [PERPS_EVENT_PROPERTY.ABSTRACTION_MODE]: 'default', + [PERPS_EVENT_PROPERTY.STATUS]: + PERPS_EVENT_VALUE.STATUS.MIGRATION_REQUIRED, + }, + ], + ]); + expect(migrationAttempted()).toBe(false); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('leaves the referral to retry, unrecorded', async () => { + const { accountSignerProvider, onAgentRejected, exchangeClient } = + createRejectingProvider('setReferrer'); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: false }); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + REFERRAL_WRITE, + ]); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + expect(referralAttempted()).toBe(false); + expect(loggerError).not.toHaveBeenCalled(); + }); + }); + }); + + describe('without an agent', () => { + it('keeps treating a rejected main account as a wallet with no HyperLiquid account', async () => { + const getAgentSigner = jest.fn().mockResolvedValue(null); + const onAgentRejected = jest.fn(); + const { accountSignerProvider, exchangeClient, sdkWallet } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + onAgentRejected, + }); + exchangeClient.order.mockImplementation(async () => { + await sdkWallet().signTypedData(L1_PAYLOAD); + throw unknownWalletError(MAIN_ADDRESS); + }); + await accountSignerProvider.getMarketDataWithPrices(); + + const order = await accountSignerProvider.placeOrder(BTC_MARKET_ORDER); + + expect(order).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, + }); + expect(onAgentRejected).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-signer.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-signer.test.ts new file mode 100644 index 00000000000..b8f82e5b096 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-signer.test.ts @@ -0,0 +1,671 @@ +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '../../../src/constants/eventNames.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { + AgentBindings, + AgentSignerUnavailableError, +} from '../../../src/services/agentSigner.js'; +import type { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService.js'; +import { PerpsAnalyticsEvent } from '../../../src/types/index.js'; +import type { PerpsAgentAccount } from '../../../src/types/index.js'; +import { + APPROVE_BUILDER_FEE_PAYLOAD, + L1_PAYLOAD, + MAIN_ADDRESS, + MAINNET_ACCOUNT, + OTHER_MAIN_ADDRESS, + unknownWalletError, + USER_SIGNED_PAYLOAD, +} from '../../helpers/agentFixtures.js'; +import { + BTC_MARKET_ORDER, + BUILDER_FEE_WRITE, + REFERRAL_WRITE, + SILENT_MIGRATION_WRITE, + bind, + createAccountSignerProvider, + createPendingResolver, + referralAttempted, + setUpAccountSignerSuite, +} from '../../helpers/hyperLiquidAccountSignerFixture.js'; + +// The SDK ships ES modules only; the provider reaches it through the mocked +// client service, so the module itself is never loaded. The provider checks +// cancel errors against its error class. +jest.mock('@nktkas/hyperliquid', () => ({ + HyperliquidError: class MockHyperliquidError extends Error {}, +})); + +// The client and subscription services are mocked: they own the SDK's +// REST/exchange/info clients and the WebSocket subscriptions. The wallet +// service, the signing caches and the validation run for real. +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); + +describe('HyperLiquidProvider with accountSigner: agents', () => { + let mockClientService: jest.Mocked; + let loggerError: jest.SpyInstance; + let trackPerpsEvent: jest.SpyInstance; + + beforeEach(() => { + ({ mockClientService, loggerError, trackPerpsEvent } = + setUpAccountSignerSuite()); + }); + + describe('with an agent', () => { + it('resolves the agent at the first L1 signature and signs with it', async () => { + const getAgentSigner = jest.fn(); + const { accountSignerProvider, accountSigner, agentSigner, sdkWallet } = + createAccountSignerProvider({ + abstraction: 'default', + getAgentSigner, + }); + getAgentSigner.mockResolvedValue(agentSigner); + + await accountSignerProvider.getMarketDataWithPrices(); + + expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); + expect(sdkWallet().address).toBe(MAIN_ADDRESS); + expect(agentSigner.signTypedData.mock.calls).toStrictEqual([ + [L1_PAYLOAD], + ]); + expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + }); + + it('keeps a resolved agent for later L1 actions', async () => { + const getAgentSigner = jest.fn(); + const { accountSignerProvider, agentSigner } = + createAccountSignerProvider({ + abstraction: 'default', + getAgentSigner, + }); + getAgentSigner.mockResolvedValue(agentSigner); + + await accountSignerProvider.getMarketDataWithPrices(); + await accountSignerProvider.prepareTradingWallet(); + + // Migration at connect, then referral setup. + expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); + expect(agentSigner.signTypedData.mock.calls).toStrictEqual([ + [L1_PAYLOAD], + [L1_PAYLOAD], + ]); + }); + + it('asks again after a null answer', async () => { + const getAgentSigner = jest.fn(); + const { accountSignerProvider, accountSigner, agentSigner } = + createAccountSignerProvider({ + abstraction: 'default', + getAgentSigner, + }); + getAgentSigner + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(agentSigner); + + await accountSignerProvider.getMarketDataWithPrices(); + await accountSignerProvider.prepareTradingWallet(); + + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [MAIN_ADDRESS, L1_PAYLOAD], + ]); + expect(agentSigner.signTypedData.mock.calls).toStrictEqual([ + [L1_PAYLOAD], + ]); + }); + + it('does not ask for an agent when nothing is signed', async () => { + const getAgentSigner = jest.fn(); + const { accountSignerProvider, infoClient } = createAccountSignerProvider( + { + abstraction: 'unifiedAccount', + getAgentSigner, + }, + ); + + await accountSignerProvider.getMarketDataWithPrices(); + + // Connect reached the migration step and found nothing to sign. + expect(infoClient.userAbstraction.mock.calls).toStrictEqual([ + [{ user: MAIN_ADDRESS }], + ]); + expect(getAgentSigner).not.toHaveBeenCalled(); + }); + + it('keeps user-signed actions on the main account', async () => { + const getAgentSigner = jest.fn(); + const { accountSignerProvider, accountSigner, agentSigner } = + createAccountSignerProvider({ getAgentSigner }); + getAgentSigner.mockResolvedValue(agentSigner); + + await accountSignerProvider.getMarketDataWithPrices(); + + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [MAIN_ADDRESS, USER_SIGNED_PAYLOAD], + ]); + expect(agentSigner.signTypedData).not.toHaveBeenCalled(); + expect(getAgentSigner).not.toHaveBeenCalled(); + }); + + it('fails only the L1 actions and asks again when getAgentSigner rejects', async () => { + const getAgentSigner = jest + .fn() + .mockRejectedValue(new Error('agent store unavailable')); + const { + accountSignerProvider, + accountSigner, + exchangeClient, + infoClient, + } = createAccountSignerProvider({ + abstraction: 'default', + getAgentSigner, + }); + // Not approved yet: the approval is a user-signed write. + infoClient.maxBuilderFee.mockResolvedValueOnce(0); + + const marketData = await accountSignerProvider.getMarketDataWithPrices(); + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(marketData.map(({ symbol }) => symbol)).toStrictEqual([ + 'BTC', + 'ETH', + ]); + // A failed silent migration is retried: at connect, when prepare + // re-runs the connect steps, and once more by the trading setup; the + // referral write is the fourth L1 action. Each asks getAgentSigner. + expect(exchangeClient.agentSetAbstraction.mock.calls).toStrictEqual([ + SILENT_MIGRATION_WRITE, + SILENT_MIGRATION_WRITE, + SILENT_MIGRATION_WRITE, + ]); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + REFERRAL_WRITE, + ]); + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); + // The user-signed builder fee approval still signs on the main account. + expect(exchangeClient.approveBuilderFee.mock.calls).toStrictEqual([ + BUILDER_FEE_WRITE, + ]); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [MAIN_ADDRESS, APPROVE_BUILDER_FEE_PAYLOAD], + ]); + expect(result).toStrictEqual({ ready: false }); + // Retryable like a locked keyring: no failure metric, nothing logged. + expect(trackPerpsEvent.mock.calls).toStrictEqual([ + [ + PerpsAnalyticsEvent.AccountSetup, + { + [PERPS_EVENT_PROPERTY.ABSTRACTION_MODE]: 'default', + [PERPS_EVENT_PROPERTY.STATUS]: + PERPS_EVENT_VALUE.STATUS.MIGRATION_REQUIRED, + }, + ], + [ + PerpsAnalyticsEvent.AccountSetup, + { + [PERPS_EVENT_PROPERTY.ABSTRACTION_MODE]: 'default', + [PERPS_EVENT_PROPERTY.STATUS]: + PERPS_EVENT_VALUE.STATUS.MIGRATION_REQUIRED, + }, + ], + [ + PerpsAnalyticsEvent.AccountSetup, + { + [PERPS_EVENT_PROPERTY.ABSTRACTION_MODE]: 'default', + [PERPS_EVENT_PROPERTY.STATUS]: + PERPS_EVENT_VALUE.STATUS.MIGRATION_REQUIRED, + }, + ], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('signs with the agent bound to the selected account', async () => { + const bindings = new AgentBindings(undefined); + const { accountSignerProvider, agentSigner } = + createAccountSignerProvider({ + abstraction: 'default', + getAgentSigner: bindings.resolve, + }); + + bindings.set(MAINNET_ACCOUNT, agentSigner); + await accountSignerProvider.getMarketDataWithPrices(); + + expect(agentSigner.signTypedData.mock.calls).toStrictEqual([ + [L1_PAYLOAD], + ]); + }); + + it('binds the agent to the account it names, not the selected one', async () => { + const bindings = new AgentBindings(undefined); + const { + accountSignerProvider, + accountSigner, + agentSigner, + selectAccount, + } = createAccountSignerProvider({ + abstraction: 'default', + getAgentSigner: bindings.resolve, + }); + + bindings.set( + { mainAddress: OTHER_MAIN_ADDRESS, isTestnet: false }, + agentSigner, + ); + await accountSignerProvider.getMarketDataWithPrices(); + selectAccount(OTHER_MAIN_ADDRESS); + await accountSignerProvider.prepareTradingWallet(); + + // The selected account's migration signs on the main account; the + // other account's L1 actions sign with its agent. + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [MAIN_ADDRESS, L1_PAYLOAD], + ]); + expect(agentSigner.signTypedData.mock.calls).toStrictEqual([ + [L1_PAYLOAD], + [L1_PAYLOAD], + ]); + }); + + it('never signs on another network with the agent bound for mainnet', async () => { + const bindings = new AgentBindings(undefined); + // A testnet provider, over a testnet client service. + mockClientService.isTestnetMode.mockReturnValue(true); + const { accountSignerProvider, accountSigner, agentSigner } = + createAccountSignerProvider({ + abstraction: 'default', + getAgentSigner: bindings.resolve, + isTestnet: true, + }); + bindings.set(MAINNET_ACCOUNT, agentSigner); + + await accountSignerProvider.getMarketDataWithPrices(); + + expect(agentSigner.signTypedData).not.toHaveBeenCalled(); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [MAIN_ADDRESS, L1_PAYLOAD], + ]); + }); + + it('pins the main account with a null binding without asking getAgentSigner', async () => { + const getAgentSigner = jest.fn(); + const bindings = new AgentBindings(getAgentSigner); + const { accountSignerProvider, accountSigner, agentSigner } = + createAccountSignerProvider({ + abstraction: 'default', + getAgentSigner: bindings.resolve, + }); + getAgentSigner.mockResolvedValue(agentSigner); + + bindings.set(MAINNET_ACCOUNT, null); + await accountSignerProvider.getMarketDataWithPrices(); + await accountSignerProvider.prepareTradingWallet(); + + expect(getAgentSigner).not.toHaveBeenCalled(); + expect(agentSigner.signTypedData).not.toHaveBeenCalled(); + // Migration, then referral. + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [MAIN_ADDRESS, L1_PAYLOAD], + [MAIN_ADDRESS, L1_PAYLOAD], + ]); + }); + + it('lets a pin made while getAgentSigner is pending win', async () => { + const { getAgentSigner, answer, asked } = createPendingResolver(); + const bindings = new AgentBindings(getAgentSigner); + const { accountSignerProvider, accountSigner, agentSigner } = + createAccountSignerProvider({ + abstraction: 'default', + getAgentSigner: bindings.resolve, + }); + + const reading = accountSignerProvider.getMarketDataWithPrices(); + await asked; + bind(accountSignerProvider, bindings, MAINNET_ACCOUNT, null); + answer.resolve(agentSigner); + await reading; + + expect(agentSigner.signTypedData).not.toHaveBeenCalled(); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [MAIN_ADDRESS, L1_PAYLOAD], + ]); + }); + + it('keeps an agent bound while a failing getAgentSigner answer is pending', async () => { + const { getAgentSigner, answer, asked } = createPendingResolver(); + const bindings = new AgentBindings(getAgentSigner); + const { accountSignerProvider, agentSigner, exchangeClient } = + createAccountSignerProvider({ + abstraction: 'default', + getAgentSigner: bindings.resolve, + }); + + const reading = accountSignerProvider.getMarketDataWithPrices(); + await asked; + bind(accountSignerProvider, bindings, MAINNET_ACCOUNT, agentSigner); + answer.reject(new Error('agent store unavailable')); + await reading; + // The connect-time migration signed with the bound agent, at once. + const migrationsAtConnect = + exchangeClient.agentSetAbstraction.mock.calls.slice(); + const agentSignaturesAtConnect = + agentSigner.signTypedData.mock.calls.slice(); + await accountSignerProvider.prepareTradingWallet(); + + expect(migrationsAtConnect).toStrictEqual([SILENT_MIGRATION_WRITE]); + expect(agentSignaturesAtConnect).toStrictEqual([[L1_PAYLOAD]]); + expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); + expect(agentSigner.signTypedData.mock.calls).toStrictEqual([ + [L1_PAYLOAD], + [L1_PAYLOAD], + ]); + }); + + it('asks getAgentSigner with the network of the provider', async () => { + const getAgentSigner = jest.fn().mockResolvedValue(null); + // A testnet provider, over a testnet client service. + mockClientService.isTestnetMode.mockReturnValue(true); + const { accountSignerProvider } = createAccountSignerProvider({ + abstraction: 'default', + getAgentSigner, + isTestnet: true, + }); + + await accountSignerProvider.getMarketDataWithPrices(); + + expect(getAgentSigner.mock.calls).toStrictEqual([ + [{ mainAddress: MAIN_ADDRESS, isTestnet: true }], + ]); + }); + + it('does not reuse the mainnet agent after the provider switches to testnet', async () => { + const getAgentSigner = jest.fn(); + const { accountSignerProvider, accountSigner, agentSigner, sdkWallet } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + }); + // An agent is approved on mainnet only. + getAgentSigner.mockImplementation(async (account: PerpsAgentAccount) => + account.isTestnet ? null : agentSigner, + ); + await accountSignerProvider.getMarketDataWithPrices(); + const wallet = sdkWallet(); + await wallet.signTypedData(L1_PAYLOAD); + + mockClientService.isTestnetMode.mockReturnValue(true); + await wallet.signTypedData(L1_PAYLOAD); + + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [{ mainAddress: MAIN_ADDRESS, isTestnet: true }], + ]); + expect(agentSigner.signTypedData.mock.calls).toStrictEqual([ + [L1_PAYLOAD], + ]); + // The testnet action signs on the main account. + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [MAIN_ADDRESS, L1_PAYLOAD], + ]); + }); + + it('signs with the main account while getAgentSigner answers null, and with the agent once it answers again', async () => { + const getAgentSigner = jest.fn(); + const { accountSignerProvider, accountSigner, agentSigner, sdkWallet } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + }); + getAgentSigner.mockResolvedValue(agentSigner); + await accountSignerProvider.getMarketDataWithPrices(); + const wallet = sdkWallet(); + + await wallet.signTypedData(L1_PAYLOAD); + getAgentSigner.mockResolvedValue(null); + accountSignerProvider.clearAgentSigners(); + await wallet.signTypedData(L1_PAYLOAD); + getAgentSigner.mockResolvedValue(agentSigner); + await wallet.signTypedData(L1_PAYLOAD); + + expect(agentSigner.signTypedData.mock.calls).toStrictEqual([ + [L1_PAYLOAD], + [L1_PAYLOAD], + ]); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [MAIN_ADDRESS, L1_PAYLOAD], + ]); + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); + }); + + it('asks getAgentSigner, instead of keeping the main-account pin, once the bindings are cleared', async () => { + const getAgentSigner = jest.fn(); + const bindings = new AgentBindings(getAgentSigner); + const { accountSignerProvider, accountSigner, agentSigner, sdkWallet } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner: bindings.resolve, + }); + getAgentSigner.mockResolvedValue(agentSigner); + bindings.set(MAINNET_ACCOUNT, null); + await accountSignerProvider.getMarketDataWithPrices(); + const wallet = sdkWallet(); + // Pinned to the main account while the null binding holds. + await wallet.signTypedData(L1_PAYLOAD); + const pinnedSignatures = accountSigner.signTypedData.mock.calls.slice(); + + bindings.clear(); + accountSignerProvider.clearAgentSigners(); + await wallet.signTypedData(L1_PAYLOAD); + + expect(pinnedSignatures).toStrictEqual([[MAIN_ADDRESS, L1_PAYLOAD]]); + expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); + expect(agentSigner.signTypedData.mock.calls).toStrictEqual([ + [L1_PAYLOAD], + ]); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual( + pinnedSignatures, + ); + }); + + it('leaves the referral to retry, unrecorded, when the agent fails to sign', async () => { + const getAgentSigner = jest.fn(); + const { accountSignerProvider, agentSigner, exchangeClient, sdkWallet } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + }); + agentSigner.signTypedData.mockRejectedValue( + new Error('agent key locked'), + ); + getAgentSigner.mockResolvedValue(agentSigner); + + const result = await accountSignerProvider.prepareTradingWallet(); + const wallet = sdkWallet(); + const nextSigning = await wallet + .signTypedData(L1_PAYLOAD) + .catch((error: unknown) => error); + + expect(result).toStrictEqual({ ready: false }); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + REFERRAL_WRITE, + ]); + expect(referralAttempted()).toBe(false); + // The agent stays in use: the next L1 action asks it again, not the host. + expect(nextSigning).toBeInstanceOf(AgentSignerUnavailableError); + expect(agentSigner.signTypedData.mock.calls).toStrictEqual([ + [L1_PAYLOAD], + [L1_PAYLOAD], + ]); + expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('treats a getAgentSigner that throws synchronously like a rejection', async () => { + const getAgentSigner = jest.fn(() => { + throw new Error('agent store unavailable'); + }); + const { accountSignerProvider, accountSigner, sdkWallet } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + }); + await accountSignerProvider.getMarketDataWithPrices(); + const wallet = sdkWallet(); + + await expect(wallet.signTypedData(L1_PAYLOAD)).rejects.toBeInstanceOf( + AgentSignerUnavailableError, + ); + await expect(wallet.signTypedData(L1_PAYLOAD)).rejects.toBeInstanceOf( + AgentSignerUnavailableError, + ); + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); + expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + }); + + it('discards an answer pending across clearAgentSigners and asks again', async () => { + const { getAgentSigner, answer, asked } = createPendingResolver(); + const { accountSignerProvider, accountSigner, agentSigner } = + createAccountSignerProvider({ + abstraction: 'default', + getAgentSigner, + }); + + const reading = accountSignerProvider.getMarketDataWithPrices(); + await asked; + getAgentSigner.mockResolvedValue(null); + accountSignerProvider.clearAgentSigners(); + answer.resolve(agentSigner); + await reading; + + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); + expect(agentSigner.signTypedData).not.toHaveBeenCalled(); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [MAIN_ADDRESS, L1_PAYLOAD], + ]); + }); + + it('keeps trading setup retryable until the referral succeeds after getAgentSigner rejected', async () => { + const getAgentSigner = jest + .fn() + .mockRejectedValueOnce(new Error('agent store unavailable')) + .mockResolvedValue(null); + const { accountSignerProvider, exchangeClient } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + }); + + const firstResult = await accountSignerProvider.prepareTradingWallet(); + const recordedAfterFailure = referralAttempted(); + const secondResult = await accountSignerProvider.prepareTradingWallet(); + + // The failed attempt is left to retry, unrecorded and unreported. + expect(firstResult).toStrictEqual({ ready: false }); + expect(recordedAfterFailure).toBe(false); + expect(secondResult).toStrictEqual({ ready: true }); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + REFERRAL_WRITE, + REFERRAL_WRITE, + ]); + expect(referralAttempted()).toBe(true); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('fails an order with KEYRING_LOCKED without reporting it when getAgentSigner rejects', async () => { + const getAgentSigner = jest.fn().mockResolvedValue(null); + const { accountSignerProvider } = createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + }); + await accountSignerProvider.getMarketDataWithPrices(); + getAgentSigner.mockRejectedValue(new Error('agent store unavailable')); + + const order = await accountSignerProvider.placeOrder(BTC_MARKET_ORDER); + + expect(order).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('drops an agent the venue rejects, and asks again, for a host without onAgentRejected', async () => { + const getAgentSigner = jest.fn(); + const { accountSignerProvider, agentSigner, exchangeClient, sdkWallet } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + }); + getAgentSigner.mockResolvedValue(agentSigner); + await accountSignerProvider.getMarketDataWithPrices(); + exchangeClient.cancel.mockImplementation(async () => { + await sdkWallet().signTypedData(L1_PAYLOAD); + throw unknownWalletError(agentSigner.address); + }); + + const result = await accountSignerProvider.cancelOrder({ + orderId: '123', + symbol: 'BTC', + }); + await sdkWallet().signTypedData(L1_PAYLOAD); + + expect(result).toStrictEqual({ + success: false, + orderId: '123', + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + // Dropped, so the next L1 action asks again. + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('reports a failed answer asked again after a clear as unavailable', async () => { + const { getAgentSigner, answer, asked } = createPendingResolver(); + const { accountSignerProvider, agentSigner, sdkWallet } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + }); + await accountSignerProvider.getMarketDataWithPrices(); + const wallet = sdkWallet(); + + const signing = wallet.signTypedData(L1_PAYLOAD); + await asked; + getAgentSigner.mockRejectedValue(new Error('agent store unavailable')); + accountSignerProvider.clearAgentSigners(); + answer.resolve(agentSigner); + + await expect(signing).rejects.toBeInstanceOf(AgentSignerUnavailableError); + // Asked again after the clear, and that answer failed. + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); + expect(agentSigner.signTypedData).not.toHaveBeenCalled(); + }); + }); +}); 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 f3230ee2ac2..c21a7f99e1e 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 @@ -434,8 +434,8 @@ describe('HyperLiquidProvider', () => { getUserAddressWithDefault: jest .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), - isKeyringUnlocked: jest.fn().mockReturnValue(true), - isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + isMainAccountSignerReady: jest.fn().mockReturnValue(true), + requiresSignatureConfirmation: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; mockSubscriptionService = { @@ -1646,7 +1646,7 @@ describe('HyperLiquidProvider', () => { expect(mockCompleteInFlight).toHaveBeenCalled(); }); - it('skips cache when KEYRING_LOCKED error is thrown', async () => { + it('skips cache and rethrows when KEYRING_LOCKED error is thrown', async () => { // Arrange const mockCompleteInFlight = jest.fn(); ( @@ -1661,12 +1661,14 @@ describe('HyperLiquidProvider', () => { createMockExchangeClient({ approveBuilderFee: jest .fn() - .mockRejectedValue(new Error('KEYRING_LOCKED')), + .mockRejectedValue(new Error(PERPS_ERROR_CODES.KEYRING_LOCKED)), }), ); - // Act - should resolve without throwing - await testableProvider.ensureBuilderFeeApproval(); + // Act - rethrows, so the caller reports a retryable failure + await expect(testableProvider.ensureBuilderFeeApproval()).rejects.toThrow( + PERPS_ERROR_CODES.KEYRING_LOCKED, + ); // Assert - cache should NOT be set (so it retries when unlocked) expect( @@ -1709,44 +1711,6 @@ describe('HyperLiquidProvider', () => { expect(mockClientService.getInfoClient).not.toHaveBeenCalled(); }); - it('waits for in-flight operation instead of duplicating request', async () => { - // Arrange - ensure getReferral returns undefined (not cached) - ( - PerpsSigningCache as jest.Mocked - ).getReferral.mockReturnValue(undefined); - - // Simulate in-flight operation from another provider - let resolveInFlight: () => void = () => undefined; - const inFlightPromise = new Promise((resolve) => { - resolveInFlight = resolve; - }); - ( - PerpsSigningCache as jest.Mocked - ).isInFlight.mockReturnValue(inFlightPromise); - - // Act - const referralPromise = testableProvider.ensureReferralSet(); - - // Resolve the in-flight operation - resolveInFlight(); - await referralPromise; - - // Verify it called isInFlight to check for concurrent operations - expect( - (PerpsSigningCache as jest.Mocked).isInFlight, - ).toHaveBeenCalledWith( - 'referral', - 'mainnet', - '0x1234567890123456789012345678901234567890', - ); - - // Assert - should not have set its own in-flight lock - expect( - (PerpsSigningCache as jest.Mocked) - .setInFlight, - ).not.toHaveBeenCalled(); - }); - it('caches success after successful referral setup', async () => { // Arrange const mockCompleteInFlight = jest.fn(); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.data.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.data.test.ts index 63bd5de3e3b..c67c5b2db47 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.data.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.data.test.ts @@ -427,8 +427,8 @@ describe('HyperLiquidProvider', () => { getUserAddressWithDefault: jest .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), - isKeyringUnlocked: jest.fn().mockReturnValue(true), - isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + isMainAccountSignerReady: jest.fn().mockReturnValue(true), + requiresSignatureConfirmation: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; mockSubscriptionService = { diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.error-handling.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.error-handling.test.ts index ff7687cf814..b91b708fe29 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.error-handling.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.error-handling.test.ts @@ -439,8 +439,8 @@ describe('HyperLiquidProvider', () => { getUserAddressWithDefault: jest .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), - isKeyringUnlocked: jest.fn().mockReturnValue(true), - isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + isMainAccountSignerReady: jest.fn().mockReturnValue(true), + requiresSignatureConfirmation: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; mockSubscriptionService = { diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.history.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.history.test.ts index 85e0c1e5f11..3bcb82f221b 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.history.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.history.test.ts @@ -427,8 +427,8 @@ describe('HyperLiquidProvider', () => { getUserAddressWithDefault: jest .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), - isKeyringUnlocked: jest.fn().mockReturnValue(true), - isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + isMainAccountSignerReady: jest.fn().mockReturnValue(true), + requiresSignatureConfirmation: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; mockSubscriptionService = { diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.lifecycle.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.lifecycle.test.ts index 3110acf93f2..4881383d7ca 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.lifecycle.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.lifecycle.test.ts @@ -427,8 +427,8 @@ describe('HyperLiquidProvider', () => { getUserAddressWithDefault: jest .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), - isKeyringUnlocked: jest.fn().mockReturnValue(true), - isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + isMainAccountSignerReady: jest.fn().mockReturnValue(true), + requiresSignatureConfirmation: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; mockSubscriptionService = { diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.misc.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.misc.test.ts index 6c013ad6736..19dfeb6cbd6 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.misc.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.misc.test.ts @@ -427,8 +427,8 @@ describe('HyperLiquidProvider', () => { getUserAddressWithDefault: jest .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), - isKeyringUnlocked: jest.fn().mockReturnValue(true), - isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + isMainAccountSignerReady: jest.fn().mockReturnValue(true), + requiresSignatureConfirmation: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; mockSubscriptionService = { diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.prepare-trading-wallet.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.prepare-trading-wallet.test.ts new file mode 100644 index 00000000000..1e19cab9714 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.prepare-trading-wallet.test.ts @@ -0,0 +1,925 @@ +import { + BUILDER_FEE_CONFIG, + REFERRAL_CONFIG, +} from '../../../src/constants/hyperLiquidConfig.js'; +import { PERPS_CONSTANTS } from '../../../src/constants/perpsConfig.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { + PerpsSigningCache, + TradingReadinessCache, +} from '../../../src/services/TradingReadinessCache.js'; +import type { PerpsTypedDataPayload } from '../../../src/types/index.js'; +import { + APPROVE_BUILDER_FEE_PAYLOAD, + L1_PAYLOAD, + MAIN_ADDRESS, + MAIN_SIGNATURE, + OTHER_MAIN_ADDRESS, + USER_SIGNED_PAYLOAD, + unknownWalletError, +} from '../../helpers/agentFixtures.js'; +import { + BTC_MARKET_ORDER, + BUILDER_FEE_WRITE, + BUILDER_REFERRAL_LOOKUP, + MIGRATION_WRITE, + NOW, + REFERRAL_WRITE, + RESTING_ORDER_ID, + createAccountSignerProvider, + migrationAttempted, + referralAttempted, + setUpAccountSignerSuite, +} from '../../helpers/hyperLiquidAccountSignerFixture.js'; +import { + createDeferred, + createMockEvmAccount, +} from '../../helpers/serviceMocks.js'; + +// The SDK ships ES modules only; the provider reaches it through the mocked +// client service, so the module itself is never loaded. The provider checks +// cancel errors against its error class. +jest.mock('@nktkas/hyperliquid', () => ({ + HyperliquidError: class MockHyperliquidError extends Error {}, +})); + +// The client and subscription services are mocked: they own the SDK's +// REST/exchange/info clients and the WebSocket subscriptions. The wallet +// service, the signing caches and the validation run for real. +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); + +describe('HyperLiquidProvider with accountSigner: prepareTradingWallet', () => { + let loggerError: jest.SpyInstance; + + beforeEach(() => { + ({ loggerError } = setUpAccountSignerSuite()); + }); + + describe('prepareTradingWallet', () => { + it('runs the deferred migration and referral, finds the builder fee approved, and reports ready', async () => { + const { + accountSignerProvider, + accountSigner, + exchangeClient, + infoClient, + } = createAccountSignerProvider({ + signer: { requiresSignatureConfirmation: () => true }, + }); + await accountSignerProvider.getMarketDataWithPrices(); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: true }); + expect(exchangeClient.userSetAbstraction.mock.calls).toStrictEqual([ + MIGRATION_WRITE, + ]); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + REFERRAL_WRITE, + ]); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [MAIN_ADDRESS, USER_SIGNED_PAYLOAD], + [MAIN_ADDRESS, L1_PAYLOAD], + ]); + // Already approved, so nothing is signed for it. + expect(infoClient.maxBuilderFee.mock.calls).toStrictEqual([ + [{ user: MAIN_ADDRESS, builder: BUILDER_FEE_CONFIG.MainnetBuilder }], + ]); + expect(exchangeClient.approveBuilderFee).not.toHaveBeenCalled(); + }); + + it('signs every setup step, so the first order signs only itself', async () => { + const { + accountSignerProvider, + accountSigner, + exchangeClient, + infoClient, + } = createAccountSignerProvider({ + signer: { requiresSignatureConfirmation: () => true }, + }); + await accountSignerProvider.getMarketDataWithPrices(); + // Not approved yet; the venue reports the approval once signed. + infoClient.maxBuilderFee.mockResolvedValueOnce(0); + + const result = await accountSignerProvider.prepareTradingWallet(); + const setupSignatures = accountSigner.signTypedData.mock.calls.slice(); + accountSigner.signTypedData.mockClear(); + const order = await accountSignerProvider.placeOrder(BTC_MARKET_ORDER); + + expect(result).toStrictEqual({ ready: true }); + // Migration, referral, builder fee approval. + expect(setupSignatures).toStrictEqual([ + [MAIN_ADDRESS, USER_SIGNED_PAYLOAD], + [MAIN_ADDRESS, L1_PAYLOAD], + [MAIN_ADDRESS, APPROVE_BUILDER_FEE_PAYLOAD], + ]); + expect(order).toStrictEqual({ + success: true, + orderId: String(RESTING_ORDER_ID), + submittedSize: '0.1', + averagePrice: undefined, + filledSize: undefined, + }); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [MAIN_ADDRESS, L1_PAYLOAD], + ]); + expect(exchangeClient.userSetAbstraction.mock.calls).toStrictEqual([ + MIGRATION_WRITE, + ]); + expect(exchangeClient.approveBuilderFee.mock.calls).toStrictEqual([ + BUILDER_FEE_WRITE, + ]); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + REFERRAL_WRITE, + ]); + }); + + /** + * Have another provider hold the real referral lock until released. + * + * @param waiters - How many lookups must find the lock before `waiting` + * resolves. + * @returns Resolves once that many providers found the lock and wait on + * it, the number of lookups that found it, and the release. + */ + function holdReferralLock(waiters = 1): { + waiting: Promise; + lookupsWhileHeld: () => number; + release: () => void; + } { + const release = PerpsSigningCache.setInFlight( + 'referral', + 'mainnet', + MAIN_ADDRESS, + ); + const waiting = createDeferred(); + let lookupsWhileHeld = 0; + const isInFlight = PerpsSigningCache.isInFlight.bind(PerpsSigningCache); + // Only observes the lookup: the lock and its answer are real. + jest + .spyOn(PerpsSigningCache, 'isInFlight') + .mockImplementation((operationType, network, userAddress) => { + const pending = isInFlight(operationType, network, userAddress); + if (operationType === 'referral' && pending) { + lookupsWhileHeld += 1; + if (lookupsWhileHeld >= waiters) { + waiting.resolve(); + } + } + return pending; + }); + return { + waiting: waiting.promise, + lookupsWhileHeld: (): number => lookupsWhileHeld, + release, + }; + } + + it('makes its own referral attempt when another provider ended without a result', async () => { + const { accountSignerProvider, exchangeClient } = + createAccountSignerProvider({ abstraction: 'unifiedAccount' }); + const lock = holdReferralLock(); + + // Whether the other provider's lock was released at each referral write. + let released = false; + const releasedAtWrite: boolean[] = []; + exchangeClient.setReferrer.mockImplementation(async () => { + releasedAtWrite.push(released); + return { status: 'ok' }; + }); + let result; + try { + const preparing = accountSignerProvider.prepareTradingWallet(); + await lock.waiting; + released = true; + lock.release(); + result = await preparing; + } finally { + // Never leak the global lock into later tests. + lock.release(); + } + + expect(releasedAtWrite).toStrictEqual([true]); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + REFERRAL_WRITE, + ]); + expect( + PerpsSigningCache.getReferral('mainnet', MAIN_ADDRESS), + ).toStrictEqual({ + attempted: true, + success: true, + }); + expect(result).toStrictEqual({ ready: true }); + }); + + it('uses the referral result another provider cached while it waited', async () => { + const { accountSignerProvider, exchangeClient } = + createAccountSignerProvider({ abstraction: 'unifiedAccount' }); + const lock = holdReferralLock(); + + let result; + try { + const preparing = accountSignerProvider.prepareTradingWallet(); + await lock.waiting; + PerpsSigningCache.setReferral('mainnet', MAIN_ADDRESS, { + attempted: true, + success: true, + }); + lock.release(); + result = await preparing; + } finally { + lock.release(); + } + + expect(exchangeClient.setReferrer).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ ready: true }); + }); + + it('lets only one of several waiting providers make the referral attempt', async () => { + const first = createAccountSignerProvider({ + abstraction: 'unifiedAccount', + }); + const second = createAccountSignerProvider({ + abstraction: 'unifiedAccount', + }); + const lock = holdReferralLock(2); + + let results; + let waitersAtRelease; + try { + const preparing = [ + first.accountSignerProvider.prepareTradingWallet(), + second.accountSignerProvider.prepareTradingWallet(), + ]; + // Both providers found the lock and wait on it. + await lock.waiting; + waitersAtRelease = lock.lookupsWhileHeld(); + lock.release(); + results = await Promise.all(preparing); + } finally { + lock.release(); + } + + expect(waitersAtRelease).toBe(2); + + // One referral write across both providers. + expect( + [first, second].flatMap( + ({ exchangeClient }): unknown[] => + exchangeClient.setReferrer.mock.calls, + ), + ).toStrictEqual([REFERRAL_WRITE]); + expect(results).toStrictEqual([{ ready: true }, { ready: true }]); + }); + + it('signs nothing more when called again', async () => { + jest.spyOn(Date, 'now').mockReturnValue(NOW); + const { accountSignerProvider, accountSigner } = + createAccountSignerProvider({ + signer: { requiresSignatureConfirmation: () => true }, + }); + await accountSignerProvider.prepareTradingWallet(); + const firstSignatures = accountSigner.signTypedData.mock.calls.slice(); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: true }); + expect(TradingReadinessCache.get('mainnet', MAIN_ADDRESS)).toStrictEqual({ + attempted: true, + enabled: true, + reason: undefined, + timestamp: NOW, + }); + // Migration, then referral; nothing on the second call. + expect(firstSignatures).toStrictEqual([ + [MAIN_ADDRESS, USER_SIGNED_PAYLOAD], + [MAIN_ADDRESS, L1_PAYLOAD], + ]); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual( + firstSignatures, + ); + }); + + it('reports ready after the user declines the migration, since it is not asked again', async () => { + jest.spyOn(Date, 'now').mockReturnValue(NOW); + const { accountSignerProvider, accountSigner } = + createAccountSignerProvider({ + signer: { requiresSignatureConfirmation: () => true }, + }); + accountSigner.signTypedData.mockImplementation( + async (_address: string, payload: PerpsTypedDataPayload) => { + if (payload === USER_SIGNED_PAYLOAD) { + throw new Error('User rejected the request.'); + } + return MAIN_SIGNATURE; + }, + ); + + const result = await accountSignerProvider.prepareTradingWallet(); + const secondResult = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: true }); + expect(secondResult).toStrictEqual({ ready: true }); + expect(TradingReadinessCache.get('mainnet', MAIN_ADDRESS)).toStrictEqual({ + attempted: true, + enabled: false, + reason: undefined, + timestamp: NOW, + }); + // Declined once, not asked again. + expect( + accountSigner.signTypedData.mock.calls.filter( + ([, payload]) => payload === USER_SIGNED_PAYLOAD, + ), + ).toHaveLength(1); + }); + + it('reports not ready without logging when the builder fee approval is rejected, and asks again at the next preparation', async () => { + const { accountSignerProvider, exchangeClient, infoClient } = + createAccountSignerProvider({ abstraction: 'unifiedAccount' }); + infoClient.maxBuilderFee.mockResolvedValue(0); + exchangeClient.approveBuilderFee.mockRejectedValue( + new Error('User rejected the request.'), + ); + + const rejected = await accountSignerProvider.prepareTradingWallet(); + const rejectedAgain = await accountSignerProvider.prepareTradingWallet(); + + expect(rejected).toStrictEqual({ ready: false }); + expect(rejectedAgain).toStrictEqual({ ready: false }); + expect(exchangeClient.approveBuilderFee.mock.calls).toStrictEqual([ + BUILDER_FEE_WRITE, + BUILDER_FEE_WRITE, + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('reports KEYRING_LOCKED when accountSigner is not ready, without running or logging setup', async () => { + const { + accountSignerProvider, + accountSigner, + exchangeClient, + initialize, + } = createAccountSignerProvider({ + signer: { isReady: () => false }, + }); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(initialize).not.toHaveBeenCalled(); + expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + expect(exchangeClient.userSetAbstraction).not.toHaveBeenCalled(); + expect(exchangeClient.setReferrer).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + expect(migrationAttempted()).toBe(false); + expect(referralAttempted()).toBe(false); + }); + + it('reports and logs the error when the clients cannot initialize', async () => { + const { accountSignerProvider, initialize } = + createAccountSignerProvider(); + const failure = new Error('transport unavailable'); + initialize.mockRejectedValue(failure); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: 'transport unavailable', + }); + expect(loggerError.mock.calls).toStrictEqual([ + [ + failure, + { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: 'hyperliquid', + network: 'mainnet', + }, + context: { + name: 'HyperLiquidProvider', + data: { method: 'prepareTradingWallet' }, + }, + }, + ], + ]); + }); + + it('reports a provider disconnected while it checks for a HyperLiquid account as stale, without logging', async () => { + let disconnecting: Promise | undefined; + const { accountSignerProvider, infoClient } = createAccountSignerProvider( + { abstraction: 'unifiedAccount' }, + ); + // The migration's checks (at connect and at trading setup) and the + // referral's find no account; the provider disconnects during + // preparation's own check. + infoClient.userNonFundingLedgerUpdates + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockImplementationOnce(async () => { + disconnecting = accountSignerProvider.disconnect(); + return []; + }); + + const result = await accountSignerProvider.prepareTradingWallet(); + await disconnecting; + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + }); + expect(infoClient.userNonFundingLedgerUpdates).toHaveBeenCalledTimes(4); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('reports a provider disconnected during builder fee setup as stale, without asking for the approval or logging', async () => { + let disconnecting: Promise | undefined; + const { accountSignerProvider, exchangeClient, infoClient } = + createAccountSignerProvider({ abstraction: 'unifiedAccount' }); + infoClient.maxBuilderFee.mockImplementation(async () => { + disconnecting = accountSignerProvider.disconnect(); + return 0; + }); + + const result = await accountSignerProvider.prepareTradingWallet(); + await disconnecting; + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + }); + expect(exchangeClient.approveBuilderFee).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it.each([ + { + change: 'switched', + changeAccount: ({ + selectAccount, + }: ReturnType): void => + selectAccount(OTHER_MAIN_ADDRESS), + }, + { + change: 'deselected', + changeAccount: ({ + deselectAccount, + }: ReturnType): void => + deselectAccount(), + }, + ])( + 'reports a preparation whose account was $change meanwhile as stale, without logging', + async ({ changeAccount }) => { + const fixture = createAccountSignerProvider({ + abstraction: 'unifiedAccount', + }); + fixture.infoClient.maxBuilderFee.mockImplementation(async () => { + changeAccount(fixture); + return 1; + }); + + const result = + await fixture.accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + }); + expect(loggerError).not.toHaveBeenCalled(); + }, + ); + + it('signs the migration at connect and the referral in preparation through the keyring without accountSigner', async () => { + const { accountSignerProvider, call, exchangeClient } = + createAccountSignerProvider({ keyring: true }); + const typedDataSignatures = (): unknown[] => + call.mock.calls.filter( + ([action]) => action === 'KeyringController:signTypedMessage', + ); + + // A software keyring is not deferred: the migration signs at connect. + await accountSignerProvider.getMarketDataWithPrices(); + const connectSignatures = typedDataSignatures(); + call.mockClear(); + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: true }); + expect(exchangeClient.userSetAbstraction.mock.calls).toStrictEqual([ + MIGRATION_WRITE, + ]); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + REFERRAL_WRITE, + ]); + expect(connectSignatures).toStrictEqual([ + [ + 'KeyringController:signTypedMessage', + { from: MAIN_ADDRESS, data: USER_SIGNED_PAYLOAD }, + 'V4', + ], + ]); + expect(typedDataSignatures()).toStrictEqual([ + [ + 'KeyringController:signTypedMessage', + { from: MAIN_ADDRESS, data: L1_PAYLOAD }, + 'V4', + ], + ]); + }); + + it('attempts the referral again when the signer locks while signing it', async () => { + let signerReady = true; + const { accountSignerProvider, accountSigner, exchangeClient } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + signer: { isReady: () => signerReady }, + }); + // The host's signer locks while signing and throws its own error. + accountSigner.signTypedData.mockImplementationOnce(async () => { + signerReady = false; + throw new Error('Wallet is locked'); + }); + + const lockedResult = await accountSignerProvider.prepareTradingWallet(); + const referralAfterLock = referralAttempted(); + signerReady = true; + const retriedResult = await accountSignerProvider.prepareTradingWallet(); + + expect(lockedResult).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(referralAfterLock).toBe(false); + expect(retriedResult).toStrictEqual({ ready: true }); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + REFERRAL_WRITE, + REFERRAL_WRITE, + ]); + expect(referralAttempted()).toBe(true); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('reports KEYRING_LOCKED without logging when the signer locks while a step fails', async () => { + let signerReady = true; + const { accountSignerProvider, initialize } = createAccountSignerProvider( + { signer: { isReady: () => signerReady } }, + ); + initialize.mockImplementation(async () => { + signerReady = false; + throw new Error('wallet disconnected'); + }); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('reports KEYRING_LOCKED when the signer locks while setup signs', async () => { + let signerReady = true; + const { accountSignerProvider, accountSigner } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + signer: { isReady: () => signerReady }, + }); + // The referral signs, then the signer locks before setup ends. + accountSigner.signTypedData.mockImplementation(async () => { + signerReady = false; + return MAIN_SIGNATURE; + }); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [MAIN_ADDRESS, L1_PAYLOAD], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('reports not ready, without an error, while only the migration needs another attempt', async () => { + const { accountSignerProvider, exchangeClient } = + createAccountSignerProvider({ abstraction: 'default' }); + exchangeClient.agentSetAbstraction.mockRejectedValue( + new Error(PERPS_ERROR_CODES.KEYRING_LOCKED), + ); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: false }); + expect(migrationAttempted()).toBe(false); + expect(referralAttempted()).toBe(true); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + REFERRAL_WRITE, + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('reports EXCHANGE_ACCOUNT_NOT_FOUND without signing for a wallet with no HyperLiquid account yet', async () => { + const { accountSignerProvider, accountSigner, exchangeClient } = + createAccountSignerProvider({ + abstraction: 'default', + info: { + userNonFundingLedgerUpdates: jest.fn().mockResolvedValue([]), + // Not approved: the venue would reject the approval anyway. + maxBuilderFee: jest.fn().mockResolvedValue(0), + }, + }); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, + }); + expect(exchangeClient.agentSetAbstraction).not.toHaveBeenCalled(); + expect(exchangeClient.setReferrer).not.toHaveBeenCalled(); + expect(exchangeClient.approveBuilderFee).not.toHaveBeenCalled(); + expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('leaves the referral of a wallet prepared before its first deposit to the next provider, as orders do', async () => { + let deposited = false; + const { accountSignerProvider, exchangeClient } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + info: { + userNonFundingLedgerUpdates: jest.fn(async () => + deposited + ? [{ delta: { type: 'deposit', usdc: '100' }, time: NOW }] + : [], + ), + }, + }); + + const beforeDeposit = await accountSignerProvider.prepareTradingWallet(); + deposited = true; + const afterDeposit = await accountSignerProvider.prepareTradingWallet(); + + expect(beforeDeposit).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, + }); + expect(afterDeposit).toStrictEqual({ ready: true }); + // Not attempted and not recorded, so the next provider attempts it. + expect(exchangeClient.setReferrer).not.toHaveBeenCalled(); + expect(referralAttempted()).toBe(false); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('reports KEYRING_LOCKED for a wallet with no HyperLiquid account when the signer locks during setup', async () => { + let signerReady = true; + const { accountSignerProvider, accountSigner } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + signer: { isReady: () => signerReady }, + info: { + // The signer locks while the account is being looked up. + userNonFundingLedgerUpdates: jest.fn(async () => { + signerReady = false; + return []; + }), + }, + }); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('leaves a referral the venue rejects as an unknown wallet unrecorded, for the next provider', async () => { + const { accountSignerProvider, exchangeClient } = + createAccountSignerProvider({ abstraction: 'unifiedAccount' }); + // The probe sees a deposit, but the venue has not caught up yet. + exchangeClient.setReferrer.mockRejectedValueOnce( + unknownWalletError(MAIN_ADDRESS), + ); + + const rejected = await accountSignerProvider.prepareTradingWallet(); + const preparedAgain = await accountSignerProvider.prepareTradingWallet(); + + // The referral is non-blocking, and this provider does not ask again. + expect(rejected).toStrictEqual({ ready: true }); + expect(preparedAgain).toStrictEqual({ ready: true }); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + REFERRAL_WRITE, + ]); + expect(referralAttempted()).toBe(false); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('reports ready while the referral code is not ready, and sets the referral at a later preparation once it is', async () => { + let codeReady = false; + const { accountSignerProvider, exchangeClient } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + info: { + referral: jest.fn(async () => ({ + referrerState: codeReady + ? { + stage: 'ready', + data: { code: REFERRAL_CONFIG.MainnetCode }, + } + : { stage: 'not_ready', data: null }, + })), + }, + }); + + const beforeReady = await accountSignerProvider.prepareTradingWallet(); + codeReady = true; + const afterReady = await accountSignerProvider.prepareTradingWallet(); + + expect(beforeReady).toStrictEqual({ ready: true }); + expect(afterReady).toStrictEqual({ ready: true }); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + REFERRAL_WRITE, + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('leaves the referral to the next preparation, not to an order, when a preparation fails before its setup', async () => { + let codeReady = false; + const referral = jest.fn(async () => ({ + referrerState: codeReady + ? { stage: 'ready', data: { code: REFERRAL_CONFIG.MainnetCode } } + : { stage: 'not_ready', data: null }, + })); + const { + accountSignerProvider, + call, + deselectAccount, + exchangeClient, + selectAccount, + } = createAccountSignerProvider({ + abstraction: 'unifiedAccount', + info: { referral }, + }); + const pending = await accountSignerProvider.prepareTradingWallet(); + codeReady = true; + // The account is deselected right after preparation reads it, so the + // migration check fails before the shared setup runs. + call.mockImplementationOnce(() => { + deselectAccount(); + return { ...createMockEvmAccount(), scopes: ['eip155:0'] }; + }); + + const failed = await accountSignerProvider.prepareTradingWallet(); + selectAccount(MAIN_ADDRESS); + const order = await accountSignerProvider.placeOrder(BTC_MARKET_ORDER); + const referralCallsAfterOrder = + exchangeClient.setReferrer.mock.calls.slice(); + const prepared = await accountSignerProvider.prepareTradingWallet(); + + expect(pending).toStrictEqual({ ready: true }); + expect(failed).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED, + }); + expect(order.success).toBe(true); + expect(referralCallsAfterOrder).toStrictEqual([]); + expect(prepared).toStrictEqual({ ready: true }); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + REFERRAL_WRITE, + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('checks a referral code that is not ready again at the next preparation, not before every order', async () => { + const referral = jest.fn().mockResolvedValue({ + referrerState: { stage: 'not_ready', data: null }, + }); + const { accountSignerProvider, exchangeClient } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + info: { referral }, + }); + + await accountSignerProvider.prepareTradingWallet(); + const orders = [ + await accountSignerProvider.placeOrder(BTC_MARKET_ORDER), + await accountSignerProvider.placeOrder(BTC_MARKET_ORDER), + await accountSignerProvider.placeOrder(BTC_MARKET_ORDER), + ]; + const lookupsAfterOrders = referral.mock.calls.slice(); + await accountSignerProvider.prepareTradingWallet(); + + expect(orders.map(({ success }) => success)).toStrictEqual([ + true, + true, + true, + ]); + expect(lookupsAfterOrders).toStrictEqual([BUILDER_REFERRAL_LOOKUP]); + expect(referral.mock.calls).toStrictEqual([ + BUILDER_REFERRAL_LOOKUP, + BUILDER_REFERRAL_LOOKUP, + ]); + expect(exchangeClient.setReferrer).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('logs a failed referral code lookup once, without looking it up again before orders or preparation', async () => { + const lookupError = new Error('Network request failed'); + const referral = jest.fn().mockRejectedValue(lookupError); + const { accountSignerProvider, exchangeClient } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + info: { referral }, + }); + + const prepared = await accountSignerProvider.prepareTradingWallet(); + const orders = [ + await accountSignerProvider.placeOrder(BTC_MARKET_ORDER), + await accountSignerProvider.placeOrder(BTC_MARKET_ORDER), + ]; + const preparedAgain = await accountSignerProvider.prepareTradingWallet(); + + expect(prepared).toStrictEqual({ ready: true }); + expect(preparedAgain).toStrictEqual({ ready: true }); + expect(orders.map(({ success }) => success)).toStrictEqual([true, true]); + expect(referral.mock.calls).toStrictEqual([BUILDER_REFERRAL_LOOKUP]); + expect(exchangeClient.setReferrer).not.toHaveBeenCalled(); + expect( + loggerError.mock.calls.map((args: unknown[]) => args[0]), + ).toStrictEqual([lookupError]); + }); + + it('reports KEYRING_LOCKED without logging when the builder fee signature is rejected as locked', async () => { + const { accountSignerProvider, accountSigner, exchangeClient } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + // Not approved yet. + info: { maxBuilderFee: jest.fn().mockResolvedValue(0) }, + }); + // The signer reports ready, but rejects the approval as locked. + accountSigner.signTypedData.mockImplementation( + async (_address: string, payload: PerpsTypedDataPayload) => { + if (payload === APPROVE_BUILDER_FEE_PAYLOAD) { + throw new Error(PERPS_ERROR_CODES.KEYRING_LOCKED); + } + return MAIN_SIGNATURE; + }, + ); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(exchangeClient.approveBuilderFee.mock.calls).toStrictEqual([ + BUILDER_FEE_WRITE, + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('reports NO_ACCOUNT_SELECTED without logging when no account is selected', async () => { + const { accountSignerProvider, accountSigner, deselectAccount } = + createAccountSignerProvider({ abstraction: 'unifiedAccount' }); + await accountSignerProvider.getMarketDataWithPrices(); + deselectAccount(); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED, + }); + expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('reports KEYRING_LOCKED once the signer locks, even after setup completed', async () => { + let signerReady = true; + const { accountSignerProvider } = createAccountSignerProvider({ + abstraction: 'unifiedAccount', + signer: { isReady: () => signerReady }, + }); + const firstResult = await accountSignerProvider.prepareTradingWallet(); + + signerReady = false; + const lockedResult = await accountSignerProvider.prepareTradingWallet(); + + expect(firstResult).toStrictEqual({ ready: true }); + expect(lockedResult).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts index 07a8385e921..49bfbfb5790 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts @@ -428,8 +428,8 @@ describe('HyperLiquidProvider', () => { getUserAddressWithDefault: jest .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), - isKeyringUnlocked: jest.fn().mockReturnValue(true), - isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + isMainAccountSignerReady: jest.fn().mockReturnValue(true), + requiresSignatureConfirmation: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; mockSubscriptionService = { diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts index f1a066ee667..63af90cd79d 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts @@ -502,8 +502,8 @@ describe('HyperLiquidProvider - strategy order types', () => { getUserAddressWithDefault: jest .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), - isKeyringUnlocked: jest.fn().mockReturnValue(true), - isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + isMainAccountSignerReady: jest.fn().mockReturnValue(true), + requiresSignatureConfirmation: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; mockSubscriptionService = { @@ -5015,6 +5015,205 @@ describe('HyperLiquidProvider - strategy order types', () => { ); }); + describe('HIP-3 collateral transfers after an order', () => { + /** + * A HIP-3 market order placed outside unified accounts, so collateral + * moves to the xyz DEX before the order and back after it. + * + * @param orderResponse - The venue's answer to the order. + * @returns The provider's DEX transfers, which the test answers. + */ + const useHip3MarketOrder = ( + orderResponse: Record, + ): { transfer: jest.SpyInstance; order: jest.Mock } => { + let ordered = false; + const order = jest.fn(async () => { + ordered = true; + return orderResponse; + }); + useStrategyClients({ + exchange: { order }, + info: { + clearinghouseState: jest + .fn() + .mockImplementation(({ dex }: { dex?: string }) => { + let withdrawable = '10000'; + if (dex === 'xyz') { + // Empty before the order, with excess left after it. + withdrawable = ordered ? '20' : '0'; + } + return Promise.resolve(createClearinghouseBalance(withdrawable)); + }), + perpDexs: jest.fn().mockResolvedValue([null, { name: 'xyz' }]), + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'xyz:TSLA', szDecimals: 3, maxLeverage: 20 }], + collateralToken: 0, + }), + allMids: jest.fn().mockResolvedValue({ 'xyz:TSLA': '3000' }), + }, + }); + provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + useUnifiedAccount: false, + initialAssetMapping: [['xyz:TSLA', 110000]], + }); + return { transfer: jest.spyOn(provider, 'transferBetweenDexs'), order }; + }; + + const HIP3_MARKET_ORDER = { + ...baseOrder, + orderType: 'market', + symbol: 'xyz:TSLA', + } satisfies OrderParams; + // The order's margin, with its buffer, moved to the xyz DEX and back. + const PRE_ORDER_TRANSFER = { + sourceDex: '', + destinationDex: 'xyz', + amount: '154.963500', + }; + const ROLLBACK_TRANSFER = { + sourceDex: 'xyz', + destinationDex: '', + amount: '154.963500', + }; + // The 20 USDC left on xyz after the order, less the 0.1 USDC buffer. + const REBALANCE_TRANSFER = { + sourceDex: 'xyz', + destinationDex: '', + amount: '19.900000', + }; + const REFUSED_ORDER = { status: 'err', response: 'venue busy' }; + const ORDER_FAILURE = `Order failed: ${JSON.stringify(REFUSED_ORDER)}`; + const ROLLBACK_NOT_SIGNED = + 'HyperLiquidProvider: Rollback not signed - funds remain on HIP-3 DEX'; + const REBALANCE_NOT_SIGNED = + 'HyperLiquidProvider: Auto-rebalance not signed - funds remain on HIP-3 DEX'; + + /** + * The transfers noted as not signed, with their details. + * + * @returns Each note's message and details. + */ + const unsignedTransferNotes = (): [unknown, unknown][] => + (mockPlatformDependencies.debugLogger.log as jest.Mock).mock.calls + .filter( + ([message]: [unknown]) => + message === ROLLBACK_NOT_SIGNED || message === REBALANCE_NOT_SIGNED, + ) + .map(([message, details]: [unknown, unknown]) => [message, details]); + + /** + * The errors reported, with the provider method named in each. + * + * @returns Each reported error's message and method. + */ + const reportedErrors = (): [string, unknown][] => + (mockPlatformDependencies.logger.error as jest.Mock).mock.calls.map( + ([error, options]: [ + Error, + { context: { data: { method: unknown } } }, + ]) => [error.message, options.context.data.method], + ); + + it('fails a HIP-3 order with KEYRING_LOCKED, sending nothing, when its collateral transfer cannot be signed', async () => { + const { transfer, order } = useHip3MarketOrder(REFUSED_ORDER); + transfer.mockResolvedValueOnce({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + + const result = await provider.placeOrder(HIP3_MARKET_ORDER); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(transfer.mock.calls).toStrictEqual([[PRE_ORDER_TRANSFER]]); + expect(order).not.toHaveBeenCalled(); + expect(reportedErrors()).toStrictEqual([]); + }); + + it.each([ + { + transferError: PERPS_ERROR_CODES.KEYRING_LOCKED, + reported: [[ORDER_FAILURE, 'placeOrder']], + notes: [[ROLLBACK_NOT_SIGNED, { dex: 'xyz', amount: 154.9635 }]], + }, + { + transferError: 'transfer failed', + reported: [ + ['transfer failed', 'placeOrder:rollback'], + [ORDER_FAILURE, 'placeOrder'], + ], + notes: [], + }, + ])( + 'reports the rollback of a failed HIP-3 order only when it fails for a reason other than the signer ($transferError)', + async ({ transferError, reported, notes }) => { + const { transfer } = useHip3MarketOrder(REFUSED_ORDER); + transfer + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ success: false, error: transferError }); + + const result = await provider.placeOrder(HIP3_MARKET_ORDER); + + expect(result).toStrictEqual({ success: false, error: ORDER_FAILURE }); + expect(transfer.mock.calls).toStrictEqual([ + [PRE_ORDER_TRANSFER], + [ROLLBACK_TRANSFER], + ]); + expect(reportedErrors()).toStrictEqual(reported); + expect(unsignedTransferNotes()).toStrictEqual(notes); + }, + ); + + it.each([ + { + transferError: PERPS_ERROR_CODES.KEYRING_LOCKED, + reported: [], + notes: [[REBALANCE_NOT_SIGNED, { dex: 'xyz', excessAmount: 19.9 }]], + }, + { + transferError: 'transfer failed', + reported: [['transfer failed', 'placeOrder:autoRebalance']], + notes: [], + }, + ])( + 'reports the rebalance after a HIP-3 order only when it fails for a reason other than the signer ($transferError)', + async ({ transferError, reported, notes }) => { + const { transfer } = useHip3MarketOrder({ + status: 'ok', + response: { + data: { + statuses: [{ filled: { oid: 7, totalSz: '1', avgPx: '3000' } }], + }, + }, + }); + transfer + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ success: false, error: transferError }); + + const result = await provider.placeOrder(HIP3_MARKET_ORDER); + + // The order succeeded either way. + expect(result).toStrictEqual({ + success: true, + orderId: '7', + filledSize: '1', + submittedSize: '1', + averagePrice: '3000', + }); + expect(transfer.mock.calls).toStrictEqual([ + [PRE_ORDER_TRANSFER], + [REBALANCE_TRANSFER], + ]); + expect(reportedErrors()).toStrictEqual(reported); + expect(unsignedTransferNotes()).toStrictEqual(notes); + }, + ); + }); + describe('Existing order types are unaffected', () => { it('still routes a market order through the order action', async () => { const { exchangeClient } = useStrategyClients(); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-signer.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-signer.test.ts new file mode 100644 index 00000000000..210e06acaef --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-signer.test.ts @@ -0,0 +1,678 @@ +import type { Hex } from '@metamask/utils'; + +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import type { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider.js'; +import { + AGENT_ADDRESS, + L1_PAYLOAD, + MAIN_ADDRESS, + MAINNET_ACCOUNT, + unknownWalletError, +} from '../../helpers/agentFixtures.js'; +import { + CANCEL_DELIVERIES, + NOW, + RESTING_ORDER_ID, + cancelStatusesResponse, + createAccountSignerProvider, + orderIdOf, + setUpAccountSignerSuite, +} from '../../helpers/hyperLiquidAccountSignerFixture.js'; +import { createFrontendOpenOrder } from '../../helpers/providerMocks.js'; + +// The SDK ships ES modules only; the provider reaches it through the mocked +// client service, so the module itself is never loaded. The provider checks +// cancel errors against its error class. +jest.mock('@nktkas/hyperliquid', () => ({ + HyperliquidError: class MockHyperliquidError extends Error {}, +})); + +// The client and subscription services are mocked: they own the SDK's +// REST/exchange/info clients and the WebSocket subscriptions. The wallet +// service, the signing caches and the validation run for real. +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); + +describe('HyperLiquidProvider with accountSigner: strategy cancels', () => { + let loggerError: jest.SpyInstance; + + beforeEach(() => { + ({ loggerError } = setUpAccountSignerSuite()); + }); + + describe('with an agent', () => { + describe('when a strategy cancel cannot be signed', () => { + const ETH_ORDER = { + symbol: 'ETH', + isBuy: true, + size: '1', + currentPrice: 3000, + } as const; + const SCALE_ORDER = { + ...ETH_ORDER, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 2, + } as const; + const TWAP_HISTORY = [ + { + time: 1_700_000_030, + twapId: 987, + state: { + coin: 'ETH', + executedNtl: '0', + executedSz: '0', + minutes: 30, + randomize: false, + reduceOnly: false, + side: 'B', + sz: '1', + timestamp: NOW, + user: MAIN_ADDRESS, + }, + status: { status: 'activated' }, + }, + ]; + + /** + * An ETH book whose best bid is the given price. + * + * @param bid - The best bid. + * @returns The book. + */ + const bookAt = (bid: string): Record => ({ + coin: 'ETH', + levels: [ + [{ px: bid, sz: '10', n: 1 }], + [{ px: '3001', sz: '10', n: 1 }], + ], + }); + + /** + * An exchange response carrying one status per request. + * + * @param statuses - The statuses. + * @returns The response. + */ + const withStatuses = ( + ...statuses: unknown[] + ): Record => ({ + status: 'ok', + response: { data: { statuses } }, + }); + + type SignerFailure = 'locked' | 'unavailable' | 'rejected' | 'reported'; + + /** + * A provider whose strategy orders are placed while signing works, and + * whose later cancels sign through the SDK wallet: `failSigning` makes + * the account signer not ready (no agent), makes the agent fail to sign, + * or has the venue reject the agent, by throwing or in the cancel status + * entries. + * + * @param failure - How the cancel fails to be signed. + * @returns The provider, its endpoints and the failure switch. + */ + function createStrategyProvider(failure: SignerFailure): { + provider: HyperLiquidProvider; + order: jest.Mock; + cancel: jest.Mock; + cancelByCloid: jest.Mock; + twapCancel: jest.Mock; + twapOrder: jest.Mock; + l2Book: jest.Mock; + getAgentSigner: jest.Mock; + onAgentRejected: jest.Mock; + signL1Action: () => Promise; + failSigning: () => void; + } { + let signerReady = true; + const cancel = jest.fn(); + const cancelByCloid = jest.fn(); + const twapCancel = jest.fn(); + const twapOrder = jest.fn(); + const l2Book = jest.fn().mockResolvedValue(bookAt('2999')); + const getAgentSigner = jest.fn(); + const onAgentRejected = jest.fn(); + const fixture = createAccountSignerProvider({ + abstraction: 'unifiedAccount', + signer: { isReady: () => signerReady }, + getAgentSigner, + onAgentRejected, + exchange: { cancel, cancelByCloid, twapCancel, twapOrder }, + info: { + twapHistory: jest.fn().mockResolvedValue(TWAP_HISTORY), + userTwapSliceFills: jest.fn().mockResolvedValue([]), + l2Book, + // The resting chase order, read before a re-price. + orderStatus: jest.fn().mockResolvedValue({ + status: 'order', + order: { + status: 'open', + order: createFrontendOpenOrder({ + coin: 'ETH', + limitPx: '2999.1', + sz: '1', + origSz: '1', + tif: 'Alo', + }), + }, + }), + }, + }); + getAgentSigner.mockResolvedValue( + failure === 'locked' ? null : fixture.agentSigner, + ); + const signL1Action = async (): Promise => + await fixture.sdkWallet().signTypedData(L1_PAYLOAD); + const signedCancel = async (): Promise => { + await signL1Action(); + // Only a rejected agent gets this far. + throw unknownWalletError(fixture.agentSigner.address); + }; + // The venue answers with a rejection in every status entry. + const rejectedEntry = { + error: unknownWalletError(fixture.agentSigner.address).message, + }; + const reportedCancel = async ({ + cancels, + }: { + cancels: unknown[]; + }): Promise> => { + await signL1Action(); + return withStatuses(...cancels.map(() => rejectedEntry)); + }; + const reportedTwapCancel = async (): Promise< + Record + > => { + await signL1Action(); + return { + status: 'ok', + response: { type: 'twapCancel', data: { status: rejectedEntry } }, + }; + }; + return { + provider: fixture.accountSignerProvider, + order: fixture.exchangeClient.order, + cancel, + cancelByCloid, + twapCancel, + twapOrder, + l2Book, + getAgentSigner, + onAgentRejected, + signL1Action, + failSigning: (): void => { + signerReady = failure !== 'locked'; + if (failure === 'unavailable') { + fixture.agentSigner.signTypedData.mockRejectedValue( + new Error('agent key locked'), + ); + } + if (failure === 'reported') { + cancel.mockImplementation(reportedCancel); + cancelByCloid.mockImplementation(reportedCancel); + twapCancel.mockImplementation(reportedTwapCancel); + return; + } + for (const endpoint of [cancel, cancelByCloid, twapCancel]) { + endpoint.mockImplementation(signedCancel); + } + }, + }; + } + + const SIGNER_FAILURES = [ + { + name: 'an account signer that is not ready', + failure: 'locked', + rejectedAgents: [], + }, + { + name: 'an agent that cannot sign', + failure: 'unavailable', + rejectedAgents: [], + }, + { + name: 'an agent the venue rejects', + failure: 'rejected', + rejectedAgents: [[MAINNET_ACCOUNT, AGENT_ADDRESS]], + }, + { + name: 'an agent the venue rejects in status entries', + failure: 'reported', + rejectedAgents: [[MAINNET_ACCOUNT, AGENT_ADDRESS]], + }, + ] as const; + + it.each(SIGNER_FAILURES)( + 'fails a TWAP cancel with KEYRING_LOCKED without logging it, for $name', + async ({ failure, rejectedAgents }) => { + const { provider, twapCancel, onAgentRejected, failSigning } = + createStrategyProvider(failure); + await provider.getMarketDataWithPrices(); + failSigning(); + + const result = await provider.cancelOrder({ + orderId: '987', + symbol: 'ETH', + orderType: 'twap', + }); + + expect(result).toStrictEqual({ + success: false, + orderId: '987', + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(twapCancel.mock.calls).toStrictEqual([[{ a: 1, t: 987 }]]); + expect(onAgentRejected.mock.calls).toStrictEqual(rejectedAgents); + expect(loggerError).not.toHaveBeenCalled(); + }, + ); + + it.each(SIGNER_FAILURES)( + 'fails a scale cancel with KEYRING_LOCKED and keeps the ladder cancellable, for $name', + async ({ failure, rejectedAgents }) => { + const { provider, order, cancel, onAgentRejected, failSigning } = + createStrategyProvider(failure); + order.mockResolvedValueOnce( + withStatuses({ resting: { oid: 11 } }, { resting: { oid: 22 } }), + ); + const placed = await provider.placeOrder(SCALE_ORDER); + failSigning(); + + const result = await provider.cancelOrder({ + orderId: orderIdOf(placed), + symbol: 'ETH', + orderType: 'scale', + }); + cancel.mockResolvedValue(withStatuses('success', 'success')); + const retry = await provider.cancelOrder({ + orderId: orderIdOf(placed), + symbol: 'ETH', + orderType: 'scale', + }); + + expect(result).toStrictEqual({ + success: false, + orderId: placed.orderId, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(retry).toStrictEqual({ + success: true, + orderId: placed.orderId, + }); + expect(cancel.mock.calls).toStrictEqual([ + [ + { + cancels: [ + { a: 1, o: 11 }, + { a: 1, o: 22 }, + ], + }, + ], + [ + { + cancels: [ + { a: 1, o: 11 }, + { a: 1, o: 22 }, + ], + }, + ], + ]); + expect(onAgentRejected.mock.calls).toStrictEqual(rejectedAgents); + expect(loggerError).not.toHaveBeenCalled(); + }, + ); + + it.each(CANCEL_DELIVERIES)( + 'keeps only the rungs a cancel by client order ID left resting when the venue cancels one and rejects the agent on the other ($label)', + async ({ delivery }) => { + const { + provider, + order, + cancelByCloid, + onAgentRejected, + signL1Action, + } = createStrategyProvider('reported'); + // Neither rung rests, and the cleanup cannot cancel them, so the + // ladder stays registered by client order ID. + order.mockResolvedValueOnce( + withStatuses('waitingForFill', 'waitingForFill'), + ); + cancelByCloid.mockResolvedValueOnce( + withStatuses({ error: 'Busy' }, { error: 'Busy' }), + ); + const placed = await provider.placeOrder(SCALE_ORDER); + const [[{ orders }]] = order.mock.calls as [ + [{ orders: { c: Hex }[] }], + ]; + loggerError.mockClear(); + cancelByCloid.mockImplementationOnce(async () => { + await signL1Action(); + return cancelStatusesResponse( + ['success', { error: unknownWalletError(AGENT_ADDRESS).message }], + delivery, + ); + }); + + const result = await provider.cancelOrder({ + orderId: orderIdOf(placed), + symbol: 'ETH', + orderType: 'scale', + }); + cancelByCloid.mockResolvedValueOnce(withStatuses('success')); + const retry = await provider.cancelOrder({ + orderId: orderIdOf(placed), + symbol: 'ETH', + orderType: 'scale', + }); + + expect(result).toStrictEqual({ + success: false, + orderId: placed.orderId, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(retry).toStrictEqual({ + success: true, + orderId: placed.orderId, + }); + const bothRungs = { + cancels: orders.map(({ c }) => ({ asset: 1, cloid: c })), + }; + // The placement's cleanup, the cancel, then the retry of the rung + // that was not cancelled. + expect(cancelByCloid.mock.calls).toStrictEqual([ + [bothRungs], + [bothRungs], + [{ cancels: [{ asset: 1, cloid: orders[1].c }] }], + ]); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }, + ); + + it.each(SIGNER_FAILURES)( + 'fails a scale cancel by client order ID with KEYRING_LOCKED, for $name', + async ({ failure, rejectedAgents }) => { + const { + provider, + order, + cancelByCloid, + onAgentRejected, + failSigning, + } = createStrategyProvider(failure); + // Neither rung rests, and the cleanup cannot cancel them, so the + // ladder stays registered by client order ID. + order.mockResolvedValueOnce( + withStatuses('waitingForFill', 'waitingForFill'), + ); + cancelByCloid.mockResolvedValueOnce( + withStatuses({ error: 'Busy' }, { error: 'Busy' }), + ); + const placed = await provider.placeOrder(SCALE_ORDER); + const [[{ orders }]] = order.mock.calls as [ + [{ orders: { c: Hex }[] }], + ]; + loggerError.mockClear(); + failSigning(); + + const result = await provider.cancelOrder({ + orderId: orderIdOf(placed), + symbol: 'ETH', + orderType: 'scale', + }); + + const { orderId: groupId, ...placement } = placed; + expect(groupId).toMatch(/^scale:/u); + expect(placement).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.ORDER_STRATEGY_CANCEL_INCOMPLETE, + acceptedChildren: [ + { state: 'waitingForFill' }, + { state: 'waitingForFill' }, + ], + acceptedSize: '1', + submittedSize: '1', + weightedAverageLimitPrice: '2500', + childOrderIds: [], + }); + expect(result).toStrictEqual({ + success: false, + orderId: placed.orderId, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + const cloidCancels = { + cancels: orders.map(({ c }) => ({ asset: 1, cloid: c })), + }; + // The placement's cleanup, then the cancel. + expect(cancelByCloid.mock.calls).toStrictEqual([ + [cloidCancels], + [cloidCancels], + ]); + expect(onAgentRejected.mock.calls).toStrictEqual(rejectedAgents); + expect(loggerError).not.toHaveBeenCalled(); + }, + ); + + it.each(SIGNER_FAILURES)( + 'fails a chase cancel with KEYRING_LOCKED without logging it, for $name', + async ({ failure, rejectedAgents }) => { + const { provider, cancel, onAgentRejected, failSigning } = + createStrategyProvider(failure); + const placed = await provider.placeOrder({ + ...ETH_ORDER, + orderType: 'chase', + }); + failSigning(); + + const result = await provider.cancelOrder({ + orderId: orderIdOf(placed), + symbol: 'ETH', + orderType: 'chase', + }); + + expect(result).toStrictEqual({ + success: false, + orderId: placed.orderId, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(cancel.mock.calls).toStrictEqual([ + [{ cancels: [{ a: 1, o: RESTING_ORDER_ID }] }], + ]); + expect(onAgentRejected.mock.calls).toStrictEqual(rejectedAgents); + expect(loggerError).not.toHaveBeenCalled(); + }, + ); + + it.each([ + [ + 'thrown', + (): Promise => + Promise.reject(unknownWalletError(AGENT_ADDRESS)), + ], + [ + 'in its status entry', + async (): Promise> => ({ + status: 'ok', + response: { + type: 'twapCancel', + data: { + status: { error: unknownWalletError(AGENT_ADDRESS).message }, + }, + }, + }), + ], + ])( + 'drops an agent the venue rejects while retracting a stale TWAP (%s)', + async (_how, answerCancel) => { + const { + provider, + twapOrder, + twapCancel, + getAgentSigner, + onAgentRejected, + signL1Action, + } = createStrategyProvider('rejected'); + let disconnected: Promise | undefined; + // The provider is torn down while the TWAP is placed, so it + // retracts it. + twapOrder.mockImplementation(async () => { + await signL1Action(); + disconnected = provider.disconnect(); + return { + status: 'ok', + response: { + type: 'twapOrder', + data: { status: { running: { twapId: 987 } } }, + }, + }; + }); + twapCancel.mockImplementation(async () => { + await signL1Action(); + return await answerCancel(); + }); + + const placed = await provider.placeOrder({ + ...ETH_ORDER, + orderType: 'twap', + twapDuration: 30, + }); + await disconnected; + await signL1Action(); + + // The retraction was refused, so the TWAP is reported as live. + expect(placed).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + submittedSize: '1', + orderId: '987', + }); + expect(twapCancel.mock.calls).toStrictEqual([[{ a: 1, t: 987 }]]); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + // Dropped, so the next L1 action asks again. + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }, + ); + + it.each([ + [ + 'thrown', + (): Promise => + Promise.reject(unknownWalletError(AGENT_ADDRESS)), + ], + [ + 'in its status entry', + async (): Promise> => + withStatuses({ error: unknownWalletError(AGENT_ADDRESS).message }), + ], + ])( + 'drops an agent the venue rejects while retracting an abandoned chase order (%s)', + async (_how, answerCancel) => { + const { + provider, + order, + cancel, + getAgentSigner, + onAgentRejected, + signL1Action, + } = createStrategyProvider('rejected'); + let disconnected: Promise | undefined; + // The provider is torn down while the chase order is placed, so it + // retracts it. + order.mockImplementation(async () => { + await signL1Action(); + disconnected = provider.disconnect(); + return withStatuses({ resting: { oid: RESTING_ORDER_ID } }); + }); + cancel.mockImplementation(async () => { + await signL1Action(); + return await answerCancel(); + }); + + const placed = await provider.placeOrder({ + ...ETH_ORDER, + orderType: 'chase', + }); + await disconnected; + await signL1Action(); + + // The retraction was refused, so the order is reported as resting. + expect(placed).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.ORDER_CHASE_ABANDONED, + submittedSize: '1', + childOrderIds: [String(RESTING_ORDER_ID)], + }); + expect(cancel.mock.calls).toStrictEqual([ + [{ cancels: [{ a: 1, o: RESTING_ORDER_ID }] }], + ]); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + // Dropped, so the next L1 action asks again. + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }, + ); + + describe('during a chase re-price', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('drops an agent the venue rejects, so the next L1 action asks again', async () => { + const { + provider, + cancel, + l2Book, + getAgentSigner, + onAgentRejected, + signL1Action, + failSigning, + } = createStrategyProvider('rejected'); + await provider.placeOrder({ + ...ETH_ORDER, + orderType: 'chase', + chaseIntervalMs: 1000, + }); + // The touch moves, so the next tick cancels to re-price. + l2Book.mockResolvedValue(bookAt('2998')); + failSigning(); + + await jest.advanceTimersByTimeAsync(1000); + await signL1Action(); + + expect(cancel.mock.calls).toStrictEqual([ + [{ cancels: [{ a: 1, o: RESTING_ORDER_ID }] }], + ]); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + // Resolved for the placement, then asked again after the rejection. + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + }); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts index 7ae9ad787b2..542341ed72b 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts @@ -1,7 +1,11 @@ /* eslint-disable */ -jest.mock('@nktkas/hyperliquid', () => ({})); +// The provider checks cancel errors against the SDK's error class. +jest.mock('@nktkas/hyperliquid', () => ({ + HyperliquidError: class MockHyperliquidError extends Error {}, +})); import type { CaipAssetId, Hex } from '@metamask/utils'; +import { HyperliquidError } from '@nktkas/hyperliquid'; import { CandlePeriod } from '../../../src/constants/chartConfig.js'; import { @@ -459,8 +463,8 @@ describe('HyperLiquidProvider', () => { getUserAddressWithDefault: jest .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), - isKeyringUnlocked: jest.fn().mockReturnValue(true), - isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + isMainAccountSignerReady: jest.fn().mockReturnValue(true), + requiresSignatureConfirmation: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; mockSubscriptionService = { @@ -4299,6 +4303,45 @@ describe('HyperLiquidProvider', () => { ); }); + it('keeps the orders the venue cancelled when the SDK throws for a failed entry', async () => { + const statuses = ['success', { error: 'multi-sig required' }]; + const sdkError = Object.assign( + new HyperliquidError('cancel 1: multi-sig required'), + { + name: 'ApiRequestError', + response: { + status: 'ok', + response: { type: 'cancel', data: { statuses } }, + }, + }, + ); + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + cancel: jest.fn().mockRejectedValue(sdkError), + }), + ); + + const result = await provider.cancelOrders([ + { orderId: '123', symbol: 'BTC' }, + { orderId: '456', symbol: 'ETH' }, + ]); + + expect(result).toStrictEqual({ + success: true, + successCount: 1, + failureCount: 1, + results: [ + { orderId: '123', symbol: 'BTC', success: true }, + { + orderId: '456', + symbol: 'ETH', + success: false, + error: PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, + }, + ], + }); + }); + it('rejects a non-ok batch response even when its statuses say success', async () => { mockClientService.getExchangeClient = jest.fn().mockReturnValue( createMockExchangeClient({ diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts index 340d32c988b..3c0259113e4 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts @@ -427,8 +427,8 @@ describe('HyperLiquidProvider', () => { getUserAddressWithDefault: jest .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), - isKeyringUnlocked: jest.fn().mockReturnValue(true), - isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + isMainAccountSignerReady: jest.fn().mockReturnValue(true), + requiresSignatureConfirmation: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; mockSubscriptionService = { diff --git a/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts new file mode 100644 index 00000000000..2a595ca94d4 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts @@ -0,0 +1,679 @@ +import { LIGHTER_TX_TYPE_CHANGE_PUB_KEY } from '../../../src/constants/lighterConfig.js'; +import { PERPS_CONSTANTS } from '../../../src/constants/perpsConfig.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { LighterProvider } from '../../../src/providers/LighterProvider.js'; +import { + LighterApiError, + LighterClientService, +} from '../../../src/services/LighterClientService.js'; +import type { + LighterSignerBridge, + LighterSignerOperation, + LighterSignerResult, + LighterWasmCall, +} from '../../../src/types/lighter-types.js'; +import { + MAIN_SIGNATURE, + OTHER_MAIN_ADDRESS, +} from '../../helpers/agentFixtures.js'; +import { + createKeyringlessMessenger, + createKeyringMessenger, + createMockEvmAccount, + createMockInfrastructure, + keyringCalls, + NOW, +} from '../../helpers/serviceMocks.js'; + +// The wallet service stays real. The venue REST client and the WASM signer +// bridge are the mocked I/O boundaries. +jest.mock('../../../src/services/LighterClientService', () => ({ + ...jest.requireActual< + typeof import('../../../src/services/LighterClientService.js') + >('../../../src/services/LighterClientService'), + LighterClientService: jest.fn(), +})); + +const MockedClientService = LighterClientService as jest.MockedClass< + typeof LighterClientService +>; + +const ACCOUNT_INDEX = 28; +const API_KEY_INDEX = 7; +const NEXT_NONCE = 42; +// Expiry of the mocked signed transaction; only needs to be in the future. +const TX_EXPIRY_MS = 9 * 60 * 1000; +const CHANGE_PUB_KEY_BODY = + 'Register Lighter Account\n\npubkey: 0x9c...\nOnly sign this message for a trusted client!'; +// Lighter's API error code for an L1 address with no account. +const ACCOUNT_NOT_FOUND_CODE = 21100; +// EIP-1193 `userRejectedRequest`, pinned here independently of the provider. +const EIP1193_USER_REJECTED_CODE = 4001; +// The registration transaction the mocked signer submits. +const CHANGE_PUB_KEY_TX = [ + LIGHTER_TX_TYPE_CHANGE_PUB_KEY, + JSON.stringify({ + changePubKey: true, + Nonce: NEXT_NONCE, + ExpiredAt: NOW + TX_EXPIRY_MS, + }), +]; + +/** + * Pin the clock the mocked signer stamps its transaction with. + */ +function pinClock(): void { + jest.spyOn(Date, 'now').mockReturnValue(NOW); +} + +function createBridge(): { + bridge: LighterSignerBridge; + calls: LighterWasmCall[]; +} { + const calls: LighterWasmCall[] = []; + const bridge: LighterSignerBridge = { + createClient: jest.fn(async (params) => + bridge.execute({ + function: '_createClient', + params: [ + params.chainId, + params.accountIndex, + params.nonce, + params.apiKeyIndex, + ], + }), + ), + execute: jest.fn( + async ( + call: LighterWasmCall, + ): Promise> => { + calls.push(call); + if (call.function === '_createClient') { + return { + success: true, + pk: '9c'.repeat(40), + pubKeySuccess: true, + body: CHANGE_PUB_KEY_BODY, + } as LighterSignerResult; + } + return { + txInfo: JSON.stringify({ + changePubKey: true, + Nonce: NEXT_NONCE, + ExpiredAt: Date.now() + TX_EXPIRY_MS, + }), + txHash: 'dddd000000000001', + } as LighterSignerResult; + }, + ), + }; + return { bridge, calls }; +} + +type BuiltProvider = { + provider: LighterProvider; + address: string; + client: { + sendTx: jest.Mock; + getAccountsByL1Address: jest.Mock; + getApiKeys: jest.Mock; + getNextNonce: jest.Mock; + }; + accountSigner: { signPersonalMessage: jest.Mock }; + call: jest.SpyInstance; + selectAccount: (address: `0x${string}`) => void; + deselectAccount: () => void; + calls: LighterWasmCall[]; + deps: ReturnType; +}; + +type BuildOptions = { + isReady?: () => boolean; + // Sign through a KeyringController instead of accountSigner. + keyring?: boolean; + // Whether that KeyringController is unlocked. + keyringUnlocked?: boolean; + // Find the account by L1 address instead of a configured index. + findAccountByAddress?: boolean; + withoutBridge?: boolean; +}; + +function buildProvider({ + isReady, + keyring = false, + keyringUnlocked = true, + findAccountByAddress = false, + withoutBridge = false, +}: BuildOptions = {}): BuiltProvider { + const { address } = createMockEvmAccount(); + const account = { + code: 0, + accountType: 0, + index: ACCOUNT_INDEX, + l1Address: address, + status: 1, + collateral: '0', + availableBalance: '0', + positions: [], + }; + const client = { + network: 'testnet', + getAccountsByL1Address: jest.fn().mockResolvedValue({ + code: 200, + l1Address: address, + subAccounts: [account], + }), + getAccountByIndex: jest + .fn() + .mockResolvedValue({ code: 200, accounts: [account] }), + getApiKeys: jest.fn().mockResolvedValue({ code: 200, apiKeys: [] }), + getNextNonce: jest.fn().mockResolvedValue({ code: 200, nonce: NEXT_NONCE }), + getTx: jest.fn().mockResolvedValue(null), + sendTx: jest.fn().mockResolvedValue({ code: 200, txHash: '0xsent' }), + }; + MockedClientService.mockImplementation( + () => client as unknown as LighterClientService, + ); + const accountSigner = { + signTypedData: jest.fn(), + signPersonalMessage: jest.fn().mockResolvedValue(MAIN_SIGNATURE), + isReady, + }; + const { messenger, call, selectAccount, deselectAccount } = keyring + ? createKeyringMessenger(MAIN_SIGNATURE, keyringUnlocked) + : createKeyringlessMessenger(); + const { bridge, calls } = createBridge(); + const deps = keyring + ? createMockInfrastructure() + : { ...createMockInfrastructure(), accountSigner }; + const provider = new LighterProvider({ + isTestnet: true, + platformDependencies: deps, + messenger, + lighterAuthConfig: { + accountIndex: findAccountByAddress ? undefined : ACCOUNT_INDEX, + apiKeyIndex: API_KEY_INDEX, + }, + signerBridge: withoutBridge ? undefined : bridge, + webSocketCtor: null, + }); + return { + provider, + address, + client, + accountSigner, + call, + selectAccount, + deselectAccount, + calls, + deps, + }; +} + +describe('LighterProvider with accountSigner', () => { + beforeEach(pinClock); + + it('registers the venue key with an L1 signature from accountSigner', async () => { + const { provider, address, client, accountSigner, call, calls } = + buildProvider(); + + const result = await provider.isReadyToTrade(); + + expect(result).toStrictEqual({ + ready: true, + walletConnected: true, + networkSupported: true, + authenticatedAddress: address, + }); + expect(accountSigner.signPersonalMessage.mock.calls).toStrictEqual([ + [address, CHANGE_PUB_KEY_BODY], + ]); + const changePubKey = calls.find( + (wasmCall) => wasmCall.function === '_signChangePubKey', + ); + expect(changePubKey?.params).toStrictEqual([ + ACCOUNT_INDEX, + MAIN_SIGNATURE, + NEXT_NONCE, + API_KEY_INDEX, + ]); + expect(client.sendTx.mock.calls).toStrictEqual([CHANGE_PUB_KEY_TX]); + expect(keyringCalls(call)).toStrictEqual([]); + }); + + it('reports KEYRING_LOCKED and registers nothing when accountSigner is not ready', async () => { + const { provider, client, accountSigner, call, calls } = buildProvider({ + isReady: () => false, + }); + + const result = await provider.isReadyToTrade(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + walletConnected: false, + networkSupported: true, + }); + expect(accountSigner.signPersonalMessage).not.toHaveBeenCalled(); + expect( + calls.some((wasmCall) => wasmCall.function === '_signChangePubKey'), + ).toBe(false); + expect(client.sendTx).not.toHaveBeenCalled(); + expect(keyringCalls(call)).toStrictEqual([]); + }); + + it('registers the venue key through prepareTradingWallet', async () => { + const { provider, address, client, accountSigner } = buildProvider(); + + const result = await provider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: true }); + expect(accountSigner.signPersonalMessage.mock.calls).toStrictEqual([ + [address, CHANGE_PUB_KEY_BODY], + ]); + expect(client.sendTx.mock.calls).toStrictEqual([CHANGE_PUB_KEY_TX]); + }); + + it('prepares nothing while the account signer is locked from the first call', async () => { + const { provider, client, accountSigner, calls, deps } = buildProvider({ + isReady: () => false, + }); + const loggerError = jest.spyOn(deps.logger, 'error'); + + const result = await provider.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(calls).toStrictEqual([]); + expect(client.getNextNonce).not.toHaveBeenCalled(); + expect(accountSigner.signPersonalMessage).not.toHaveBeenCalled(); + expect(client.sendTx).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('reports KEYRING_LOCKED for a read-only provider while the account signer is locked: the lock check comes first', async () => { + const { provider, deps } = buildProvider({ + isReady: () => false, + withoutBridge: true, + }); + const loggerError = jest.spyOn(deps.logger, 'error'); + + const result = await provider.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('reports KEYRING_LOCKED from prepareTradingWallet once the signer locks, even with a registered venue key', async () => { + let signerReady = true; + const { provider } = buildProvider({ isReady: () => signerReady }); + const firstResult = await provider.prepareTradingWallet(); + + signerReady = false; + const lockedResult = await provider.prepareTradingWallet(); + + expect(firstResult).toStrictEqual({ ready: true }); + expect(lockedResult).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + }); + + it('logs a failed prepareTradingWallet with the original error', async () => { + const { provider, client, deps } = buildProvider(); + const failure = new Error('venue unavailable'); + client.sendTx.mockRejectedValue(failure); + const loggerError = jest.spyOn(deps.logger, 'error'); + + const result = await provider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: false, error: 'venue unavailable' }); + expect(loggerError.mock.calls).toStrictEqual([ + [ + failure, + { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: 'LighterProvider', + network: 'testnet', + }, + context: { + name: 'LighterProvider.prepareTradingWallet', + data: { isTestnet: true }, + }, + }, + ], + ]); + }); + + it.each([ + [ + 'an EIP-1193 rejection code', + Object.assign(new Error('Rejected'), { + code: EIP1193_USER_REJECTED_CODE, + }), + ], + ['a "User rejected" message', new Error('User rejected the request.')], + ['a "User denied" message', new Error('User denied message signature.')], + ['a "User cancelled" message', new Error('User cancelled the request.')], + ['a "User canceled" message', new Error('User canceled the request.')], + [ + 'a rejection message wrapped in the cause chain', + new Error('Signing failed', { + cause: new Error('User rejected the request.'), + }), + ], + [ + 'a rejection code wrapped in the cause chain', + new Error('Signing failed', { + cause: Object.assign(new Error('Rejected'), { + code: EIP1193_USER_REJECTED_CODE, + }), + }), + ], + ])( + 'reports a decline signalled by %s as a retry without logging, and asks again', + async (_signal, rejection) => { + const { provider, address, accountSigner, client, deps } = + buildProvider(); + accountSigner.signPersonalMessage.mockRejectedValueOnce(rejection); + const loggerError = jest.spyOn(deps.logger, 'error'); + + const declined = await provider.prepareTradingWallet(); + const retried = await provider.prepareTradingWallet(); + + expect(declined).toStrictEqual({ ready: false }); + expect(retried).toStrictEqual({ ready: true }); + expect(accountSigner.signPersonalMessage.mock.calls).toStrictEqual([ + [address, CHANGE_PUB_KEY_BODY], + [address, CHANGE_PUB_KEY_BODY], + ]); + expect(client.sendTx.mock.calls).toStrictEqual([CHANGE_PUB_KEY_TX]); + expect(loggerError).not.toHaveBeenCalled(); + }, + ); + + it('reports EXCHANGE_ACCOUNT_NOT_FOUND without logging for a wallet with no Lighter account yet, then registers once it exists', async () => { + const { provider, client, deps } = buildProvider({ + findAccountByAddress: true, + }); + client.getAccountsByL1Address.mockRejectedValueOnce( + new LighterApiError('account not found', ACCOUNT_NOT_FOUND_CODE), + ); + const loggerError = jest.spyOn(deps.logger, 'error'); + + const missing = await provider.prepareTradingWallet(); + // The account now exists (funded through the bridge). + const retried = await provider.prepareTradingWallet(); + + expect(missing).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, + }); + expect(retried).toStrictEqual({ ready: true }); + expect(client.sendTx.mock.calls).toStrictEqual([CHANGE_PUB_KEY_TX]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('reports ready without signing when the venue key is already registered', async () => { + const { provider, accountSigner, client } = buildProvider(); + client.getApiKeys.mockResolvedValue({ + code: 200, + apiKeys: [{ apiKeyIndex: API_KEY_INDEX, publicKey: '9c'.repeat(40) }], + }); + + const result = await provider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: true }); + expect(accountSigner.signPersonalMessage).not.toHaveBeenCalled(); + expect(client.sendTx).not.toHaveBeenCalled(); + }); + + it('reports NO_ACCOUNT_SELECTED without registering or logging when no account is selected', async () => { + const { provider, accountSigner, client, calls, deps, deselectAccount } = + buildProvider(); + deselectAccount(); + const loggerError = jest.spyOn(deps.logger, 'error'); + + const result = await provider.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED, + }); + expect(calls).toStrictEqual([]); + expect(accountSigner.signPersonalMessage).not.toHaveBeenCalled(); + expect(client.sendTx).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it.each([ + [ + 'the provider disconnects', + async ({ provider }: BuiltProvider): Promise => { + await provider.disconnect(); + }, + ], + [ + 'the wallet switches accounts', + async ({ selectAccount }: BuiltProvider): Promise => { + selectAccount(OTHER_MAIN_ADDRESS); + }, + ], + [ + 'the wallet deselects its account', + async ({ deselectAccount }: BuiltProvider): Promise => { + deselectAccount(); + }, + ], + ])( + 'reports a registration cancelled because %s as a stale provider without logging', + async (_cause, cancelSession) => { + const built = buildProvider(); + const { provider, accountSigner, client, deps } = built; + accountSigner.signPersonalMessage.mockImplementation(async () => { + await cancelSession(built); + return MAIN_SIGNATURE; + }); + const loggerError = jest.spyOn(deps.logger, 'error'); + + const result = await provider.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + }); + expect(client.sendTx).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + }, + ); + + it('reports a read-only provider (no signer bridge) as ready without logging', async () => { + const { provider, accountSigner, client, deps } = buildProvider({ + withoutBridge: true, + }); + const loggerError = jest.spyOn(deps.logger, 'error'); + + const result = await provider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: true }); + expect(accountSigner.signPersonalMessage).not.toHaveBeenCalled(); + expect(client.sendTx).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('reports NO_ACCOUNT_SELECTED for a read-only provider (no signer bridge) with no account selected', async () => { + const { provider, deselectAccount, deps } = buildProvider({ + withoutBridge: true, + }); + deselectAccount(); + const loggerError = jest.spyOn(deps.logger, 'error'); + + const result = await provider.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED, + }); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('reports KEYRING_LOCKED when the signer locks once the venue key is registered', async () => { + let signerReady = true; + const { provider, accountSigner, client, deps } = buildProvider({ + isReady: () => signerReady, + }); + accountSigner.signPersonalMessage.mockImplementation(async () => { + signerReady = false; + return MAIN_SIGNATURE; + }); + const loggerError = jest.spyOn(deps.logger, 'error'); + + const result = await provider.prepareTradingWallet(); + + expect(client.sendTx.mock.calls).toStrictEqual([CHANGE_PUB_KEY_TX]); + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('reports KEYRING_LOCKED without logging when the host rejects as locked while still reporting ready', async () => { + const { provider, accountSigner, client, deps } = buildProvider(); + accountSigner.signPersonalMessage.mockRejectedValue( + new Error('Signing failed', { + cause: new Error(PERPS_ERROR_CODES.KEYRING_LOCKED), + }), + ); + const loggerError = jest.spyOn(deps.logger, 'error'); + + const result = await provider.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(client.sendTx).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('reports KEYRING_LOCKED without logging when the signer locked after signing and the submission fails', async () => { + let signerReady = true; + const { provider, accountSigner, client, deps } = buildProvider({ + isReady: () => signerReady, + }); + // The signature succeeds; the lock and the failure come after it. + accountSigner.signPersonalMessage.mockImplementation(async () => { + signerReady = false; + return MAIN_SIGNATURE; + }); + client.sendTx.mockRejectedValue(new Error('venue unavailable')); + const loggerError = jest.spyOn(deps.logger, 'error'); + + const result = await provider.prepareTradingWallet(); + + expect(client.sendTx.mock.calls).toStrictEqual([CHANGE_PUB_KEY_TX]); + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('registers on the next preparation after the account signer locked during registration', async () => { + let signerReady = true; + const { provider, address, accountSigner, client, deps } = buildProvider({ + isReady: () => signerReady, + }); + // The host's signer locks while signing and throws its own error. + accountSigner.signPersonalMessage.mockImplementationOnce(async () => { + signerReady = false; + throw new Error('Wallet is locked'); + }); + const loggerError = jest.spyOn(deps.logger, 'error'); + + const lockedResult = await provider.prepareTradingWallet(); + signerReady = true; + const retriedResult = await provider.prepareTradingWallet(); + + expect(lockedResult).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(retriedResult).toStrictEqual({ ready: true }); + expect(accountSigner.signPersonalMessage.mock.calls).toStrictEqual([ + [address, CHANGE_PUB_KEY_BODY], + [address, CHANGE_PUB_KEY_BODY], + ]); + expect(client.sendTx.mock.calls).toStrictEqual([CHANGE_PUB_KEY_TX]); + expect(loggerError).not.toHaveBeenCalled(); + }); +}); + +describe('LighterProvider with a KeyringController', () => { + beforeEach(pinClock); + + it('prepares nothing and reports KEYRING_LOCKED while the keyring is locked', async () => { + const { provider, client, call, calls, deps } = buildProvider({ + keyring: true, + keyringUnlocked: false, + }); + const loggerError = jest.spyOn(deps.logger, 'error'); + + const result = await provider.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(keyringCalls(call)).toStrictEqual(['KeyringController:getState']); + expect(calls).toStrictEqual([]); + expect(client.getNextNonce).not.toHaveBeenCalled(); + expect(client.sendTx).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('registers the venue key through prepareTradingWallet with a keyring signature', async () => { + const { provider, address, client, call, calls } = buildProvider({ + keyring: true, + }); + + const result = await provider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: true }); + // Readiness before and after registration, around the signature of the + // registration body's UTF-8 bytes, hex-encoded. + expect( + call.mock.calls.filter(([action]: [string]) => + action.startsWith('KeyringController:'), + ), + ).toStrictEqual([ + ['KeyringController:getState'], + ['KeyringController:getState'], + [ + 'KeyringController:signPersonalMessage', + { + from: address, + data: `0x${Buffer.from(CHANGE_PUB_KEY_BODY, 'utf8').toString('hex')}`, + }, + ], + ['KeyringController:getState'], + ]); + const changePubKey = calls.find( + (wasmCall) => wasmCall.function === '_signChangePubKey', + ); + expect(changePubKey?.params).toStrictEqual([ + ACCOUNT_INDEX, + MAIN_SIGNATURE, + NEXT_NONCE, + API_KEY_INDEX, + ]); + expect(client.sendTx.mock.calls).toStrictEqual([CHANGE_PUB_KEY_TX]); + }); +}); diff --git a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts new file mode 100644 index 00000000000..5f2aa7642ac --- /dev/null +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts @@ -0,0 +1,564 @@ +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { Hex } from '@metamask/utils'; +import type * as HyperLiquidExchange from '@nktkas/hyperliquid/api/exchange'; +import type * as HyperLiquidSigning from '@nktkas/hyperliquid/signing'; +import { recoverTypedDataAddress } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; + +import { + ARBITRUM_SEPOLIA_CHAIN_ID, + BUILDER_FEE_CONFIG, +} from '../../../src/constants/hyperLiquidConfig.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { + AgentSignerUnavailableError, + isAgentSignerUnavailableError, +} from '../../../src/services/agentSigner.js'; +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; +import type { + PerpsAgentSigner, + PerpsTypedDataPayload, +} from '../../../src/types/index.js'; +import { + AGENT_ADDRESS, + AGENT_SIGNATURE, + L1_PAYLOAD, + MAIN_SIGNATURE, + OTHER_MAIN_ADDRESS, + USER_SIGNED_PAYLOAD, +} from '../../helpers/agentFixtures.js'; +import { + createKeyringlessMessenger, + createKeyringMessenger, + createMockEvmAccount, + createMockInfrastructure, + keyringCalls, +} from '../../helpers/serviceMocks.js'; + +type SignerOverrides = { + signTypedData?: jest.Mock; + isReady?: () => boolean; + requiresSignatureConfirmation?: () => boolean; +}; + +type Built = { + service: HyperLiquidWalletService; + call: jest.SpyInstance; + signer: { signTypedData: jest.Mock; signPersonalMessage: jest.Mock }; +}; + +function buildService( + overrides: SignerOverrides = {}, + keyringType?: string, +): Built { + const signer = { + signTypedData: + overrides.signTypedData ?? jest.fn().mockResolvedValue(MAIN_SIGNATURE), + signPersonalMessage: jest.fn().mockResolvedValue(MAIN_SIGNATURE), + isReady: overrides.isReady, + requiresSignatureConfirmation: overrides.requiresSignatureConfirmation, + }; + const { messenger, call } = createKeyringlessMessenger(keyringType); + const service = new HyperLiquidWalletService( + { ...createMockInfrastructure(), accountSigner: signer }, + messenger, + { isTestnet: true }, + ); + return { service, call, signer }; +} + +describe('HyperLiquidWalletService with accountSigner', () => { + const { address } = createMockEvmAccount(); + + it('signs typed data through the account signer without KeyringController', async () => { + const { service, call, signer } = buildService(); + + const signature = await service + .createWalletAdapter() + .signTypedData(L1_PAYLOAD); + + expect(signature).toBe(MAIN_SIGNATURE); + expect(signer.signTypedData.mock.calls).toStrictEqual([ + [address, L1_PAYLOAD], + ]); + expect(keyringCalls(call)).toStrictEqual([]); + }); + + it('propagates account signer rejections', async () => { + const rejection = new Error('User rejected the request.'); + const { service } = buildService({ + signTypedData: jest.fn().mockRejectedValue(rejection), + }); + + await expect( + service.createWalletAdapter().signTypedData(L1_PAYLOAD), + ).rejects.toBe(rejection); + }); + + it('fails with KEYRING_LOCKED, keeping the host error as its cause, when the signer locks while signing', async () => { + let ready = true; + const hostError = new Error('Wallet is locked'); + const { service } = buildService({ + isReady: () => ready, + signTypedData: jest.fn(async () => { + ready = false; + throw hostError; + }), + }); + + const error: unknown = await service + .createWalletAdapter() + .signTypedData(L1_PAYLOAD) + .catch((caught: unknown) => caught); + + expect(error).toStrictEqual(new Error(PERPS_ERROR_CODES.KEYRING_LOCKED)); + // Jest's Error equality ignores `cause`, so check it by identity. + expect((error as Error).cause).toBe(hostError); + }); + + it('reports ready when isReady is omitted', () => { + const { service, call } = buildService(); + + expect(service.isMainAccountSignerReady()).toBe(true); + expect(keyringCalls(call)).toStrictEqual([]); + }); + + it('fails with KEYRING_LOCKED and does not sign when isReady returns false', async () => { + const { service, call, signer } = buildService({ isReady: () => false }); + + expect(service.isMainAccountSignerReady()).toBe(false); + await expect( + service.createWalletAdapter().signTypedData(L1_PAYLOAD), + ).rejects.toThrow(PERPS_ERROR_CODES.KEYRING_LOCKED); + expect(signer.signTypedData).not.toHaveBeenCalled(); + expect(keyringCalls(call)).toStrictEqual([]); + }); + + it("requires signature confirmation when the account signer's requiresSignatureConfirmation says so, whatever the keyring type", () => { + const { service } = buildService( + { requiresSignatureConfirmation: () => true }, + KeyringTypes.hd, + ); + + expect(service.requiresSignatureConfirmation()).toBe(true); + }); + + it("does not require signature confirmation when the account signer's requiresSignatureConfirmation says so, whatever the keyring type", () => { + const { service } = buildService( + { requiresSignatureConfirmation: () => false }, + KeyringTypes.ledger, + ); + + expect(service.requiresSignatureConfirmation()).toBe(false); + }); + + it.each([ + [KeyringTypes.ledger, true], + [KeyringTypes.hd, false], + ])( + 'falls back to the %s keyring type when requiresSignatureConfirmation is omitted', + (keyringType, expected) => { + const { service } = buildService({}, keyringType); + + expect(service.requiresSignatureConfirmation()).toBe(expected); + }, + ); +}); + +describe('HyperLiquidWalletService wallet adapter with an agent', () => { + const { address: mainAddress } = createMockEvmAccount(); + function buildAdapter(agentAvailable = true): { + adapter: ReturnType; + resolveAgent: jest.Mock; + agentSign: jest.Mock; + mainSign: jest.Mock; + call: jest.SpyInstance; + selectAccount: (address: `0x${string}`) => void; + } { + const signer = { + signTypedData: jest.fn().mockResolvedValue(MAIN_SIGNATURE), + signPersonalMessage: jest.fn(), + }; + const { messenger, call, selectAccount } = createKeyringlessMessenger(); + const agentSign = jest.fn().mockResolvedValue(AGENT_SIGNATURE); + const resolveAgent = jest + .fn() + .mockResolvedValue( + agentAvailable + ? { address: AGENT_ADDRESS, signTypedData: agentSign } + : null, + ); + const service = new HyperLiquidWalletService( + { ...createMockInfrastructure(), accountSigner: signer }, + messenger, + { isTestnet: true, resolveAgent }, + ); + return { + adapter: service.createWalletAdapter(), + resolveAgent, + agentSign, + mainSign: signer.signTypedData, + call, + selectAccount, + }; + } + + it('keeps the main account as the wallet address', () => { + const { adapter } = buildAdapter(); + + expect(adapter.address).toBe(mainAddress); + }); + + it('signs L1 actions with the agent resolved for the selected account', async () => { + const { adapter, resolveAgent, agentSign, mainSign, call } = buildAdapter(); + + const signature = await adapter.signTypedData(L1_PAYLOAD); + + expect(signature).toBe(AGENT_SIGNATURE); + expect(resolveAgent.mock.calls).toStrictEqual([[mainAddress]]); + expect(agentSign.mock.calls).toStrictEqual([[L1_PAYLOAD]]); + expect(mainSign).not.toHaveBeenCalled(); + expect(keyringCalls(call)).toStrictEqual([]); + }); + + it('signs user-signed actions with the main account without resolving an agent', async () => { + const { adapter, resolveAgent, agentSign, mainSign } = buildAdapter(); + + const signature = await adapter.signTypedData(USER_SIGNED_PAYLOAD); + + expect(signature).toBe(MAIN_SIGNATURE); + expect(mainSign.mock.calls).toStrictEqual([ + [mainAddress, USER_SIGNED_PAYLOAD], + ]); + expect(resolveAgent).not.toHaveBeenCalled(); + expect(agentSign).not.toHaveBeenCalled(); + }); + + it('keeps an Agent primary type outside the Exchange domain on the main account', async () => { + const { adapter, resolveAgent, agentSign, mainSign } = buildAdapter(); + const lookalike = { + ...L1_PAYLOAD, + domain: { ...L1_PAYLOAD.domain, name: USER_SIGNED_PAYLOAD.domain.name }, + }; + + const signature = await adapter.signTypedData(lookalike); + + expect(signature).toBe(MAIN_SIGNATURE); + expect(mainSign.mock.calls).toStrictEqual([[mainAddress, lookalike]]); + expect(resolveAgent).not.toHaveBeenCalled(); + expect(agentSign).not.toHaveBeenCalled(); + }); + + it('keeps another primary type in the Exchange domain on the main account', async () => { + const { adapter, resolveAgent, agentSign, mainSign } = buildAdapter(); + const lookalike = { + ...USER_SIGNED_PAYLOAD, + domain: L1_PAYLOAD.domain, + }; + + const signature = await adapter.signTypedData(lookalike); + + expect(signature).toBe(MAIN_SIGNATURE); + expect(mainSign.mock.calls).toStrictEqual([[mainAddress, lookalike]]); + expect(resolveAgent).not.toHaveBeenCalled(); + expect(agentSign).not.toHaveBeenCalled(); + }); + + it('signs L1 actions with the main account when no agent is resolved', async () => { + const { adapter, resolveAgent, mainSign } = buildAdapter(false); + + const signature = await adapter.signTypedData(L1_PAYLOAD); + + expect(signature).toBe(MAIN_SIGNATURE); + expect(resolveAgent.mock.calls).toStrictEqual([[mainAddress]]); + expect(mainSign.mock.calls).toStrictEqual([[mainAddress, L1_PAYLOAD]]); + }); + + it('resolves the agent for the account selected at signing time', async () => { + const { adapter, resolveAgent, selectAccount } = buildAdapter(); + + selectAccount(OTHER_MAIN_ADDRESS); + await adapter.signTypedData(L1_PAYLOAD); + + expect(resolveAgent.mock.calls).toStrictEqual([[OTHER_MAIN_ADDRESS]]); + }); + + it('propagates agent resolution failures', async () => { + const { adapter, resolveAgent, mainSign } = buildAdapter(); + const failure = new Error('agent store unavailable'); + resolveAgent.mockRejectedValue(failure); + + await expect(adapter.signTypedData(L1_PAYLOAD)).rejects.toBe(failure); + expect(mainSign).not.toHaveBeenCalled(); + }); + + it('signs L1 actions with the agent while the main signer is not ready', async () => { + const agentSign = jest.fn().mockResolvedValue(AGENT_SIGNATURE); + const { messenger } = createKeyringlessMessenger(); + const signer = { + signTypedData: jest.fn(), + signPersonalMessage: jest.fn(), + isReady: (): boolean => false, + }; + const adapter = new HyperLiquidWalletService( + { ...createMockInfrastructure(), accountSigner: signer }, + messenger, + { + isTestnet: true, + resolveAgent: async (): Promise => ({ + address: AGENT_ADDRESS, + signTypedData: agentSign, + }), + }, + ).createWalletAdapter(); + + const signature = await adapter.signTypedData(L1_PAYLOAD); + + expect(signature).toBe(AGENT_SIGNATURE); + await expect(adapter.signTypedData(USER_SIGNED_PAYLOAD)).rejects.toThrow( + PERPS_ERROR_CODES.KEYRING_LOCKED, + ); + expect(signer.signTypedData).not.toHaveBeenCalled(); + }); + + it('reports an agent that fails to sign as unavailable without signing with the main account', async () => { + const { adapter, agentSign, mainSign } = buildAdapter(); + const failure = new Error('agent key locked'); + agentSign.mockRejectedValue(failure); + + const error: unknown = await adapter + .signTypedData(L1_PAYLOAD) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(AgentSignerUnavailableError); + expect(error).toHaveProperty('cause', failure); + expect(mainSign).not.toHaveBeenCalled(); + }); +}); + +describe('HyperLiquidWalletService wallet adapter with an agent and a keyring', () => { + // The shape Mobile and Extension would run: KeyringController present, no + // accountSigner, and an agent resolver. + const { address: mainAddress } = createMockEvmAccount(); + function buildKeyringAdapter(): { + adapter: ReturnType; + agentSign: jest.Mock; + call: jest.SpyInstance; + } { + const { messenger, call } = createKeyringMessenger(MAIN_SIGNATURE); + const agentSign = jest.fn().mockResolvedValue(AGENT_SIGNATURE); + const service = new HyperLiquidWalletService( + createMockInfrastructure(), + messenger, + { + resolveAgent: async (): Promise => ({ + address: AGENT_ADDRESS, + signTypedData: agentSign, + }), + }, + ); + return { adapter: service.createWalletAdapter(), agentSign, call }; + } + + it('signs L1 actions with the agent without calling KeyringController', async () => { + const { adapter, agentSign, call } = buildKeyringAdapter(); + + const signature = await adapter.signTypedData(L1_PAYLOAD); + + expect(signature).toBe(AGENT_SIGNATURE); + expect(agentSign.mock.calls).toStrictEqual([[L1_PAYLOAD]]); + expect(keyringCalls(call)).toStrictEqual([]); + }); + + it('signs user-signed actions through KeyringController:signTypedMessage V4', async () => { + const { adapter, agentSign, call } = buildKeyringAdapter(); + + const signature = await adapter.signTypedData(USER_SIGNED_PAYLOAD); + + expect(signature).toBe(MAIN_SIGNATURE); + expect(agentSign).not.toHaveBeenCalled(); + expect( + call.mock.calls.filter( + ([action]) => action === 'KeyringController:signTypedMessage', + ), + ).toStrictEqual([ + [ + 'KeyringController:signTypedMessage', + { from: mainAddress, data: USER_SIGNED_PAYLOAD }, + 'V4', + ], + ]); + }); +}); + +// The SDK ships ES modules only, and Jest can require them only on Node 24.9 +// or newer, so this suite runs in the Node 24 CI job and is skipped on Node 22. +const [nodeMajor, nodeMinor] = process.versions.node.split('.').map(Number); +const describeWithSdk = + nodeMajor > 24 || (nodeMajor === 24 && nodeMinor >= 9) + ? describe + : describe.skip; + +describeWithSdk( + 'HyperLiquidWalletService wallet adapter with the HyperLiquid SDK', + () => { + // Drive the adapter through the SDK's own signing functions and recover the + // signer from each signature, so the routing holds for the payloads the SDK + // builds (including its EIP712Domain entry) and for its wallet detection. + // Fixed keys, so every run signs and recovers the same bytes. + const mainAccount = privateKeyToAccount(`0x${'11'.repeat(32)}`); + const agentAccount = privateKeyToAccount(`0x${'22'.repeat(32)}`); + let signing: typeof HyperLiquidSigning; + let exchange: typeof HyperLiquidExchange; + + beforeAll(() => { + signing = jest.requireActual( + '@nktkas/hyperliquid/signing', + ); + exchange = jest.requireActual( + '@nktkas/hyperliquid/api/exchange', + ); + }); + + type RecordedSignature = { payload: PerpsTypedDataPayload; signature: Hex }; + + function buildSdkAdapter( + resolveAgent: () => Promise = async () => + // A viem local account is a PerpsAgentSigner as it is. + agentAccount, + ): { + adapter: ReturnType; + signatures: RecordedSignature[]; + agentSignatures: () => Promise; + } { + const { messenger, selectAccount } = createKeyringlessMessenger(); + selectAccount(mainAccount.address); + const signatures: { payload: PerpsTypedDataPayload; signature: Hex }[] = + []; + const recordSignature = + (account: typeof mainAccount) => + async (payload: PerpsTypedDataPayload): Promise => { + const signature = await account.signTypedData(payload); + signatures.push({ payload, signature }); + return signature; + }; + const service = new HyperLiquidWalletService( + { + ...createMockInfrastructure(), + accountSigner: { + signTypedData: async (_address, payload): Promise => + await recordSignature(mainAccount)(payload), + signPersonalMessage: async (_address, message): Promise => + await mainAccount.signMessage({ message }), + }, + }, + messenger, + { isTestnet: true, resolveAgent }, + ); + const agentSign = jest.spyOn(agentAccount, 'signTypedData'); + return { + adapter: service.createWalletAdapter(), + signatures, + agentSignatures: async () => + await Promise.all( + agentSign.mock.calls.map(async ([payload], index) => ({ + payload: payload as PerpsTypedDataPayload, + signature: (await agentSign.mock.results[index].value) as Hex, + })), + ), + }; + } + + async function recoverSigner({ + payload, + signature, + }: { + payload: PerpsTypedDataPayload; + signature: Hex; + }): Promise { + return await recoverTypedDataAddress({ + domain: payload.domain, + types: payload.types, + primaryType: payload.primaryType, + message: payload.message, + signature, + }); + } + + it('signs an SDK L1 action with the agent', async () => { + const { adapter, signatures, agentSignatures } = buildSdkAdapter(); + + await signing.signL1Action({ + wallet: adapter, + action: { type: 'cancel', cancels: [{ a: 0, o: 1 }] }, + nonce: 1, + isTestnet: true, + }); + + const agentSigned = await agentSignatures(); + expect(signatures).toHaveLength(0); + expect(agentSigned).toHaveLength(1); + expect(agentSigned[0].payload.types).toHaveProperty('EIP712Domain'); + expect(await recoverSigner(agentSigned[0])).toBe(agentAccount.address); + }); + + it('signs an SDK user-signed action with the main account', async () => { + const { adapter, signatures, agentSignatures } = buildSdkAdapter(); + + await signing.signUserSignedAction({ + wallet: adapter, + action: { + type: 'approveBuilderFee', + signatureChainId: ARBITRUM_SEPOLIA_CHAIN_ID, + hyperliquidChain: 'Testnet', + maxFeeRate: BUILDER_FEE_CONFIG.MaxFeeRate, + builder: agentAccount.address, + nonce: 1, + }, + types: exchange.ApproveBuilderFeeTypes, + }); + + expect(await agentSignatures()).toHaveLength(0); + expect(signatures).toHaveLength(1); + expect(await recoverSigner(signatures[0])).toBe(mainAccount.address); + }); + + it('keeps an unavailable agent recognizable through the SDK error', async () => { + const { adapter } = buildSdkAdapter(async () => { + throw new AgentSignerUnavailableError(new Error('agent store down')); + }); + + const error: unknown = await signing + .signL1Action({ + wallet: adapter, + action: { type: 'cancel', cancels: [{ a: 0, o: 1 }] }, + nonce: 1, + isTestnet: true, + }) + .catch((caught: unknown) => caught); + + expect(error).not.toBeInstanceOf(AgentSignerUnavailableError); + expect(isAgentSignerUnavailableError(error)).toBe(true); + }); + + it('keeps an agent signing failure recognizable through the SDK error', async () => { + const { adapter } = buildSdkAdapter(async () => ({ + address: agentAccount.address, + signTypedData: async (): Promise => { + throw new Error('agent key locked'); + }, + })); + + const error: unknown = await signing + .signL1Action({ + wallet: adapter, + action: { type: 'cancel', cancels: [{ a: 0, o: 1 }] }, + nonce: 1, + isTestnet: true, + }) + .catch((caught: unknown) => caught); + + expect(isAgentSignerUnavailableError(error)).toBe(true); + }); + }, +); diff --git a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts index 72fb99734bc..1658a499d4f 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts @@ -345,8 +345,8 @@ describe('HyperLiquidWalletService', () => { expect(address).toBe(mockEvmAccount.address); }); - it('returns false for software wallet', () => { - expect(service.isSelectedHardwareWallet()).toBe(false); + it('requires no signature confirmation for an HD keyring account', () => { + expect(service.requiresSignatureConfirmation()).toBe(false); }); it.each([ @@ -355,7 +355,7 @@ describe('HyperLiquidWalletService', () => { 'OneKey Hardware', 'Lattice Hardware', 'QR Hardware Wallet Device', - ])('returns true for %s wallet', (keyringType) => { + ])('requires signature confirmation for %s', (keyringType) => { (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { if ( action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' @@ -373,7 +373,7 @@ describe('HyperLiquidWalletService', () => { return undefined; }); - expect(service.isSelectedHardwareWallet()).toBe(true); + expect(service.requiresSignatureConfirmation()).toBe(true); }); }); @@ -469,8 +469,8 @@ describe('HyperLiquidWalletService', () => { ); }); - it('should return keyring unlocked status via isKeyringUnlocked()', () => { - expect(service.isKeyringUnlocked()).toBe(true); + it('reports whether the main-account signer is ready from the keyring lock state', () => { + expect(service.isMainAccountSignerReady()).toBe(true); (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { if (action === 'KeyringController:getState') { @@ -479,7 +479,7 @@ describe('HyperLiquidWalletService', () => { return undefined; }); - expect(service.isKeyringUnlocked()).toBe(false); + expect(service.isMainAccountSignerReady()).toBe(false); }); it('should handle keyring controller initialization errors', async () => { diff --git a/packages/perps-controller/tests/src/services/LighterWalletService.account-signer.test.ts b/packages/perps-controller/tests/src/services/LighterWalletService.account-signer.test.ts new file mode 100644 index 00000000000..5607cfa5afa --- /dev/null +++ b/packages/perps-controller/tests/src/services/LighterWalletService.account-signer.test.ts @@ -0,0 +1,154 @@ +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { LighterWalletService } from '../../../src/services/LighterWalletService.js'; +import { MAIN_SIGNATURE } from '../../helpers/agentFixtures.js'; +import { + createKeyringMessenger, + createKeyringlessMessenger, + createMockEvmAccount, + createMockInfrastructure, + keyringCalls, +} from '../../helpers/serviceMocks.js'; + +function createSigner(isReady?: () => boolean): { + signTypedData: jest.Mock; + signPersonalMessage: jest.Mock; + isReady?: () => boolean; +} { + return { + signTypedData: jest.fn(), + signPersonalMessage: jest.fn().mockResolvedValue(MAIN_SIGNATURE), + isReady, + }; +} + +describe('LighterWalletService with accountSigner', () => { + it('signs personal messages through the account signer without KeyringController', async () => { + const signer = createSigner(); + const { messenger, call } = createKeyringlessMessenger(); + const service = new LighterWalletService( + { ...createMockInfrastructure(), accountSigner: signer }, + { isTestnet: true, messenger }, + ); + + const signature = await service.signPersonalMessage('hello'); + + expect(signature).toBe(MAIN_SIGNATURE); + expect(signer.signPersonalMessage.mock.calls).toStrictEqual([ + [createMockEvmAccount().address, 'hello'], + ]); + expect(keyringCalls(call)).toStrictEqual([]); + }); + + it('fails with KEYRING_LOCKED, keeping the host error as its cause, when the signer locks while signing', async () => { + let ready = true; + const hostError = new Error('Wallet is locked'); + const signer = createSigner(() => ready); + signer.signPersonalMessage.mockImplementation(async () => { + ready = false; + throw hostError; + }); + const { messenger } = createKeyringlessMessenger(); + const service = new LighterWalletService( + { ...createMockInfrastructure(), accountSigner: signer }, + { isTestnet: true, messenger }, + ); + + const error: unknown = await service + .signPersonalMessage('hello') + .catch((caught: unknown) => caught); + + expect(error).toStrictEqual(new Error(PERPS_ERROR_CODES.KEYRING_LOCKED)); + // Jest's Error equality ignores `cause`, so check it by identity. + expect((error as Error).cause).toBe(hostError); + }); + + it('rethrows an account signer rejection unchanged while the signer stays ready', async () => { + const rejection = new Error('User rejected the request.'); + const signer = createSigner(() => true); + signer.signPersonalMessage.mockRejectedValue(rejection); + const { messenger } = createKeyringlessMessenger(); + const service = new LighterWalletService( + { ...createMockInfrastructure(), accountSigner: signer }, + { isTestnet: true, messenger }, + ); + + await expect(service.signPersonalMessage('hello')).rejects.toBe(rejection); + }); + + it('fails with KEYRING_LOCKED and does not sign when isReady returns false', async () => { + const signer = createSigner(() => false); + const { messenger, call } = createKeyringlessMessenger(); + const service = new LighterWalletService( + { ...createMockInfrastructure(), accountSigner: signer }, + { isTestnet: true, messenger }, + ); + + await expect(service.signPersonalMessage('hello')).rejects.toThrow( + PERPS_ERROR_CODES.KEYRING_LOCKED, + ); + expect(signer.signPersonalMessage).not.toHaveBeenCalled(); + expect(keyringCalls(call)).toStrictEqual([]); + }); + + it('fails with NO_ACCOUNT_SELECTED without a messenger to resolve the address', async () => { + const signer = createSigner(); + const service = new LighterWalletService( + { ...createMockInfrastructure(), accountSigner: signer }, + { isTestnet: true }, + ); + + await expect(service.signPersonalMessage('hello')).rejects.toThrow( + PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED, + ); + expect(signer.signPersonalMessage).not.toHaveBeenCalled(); + }); +}); + +describe('LighterWalletService.isMainAccountSignerReady', () => { + it('follows the account signer when one is set', () => { + let ready = true; + const { messenger, call } = createKeyringlessMessenger(); + const service = new LighterWalletService( + { + ...createMockInfrastructure(), + accountSigner: createSigner(() => ready), + }, + { isTestnet: true, messenger }, + ); + + const whileReady = service.isMainAccountSignerReady(); + ready = false; + + expect(whileReady).toBe(true); + expect(service.isMainAccountSignerReady()).toBe(false); + expect(keyringCalls(call)).toStrictEqual([]); + }); + + it.each([ + ['unlocked', true], + ['locked', false], + ])( + "follows the keyring's unlock state without an account signer (%s)", + (_state, isUnlocked) => { + const { messenger, call } = createKeyringMessenger( + MAIN_SIGNATURE, + isUnlocked, + ); + const service = new LighterWalletService(createMockInfrastructure(), { + isTestnet: true, + messenger, + }); + + expect(service.isMainAccountSignerReady()).toBe(isUnlocked); + expect(keyringCalls(call)).toStrictEqual(['KeyringController:getState']); + }, + ); + + it('is not ready without an account signer or a messenger', () => { + const service = new LighterWalletService(createMockInfrastructure(), { + isTestnet: true, + }); + + expect(service.isMainAccountSignerReady()).toBe(false); + }); +}); diff --git a/packages/perps-controller/tests/src/services/LighterWalletService.test.ts b/packages/perps-controller/tests/src/services/LighterWalletService.test.ts index cee1114cb4c..a6ce56c6ad0 100644 --- a/packages/perps-controller/tests/src/services/LighterWalletService.test.ts +++ b/packages/perps-controller/tests/src/services/LighterWalletService.test.ts @@ -1,41 +1,20 @@ import type { PerpsControllerMessenger } from '../../../src/PerpsController.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; import { LighterWalletService } from '../../../src/services/LighterWalletService.js'; +import { MAIN_SIGNATURE } from '../../helpers/agentFixtures.js'; import { createMockInfrastructure, createMockMessenger, } from '../../helpers/serviceMocks.js'; -// A fixed 65-byte signature (deterministic vector). -const FIXED_SIGNATURE = `0x${'ab'.repeat(65)}`; -const HEADLESS_ADDRESS = '0x8D7f03FdE1A626223364E592740a233b72395235'; +const SELECTED_ADDRESS = '0x8D7f03FdE1A626223364E592740a233b72395235'; describe('LighterWalletService', () => { - describe('headless (injected signer)', () => { - const buildService = ( - signer = jest.fn().mockResolvedValue(FIXED_SIGNATURE), - ): { service: LighterWalletService; signer: jest.Mock } => { + describe('network', () => { + it('exposes and toggles testnet mode', () => { const service = new LighterWalletService(createMockInfrastructure(), { isTestnet: true, - personalSigner: signer, - l1Address: HEADLESS_ADDRESS, }); - return { service, signer }; - }; - - it('returns the injected L1 address', () => { - const { service } = buildService(); - expect(service.getUserAddress()).toBe(HEADLESS_ADDRESS); - }); - - it('routes personal_sign through the injected signer', async () => { - const { service, signer } = buildService(); - const signature = await service.signPersonalMessage('hello'); - expect(signature).toBe(FIXED_SIGNATURE); - expect(signer).toHaveBeenCalledWith('hello'); - }); - - it('exposes and toggles testnet mode', () => { - const { service } = buildService(); expect(service.isTestnetMode()).toBe(true); service.setTestnetMode(false); expect(service.isTestnetMode()).toBe(false); @@ -45,7 +24,7 @@ describe('LighterWalletService', () => { describe('messenger-backed', () => { const selectedAccount = { - address: HEADLESS_ADDRESS, + address: SELECTED_ADDRESS, type: 'eip155:eoa', metadata: {}, }; @@ -70,7 +49,7 @@ describe('LighterWalletService', () => { return [selectedAccount]; } if (action === 'KeyringController:signPersonalMessage') { - return Promise.resolve(FIXED_SIGNATURE); + return Promise.resolve(MAIN_SIGNATURE); } throw new Error(`Unexpected action: ${action}`); }); @@ -81,35 +60,38 @@ describe('LighterWalletService', () => { return { service, messenger }; }; - it('signs through KeyringController:signPersonalMessage', async () => { + it('signs the hex-encoded UTF-8 message through KeyringController:signPersonalMessage', async () => { const { service, messenger } = buildMessengerService(); - const signature = await service.signPersonalMessage('register me'); - expect(signature).toBe(FIXED_SIGNATURE); - expect(messenger.call).toHaveBeenCalledWith( - 'KeyringController:signPersonalMessage', - expect.objectContaining({ - from: HEADLESS_ADDRESS, - data: expect.stringMatching(/^0x/u), - }), - ); + const signature = await service.signPersonalMessage('register me ✓'); + expect(signature).toBe(MAIN_SIGNATURE); + expect( + messenger.call.mock.calls.filter( + ([action]) => action === 'KeyringController:signPersonalMessage', + ), + ).toStrictEqual([ + [ + 'KeyringController:signPersonalMessage', + // 'register me ✓' as UTF-8 bytes. + { from: SELECTED_ADDRESS, data: '0x7265676973746572206d6520e29c93' }, + ], + ]); }); it('rejects when the keyring is locked', async () => { const { service } = buildMessengerService(false); await expect(service.signPersonalMessage('nope')).rejects.toThrow( - 'KEYRING_LOCKED', + PERPS_ERROR_CODES.KEYRING_LOCKED, ); }); }); describe('unconfigured', () => { - it('rejects signing without messenger or injected signer', async () => { + it('rejects signing without messenger or account signer', async () => { const service = new LighterWalletService(createMockInfrastructure(), { isTestnet: true, - l1Address: HEADLESS_ADDRESS, }); await expect(service.signPersonalMessage('x')).rejects.toThrow( - 'NO_ACCOUNT_SELECTED', + PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED, ); }); @@ -117,7 +99,9 @@ describe('LighterWalletService', () => { const service = new LighterWalletService(createMockInfrastructure(), { isTestnet: true, }); - expect(() => service.getUserAddress()).toThrow('NO_ACCOUNT_SELECTED'); + expect(() => service.getUserAddress()).toThrow( + PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED, + ); }); }); }); diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index 9ee86c01839..2fcdaedc37d 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -1,4 +1,5 @@ import { PERPS_EVENT_VALUE } from '../../../src/constants/eventNames.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; import type { ServiceContext } from '../../../src/services/ServiceContext.js'; import { TradingService } from '../../../src/services/TradingService.js'; import { PerpsAnalyticsEvent } from '../../../src/types/index.js'; @@ -1525,6 +1526,25 @@ describe('TradingService', () => { ); }); + it('does not log a cancel the signer could not sign', async () => { + mockProvider.cancelOrder.mockResolvedValue({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + + const result = await tradingService.cancelOrder({ + provider: mockProvider, + params: { orderId: 'order-123', symbol: 'BTC' }, + context: mockContext, + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(mockDeps.logger.error).not.toHaveBeenCalled(); + }); + it('handles provider exception during order cancel', async () => { const cancelParams: CancelOrderParams = { orderId: 'order-123', @@ -1816,17 +1836,124 @@ describe('TradingService', () => { }); expect(result.success).toBe(false); - expect(mockDeps.logger.error).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining( - 'cancelOrders batch failure: 2/2 failed', + expect((mockDeps.logger.error as jest.Mock).mock.calls).toStrictEqual([ + [ + new Error( + 'cancelOrders batch failure: 2/2 failed (2 reported) - BTC/order-1: rate limit; ETH/order-2: not found', ), - }), - expect.objectContaining({ - controller: 'TradingService', - method: 'cancelOrders', - }), + { + controller: 'TradingService', + method: 'cancelOrders', + successCount: 0, + failureCount: 2, + reportedFailureCount: 2, + cancelAll: true, + }, + ], + ]); + }); + + it('does not log a batch cancel that failed only because the signer could not sign', async () => { + mockGetOpenOrders.mockResolvedValue(mockOrders); + mockWithStreamPause.mockImplementation( + async (callback) => await callback(), + ); + (mockProvider.cancelOrders as jest.Mock).mockResolvedValue({ + success: false, + successCount: 0, + failureCount: 2, + results: [ + { + orderId: 'order-1', + symbol: 'BTC', + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }, + { + orderId: 'order-2', + symbol: 'ETH', + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }, + ], + }); + + const result = await tradingService.cancelOrders({ + provider: mockProvider, + params: { cancelAll: true }, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + expect(result).toStrictEqual({ + success: false, + successCount: 0, + failureCount: 2, + results: [ + { + orderId: 'order-1', + symbol: 'BTC', + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }, + { + orderId: 'order-2', + symbol: 'ETH', + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }, + ], + }); + expect(mockDeps.logger.error).not.toHaveBeenCalled(); + }); + + it('counts and lists only the reported failures of a batch cancel', async () => { + mockGetOpenOrders.mockResolvedValue(mockOrders); + mockWithStreamPause.mockImplementation( + async (callback) => await callback(), ); + (mockProvider.cancelOrders as jest.Mock).mockResolvedValue({ + success: false, + successCount: 0, + failureCount: 2, + results: [ + { + orderId: 'order-1', + symbol: 'BTC', + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }, + { + orderId: 'order-2', + symbol: 'ETH', + success: false, + error: 'rate limit', + }, + ], + }); + + await tradingService.cancelOrders({ + provider: mockProvider, + params: { cancelAll: true }, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + expect((mockDeps.logger.error as jest.Mock).mock.calls).toStrictEqual([ + [ + new Error( + 'cancelOrders batch failure: 2/2 failed (1 reported) - ETH/order-2: rate limit', + ), + { + controller: 'TradingService', + method: 'cancelOrders', + successCount: 0, + failureCount: 2, + reportedFailureCount: 1, + cancelAll: true, + }, + ], + ]); }); it('does NOT log batch error when using fallback path (provider.cancelOrders undefined)', async () => { @@ -2168,6 +2295,30 @@ describe('TradingService', () => { }), ); }); + + it('does not log a close the signer could not sign', async () => { + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.closePosition.mockResolvedValue({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + const result = await tradingService.closePosition({ + provider: mockProvider, + params: { symbol: 'BTC' }, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(mockDeps.logger.error).not.toHaveBeenCalled(); + }); }); describe('closePositions', () => { @@ -2470,17 +2621,102 @@ describe('TradingService', () => { }); expect(result.success).toBe(false); - expect(mockDeps.logger.error).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining( - 'closePositions batch failure: 2/2 failed', + expect((mockDeps.logger.error as jest.Mock).mock.calls).toStrictEqual([ + [ + new Error( + 'closePositions batch failure: 2/2 failed (2 reported) - BTC: insufficient liquidity; ETH: min size', ), - }), - expect.objectContaining({ - controller: 'TradingService', - method: 'closePositions', - }), + { + controller: 'TradingService', + method: 'closePositions', + successCount: 0, + failureCount: 2, + reportedFailureCount: 2, + symbols: 0, + closeAll: true, + }, + ], + ]); + }); + + it('does not log a batch close that failed only because the signer could not sign', async () => { + (mockProvider.closePositions as jest.Mock).mockResolvedValue({ + success: false, + successCount: 0, + failureCount: 1, + results: [ + { + symbol: 'BTC', + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }, + ], + }); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + const result = await tradingService.closePositions({ + provider: mockProvider, + params: { closeAll: true }, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(result).toStrictEqual({ + success: false, + successCount: 0, + failureCount: 1, + results: [ + { + symbol: 'BTC', + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }, + ], + }); + expect(mockDeps.logger.error).not.toHaveBeenCalled(); + }); + + it('counts and lists only the reported failures of a batch close', async () => { + (mockProvider.closePositions as jest.Mock).mockResolvedValue({ + success: false, + successCount: 0, + failureCount: 2, + results: [ + { + symbol: 'BTC', + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }, + { symbol: 'ETH', success: false, error: 'min size' }, + ], + }); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, ); + + await tradingService.closePositions({ + provider: mockProvider, + params: { closeAll: true }, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect((mockDeps.logger.error as jest.Mock).mock.calls).toStrictEqual([ + [ + new Error( + 'closePositions batch failure: 2/2 failed (1 reported) - ETH: min size', + ), + { + controller: 'TradingService', + method: 'closePositions', + successCount: 0, + failureCount: 2, + reportedFailureCount: 1, + symbols: 0, + closeAll: true, + }, + ], + ]); }); it('does NOT log batch error when using fallback path (provider.closePositions undefined)', async () => { diff --git a/packages/perps-controller/tests/src/services/agentSigner.test.ts b/packages/perps-controller/tests/src/services/agentSigner.test.ts new file mode 100644 index 00000000000..2f88736defb --- /dev/null +++ b/packages/perps-controller/tests/src/services/agentSigner.test.ts @@ -0,0 +1,138 @@ +import { + AgentBindings, + AgentSignerUnavailableError, + isAgentSignerUnavailableError, +} from '../../../src/services/agentSigner.js'; +import type { PerpsAgentAccount } from '../../../src/types/index.js'; +import { + AGENT_ADDRESS, + MAINNET_ACCOUNT, + OTHER_AGENT_ADDRESS, + OTHER_MAIN_ADDRESS, + sdkSigningError, + TESTNET_ACCOUNT, +} from '../../helpers/agentFixtures.js'; + +// The same main account, spelled in upper case. +const UPPER_CASE_MAIN_ADDRESS = `0x${MAINNET_ACCOUNT.mainAddress + .slice(2) + .toUpperCase()}` as const; +const AGENT = { + address: AGENT_ADDRESS, + signTypedData: jest.fn(), +} as const; + +describe('AgentBindings', () => { + it('resolves no agent without getAgentSigner or a binding', async () => { + const bindings = new AgentBindings(undefined); + + expect(await bindings.resolve(MAINNET_ACCOUNT)).toBeNull(); + }); + + it('answers with a binding for its account and network only, and asks getAgentSigner for the others', async () => { + const getAgentSigner = jest.fn().mockResolvedValue(null); + const bindings = new AgentBindings(getAgentSigner); + const otherAccount: PerpsAgentAccount = { + ...MAINNET_ACCOUNT, + mainAddress: OTHER_MAIN_ADDRESS, + }; + + bindings.set(MAINNET_ACCOUNT, AGENT); + + expect(await bindings.resolve(MAINNET_ACCOUNT)).toBe(AGENT); + expect(await bindings.resolve(otherAccount)).toBeNull(); + expect(await bindings.resolve(TESTNET_ACCOUNT)).toBeNull(); + expect(getAgentSigner.mock.calls).toStrictEqual([ + [otherAccount], + [TESTNET_ACCOUNT], + ]); + }); + + it('matches a binding whatever the main address casing', async () => { + const getAgentSigner = jest.fn().mockResolvedValue(null); + const bindings = new AgentBindings(getAgentSigner); + + bindings.set(MAINNET_ACCOUNT, AGENT); + + expect( + await bindings.resolve({ + ...MAINNET_ACCOUNT, + mainAddress: UPPER_CASE_MAIN_ADDRESS, + }), + ).toBe(AGENT); + expect(getAgentSigner).not.toHaveBeenCalled(); + }); + + it('forgets bindings and pins once cleared', async () => { + const getAgentSigner = jest.fn().mockResolvedValue(null); + const bindings = new AgentBindings(getAgentSigner); + const otherAccount: PerpsAgentAccount = { + ...MAINNET_ACCOUNT, + mainAddress: OTHER_MAIN_ADDRESS, + }; + bindings.set(MAINNET_ACCOUNT, null); + bindings.set(otherAccount, AGENT); + + bindings.clear(); + + expect(await bindings.resolve(MAINNET_ACCOUNT)).toBeNull(); + expect(await bindings.resolve(otherAccount)).toBeNull(); + // Both now fall through to the host. + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [otherAccount], + ]); + }); + + it('releases a binding to the rejected agent whatever the address casing', async () => { + const getAgentSigner = jest.fn().mockResolvedValue(null); + const bindings = new AgentBindings(getAgentSigner); + bindings.set(MAINNET_ACCOUNT, AGENT); + + bindings.release( + { ...MAINNET_ACCOUNT, mainAddress: UPPER_CASE_MAIN_ADDRESS }, + AGENT.address.toUpperCase().replace('0X', '0x'), + ); + + expect(await bindings.resolve(MAINNET_ACCOUNT)).toBeNull(); + expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); + }); + + it("releases only the rejecting account's binding to the agent", async () => { + const getAgentSigner = jest.fn().mockResolvedValue(null); + const bindings = new AgentBindings(getAgentSigner); + bindings.set(MAINNET_ACCOUNT, AGENT); + bindings.set(TESTNET_ACCOUNT, AGENT); + + bindings.release(TESTNET_ACCOUNT, AGENT.address); + + expect(await bindings.resolve(MAINNET_ACCOUNT)).toBe(AGENT); + expect(await bindings.resolve(TESTNET_ACCOUNT)).toBeNull(); + expect(getAgentSigner.mock.calls).toStrictEqual([[TESTNET_ACCOUNT]]); + }); + + it('keeps a binding to another agent and a pin when an agent is rejected', async () => { + const getAgentSigner = jest.fn().mockResolvedValue(AGENT); + const bindings = new AgentBindings(getAgentSigner); + bindings.set(MAINNET_ACCOUNT, AGENT); + bindings.set(TESTNET_ACCOUNT, null); + + bindings.release(MAINNET_ACCOUNT, OTHER_AGENT_ADDRESS); + bindings.release(TESTNET_ACCOUNT, AGENT.address); + + expect(await bindings.resolve(MAINNET_ACCOUNT)).toBe(AGENT); + expect(await bindings.resolve(TESTNET_ACCOUNT)).toBeNull(); + expect(getAgentSigner).not.toHaveBeenCalled(); + }); +}); + +describe('isAgentSignerUnavailableError', () => { + it('finds the error anywhere in the cause chain', () => { + const unavailable = new AgentSignerUnavailableError(new Error('down')); + + expect(isAgentSignerUnavailableError(sdkSigningError(unavailable))).toBe( + true, + ); + expect(isAgentSignerUnavailableError(new Error('other'))).toBe(false); + }); +});