From a370f2d02ed4e4a596ccef4aa2d934f0e035dedc Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 28 Sep 2026 21:22:05 +0800 Subject: [PATCH 01/33] feat(perps-controller): add accountSigner platform dependency Clients without a KeyringController can now sign through their own wallet. PerpsPlatformDependencies gains an optional accountSigner (signTypedData, signPersonalMessage, isReady, isHardwareWallet). When it is set, the HyperLiquid and Lighter wallet services sign through it instead of the KeyringController:* messenger actions. Clients that do not set it keep the current behaviour. isReady() returning false fails with the existing KEYRING_LOCKED code, and isHardwareWallet() drives the same prompt deferral as the keyring-type check. --- packages/perps-controller/CHANGELOG.md | 4 + packages/perps-controller/src/index.ts | 2 + .../src/services/HyperLiquidWalletService.ts | 20 +- .../src/services/LighterWalletService.ts | 19 +- packages/perps-controller/src/types/index.ts | 60 ++++++ ...HyperLiquidProvider.account-signer.test.ts | 81 +++++++ ...rLiquidWalletService.accountSigner.test.ts | 197 ++++++++++++++++++ ...LighterWalletService.accountSigner.test.ts | 117 +++++++++++ 8 files changed, 496 insertions(+), 4 deletions(-) create mode 100644 packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts create mode 100644 packages/perps-controller/tests/src/services/HyperLiquidWalletService.accountSigner.test.ts create mode 100644 packages/perps-controller/tests/src/services/LighterWalletService.accountSigner.test.ts diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index d7064e7f221..4b2639773e8 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -12,6 +12,10 @@ 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 + - Export the new `PerpsAccountSigner` and `PerpsTypedDataPayload` types + - When set, HyperLiquid typed-data signing and Lighter `personal_sign` go through it instead of the `KeyringController:*` messenger actions + - `isReady()` returning `false` fails signing with the existing `KEYRING_LOCKED` error code; `isHardwareWallet()` defers optional signing prompts like a hardware keyring does ## [18.0.1] diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 75afd7d8060..2a37950ff4f 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -331,6 +331,8 @@ export type { PerpsPerformance, PerpsTracer, PerpsTypedMessageParams, + PerpsTypedDataPayload, + PerpsAccountSigner, PerpsTransactionParams, PerpsAddTransactionOptions, PerpsInternalAccount, diff --git a/packages/perps-controller/src/services/HyperLiquidWalletService.ts b/packages/perps-controller/src/services/HyperLiquidWalletService.ts index ab0a7dad883..ec693094fdb 100644 --- a/packages/perps-controller/src/services/HyperLiquidWalletService.ts +++ b/packages/perps-controller/src/services/HyperLiquidWalletService.ts @@ -9,6 +9,7 @@ import { getChainId } from '../constants/hyperLiquidConfig.js'; import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; import type { PerpsPlatformDependencies, + PerpsTypedDataPayload, PerpsTypedMessageParams, } from '../types/index.js'; import type { PerpsControllerMessengerBase } from '../types/messenger.js'; @@ -55,6 +56,10 @@ export class HyperLiquidWalletService { * @returns True if the keyring is unlocked and available for signing. */ public isKeyringUnlocked(): boolean { + const { accountSigner } = this.#deps; + if (accountSigner) { + return accountSigner.isReady?.() ?? true; + } return this.#messenger.call('KeyringController:getState').isUnlocked; } @@ -64,6 +69,11 @@ export class HyperLiquidWalletService { * @returns True for MetaMask hardware keyrings; false for software accounts. */ public isSelectedHardwareWallet(): boolean { + const { accountSigner } = this.#deps; + if (accountSigner) { + return accountSigner.isHardwareWallet?.() ?? false; + } + const selectedEvmAccount = getSelectedEvmAccountDetailsFromMessenger( this.#messenger, ); @@ -80,7 +90,8 @@ export class HyperLiquidWalletService { } /** - * Sign typed data via DI keyring controller + * Sign typed data via the injected account signer, or the keyring + * controller when none is injected. * * @param msgParams - The typed message parameters including data and sender address. * @returns The signature string. @@ -89,6 +100,13 @@ export class HyperLiquidWalletService { if (!this.isKeyringUnlocked()) { throw new Error(PERPS_ERROR_CODES.KEYRING_LOCKED); } + const { accountSigner } = this.#deps; + if (accountSigner) { + return accountSigner.signTypedData( + msgParams.from as Hex, + msgParams.data as PerpsTypedDataPayload, + ); + } // Cast needed: PerpsTypedMessageParams uses loose `data: unknown` type // while KeyringController uses strict TypedMessageParams / SignTypedDataVersion return this.#messenger.call( diff --git a/packages/perps-controller/src/services/LighterWalletService.ts b/packages/perps-controller/src/services/LighterWalletService.ts index 09498a5f77d..b1f33cb96a8 100644 --- a/packages/perps-controller/src/services/LighterWalletService.ts +++ b/packages/perps-controller/src/services/LighterWalletService.ts @@ -13,9 +13,10 @@ * 2. Venue-key (Schnorr/ECgFp5) signatures over L2 transactions, produced * inside the injected signer bridge from client-managed key material. * - * Signature routing goes through + * Signature routing goes through the injected `accountSigner` when it + * implements `signPersonalMessage`, else * `KeyringController:signPersonalMessage` when a messenger is available, - * or through an injected `LighterPersonalSigner` for headless use. + * else an injected `LighterPersonalSigner` for headless use. */ import { bytesToHex } from '@metamask/utils'; @@ -83,13 +84,25 @@ export class LighterWalletService { /** * Sign an EIP-191 personal message with the user's L1 account. * - * Routes through the keyring when a messenger is present, else the + * Routes through the injected account signer when it can sign personal + * messages, else the keyring when a messenger is present, else the * injected headless signer. * * @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?.signPersonalMessage) { + if (!(accountSigner.isReady?.() ?? true)) { + throw new Error(PERPS_ERROR_CODES.KEYRING_LOCKED); + } + return await accountSigner.signPersonalMessage( + this.getUserAddress() as Hex, + message, + ); + } + if (this.#messenger) { const { isUnlocked } = this.#messenger.call('KeyringController:getState'); if (!isUnlocked) { diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 0a4aecc4f1f..90013b79b81 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -2572,6 +2572,59 @@ export type PerpsTypedMessageParams = { data: unknown; }; +/** + * EIP-712 payload produced by the HyperLiquid SDK, signed with + * `eth_signTypedData_v4` semantics. + */ +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 instead of KeyringController. Clients + * that own a KeyringController omit it. + */ +export type PerpsAccountSigner = { + /** + * Sign EIP-712 typed data as `address`. + * + * @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`. Needed only for Lighter. + * + * @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 physical confirmation, which defers + * optional signing prompts. Defaults to false. + */ + isHardwareWallet?(): boolean; +}; + /** * Minimal transaction params passed to TransactionController.addTransaction. * Only the fields PerpsController actually sets. @@ -2764,6 +2817,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/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..6c75ca6f914 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts @@ -0,0 +1,81 @@ +import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider.js'; +import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService.js'; +import type { HyperLiquidWalletParams } from '../../../src/services/HyperLiquidClientService.js'; +import type { PerpsTypedDataPayload } from '../../../src/types/index.js'; +import { + createMockEvmAccount, + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks.js'; + +// The wallet service stays real: this test proves the provider hands the SDK a +// wallet adapter that signs through the injected account signer. +jest.mock('@nktkas/hyperliquid', () => ({})); +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); +jest.mock('../../../src/services/TradingReadinessCache'); + +const MockedHyperLiquidClientService = + HyperLiquidClientService as jest.MockedClass; + +const SIGNATURE = `0x${'cd'.repeat(65)}` as const; + +const ORDER_TYPED_DATA: PerpsTypedDataPayload = { + domain: { + name: 'Exchange', + version: '1', + chainId: 1337, + verifyingContract: '0x0000000000000000000000000000000000000000', + }, + types: { + Agent: [ + { name: 'source', type: 'string' }, + { name: 'connectionId', type: 'bytes32' }, + ], + }, + primaryType: 'Agent', + message: { source: 'b', connectionId: `0x${'22'.repeat(32)}` }, +}; + +describe('HyperLiquidProvider with accountSigner', () => { + it('initializes the SDK with a wallet that signs through the account signer', async () => { + const initialize = jest.fn, [HyperLiquidWalletParams]>(); + MockedHyperLiquidClientService.mockImplementation( + () => + ({ + initialize, + isTestnetMode: jest.fn().mockReturnValue(true), + setOnTerminateCallback: jest.fn(), + setOnReconnectCallback: jest.fn(), + }) as unknown as HyperLiquidClientService, + ); + const signTypedData = jest.fn().mockResolvedValue(SIGNATURE); + const messenger = createMockMessenger(); + const call = jest.spyOn(messenger, 'call'); + const provider = new HyperLiquidProvider({ + isTestnet: true, + platformDependencies: { + ...createMockInfrastructure(), + accountSigner: { signTypedData }, + }, + messenger, + }); + + await provider.initialize(); + + expect(initialize).toHaveBeenCalledTimes(1); + const [[wallet]] = initialize.mock.calls; + const signature = await wallet.signTypedData(ORDER_TYPED_DATA); + + expect(signature).toBe(SIGNATURE); + expect(signTypedData).toHaveBeenCalledWith( + createMockEvmAccount().address, + ORDER_TYPED_DATA, + ); + expect( + call.mock.calls + .map(([action]) => String(action)) + .filter((action) => action.startsWith('KeyringController:')), + ).toStrictEqual([]); + }); +}); diff --git a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.accountSigner.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.accountSigner.test.ts new file mode 100644 index 00000000000..20cacc3364d --- /dev/null +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.accountSigner.test.ts @@ -0,0 +1,197 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; + +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; +import type { + PerpsAccountSigner, + PerpsTypedDataPayload, +} from '../../../src/types/index.js'; +import type { PerpsControllerMessengerBase } from '../../../src/types/messenger.js'; +import { + createMockEvmAccount, + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks.js'; + +const SIGNATURE = `0x${'cd'.repeat(65)}` as const; + +const TYPED_DATA: PerpsTypedDataPayload = { + domain: { + name: 'Exchange', + version: '1', + chainId: 1337, + verifyingContract: '0x0000000000000000000000000000000000000000', + }, + types: { + Agent: [ + { name: 'source', type: 'string' }, + { name: 'connectionId', type: 'bytes32' }, + ], + }, + primaryType: 'Agent', + message: { source: 'b', connectionId: `0x${'11'.repeat(32)}` }, +}; + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +type HostMessenger = { + messenger: PerpsControllerMessengerBase; + call: jest.SpyInstance; +}; + +/** + * Build a real messenger that only knows the selected account, like a host + * without a KeyringController. Any `KeyringController:*` call throws. + * + * @param keyringType - Keyring type reported in the account metadata. + * @returns The PerpsController-namespaced messenger and a spy on its `call`. + */ +function buildHostMessenger(keyringType = 'HD Key Tree'): HostMessenger { + const account = createMockEvmAccount(); + const root: RootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE }); + const messenger: PerpsControllerMessengerBase = new Messenger({ + namespace: 'PerpsController', + parent: root, + }); + root.registerActionHandler('AccountsController:getSelectedAccount', () => ({ + ...account, + scopes: ['eip155:0'], + metadata: { ...account.metadata, keyring: { type: keyringType } }, + })); + root.delegate({ + actions: ['AccountsController:getSelectedAccount'], + messenger, + }); + return { messenger, call: jest.spyOn(messenger, 'call') }; +} + +function keyringCalls(call: jest.SpyInstance): string[] { + return call.mock.calls + .map(([action]: [string]) => action) + .filter((action) => action.startsWith('KeyringController:')); +} + +describe('HyperLiquidWalletService with accountSigner', () => { + const { address } = createMockEvmAccount(); + + function buildService( + accountSigner: PerpsAccountSigner, + keyringType?: string, + ): { service: HyperLiquidWalletService; host: HostMessenger } { + const host = buildHostMessenger(keyringType); + const deps = { ...createMockInfrastructure(), accountSigner }; + return { + service: new HyperLiquidWalletService(deps, host.messenger, { + isTestnet: true, + }), + host, + }; + } + + it('signs typed data through the account signer without KeyringController', async () => { + const signTypedData = jest.fn().mockResolvedValue(SIGNATURE); + const { service, host } = buildService({ signTypedData }); + + const signature = await service + .createWalletAdapter() + .signTypedData(TYPED_DATA); + + expect(signature).toBe(SIGNATURE); + expect(signTypedData).toHaveBeenCalledTimes(1); + expect(signTypedData).toHaveBeenCalledWith(address, TYPED_DATA); + expect(keyringCalls(host.call)).toStrictEqual([]); + }); + + it('propagates account signer rejections', async () => { + const signTypedData = jest + .fn() + .mockRejectedValue(new Error('User rejected the request.')); + const { service } = buildService({ signTypedData }); + + await expect( + service.createWalletAdapter().signTypedData(TYPED_DATA), + ).rejects.toThrow('User rejected the request.'); + }); + + it('reports ready when isReady is omitted', () => { + const { service, host } = buildService({ signTypedData: jest.fn() }); + + expect(service.isKeyringUnlocked()).toBe(true); + expect(keyringCalls(host.call)).toStrictEqual([]); + }); + + it('fails with KEYRING_LOCKED and does not sign when isReady returns false', async () => { + const signTypedData = jest.fn().mockResolvedValue(SIGNATURE); + const { service, host } = buildService({ + signTypedData, + isReady: () => false, + }); + + expect(service.isKeyringUnlocked()).toBe(false); + await expect( + service.createWalletAdapter().signTypedData(TYPED_DATA), + ).rejects.toThrow(PERPS_ERROR_CODES.KEYRING_LOCKED); + expect(signTypedData).not.toHaveBeenCalled(); + expect(keyringCalls(host.call)).toStrictEqual([]); + }); + + it('uses isHardwareWallet instead of the account keyring type', () => { + const { service: hardware } = buildService( + { signTypedData: jest.fn(), isHardwareWallet: () => true }, + 'HD Key Tree', + ); + const { service: defaulted } = buildService( + { signTypedData: jest.fn() }, + 'Ledger Hardware', + ); + + expect(hardware.isSelectedHardwareWallet()).toBe(true); + expect(defaulted.isSelectedHardwareWallet()).toBe(false); + }); +}); + +describe('HyperLiquidWalletService without accountSigner', () => { + it('keeps signing through KeyringController:signTypedMessage V4', async () => { + const messenger = createMockMessenger(); + const call = jest.spyOn(messenger, 'call'); + const service = new HyperLiquidWalletService( + createMockInfrastructure(), + messenger, + ); + + const signature = await service + .createWalletAdapter() + .signTypedData(TYPED_DATA); + + expect(signature).toBe('0xSignatureResult'); + expect(call).toHaveBeenCalledWith('KeyringController:getState'); + expect(call).toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + { from: createMockEvmAccount().address, data: TYPED_DATA }, + 'V4', + ); + }); + + it('fails when no KeyringController handler is delegated', async () => { + const host = buildHostMessenger(); + const service = new HyperLiquidWalletService( + createMockInfrastructure(), + host.messenger, + ); + + await expect( + service.createWalletAdapter().signTypedData(TYPED_DATA), + ).rejects.toThrow( + 'A handler for KeyringController:getState has not been delegated to PerpsController', + ); + }); +}); diff --git a/packages/perps-controller/tests/src/services/LighterWalletService.accountSigner.test.ts b/packages/perps-controller/tests/src/services/LighterWalletService.accountSigner.test.ts new file mode 100644 index 00000000000..0719a6541c0 --- /dev/null +++ b/packages/perps-controller/tests/src/services/LighterWalletService.accountSigner.test.ts @@ -0,0 +1,117 @@ +import type { PerpsControllerMessenger } from '../../../src/PerpsController.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { LighterWalletService } from '../../../src/services/LighterWalletService.js'; +import type { PerpsAccountSigner } from '../../../src/types/index.js'; +import { createMockInfrastructure } from '../../helpers/serviceMocks.js'; + +const ADAPTER_SIGNATURE = `0x${'ab'.repeat(65)}` as const; +const KEYRING_SIGNATURE = `0x${'ef'.repeat(65)}` as const; +const ADDRESS = '0x8D7f03FdE1A626223364E592740a233b72395235'; + +type Built = { + service: LighterWalletService; + call: jest.Mock; +}; + +function buildService(accountSigner?: PerpsAccountSigner): Built { + const selectedAccount = { address: ADDRESS, type: 'eip155:eoa' }; + const call = jest.fn((action: string) => { + if (action === 'AccountsController:getSelectedAccount') { + return selectedAccount; + } + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } + if (action === 'KeyringController:signPersonalMessage') { + return Promise.resolve(KEYRING_SIGNATURE); + } + throw new Error(`Unexpected action: ${action}`); + }); + const messenger = { call } as unknown as PerpsControllerMessenger; + const deps = { ...createMockInfrastructure(), accountSigner }; + const service = new LighterWalletService(deps, { + isTestnet: true, + messenger, + }); + return { service, call }; +} + +function keyringCalls(call: jest.Mock): string[] { + return call.mock.calls + .map(([action]: [string]) => action) + .filter((action) => action.startsWith('KeyringController:')); +} + +describe('LighterWalletService with accountSigner', () => { + it('signs personal messages through the account signer before the messenger', async () => { + const signPersonalMessage = jest.fn().mockResolvedValue(ADAPTER_SIGNATURE); + const { service, call } = buildService({ + signTypedData: jest.fn(), + signPersonalMessage, + }); + + const signature = await service.signPersonalMessage('hello'); + + expect(signature).toBe(ADAPTER_SIGNATURE); + expect(signPersonalMessage).toHaveBeenCalledWith(ADDRESS, 'hello'); + expect(keyringCalls(call)).toStrictEqual([]); + }); + + it('fails with KEYRING_LOCKED when isReady returns false', async () => { + const signPersonalMessage = jest.fn().mockResolvedValue(ADAPTER_SIGNATURE); + const { service, call } = buildService({ + signTypedData: jest.fn(), + signPersonalMessage, + isReady: () => false, + }); + + await expect(service.signPersonalMessage('hello')).rejects.toThrow( + PERPS_ERROR_CODES.KEYRING_LOCKED, + ); + expect(signPersonalMessage).not.toHaveBeenCalled(); + expect(keyringCalls(call)).toStrictEqual([]); + }); + + it('fails with NO_ACCOUNT_SELECTED without a messenger to resolve the address', async () => { + const signPersonalMessage = jest.fn().mockResolvedValue(ADAPTER_SIGNATURE); + const service = new LighterWalletService( + { + ...createMockInfrastructure(), + accountSigner: { signTypedData: jest.fn(), signPersonalMessage }, + }, + { isTestnet: true }, + ); + + await expect(service.signPersonalMessage('hello')).rejects.toThrow( + PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED, + ); + expect(signPersonalMessage).not.toHaveBeenCalled(); + }); + + it('falls back to the messenger when the signer has no signPersonalMessage', async () => { + const { service, call } = buildService({ signTypedData: jest.fn() }); + + const signature = await service.signPersonalMessage('hello'); + + expect(signature).toBe(KEYRING_SIGNATURE); + expect(call).toHaveBeenCalledWith( + 'KeyringController:signPersonalMessage', + expect.objectContaining({ from: ADDRESS }), + ); + }); +}); + +describe('LighterWalletService without accountSigner', () => { + it('keeps signing through KeyringController:signPersonalMessage', async () => { + const { service, call } = buildService(); + + const signature = await service.signPersonalMessage('hello'); + + expect(signature).toBe(KEYRING_SIGNATURE); + expect(call).toHaveBeenCalledWith('KeyringController:getState'); + expect(call).toHaveBeenCalledWith( + 'KeyringController:signPersonalMessage', + expect.objectContaining({ from: ADDRESS }), + ); + }); +}); From 98232e9ad3304939a6e82180b6df77aad4d1da37 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 28 Sep 2026 21:22:13 +0800 Subject: [PATCH 02/33] feat(perps-controller)!: remove headless Lighter signer options BREAKING CHANGE: remove the LighterPersonalSigner type and the personalSigner and l1Address fields of LighterAuthConfig. PerpsController never forwarded these fields to the Lighter provider, so they had no effect for controller clients. accountSigner.signPersonalMessage replaces the injected signer, and the L1 address comes from the messenger's selected account. The Lighter e2e now signs through accountSigner with a selected-account messenger. --- packages/perps-controller/CHANGELOG.md | 6 + packages/perps-controller/src/index.ts | 1 - .../src/providers/LighterProvider.ts | 4 +- .../src/services/LighterWalletService.ts | 40 ++---- .../src/types/lighter-types.ts | 12 -- .../perps-controller/tests/e2e/lighter.e2e.ts | 132 +++++++++--------- .../src/services/LighterWalletService.test.ts | 34 +---- 7 files changed, 90 insertions(+), 139 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 4b2639773e8..6a637af90f5 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -17,6 +17,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - When set, HyperLiquid typed-data signing and Lighter `personal_sign` go through it instead of the `KeyringController:*` messenger actions - `isReady()` returning `false` fails signing with the existing `KEYRING_LOCKED` error code; `isHardwareWallet()` defers optional signing prompts like a hardware keyring does +### Removed + +- **BREAKING:** Remove the `LighterPersonalSigner` type and the `personalSigner` and `l1Address` fields of `LighterAuthConfig` + - `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 + ## [18.0.1] ### Fixed diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 2a37950ff4f..74978750684 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -504,7 +504,6 @@ export type { LighterWebSocketLike, LighterWasmCall, LighterAuthConfig, - LighterPersonalSigner, } from './types/lighter-types.js'; export { PERPS_CONSTANTS, diff --git a/packages/perps-controller/src/providers/LighterProvider.ts b/packages/perps-controller/src/providers/LighterProvider.ts index 194b0fccf08..d10792461b6 100644 --- a/packages/perps-controller/src/providers/LighterProvider.ts +++ b/packages/perps-controller/src/providers/LighterProvider.ts @@ -1173,8 +1173,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', { @@ -4131,7 +4129,7 @@ export class LighterProvider implements PerpsProvider { // 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 + // 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) { diff --git a/packages/perps-controller/src/services/LighterWalletService.ts b/packages/perps-controller/src/services/LighterWalletService.ts index b1f33cb96a8..ccda66c30c8 100644 --- a/packages/perps-controller/src/services/LighterWalletService.ts +++ b/packages/perps-controller/src/services/LighterWalletService.ts @@ -15,8 +15,8 @@ * * Signature routing goes through the injected `accountSigner` when it * implements `signPersonalMessage`, else - * `KeyringController:signPersonalMessage` when a messenger is available, - * else an injected `LighterPersonalSigner` for headless use. + * `KeyringController:signPersonalMessage`. The L1 address always comes from + * the messenger's selected account. */ import { bytesToHex } from '@metamask/utils'; @@ -25,10 +25,7 @@ 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'; export class LighterWalletService { @@ -38,23 +35,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; } @@ -68,25 +57,20 @@ 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; + const evmAccount = this.#messenger + ? getSelectedEvmAccountFromMessenger(this.#messenger) + : undefined; + if (!evmAccount?.address) { + throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); } - if (this.#l1Address) { - return this.#l1Address; - } - throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); + return evmAccount.address; } /** * Sign an EIP-191 personal message with the user's L1 account. * * Routes through the injected account signer when it can sign personal - * messages, else the keyring when a messenger is present, else the - * injected headless signer. + * messages, else the keyring when a messenger is present. * * @param message - Plaintext message to sign. * @returns 65-byte signature as 0x-prefixed hex. @@ -120,10 +104,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/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/tests/e2e/lighter.e2e.ts b/packages/perps-controller/tests/e2e/lighter.e2e.ts index 9f1fbad0183..c1e0264f622 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,16 @@ async function createE2eSignerBridge(): Promise { }); } +/** + * Signs as the e2e viem account, in place of a wallet's KeyringController. + */ +const accountSigner: PerpsAccountSigner = { + signTypedData: async (_address, payload) => + await viemAccount.signTypedData(payload), + signPersonalMessage: async (_address, message) => + await viemAccount.signMessage({ message }), +}; + /** * Minimal faithful PerpsPlatformDependencies (mirrors the mm-harness core * adapter's buildInfrastructure — read/write paths only touch loggers and @@ -164,17 +184,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 +383,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 +448,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 +563,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 +685,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 +744,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 +798,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 +968,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 +1182,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 +1400,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 +1450,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 +1535,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 +1625,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 +1880,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/src/services/LighterWalletService.test.ts b/packages/perps-controller/tests/src/services/LighterWalletService.test.ts index cee1114cb4c..21234372ca5 100644 --- a/packages/perps-controller/tests/src/services/LighterWalletService.test.ts +++ b/packages/perps-controller/tests/src/services/LighterWalletService.test.ts @@ -7,35 +7,14 @@ import { // 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: {}, }; @@ -88,7 +67,7 @@ describe('LighterWalletService', () => { expect(messenger.call).toHaveBeenCalledWith( 'KeyringController:signPersonalMessage', expect.objectContaining({ - from: HEADLESS_ADDRESS, + from: SELECTED_ADDRESS, data: expect.stringMatching(/^0x/u), }), ); @@ -103,10 +82,9 @@ describe('LighterWalletService', () => { }); 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', From 36d86a775bee7b4242b9abd02f385cdc146cc2cd Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 28 Sep 2026 21:40:48 +0800 Subject: [PATCH 03/33] fix(perps-controller): tighten accountSigner contract and coverage - Make signPersonalMessage required. When accountSigner is set, neither wallet service calls KeyringController, and isReady gates both. - Default isHardwareWallet to the selected account's keyring type instead of false. - Branch to accountSigner inside the Hyperliquid wallet adapter, where the payload is typed, instead of casting in the keyring path. - Cover real provider flows with a keyringless messenger (the Hyperliquid unified-account migration and the Lighter ChangePubKey registration), and cover the controller passing accountSigner to both providers. --- packages/perps-controller/CHANGELOG.md | 5 +- .../src/providers/LighterProvider.ts | 6 +- .../src/services/HyperLiquidWalletService.ts | 57 ++--- .../src/services/LighterWalletService.ts | 16 +- .../src/services/accountSigner.ts | 13 ++ packages/perps-controller/src/types/index.ts | 12 +- .../perps-controller/tests/e2e/lighter.e2e.ts | 25 ++- .../tests/helpers/serviceMocks.ts | 50 +++++ .../PerpsController.providers-cache.test.ts | 30 +++ .../HyperLiquidProvider.account-mode.test.ts | 132 ++++++++++++ ...HyperLiquidProvider.account-signer.test.ts | 81 ------- .../LighterProvider.account-signer.test.ts | 150 +++++++++++++ ...LiquidWalletService.account-signer.test.ts | 137 ++++++++++++ ...rLiquidWalletService.accountSigner.test.ts | 197 ------------------ ...ighterWalletService.account-signer.test.ts | 70 +++++++ ...LighterWalletService.accountSigner.test.ts | 117 ----------- 16 files changed, 641 insertions(+), 457 deletions(-) create mode 100644 packages/perps-controller/src/services/accountSigner.ts delete mode 100644 packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts create mode 100644 packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts create mode 100644 packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts delete mode 100644 packages/perps-controller/tests/src/services/HyperLiquidWalletService.accountSigner.test.ts create mode 100644 packages/perps-controller/tests/src/services/LighterWalletService.account-signer.test.ts delete mode 100644 packages/perps-controller/tests/src/services/LighterWalletService.accountSigner.test.ts diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 6a637af90f5..a337bc20449 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -14,8 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 - Export the new `PerpsAccountSigner` and `PerpsTypedDataPayload` types - - When set, HyperLiquid typed-data signing and Lighter `personal_sign` go through it instead of the `KeyringController:*` messenger actions - - `isReady()` returning `false` fails signing with the existing `KEYRING_LOCKED` error code; `isHardwareWallet()` defers optional signing prompts like a hardware keyring does + - 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 + - `isHardwareWallet()` defers optional signing prompts like a hardware keyring does; when omitted, the selected account's keyring type decides ### Removed diff --git a/packages/perps-controller/src/providers/LighterProvider.ts b/packages/perps-controller/src/providers/LighterProvider.ts index d10792461b6..05723297b6f 100644 --- a/packages/perps-controller/src/providers/LighterProvider.ts +++ b/packages/perps-controller/src/providers/LighterProvider.ts @@ -4129,9 +4129,9 @@ export class LighterProvider implements PerpsProvider { // 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 - // 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', diff --git a/packages/perps-controller/src/services/HyperLiquidWalletService.ts b/packages/perps-controller/src/services/HyperLiquidWalletService.ts index ec693094fdb..81ed99928fe 100644 --- a/packages/perps-controller/src/services/HyperLiquidWalletService.ts +++ b/packages/perps-controller/src/services/HyperLiquidWalletService.ts @@ -17,6 +17,7 @@ import { getSelectedEvmAccountDetailsFromMessenger, getSelectedEvmAccountFromMessenger, } from '../utils/accountUtils.js'; +import { isAccountSignerReady } from './accountSigner.js'; // Mirrors KeyringTypes from @metamask/keyring-controller. Inlined to keep this // service portable between mobile and the core monorepo. @@ -58,7 +59,7 @@ export class HyperLiquidWalletService { public isKeyringUnlocked(): boolean { const { accountSigner } = this.#deps; if (accountSigner) { - return accountSigner.isReady?.() ?? true; + return isAccountSignerReady(accountSigner); } return this.#messenger.call('KeyringController:getState').isUnlocked; } @@ -69,9 +70,9 @@ export class HyperLiquidWalletService { * @returns True for MetaMask hardware keyrings; false for software accounts. */ public isSelectedHardwareWallet(): boolean { - const { accountSigner } = this.#deps; - if (accountSigner) { - return accountSigner.isHardwareWallet?.() ?? false; + const declared = this.#deps.accountSigner?.isHardwareWallet?.(); + if (declared !== undefined) { + return declared; } const selectedEvmAccount = getSelectedEvmAccountDetailsFromMessenger( @@ -90,8 +91,7 @@ export class HyperLiquidWalletService { } /** - * Sign typed data via the injected account signer, or the keyring - * controller when none is injected. + * Sign typed data via DI keyring controller * * @param msgParams - The typed message parameters including data and sender address. * @returns The signature string. @@ -100,13 +100,6 @@ export class HyperLiquidWalletService { if (!this.isKeyringUnlocked()) { throw new Error(PERPS_ERROR_CODES.KEYRING_LOCKED); } - const { accountSigner } = this.#deps; - if (accountSigner) { - return accountSigner.signTypedData( - msgParams.from as Hex, - msgParams.data as PerpsTypedDataPayload, - ); - } // Cast needed: PerpsTypedMessageParams uses loose `data: unknown` type // while KeyringController uses strict TypedMessageParams / SignTypedDataVersion return this.#messenger.call( @@ -126,19 +119,7 @@ export class HyperLiquidWalletService { */ 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; + signTypedData: (params: PerpsTypedDataPayload) => Promise; getChainId?: () => Promise; } { // Get current EVM account via DI messenger @@ -152,19 +133,7 @@ export class HyperLiquidWalletService { return { address, - signTypedData: async (params: { - domain: { - name: string; - version: string; - chainId: number; - verifyingContract: Hex; - }; - types: { - [key: string]: { name: string; type: string }[]; - }; - primaryType: string; - message: Record; - }): Promise => { + signTypedData: async (params: PerpsTypedDataPayload): 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( @@ -178,7 +147,7 @@ export class HyperLiquidWalletService { const currentAddress = currentEvmAccount.address as Hex; // Construct EIP-712 typed data - const typedData = { + const typedData: PerpsTypedDataPayload = { domain: params.domain, types: params.types, primaryType: params.primaryType, @@ -194,6 +163,14 @@ export class HyperLiquidWalletService { }, ); + const { accountSigner } = this.#deps; + if (accountSigner) { + if (!isAccountSignerReady(accountSigner)) { + throw new Error(PERPS_ERROR_CODES.KEYRING_LOCKED); + } + return await accountSigner.signTypedData(currentAddress, typedData); + } + // Use messenger to sign typed data const signature = await this.#signTypedMessage({ from: currentAddress, diff --git a/packages/perps-controller/src/services/LighterWalletService.ts b/packages/perps-controller/src/services/LighterWalletService.ts index ccda66c30c8..d3e3d26c472 100644 --- a/packages/perps-controller/src/services/LighterWalletService.ts +++ b/packages/perps-controller/src/services/LighterWalletService.ts @@ -13,10 +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 the injected `accountSigner` when it - * implements `signPersonalMessage`, else - * `KeyringController:signPersonalMessage`. The L1 address always comes from - * the messenger's selected account. + * 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'; @@ -27,6 +26,7 @@ import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; import type { PerpsPlatformDependencies } from '../types/index.js'; import type { LighterNetwork } from '../types/lighter-types.js'; import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils.js'; +import { isAccountSignerReady } from './accountSigner.js'; export class LighterWalletService { #isTestnet: boolean; @@ -69,16 +69,16 @@ export class LighterWalletService { /** * Sign an EIP-191 personal message with the user's L1 account. * - * Routes through the injected account signer when it can sign personal - * messages, else the keyring when a messenger is present. + * 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?.signPersonalMessage) { - if (!(accountSigner.isReady?.() ?? true)) { + if (accountSigner) { + if (!isAccountSignerReady(accountSigner)) { throw new Error(PERPS_ERROR_CODES.KEYRING_LOCKED); } return await accountSigner.signPersonalMessage( diff --git a/packages/perps-controller/src/services/accountSigner.ts b/packages/perps-controller/src/services/accountSigner.ts new file mode 100644 index 00000000000..a8762e2c983 --- /dev/null +++ b/packages/perps-controller/src/services/accountSigner.ts @@ -0,0 +1,13 @@ +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; +} diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 90013b79b81..e4cbdd51e5d 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -2590,8 +2590,9 @@ export type PerpsTypedDataPayload = { /** * Client-implemented signer for the user's main EVM account. When provided, - * the wallet services sign through it instead of KeyringController. Clients - * that own a KeyringController omit it. + * 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 = { /** @@ -2604,13 +2605,13 @@ export type PerpsAccountSigner = { signTypedData(address: Hex, payload: PerpsTypedDataPayload): Promise; /** - * EIP-191 `personal_sign` as `address`. Needed only for Lighter. + * EIP-191 `personal_sign` as `address`. * * @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; + signPersonalMessage(address: Hex, message: string): Promise; /** * False while the signer cannot sign (e.g. wallet disconnected). Signing @@ -2620,7 +2621,8 @@ export type PerpsAccountSigner = { /** * True when every signature needs a physical confirmation, which defers - * optional signing prompts. Defaults to false. + * optional signing prompts. When omitted, the selected account's keyring + * type decides. */ isHardwareWallet?(): boolean; }; diff --git a/packages/perps-controller/tests/e2e/lighter.e2e.ts b/packages/perps-controller/tests/e2e/lighter.e2e.ts index c1e0264f622..db4ce7d1a27 100644 --- a/packages/perps-controller/tests/e2e/lighter.e2e.ts +++ b/packages/perps-controller/tests/e2e/lighter.e2e.ts @@ -120,14 +120,31 @@ 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) => - await viemAccount.signTypedData(payload), - signPersonalMessage: async (_address, message) => - await viemAccount.signMessage({ message }), + signTypedData: async (address, payload) => { + assertSignerAddress(address); + return await viemAccount.signTypedData(payload); + }, + signPersonalMessage: async (address, message) => { + assertSignerAddress(address); + return await viemAccount.signMessage({ message }); + }, }; /** diff --git a/packages/perps-controller/tests/helpers/serviceMocks.ts b/packages/perps-controller/tests/helpers/serviceMocks.ts index 7c9243fa7ac..99ebd4d74ff 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, @@ -284,3 +290,47 @@ export const createMockMessenger = ( ...overrides, } as unknown as jest.Mocked; }; + +/** + * 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 and a spy on its `call`. + */ +export const createKeyringlessMessenger = ( + keyringType = 'HD Key Tree', +): { messenger: PerpsControllerMessenger; call: jest.SpyInstance } => { + const account = createMockEvmAccount(); + const root = new Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + const messenger: PerpsControllerMessenger = new Messenger({ + namespace: 'PerpsController', + parent: root, + }); + root.registerActionHandler('AccountsController:getSelectedAccount', () => ({ + ...account, + scopes: ['eip155:0'], + metadata: { ...account.metadata, keyring: { type: keyringType } }, + })); + root.delegate({ + actions: ['AccountsController:getSelectedAccount'], + messenger, + }); + return { messenger, call: jest.spyOn(messenger, 'call') }; +}; + +/** + * 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/src/PerpsController.providers-cache.test.ts b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts index 75c37524b8a..690783f65d8 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -878,6 +878,36 @@ describe('PerpsController', () => { ); }); + it('hands infrastructure.accountSigner to the HyperLiquid and Lighter providers', async () => { + const accountSigner = { + signTypedData: jest.fn(), + signPersonalMessage: jest.fn(), + }; + const MockLighterConstructor = jest.fn(() => + createMockHyperLiquidProvider(), + ); + controller = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + infrastructure: { ...mockInfrastructure, accountSigner }, + }); + + await controller.init(); + controller.testRegisterLighterProvider( + MockLighterConstructor as unknown as new ( + opts: Record, + ) => PerpsProvider, + ); + + const withAccountSigner = expect.objectContaining({ + platformDependencies: expect.objectContaining({ accountSigner }), + }); + expect( + HyperLiquidProvider as jest.MockedClass, + ).toHaveBeenCalledWith(withAccountSigner); + expect(MockLighterConstructor).toHaveBeenCalledWith(withAccountSigner); + }); + it('handleLighterImportError logs debug for MODULE_NOT_FOUND errors', () => { const moduleError = Object.assign( new Error('Cannot find module ./providers/LighterProvider'), 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..6831f58922c 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 @@ -12,6 +12,7 @@ import { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from '../../../src/constants/tra import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider.js'; import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService.js'; +import type { HyperLiquidWalletParams } from '../../../src/services/HyperLiquidClientService.js'; import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService.js'; import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; import { TradingReadinessCache } from '../../../src/services/TradingReadinessCache.js'; @@ -19,7 +20,9 @@ import type { ClosePositionParams, DepositParams, Order, + PerpsAccountSigner, PerpsPlatformDependencies, + PerpsTypedDataPayload, LiveDataConfig, OrderParams, } from '../../../src/types/index.js'; @@ -33,8 +36,11 @@ import { } from '../../../src/utils/hyperLiquidValidation.js'; import { createStandaloneInfoClient } from '../../../src/utils/standaloneInfoClient.js'; import { + createKeyringlessMessenger, + createMockEvmAccount, createMockInfrastructure, createMockMessenger, + keyringCalls, } from '../../helpers/serviceMocks.js'; jest.mock('../../../src/services/HyperLiquidClientService'); @@ -2247,4 +2253,130 @@ describe('HyperLiquidProvider', () => { expect(mockCompleteInFlight).toHaveBeenCalled(); }); }); + + describe('with a real wallet service and accountSigner', () => { + // The wallet service is real and the messenger has no KeyringController, + // so every 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. + const { HyperLiquidWalletService: RealHyperLiquidWalletService } = + jest.requireActual< + typeof import('../../../src/services/HyperLiquidWalletService.js') + >('../../../src/services/HyperLiquidWalletService'); + const ACCOUNT_ADDRESS = createMockEvmAccount().address; + const SIGNATURE = `0x${'cd'.repeat(65)}` as const; + const MIGRATION_PAYLOAD: PerpsTypedDataPayload = { + domain: { + name: 'HyperliquidSignTransaction', + version: '1', + chainId: 42161, + verifyingContract: '0x0000000000000000000000000000000000000000', + }, + types: { + '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: ACCOUNT_ADDRESS, + abstraction: 'unifiedAccount', + nonce: 1, + }, + }; + + function createAccountSignerProvider( + signerOverrides: Partial = {}, + ) { + const accountSigner = { + signTypedData: jest.fn().mockResolvedValue(SIGNATURE), + signPersonalMessage: jest.fn(), + ...signerOverrides, + }; + const { messenger, call } = createKeyringlessMessenger(); + MockedHyperLiquidWalletService.mockImplementation( + (deps, walletMessenger, options) => + new RealHyperLiquidWalletService(deps, walletMessenger, options), + ); + let sdkWallet: HyperLiquidWalletParams | undefined; + mockClientService.initialize.mockImplementation(async (wallet) => { + sdkWallet = wallet; + }); + const exchangeClient = createMockExchangeClient({ + userSetAbstraction: jest.fn(async () => { + if (!sdkWallet) { + throw new Error('SDK used before initialize'); + } + await sdkWallet.signTypedData(MIGRATION_PAYLOAD); + return { status: 'ok' }; + }), + }); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('dexAbstraction'), + }), + ); + const accountSignerProvider = new HyperLiquidProvider({ + platformDependencies: { ...mockPlatformDependencies, accountSigner }, + messenger, + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ], + }); + return { accountSignerProvider, accountSigner, call, exchangeClient }; + } + + it('signs the init-time unified-account migration through accountSigner', async () => { + const { accountSignerProvider, accountSigner, call, exchangeClient } = + createAccountSignerProvider(); + + await accountSignerProvider.getMarketDataWithPrices(); + + expect(exchangeClient.userSetAbstraction).toHaveBeenCalledWith({ + user: ACCOUNT_ADDRESS, + abstraction: 'unifiedAccount', + }); + expect(accountSigner.signTypedData).toHaveBeenCalledWith( + ACCOUNT_ADDRESS, + MIGRATION_PAYLOAD, + ); + expect(keyringCalls(call)).toStrictEqual([]); + }); + + it('defers the init-time migration when accountSigner reports a hardware wallet', async () => { + const { accountSignerProvider, accountSigner, call, exchangeClient } = + createAccountSignerProvider({ isHardwareWallet: () => true }); + + await accountSignerProvider.getMarketDataWithPrices(); + + 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 } = + createAccountSignerProvider({ isReady: () => false }); + + await accountSignerProvider.getMarketDataWithPrices(); + + expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + expect(mockPlatformDependencies.debugLogger.log).toHaveBeenCalledWith( + '[ensureUnifiedAccountEnabled] Keyring locked, will retry later', + ); + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).not.toHaveBeenCalled(); + expect(keyringCalls(call)).toStrictEqual([]); + }); + }); }); 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 deleted file mode 100644 index 6c75ca6f914..00000000000 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider.js'; -import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService.js'; -import type { HyperLiquidWalletParams } from '../../../src/services/HyperLiquidClientService.js'; -import type { PerpsTypedDataPayload } from '../../../src/types/index.js'; -import { - createMockEvmAccount, - createMockInfrastructure, - createMockMessenger, -} from '../../helpers/serviceMocks.js'; - -// The wallet service stays real: this test proves the provider hands the SDK a -// wallet adapter that signs through the injected account signer. -jest.mock('@nktkas/hyperliquid', () => ({})); -jest.mock('../../../src/services/HyperLiquidClientService'); -jest.mock('../../../src/services/HyperLiquidSubscriptionService'); -jest.mock('../../../src/services/TradingReadinessCache'); - -const MockedHyperLiquidClientService = - HyperLiquidClientService as jest.MockedClass; - -const SIGNATURE = `0x${'cd'.repeat(65)}` as const; - -const ORDER_TYPED_DATA: PerpsTypedDataPayload = { - domain: { - name: 'Exchange', - version: '1', - chainId: 1337, - verifyingContract: '0x0000000000000000000000000000000000000000', - }, - types: { - Agent: [ - { name: 'source', type: 'string' }, - { name: 'connectionId', type: 'bytes32' }, - ], - }, - primaryType: 'Agent', - message: { source: 'b', connectionId: `0x${'22'.repeat(32)}` }, -}; - -describe('HyperLiquidProvider with accountSigner', () => { - it('initializes the SDK with a wallet that signs through the account signer', async () => { - const initialize = jest.fn, [HyperLiquidWalletParams]>(); - MockedHyperLiquidClientService.mockImplementation( - () => - ({ - initialize, - isTestnetMode: jest.fn().mockReturnValue(true), - setOnTerminateCallback: jest.fn(), - setOnReconnectCallback: jest.fn(), - }) as unknown as HyperLiquidClientService, - ); - const signTypedData = jest.fn().mockResolvedValue(SIGNATURE); - const messenger = createMockMessenger(); - const call = jest.spyOn(messenger, 'call'); - const provider = new HyperLiquidProvider({ - isTestnet: true, - platformDependencies: { - ...createMockInfrastructure(), - accountSigner: { signTypedData }, - }, - messenger, - }); - - await provider.initialize(); - - expect(initialize).toHaveBeenCalledTimes(1); - const [[wallet]] = initialize.mock.calls; - const signature = await wallet.signTypedData(ORDER_TYPED_DATA); - - expect(signature).toBe(SIGNATURE); - expect(signTypedData).toHaveBeenCalledWith( - createMockEvmAccount().address, - ORDER_TYPED_DATA, - ); - expect( - call.mock.calls - .map(([action]) => String(action)) - .filter((action) => action.startsWith('KeyringController:')), - ).toStrictEqual([]); - }); -}); 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..3f2afcf3690 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts @@ -0,0 +1,150 @@ +import { LighterProvider } from '../../../src/providers/LighterProvider.js'; +import { LighterClientService } from '../../../src/services/LighterClientService.js'; +import type { + LighterSignerBridge, + LighterSignerOperation, + LighterSignerResult, + LighterWasmCall, +} from '../../../src/types/lighter-types.js'; +import { + createKeyringlessMessenger, + createMockEvmAccount, + createMockInfrastructure, + keyringCalls, +} 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 L1_SIGNATURE = `0x${'ab'.repeat(65)}` as const; +const CHANGE_PUB_KEY_BODY = + 'Register Lighter Account\n\npubkey: 0x9c...\nOnly sign this message for a trusted client!'; + +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: 42, + ExpiredAt: Date.now() + 599_000, + }), + txHash: 'dddd000000000001', + } as LighterSignerResult; + }, + ), + }; + return { bridge, calls }; +} + +describe('LighterProvider with accountSigner', () => { + it('registers the venue key with an L1 signature from accountSigner', async () => { + 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: 42 }), + 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(L1_SIGNATURE), + }; + const { messenger, call } = createKeyringlessMessenger(); + const { bridge, calls } = createBridge(); + const provider = new LighterProvider({ + isTestnet: true, + platformDependencies: { ...createMockInfrastructure(), accountSigner }, + messenger, + lighterAuthConfig: { + accountIndex: ACCOUNT_INDEX, + apiKeyIndex: API_KEY_INDEX, + }, + signerBridge: bridge, + webSocketCtor: null, + }); + + const result = await provider.isReadyToTrade(); + + expect(result.ready).toBe(true); + expect(accountSigner.signPersonalMessage).toHaveBeenCalledWith( + address, + CHANGE_PUB_KEY_BODY, + ); + const changePubKey = calls.find( + (wasmCall) => wasmCall.function === '_signChangePubKey', + ); + expect(changePubKey?.params).toStrictEqual([ + ACCOUNT_INDEX, + L1_SIGNATURE, + 42, + API_KEY_INDEX, + ]); + expect(client.sendTx).toHaveBeenCalledWith( + 8, + expect.stringContaining('"changePubKey":true'), + ); + expect(keyringCalls(call)).toStrictEqual([]); + }); +}); 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..2f355fada68 --- /dev/null +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts @@ -0,0 +1,137 @@ +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; +import type { PerpsTypedDataPayload } from '../../../src/types/index.js'; +import { + createKeyringlessMessenger, + createMockEvmAccount, + createMockInfrastructure, + keyringCalls, +} from '../../helpers/serviceMocks.js'; + +const SIGNATURE = `0x${'cd'.repeat(65)}` as const; + +const TYPED_DATA: PerpsTypedDataPayload = { + domain: { + name: 'Exchange', + version: '1', + chainId: 1337, + verifyingContract: '0x0000000000000000000000000000000000000000', + }, + types: { + Agent: [ + { name: 'source', type: 'string' }, + { name: 'connectionId', type: 'bytes32' }, + ], + }, + primaryType: 'Agent', + message: { source: 'b', connectionId: `0x${'11'.repeat(32)}` }, +}; + +type SignerOverrides = { + signTypedData?: jest.Mock; + isReady?: () => boolean; + isHardwareWallet?: () => 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(SIGNATURE), + signPersonalMessage: jest.fn().mockResolvedValue(SIGNATURE), + isReady: overrides.isReady, + isHardwareWallet: overrides.isHardwareWallet, + }; + 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(TYPED_DATA); + + expect(signature).toBe(SIGNATURE); + expect(signer.signTypedData).toHaveBeenCalledTimes(1); + expect(signer.signTypedData).toHaveBeenCalledWith(address, TYPED_DATA); + expect(keyringCalls(call)).toStrictEqual([]); + }); + + it('propagates account signer rejections', async () => { + const { service } = buildService({ + signTypedData: jest + .fn() + .mockRejectedValue(new Error('User rejected the request.')), + }); + + await expect( + service.createWalletAdapter().signTypedData(TYPED_DATA), + ).rejects.toThrow('User rejected the request.'); + }); + + it('reports ready when isReady is omitted', () => { + const { service, call } = buildService(); + + expect(service.isKeyringUnlocked()).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.isKeyringUnlocked()).toBe(false); + await expect( + service.createWalletAdapter().signTypedData(TYPED_DATA), + ).rejects.toThrow(PERPS_ERROR_CODES.KEYRING_LOCKED); + expect(signer.signTypedData).not.toHaveBeenCalled(); + expect(keyringCalls(call)).toStrictEqual([]); + }); + + it('treats the account as hardware when isHardwareWallet returns true', () => { + const { service } = buildService( + { isHardwareWallet: () => true }, + 'HD Key Tree', + ); + + expect(service.isSelectedHardwareWallet()).toBe(true); + }); + + it('treats the account as software when isHardwareWallet returns false', () => { + const { service } = buildService( + { isHardwareWallet: () => false }, + 'Ledger Hardware', + ); + + expect(service.isSelectedHardwareWallet()).toBe(false); + }); + + it.each([ + ['Ledger Hardware', true], + ['HD Key Tree', false], + ])( + 'falls back to the %s keyring type when isHardwareWallet is omitted', + (keyringType, expected) => { + const { service } = buildService({}, keyringType); + + expect(service.isSelectedHardwareWallet()).toBe(expected); + }, + ); +}); diff --git a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.accountSigner.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.accountSigner.test.ts deleted file mode 100644 index 20cacc3364d..00000000000 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.accountSigner.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; -import type { - MessengerActions, - MessengerEvents, - MockAnyNamespace, -} from '@metamask/messenger'; - -import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; -import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; -import type { - PerpsAccountSigner, - PerpsTypedDataPayload, -} from '../../../src/types/index.js'; -import type { PerpsControllerMessengerBase } from '../../../src/types/messenger.js'; -import { - createMockEvmAccount, - createMockInfrastructure, - createMockMessenger, -} from '../../helpers/serviceMocks.js'; - -const SIGNATURE = `0x${'cd'.repeat(65)}` as const; - -const TYPED_DATA: PerpsTypedDataPayload = { - domain: { - name: 'Exchange', - version: '1', - chainId: 1337, - verifyingContract: '0x0000000000000000000000000000000000000000', - }, - types: { - Agent: [ - { name: 'source', type: 'string' }, - { name: 'connectionId', type: 'bytes32' }, - ], - }, - primaryType: 'Agent', - message: { source: 'b', connectionId: `0x${'11'.repeat(32)}` }, -}; - -type RootMessenger = Messenger< - MockAnyNamespace, - MessengerActions, - MessengerEvents ->; - -type HostMessenger = { - messenger: PerpsControllerMessengerBase; - call: jest.SpyInstance; -}; - -/** - * Build a real messenger that only knows the selected account, like a host - * without a KeyringController. Any `KeyringController:*` call throws. - * - * @param keyringType - Keyring type reported in the account metadata. - * @returns The PerpsController-namespaced messenger and a spy on its `call`. - */ -function buildHostMessenger(keyringType = 'HD Key Tree'): HostMessenger { - const account = createMockEvmAccount(); - const root: RootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE }); - const messenger: PerpsControllerMessengerBase = new Messenger({ - namespace: 'PerpsController', - parent: root, - }); - root.registerActionHandler('AccountsController:getSelectedAccount', () => ({ - ...account, - scopes: ['eip155:0'], - metadata: { ...account.metadata, keyring: { type: keyringType } }, - })); - root.delegate({ - actions: ['AccountsController:getSelectedAccount'], - messenger, - }); - return { messenger, call: jest.spyOn(messenger, 'call') }; -} - -function keyringCalls(call: jest.SpyInstance): string[] { - return call.mock.calls - .map(([action]: [string]) => action) - .filter((action) => action.startsWith('KeyringController:')); -} - -describe('HyperLiquidWalletService with accountSigner', () => { - const { address } = createMockEvmAccount(); - - function buildService( - accountSigner: PerpsAccountSigner, - keyringType?: string, - ): { service: HyperLiquidWalletService; host: HostMessenger } { - const host = buildHostMessenger(keyringType); - const deps = { ...createMockInfrastructure(), accountSigner }; - return { - service: new HyperLiquidWalletService(deps, host.messenger, { - isTestnet: true, - }), - host, - }; - } - - it('signs typed data through the account signer without KeyringController', async () => { - const signTypedData = jest.fn().mockResolvedValue(SIGNATURE); - const { service, host } = buildService({ signTypedData }); - - const signature = await service - .createWalletAdapter() - .signTypedData(TYPED_DATA); - - expect(signature).toBe(SIGNATURE); - expect(signTypedData).toHaveBeenCalledTimes(1); - expect(signTypedData).toHaveBeenCalledWith(address, TYPED_DATA); - expect(keyringCalls(host.call)).toStrictEqual([]); - }); - - it('propagates account signer rejections', async () => { - const signTypedData = jest - .fn() - .mockRejectedValue(new Error('User rejected the request.')); - const { service } = buildService({ signTypedData }); - - await expect( - service.createWalletAdapter().signTypedData(TYPED_DATA), - ).rejects.toThrow('User rejected the request.'); - }); - - it('reports ready when isReady is omitted', () => { - const { service, host } = buildService({ signTypedData: jest.fn() }); - - expect(service.isKeyringUnlocked()).toBe(true); - expect(keyringCalls(host.call)).toStrictEqual([]); - }); - - it('fails with KEYRING_LOCKED and does not sign when isReady returns false', async () => { - const signTypedData = jest.fn().mockResolvedValue(SIGNATURE); - const { service, host } = buildService({ - signTypedData, - isReady: () => false, - }); - - expect(service.isKeyringUnlocked()).toBe(false); - await expect( - service.createWalletAdapter().signTypedData(TYPED_DATA), - ).rejects.toThrow(PERPS_ERROR_CODES.KEYRING_LOCKED); - expect(signTypedData).not.toHaveBeenCalled(); - expect(keyringCalls(host.call)).toStrictEqual([]); - }); - - it('uses isHardwareWallet instead of the account keyring type', () => { - const { service: hardware } = buildService( - { signTypedData: jest.fn(), isHardwareWallet: () => true }, - 'HD Key Tree', - ); - const { service: defaulted } = buildService( - { signTypedData: jest.fn() }, - 'Ledger Hardware', - ); - - expect(hardware.isSelectedHardwareWallet()).toBe(true); - expect(defaulted.isSelectedHardwareWallet()).toBe(false); - }); -}); - -describe('HyperLiquidWalletService without accountSigner', () => { - it('keeps signing through KeyringController:signTypedMessage V4', async () => { - const messenger = createMockMessenger(); - const call = jest.spyOn(messenger, 'call'); - const service = new HyperLiquidWalletService( - createMockInfrastructure(), - messenger, - ); - - const signature = await service - .createWalletAdapter() - .signTypedData(TYPED_DATA); - - expect(signature).toBe('0xSignatureResult'); - expect(call).toHaveBeenCalledWith('KeyringController:getState'); - expect(call).toHaveBeenCalledWith( - 'KeyringController:signTypedMessage', - { from: createMockEvmAccount().address, data: TYPED_DATA }, - 'V4', - ); - }); - - it('fails when no KeyringController handler is delegated', async () => { - const host = buildHostMessenger(); - const service = new HyperLiquidWalletService( - createMockInfrastructure(), - host.messenger, - ); - - await expect( - service.createWalletAdapter().signTypedData(TYPED_DATA), - ).rejects.toThrow( - 'A handler for KeyringController:getState has not been delegated to PerpsController', - ); - }); -}); 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..3f1ae27ea55 --- /dev/null +++ b/packages/perps-controller/tests/src/services/LighterWalletService.account-signer.test.ts @@ -0,0 +1,70 @@ +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { LighterWalletService } from '../../../src/services/LighterWalletService.js'; +import { + createKeyringlessMessenger, + createMockEvmAccount, + createMockInfrastructure, + keyringCalls, +} from '../../helpers/serviceMocks.js'; + +const SIGNATURE = `0x${'ab'.repeat(65)}` as const; + +function createSigner(isReady?: () => boolean): { + signTypedData: jest.Mock; + signPersonalMessage: jest.Mock; + isReady?: () => boolean; +} { + return { + signTypedData: jest.fn(), + signPersonalMessage: jest.fn().mockResolvedValue(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(SIGNATURE); + expect(signer.signPersonalMessage).toHaveBeenCalledWith( + createMockEvmAccount().address, + 'hello', + ); + expect(keyringCalls(call)).toStrictEqual([]); + }); + + 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(); + }); +}); diff --git a/packages/perps-controller/tests/src/services/LighterWalletService.accountSigner.test.ts b/packages/perps-controller/tests/src/services/LighterWalletService.accountSigner.test.ts deleted file mode 100644 index 0719a6541c0..00000000000 --- a/packages/perps-controller/tests/src/services/LighterWalletService.accountSigner.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { PerpsControllerMessenger } from '../../../src/PerpsController.js'; -import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; -import { LighterWalletService } from '../../../src/services/LighterWalletService.js'; -import type { PerpsAccountSigner } from '../../../src/types/index.js'; -import { createMockInfrastructure } from '../../helpers/serviceMocks.js'; - -const ADAPTER_SIGNATURE = `0x${'ab'.repeat(65)}` as const; -const KEYRING_SIGNATURE = `0x${'ef'.repeat(65)}` as const; -const ADDRESS = '0x8D7f03FdE1A626223364E592740a233b72395235'; - -type Built = { - service: LighterWalletService; - call: jest.Mock; -}; - -function buildService(accountSigner?: PerpsAccountSigner): Built { - const selectedAccount = { address: ADDRESS, type: 'eip155:eoa' }; - const call = jest.fn((action: string) => { - if (action === 'AccountsController:getSelectedAccount') { - return selectedAccount; - } - if (action === 'KeyringController:getState') { - return { isUnlocked: true }; - } - if (action === 'KeyringController:signPersonalMessage') { - return Promise.resolve(KEYRING_SIGNATURE); - } - throw new Error(`Unexpected action: ${action}`); - }); - const messenger = { call } as unknown as PerpsControllerMessenger; - const deps = { ...createMockInfrastructure(), accountSigner }; - const service = new LighterWalletService(deps, { - isTestnet: true, - messenger, - }); - return { service, call }; -} - -function keyringCalls(call: jest.Mock): string[] { - return call.mock.calls - .map(([action]: [string]) => action) - .filter((action) => action.startsWith('KeyringController:')); -} - -describe('LighterWalletService with accountSigner', () => { - it('signs personal messages through the account signer before the messenger', async () => { - const signPersonalMessage = jest.fn().mockResolvedValue(ADAPTER_SIGNATURE); - const { service, call } = buildService({ - signTypedData: jest.fn(), - signPersonalMessage, - }); - - const signature = await service.signPersonalMessage('hello'); - - expect(signature).toBe(ADAPTER_SIGNATURE); - expect(signPersonalMessage).toHaveBeenCalledWith(ADDRESS, 'hello'); - expect(keyringCalls(call)).toStrictEqual([]); - }); - - it('fails with KEYRING_LOCKED when isReady returns false', async () => { - const signPersonalMessage = jest.fn().mockResolvedValue(ADAPTER_SIGNATURE); - const { service, call } = buildService({ - signTypedData: jest.fn(), - signPersonalMessage, - isReady: () => false, - }); - - await expect(service.signPersonalMessage('hello')).rejects.toThrow( - PERPS_ERROR_CODES.KEYRING_LOCKED, - ); - expect(signPersonalMessage).not.toHaveBeenCalled(); - expect(keyringCalls(call)).toStrictEqual([]); - }); - - it('fails with NO_ACCOUNT_SELECTED without a messenger to resolve the address', async () => { - const signPersonalMessage = jest.fn().mockResolvedValue(ADAPTER_SIGNATURE); - const service = new LighterWalletService( - { - ...createMockInfrastructure(), - accountSigner: { signTypedData: jest.fn(), signPersonalMessage }, - }, - { isTestnet: true }, - ); - - await expect(service.signPersonalMessage('hello')).rejects.toThrow( - PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED, - ); - expect(signPersonalMessage).not.toHaveBeenCalled(); - }); - - it('falls back to the messenger when the signer has no signPersonalMessage', async () => { - const { service, call } = buildService({ signTypedData: jest.fn() }); - - const signature = await service.signPersonalMessage('hello'); - - expect(signature).toBe(KEYRING_SIGNATURE); - expect(call).toHaveBeenCalledWith( - 'KeyringController:signPersonalMessage', - expect.objectContaining({ from: ADDRESS }), - ); - }); -}); - -describe('LighterWalletService without accountSigner', () => { - it('keeps signing through KeyringController:signPersonalMessage', async () => { - const { service, call } = buildService(); - - const signature = await service.signPersonalMessage('hello'); - - expect(signature).toBe(KEYRING_SIGNATURE); - expect(call).toHaveBeenCalledWith('KeyringController:getState'); - expect(call).toHaveBeenCalledWith( - 'KeyringController:signPersonalMessage', - expect.objectContaining({ from: ADDRESS }), - ); - }); -}); From 4da0d41f1dc6173d76f65922dca12ef040f085f0 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 28 Sep 2026 21:54:01 +0800 Subject: [PATCH 04/33] feat(perps-controller): sign HyperLiquid L1 actions with a host-owned agent Adds agent (API wallet) signing on top of accountSigner. A host-owned PerpsAgentSigner, resolved through providerCredentials.hyperliquid .getAgentSigner or set at runtime with PerpsController:setAgentSigner, signs L1 actions (Agent type over the Exchange domain). User-signed actions stay on the main account, through accountSigner or the keyring. Switching the agent rebuilds only the SDK exchange client over the existing HTTP transport (HyperLiquidClientService.setWallet), so live WebSocket subscriptions keep running. PerpsController:prepareTradingWallet runs the deferred trading-readiness steps before the first order. Also addresses self-review round 2: document the EIP712Domain entry in PerpsTypedDataPayload, share the payload type with the SDK wallet params, log the Lighter signing address on both paths, strengthen the not-ready tests and scope the isHardwareWallet changelog note to HyperLiquid. Co-authored-by: Monte Lai --- packages/perps-controller/CHANGELOG.md | 7 +- .../PerpsController-method-action-types.ts | 27 +++ .../perps-controller/src/PerpsController.ts | 35 ++++ packages/perps-controller/src/index.ts | 3 + .../src/providers/AggregatedPerpsProvider.ts | 4 + .../src/providers/HyperLiquidProvider.ts | 84 +++++++- .../src/services/HyperLiquidClientService.ts | 63 ++++-- .../src/services/HyperLiquidWalletService.ts | 140 ++++++++----- .../src/services/LighterWalletService.ts | 9 +- packages/perps-controller/src/types/index.ts | 47 ++++- .../PerpsController.providers-cache.test.ts | 56 ++++++ .../providers/AggregatedPerpsProvider.test.ts | 15 ++ .../HyperLiquidProvider.account-mode.test.ts | 189 +++++++++++++++--- .../LighterProvider.account-signer.test.ts | 138 ++++++++----- .../services/HyperLiquidClientService.test.ts | 48 +++++ ...LiquidWalletService.account-signer.test.ts | 106 ++++++++++ 16 files changed, 808 insertions(+), 163 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index a337bc20449..4742e98b99e 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -16,7 +16,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 - - `isHardwareWallet()` defers optional signing prompts like a hardware keyring does; when omitted, the selected account's keyring type decides + - `isHardwareWallet()` 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 + - Add optional `providerCredentials.hyperliquid.getAgentSigner`, which resolves the approved agent (new exported `PerpsAgentSigner` type) when the HyperLiquid clients initialize + - Add `PerpsController:setAgentSigner` (`PerpsControllerSetAgentSignerAction`) to switch to an agent, or back to the main account with `null`, at runtime; only the exchange client is rebuilt, so live subscriptions keep running + - User-signed actions (builder fee, withdraw, account migration, ...) always stay on the main account; approving the agent remains the client's job +- Add `PerpsController:prepareTradingWallet` (`PerpsControllerPrepareTradingWalletAction`) and optional `PerpsProvider.prepareTradingWallet` to run deferred trading-readiness steps (account migration, builder fee and referral setup) before the first order ### Removed diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index 1271dd001d6..1de66f4181b 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -905,6 +905,31 @@ export type PerpsControllerCalculateFeesAction = { handler: PerpsController['calculateFees']; }; +/** + * Sign HyperLiquid L1 actions (orders, cancels, leverage, ...) with an + * approved agent, or with the main account again when `agentSigner` is null + * (for example when the wallet locks). User-signed actions stay on the main + * account. A provider re-creation (network toggle, account switch) resolves + * the agent through `providerCredentials.hyperliquid.getAgentSigner` again. + * + * @param agentSigner - The host-owned agent signer, or null to clear it. + */ +export type PerpsControllerSetAgentSignerAction = { + type: `PerpsController:setAgentSigner`; + handler: PerpsController['setAgentSigner']; +}; + +/** + * Run the active provider's deferred trading-readiness steps (account + * migration, builder fee and referral setup) ahead of the first order, so a + * hardware wallet signs them in one guided session, such as agent setup, + * instead of at order time. Providers without deferred setup do nothing. + */ +export type PerpsControllerPrepareTradingWalletAction = { + type: `PerpsController:prepareTradingWallet`; + handler: PerpsController['prepareTradingWallet']; +}; + /** * Approve the dedicated subscription builder outside order submission. * @@ -1453,6 +1478,8 @@ export type PerpsControllerMethodActions = | PerpsControllerSubscribeToOICapsAction | PerpsControllerSetLiveDataConfigAction | PerpsControllerCalculateFeesAction + | PerpsControllerSetAgentSignerAction + | PerpsControllerPrepareTradingWalletAction | PerpsControllerApproveSubscriptionBuilderFeeAction | PerpsControllerInvalidateSubscriptionBenefitsAction | PerpsControllerDisconnectAction diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index cb33e71c0c8..1274ea2d788 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -141,6 +141,7 @@ import type { GetHistoricalPortfolioParams, HistoricalPortfolioResult, OrderType, + PerpsAgentSigner, PerpsPlatformDependencies, PerpsLogger, PerpsActiveProviderMode, @@ -958,6 +959,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'markFirstOrderCompleted', 'markTutorialCompleted', 'placeOrder', + 'prepareTradingWallet', 'previewPositionModify', 'reconnect', 'recordMarketViewed', @@ -976,6 +978,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'saveOrderBookGrouping', 'savePendingTradeConfiguration', 'saveTradeConfiguration', + 'setAgentSigner', 'setAttributionContext', 'setLiveDataConfig', 'setSelectedPaymentToken', @@ -2361,6 +2364,9 @@ export class PerpsController extends BaseController< this.#options.clientConfig?.providerCredentials?.hyperliquid ?.subscriptionBuilderAddressMainnet, onChaseOrderMaxDistanceReached: this.#publishChaseOrderMaxDistanceReached, + getAgentSigner: + this.#options.clientConfig?.providerCredentials?.hyperliquid + ?.getAgentSigner, }); this.providers.set('hyperliquid', hyperLiquidProvider); @@ -5852,6 +5858,35 @@ export class PerpsController extends BaseController< return this.#marketDataService.calculateFees({ provider, params, context }); } + /** + * Sign HyperLiquid L1 actions (orders, cancels, leverage, ...) with an + * approved agent, or with the main account again when `agentSigner` is null + * (for example when the wallet locks). User-signed actions stay on the main + * account. A provider re-creation (network toggle, account switch) resolves + * the agent through `providerCredentials.hyperliquid.getAgentSigner` again. + * + * @param agentSigner - The host-owned agent signer, or null to clear it. + */ + async setAgentSigner(agentSigner: PerpsAgentSigner | null): Promise { + await this.#getActiveProviderWhenReady(); + const provider = this.providers.get('hyperliquid'); + if (!(provider instanceof HyperLiquidProvider)) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE); + } + await provider.setAgentSigner(agentSigner); + } + + /** + * Run the active provider's deferred trading-readiness steps (account + * migration, builder fee and referral setup) ahead of the first order, so a + * hardware wallet signs them in one guided session, such as agent setup, + * instead of at order time. Providers without deferred setup do nothing. + */ + async prepareTradingWallet(): Promise { + const provider = await this.#getActiveProviderWhenReady(); + await provider.prepareTradingWallet?.(); + } + /** * Approve the dedicated subscription builder outside order submission. * diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 74978750684..e21c8e04ace 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -127,6 +127,7 @@ export type { PerpsControllerMarkFirstOrderCompletedAction, PerpsControllerMarkTutorialCompletedAction, PerpsControllerPlaceOrderAction, + PerpsControllerPrepareTradingWalletAction, PerpsControllerReconnectAction, PerpsControllerRecordMarketViewedAction, PerpsControllerRefreshEligibilityAction, @@ -142,6 +143,7 @@ export type { PerpsControllerSaveOrderBookGroupingAction, PerpsControllerSavePendingTradeConfigurationAction, PerpsControllerSaveTradeConfigurationAction, + PerpsControllerSetAgentSignerAction, PerpsControllerSetAttributionContextAction, PerpsControllerSetLiveDataConfigAction, PerpsControllerSetSelectedPaymentTokenAction, @@ -333,6 +335,7 @@ export type { PerpsTypedMessageParams, PerpsTypedDataPayload, PerpsAccountSigner, + PerpsAgentSigner, PerpsTransactionParams, PerpsAddTransactionOptions, PerpsInternalAccount, diff --git a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts index d6f94f9b18e..8912af3184f 100644 --- a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts +++ b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts @@ -1047,6 +1047,10 @@ export class AggregatedPerpsProvider implements PerpsProvider { return this.#getDefaultProvider().isReadyToTrade(); } + async prepareTradingWallet(): Promise { + await this.#getDefaultProvider().prepareTradingWallet?.(); + } + 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..1ef6f572a77 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -53,6 +53,7 @@ import { HyperLiquidClientService, WebSocketConnectionState, } from '../services/HyperLiquidClientService.js'; +import type { HyperLiquidWalletParams } from '../services/HyperLiquidClientService.js'; import { HyperLiquidSubscriptionService } from '../services/HyperLiquidSubscriptionService.js'; import { HyperLiquidWalletService } from '../services/HyperLiquidWalletService.js'; import { @@ -108,6 +109,8 @@ import type { GetUserDataSnapshotParams, HistoricalPortfolioResult, InitializeResult, + HyperLiquidCredentials, + PerpsAgentSigner, PerpsPlatformDependencies, PerpsProvider, PerpsProviderType, @@ -836,6 +839,7 @@ type HyperLiquidProviderOptions = { subscriptionBuilderAddressTestnet?: string; subscriptionBuilderAddressMainnet?: string; onChaseOrderMaxDistanceReached?: ChaseOrderMaxDistanceReachedHandler; + getAgentSigner?: HyperLiquidCredentials['getAgentSigner']; }; type HandleHip3PreOrderParams = { @@ -1536,6 +1540,15 @@ export class HyperLiquidProvider implements PerpsProvider { // Track whether clients have been initialized (lazy initialization) #clientsInitialized = false; + // Agent set through setAgentSigner. Undefined until the host sets one; + // null when the host cleared it, which also skips #getAgentSigner. + #agentSigner: PerpsAgentSigner | null | undefined; + + readonly #getAgentSigner: HyperLiquidCredentials['getAgentSigner']; + + // Serializes setAgentSigner so a clear cannot interleave with an activation. + #agentSignerUpdate: Promise = Promise.resolve(); + // Promise-based lock to prevent race conditions in concurrent initialization #initializationPromise: Promise | null = null; @@ -1570,6 +1583,7 @@ export class HyperLiquidProvider implements PerpsProvider { options.subscriptionBuilderAddressTestnet; this.#subscriptionBuilderAddressMainnet = options.subscriptionBuilderAddressMainnet; + this.#getAgentSigner = options.getAgentSigner; this.#onChaseOrderMaxDistanceReached = options.onChaseOrderMaxDistanceReached; this.#priceDeviationLimit = @@ -1973,7 +1987,7 @@ export class HyperLiquidProvider implements PerpsProvider { throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); } - const wallet = this.#walletService.createWalletAdapter(); + const wallet = await this.#buildWallet(); await this.#clientService.initialize(wallet); if (this.#disconnectOperationsInFlight > 0) { throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); @@ -2024,6 +2038,27 @@ export class HyperLiquidProvider implements PerpsProvider { } } + /** + * Build the wallet the SDK signs with: the agent set through + * setAgentSigner, else the agent `getAgentSigner` resolves for the main + * account (unless the host cleared it), else the main account. + * + * @returns The wallet adapter for the client service. + */ + async #buildWallet(): Promise { + if (this.#agentSigner) { + return this.#walletService.createAgentWalletAdapter(this.#agentSigner); + } + const mainWallet = this.#walletService.createWalletAdapter(); + if (this.#agentSigner === null || !this.#getAgentSigner) { + return mainWallet; + } + const agentSigner = await this.#getAgentSigner(mainWallet.address); + return agentSigner + ? this.#walletService.createAgentWalletAdapter(agentSigner) + : mainWallet; + } + /** * Decide whether the wallet has a Hyperliquid account. * @@ -14078,6 +14113,53 @@ export class HyperLiquidProvider implements PerpsProvider { * * @returns A promise that resolves to the result. */ + /** + * Sign L1 actions with an approved agent, or with the main account again + * when `agentSigner` is null. Only the exchange client is rebuilt, so live + * subscriptions keep running. Before the clients initialize, the agent is + * stored and used by the first initialization. + * + * @param agentSigner - The host-owned agent signer, or null to clear it. + */ + async setAgentSigner(agentSigner: PerpsAgentSigner | null): Promise { + const update = this.#applyAgentSigner(agentSigner, this.#agentSignerUpdate); + this.#agentSignerUpdate = update.catch(() => undefined); + await update; + } + + /** + * Store the agent after the previous update, then swap the signing wallet + * if the clients are initialized. + * + * @param agentSigner - The host-owned agent signer, or null to clear it. + * @param previousUpdate - The update this one must run after. + */ + async #applyAgentSigner( + agentSigner: PerpsAgentSigner | null, + previousUpdate: Promise, + ): Promise { + await previousUpdate; + this.#agentSigner = agentSigner; + try { + await this.#initializationPromise; + } catch { + // A failed initialization retries lazily with the stored agent. + } + if (this.#clientsInitialized) { + this.#clientService.setWallet(await this.#buildWallet()); + } + } + + /** + * Run the deferred trading-readiness steps (account migration with user + * signing, builder fee and referral setup) ahead of the first order, so a + * hardware wallet signs them in one guided session instead of at order + * time. Results are cached, so an already-ready account signs nothing. + */ + async prepareTradingWallet(): Promise { + await this.#ensureReadyForTrading({ requiresBuilderFee: true }); + } + async isReadyToTrade(): Promise { try { const exchangeClient = this.#clientService.getExchangeClient(); diff --git a/packages/perps-controller/src/services/HyperLiquidClientService.ts b/packages/perps-controller/src/services/HyperLiquidClientService.ts index 4837427bf3c..ddcf8a4dfd4 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 signing account; the SDK keys nonces and locks by it. */ + address?: Hex; + signTypedData: (params: PerpsTypedDataPayload) => Promise; getChainId?: () => Promise; }; @@ -360,14 +351,26 @@ export class HyperLiquidClientService { this.#infoClientHttp = new InfoClient({ transport: this.#httpTransport }); - if (effectiveWallet) { - this.#exchangeClient = new ExchangeClient({ - wallet: effectiveWallet as any, // eslint-disable-line @typescript-eslint/no-explicit-any -- Type widening for SDK compatibility - transport: this.#httpTransport, - }); - } else { - this.#exchangeClient = undefined; - } + this.#exchangeClient = effectiveWallet + ? this.#createExchangeClient(effectiveWallet, this.#httpTransport) + : undefined; + } + + /** + * Create the exchange client that signs with the given wallet. + * + * @param wallet - The wallet parameters for signing typed data. + * @param transport - The HTTP transport to send exchange actions over. + * @returns The exchange client. + */ + #createExchangeClient( + wallet: HyperLiquidWalletParams, + transport: HttpTransport, + ): ExchangeClient { + return new ExchangeClient({ + wallet: wallet as any, // eslint-disable-line @typescript-eslint/no-explicit-any -- Type widening for SDK compatibility + transport, + }); } /** @@ -439,6 +442,24 @@ export class HyperLiquidClientService { } } + /** + * Replace the signing wallet. Only the exchange client depends on it, so + * this rebuilds that client over the existing HTTP transport and leaves the + * WebSocket and its subscriptions untouched. Before initialization it only + * stores the wallet for `initialize` to use. + * + * @param wallet - The wallet parameters for signing typed data. + */ + public setWallet(wallet: HyperLiquidWalletParams): void { + this.#walletParams = wallet; + if (this.#httpTransport) { + this.#exchangeClient = this.#createExchangeClient( + wallet, + this.#httpTransport, + ); + } + } + /** * Get the exchange client * diff --git a/packages/perps-controller/src/services/HyperLiquidWalletService.ts b/packages/perps-controller/src/services/HyperLiquidWalletService.ts index 81ed99928fe..e791184c418 100644 --- a/packages/perps-controller/src/services/HyperLiquidWalletService.ts +++ b/packages/perps-controller/src/services/HyperLiquidWalletService.ts @@ -8,6 +8,7 @@ import type { CaipAccountId, Hex } from '@metamask/utils'; import { getChainId } from '../constants/hyperLiquidConfig.js'; import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; import type { + PerpsAgentSigner, PerpsPlatformDependencies, PerpsTypedDataPayload, PerpsTypedMessageParams, @@ -18,6 +19,7 @@ import { getSelectedEvmAccountFromMessenger, } from '../utils/accountUtils.js'; import { isAccountSignerReady } from './accountSigner.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. @@ -52,9 +54,10 @@ export class HyperLiquidWalletService { } /** - * 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 { const { accountSigner } = this.#deps; @@ -65,9 +68,11 @@ export class HyperLiquidWalletService { } /** - * Check whether the selected EVM account is backed by hardware. + * Check whether the selected EVM account is backed by hardware. The + * injected account signer's `isHardwareWallet()` decides when it answers; + * otherwise the selected account's keyring type does. * - * @returns True for MetaMask hardware keyrings; false for software accounts. + * @returns True for hardware-backed accounts; false for software accounts. */ public isSelectedHardwareWallet(): boolean { const declared = this.#deps.accountSigner?.isHardwareWallet?.(); @@ -112,72 +117,97 @@ export class HyperLiquidWalletService { } /** - * Create wallet adapter that implements AbstractViemJsonRpcAccount interface - * Required by @nktkas/hyperliquid SDK for signing transactions + * Sign typed data with the selected main account: through the injected + * account signer when one is set, else through the keyring. The account is + * resolved on every call so an account switch cannot race a cached adapter. + * + * @param params - The typed data the SDK asked the wallet to sign. + * @returns The signature. + */ + async #signWithMainAccount(params: PerpsTypedDataPayload): Promise { + const currentEvmAccount = getSelectedEvmAccountFromMessenger( + this.#messenger, + ); + + if (!currentEvmAccount?.address) { + throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); + } + + const currentAddress = currentEvmAccount.address as Hex; + + this.#deps.debugLogger.log('HyperLiquidWalletService: Signing typed data', { + address: currentAddress, + primaryType: params.primaryType, + domain: params.domain, + }); + + const { accountSigner } = this.#deps; + if (accountSigner) { + if (!isAccountSignerReady(accountSigner)) { + throw new Error(PERPS_ERROR_CODES.KEYRING_LOCKED); + } + return await accountSigner.signTypedData(currentAddress, params); + } + + const signature = await this.#signTypedMessage({ + from: currentAddress, + data: params, + }); + + return signature as Hex; + } + + /** + * Create the wallet adapter the HyperLiquid SDK signs with, backed by the + * selected main account. * * @returns The wallet adapter with address, signTypedData, and getChainId methods. */ - public createWalletAdapter(): { - address: Hex; - signTypedData: (params: PerpsTypedDataPayload) => Promise; - getChainId?: () => Promise; - } { - // Get current EVM account via DI messenger + public createWalletAdapter(): HyperLiquidWalletParams & { address: 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 { + address: evmAccount.address as Hex, + signTypedData: async (params: PerpsTypedDataPayload): Promise => + await this.#signWithMainAccount(params), + getChainId: async (): Promise => + parseInt(getChainId(this.#isTestnet), 10), + }; + } + /** + * Create a wallet adapter backed by an approved agent. + * + * HyperLiquid lets an agent sign only L1 actions (orders, cancels, + * leverage, ...), which the SDK signs as primary type `Agent` over the + * `Exchange` domain. Every other request is a user-signed action that + * authorizes the main account (builder fee, withdraw, ...), so it goes to + * the main account. + * + * @param agentSigner - The host-owned agent signer. + * @returns The wallet adapter with the agent as its signing address. + */ + public createAgentWalletAdapter( + agentSigner: PerpsAgentSigner, + ): HyperLiquidWalletParams & { address: Hex } { return { - address, + address: agentSigner.address, signTypedData: async (params: PerpsTypedDataPayload): Promise => { - // Get FRESH account on every sign to handle account switches - // This prevents race conditions where wallet adapter was created with old account - const currentEvmAccount = getSelectedEvmAccountFromMessenger( - this.#messenger, - ); - - if (!currentEvmAccount?.address) { - throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); + if ( + params.primaryType !== 'Agent' || + params.domain.name !== 'Exchange' + ) { + return await this.#signWithMainAccount(params); } - - const currentAddress = currentEvmAccount.address as Hex; - - // Construct EIP-712 typed data - const typedData: PerpsTypedDataPayload = { - domain: params.domain, - types: params.types, - primaryType: params.primaryType, - message: params.message, - }; - this.#deps.debugLogger.log( - 'HyperLiquidWalletService: Signing typed data', - { - address: currentAddress, - primaryType: params.primaryType, - domain: params.domain, - }, + 'HyperLiquidWalletService: Signing L1 action with agent', + { agent: agentSigner.address }, ); - - const { accountSigner } = this.#deps; - if (accountSigner) { - if (!isAccountSignerReady(accountSigner)) { - throw new Error(PERPS_ERROR_CODES.KEYRING_LOCKED); - } - return await accountSigner.signTypedData(currentAddress, typedData); - } - - // Use messenger to sign typed data - const signature = await this.#signTypedMessage({ - from: currentAddress, - data: typedData, - }); - - return signature as Hex; + return await agentSigner.signTypedData(params); }, 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 d3e3d26c472..c4d011d7a59 100644 --- a/packages/perps-controller/src/services/LighterWalletService.ts +++ b/packages/perps-controller/src/services/LighterWalletService.ts @@ -81,10 +81,11 @@ export class LighterWalletService { if (!isAccountSignerReady(accountSigner)) { throw new Error(PERPS_ERROR_CODES.KEYRING_LOCKED); } - return await accountSigner.signPersonalMessage( - this.getUserAddress() as Hex, - message, - ); + const address = this.getUserAddress() as Hex; + this.#deps.debugLogger.log('LighterWalletService: personal_sign', { + address, + }); + return await accountSigner.signPersonalMessage(address, message); } if (this.#messenger) { diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index e4cbdd51e5d..14264d553bb 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1115,6 +1115,14 @@ export type HyperLiquidCredentials = { subscriptionBuilderAddressTestnet?: string; /** Dedicated subscription waiver builder for mainnet. */ subscriptionBuilderAddressMainnet?: string; + /** + * Resolves the approved agent for a main account, or null when none is + * active. Consulted when the HyperLiquid clients first initialize; use + * `PerpsController:setAgentSigner` to switch at runtime. With an agent, L1 + * actions (orders, cancels, leverage, ...) are signed by the agent key and + * user-signed actions (builder fee, withdraw, ...) by the main account. + */ + getAgentSigner?: (mainAddress: Hex) => Promise; }; export type LighterCredentials = { @@ -2129,6 +2137,13 @@ export type PerpsProvider = { toggleTestnet(): Promise; initialize(): Promise; isReadyToTrade(): Promise; + /** + * Run the deferred trading-readiness steps (account migration, builder fee + * and referral setup) ahead of the first order, so any main-account + * signature surfaces in a guided session instead of at order time. + * Providers without deferred setup omit it. + */ + prepareTradingWallet?(): Promise; disconnect(): Promise; ping(timeoutMs?: number): Promise; // Lightweight WebSocket health check with configurable timeout getWebSocketConnectionState?(): WebSocketConnectionState; // Optional: get current WebSocket connection state @@ -2574,7 +2589,8 @@ export type PerpsTypedMessageParams = { /** * EIP-712 payload produced by the HyperLiquid SDK, signed with - * `eth_signTypedData_v4` semantics. + * `eth_signTypedData_v4` semantics. `types` includes the `EIP712Domain` + * entry derived from `domain`, as `eth_signTypedData_v4` expects. */ export type PerpsTypedDataPayload = { domain: { @@ -2605,7 +2621,9 @@ export type PerpsAccountSigner = { signTypedData(address: Hex, payload: PerpsTypedDataPayload): Promise; /** - * EIP-191 `personal_sign` as `address`. + * 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. @@ -2620,13 +2638,32 @@ export type PerpsAccountSigner = { isReady?(): boolean; /** - * True when every signature needs a physical confirmation, which defers - * optional signing prompts. When omitted, the selected account's keyring - * type decides. + * True when every signature needs a physical confirmation. HyperLiquid then + * defers its optional init-time signing prompts to action time. When + * omitted, the selected account's keyring type decides. */ isHardwareWallet?(): 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. 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 690783f65d8..0a27b4d5b93 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -908,6 +908,62 @@ describe('PerpsController', () => { expect(MockLighterConstructor).toHaveBeenCalledWith(withAccountSigner); }); + it('passes providerCredentials.hyperliquid.getAgentSigner to the HyperLiquid provider', async () => { + const getAgentSigner = jest.fn(); + controller = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + clientConfig: { + providerCredentials: { hyperliquid: { getAgentSigner } }, + }, + infrastructure: mockInfrastructure, + }); + + await controller.init(); + + expect( + HyperLiquidProvider as jest.MockedClass, + ).toHaveBeenCalledWith(expect.objectContaining({ getAgentSigner })); + }); + + it('setAgentSigner forwards the agent to the HyperLiquid provider', async () => { + const agentSigner = { + address: '0x00000000000000000000000000000000000a9e17' as const, + signTypedData: jest.fn(), + }; + Object.setPrototypeOf(mockProvider, HyperLiquidProvider.prototype); + mockProvider.setAgentSigner = jest.fn().mockResolvedValue(undefined); + await controller.init(); + + await controller.setAgentSigner(agentSigner); + await controller.setAgentSigner(null); + + expect(mockProvider.setAgentSigner).toHaveBeenNthCalledWith( + 1, + agentSigner, + ); + expect(mockProvider.setAgentSigner).toHaveBeenNthCalledWith(2, null); + }); + + it('setAgentSigner rejects when the hyperliquid provider is not a HyperLiquidProvider', async () => { + await controller.init(); + + await expect(controller.setAgentSigner(null)).rejects.toThrow( + PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE, + ); + }); + + it("prepareTradingWallet runs the active provider's deferred setup", async () => { + mockProvider.prepareTradingWallet = jest + .fn() + .mockResolvedValue(undefined); + await controller.init(); + + await controller.prepareTradingWallet(); + + expect(mockProvider.prepareTradingWallet).toHaveBeenCalledTimes(1); + }); + it('handleLighterImportError logs debug for MODULE_NOT_FOUND errors', () => { const moduleError = Object.assign( new Error('Cannot find module ./providers/LighterProvider'), diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts index 2e3a61be89f..411a55ed2a5 100644 --- a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -1060,6 +1060,21 @@ describe('AggregatedPerpsProvider', () => { expect(mockHLProvider.isReadyToTrade).toHaveBeenCalled(); }); + it('delegates prepareTradingWallet to default provider', async () => { + const prepareTradingWallet = jest.fn().mockResolvedValue(undefined); + Object.assign(mockHLProvider, { prepareTradingWallet }); + + await aggregatedProvider.prepareTradingWallet(); + + expect(prepareTradingWallet).toHaveBeenCalledTimes(1); + }); + + it('resolves prepareTradingWallet when the default provider has no deferred setup', async () => { + await expect( + aggregatedProvider.prepareTradingWallet(), + ).resolves.toBeUndefined(); + }); + 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 6831f58922c..9a127b329a3 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 @@ -2256,21 +2256,26 @@ describe('HyperLiquidProvider', () => { describe('with a real wallet service and accountSigner', () => { // The wallet service is real and the messenger has no KeyringController, - // so every 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. + // 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 (or swapped) it with. const { HyperLiquidWalletService: RealHyperLiquidWalletService } = jest.requireActual< typeof import('../../../src/services/HyperLiquidWalletService.js') >('../../../src/services/HyperLiquidWalletService'); const ACCOUNT_ADDRESS = createMockEvmAccount().address; + const AGENT_ADDRESS = '0x00000000000000000000000000000000000a9e17' as const; const SIGNATURE = `0x${'cd'.repeat(65)}` as const; - const MIGRATION_PAYLOAD: PerpsTypedDataPayload = { + const AGENT_SIGNATURE = `0x${'ef'.repeat(65)}` as const; + const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as const; + // Shapes the SDK signs: user-signed actions use the + // HyperliquidSignTransaction domain, L1 actions the Exchange domain. + const USER_SIGNED_PAYLOAD: PerpsTypedDataPayload = { domain: { name: 'HyperliquidSignTransaction', version: '1', - chainId: 42161, - verifyingContract: '0x0000000000000000000000000000000000000000', + chainId: 1, + verifyingContract: ZERO_ADDRESS, }, types: { 'HyperliquidTransaction:UserSetAbstraction': [ @@ -2288,39 +2293,76 @@ describe('HyperLiquidProvider', () => { nonce: 1, }, }; + const L1_PAYLOAD: PerpsTypedDataPayload = { + domain: { + name: 'Exchange', + version: '1', + chainId: 1337, + verifyingContract: ZERO_ADDRESS, + }, + types: { + Agent: [ + { name: 'source', type: 'string' }, + { name: 'connectionId', type: 'bytes32' }, + ], + }, + primaryType: 'Agent', + message: { source: 'a', connectionId: `0x${'22'.repeat(32)}` }, + }; + + type Options = { + signer?: { isReady?: () => boolean; isHardwareWallet?: () => boolean }; + abstraction?: 'dexAbstraction' | 'default' | 'unifiedAccount'; + getAgentSigner?: jest.Mock; + }; - function createAccountSignerProvider( - signerOverrides: Partial = {}, - ) { + function createAccountSignerProvider(options: Options = {}) { const accountSigner = { signTypedData: jest.fn().mockResolvedValue(SIGNATURE), signPersonalMessage: jest.fn(), - ...signerOverrides, + ...options.signer, + }; + const agentSigner = { + address: AGENT_ADDRESS, + signTypedData: jest.fn().mockResolvedValue(AGENT_SIGNATURE), }; const { messenger, call } = createKeyringlessMessenger(); MockedHyperLiquidWalletService.mockImplementation( - (deps, walletMessenger, options) => - new RealHyperLiquidWalletService(deps, walletMessenger, options), + (deps, walletMessenger, walletOptions) => + new RealHyperLiquidWalletService( + deps, + walletMessenger, + walletOptions, + ), ); let sdkWallet: HyperLiquidWalletParams | undefined; - mockClientService.initialize.mockImplementation(async (wallet) => { + const initialize = jest.fn(async (wallet: HyperLiquidWalletParams) => { sdkWallet = wallet; }); - const exchangeClient = createMockExchangeClient({ - userSetAbstraction: jest.fn(async () => { + const setWallet = jest.fn((wallet: HyperLiquidWalletParams) => { + sdkWallet = wallet; + }); + Object.assign(mockClientService, { initialize, setWallet }); + const signThroughSdkWallet = + (payload: PerpsTypedDataPayload) => async () => { if (!sdkWallet) { throw new Error('SDK used before initialize'); } - await sdkWallet.signTypedData(MIGRATION_PAYLOAD); + await sdkWallet.signTypedData(payload); return { status: 'ok' }; - }), + }; + const exchangeClient = createMockExchangeClient({ + userSetAbstraction: jest.fn(signThroughSdkWallet(USER_SIGNED_PAYLOAD)), + agentSetAbstraction: jest.fn(signThroughSdkWallet(L1_PAYLOAD)), }); mockClientService.getExchangeClient = jest .fn() .mockReturnValue(exchangeClient); mockClientService.getInfoClient = jest.fn().mockReturnValue( createMockInfoClient({ - userAbstraction: jest.fn().mockResolvedValue('dexAbstraction'), + userAbstraction: jest + .fn() + .mockResolvedValue(options.abstraction ?? 'dexAbstraction'), }), ); const accountSignerProvider = new HyperLiquidProvider({ @@ -2330,8 +2372,17 @@ describe('HyperLiquidProvider', () => { ['BTC', 0], ['ETH', 1], ], + getAgentSigner: options.getAgentSigner, }); - return { accountSignerProvider, accountSigner, call, exchangeClient }; + return { + accountSignerProvider, + accountSigner, + agentSigner, + call, + exchangeClient, + initialize, + setWallet, + }; } it('signs the init-time unified-account migration through accountSigner', async () => { @@ -2346,14 +2397,16 @@ describe('HyperLiquidProvider', () => { }); expect(accountSigner.signTypedData).toHaveBeenCalledWith( ACCOUNT_ADDRESS, - MIGRATION_PAYLOAD, + USER_SIGNED_PAYLOAD, ); expect(keyringCalls(call)).toStrictEqual([]); }); it('defers the init-time migration when accountSigner reports a hardware wallet', async () => { const { accountSignerProvider, accountSigner, call, exchangeClient } = - createAccountSignerProvider({ isHardwareWallet: () => true }); + createAccountSignerProvider({ + signer: { isHardwareWallet: () => true }, + }); await accountSignerProvider.getMarketDataWithPrices(); @@ -2363,20 +2416,104 @@ describe('HyperLiquidProvider', () => { }); it('treats a not-ready accountSigner as a locked keyring and caches nothing', async () => { - const { accountSignerProvider, accountSigner, call } = - createAccountSignerProvider({ isReady: () => false }); + const { accountSignerProvider, accountSigner, call, exchangeClient } = + createAccountSignerProvider({ signer: { isReady: () => false } }); await accountSignerProvider.getMarketDataWithPrices(); + expect(exchangeClient.userSetAbstraction).toHaveBeenCalledTimes(1); expect(accountSigner.signTypedData).not.toHaveBeenCalled(); - expect(mockPlatformDependencies.debugLogger.log).toHaveBeenCalledWith( - '[ensureUnifiedAccountEnabled] Keyring locked, will retry later', - ); expect( (TradingReadinessCache as jest.Mocked) .set, ).not.toHaveBeenCalled(); expect(keyringCalls(call)).toStrictEqual([]); }); + + it('runs the deferred migration through prepareTradingWallet', async () => { + const { accountSignerProvider, accountSigner, exchangeClient } = + createAccountSignerProvider({ + signer: { isHardwareWallet: () => true }, + }); + await accountSignerProvider.getMarketDataWithPrices(); + + await accountSignerProvider.prepareTradingWallet(); + + expect(exchangeClient.userSetAbstraction).toHaveBeenCalledTimes(1); + expect(accountSigner.signTypedData).toHaveBeenCalledWith( + ACCOUNT_ADDRESS, + USER_SIGNED_PAYLOAD, + ); + }); + + describe('with an agent', () => { + it('resolves the agent at init and signs L1 actions with it', async () => { + const getAgentSigner = jest.fn(); + const { + accountSignerProvider, + accountSigner, + agentSigner, + initialize, + } = createAccountSignerProvider({ + abstraction: 'default', + getAgentSigner, + }); + getAgentSigner.mockResolvedValue(agentSigner); + + await accountSignerProvider.getMarketDataWithPrices(); + + expect(getAgentSigner).toHaveBeenCalledWith(ACCOUNT_ADDRESS); + expect(initialize.mock.calls[0][0].address).toBe(AGENT_ADDRESS); + expect(agentSigner.signTypedData).toHaveBeenCalledWith(L1_PAYLOAD); + expect(accountSigner.signTypedData).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).toHaveBeenCalledWith( + ACCOUNT_ADDRESS, + USER_SIGNED_PAYLOAD, + ); + expect(agentSigner.signTypedData).not.toHaveBeenCalled(); + }); + + it('uses an agent set before the clients initialize', async () => { + const { accountSignerProvider, agentSigner, initialize, setWallet } = + createAccountSignerProvider({ abstraction: 'default' }); + + await accountSignerProvider.setAgentSigner(agentSigner); + await accountSignerProvider.getMarketDataWithPrices(); + + expect(setWallet).not.toHaveBeenCalled(); + expect(initialize.mock.calls[0][0].address).toBe(AGENT_ADDRESS); + expect(agentSigner.signTypedData).toHaveBeenCalledWith(L1_PAYLOAD); + }); + + it('swaps the signing wallet at runtime without reconnecting', async () => { + const getAgentSigner = jest.fn().mockResolvedValue(null); + const { accountSignerProvider, agentSigner, setWallet } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + }); + await accountSignerProvider.getMarketDataWithPrices(); + getAgentSigner.mockClear(); + + await accountSignerProvider.setAgentSigner(agentSigner); + await accountSignerProvider.setAgentSigner(null); + + expect(setWallet).toHaveBeenCalledTimes(2); + expect(setWallet.mock.calls[0][0].address).toBe(AGENT_ADDRESS); + expect(setWallet.mock.calls[1][0].address).toBe(ACCOUNT_ADDRESS); + expect(getAgentSigner).not.toHaveBeenCalled(); + expect(mockClientService.disconnect).not.toHaveBeenCalled(); + }); + }); }); }); 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 index 3f2afcf3690..6b256477e4d 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts @@ -1,3 +1,5 @@ +import { LIGHTER_TX_TYPE_CHANGE_PUB_KEY } from '../../../src/constants/lighterConfig.js'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; import { LighterProvider } from '../../../src/providers/LighterProvider.js'; import { LighterClientService } from '../../../src/services/LighterClientService.js'; import type { @@ -28,6 +30,9 @@ const MockedClientService = LighterClientService as jest.MockedClass< 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 L1_SIGNATURE = `0x${'ab'.repeat(65)}` as const; const CHANGE_PUB_KEY_BODY = 'Register Lighter Account\n\npubkey: 0x9c...\nOnly sign this message for a trusted client!'; @@ -65,8 +70,8 @@ function createBridge(): { return { txInfo: JSON.stringify({ changePubKey: true, - Nonce: 42, - ExpiredAt: Date.now() + 599_000, + Nonce: NEXT_NONCE, + ExpiredAt: Date.now() + TX_EXPIRY_MS, }), txHash: 'dddd000000000001', } as LighterSignerResult; @@ -76,54 +81,70 @@ function createBridge(): { return { bridge, calls }; } +type BuiltProvider = { + provider: LighterProvider; + address: string; + client: { sendTx: jest.Mock }; + accountSigner: { signPersonalMessage: jest.Mock }; + call: jest.SpyInstance; + calls: LighterWasmCall[]; +}; + +function buildProvider(isReady?: () => boolean): 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(L1_SIGNATURE), + isReady, + }; + const { messenger, call } = createKeyringlessMessenger(); + const { bridge, calls } = createBridge(); + const provider = new LighterProvider({ + isTestnet: true, + platformDependencies: { ...createMockInfrastructure(), accountSigner }, + messenger, + lighterAuthConfig: { + accountIndex: ACCOUNT_INDEX, + apiKeyIndex: API_KEY_INDEX, + }, + signerBridge: bridge, + webSocketCtor: null, + }); + return { provider, address, client, accountSigner, call, calls }; +} + describe('LighterProvider with accountSigner', () => { it('registers the venue key with an L1 signature from accountSigner', async () => { - 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: 42 }), - 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(L1_SIGNATURE), - }; - const { messenger, call } = createKeyringlessMessenger(); - const { bridge, calls } = createBridge(); - const provider = new LighterProvider({ - isTestnet: true, - platformDependencies: { ...createMockInfrastructure(), accountSigner }, - messenger, - lighterAuthConfig: { - accountIndex: ACCOUNT_INDEX, - apiKeyIndex: API_KEY_INDEX, - }, - signerBridge: bridge, - webSocketCtor: null, - }); + const { provider, address, client, accountSigner, call, calls } = + buildProvider(); const result = await provider.isReadyToTrade(); @@ -138,13 +159,30 @@ describe('LighterProvider with accountSigner', () => { expect(changePubKey?.params).toStrictEqual([ ACCOUNT_INDEX, L1_SIGNATURE, - 42, + NEXT_NONCE, API_KEY_INDEX, ]); expect(client.sendTx).toHaveBeenCalledWith( - 8, + LIGHTER_TX_TYPE_CHANGE_PUB_KEY, expect.stringContaining('"changePubKey":true'), ); expect(keyringCalls(call)).toStrictEqual([]); }); + + it('reports KEYRING_LOCKED and registers nothing when accountSigner is not ready', async () => { + const { provider, client, accountSigner, call, calls } = buildProvider( + () => false, + ); + + const result = await provider.isReadyToTrade(); + + expect(result.ready).toBe(false); + expect(result.error).toContain(PERPS_ERROR_CODES.KEYRING_LOCKED); + expect(accountSigner.signPersonalMessage).not.toHaveBeenCalled(); + expect( + calls.some((wasmCall) => wasmCall.function === '_signChangePubKey'), + ).toBe(false); + expect(client.sendTx).not.toHaveBeenCalled(); + expect(keyringCalls(call)).toStrictEqual([]); + }); }); diff --git a/packages/perps-controller/tests/src/services/HyperLiquidClientService.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidClientService.test.ts index c1f0176a1b3..82f9068216e 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidClientService.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidClientService.test.ts @@ -275,6 +275,54 @@ describe('HyperLiquidClientService', () => { }); }); + describe('setWallet', () => { + const agentWallet = { + address: '0x00000000000000000000000000000000000a9e17' as const, + signTypedData: jest.fn(), + }; + + it('only stores the wallet before initialization', () => { + const { ExchangeClient } = require('@nktkas/hyperliquid'); + + service.setWallet(agentWallet); + + expect(ExchangeClient).not.toHaveBeenCalled(); + expect(() => service.getExchangeClient()).toThrow( + 'CLIENT_NOT_INITIALIZED', + ); + }); + + it('rebuilds only the exchange client and keeps the WebSocket', async () => { + await service.initialize(mockWallet); + const { + ExchangeClient, + InfoClient, + SubscriptionClient, + WebSocketTransport, + } = require('@nktkas/hyperliquid'); + const agentExchangeClient = { agent: true }; + ExchangeClient.mockClear(); + InfoClient.mockClear(); + SubscriptionClient.mockClear(); + WebSocketTransport.mockClear(); + ExchangeClient.mockImplementationOnce(() => agentExchangeClient); + + service.setWallet(agentWallet); + + expect(ExchangeClient).toHaveBeenCalledTimes(1); + expect(ExchangeClient).toHaveBeenCalledWith({ + wallet: agentWallet, + transport: mockHttpTransport, + }); + expect(service.getExchangeClient()).toBe(agentExchangeClient); + expect(InfoClient).not.toHaveBeenCalled(); + expect(SubscriptionClient).not.toHaveBeenCalled(); + expect(WebSocketTransport).not.toHaveBeenCalled(); + expect(mockWsTransport.close).not.toHaveBeenCalled(); + expect(service.isInitialized()).toBe(true); + }); + }); + describe('Client Access', () => { beforeEach(async () => { await service.initialize(mockWallet); 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 index 2f355fada68..ad65c7f7643 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts @@ -5,6 +5,7 @@ import { createKeyringlessMessenger, createMockEvmAccount, createMockInfrastructure, + createMockMessenger, keyringCalls, } from '../../helpers/serviceMocks.js'; @@ -135,3 +136,108 @@ describe('HyperLiquidWalletService with accountSigner', () => { }, ); }); + +describe('HyperLiquidWalletService agent wallet adapter', () => { + const { address: mainAddress } = createMockEvmAccount(); + const AGENT_ADDRESS = '0x00000000000000000000000000000000000a9e17' as const; + const AGENT_SIGNATURE = `0x${'ef'.repeat(65)}` as const; + const USER_SIGNED_ACTION: PerpsTypedDataPayload = { + domain: { + name: 'HyperliquidSignTransaction', + version: '1', + chainId: 1, + verifyingContract: '0x0000000000000000000000000000000000000000', + }, + types: { + 'HyperliquidTransaction:ApproveBuilderFee': [ + { name: 'hyperliquidChain', type: 'string' }, + { name: 'maxFeeRate', type: 'string' }, + { name: 'builder', type: 'address' }, + { name: 'nonce', type: 'uint64' }, + ], + }, + primaryType: 'HyperliquidTransaction:ApproveBuilderFee', + message: { + hyperliquidChain: 'Mainnet', + maxFeeRate: '0.1%', + builder: AGENT_ADDRESS, + nonce: 1, + }, + }; + + function buildAgentAdapter(): { + adapter: ReturnType; + agentSign: jest.Mock; + mainSign: jest.Mock; + call: jest.SpyInstance; + } { + const { service, call, signer } = buildService(); + const agentSign = jest.fn().mockResolvedValue(AGENT_SIGNATURE); + const adapter = service.createAgentWalletAdapter({ + address: AGENT_ADDRESS, + signTypedData: agentSign, + }); + return { adapter, agentSign, mainSign: signer.signTypedData, call }; + } + + it('uses the agent as the signing address', () => { + const { adapter } = buildAgentAdapter(); + + expect(adapter.address).toBe(AGENT_ADDRESS); + }); + + it('signs L1 actions with the agent', async () => { + const { adapter, agentSign, mainSign, call } = buildAgentAdapter(); + + const signature = await adapter.signTypedData(TYPED_DATA); + + expect(signature).toBe(AGENT_SIGNATURE); + expect(agentSign).toHaveBeenCalledWith(TYPED_DATA); + expect(mainSign).not.toHaveBeenCalled(); + expect(keyringCalls(call)).toStrictEqual([]); + }); + + it('signs user-signed actions with the main account', async () => { + const { adapter, agentSign, mainSign } = buildAgentAdapter(); + + const signature = await adapter.signTypedData(USER_SIGNED_ACTION); + + expect(signature).toBe(SIGNATURE); + expect(mainSign).toHaveBeenCalledWith(mainAddress, USER_SIGNED_ACTION); + expect(agentSign).not.toHaveBeenCalled(); + }); + + it('keeps an Agent primary type outside the Exchange domain on the main account', async () => { + const { adapter, agentSign, mainSign } = buildAgentAdapter(); + const lookalike = { + ...TYPED_DATA, + domain: { ...TYPED_DATA.domain, name: 'HyperliquidSignTransaction' }, + }; + + await adapter.signTypedData(lookalike); + + expect(mainSign).toHaveBeenCalledWith(mainAddress, lookalike); + expect(agentSign).not.toHaveBeenCalled(); + }); + + it('signs user-signed actions through the keyring when no account signer is set', async () => { + const messenger = createMockMessenger(); + const call = jest.spyOn(messenger, 'call'); + const service = new HyperLiquidWalletService( + createMockInfrastructure(), + messenger, + ); + const adapter = service.createAgentWalletAdapter({ + address: AGENT_ADDRESS, + signTypedData: jest.fn(), + }); + + await adapter.signTypedData(USER_SIGNED_ACTION); + + expect(call).toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + { from: mainAddress, data: USER_SIGNED_ACTION }, + 'V4', + ); + }); +}); From 50554e1d0a9b91bcdb805b053ca82c5518ffddc9 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 28 Sep 2026 22:08:51 +0800 Subject: [PATCH 05/33] fix(perps-controller): bind agents to their account and network Resolve the HyperLiquid agent when an L1 action is signed, keyed by the selected main account and network, instead of swapping the SDK wallet. An agent now never signs for another account or network, reads never call getAgentSigner (whose failure only fails the L1 action that needed it), and the exchange-client swap is no longer needed. - getAgentSigner receives { mainAddress, isTestnet }. - setAgentSigner binds to the selected account and network. - prepareTradingWallet resolves a ReadyToTradeResult that is not ready while a step still needs a signature. - Verify agent routing through the SDK's own signing functions. Co-authored-by: Monte Lai --- packages/perps-controller/CHANGELOG.md | 7 +- .../PerpsController-method-action-types.ts | 17 +- .../perps-controller/src/PerpsController.ts | 22 +- .../src/providers/AggregatedPerpsProvider.ts | 11 +- .../src/providers/HyperLiquidProvider.ts | 148 +++++++----- .../src/services/HyperLiquidClientService.ts | 50 +--- .../src/services/HyperLiquidWalletService.ts | 116 +++++----- packages/perps-controller/src/types/index.ts | 23 +- .../tests/helpers/serviceMocks.ts | 19 +- .../PerpsController.providers-cache.test.ts | 15 +- .../providers/AggregatedPerpsProvider.test.ts | 15 +- .../HyperLiquidProvider.account-mode.test.ts | 218 ++++++++++++++---- .../services/HyperLiquidClientService.test.ts | 48 ---- ...LiquidWalletService.account-signer.test.ts | 177 +++++++++++--- 14 files changed, 574 insertions(+), 312 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 4742e98b99e..c3486288051 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -18,10 +18,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `isReady()` returning `false` fails signing with the existing `KEYRING_LOCKED` error code - `isHardwareWallet()` 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 - - Add optional `providerCredentials.hyperliquid.getAgentSigner`, which resolves the approved agent (new exported `PerpsAgentSigner` type) when the HyperLiquid clients initialize - - Add `PerpsController:setAgentSigner` (`PerpsControllerSetAgentSignerAction`) to switch to an agent, or back to the main account with `null`, at runtime; only the exchange client is rebuilt, so live subscriptions keep running - - User-signed actions (builder fee, withdraw, account migration, ...) always stay on the main account; approving the agent remains the client's job + - Add optional `providerCredentials.hyperliquid.getAgentSigner({ mainAddress, isTestnet })`, which resolves the approved agent (new exported `PerpsAgentSigner` type) the first time an L1 action is signed for that account and network; reads never call it + - Add `PerpsController:setAgentSigner` (`PerpsControllerSetAgentSignerAction`) to set the agent for the selected account and network at runtime, or clear it with `null` + - An agent only ever signs for the main account and network it was set or resolved for, and user-signed actions (builder fee, withdraw, account migration, ...) always stay on the main account; approving the agent remains the client's job - Add `PerpsController:prepareTradingWallet` (`PerpsControllerPrepareTradingWalletAction`) and optional `PerpsProvider.prepareTradingWallet` to run deferred trading-readiness steps (account migration, builder fee and referral setup) before the first order + - Resolves a `ReadyToTradeResult` that is `ready: false` while a step still needs a signature ### Removed diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index 1de66f4181b..a61072dd71b 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -906,11 +906,13 @@ export type PerpsControllerCalculateFeesAction = { }; /** - * Sign HyperLiquid L1 actions (orders, cancels, leverage, ...) with an - * approved agent, or with the main account again when `agentSigner` is null - * (for example when the wallet locks). User-signed actions stay on the main - * account. A provider re-creation (network toggle, account switch) resolves - * the agent through `providerCredentials.hyperliquid.getAgentSigner` again. + * Sign HyperLiquid L1 actions (orders, cancels, leverage, ...) for the + * selected account on the current network with an approved agent, or with + * the main account when `agentSigner` is null (for example when the wallet + * locks). User-signed actions stay on the main account. The agent is never + * used for another account or network, and a provider re-creation (network + * toggle, reconnect) asks `providerCredentials.hyperliquid.getAgentSigner` + * again. Requires an initialized controller. * * @param agentSigner - The host-owned agent signer, or null to clear it. */ @@ -923,7 +925,10 @@ export type PerpsControllerSetAgentSignerAction = { * Run the active provider's deferred trading-readiness steps (account * migration, builder fee and referral setup) ahead of the first order, so a * hardware wallet signs them in one guided session, such as agent setup, - * instead of at order time. Providers without deferred setup do nothing. + * instead of at order time. + * + * @returns `ready: false` when a step still needs a signature; providers + * without deferred setup are ready. */ export type PerpsControllerPrepareTradingWalletAction = { type: `PerpsController:prepareTradingWallet`; diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 1274ea2d788..c6918032195 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -131,6 +131,7 @@ import type { SubscribeTwapOrdersParams, SubscribePositionsParams, SubscribePricesParams, + ReadyToTradeResult, SwitchProviderResult, ToggleTestnetResult, TwapOrder, @@ -5859,11 +5860,13 @@ export class PerpsController extends BaseController< } /** - * Sign HyperLiquid L1 actions (orders, cancels, leverage, ...) with an - * approved agent, or with the main account again when `agentSigner` is null - * (for example when the wallet locks). User-signed actions stay on the main - * account. A provider re-creation (network toggle, account switch) resolves - * the agent through `providerCredentials.hyperliquid.getAgentSigner` again. + * Sign HyperLiquid L1 actions (orders, cancels, leverage, ...) for the + * selected account on the current network with an approved agent, or with + * the main account when `agentSigner` is null (for example when the wallet + * locks). User-signed actions stay on the main account. The agent is never + * used for another account or network, and a provider re-creation (network + * toggle, reconnect) asks `providerCredentials.hyperliquid.getAgentSigner` + * again. Requires an initialized controller. * * @param agentSigner - The host-owned agent signer, or null to clear it. */ @@ -5880,11 +5883,14 @@ export class PerpsController extends BaseController< * Run the active provider's deferred trading-readiness steps (account * migration, builder fee and referral setup) ahead of the first order, so a * hardware wallet signs them in one guided session, such as agent setup, - * instead of at order time. Providers without deferred setup do nothing. + * instead of at order time. + * + * @returns `ready: false` when a step still needs a signature; providers + * without deferred setup are ready. */ - async prepareTradingWallet(): Promise { + async prepareTradingWallet(): Promise { const provider = await this.#getActiveProviderWhenReady(); - await provider.prepareTradingWallet?.(); + return (await provider.prepareTradingWallet?.()) ?? { ready: true }; } /** diff --git a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts index 8912af3184f..6b8aae7d8f0 100644 --- a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts +++ b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts @@ -1047,8 +1047,15 @@ export class AggregatedPerpsProvider implements PerpsProvider { return this.#getDefaultProvider().isReadyToTrade(); } - async prepareTradingWallet(): Promise { - await this.#getDefaultProvider().prepareTradingWallet?.(); + /** + * Prepare the default provider only; other providers keep their setup + * signatures (such as Lighter's venue-key registration) at order time. + * + * @returns The default provider's readiness. + */ + async prepareTradingWallet(): Promise { + const provider = this.#getDefaultProvider(); + return (await provider.prepareTradingWallet?.()) ?? { ready: true }; } async disconnect(): Promise { diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 1ef6f572a77..b0b74acb376 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -53,7 +53,6 @@ import { HyperLiquidClientService, WebSocketConnectionState, } from '../services/HyperLiquidClientService.js'; -import type { HyperLiquidWalletParams } from '../services/HyperLiquidClientService.js'; import { HyperLiquidSubscriptionService } from '../services/HyperLiquidSubscriptionService.js'; import { HyperLiquidWalletService } from '../services/HyperLiquidWalletService.js'; import { @@ -108,8 +107,8 @@ import type { GetSupportedPathsParams, GetUserDataSnapshotParams, HistoricalPortfolioResult, - InitializeResult, HyperLiquidCredentials, + InitializeResult, PerpsAgentSigner, PerpsPlatformDependencies, PerpsProvider, @@ -1540,14 +1539,12 @@ export class HyperLiquidProvider implements PerpsProvider { // Track whether clients have been initialized (lazy initialization) #clientsInitialized = false; - // Agent set through setAgentSigner. Undefined until the host sets one; - // null when the host cleared it, which also skips #getAgentSigner. - #agentSigner: PerpsAgentSigner | null | undefined; - readonly #getAgentSigner: HyperLiquidCredentials['getAgentSigner']; - // Serializes setAgentSigner so a clear cannot interleave with an activation. - #agentSignerUpdate: Promise = Promise.resolve(); + // Agent per network and main account (see #getAgentKey), from + // setAgentSigner or a getAgentSigner answer. An agent is only ever used for + // the account and network it was set or resolved for. + readonly #agentSigners = new Map>(); // Promise-based lock to prevent race conditions in concurrent initialization #initializationPromise: Promise | null = null; @@ -1987,7 +1984,9 @@ export class HyperLiquidProvider implements PerpsProvider { throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); } - const wallet = await this.#buildWallet(); + const wallet = this.#walletService.createWalletAdapter( + async (mainAddress) => await this.#resolveAgentSigner(mainAddress), + ); await this.#clientService.initialize(wallet); if (this.#disconnectOperationsInFlight > 0) { throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); @@ -2039,24 +2038,52 @@ export class HyperLiquidProvider implements PerpsProvider { } /** - * Build the wallet the SDK signs with: the agent set through - * setAgentSigner, else the agent `getAgentSigner` resolves for the main - * account (unless the host cleared it), else the main account. + * Key an agent by network and main account. + * + * @param mainAddress - The main account. + * @returns The agent key. + */ + #getAgentKey(mainAddress: string): string { + const network = this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet'; + return `${network}:${mainAddress.toLowerCase()}`; + } + + /** + * Resolve the agent that signs L1 actions for a main account on the + * current network. Asks `getAgentSigner` once per account and network; a + * failed answer is not kept, so the next L1 action asks again. * - * @returns The wallet adapter for the client service. + * @param mainAddress - The selected main account. + * @returns The agent, or null to sign with the main account. */ - async #buildWallet(): Promise { - if (this.#agentSigner) { - return this.#walletService.createAgentWalletAdapter(this.#agentSigner); + async #resolveAgentSigner( + mainAddress: Hex, + ): Promise { + const getAgentSigner = this.#getAgentSigner; + const key = this.#getAgentKey(mainAddress); + let agentSigner = this.#agentSigners.get(key); + if (!agentSigner) { + if (!getAgentSigner) { + return null; + } + agentSigner = getAgentSigner({ + mainAddress, + isTestnet: this.#clientService.isTestnetMode(), + }); + this.#agentSigners.set(key, agentSigner); } - const mainWallet = this.#walletService.createWalletAdapter(); - if (this.#agentSigner === null || !this.#getAgentSigner) { - return mainWallet; + try { + return await agentSigner; + } catch (error) { + if (this.#agentSigners.get(key) === agentSigner) { + this.#agentSigners.delete(key); + } + this.#deps.logger.error( + ensureError(error, 'HyperLiquidProvider.resolveAgentSigner'), + this.#getErrorContext('resolveAgentSigner'), + ); + throw error; } - const agentSigner = await this.#getAgentSigner(mainWallet.address); - return agentSigner - ? this.#walletService.createAgentWalletAdapter(agentSigner) - : mainWallet; } /** @@ -14109,57 +14136,60 @@ export class HyperLiquidProvider implements PerpsProvider { } /** - * Check if ready to trade - * - * @returns A promise that resolves to the result. - */ - /** - * Sign L1 actions with an approved agent, or with the main account again - * when `agentSigner` is null. Only the exchange client is rebuilt, so live - * subscriptions keep running. Before the clients initialize, the agent is - * stored and used by the first initialization. + * Sign L1 actions for the selected main account on the current network + * with an approved agent, or with the main account when `agentSigner` is + * null. Other accounts and networks are unaffected, so the agent never + * signs for an account or network it was not approved for. * * @param agentSigner - The host-owned agent signer, or null to clear it. */ async setAgentSigner(agentSigner: PerpsAgentSigner | null): Promise { - const update = this.#applyAgentSigner(agentSigner, this.#agentSignerUpdate); - this.#agentSignerUpdate = update.catch(() => undefined); - await update; + const mainAddress = await this.#walletService.getUserAddressWithDefault(); + this.#agentSigners.set( + this.#getAgentKey(mainAddress), + Promise.resolve(agentSigner), + ); } /** - * Store the agent after the previous update, then swap the signing wallet - * if the clients are initialized. + * Run the deferred trading-readiness steps (account migration with user + * signing, builder fee and referral setup) ahead of the first order, so a + * hardware wallet signs them in one guided session instead of at order + * time. Results are cached, so an already-ready account signs nothing. * - * @param agentSigner - The host-owned agent signer, or null to clear it. - * @param previousUpdate - The update this one must run after. + * @returns `ready: true` when no step still needs a signature. */ - async #applyAgentSigner( - agentSigner: PerpsAgentSigner | null, - previousUpdate: Promise, - ): Promise { - await previousUpdate; - this.#agentSigner = agentSigner; + async prepareTradingWallet(): Promise { try { - await this.#initializationPromise; - } catch { - // A failed initialization retries lazily with the stored agent. - } - if (this.#clientsInitialized) { - this.#clientService.setWallet(await this.#buildWallet()); + const { network, userAddress } = await this.#ensureReadyForTrading({ + requiresBuilderFee: true, + }); + const ready = + this.#tradingSetupComplete && + !this.#unifiedAccountSetupNeedsRetry && + this.#builderFeeCheckCache.has(this.#getCacheKey(network, userAddress)); + if (ready) { + return { ready: true }; + } + return this.#walletService.isKeyringUnlocked() + ? { ready: false } + : { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; + } catch (error) { + return { + ready: false, + error: + error instanceof Error + ? error.message + : PERPS_ERROR_CODES.UNKNOWN_ERROR, + }; } } /** - * Run the deferred trading-readiness steps (account migration with user - * signing, builder fee and referral setup) ahead of the first order, so a - * hardware wallet signs them in one guided session instead of at order - * time. Results are cached, so an already-ready account signs nothing. + * Check if ready to trade + * + * @returns A promise that resolves to the result. */ - async prepareTradingWallet(): Promise { - await this.#ensureReadyForTrading({ requiresBuilderFee: true }); - } - async isReadyToTrade(): Promise { try { const exchangeClient = this.#clientService.getExchangeClient(); diff --git a/packages/perps-controller/src/services/HyperLiquidClientService.ts b/packages/perps-controller/src/services/HyperLiquidClientService.ts index ddcf8a4dfd4..7549943d0fb 100644 --- a/packages/perps-controller/src/services/HyperLiquidClientService.ts +++ b/packages/perps-controller/src/services/HyperLiquidClientService.ts @@ -46,8 +46,8 @@ export type ValidCandleInterval = CandlePeriod; * Extracted for reuse across initialize(), toggleTestnet(), and ensureSubscriptionClient() methods. */ export type HyperLiquidWalletParams = { - /** The signing account; the SDK keys nonces and locks by it. */ - address?: Hex; + /** The main account; the SDK recognizes the wallet and keys nonces by it. */ + address: Hex; signTypedData: (params: PerpsTypedDataPayload) => Promise; getChainId?: () => Promise; }; @@ -351,26 +351,14 @@ export class HyperLiquidClientService { this.#infoClientHttp = new InfoClient({ transport: this.#httpTransport }); - this.#exchangeClient = effectiveWallet - ? this.#createExchangeClient(effectiveWallet, this.#httpTransport) - : undefined; - } - - /** - * Create the exchange client that signs with the given wallet. - * - * @param wallet - The wallet parameters for signing typed data. - * @param transport - The HTTP transport to send exchange actions over. - * @returns The exchange client. - */ - #createExchangeClient( - wallet: HyperLiquidWalletParams, - transport: HttpTransport, - ): ExchangeClient { - return new ExchangeClient({ - wallet: wallet as any, // eslint-disable-line @typescript-eslint/no-explicit-any -- Type widening for SDK compatibility - transport, - }); + if (effectiveWallet) { + this.#exchangeClient = new ExchangeClient({ + wallet: effectiveWallet as any, // eslint-disable-line @typescript-eslint/no-explicit-any -- Type widening for SDK compatibility + transport: this.#httpTransport, + }); + } else { + this.#exchangeClient = undefined; + } } /** @@ -442,24 +430,6 @@ export class HyperLiquidClientService { } } - /** - * Replace the signing wallet. Only the exchange client depends on it, so - * this rebuilds that client over the existing HTTP transport and leaves the - * WebSocket and its subscriptions untouched. Before initialization it only - * stores the wallet for `initialize` to use. - * - * @param wallet - The wallet parameters for signing typed data. - */ - public setWallet(wallet: HyperLiquidWalletParams): void { - this.#walletParams = wallet; - if (this.#httpTransport) { - this.#exchangeClient = this.#createExchangeClient( - wallet, - this.#httpTransport, - ); - } - } - /** * Get the exchange client * diff --git a/packages/perps-controller/src/services/HyperLiquidWalletService.ts b/packages/perps-controller/src/services/HyperLiquidWalletService.ts index e791184c418..831f83cfb2b 100644 --- a/packages/perps-controller/src/services/HyperLiquidWalletService.ts +++ b/packages/perps-controller/src/services/HyperLiquidWalletService.ts @@ -31,6 +31,24 @@ const HARDWARE_KEYRING_TYPES = new Set([ 'QR Hardware Wallet Device', ]); +// The SDK signs every L1 action (orders, cancels, leverage, ...) as this +// primary type over this domain; only these may be signed by an agent. +const L1_ACTION_PRIMARY_TYPE = 'Agent'; +const L1_ACTION_DOMAIN_NAME = 'Exchange'; + +/** + * 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 === L1_ACTION_PRIMARY_TYPE && + params.domain.name === L1_ACTION_DOMAIN_NAME + ); +} + /** * Service for MetaMask wallet integration with HyperLiquid SDK * Provides wallet adapter that implements AbstractWindowEthereum interface @@ -117,26 +135,35 @@ export class HyperLiquidWalletService { } /** - * Sign typed data with the selected main account: through the injected - * account signer when one is set, else through the keyring. The account is - * resolved on every call so an account switch cannot race a cached adapter. + * Resolve the selected main account. It is read on every signature so an + * account switch cannot race a cached adapter. * - * @param params - The typed data the SDK asked the wallet to sign. - * @returns The signature. + * @returns The selected main account address. */ - async #signWithMainAccount(params: PerpsTypedDataPayload): Promise { - const currentEvmAccount = getSelectedEvmAccountFromMessenger( - this.#messenger, - ); + #getSelectedMainAddress(): Hex { + const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); - if (!currentEvmAccount?.address) { + if (!evmAccount?.address) { throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); } - const currentAddress = currentEvmAccount.address as Hex; + return evmAccount.address as Hex; + } + /** + * 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: currentAddress, + address: mainAddress, primaryType: params.primaryType, domain: params.domain, }); @@ -146,11 +173,11 @@ export class HyperLiquidWalletService { if (!isAccountSignerReady(accountSigner)) { throw new Error(PERPS_ERROR_CODES.KEYRING_LOCKED); } - return await accountSigner.signTypedData(currentAddress, params); + return await accountSigner.signTypedData(mainAddress, params); } const signature = await this.#signTypedMessage({ - from: currentAddress, + from: mainAddress, data: params, }); @@ -158,54 +185,35 @@ export class HyperLiquidWalletService { } /** - * Create the wallet adapter the HyperLiquid SDK signs with, backed by the - * selected main account. + * Create the wallet adapter the HyperLiquid SDK signs with. * - * @returns The wallet adapter with address, signTypedData, and getChainId methods. - */ - public createWalletAdapter(): HyperLiquidWalletParams & { address: Hex } { - const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); - - if (!evmAccount?.address) { - throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); - } - - return { - address: evmAccount.address as Hex, - signTypedData: async (params: PerpsTypedDataPayload): Promise => - await this.#signWithMainAccount(params), - getChainId: async (): Promise => - parseInt(getChainId(this.#isTestnet), 10), - }; - } - - /** - * Create a wallet adapter backed by an approved agent. + * Every signature is for the currently selected main account. When + * `resolveAgent` returns an agent for that account, L1 actions (orders, + * cancels, leverage, ...), which the SDK signs as primary type `Agent` + * over the `Exchange` domain, are signed by the agent. User-signed actions + * (builder fee, withdraw, ...) authorize the main account itself, so the + * main account always signs them. * - * HyperLiquid lets an agent sign only L1 actions (orders, cancels, - * leverage, ...), which the SDK signs as primary type `Agent` over the - * `Exchange` domain. Every other request is a user-signed action that - * authorizes the main account (builder fee, withdraw, ...), so it goes to - * the main account. - * - * @param agentSigner - The host-owned agent signer. - * @returns The wallet adapter with the agent as its signing address. + * @param resolveAgent - Returns the agent for a main account, or null. + * @returns The wallet adapter with address, signTypedData, and getChainId methods. */ - public createAgentWalletAdapter( - agentSigner: PerpsAgentSigner, - ): HyperLiquidWalletParams & { address: Hex } { + public createWalletAdapter( + resolveAgent?: (mainAddress: Hex) => Promise, + ): HyperLiquidWalletParams { return { - address: agentSigner.address, + address: this.#getSelectedMainAddress(), signTypedData: async (params: PerpsTypedDataPayload): Promise => { - if ( - params.primaryType !== 'Agent' || - params.domain.name !== 'Exchange' - ) { - return await this.#signWithMainAccount(params); + const mainAddress = this.#getSelectedMainAddress(); + const agentSigner = + resolveAgent && isL1Action(params) + ? await resolveAgent(mainAddress) + : null; + if (!agentSigner) { + return await this.#signWithMainAccount(mainAddress, params); } this.#deps.debugLogger.log( 'HyperLiquidWalletService: Signing L1 action with agent', - { agent: agentSigner.address }, + { address: mainAddress, agent: agentSigner.address }, ); return await agentSigner.signTypedData(params); }, diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 14264d553bb..29ca653acf0 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1116,13 +1116,19 @@ export type HyperLiquidCredentials = { /** Dedicated subscription waiver builder for mainnet. */ subscriptionBuilderAddressMainnet?: string; /** - * Resolves the approved agent for a main account, or null when none is - * active. Consulted when the HyperLiquid clients first initialize; use - * `PerpsController:setAgentSigner` to switch at runtime. With an agent, L1 - * actions (orders, cancels, leverage, ...) are signed by the agent key and - * user-signed actions (builder fee, withdraw, ...) by the main account. + * Resolves the agent approved for a main account on a network, or null + * when none is active (for example while the wallet is locked). Called + * lazily, the first time an L1 action (order, cancel, leverage, ...) is + * signed for that account and network; reads never call it. The answer is + * kept until the provider is re-created, and `PerpsController:setAgentSigner` + * replaces it for the selected account. With an agent, L1 actions are signed + * by the agent key and user-signed actions (builder fee, withdraw, ...) by + * the main account. */ - getAgentSigner?: (mainAddress: Hex) => Promise; + getAgentSigner?: (context: { + mainAddress: Hex; + isTestnet: boolean; + }) => Promise; }; export type LighterCredentials = { @@ -2141,9 +2147,10 @@ export type PerpsProvider = { * Run the deferred trading-readiness steps (account migration, builder fee * and referral setup) ahead of the first order, so any main-account * signature surfaces in a guided session instead of at order time. - * Providers without deferred setup omit it. + * Resolves `ready: false` when a step still needs a signature. Providers + * without deferred setup omit it. */ - prepareTradingWallet?(): Promise; + prepareTradingWallet?(): Promise; disconnect(): Promise; ping(timeoutMs?: number): Promise; // Lightweight WebSocket health check with configurable timeout getWebSocketConnectionState?(): WebSocketConnectionState; // Optional: get current WebSocket connection state diff --git a/packages/perps-controller/tests/helpers/serviceMocks.ts b/packages/perps-controller/tests/helpers/serviceMocks.ts index 99ebd4d74ff..7a23e699ead 100644 --- a/packages/perps-controller/tests/helpers/serviceMocks.ts +++ b/packages/perps-controller/tests/helpers/serviceMocks.ts @@ -297,12 +297,18 @@ export const createMockMessenger = ( * delegated, so any `KeyringController:*` call throws. * * @param keyringType - Keyring type reported in the selected account metadata. - * @returns The messenger and a spy on its `call`. + * @returns The messenger, a spy on its `call`, and a way to switch the + * selected account. */ export const createKeyringlessMessenger = ( keyringType = 'HD Key Tree', -): { messenger: PerpsControllerMessenger; call: jest.SpyInstance } => { +): { + messenger: PerpsControllerMessenger; + call: jest.SpyInstance; + selectAccount: (address: `0x${string}`) => void; +} => { const account = createMockEvmAccount(); + let selectedAddress = account.address; const root = new Messenger< MockAnyNamespace, MessengerActions, @@ -314,6 +320,7 @@ export const createKeyringlessMessenger = ( }); root.registerActionHandler('AccountsController:getSelectedAccount', () => ({ ...account, + address: selectedAddress, scopes: ['eip155:0'], metadata: { ...account.metadata, keyring: { type: keyringType } }, })); @@ -321,7 +328,13 @@ export const createKeyringlessMessenger = ( actions: ['AccountsController:getSelectedAccount'], messenger, }); - return { messenger, call: jest.spyOn(messenger, 'call') }; + return { + messenger, + call: jest.spyOn(messenger, 'call'), + selectAccount: (address): void => { + selectedAddress = address; + }, + }; }; /** 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 0a27b4d5b93..d995b674bd8 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -953,17 +953,26 @@ describe('PerpsController', () => { ); }); - it("prepareTradingWallet runs the active provider's deferred setup", async () => { + it("prepareTradingWallet returns the active provider's readiness", async () => { mockProvider.prepareTradingWallet = jest .fn() - .mockResolvedValue(undefined); + .mockResolvedValue({ ready: false, error: 'KEYRING_LOCKED' }); await controller.init(); - await controller.prepareTradingWallet(); + const result = await controller.prepareTradingWallet(); + expect(result).toStrictEqual({ ready: false, error: 'KEYRING_LOCKED' }); expect(mockProvider.prepareTradingWallet).toHaveBeenCalledTimes(1); }); + it('prepareTradingWallet reports ready when the provider has no deferred setup', async () => { + await controller.init(); + + const result = await controller.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: true }); + }); + it('handleLighterImportError logs debug for MODULE_NOT_FOUND errors', () => { const moduleError = Object.assign( new Error('Cannot find module ./providers/LighterProvider'), diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts index 411a55ed2a5..7223d40f668 100644 --- a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -1061,18 +1061,21 @@ describe('AggregatedPerpsProvider', () => { }); it('delegates prepareTradingWallet to default provider', async () => { - const prepareTradingWallet = jest.fn().mockResolvedValue(undefined); + const prepareTradingWallet = jest.fn().mockResolvedValue({ + ready: false, + }); Object.assign(mockHLProvider, { prepareTradingWallet }); - await aggregatedProvider.prepareTradingWallet(); + const result = await aggregatedProvider.prepareTradingWallet(); + expect(result).toStrictEqual({ ready: false }); expect(prepareTradingWallet).toHaveBeenCalledTimes(1); }); - it('resolves prepareTradingWallet when the default provider has no deferred setup', async () => { - await expect( - aggregatedProvider.prepareTradingWallet(), - ).resolves.toBeUndefined(); + it('reports ready when the default provider has no deferred setup', async () => { + const result = await aggregatedProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: true }); }); it('delegates toggleTestnet to default provider', async () => { 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 9a127b329a3..704afc5e966 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 @@ -2264,6 +2264,8 @@ describe('HyperLiquidProvider', () => { typeof import('../../../src/services/HyperLiquidWalletService.js') >('../../../src/services/HyperLiquidWalletService'); const ACCOUNT_ADDRESS = createMockEvmAccount().address; + const OTHER_ACCOUNT_ADDRESS = + '0x00000000000000000000000000000000000b0b01' as const; const AGENT_ADDRESS = '0x00000000000000000000000000000000000a9e17' as const; const SIGNATURE = `0x${'cd'.repeat(65)}` as const; const AGENT_SIGNATURE = `0x${'ef'.repeat(65)}` as const; @@ -2326,7 +2328,7 @@ describe('HyperLiquidProvider', () => { address: AGENT_ADDRESS, signTypedData: jest.fn().mockResolvedValue(AGENT_SIGNATURE), }; - const { messenger, call } = createKeyringlessMessenger(); + const { messenger, call, selectAccount } = createKeyringlessMessenger(); MockedHyperLiquidWalletService.mockImplementation( (deps, walletMessenger, walletOptions) => new RealHyperLiquidWalletService( @@ -2339,10 +2341,7 @@ describe('HyperLiquidProvider', () => { const initialize = jest.fn(async (wallet: HyperLiquidWalletParams) => { sdkWallet = wallet; }); - const setWallet = jest.fn((wallet: HyperLiquidWalletParams) => { - sdkWallet = wallet; - }); - Object.assign(mockClientService, { initialize, setWallet }); + Object.assign(mockClientService, { initialize }); const signThroughSdkWallet = (payload: PerpsTypedDataPayload) => async () => { if (!sdkWallet) { @@ -2381,7 +2380,7 @@ describe('HyperLiquidProvider', () => { call, exchangeClient, initialize, - setWallet, + selectAccount, }; } @@ -2430,44 +2429,128 @@ describe('HyperLiquidProvider', () => { expect(keyringCalls(call)).toStrictEqual([]); }); - it('runs the deferred migration through prepareTradingWallet', async () => { - const { accountSignerProvider, accountSigner, exchangeClient } = - createAccountSignerProvider({ - signer: { isHardwareWallet: () => true }, + describe('prepareTradingWallet', () => { + it('runs the deferred migration, builder fee and referral setup and reports ready', async () => { + const { accountSignerProvider, accountSigner, exchangeClient } = + createAccountSignerProvider({ + signer: { isHardwareWallet: () => true }, + }); + await accountSignerProvider.getMarketDataWithPrices(); + const referral = mockClientService.getInfoClient().referral; + const maxBuilderFee = mockClientService.getInfoClient().maxBuilderFee; + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: true }); + expect(exchangeClient.userSetAbstraction).toHaveBeenCalledTimes(1); + expect(accountSigner.signTypedData).toHaveBeenCalledWith( + ACCOUNT_ADDRESS, + USER_SIGNED_PAYLOAD, + ); + expect(maxBuilderFee).toHaveBeenCalled(); + expect(referral).toHaveBeenCalled(); + }); + + it('signs nothing more when called again', async () => { + const { accountSignerProvider, accountSigner } = + createAccountSignerProvider({ + signer: { isHardwareWallet: () => true }, + }); + const readinessCache = TradingReadinessCache as jest.Mocked< + typeof TradingReadinessCache + >; + await accountSignerProvider.prepareTradingWallet(); + const signaturesAfterFirstCall = + accountSigner.signTypedData.mock.calls.length; + // The global cache is mocked in this suite: replay what the first call + // wrote to it, as the real cache would. + expect(readinessCache.set).toHaveBeenCalledWith( + 'mainnet', + ACCOUNT_ADDRESS, + { attempted: true, enabled: true }, + ); + readinessCache.get.mockReturnValue({ + attempted: true, + enabled: true, + timestamp: Date.now(), }); - await accountSignerProvider.getMarketDataWithPrices(); - await accountSignerProvider.prepareTradingWallet(); + const result = await accountSignerProvider.prepareTradingWallet(); - expect(exchangeClient.userSetAbstraction).toHaveBeenCalledTimes(1); - expect(accountSigner.signTypedData).toHaveBeenCalledWith( - ACCOUNT_ADDRESS, - USER_SIGNED_PAYLOAD, - ); + expect(result).toStrictEqual({ ready: true }); + expect(accountSigner.signTypedData).toHaveBeenCalledTimes( + signaturesAfterFirstCall, + ); + }); + + it('reports not ready when the builder fee approval is rejected', async () => { + const { accountSignerProvider, exchangeClient } = + createAccountSignerProvider({ abstraction: 'unifiedAccount' }); + ( + mockClientService.getInfoClient().maxBuilderFee as jest.Mock + ).mockResolvedValue(0); + exchangeClient.approveBuilderFee.mockRejectedValue( + new Error('User rejected the request.'), + ); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: false }); + }); + + it('reports KEYRING_LOCKED when accountSigner is not ready', async () => { + const { accountSignerProvider } = createAccountSignerProvider({ + signer: { isReady: () => false }, + }); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + }); }); describe('with an agent', () => { - it('resolves the agent at init and signs L1 actions with it', async () => { + const agentOptions = (getAgentSigner: jest.Mock): Options => ({ + abstraction: 'default', + getAgentSigner, + }); + + it('resolves the agent at the first L1 signature and signs with it', async () => { const getAgentSigner = jest.fn(); const { accountSignerProvider, accountSigner, agentSigner, initialize, - } = createAccountSignerProvider({ - abstraction: 'default', - getAgentSigner, - }); + } = createAccountSignerProvider(agentOptions(getAgentSigner)); getAgentSigner.mockResolvedValue(agentSigner); await accountSignerProvider.getMarketDataWithPrices(); - expect(getAgentSigner).toHaveBeenCalledWith(ACCOUNT_ADDRESS); - expect(initialize.mock.calls[0][0].address).toBe(AGENT_ADDRESS); + expect(getAgentSigner).toHaveBeenCalledWith({ + mainAddress: ACCOUNT_ADDRESS, + isTestnet: false, + }); + expect(initialize.mock.calls[0][0].address).toBe(ACCOUNT_ADDRESS); expect(agentSigner.signTypedData).toHaveBeenCalledWith(L1_PAYLOAD); expect(accountSigner.signTypedData).not.toHaveBeenCalled(); }); + it('does not ask for an agent when nothing is signed', async () => { + const getAgentSigner = jest.fn(); + const { accountSignerProvider } = createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + }); + + await accountSignerProvider.getMarketDataWithPrices(); + + expect(getAgentSigner).not.toHaveBeenCalled(); + }); + it('keeps user-signed actions on the main account', async () => { const getAgentSigner = jest.fn(); const { accountSignerProvider, accountSigner, agentSigner } = @@ -2481,38 +2564,93 @@ describe('HyperLiquidProvider', () => { USER_SIGNED_PAYLOAD, ); expect(agentSigner.signTypedData).not.toHaveBeenCalled(); + expect(getAgentSigner).not.toHaveBeenCalled(); }); - it('uses an agent set before the clients initialize', async () => { - const { accountSignerProvider, agentSigner, initialize, setWallet } = + it('fails only the L1 action and asks again when getAgentSigner rejects', async () => { + const getAgentSigner = jest + .fn() + .mockRejectedValue(new Error('agent store unavailable')); + const { accountSignerProvider, accountSigner, exchangeClient } = + createAccountSignerProvider(agentOptions(getAgentSigner)); + + const marketData = + await accountSignerProvider.getMarketDataWithPrices(); + await accountSignerProvider.prepareTradingWallet(); + + expect(marketData.length).toBeGreaterThan(0); + expect(exchangeClient.agentSetAbstraction).toHaveBeenCalled(); + expect(accountSigner.signTypedData).not.toHaveBeenCalledWith( + ACCOUNT_ADDRESS, + L1_PAYLOAD, + ); + // Each L1 action (migration, then referral setup) asks again. + expect(getAgentSigner.mock.calls.length).toBeGreaterThan(1); + expect(mockPlatformDependencies.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ message: 'agent store unavailable' }), + expect.anything(), + ); + }); + + it('signs with an agent set through setAgentSigner', async () => { + const { accountSignerProvider, agentSigner } = createAccountSignerProvider({ abstraction: 'default' }); await accountSignerProvider.setAgentSigner(agentSigner); await accountSignerProvider.getMarketDataWithPrices(); - expect(setWallet).not.toHaveBeenCalled(); - expect(initialize.mock.calls[0][0].address).toBe(AGENT_ADDRESS); expect(agentSigner.signTypedData).toHaveBeenCalledWith(L1_PAYLOAD); }); - it('swaps the signing wallet at runtime without reconnecting', async () => { - const getAgentSigner = jest.fn().mockResolvedValue(null); - const { accountSignerProvider, agentSigner, setWallet } = - createAccountSignerProvider({ - abstraction: 'unifiedAccount', - getAgentSigner, - }); + it('never signs for another account with the agent it was set for', async () => { + const { + accountSignerProvider, + accountSigner, + agentSigner, + selectAccount, + } = createAccountSignerProvider({ abstraction: 'default' }); + await accountSignerProvider.setAgentSigner(agentSigner); + + selectAccount(OTHER_ACCOUNT_ADDRESS); await accountSignerProvider.getMarketDataWithPrices(); - getAgentSigner.mockClear(); + expect(agentSigner.signTypedData).not.toHaveBeenCalled(); + expect(accountSigner.signTypedData).toHaveBeenCalledWith( + OTHER_ACCOUNT_ADDRESS, + L1_PAYLOAD, + ); + }); + + it('never signs on another network with the agent it was set for', async () => { + const { accountSignerProvider, accountSigner, agentSigner } = + createAccountSignerProvider({ abstraction: 'default' }); await accountSignerProvider.setAgentSigner(agentSigner); + + mockClientService.isTestnetMode.mockReturnValue(true); + await accountSignerProvider.getMarketDataWithPrices(); + + expect(agentSigner.signTypedData).not.toHaveBeenCalled(); + expect(accountSigner.signTypedData).toHaveBeenCalledWith( + ACCOUNT_ADDRESS, + L1_PAYLOAD, + ); + }); + + it('signs with the main account after setAgentSigner(null) without asking getAgentSigner', async () => { + const getAgentSigner = jest.fn(); + const { accountSignerProvider, accountSigner, agentSigner } = + createAccountSignerProvider(agentOptions(getAgentSigner)); + getAgentSigner.mockResolvedValue(agentSigner); + await accountSignerProvider.setAgentSigner(null); + await accountSignerProvider.getMarketDataWithPrices(); - expect(setWallet).toHaveBeenCalledTimes(2); - expect(setWallet.mock.calls[0][0].address).toBe(AGENT_ADDRESS); - expect(setWallet.mock.calls[1][0].address).toBe(ACCOUNT_ADDRESS); expect(getAgentSigner).not.toHaveBeenCalled(); - expect(mockClientService.disconnect).not.toHaveBeenCalled(); + expect(agentSigner.signTypedData).not.toHaveBeenCalled(); + expect(accountSigner.signTypedData).toHaveBeenCalledWith( + ACCOUNT_ADDRESS, + L1_PAYLOAD, + ); }); }); }); diff --git a/packages/perps-controller/tests/src/services/HyperLiquidClientService.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidClientService.test.ts index 82f9068216e..c1f0176a1b3 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidClientService.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidClientService.test.ts @@ -275,54 +275,6 @@ describe('HyperLiquidClientService', () => { }); }); - describe('setWallet', () => { - const agentWallet = { - address: '0x00000000000000000000000000000000000a9e17' as const, - signTypedData: jest.fn(), - }; - - it('only stores the wallet before initialization', () => { - const { ExchangeClient } = require('@nktkas/hyperliquid'); - - service.setWallet(agentWallet); - - expect(ExchangeClient).not.toHaveBeenCalled(); - expect(() => service.getExchangeClient()).toThrow( - 'CLIENT_NOT_INITIALIZED', - ); - }); - - it('rebuilds only the exchange client and keeps the WebSocket', async () => { - await service.initialize(mockWallet); - const { - ExchangeClient, - InfoClient, - SubscriptionClient, - WebSocketTransport, - } = require('@nktkas/hyperliquid'); - const agentExchangeClient = { agent: true }; - ExchangeClient.mockClear(); - InfoClient.mockClear(); - SubscriptionClient.mockClear(); - WebSocketTransport.mockClear(); - ExchangeClient.mockImplementationOnce(() => agentExchangeClient); - - service.setWallet(agentWallet); - - expect(ExchangeClient).toHaveBeenCalledTimes(1); - expect(ExchangeClient).toHaveBeenCalledWith({ - wallet: agentWallet, - transport: mockHttpTransport, - }); - expect(service.getExchangeClient()).toBe(agentExchangeClient); - expect(InfoClient).not.toHaveBeenCalled(); - expect(SubscriptionClient).not.toHaveBeenCalled(); - expect(WebSocketTransport).not.toHaveBeenCalled(); - expect(mockWsTransport.close).not.toHaveBeenCalled(); - expect(service.isInitialized()).toBe(true); - }); - }); - describe('Client Access', () => { beforeEach(async () => { await service.initialize(mockWallet); 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 index ad65c7f7643..81e8aa383da 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts @@ -1,3 +1,11 @@ +import type { Hex } from '@metamask/utils'; +import { ApproveBuilderFeeTypes } from '@nktkas/hyperliquid/api/exchange'; +import { + signL1Action, + signUserSignedAction, +} from '@nktkas/hyperliquid/signing'; +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; + import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; import type { PerpsTypedDataPayload } from '../../../src/types/index.js'; @@ -5,7 +13,6 @@ import { createKeyringlessMessenger, createMockEvmAccount, createMockInfrastructure, - createMockMessenger, keyringCalls, } from '../../helpers/serviceMocks.js'; @@ -137,9 +144,10 @@ describe('HyperLiquidWalletService with accountSigner', () => { ); }); -describe('HyperLiquidWalletService agent wallet adapter', () => { +describe('HyperLiquidWalletService wallet adapter with an agent', () => { const { address: mainAddress } = createMockEvmAccount(); - const AGENT_ADDRESS = '0x00000000000000000000000000000000000a9e17' as const; + const OTHER_MAIN_ADDRESS = '0x00000000000000000000000000000000000b0b01'; + const AGENT_ADDRESS = '0x00000000000000000000000000000000000a9e17'; const AGENT_SIGNATURE = `0x${'ef'.repeat(65)}` as const; const USER_SIGNED_ACTION: PerpsTypedDataPayload = { domain: { @@ -165,50 +173,73 @@ describe('HyperLiquidWalletService agent wallet adapter', () => { }, }; - function buildAgentAdapter(): { - adapter: ReturnType; + function buildAdapter(agentAvailable = true): { + adapter: ReturnType; + resolveAgent: jest.Mock; agentSign: jest.Mock; mainSign: jest.Mock; call: jest.SpyInstance; + selectAccount: (address: `0x${string}`) => void; } { - const { service, call, signer } = buildService(); + const signer = { + signTypedData: jest.fn().mockResolvedValue(SIGNATURE), + signPersonalMessage: jest.fn(), + }; + const { messenger, call, selectAccount } = createKeyringlessMessenger(); + const service = new HyperLiquidWalletService( + { ...createMockInfrastructure(), accountSigner: signer }, + messenger, + { isTestnet: true }, + ); const agentSign = jest.fn().mockResolvedValue(AGENT_SIGNATURE); - const adapter = service.createAgentWalletAdapter({ - address: AGENT_ADDRESS, - signTypedData: agentSign, - }); - return { adapter, agentSign, mainSign: signer.signTypedData, call }; + const resolveAgent = jest + .fn() + .mockResolvedValue( + agentAvailable + ? { address: AGENT_ADDRESS, signTypedData: agentSign } + : null, + ); + return { + adapter: service.createWalletAdapter(resolveAgent), + resolveAgent, + agentSign, + mainSign: signer.signTypedData, + call, + selectAccount, + }; } - it('uses the agent as the signing address', () => { - const { adapter } = buildAgentAdapter(); + it('keeps the main account as the wallet address', () => { + const { adapter } = buildAdapter(); - expect(adapter.address).toBe(AGENT_ADDRESS); + expect(adapter.address).toBe(mainAddress); }); - it('signs L1 actions with the agent', async () => { - const { adapter, agentSign, mainSign, call } = buildAgentAdapter(); + 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(TYPED_DATA); expect(signature).toBe(AGENT_SIGNATURE); + expect(resolveAgent).toHaveBeenCalledWith(mainAddress); expect(agentSign).toHaveBeenCalledWith(TYPED_DATA); expect(mainSign).not.toHaveBeenCalled(); expect(keyringCalls(call)).toStrictEqual([]); }); - it('signs user-signed actions with the main account', async () => { - const { adapter, agentSign, mainSign } = buildAgentAdapter(); + 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_ACTION); expect(signature).toBe(SIGNATURE); expect(mainSign).toHaveBeenCalledWith(mainAddress, USER_SIGNED_ACTION); + 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, agentSign, mainSign } = buildAgentAdapter(); + const { adapter, agentSign, mainSign } = buildAdapter(); const lookalike = { ...TYPED_DATA, domain: { ...TYPED_DATA.domain, name: 'HyperliquidSignTransaction' }, @@ -220,24 +251,106 @@ describe('HyperLiquidWalletService agent wallet adapter', () => { expect(agentSign).not.toHaveBeenCalled(); }); - it('signs user-signed actions through the keyring when no account signer is set', async () => { - const messenger = createMockMessenger(); - const call = jest.spyOn(messenger, 'call'); + it('signs L1 actions with the main account when no agent is resolved', async () => { + const { adapter, mainSign } = buildAdapter(false); + + await adapter.signTypedData(TYPED_DATA); + + expect(mainSign).toHaveBeenCalledWith(mainAddress, TYPED_DATA); + }); + + it('resolves the agent for the account selected at signing time', async () => { + const { adapter, resolveAgent, selectAccount } = buildAdapter(); + + selectAccount(OTHER_MAIN_ADDRESS); + await adapter.signTypedData(TYPED_DATA); + + expect(resolveAgent).toHaveBeenCalledWith(OTHER_MAIN_ADDRESS); + expect(resolveAgent).not.toHaveBeenCalledWith(mainAddress); + }); + + it('propagates agent resolution failures', async () => { + const { adapter, resolveAgent, mainSign } = buildAdapter(); + resolveAgent.mockRejectedValue(new Error('agent store unavailable')); + + await expect(adapter.signTypedData(TYPED_DATA)).rejects.toThrow( + 'agent store unavailable', + ); + expect(mainSign).not.toHaveBeenCalled(); + }); +}); + +describe('HyperLiquidWalletService wallet adapter with the HyperLiquid SDK', () => { + // Drive the adapter through the SDK's own signing functions so the routing + // holds for the payloads the SDK actually builds and for the SDK's wallet + // detection, not just for hand-written payloads. + const mainAccount = privateKeyToAccount(generatePrivateKey()); + const agentAccount = privateKeyToAccount(generatePrivateKey()); + + function buildSdkAdapter(): { + adapter: ReturnType; + mainSign: jest.SpyInstance; + agentSign: jest.SpyInstance; + } { + const { messenger, selectAccount } = createKeyringlessMessenger(); + selectAccount(mainAccount.address); + const mainSign = jest.spyOn(mainAccount, 'signTypedData'); + const agentSign = jest.spyOn(agentAccount, 'signTypedData'); const service = new HyperLiquidWalletService( - createMockInfrastructure(), + { + ...createMockInfrastructure(), + accountSigner: { + signTypedData: async (_address, payload): Promise => + await mainAccount.signTypedData(payload), + signPersonalMessage: async (_address, message): Promise => + await mainAccount.signMessage({ message }), + }, + }, messenger, + { isTestnet: true }, ); - const adapter = service.createAgentWalletAdapter({ - address: AGENT_ADDRESS, - signTypedData: jest.fn(), + return { + adapter: service.createWalletAdapter(async () => agentAccount), + mainSign, + agentSign, + }; + } + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('signs an SDK L1 action with the agent', async () => { + const { adapter, mainSign, agentSign } = buildSdkAdapter(); + + await signL1Action({ + wallet: adapter, + action: { type: 'cancel', cancels: [{ a: 0, o: 1 }] }, + nonce: 1, + isTestnet: true, }); - await adapter.signTypedData(USER_SIGNED_ACTION); + expect(agentSign).toHaveBeenCalledTimes(1); + expect(mainSign).not.toHaveBeenCalled(); + }); - expect(call).toHaveBeenCalledWith( - 'KeyringController:signTypedMessage', - { from: mainAddress, data: USER_SIGNED_ACTION }, - 'V4', - ); + it('signs an SDK user-signed action with the main account', async () => { + const { adapter, mainSign, agentSign } = buildSdkAdapter(); + + await signUserSignedAction({ + wallet: adapter, + action: { + type: 'approveBuilderFee', + signatureChainId: '0x66eee', + hyperliquidChain: 'Testnet', + maxFeeRate: '0.1%', + builder: agentAccount.address, + nonce: 1, + }, + types: ApproveBuilderFeeTypes, + }); + + expect(mainSign).toHaveBeenCalledTimes(1); + expect(agentSign).not.toHaveBeenCalled(); }); }); From f62ce5ba5dffb6135f70829210c4605b94fde20b Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 28 Sep 2026 22:27:51 +0800 Subject: [PATCH 06/33] fix(perps-controller): bind agents explicitly and harden resolution - setAgentSigner(account, agentSigner) takes the main account and network the agent is approved for, so a pending account switch or re-initialization cannot attach it to another account. getAgentSigner receives the same PerpsAgentAccount. - Keep only non-null getAgentSigner answers; null and failures are asked again at the next L1 action. A binding set while an answer is pending wins. - Hold the agent resolver in HyperLiquidWalletService so every wallet adapter it creates, including the subscription service's, routes L1 actions. - Log resolver failures once (the migration path reports them), move the L1 action constants to hyperLiquidConfig, and document what ready means. - Recover signers from real SDK signatures and cover the resolver races. Co-authored-by: Monte Lai --- packages/perps-controller/CHANGELOG.md | 7 +- .../PerpsController-method-action-types.ts | 25 +- .../perps-controller/src/PerpsController.ts | 31 +- .../src/constants/hyperLiquidConfig.ts | 6 + packages/perps-controller/src/index.ts | 1 + .../src/providers/HyperLiquidProvider.ts | 113 ++++--- .../src/services/HyperLiquidWalletService.ts | 40 +-- packages/perps-controller/src/types/index.ts | 36 ++- .../PerpsController.providers-cache.test.ts | 92 +++--- .../HyperLiquidProvider.account-mode.test.ts | 275 ++++++++++++++---- ...LiquidWalletService.account-signer.test.ts | 84 ++++-- 11 files changed, 490 insertions(+), 220 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index c3486288051..5ed9a052165 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -18,11 +18,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `isReady()` returning `false` fails signing with the existing `KEYRING_LOCKED` error code - `isHardwareWallet()` 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 - - Add optional `providerCredentials.hyperliquid.getAgentSigner({ mainAddress, isTestnet })`, which resolves the approved agent (new exported `PerpsAgentSigner` type) the first time an L1 action is signed for that account and network; reads never call it - - Add `PerpsController:setAgentSigner` (`PerpsControllerSetAgentSignerAction`) to set the agent for the selected account and network at runtime, or clear it with `null` + - 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 it returns is kept for the provider's lifetime, while `null` and failures are asked again at the next L1 action + - Add `PerpsController:setAgentSigner(account, agentSigner)` (`PerpsControllerSetAgentSignerAction`) to set or clear (`null`) the agent for an explicit main account and network - An agent only ever signs for the main account and network it was set or resolved for, and user-signed actions (builder fee, withdraw, account migration, ...) 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 deferred trading-readiness steps (account migration, builder fee and referral setup) before the first order - - Resolves a `ReadyToTradeResult` that is `ready: false` while a step still needs a signature + - Resolves a `ReadyToTradeResult` that is `ready: true` once none of these steps will ask the main account to sign again before the first order ### Removed diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index a61072dd71b..94b04b5095b 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -906,14 +906,18 @@ export type PerpsControllerCalculateFeesAction = { }; /** - * Sign HyperLiquid L1 actions (orders, cancels, leverage, ...) for the - * selected account on the current network with an approved agent, or with - * the main account when `agentSigner` is null (for example when the wallet - * locks). User-signed actions stay on the main account. The agent is never - * used for another account or network, and a provider re-creation (network - * toggle, reconnect) asks `providerCredentials.hyperliquid.getAgentSigner` - * again. Requires an initialized controller. - * + * Sign HyperLiquid L1 actions (orders, cancels, leverage, ...) for a main + * account on a network with an approved agent, or with the main account + * when `agentSigner` is null. User-signed actions stay on the main account. + * The agent is never used for another account or network. The binding lasts + * for the lifetime of the HyperLiquid provider instance; initialization and + * re-initialization (for example a network toggle) create a new one, which + * asks `providerCredentials.hyperliquid.getAgentSigner` again. To stop agent + * signing everywhere, for example when the wallet locks, clear each account + * set here and have `getAgentSigner` return null. Requires an initialized + * controller. + * + * @param account - The main account and network the agent is approved for. * @param agentSigner - The host-owned agent signer, or null to clear it. */ export type PerpsControllerSetAgentSignerAction = { @@ -927,8 +931,9 @@ export type PerpsControllerSetAgentSignerAction = { * hardware wallet signs them in one guided session, such as agent setup, * instead of at order time. * - * @returns `ready: false` when a step still needs a signature; providers - * without deferred setup are ready. + * @returns `ready: true` when none of these steps will ask the main account + * to sign again before the first order; providers without deferred setup + * are ready. */ export type PerpsControllerPrepareTradingWalletAction = { type: `PerpsController:prepareTradingWallet`; diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index c6918032195..8a5be8f44a8 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -142,6 +142,7 @@ import type { GetHistoricalPortfolioParams, HistoricalPortfolioResult, OrderType, + PerpsAgentAccount, PerpsAgentSigner, PerpsPlatformDependencies, PerpsLogger, @@ -5860,23 +5861,30 @@ export class PerpsController extends BaseController< } /** - * Sign HyperLiquid L1 actions (orders, cancels, leverage, ...) for the - * selected account on the current network with an approved agent, or with - * the main account when `agentSigner` is null (for example when the wallet - * locks). User-signed actions stay on the main account. The agent is never - * used for another account or network, and a provider re-creation (network - * toggle, reconnect) asks `providerCredentials.hyperliquid.getAgentSigner` - * again. Requires an initialized controller. + * Sign HyperLiquid L1 actions (orders, cancels, leverage, ...) for a main + * account on a network with an approved agent, or with the main account + * when `agentSigner` is null. User-signed actions stay on the main account. + * The agent is never used for another account or network. The binding lasts + * for the lifetime of the HyperLiquid provider instance; initialization and + * re-initialization (for example a network toggle) create a new one, which + * asks `providerCredentials.hyperliquid.getAgentSigner` again. To stop agent + * signing everywhere, for example when the wallet locks, clear each account + * set here and have `getAgentSigner` return null. Requires an initialized + * controller. * + * @param account - The main account and network the agent is approved for. * @param agentSigner - The host-owned agent signer, or null to clear it. */ - async setAgentSigner(agentSigner: PerpsAgentSigner | null): Promise { + async setAgentSigner( + account: PerpsAgentAccount, + agentSigner: PerpsAgentSigner | null, + ): Promise { await this.#getActiveProviderWhenReady(); const provider = this.providers.get('hyperliquid'); if (!(provider instanceof HyperLiquidProvider)) { throw new Error(PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE); } - await provider.setAgentSigner(agentSigner); + provider.setAgentSigner(account, agentSigner); } /** @@ -5885,8 +5893,9 @@ export class PerpsController extends BaseController< * hardware wallet signs them in one guided session, such as agent setup, * instead of at order time. * - * @returns `ready: false` when a step still needs a signature; providers - * without deferred setup are ready. + * @returns `ready: true` when none of these steps will ask the main account + * to sign again before the first order; providers without deferred setup + * are ready. */ async prepareTradingWallet(): Promise { const provider = await this.#getActiveProviderWhenReady(); diff --git a/packages/perps-controller/src/constants/hyperLiquidConfig.ts b/packages/perps-controller/src/constants/hyperLiquidConfig.ts index 16f55ee8f1c..6df5c70335d 100644 --- a/packages/perps-controller/src/constants/hyperLiquidConfig.ts +++ b/packages/perps-controller/src/constants/hyperLiquidConfig.ts @@ -182,6 +182,12 @@ export const HIP3_FEE_CONFIG = { FeeMultiplier: 2, } as const; +// The SDK signs every L1 action (orders, cancels, leverage, ...) as this +// EIP-712 primary type over this domain. Only these 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'; +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 e21c8e04ace..fbe4890f216 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -335,6 +335,7 @@ export type { PerpsTypedMessageParams, PerpsTypedDataPayload, PerpsAccountSigner, + PerpsAgentAccount, PerpsAgentSigner, PerpsTransactionParams, PerpsAddTransactionOptions, diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index b0b74acb376..8557d65fa25 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -109,6 +109,7 @@ import type { HistoricalPortfolioResult, HyperLiquidCredentials, InitializeResult, + PerpsAgentAccount, PerpsAgentSigner, PerpsPlatformDependencies, PerpsProvider, @@ -1541,10 +1542,13 @@ export class HyperLiquidProvider implements PerpsProvider { readonly #getAgentSigner: HyperLiquidCredentials['getAgentSigner']; - // Agent per network and main account (see #getAgentKey), from - // setAgentSigner or a getAgentSigner answer. An agent is only ever used for - // the account and network it was set or resolved for. - readonly #agentSigners = new Map>(); + // Agent per network and main account (see #getAgentKey): set through + // setAgentSigner (null pins the main account) or a pending or non-null + // getAgentSigner answer. An agent is only used for its account and network. + readonly #agentSigners = new Map< + string, + { agentSigner: Promise; fromResolver: boolean } + >(); // Promise-based lock to prevent race conditions in concurrent initialization #initializationPromise: Promise | null = null; @@ -1610,6 +1614,10 @@ export class HyperLiquidProvider implements PerpsProvider { this.#messenger, { isTestnet, + resolveAgent: async ( + mainAddress: Hex, + ): Promise => + await this.#resolveAgentSigner(mainAddress), }, ); this.#subscriptionService = new HyperLiquidSubscriptionService( @@ -1984,9 +1992,7 @@ export class HyperLiquidProvider implements PerpsProvider { throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); } - const wallet = this.#walletService.createWalletAdapter( - async (mainAddress) => await this.#resolveAgentSigner(mainAddress), - ); + const wallet = this.#walletService.createWalletAdapter(); await this.#clientService.initialize(wallet); if (this.#disconnectOperationsInFlight > 0) { throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); @@ -2040,18 +2046,20 @@ export class HyperLiquidProvider implements PerpsProvider { /** * Key an agent by network and main account. * - * @param mainAddress - The main account. + * @param account - The main account and network. * @returns The agent key. */ - #getAgentKey(mainAddress: string): string { - const network = this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet'; - return `${network}:${mainAddress.toLowerCase()}`; + #getAgentKey(account: PerpsAgentAccount): string { + const network = account.isTestnet ? 'testnet' : 'mainnet'; + return `${network}:${account.mainAddress.toLowerCase()}`; } /** * Resolve the agent that signs L1 actions for a main account on the - * current network. Asks `getAgentSigner` once per account and network; a - * failed answer is not kept, so the next L1 action asks again. + * current network: the one set through setAgentSigner, else the one + * `getAgentSigner` returns. A non-null answer is kept; null and failures are + * not, so the next L1 action asks again. If the binding changes while an + * answer is pending, the newer binding wins. * * @param mainAddress - The selected main account. * @returns The agent, or null to sign with the main account. @@ -2059,31 +2067,46 @@ export class HyperLiquidProvider implements PerpsProvider { async #resolveAgentSigner( mainAddress: Hex, ): Promise { - const getAgentSigner = this.#getAgentSigner; - const key = this.#getAgentKey(mainAddress); - let agentSigner = this.#agentSigners.get(key); - if (!agentSigner) { - if (!getAgentSigner) { + const account: PerpsAgentAccount = { + mainAddress, + isTestnet: this.#clientService.isTestnetMode(), + }; + const key = this.#getAgentKey(account); + let entry = this.#agentSigners.get(key); + if (!entry) { + if (!this.#getAgentSigner) { return null; } - agentSigner = getAgentSigner({ - mainAddress, - isTestnet: this.#clientService.isTestnetMode(), - }); - this.#agentSigners.set(key, agentSigner); + entry = { + agentSigner: this.#getAgentSigner(account), + fromResolver: true, + }; + this.#agentSigners.set(key, entry); } + + let agentSigner: PerpsAgentSigner | null; try { - return await agentSigner; + agentSigner = await entry.agentSigner; } catch (error) { - if (this.#agentSigners.get(key) === agentSigner) { - this.#agentSigners.delete(key); + const latest = this.#agentSigners.get(key); + if (latest && latest !== entry) { + return await latest.agentSigner; } - this.#deps.logger.error( - ensureError(error, 'HyperLiquidProvider.resolveAgentSigner'), - this.#getErrorContext('resolveAgentSigner'), - ); + this.#agentSigners.delete(key); + this.#deps.debugLogger.log('HyperLiquidProvider: getAgentSigner failed', { + error: ensureError(error, 'resolveAgentSigner').message, + }); throw error; } + + const latest = this.#agentSigners.get(key); + if (latest && latest !== entry) { + return await latest.agentSigner; + } + if (!agentSigner && entry.fromResolver) { + this.#agentSigners.delete(key); + } + return agentSigner; } /** @@ -14136,19 +14159,22 @@ export class HyperLiquidProvider implements PerpsProvider { } /** - * Sign L1 actions for the selected main account on the current network - * with an approved agent, or with the main account when `agentSigner` is - * null. Other accounts and networks are unaffected, so the agent never - * signs for an account or network it was not approved for. + * Sign L1 actions for a main account on a network with an approved agent, + * or with the main account when `agentSigner` is null. The binding is + * explicit so an account switch or re-initialization that is still pending + * cannot attach the agent to another account. * + * @param account - The main account and network the agent is approved for. * @param agentSigner - The host-owned agent signer, or null to clear it. */ - async setAgentSigner(agentSigner: PerpsAgentSigner | null): Promise { - const mainAddress = await this.#walletService.getUserAddressWithDefault(); - this.#agentSigners.set( - this.#getAgentKey(mainAddress), - Promise.resolve(agentSigner), - ); + setAgentSigner( + account: PerpsAgentAccount, + agentSigner: PerpsAgentSigner | null, + ): void { + this.#agentSigners.set(this.#getAgentKey(account), { + agentSigner: Promise.resolve(agentSigner), + fromResolver: false, + }); } /** @@ -14157,7 +14183,12 @@ export class HyperLiquidProvider implements PerpsProvider { * hardware wallet signs them in one guided session instead of at order * time. Results are cached, so an already-ready account signs nothing. * - * @returns `ready: true` when no step still needs a signature. + * @returns `ready: true` when none of these steps will ask the main account + * to sign again before the first order; a step the user declined counts, + * because the order path does not ask again either. `ready: false` carries + * `KEYRING_LOCKED` when the signer is not ready, and no error when a step + * will retry (a rejected builder fee, a transient failure, or a wallet with + * no HyperLiquid account yet). */ async prepareTradingWallet(): Promise { try { diff --git a/packages/perps-controller/src/services/HyperLiquidWalletService.ts b/packages/perps-controller/src/services/HyperLiquidWalletService.ts index 831f83cfb2b..e3ab88bbf24 100644 --- a/packages/perps-controller/src/services/HyperLiquidWalletService.ts +++ b/packages/perps-controller/src/services/HyperLiquidWalletService.ts @@ -5,7 +5,11 @@ 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, @@ -31,10 +35,11 @@ const HARDWARE_KEYRING_TYPES = new Set([ 'QR Hardware Wallet Device', ]); -// The SDK signs every L1 action (orders, cancels, leverage, ...) as this -// primary type over this domain; only these may be signed by an agent. -const L1_ACTION_PRIMARY_TYPE = 'Agent'; -const L1_ACTION_DOMAIN_NAME = 'Exchange'; +/** + * 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. @@ -44,8 +49,8 @@ const L1_ACTION_DOMAIN_NAME = 'Exchange'; */ function isL1Action(params: PerpsTypedDataPayload): boolean { return ( - params.primaryType === L1_ACTION_PRIMARY_TYPE && - params.domain.name === L1_ACTION_DOMAIN_NAME + params.primaryType === HYPERLIQUID_L1_ACTION_PRIMARY_TYPE && + params.domain.name === HYPERLIQUID_L1_ACTION_DOMAIN_NAME ); } @@ -61,14 +66,17 @@ 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; } /** @@ -187,26 +195,22 @@ export class HyperLiquidWalletService { /** * Create the wallet adapter the HyperLiquid SDK signs with. * - * Every signature is for the currently selected main account. When - * `resolveAgent` returns an agent for that account, L1 actions (orders, - * cancels, leverage, ...), which the SDK signs as primary type `Agent` - * over the `Exchange` domain, are signed by the agent. User-signed actions + * 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. * - * @param resolveAgent - Returns the agent for a main account, or null. * @returns The wallet adapter with address, signTypedData, and getChainId methods. */ - public createWalletAdapter( - resolveAgent?: (mainAddress: Hex) => Promise, - ): HyperLiquidWalletParams { + public createWalletAdapter(): HyperLiquidWalletParams { return { address: this.#getSelectedMainAddress(), signTypedData: async (params: PerpsTypedDataPayload): Promise => { const mainAddress = this.#getSelectedMainAddress(); const agentSigner = - resolveAgent && isL1Action(params) - ? await resolveAgent(mainAddress) + this.#resolveAgent && isL1Action(params) + ? await this.#resolveAgent(mainAddress) : null; if (!agentSigner) { return await this.#signWithMainAccount(mainAddress, params); diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 29ca653acf0..919d76e88b4 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1117,18 +1117,17 @@ export type HyperLiquidCredentials = { subscriptionBuilderAddressMainnet?: string; /** * Resolves the agent approved for a main account on a network, or null - * when none is active (for example while the wallet is locked). Called - * lazily, the first time an L1 action (order, cancel, leverage, ...) is - * signed for that account and network; reads never call it. The answer is - * kept until the provider is re-created, and `PerpsController:setAgentSigner` - * replaces it for the selected account. With an agent, L1 actions are signed - * by the agent key and user-signed actions (builder fee, withdraw, ...) by - * the main account. + * when there is none (for example while the wallet is locked). Called when + * an L1 action (order, cancel, leverage, ...) is signed for that account and + * network, including the unified-account migration the provider may sign + * while connecting. An agent it returns is kept for the lifetime of the + * HyperLiquid provider instance; null is not kept, so it is asked again at + * the next L1 action. With an agent, L1 actions are signed by the agent key + * and user-signed actions (builder fee, withdraw, ...) by the main account. */ - getAgentSigner?: (context: { - mainAddress: Hex; - isTestnet: boolean; - }) => Promise; + getAgentSigner?: ( + account: PerpsAgentAccount, + ) => Promise; }; export type LighterCredentials = { @@ -2147,8 +2146,9 @@ export type PerpsProvider = { * Run the deferred trading-readiness steps (account migration, builder fee * and referral setup) ahead of the first order, so any main-account * signature surfaces in a guided session instead of at order time. - * Resolves `ready: false` when a step still needs a signature. Providers - * without deferred setup omit it. + * Resolves `ready: true` when none of these steps will ask the main account + * to sign again before the first order. Providers without deferred setup + * omit it. */ prepareTradingWallet?(): Promise; disconnect(): Promise; @@ -2652,6 +2652,16 @@ export type PerpsAccountSigner = { isHardwareWallet?(): 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 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 d995b674bd8..4da998a4f5f 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -878,6 +878,43 @@ describe('PerpsController', () => { ); }); + it('handleLighterImportError logs debug for MODULE_NOT_FOUND errors', () => { + const moduleError = Object.assign( + new Error('Cannot find module ./providers/LighterProvider'), + { code: 'MODULE_NOT_FOUND' }, + ); + + controller.testHandleLighterImportError(moduleError); + + expect(mockInfrastructure.debugLogger.log).toHaveBeenCalledWith( + 'PerpsController: Lighter provider module not available, skipping registration', + ); + }); + + it('handleLighterImportError routes runtime errors to logError', () => { + // Act — error without MODULE_NOT_FOUND code goes to Sentry + controller.testHandleLighterImportError(new Error('Invalid auth config')); + + // Assert + expect(mockInfrastructure.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Invalid auth config' }), + expect.objectContaining({ + context: expect.objectContaining({ + data: expect.objectContaining({ + method: 'createProviders.lighter', + }), + }), + }), + ); + }); + }); + + describe('account and agent signers', () => { + const account = { + mainAddress: '0x1234567890123456789012345678901234567890', + isTestnet: false, + } as const; + it('hands infrastructure.accountSigner to the HyperLiquid and Lighter providers', async () => { const accountSigner = { signTypedData: jest.fn(), @@ -932,27 +969,42 @@ describe('PerpsController', () => { signTypedData: jest.fn(), }; Object.setPrototypeOf(mockProvider, HyperLiquidProvider.prototype); - mockProvider.setAgentSigner = jest.fn().mockResolvedValue(undefined); + mockProvider.setAgentSigner = jest.fn(); await controller.init(); - await controller.setAgentSigner(agentSigner); - await controller.setAgentSigner(null); + await controller.setAgentSigner(account, agentSigner); + await controller.setAgentSigner(account, null); expect(mockProvider.setAgentSigner).toHaveBeenNthCalledWith( 1, + account, agentSigner, ); - expect(mockProvider.setAgentSigner).toHaveBeenNthCalledWith(2, null); + expect(mockProvider.setAgentSigner).toHaveBeenNthCalledWith( + 2, + account, + null, + ); }); it('setAgentSigner rejects when the hyperliquid provider is not a HyperLiquidProvider', async () => { await controller.init(); - await expect(controller.setAgentSigner(null)).rejects.toThrow( + await expect(controller.setAgentSigner(account, null)).rejects.toThrow( PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE, ); }); + it('setAgentSigner rejects before the controller is initialized', async () => { + Object.setPrototypeOf(mockProvider, HyperLiquidProvider.prototype); + mockProvider.setAgentSigner = jest.fn(); + + await expect(controller.setAgentSigner(account, null)).rejects.toThrow( + PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED, + ); + expect(mockProvider.setAgentSigner).not.toHaveBeenCalled(); + }); + it("prepareTradingWallet returns the active provider's readiness", async () => { mockProvider.prepareTradingWallet = jest .fn() @@ -972,36 +1024,6 @@ describe('PerpsController', () => { expect(result).toStrictEqual({ ready: true }); }); - - it('handleLighterImportError logs debug for MODULE_NOT_FOUND errors', () => { - const moduleError = Object.assign( - new Error('Cannot find module ./providers/LighterProvider'), - { code: 'MODULE_NOT_FOUND' }, - ); - - controller.testHandleLighterImportError(moduleError); - - expect(mockInfrastructure.debugLogger.log).toHaveBeenCalledWith( - 'PerpsController: Lighter provider module not available, skipping registration', - ); - }); - - it('handleLighterImportError routes runtime errors to logError', () => { - // Act — error without MODULE_NOT_FOUND code goes to Sentry - controller.testHandleLighterImportError(new Error('Invalid auth config')); - - // Assert - expect(mockInfrastructure.logger.error).toHaveBeenCalledWith( - expect.objectContaining({ message: 'Invalid auth config' }), - expect.objectContaining({ - context: expect.objectContaining({ - data: expect.objectContaining({ - method: 'createProviders.lighter', - }), - }), - }), - ); - }); }); describe('getOpenOrders with standalone mode', () => { 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 704afc5e966..e843a044bb5 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 @@ -36,6 +36,7 @@ import { } from '../../../src/utils/hyperLiquidValidation.js'; import { createStandaloneInfoClient } from '../../../src/utils/standaloneInfoClient.js'; import { + createDeferred, createKeyringlessMessenger, createMockEvmAccount, createMockInfrastructure, @@ -2312,6 +2313,29 @@ describe('HyperLiquidProvider', () => { message: { source: 'a', connectionId: `0x${'22'.repeat(32)}` }, }; + const agentSignerShape = { + address: AGENT_ADDRESS, + signTypedData: jest.fn(), + }; + + // The global readiness cache is mocked in this suite; replay the migration + // result the real cache would keep so later calls do not migrate again. + function rememberMigration(): void { + ( + TradingReadinessCache as jest.Mocked + ).get.mockReturnValue({ + attempted: true, + enabled: true, + timestamp: Date.now(), + }); + } + + async function waitFor(condition: () => boolean): Promise { + while (!condition()) { + await new Promise((resolve) => setImmediate(resolve)); + } + } + type Options = { signer?: { isReady?: () => boolean; isHardwareWallet?: () => boolean }; abstraction?: 'dexAbstraction' | 'default' | 'unifiedAccount'; @@ -2353,6 +2377,7 @@ describe('HyperLiquidProvider', () => { const exchangeClient = createMockExchangeClient({ userSetAbstraction: jest.fn(signThroughSdkWallet(USER_SIGNED_PAYLOAD)), agentSetAbstraction: jest.fn(signThroughSdkWallet(L1_PAYLOAD)), + setReferrer: jest.fn(signThroughSdkWallet(L1_PAYLOAD)), }); mockClientService.getExchangeClient = jest .fn() @@ -2436,19 +2461,19 @@ describe('HyperLiquidProvider', () => { signer: { isHardwareWallet: () => true }, }); await accountSignerProvider.getMarketDataWithPrices(); - const referral = mockClientService.getInfoClient().referral; - const maxBuilderFee = mockClientService.getInfoClient().maxBuilderFee; const result = await accountSignerProvider.prepareTradingWallet(); expect(result).toStrictEqual({ ready: true }); expect(exchangeClient.userSetAbstraction).toHaveBeenCalledTimes(1); - expect(accountSigner.signTypedData).toHaveBeenCalledWith( - ACCOUNT_ADDRESS, - USER_SIGNED_PAYLOAD, - ); - expect(maxBuilderFee).toHaveBeenCalled(); - expect(referral).toHaveBeenCalled(); + expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], + [ACCOUNT_ADDRESS, L1_PAYLOAD], + ]); + expect( + mockClientService.getInfoClient().maxBuilderFee, + ).toHaveBeenCalled(); }); it('signs nothing more when called again', async () => { @@ -2483,6 +2508,33 @@ describe('HyperLiquidProvider', () => { ); }); + it('reports ready after the user declines the migration, since it is not asked again', async () => { + const { accountSignerProvider, accountSigner } = + createAccountSignerProvider({ + signer: { isHardwareWallet: () => true }, + }); + accountSigner.signTypedData.mockImplementation( + async (_address: string, payload: PerpsTypedDataPayload) => { + if (payload === USER_SIGNED_PAYLOAD) { + throw new Error('User rejected the request.'); + } + return SIGNATURE; + }, + ); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: true }); + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).toHaveBeenCalledWith( + 'mainnet', + ACCOUNT_ADDRESS, + expect.objectContaining({ attempted: true, enabled: false }), + ); + }); + it('reports not ready when the builder fee approval is rejected', async () => { const { accountSignerProvider, exchangeClient } = createAccountSignerProvider({ abstraction: 'unifiedAccount' }); @@ -2510,13 +2562,26 @@ describe('HyperLiquidProvider', () => { error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); }); + + it('reports the error when the clients cannot initialize', async () => { + const { accountSignerProvider, initialize } = + createAccountSignerProvider(); + initialize.mockRejectedValue(new Error('transport unavailable')); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: 'transport unavailable', + }); + }); }); describe('with an agent', () => { - const agentOptions = (getAgentSigner: jest.Mock): Options => ({ - abstraction: 'default', - getAgentSigner, - }); + const MAINNET_ACCOUNT = { + mainAddress: ACCOUNT_ADDRESS, + isTestnet: false, + } as const; it('resolves the agent at the first L1 signature and signs with it', async () => { const getAgentSigner = jest.fn(); @@ -2525,20 +2590,64 @@ describe('HyperLiquidProvider', () => { accountSigner, agentSigner, initialize, - } = createAccountSignerProvider(agentOptions(getAgentSigner)); + } = createAccountSignerProvider({ + abstraction: 'default', + getAgentSigner, + }); getAgentSigner.mockResolvedValue(agentSigner); await accountSignerProvider.getMarketDataWithPrices(); - expect(getAgentSigner).toHaveBeenCalledWith({ - mainAddress: ACCOUNT_ADDRESS, - isTestnet: false, - }); + expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); expect(initialize.mock.calls[0][0].address).toBe(ACCOUNT_ADDRESS); - expect(agentSigner.signTypedData).toHaveBeenCalledWith(L1_PAYLOAD); + 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(); + rememberMigration(); + await accountSignerProvider.prepareTradingWallet(); + + // Migration at connect, then referral setup. + expect(getAgentSigner).toHaveBeenCalledTimes(1); + expect(agentSigner.signTypedData).toHaveBeenCalledTimes(2); + }); + + 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(); + rememberMigration(); + await accountSignerProvider.prepareTradingWallet(); + + expect(getAgentSigner).toHaveBeenCalledTimes(2); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [ACCOUNT_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 } = createAccountSignerProvider({ @@ -2559,98 +2668,146 @@ describe('HyperLiquidProvider', () => { await accountSignerProvider.getMarketDataWithPrices(); - expect(accountSigner.signTypedData).toHaveBeenCalledWith( - ACCOUNT_ADDRESS, - USER_SIGNED_PAYLOAD, - ); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], + ]); expect(agentSigner.signTypedData).not.toHaveBeenCalled(); expect(getAgentSigner).not.toHaveBeenCalled(); }); - it('fails only the L1 action and asks again when getAgentSigner rejects', async () => { + 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 } = - createAccountSignerProvider(agentOptions(getAgentSigner)); + createAccountSignerProvider({ + abstraction: 'default', + getAgentSigner, + }); const marketData = await accountSignerProvider.getMarketDataWithPrices(); - await accountSignerProvider.prepareTradingWallet(); + const result = await accountSignerProvider.prepareTradingWallet(); - expect(marketData.length).toBeGreaterThan(0); - expect(exchangeClient.agentSetAbstraction).toHaveBeenCalled(); - expect(accountSigner.signTypedData).not.toHaveBeenCalledWith( - ACCOUNT_ADDRESS, - L1_PAYLOAD, - ); - // Each L1 action (migration, then referral setup) asks again. - expect(getAgentSigner.mock.calls.length).toBeGreaterThan(1); - expect(mockPlatformDependencies.logger.error).toHaveBeenCalledWith( - expect.objectContaining({ message: 'agent store unavailable' }), - expect.anything(), - ); + expect(marketData).toHaveLength(2); + // 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).toHaveBeenCalledTimes(3); + expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); + expect(getAgentSigner).toHaveBeenCalledTimes(4); + expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ ready: false }); }); - it('signs with an agent set through setAgentSigner', async () => { + it('signs with the agent set for the selected account', async () => { const { accountSignerProvider, agentSigner } = createAccountSignerProvider({ abstraction: 'default' }); - await accountSignerProvider.setAgentSigner(agentSigner); + accountSignerProvider.setAgentSigner(MAINNET_ACCOUNT, agentSigner); await accountSignerProvider.getMarketDataWithPrices(); - expect(agentSigner.signTypedData).toHaveBeenCalledWith(L1_PAYLOAD); + expect(agentSigner.signTypedData.mock.calls).toStrictEqual([ + [L1_PAYLOAD], + ]); }); - it('never signs for another account with the agent it was set for', async () => { + it('binds the agent to the account it names, not the selected one', async () => { const { accountSignerProvider, accountSigner, agentSigner, selectAccount, } = createAccountSignerProvider({ abstraction: 'default' }); - await accountSignerProvider.setAgentSigner(agentSigner); - selectAccount(OTHER_ACCOUNT_ADDRESS); + accountSignerProvider.setAgentSigner( + { mainAddress: OTHER_ACCOUNT_ADDRESS, isTestnet: false }, + agentSigner, + ); await accountSignerProvider.getMarketDataWithPrices(); + selectAccount(OTHER_ACCOUNT_ADDRESS); + await accountSignerProvider.prepareTradingWallet(); - expect(agentSigner.signTypedData).not.toHaveBeenCalled(); - expect(accountSigner.signTypedData).toHaveBeenCalledWith( - OTHER_ACCOUNT_ADDRESS, + expect(accountSigner.signTypedData.mock.calls[0]).toStrictEqual([ + ACCOUNT_ADDRESS, L1_PAYLOAD, - ); + ]); + expect(agentSigner.signTypedData).toHaveBeenCalledWith(L1_PAYLOAD); }); it('never signs on another network with the agent it was set for', async () => { const { accountSignerProvider, accountSigner, agentSigner } = createAccountSignerProvider({ abstraction: 'default' }); - await accountSignerProvider.setAgentSigner(agentSigner); + accountSignerProvider.setAgentSigner(MAINNET_ACCOUNT, agentSigner); mockClientService.isTestnetMode.mockReturnValue(true); await accountSignerProvider.getMarketDataWithPrices(); expect(agentSigner.signTypedData).not.toHaveBeenCalled(); - expect(accountSigner.signTypedData).toHaveBeenCalledWith( - ACCOUNT_ADDRESS, - L1_PAYLOAD, - ); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [ACCOUNT_ADDRESS, L1_PAYLOAD], + ]); }); - it('signs with the main account after setAgentSigner(null) without asking getAgentSigner', async () => { + it('keeps setAgentSigner(null) for later L1 actions without asking getAgentSigner', async () => { const getAgentSigner = jest.fn(); const { accountSignerProvider, accountSigner, agentSigner } = - createAccountSignerProvider(agentOptions(getAgentSigner)); + createAccountSignerProvider({ + abstraction: 'default', + getAgentSigner, + }); getAgentSigner.mockResolvedValue(agentSigner); - await accountSignerProvider.setAgentSigner(null); + accountSignerProvider.setAgentSigner(MAINNET_ACCOUNT, null); await accountSignerProvider.getMarketDataWithPrices(); + rememberMigration(); + await accountSignerProvider.prepareTradingWallet(); expect(getAgentSigner).not.toHaveBeenCalled(); expect(agentSigner.signTypedData).not.toHaveBeenCalled(); - expect(accountSigner.signTypedData).toHaveBeenCalledWith( - ACCOUNT_ADDRESS, - L1_PAYLOAD, - ); + expect(accountSigner.signTypedData).toHaveBeenCalledTimes(2); + }); + + it('lets a clear made while getAgentSigner is pending win', async () => { + const answer = createDeferred(); + const getAgentSigner = jest.fn(() => answer.promise); + const { accountSignerProvider, accountSigner, agentSigner } = + createAccountSignerProvider({ + abstraction: 'default', + getAgentSigner, + }); + + const reading = accountSignerProvider.getMarketDataWithPrices(); + await waitFor(() => getAgentSigner.mock.calls.length === 1); + accountSignerProvider.setAgentSigner(MAINNET_ACCOUNT, null); + answer.resolve(agentSigner); + await reading; + + expect(agentSigner.signTypedData).not.toHaveBeenCalled(); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [ACCOUNT_ADDRESS, L1_PAYLOAD], + ]); + }); + + it('keeps an agent set while a failing getAgentSigner answer is pending', async () => { + const answer = createDeferred(); + const getAgentSigner = jest.fn(() => answer.promise); + const { accountSignerProvider, agentSigner } = + createAccountSignerProvider({ + abstraction: 'default', + getAgentSigner, + }); + + const reading = accountSignerProvider.getMarketDataWithPrices(); + await waitFor(() => getAgentSigner.mock.calls.length === 1); + accountSignerProvider.setAgentSigner(MAINNET_ACCOUNT, agentSigner); + answer.reject(new Error('agent store unavailable')); + await reading; + rememberMigration(); + await accountSignerProvider.prepareTradingWallet(); + + expect(getAgentSigner).toHaveBeenCalledTimes(1); + expect(agentSigner.signTypedData).toHaveBeenCalledTimes(2); }); }); }); 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 index 81e8aa383da..019ac8cddd1 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts @@ -4,11 +4,15 @@ import { signL1Action, signUserSignedAction, } from '@nktkas/hyperliquid/signing'; +import { recoverTypedDataAddress } from 'viem'; import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; -import type { PerpsTypedDataPayload } from '../../../src/types/index.js'; +import type { + PerpsAgentSigner, + PerpsTypedDataPayload, +} from '../../../src/types/index.js'; import { createKeyringlessMessenger, createMockEvmAccount, @@ -186,11 +190,6 @@ describe('HyperLiquidWalletService wallet adapter with an agent', () => { signPersonalMessage: jest.fn(), }; const { messenger, call, selectAccount } = createKeyringlessMessenger(); - const service = new HyperLiquidWalletService( - { ...createMockInfrastructure(), accountSigner: signer }, - messenger, - { isTestnet: true }, - ); const agentSign = jest.fn().mockResolvedValue(AGENT_SIGNATURE); const resolveAgent = jest .fn() @@ -199,8 +198,13 @@ describe('HyperLiquidWalletService wallet adapter with an agent', () => { ? { address: AGENT_ADDRESS, signTypedData: agentSign } : null, ); + const service = new HyperLiquidWalletService( + { ...createMockInfrastructure(), accountSigner: signer }, + messenger, + { isTestnet: true, resolveAgent }, + ); return { - adapter: service.createWalletAdapter(resolveAgent), + adapter: service.createWalletAdapter(), resolveAgent, agentSign, mainSign: signer.signTypedData, @@ -281,47 +285,66 @@ describe('HyperLiquidWalletService wallet adapter with an agent', () => { }); describe('HyperLiquidWalletService wallet adapter with the HyperLiquid SDK', () => { - // Drive the adapter through the SDK's own signing functions so the routing - // holds for the payloads the SDK actually builds and for the SDK's wallet - // detection, not just for hand-written payloads. + // 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. const mainAccount = privateKeyToAccount(generatePrivateKey()); const agentAccount = privateKeyToAccount(generatePrivateKey()); function buildSdkAdapter(): { adapter: ReturnType; - mainSign: jest.SpyInstance; - agentSign: jest.SpyInstance; + signatures: { payload: PerpsTypedDataPayload; signature: Hex }[]; } { const { messenger, selectAccount } = createKeyringlessMessenger(); selectAccount(mainAccount.address); - const mainSign = jest.spyOn(mainAccount, 'signTypedData'); - const agentSign = jest.spyOn(agentAccount, 'signTypedData'); + 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 mainAccount.signTypedData(payload), + await recordSignature(mainAccount)(payload), signPersonalMessage: async (_address, message): Promise => await mainAccount.signMessage({ message }), }, }, messenger, - { isTestnet: true }, + { + isTestnet: true, + resolveAgent: async (): Promise => ({ + address: agentAccount.address, + signTypedData: recordSignature(agentAccount), + }), + }, ); - return { - adapter: service.createWalletAdapter(async () => agentAccount), - mainSign, - agentSign, - }; + return { adapter: service.createWalletAdapter(), signatures }; } - afterEach(() => { - jest.restoreAllMocks(); - }); + 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, mainSign, agentSign } = buildSdkAdapter(); + const { adapter, signatures } = buildSdkAdapter(); await signL1Action({ wallet: adapter, @@ -330,12 +353,13 @@ describe('HyperLiquidWalletService wallet adapter with the HyperLiquid SDK', () isTestnet: true, }); - expect(agentSign).toHaveBeenCalledTimes(1); - expect(mainSign).not.toHaveBeenCalled(); + expect(signatures).toHaveLength(1); + expect(signatures[0].payload.types).toHaveProperty('EIP712Domain'); + expect(await recoverSigner(signatures[0])).toBe(agentAccount.address); }); it('signs an SDK user-signed action with the main account', async () => { - const { adapter, mainSign, agentSign } = buildSdkAdapter(); + const { adapter, signatures } = buildSdkAdapter(); await signUserSignedAction({ wallet: adapter, @@ -350,7 +374,7 @@ describe('HyperLiquidWalletService wallet adapter with the HyperLiquid SDK', () types: ApproveBuilderFeeTypes, }); - expect(mainSign).toHaveBeenCalledTimes(1); - expect(agentSign).not.toHaveBeenCalled(); + expect(signatures).toHaveLength(1); + expect(await recoverSigner(signatures[0])).toBe(mainAccount.address); }); }); From 5f3e2c4101ce7c1a86e17f6fc3cb9de76e072295 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 28 Sep 2026 22:48:34 +0800 Subject: [PATCH 07/33] fix(perps-controller): add agent unbinding and harden readiness - Add PerpsController:clearAgentSigners (and optional PerpsProvider.setAgentSigner / clearAgentSigners) so a host can stop agent signing on lock and let getAgentSigner answer again after unlock; setAgentSigner(account, null) pins the main account until then. - Treat a failed or synchronously throwing getAgentSigner as retryable (AgentSignerUnavailableError), so the referral write is retried instead of being recorded as failed for the session. - prepareTradingWallet reports KEYRING_LOCKED whenever the main-account signer is not ready and logs unexpected failures; Lighter prepares its venue-key registration and the aggregated provider prepares every provider. - Export the L1 action constants from the package root. Co-authored-by: Monte Lai --- packages/perps-controller/CHANGELOG.md | 8 +- .../PerpsController-method-action-types.ts | 33 +++- .../perps-controller/src/PerpsController.ts | 48 +++-- .../src/constants/hyperLiquidConfig.ts | 12 +- packages/perps-controller/src/index.ts | 3 + .../src/providers/AggregatedPerpsProvider.ts | 16 +- .../src/providers/HyperLiquidProvider.ts | 106 ++++++++--- .../src/providers/LighterProvider.ts | 10 + packages/perps-controller/src/types/index.ts | 26 ++- .../PerpsController.providers-cache.test.ts | 14 +- .../providers/AggregatedPerpsProvider.test.ts | 24 ++- .../HyperLiquidProvider.account-mode.test.ts | 176 ++++++++++++++++-- .../LighterProvider.account-signer.test.ts | 16 ++ ...LiquidWalletService.account-signer.test.ts | 105 +++++++++-- 14 files changed, 491 insertions(+), 106 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 5ed9a052165..60ea2014917 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -19,11 +19,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `isHardwareWallet()` 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 - 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 it returns is kept for the provider's lifetime, while `null` and failures are asked again at the next L1 action - - Add `PerpsController:setAgentSigner(account, agentSigner)` (`PerpsControllerSetAgentSignerAction`) to set or clear (`null`) the agent for an explicit main account and network + - 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` + - 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.setAgentSigner` and `PerpsProvider.clearAgentSigners`, implemented by the HyperLiquid provider - An agent only ever signs for the main account and network it was set or resolved for, and user-signed actions (builder fee, withdraw, account migration, ...) 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 deferred trading-readiness steps (account migration, builder fee and referral setup) before the first order - - Resolves a `ReadyToTradeResult` that is `ready: true` once none of these steps will ask the main account to sign again before the first order +- Add `PerpsController:prepareTradingWallet` (`PerpsControllerPrepareTradingWalletAction`) and optional `PerpsProvider.prepareTradingWallet` to run setup that needs a main-account signature before the first order: account migration, builder fee and referral on HyperLiquid, venue-key registration on Lighter + - Resolves a `ReadyToTradeResult` that is `ready: true` once the main-account signer is ready and none of these steps will ask it to sign again before the first order; the aggregated provider prepares every provider in turn ### Removed diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index 94b04b5095b..8f690da91bb 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -907,24 +907,36 @@ export type PerpsControllerCalculateFeesAction = { /** * Sign HyperLiquid L1 actions (orders, cancels, leverage, ...) for a main - * account on a network with an approved agent, or with the main account - * when `agentSigner` is null. User-signed actions stay on the main account. - * The agent is never used for another account or network. The binding lasts - * for the lifetime of the HyperLiquid provider instance; initialization and - * re-initialization (for example a network toggle) create a new one, which - * asks `providerCredentials.hyperliquid.getAgentSigner` again. To stop agent - * signing everywhere, for example when the wallet locks, clear each account - * set here and have `getAgentSigner` return null. Requires an initialized - * controller. + * 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. + * Bindings last for the lifetime of the HyperLiquid provider instance; + * initialization and re-initialization (a network toggle, or a client + * reconnecting after an account switch) create a new one. Requires an + * initialized controller. * * @param account - The main account and network the agent is approved for. - * @param agentSigner - The host-owned agent signer, or null to clear it. + * @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. Call it when + * the wallet locks (with `getAgentSigner` returning null while locked) and + * nothing signs with an agent until it returns one again. Requires an + * initialized controller. + */ +export type PerpsControllerClearAgentSignersAction = { + type: `PerpsController:clearAgentSigners`; + handler: PerpsController['clearAgentSigners']; +}; + /** * Run the active provider's deferred trading-readiness steps (account * migration, builder fee and referral setup) ahead of the first order, so a @@ -1489,6 +1501,7 @@ export type PerpsControllerMethodActions = | PerpsControllerSetLiveDataConfigAction | PerpsControllerCalculateFeesAction | PerpsControllerSetAgentSignerAction + | PerpsControllerClearAgentSignersAction | PerpsControllerPrepareTradingWalletAction | PerpsControllerApproveSubscriptionBuilderFeeAction | PerpsControllerInvalidateSubscriptionBenefitsAction diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 8a5be8f44a8..d135612b7b6 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -899,6 +899,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'calculateMaintenanceMargin', 'cancelOrder', 'cancelOrders', + 'clearAgentSigners', 'clearAttributionContext', 'clearDepositResult', 'clearPendingTradeConfiguration', @@ -5862,29 +5863,52 @@ export class PerpsController extends BaseController< /** * Sign HyperLiquid L1 actions (orders, cancels, leverage, ...) for a main - * account on a network with an approved agent, or with the main account - * when `agentSigner` is null. User-signed actions stay on the main account. - * The agent is never used for another account or network. The binding lasts - * for the lifetime of the HyperLiquid provider instance; initialization and - * re-initialization (for example a network toggle) create a new one, which - * asks `providerCredentials.hyperliquid.getAgentSigner` again. To stop agent - * signing everywhere, for example when the wallet locks, clear each account - * set here and have `getAgentSigner` return null. Requires an initialized - * controller. + * 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. + * Bindings last for the lifetime of the HyperLiquid provider instance; + * initialization and re-initialization (a network toggle, or a client + * reconnecting after an account switch) create a new one. Requires an + * initialized controller. * * @param account - The main account and network the agent is approved for. - * @param agentSigner - The host-owned agent signer, or null to clear it. + * @param agentSigner - The host-owned agent signer, or null to pin the main + * account. */ async setAgentSigner( account: PerpsAgentAccount, agentSigner: PerpsAgentSigner | null, ): Promise { + (await this.#getAgentSignerProvider()).setAgentSigner?.( + account, + agentSigner, + ); + } + + /** + * Forget every HyperLiquid agent, set or resolved, so the next L1 action + * asks `providerCredentials.hyperliquid.getAgentSigner` again. Call it when + * the wallet locks (with `getAgentSigner` returning null while locked) and + * nothing signs with an agent until it returns one again. Requires an + * initialized controller. + */ + async clearAgentSigners(): Promise { + (await this.#getAgentSignerProvider()).clearAgentSigners?.(); + } + + /** + * Resolve the HyperLiquid provider, which owns the agent bindings. + * + * @returns The HyperLiquid provider. + */ + async #getAgentSignerProvider(): Promise { await this.#getActiveProviderWhenReady(); const provider = this.providers.get('hyperliquid'); - if (!(provider instanceof HyperLiquidProvider)) { + if (!provider?.setAgentSigner) { throw new Error(PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE); } - provider.setAgentSigner(account, agentSigner); + return provider; } /** diff --git a/packages/perps-controller/src/constants/hyperLiquidConfig.ts b/packages/perps-controller/src/constants/hyperLiquidConfig.ts index 6df5c70335d..426b1843e98 100644 --- a/packages/perps-controller/src/constants/hyperLiquidConfig.ts +++ b/packages/perps-controller/src/constants/hyperLiquidConfig.ts @@ -182,10 +182,16 @@ export const HIP3_FEE_CONFIG = { FeeMultiplier: 2, } as const; -// The SDK signs every L1 action (orders, cancels, leverage, ...) as this -// EIP-712 primary type over this domain. Only these may be signed by an agent; -// every other request is a user-signed action for the main account. +/** + * 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; diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index fbe4890f216..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, @@ -463,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, diff --git a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts index 6b8aae7d8f0..e2bb38529b8 100644 --- a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts +++ b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts @@ -1048,14 +1048,20 @@ export class AggregatedPerpsProvider implements PerpsProvider { } /** - * Prepare the default provider only; other providers keep their setup - * signatures (such as Lighter's venue-key registration) at order time. + * Prepare every provider in turn, so a hardware wallet sees one prompt at a + * time. * - * @returns The default provider's readiness. + * @returns The first provider readiness that is not ready, else ready. */ async prepareTradingWallet(): Promise { - const provider = this.#getDefaultProvider(); - return (await provider.prepareTradingWallet?.()) ?? { ready: true }; + let notReady: ReadyToTradeResult | undefined; + for (const [, provider] of this.#getActiveProviders()) { + const result = await provider.prepareTradingWallet?.(); + if (result && !result.ready) { + notReady ??= result; + } + } + return notReady ?? { ready: true }; } async disconnect(): Promise { diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 8557d65fa25..4530d4667ea 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -824,6 +824,37 @@ type ChaseOrderMaxDistanceReachedHandler = ( event: ChaseOrderMaxDistanceReached, ) => void; +/** + * The host's `getAgentSigner` failed. Like a locked keyring, it is retryable: + * the next L1 action asks again. + */ +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 could not be resolved. + */ +function isAgentSignerUnavailableError(error: unknown): boolean { + let current: unknown = error; + const seen = new Set(); + while (current instanceof Error && !seen.has(current)) { + if (current instanceof AgentSignerUnavailableError) { + return true; + } + seen.add(current); + current = current.cause; + } + return false; +} + type HyperLiquidProviderOptions = { isTestnet?: boolean; hip3Enabled?: boolean; @@ -2077,10 +2108,13 @@ export class HyperLiquidProvider implements PerpsProvider { if (!this.#getAgentSigner) { return null; } - entry = { - agentSigner: this.#getAgentSigner(account), - fromResolver: true, - }; + let answer: Promise; + try { + answer = this.#getAgentSigner(account); + } catch (error) { + answer = Promise.reject(error); + } + entry = { agentSigner: answer, fromResolver: true }; this.#agentSigners.set(key, entry); } @@ -2096,7 +2130,7 @@ export class HyperLiquidProvider implements PerpsProvider { this.#deps.debugLogger.log('HyperLiquidProvider: getAgentSigner failed', { error: ensureError(error, 'resolveAgentSigner').message, }); - throw error; + throw new AgentSignerUnavailableError(error); } const latest = this.#agentSigners.get(key); @@ -14160,9 +14194,10 @@ export class HyperLiquidProvider implements PerpsProvider { /** * Sign L1 actions for a main account on a network with an approved agent, - * or with the main account when `agentSigner` is null. The binding is - * explicit so an account switch or re-initialization that is still pending - * cannot attach the agent to another account. + * or pin them to the main account when `agentSigner` is null (the resolver + * is then not asked for that account and network until clearAgentSigners). + * The binding is explicit so an account switch or re-initialization that is + * still pending cannot attach the agent to another account. * * @param account - The main account and network the agent is approved for. * @param agentSigner - The host-owned agent signer, or null to clear it. @@ -14177,42 +14212,51 @@ export class HyperLiquidProvider implements PerpsProvider { }); } + /** + * Forget every agent, whether set through setAgentSigner or resolved, so the + * next L1 action asks `getAgentSigner` again. Call it when the wallet locks. + */ + clearAgentSigners(): void { + this.#agentSigners.clear(); + } + /** * Run the deferred trading-readiness steps (account migration with user * signing, builder fee and referral setup) ahead of the first order, so a * hardware wallet signs them in one guided session instead of at order * time. Results are cached, so an already-ready account signs nothing. * - * @returns `ready: true` when none of these steps will ask the main account - * to sign again before the first order; a step the user declined counts, - * because the order path does not ask again either. `ready: false` carries - * `KEYRING_LOCKED` when the signer is not ready, and no error when a step - * will retry (a rejected builder fee, a transient failure, or a wallet with - * no HyperLiquid account yet). + * @returns `ready: true` when the main-account signer is ready and none of + * these steps will ask it to sign again before the first order; a step the + * user declined counts, because the order path does not ask again either. + * `ready: false` carries `KEYRING_LOCKED` when the signer is not ready, the + * error when the steps could not run, and no error when a step will retry + * (a rejected builder fee, a transient failure, or a wallet with no + * HyperLiquid account yet). */ async prepareTradingWallet(): Promise { try { const { network, userAddress } = await this.#ensureReadyForTrading({ requiresBuilderFee: true, }); + if (!this.#walletService.isKeyringUnlocked()) { + return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; + } const ready = this.#tradingSetupComplete && !this.#unifiedAccountSetupNeedsRetry && this.#builderFeeCheckCache.has(this.#getCacheKey(network, userAddress)); - if (ready) { - return { ready: true }; - } - return this.#walletService.isKeyringUnlocked() - ? { ready: false } - : { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; + return { ready }; } catch (error) { - return { - ready: false, - error: - error instanceof Error - ? error.message - : PERPS_ERROR_CODES.UNKNOWN_ERROR, - }; + const caughtError = ensureError( + error, + 'HyperLiquidProvider.prepareTradingWallet', + ); + this.#deps.logger.error( + caughtError, + this.#getErrorContext('prepareTradingWallet'), + ); + return { ready: false, error: caughtError.message }; } } @@ -15425,6 +15469,14 @@ export class HyperLiquidProvider implements PerpsProvider { return; } + if (isAgentSignerUnavailableError(error)) { + this.#deps.debugLogger.log( + '[ensureReferralSet] Agent signer unavailable, will retry later', + ); + completeInFlight(); + return; + } + // Safety net: wallet looked registered but the SDK still rejects with // "User or API Wallet 0x... does not exist." Do not forward to Sentry. // The walletRegistered cache stores positive observations only, so no diff --git a/packages/perps-controller/src/providers/LighterProvider.ts b/packages/perps-controller/src/providers/LighterProvider.ts index 05723297b6f..90818fa8d29 100644 --- a/packages/perps-controller/src/providers/LighterProvider.ts +++ b/packages/perps-controller/src/providers/LighterProvider.ts @@ -1276,6 +1276,16 @@ export class LighterProvider implements PerpsProvider { }; } + /** + * Register the venue key ahead of the first order, so its main-account + * `personal_sign` surfaces in a guided session instead of at order time. + * + * @returns The readiness after registration. + */ + async prepareTradingWallet(): Promise { + return await this.isReadyToTrade(); + } + async isReadyToTrade(): Promise { try { if (!this.#signerBridge) { diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 919d76e88b4..cccb796d065 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -2143,14 +2143,28 @@ export type PerpsProvider = { initialize(): Promise; isReadyToTrade(): Promise; /** - * Run the deferred trading-readiness steps (account migration, builder fee - * and referral setup) ahead of the first order, so any main-account - * signature surfaces in a guided session instead of at order time. - * Resolves `ready: true` when none of these steps will ask the main account - * to sign again before the first order. Providers without deferred setup - * omit it. + * Run the deferred setup that needs a main-account signature (for example + * account migration, builder fee, referral or venue-key registration) ahead + * of the first order, so the signatures surface in a guided session instead + * of at order time. Resolves `ready: true` when none of these steps will ask + * the main account to sign again before the first order. Providers without + * such setup omit it. */ prepareTradingWallet?(): Promise; + /** + * Sign L1 actions for a main account on a network with an approved agent, + * or pin them to the main account with null. Providers without agents omit + * it. + */ + setAgentSigner?( + account: PerpsAgentAccount, + agentSigner: PerpsAgentSigner | null, + ): void; + /** + * Forget every agent so the next L1 action asks for one 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 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 4da998a4f5f..bc960671ba0 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -968,7 +968,6 @@ describe('PerpsController', () => { address: '0x00000000000000000000000000000000000a9e17' as const, signTypedData: jest.fn(), }; - Object.setPrototypeOf(mockProvider, HyperLiquidProvider.prototype); mockProvider.setAgentSigner = jest.fn(); await controller.init(); @@ -987,7 +986,7 @@ describe('PerpsController', () => { ); }); - it('setAgentSigner rejects when the hyperliquid provider is not a HyperLiquidProvider', async () => { + it('setAgentSigner rejects when the hyperliquid provider has no agent support', async () => { await controller.init(); await expect(controller.setAgentSigner(account, null)).rejects.toThrow( @@ -996,7 +995,6 @@ describe('PerpsController', () => { }); it('setAgentSigner rejects before the controller is initialized', async () => { - Object.setPrototypeOf(mockProvider, HyperLiquidProvider.prototype); mockProvider.setAgentSigner = jest.fn(); await expect(controller.setAgentSigner(account, null)).rejects.toThrow( @@ -1005,6 +1003,16 @@ describe('PerpsController', () => { expect(mockProvider.setAgentSigner).not.toHaveBeenCalled(); }); + it('clearAgentSigners forwards to the HyperLiquid provider', async () => { + mockProvider.setAgentSigner = jest.fn(); + mockProvider.clearAgentSigners = jest.fn(); + await controller.init(); + + await controller.clearAgentSigners(); + + expect(mockProvider.clearAgentSigners).toHaveBeenCalledTimes(1); + }); + it("prepareTradingWallet returns the active provider's readiness", async () => { mockProvider.prepareTradingWallet = jest .fn() diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts index 7223d40f668..8e7ec7e094a 100644 --- a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -1060,19 +1060,31 @@ describe('AggregatedPerpsProvider', () => { expect(mockHLProvider.isReadyToTrade).toHaveBeenCalled(); }); - it('delegates prepareTradingWallet to default provider', async () => { - const prepareTradingWallet = jest.fn().mockResolvedValue({ + 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: 'KEYRING_LOCKED', + }); + Object.assign(mockHLProvider, { + prepareTradingWallet: prepareHyperLiquid, + }); + Object.assign(mockLighterProvider, { + prepareTradingWallet: prepareLighter, }); - Object.assign(mockHLProvider, { prepareTradingWallet }); const result = await aggregatedProvider.prepareTradingWallet(); - expect(result).toStrictEqual({ ready: false }); - expect(prepareTradingWallet).toHaveBeenCalledTimes(1); + expect(result).toStrictEqual({ ready: false, error: 'KEYRING_LOCKED' }); + expect(prepareHyperLiquid).toHaveBeenCalledTimes(1); + expect(prepareLighter).toHaveBeenCalledTimes(1); }); - it('reports ready when the default provider has no deferred setup', async () => { + it('reports ready when every provider is ready or has no deferred setup', async () => { + Object.assign(mockHLProvider, { + prepareTradingWallet: jest.fn().mockResolvedValue({ ready: true }), + }); + const result = await aggregatedProvider.prepareTradingWallet(); expect(result).toStrictEqual({ ready: 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 e843a044bb5..e9e9e3298a5 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 @@ -15,12 +15,16 @@ import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClien import type { HyperLiquidWalletParams } from '../../../src/services/HyperLiquidClientService.js'; import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService.js'; import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; -import { TradingReadinessCache } from '../../../src/services/TradingReadinessCache.js'; +import { + PerpsSigningCache, + TradingReadinessCache, +} from '../../../src/services/TradingReadinessCache.js'; import type { ClosePositionParams, DepositParams, Order, PerpsAccountSigner, + PerpsAgentSigner, PerpsPlatformDependencies, PerpsTypedDataPayload, LiveDataConfig, @@ -2259,7 +2263,7 @@ describe('HyperLiquidProvider', () => { // The wallet service is real and the messenger has no KeyringController, // 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 (or swapped) it with. + // through the wallet the provider initialized it with. const { HyperLiquidWalletService: RealHyperLiquidWalletService } = jest.requireActual< typeof import('../../../src/services/HyperLiquidWalletService.js') @@ -2313,11 +2317,6 @@ describe('HyperLiquidProvider', () => { message: { source: 'a', connectionId: `0x${'22'.repeat(32)}` }, }; - const agentSignerShape = { - address: AGENT_ADDRESS, - signTypedData: jest.fn(), - }; - // The global readiness cache is mocked in this suite; replay the migration // result the real cache would keep so later calls do not migrate again. function rememberMigration(): void { @@ -2330,10 +2329,24 @@ describe('HyperLiquidProvider', () => { }); } - async function waitFor(condition: () => boolean): Promise { - while (!condition()) { - await new Promise((resolve) => setImmediate(resolve)); - } + /** + * 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. + */ + 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 }; } type Options = { @@ -2563,7 +2576,7 @@ describe('HyperLiquidProvider', () => { }); }); - it('reports the error when the clients cannot initialize', async () => { + it('reports and logs the error when the clients cannot initialize', async () => { const { accountSignerProvider, initialize } = createAccountSignerProvider(); initialize.mockRejectedValue(new Error('transport unavailable')); @@ -2574,6 +2587,28 @@ describe('HyperLiquidProvider', () => { ready: false, error: 'transport unavailable', }); + expect(mockPlatformDependencies.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ message: 'transport unavailable' }), + expect.anything(), + ); + }); + + it('reports KEYRING_LOCKED when the signer locks 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, + }); }); }); @@ -2749,7 +2784,7 @@ describe('HyperLiquidProvider', () => { ]); }); - it('keeps setAgentSigner(null) for later L1 actions without asking getAgentSigner', async () => { + it('pins the main account with setAgentSigner(null) without asking getAgentSigner', async () => { const getAgentSigner = jest.fn(); const { accountSignerProvider, accountSigner, agentSigner } = createAccountSignerProvider({ @@ -2768,9 +2803,8 @@ describe('HyperLiquidProvider', () => { expect(accountSigner.signTypedData).toHaveBeenCalledTimes(2); }); - it('lets a clear made while getAgentSigner is pending win', async () => { - const answer = createDeferred(); - const getAgentSigner = jest.fn(() => answer.promise); + it('lets a pin made while getAgentSigner is pending win', async () => { + const { getAgentSigner, answer, asked } = createPendingResolver(); const { accountSignerProvider, accountSigner, agentSigner } = createAccountSignerProvider({ abstraction: 'default', @@ -2778,7 +2812,7 @@ describe('HyperLiquidProvider', () => { }); const reading = accountSignerProvider.getMarketDataWithPrices(); - await waitFor(() => getAgentSigner.mock.calls.length === 1); + await asked; accountSignerProvider.setAgentSigner(MAINNET_ACCOUNT, null); answer.resolve(agentSigner); await reading; @@ -2790,8 +2824,7 @@ describe('HyperLiquidProvider', () => { }); it('keeps an agent set while a failing getAgentSigner answer is pending', async () => { - const answer = createDeferred(); - const getAgentSigner = jest.fn(() => answer.promise); + const { getAgentSigner, answer, asked } = createPendingResolver(); const { accountSignerProvider, agentSigner } = createAccountSignerProvider({ abstraction: 'default', @@ -2799,7 +2832,7 @@ describe('HyperLiquidProvider', () => { }); const reading = accountSignerProvider.getMarketDataWithPrices(); - await waitFor(() => getAgentSigner.mock.calls.length === 1); + await asked; accountSignerProvider.setAgentSigner(MAINNET_ACCOUNT, agentSigner); answer.reject(new Error('agent store unavailable')); await reading; @@ -2809,6 +2842,109 @@ describe('HyperLiquidProvider', () => { expect(getAgentSigner).toHaveBeenCalledTimes(1); expect(agentSigner.signTypedData).toHaveBeenCalledTimes(2); }); + + it('asks getAgentSigner with the network of the provider', async () => { + const getAgentSigner = jest.fn().mockResolvedValue(null); + const { accountSignerProvider } = createAccountSignerProvider({ + abstraction: 'default', + getAgentSigner, + }); + mockClientService.isTestnetMode.mockReturnValue(true); + + await accountSignerProvider.getMarketDataWithPrices(); + + expect(getAgentSigner.mock.calls).toStrictEqual([ + [{ mainAddress: ACCOUNT_ADDRESS, isTestnet: true }], + ]); + }); + + it('stops agent signing on lock and resumes after unlock', async () => { + const getAgentSigner = jest.fn(); + const { + accountSignerProvider, + accountSigner, + agentSigner, + initialize, + } = createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + }); + getAgentSigner.mockResolvedValue(agentSigner); + await accountSignerProvider.getMarketDataWithPrices(); + const [[wallet]] = initialize.mock.calls; + + 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).toHaveBeenCalledTimes(2); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [ACCOUNT_ADDRESS, L1_PAYLOAD], + ]); + expect(getAgentSigner).toHaveBeenCalledTimes(3); + }); + + it('clears pins made with setAgentSigner(null)', async () => { + const getAgentSigner = jest.fn(); + const { accountSignerProvider, agentSigner, initialize } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + }); + getAgentSigner.mockResolvedValue(agentSigner); + accountSignerProvider.setAgentSigner(MAINNET_ACCOUNT, null); + await accountSignerProvider.getMarketDataWithPrices(); + const [[wallet]] = initialize.mock.calls; + + accountSignerProvider.clearAgentSigners(); + await wallet.signTypedData(L1_PAYLOAD); + + expect(agentSigner.signTypedData).toHaveBeenCalledWith(L1_PAYLOAD); + }); + + it('retries the referral instead of recording a failure when getAgentSigner rejects', async () => { + const getAgentSigner = jest + .fn() + .mockRejectedValue(new Error('agent store unavailable')); + const { accountSignerProvider, exchangeClient } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + }); + + await accountSignerProvider.prepareTradingWallet(); + + expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); + expect( + (PerpsSigningCache as jest.Mocked) + .setReferral, + ).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, initialize } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + }); + await accountSignerProvider.getMarketDataWithPrices(); + const [[wallet]] = initialize.mock.calls; + + await expect(wallet.signTypedData(L1_PAYLOAD)).rejects.toThrow( + 'HyperLiquid agent signer unavailable', + ); + await expect(wallet.signTypedData(L1_PAYLOAD)).rejects.toThrow( + 'HyperLiquid agent signer unavailable', + ); + expect(getAgentSigner).toHaveBeenCalledTimes(2); + expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + }); }); }); }); 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 index 6b256477e4d..dd2c32a5ae5 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts @@ -185,4 +185,20 @@ describe('LighterProvider with accountSigner', () => { 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.ready).toBe(true); + expect(accountSigner.signPersonalMessage).toHaveBeenCalledWith( + address, + CHANGE_PUB_KEY_BODY, + ); + expect(client.sendTx).toHaveBeenCalledWith( + LIGHTER_TX_TYPE_CHANGE_PUB_KEY, + expect.stringContaining('"changePubKey":true'), + ); + }); }); 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 index 019ac8cddd1..8e6cd9aa151 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts @@ -17,6 +17,7 @@ import { createKeyringlessMessenger, createMockEvmAccount, createMockInfrastructure, + createMockMessenger, keyringCalls, } from '../../helpers/serviceMocks.js'; @@ -284,6 +285,69 @@ describe('HyperLiquidWalletService wallet adapter with an agent', () => { }); }); +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(); + const AGENT_SIGNATURE = `0x${'ef'.repeat(65)}` as const; + const USER_SIGNED_ACTION: PerpsTypedDataPayload = { + ...TYPED_DATA, + domain: { ...TYPED_DATA.domain, name: 'HyperliquidSignTransaction' }, + primaryType: 'HyperliquidTransaction:ApproveBuilderFee', + types: { + 'HyperliquidTransaction:ApproveBuilderFee': [ + { name: 'hyperliquidChain', type: 'string' }, + { name: 'nonce', type: 'uint64' }, + ], + }, + }; + + function buildKeyringAdapter(): { + adapter: ReturnType; + agentSign: jest.Mock; + call: jest.SpyInstance; + } { + const messenger = createMockMessenger(); + const call = jest.spyOn(messenger, 'call'); + const agentSign = jest.fn().mockResolvedValue(AGENT_SIGNATURE); + const service = new HyperLiquidWalletService( + createMockInfrastructure(), + messenger, + { + resolveAgent: async (): Promise => ({ + address: '0x00000000000000000000000000000000000a9e17', + 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(TYPED_DATA); + + expect(signature).toBe(AGENT_SIGNATURE); + expect(agentSign).toHaveBeenCalledWith(TYPED_DATA); + 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_ACTION); + + expect(signature).toBe('0xSignatureResult'); + expect(agentSign).not.toHaveBeenCalled(); + expect(call).toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + { from: mainAddress, data: USER_SIGNED_ACTION }, + 'V4', + ); + }); +}); + describe('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 @@ -291,9 +355,12 @@ describe('HyperLiquidWalletService wallet adapter with the HyperLiquid SDK', () const mainAccount = privateKeyToAccount(generatePrivateKey()); const agentAccount = privateKeyToAccount(generatePrivateKey()); + type RecordedSignature = { payload: PerpsTypedDataPayload; signature: Hex }; + function buildSdkAdapter(): { adapter: ReturnType; - signatures: { payload: PerpsTypedDataPayload; signature: Hex }[]; + signatures: RecordedSignature[]; + agentSignatures: () => Promise; } { const { messenger, selectAccount } = createKeyringlessMessenger(); selectAccount(mainAccount.address); @@ -318,13 +385,22 @@ describe('HyperLiquidWalletService wallet adapter with the HyperLiquid SDK', () messenger, { isTestnet: true, - resolveAgent: async (): Promise => ({ - address: agentAccount.address, - signTypedData: recordSignature(agentAccount), - }), + // A viem local account is a PerpsAgentSigner as it is. + resolveAgent: async (): Promise => agentAccount, }, ); - return { adapter: service.createWalletAdapter(), signatures }; + 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({ @@ -343,8 +419,12 @@ describe('HyperLiquidWalletService wallet adapter with the HyperLiquid SDK', () }); } + afterEach(() => { + jest.restoreAllMocks(); + }); + it('signs an SDK L1 action with the agent', async () => { - const { adapter, signatures } = buildSdkAdapter(); + const { adapter, signatures, agentSignatures } = buildSdkAdapter(); await signL1Action({ wallet: adapter, @@ -353,13 +433,15 @@ describe('HyperLiquidWalletService wallet adapter with the HyperLiquid SDK', () isTestnet: true, }); - expect(signatures).toHaveLength(1); - expect(signatures[0].payload.types).toHaveProperty('EIP712Domain'); - expect(await recoverSigner(signatures[0])).toBe(agentAccount.address); + 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 } = buildSdkAdapter(); + const { adapter, signatures, agentSignatures } = buildSdkAdapter(); await signUserSignedAction({ wallet: adapter, @@ -374,6 +456,7 @@ describe('HyperLiquidWalletService wallet adapter with the HyperLiquid SDK', () types: ApproveBuilderFeeTypes, }); + expect(await agentSignatures()).toHaveLength(0); expect(signatures).toHaveLength(1); expect(await recoverSigner(signatures[0])).toBe(mainAccount.address); }); From 4859114cb5c2fb61eb142f19acdf90f83fbb550c Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 28 Sep 2026 23:04:11 +0800 Subject: [PATCH 08/33] fix(perps-controller): close agent races and readiness gaps - clearAgentSigners discards getAgentSigner answers still pending, and an answer superseded by a clear or a newer binding is resolved again, so a failure always surfaces as the retryable AgentSignerUnavailableError. - clearAgentSigners is a synchronous no-op without an initialized HyperLiquid provider, so a wallet-lock handler can always call it. - A referral write that failed on the agent signer keeps trading setup retryable, so prepareTradingWallet reports not ready and retries it. - Lighter prepareTradingWallet reports KEYRING_LOCKED whenever the main signer is not ready and logs failures; the aggregated provider isolates each provider's failure. Co-authored-by: Monte Lai --- packages/perps-controller/CHANGELOG.md | 2 +- .../PerpsController-method-action-types.ts | 18 ++--- .../perps-controller/src/PerpsController.ts | 35 +++++----- .../src/providers/AggregatedPerpsProvider.ts | 14 +++- .../src/providers/HyperLiquidProvider.ts | 70 ++++++++++++++----- .../src/providers/LighterProvider.ts | 15 +++- .../src/services/LighterWalletService.ts | 16 +++++ .../PerpsController.providers-cache.test.ts | 16 ++++- .../providers/AggregatedPerpsProvider.test.ts | 38 +++++++++- .../HyperLiquidProvider.account-mode.test.ts | 63 +++++++++++++++++ .../LighterProvider.account-signer.test.ts | 43 ++++++++++-- 11 files changed, 276 insertions(+), 54 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 60ea2014917..43b498fa6aa 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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` - 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.setAgentSigner` and `PerpsProvider.clearAgentSigners`, implemented by the HyperLiquid provider - - An agent only ever signs for the main account and network it was set or resolved for, and user-signed actions (builder fee, withdraw, account migration, ...) always stay on the main account; approving the agent remains the client's job + - 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 setup that needs a main-account signature before the first order: account migration, builder fee and referral on HyperLiquid, venue-key registration on Lighter - Resolves a `ReadyToTradeResult` that is `ready: true` once the main-account signer is ready and none of these steps will ask it to sign again before the first order; the aggregated provider prepares every provider in turn diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index 8f690da91bb..c094efdc6cf 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -927,10 +927,11 @@ export type PerpsControllerSetAgentSignerAction = { /** * Forget every HyperLiquid agent, set or resolved, so the next L1 action - * asks `providerCredentials.hyperliquid.getAgentSigner` again. Call it when - * the wallet locks (with `getAgentSigner` returning null while locked) and - * nothing signs with an agent until it returns one again. Requires an - * initialized controller. + * 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. Without an initialized HyperLiquid + * provider there are no agents, so it does nothing. */ export type PerpsControllerClearAgentSignersAction = { type: `PerpsController:clearAgentSigners`; @@ -938,10 +939,11 @@ export type PerpsControllerClearAgentSignersAction = { }; /** - * Run the active provider's deferred trading-readiness steps (account - * migration, builder fee and referral setup) ahead of the first order, so a - * hardware wallet signs them in one guided session, such as agent setup, - * instead of at order time. + * Run the active provider's setup that needs a main-account signature + * (HyperLiquid account migration, builder fee and referral; Lighter + * venue-key registration) ahead of the first order, so a hardware wallet + * signs it in one guided session, such as agent setup, instead of at order + * time. * * @returns `ready: true` when none of these steps will ask the main account * to sign again before the first order; providers without deferred setup diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index d135612b7b6..0d8e8c14e99 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -5880,42 +5880,43 @@ export class PerpsController extends BaseController< account: PerpsAgentAccount, agentSigner: PerpsAgentSigner | null, ): Promise { - (await this.#getAgentSignerProvider()).setAgentSigner?.( - account, - agentSigner, - ); + (await this.#getAgentSignerSetter())(account, agentSigner); } /** * Forget every HyperLiquid agent, set or resolved, so the next L1 action - * asks `providerCredentials.hyperliquid.getAgentSigner` again. Call it when - * the wallet locks (with `getAgentSigner` returning null while locked) and - * nothing signs with an agent until it returns one again. Requires an - * initialized controller. + * 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. Without an initialized HyperLiquid + * provider there are no agents, so it does nothing. */ - async clearAgentSigners(): Promise { - (await this.#getAgentSignerProvider()).clearAgentSigners?.(); + clearAgentSigners(): void { + this.providers.get('hyperliquid')?.clearAgentSigners?.(); } /** * Resolve the HyperLiquid provider, which owns the agent bindings. * - * @returns The HyperLiquid provider. + * @returns The HyperLiquid provider's setAgentSigner. */ - async #getAgentSignerProvider(): Promise { + async #getAgentSignerSetter(): Promise< + NonNullable + > { await this.#getActiveProviderWhenReady(); const provider = this.providers.get('hyperliquid'); if (!provider?.setAgentSigner) { throw new Error(PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE); } - return provider; + return provider.setAgentSigner.bind(provider); } /** - * Run the active provider's deferred trading-readiness steps (account - * migration, builder fee and referral setup) ahead of the first order, so a - * hardware wallet signs them in one guided session, such as agent setup, - * instead of at order time. + * Run the active provider's setup that needs a main-account signature + * (HyperLiquid account migration, builder fee and referral; Lighter + * venue-key registration) ahead of the first order, so a hardware wallet + * signs it in one guided session, such as agent setup, instead of at order + * time. * * @returns `ready: true` when none of these steps will ask the main account * to sign again before the first order; providers without deferred setup diff --git a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts index e2bb38529b8..30340faa0e1 100644 --- a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts +++ b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts @@ -94,6 +94,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 { @@ -1056,7 +1057,18 @@ export class AggregatedPerpsProvider implements PerpsProvider { async prepareTradingWallet(): Promise { let notReady: ReadyToTradeResult | undefined; for (const [, provider] of this.#getActiveProviders()) { - const result = await provider.prepareTradingWallet?.(); + let result: ReadyToTradeResult | undefined; + try { + result = await provider.prepareTradingWallet?.(); + } catch (error) { + result = { + ready: false, + error: ensureError( + error, + 'AggregatedPerpsProvider.prepareTradingWallet', + ).message, + }; + } if (result && !result.ready) { notReady ??= result; } diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 4530d4667ea..29adb89b6a2 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -1576,6 +1576,10 @@ export class HyperLiquidProvider implements PerpsProvider { // Agent per network and main account (see #getAgentKey): set through // setAgentSigner (null pins the main account) or a pending or non-null // getAgentSigner answer. An agent is only used for its account and network. + // Incremented by clearAgentSigners so answers pending across a clear are + // discarded and asked again. + #agentSignersGeneration = 0; + readonly #agentSigners = new Map< string, { agentSigner: Promise; fromResolver: boolean } @@ -2081,8 +2085,10 @@ export class HyperLiquidProvider implements PerpsProvider { * @returns The agent key. */ #getAgentKey(account: PerpsAgentAccount): string { - const network = account.isTestnet ? 'testnet' : 'mainnet'; - return `${network}:${account.mainAddress.toLowerCase()}`; + return this.#getCacheKey( + account.isTestnet ? 'testnet' : 'mainnet', + account.mainAddress, + ); } /** @@ -2090,7 +2096,8 @@ export class HyperLiquidProvider implements PerpsProvider { * current network: the one set through setAgentSigner, else the one * `getAgentSigner` returns. A non-null answer is kept; null and failures are * not, so the next L1 action asks again. If the binding changes while an - * answer is pending, the newer binding wins. + * answer is pending, the newer binding wins, and 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. @@ -2103,6 +2110,7 @@ export class HyperLiquidProvider implements PerpsProvider { isTestnet: this.#clientService.isTestnetMode(), }; const key = this.#getAgentKey(account); + const generation = this.#agentSignersGeneration; let entry = this.#agentSigners.get(key); if (!entry) { if (!this.#getAgentSigner) { @@ -2118,26 +2126,35 @@ export class HyperLiquidProvider implements PerpsProvider { this.#agentSigners.set(key, entry); } + const pendingEntry = entry; + // A clear or a newer binding made while this answer was pending wins. + const isSuperseded = (): boolean => { + const latest = this.#agentSigners.get(key); + return ( + generation !== this.#agentSignersGeneration || + (latest !== undefined && latest !== pendingEntry) + ); + }; + let agentSigner: PerpsAgentSigner | null; try { - agentSigner = await entry.agentSigner; + agentSigner = await pendingEntry.agentSigner; } catch (error) { - const latest = this.#agentSigners.get(key); - if (latest && latest !== entry) { - return await latest.agentSigner; + if (isSuperseded()) { + return await this.#resolveAgentSigner(mainAddress); } this.#agentSigners.delete(key); this.#deps.debugLogger.log('HyperLiquidProvider: getAgentSigner failed', { - error: ensureError(error, 'resolveAgentSigner').message, + error: ensureError(error, 'HyperLiquidProvider.resolveAgentSigner') + .message, }); throw new AgentSignerUnavailableError(error); } - const latest = this.#agentSigners.get(key); - if (latest && latest !== entry) { - return await latest.agentSigner; + if (isSuperseded()) { + return await this.#resolveAgentSigner(mainAddress); } - if (!agentSigner && entry.fromResolver) { + if (!agentSigner && pendingEntry.fromResolver) { this.#agentSigners.delete(key); } return agentSigner; @@ -2744,6 +2761,10 @@ export class HyperLiquidProvider implements PerpsProvider { #tradingSetupComplete = false; + // Set when the referral write failed in a way that must be retried (the + // agent signer was unavailable), so trading setup is not marked complete. + #referralSetupNeedsRetry = false; + readonly #builderFeeSetupPromises = new Map>(); /** @@ -2863,8 +2884,12 @@ 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 keyring was unlocked (signing could actually + // happen) and the referral does not need another attempt. + if ( + this.#walletService.isKeyringUnlocked() && + !this.#referralSetupNeedsRetry + ) { this.#tradingSetupComplete = true; } })(); @@ -14217,6 +14242,7 @@ export class HyperLiquidProvider implements PerpsProvider { * next L1 action asks `getAgentSigner` again. Call it when the wallet locks. */ clearAgentSigners(): void { + this.#agentSignersGeneration += 1; this.#agentSigners.clear(); } @@ -14252,10 +14278,16 @@ export class HyperLiquidProvider implements PerpsProvider { error, 'HyperLiquidProvider.prepareTradingWallet', ); - this.#deps.logger.error( - caughtError, - this.#getErrorContext('prepareTradingWallet'), - ); + if (caughtError.message === PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE) { + this.#deps.debugLogger.log( + '[prepareTradingWallet] Provider replaced during preparation', + ); + } else { + this.#deps.logger.error( + caughtError, + this.#getErrorContext('prepareTradingWallet'), + ); + } return { ready: false, error: caughtError.message }; } } @@ -15328,6 +15360,7 @@ export class HyperLiquidProvider implements PerpsProvider { * Note: Non-blocking - failures are logged to Sentry but don't prevent trading */ async #ensureReferralSet(): Promise { + this.#referralSetupNeedsRetry = false; const isTestnet = this.#clientService.isTestnetMode(); const network = isTestnet ? 'testnet' : 'mainnet'; const expectedReferralCode = this.#getReferralCode(isTestnet); @@ -15473,6 +15506,7 @@ export class HyperLiquidProvider implements PerpsProvider { this.#deps.debugLogger.log( '[ensureReferralSet] Agent signer unavailable, will retry later', ); + this.#referralSetupNeedsRetry = true; completeInFlight(); return; } diff --git a/packages/perps-controller/src/providers/LighterProvider.ts b/packages/perps-controller/src/providers/LighterProvider.ts index 90818fa8d29..e3fb3ca2902 100644 --- a/packages/perps-controller/src/providers/LighterProvider.ts +++ b/packages/perps-controller/src/providers/LighterProvider.ts @@ -1280,10 +1280,21 @@ export class LighterProvider implements PerpsProvider { * Register the venue key ahead of the first order, so its main-account * `personal_sign` surfaces in a guided session instead of at order time. * - * @returns The readiness after registration. + * @returns The readiness after registration: `KEYRING_LOCKED` whenever the + * main-account signer is not ready, even with a registered venue key. */ async prepareTradingWallet(): Promise { - return await this.isReadyToTrade(); + if (!this.#walletService.isMainAccountSignerReady()) { + return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; + } + const result = await this.isReadyToTrade(); + if (!result.ready && result.error) { + this.#deps.logger.error( + new Error(result.error), + this.#getErrorContext('prepareTradingWallet'), + ); + } + return result; } async isReadyToTrade(): Promise { diff --git a/packages/perps-controller/src/services/LighterWalletService.ts b/packages/perps-controller/src/services/LighterWalletService.ts index c4d011d7a59..66703a3b7dd 100644 --- a/packages/perps-controller/src/services/LighterWalletService.ts +++ b/packages/perps-controller/src/services/LighterWalletService.ts @@ -66,6 +66,22 @@ export class LighterWalletService { 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 { + const { accountSigner } = this.#deps; + if (accountSigner) { + return isAccountSignerReady(accountSigner); + } + return this.#messenger + ? this.#messenger.call('KeyringController:getState').isUnlocked + : false; + } + /** * Sign an EIP-191 personal message with the user's L1 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 bc960671ba0..3b918c7b33f 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -1004,15 +1004,27 @@ describe('PerpsController', () => { }); it('clearAgentSigners forwards to the HyperLiquid provider', async () => { - mockProvider.setAgentSigner = jest.fn(); mockProvider.clearAgentSigners = jest.fn(); await controller.init(); - await controller.clearAgentSigners(); + controller.clearAgentSigners(); expect(mockProvider.clearAgentSigners).toHaveBeenCalledTimes(1); }); + it('clearAgentSigners does nothing before the controller is initialized', () => { + mockProvider.clearAgentSigners = jest.fn(); + + expect(() => controller.clearAgentSigners()).not.toThrow(); + expect(mockProvider.clearAgentSigners).not.toHaveBeenCalled(); + }); + + it('clearAgentSigners does nothing when the provider has no agent support', async () => { + await controller.init(); + + expect(() => controller.clearAgentSigners()).not.toThrow(); + }); + it("prepareTradingWallet returns the active provider's readiness", async () => { mockProvider.prepareTradingWallet = jest .fn() diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts index 8e7ec7e094a..b7ad28061d7 100644 --- a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -1081,13 +1081,49 @@ describe('AggregatedPerpsProvider', () => { }); 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: jest.fn().mockResolvedValue({ ready: true }), + prepareTradingWallet: prepareHyperLiquid, }); const result = await aggregatedProvider.prepareTradingWallet(); expect(result).toStrictEqual({ ready: true }); + expect(prepareHyperLiquid).toHaveBeenCalledTimes(1); + }); + + it('reports the first not-ready provider when several are not ready', async () => { + Object.assign(mockHLProvider, { + prepareTradingWallet: jest + .fn() + .mockResolvedValue({ ready: false, error: 'first' }), + }); + Object.assign(mockLighterProvider, { + prepareTradingWallet: jest + .fn() + .mockResolvedValue({ ready: false, error: 'second' }), + }); + + const result = await aggregatedProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: false, error: 'first' }); + }); + + it('still prepares the other providers when one throws', async () => { + const prepareLighter = jest.fn().mockResolvedValue({ ready: true }); + Object.assign(mockHLProvider, { + prepareTradingWallet: jest + .fn() + .mockRejectedValue(new Error('provider crashed')), + }); + Object.assign(mockLighterProvider, { + prepareTradingWallet: prepareLighter, + }); + + const result = await aggregatedProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: false, error: 'provider crashed' }); + expect(prepareLighter).toHaveBeenCalledTimes(1); }); it('delegates toggleTestnet to default provider', async () => { 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 e9e9e3298a5..31c75d8a755 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 @@ -2945,6 +2945,69 @@ describe('HyperLiquidProvider', () => { expect(getAgentSigner).toHaveBeenCalledTimes(2); 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).toHaveBeenCalledTimes(2); + expect(agentSigner.signTypedData).not.toHaveBeenCalled(); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [ACCOUNT_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 secondResult = await accountSignerProvider.prepareTradingWallet(); + + expect(firstResult).toStrictEqual({ ready: false }); + expect(secondResult).toStrictEqual({ ready: true }); + expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(2); + }); + + it('reports a failed answer asked again after a clear as unavailable', async () => { + const { getAgentSigner, answer, asked } = createPendingResolver(); + const { accountSignerProvider, agentSigner, initialize } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + }); + await accountSignerProvider.getMarketDataWithPrices(); + const [[wallet]] = initialize.mock.calls; + + const signing = wallet.signTypedData(L1_PAYLOAD); + await asked; + getAgentSigner.mockRejectedValue(new Error('agent store unavailable')); + accountSignerProvider.clearAgentSigners(); + answer.resolve(agentSigner); + + await expect(signing).rejects.toThrow( + 'HyperLiquid agent signer unavailable', + ); + expect(agentSigner.signTypedData).not.toHaveBeenCalled(); + }); }); }); }); 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 index dd2c32a5ae5..1b2a2aaf054 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts @@ -88,6 +88,7 @@ type BuiltProvider = { accountSigner: { signPersonalMessage: jest.Mock }; call: jest.SpyInstance; calls: LighterWasmCall[]; + deps: ReturnType; }; function buildProvider(isReady?: () => boolean): BuiltProvider { @@ -127,9 +128,10 @@ function buildProvider(isReady?: () => boolean): BuiltProvider { }; const { messenger, call } = createKeyringlessMessenger(); const { bridge, calls } = createBridge(); + const deps = { ...createMockInfrastructure(), accountSigner }; const provider = new LighterProvider({ isTestnet: true, - platformDependencies: { ...createMockInfrastructure(), accountSigner }, + platformDependencies: deps, messenger, lighterAuthConfig: { accountIndex: ACCOUNT_INDEX, @@ -138,7 +140,7 @@ function buildProvider(isReady?: () => boolean): BuiltProvider { signerBridge: bridge, webSocketCtor: null, }); - return { provider, address, client, accountSigner, call, calls }; + return { provider, address, client, accountSigner, call, calls, deps }; } describe('LighterProvider with accountSigner', () => { @@ -176,8 +178,12 @@ describe('LighterProvider with accountSigner', () => { const result = await provider.isReadyToTrade(); - expect(result.ready).toBe(false); - expect(result.error).toContain(PERPS_ERROR_CODES.KEYRING_LOCKED); + 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'), @@ -201,4 +207,33 @@ describe('LighterProvider with accountSigner', () => { expect.stringContaining('"changePubKey":true'), ); }); + + it('reports KEYRING_LOCKED from prepareTradingWallet once the signer locks, even with a registered venue key', async () => { + let signerReady = true; + const { provider } = buildProvider(() => signerReady); + const firstResult = await provider.prepareTradingWallet(); + + signerReady = false; + const lockedResult = await provider.prepareTradingWallet(); + + expect(firstResult.ready).toBe(true); + expect(lockedResult).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + }); + + it('logs a failed prepareTradingWallet', async () => { + const { provider, client, deps } = buildProvider(); + client.sendTx.mockRejectedValue(new Error('venue unavailable')); + const loggerError = jest.spyOn(deps.logger, 'error'); + + const result = await provider.prepareTradingWallet(); + + expect(result.ready).toBe(false); + expect(loggerError).toHaveBeenCalledWith( + expect.objectContaining({ message: result.error }), + expect.anything(), + ); + }); }); From dc9e9096107ce1953dcccde375c40fba14ad9238 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 29 Sep 2026 07:37:26 +0800 Subject: [PATCH 09/33] docs(perps-controller): link changelog entries to #10559 --- packages/perps-controller/CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 43b498fa6aa..fa08f5027d9 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -12,24 +12,24 @@ 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 +- 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 - `isHardwareWallet()` 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 +- 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 it returns is kept for the provider's lifetime, while `null` and failures are asked again at the next L1 action - 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` - 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.setAgentSigner` and `PerpsProvider.clearAgentSigners`, implemented by the HyperLiquid provider - 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 setup that needs a main-account signature before the first order: account migration, builder fee and referral on HyperLiquid, venue-key registration on Lighter +- Add `PerpsController:prepareTradingWallet` (`PerpsControllerPrepareTradingWalletAction`) and optional `PerpsProvider.prepareTradingWallet` to run setup that needs a main-account signature before the first order: account migration, builder fee and referral on HyperLiquid, venue-key registration on Lighter ([#10559](https://github.com/MetaMask/core/pull/10559)) - Resolves a `ReadyToTradeResult` that is `ready: true` once the main-account signer is ready and none of these steps will ask it to sign again before the first order; the aggregated provider prepares every provider in turn ### Removed -- **BREAKING:** Remove the `LighterPersonalSigner` type and the `personalSigner` and `l1Address` fields of `LighterAuthConfig` +- **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 From b4944ff465718897ccbe89fc612fd62b3f7ca9d3 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 29 Sep 2026 07:53:02 +0800 Subject: [PATCH 10/33] test(perps-controller): load the HyperLiquid SDK only where Jest can require it The SDK ships ES modules only, and Jest can require them only on Node 24.9 or newer, so the SDK signing suite failed to load in the Node 22 CI job. It now loads the SDK with jest.requireActual and runs on Node 24.9+; the other suites in the file still run on Node 22. --- ...LiquidWalletService.account-signer.test.ts | 232 ++++++++++-------- 1 file changed, 126 insertions(+), 106 deletions(-) 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 index 8e6cd9aa151..05502eba038 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts @@ -1,9 +1,6 @@ import type { Hex } from '@metamask/utils'; -import { ApproveBuilderFeeTypes } from '@nktkas/hyperliquid/api/exchange'; -import { - signL1Action, - signUserSignedAction, -} from '@nktkas/hyperliquid/signing'; +import type * as HyperLiquidExchange from '@nktkas/hyperliquid/api/exchange'; +import type * as HyperLiquidSigning from '@nktkas/hyperliquid/signing'; import { recoverTypedDataAddress } from 'viem'; import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; @@ -348,116 +345,139 @@ describe('HyperLiquidWalletService wallet adapter with an agent and a keyring', }); }); -describe('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. - const mainAccount = privateKeyToAccount(generatePrivateKey()); - const agentAccount = privateKeyToAccount(generatePrivateKey()); - - type RecordedSignature = { payload: PerpsTypedDataPayload; signature: Hex }; +// 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. + const mainAccount = privateKeyToAccount(generatePrivateKey()); + const agentAccount = privateKeyToAccount(generatePrivateKey()); + let signing: typeof HyperLiquidSigning; + let exchange: typeof HyperLiquidExchange; + + beforeAll(() => { + signing = jest.requireActual( + '@nktkas/hyperliquid/signing', + ); + exchange = jest.requireActual( + '@nktkas/hyperliquid/api/exchange', + ); + }); - function buildSdkAdapter(): { - 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 }), + type RecordedSignature = { payload: PerpsTypedDataPayload; signature: Hex }; + + function buildSdkAdapter(): { + 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, - // A viem local account is a PerpsAgentSigner as it is. - resolveAgent: async (): Promise => agentAccount, - }, - ); - 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, - })), - ), - }; - } + messenger, + { + isTestnet: true, + // A viem local account is a PerpsAgentSigner as it is. + resolveAgent: async (): Promise => agentAccount, + }, + ); + 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, + 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, + }); + } + + afterEach(() => { + jest.restoreAllMocks(); }); - } - afterEach(() => { - jest.restoreAllMocks(); - }); + it('signs an SDK L1 action with the agent', async () => { + const { adapter, signatures, agentSignatures } = buildSdkAdapter(); - 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, + }); - await 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); }); - 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: '0x66eee', + hyperliquidChain: 'Testnet', + maxFeeRate: '0.1%', + builder: agentAccount.address, + nonce: 1, + }, + types: exchange.ApproveBuilderFeeTypes, + }); - it('signs an SDK user-signed action with the main account', async () => { - const { adapter, signatures, agentSignatures } = buildSdkAdapter(); - - await signUserSignedAction({ - wallet: adapter, - action: { - type: 'approveBuilderFee', - signatureChainId: '0x66eee', - hyperliquidChain: 'Testnet', - maxFeeRate: '0.1%', - builder: agentAccount.address, - nonce: 1, - }, - types: ApproveBuilderFeeTypes, + expect(await agentSignatures()).toHaveLength(0); + expect(signatures).toHaveLength(1); + expect(await recoverSigner(signatures[0])).toBe(mainAccount.address); }); - - expect(await agentSignatures()).toHaveLength(0); - expect(signatures).toHaveLength(1); - expect(await recoverSigner(signatures[0])).toBe(mainAccount.address); - }); -}); + }, +); From 4cf8b4cc0f4d0e5f491636095151fde55b28d0cf Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 29 Sep 2026 08:18:30 +0800 Subject: [PATCH 11/33] fix(perps-controller): keep agent bindings on the controller and quiet retryable failures - PerpsController keeps setAgentSigner bindings (AgentBindings) across HyperLiquid provider re-creation and before init; a changed binding drops the agents the provider already resolved. PerpsProvider.setAgentSigner is removed; the provider only caches getAgentSigner answers. - An agent whose signTypedData rejects is retryable like a failing getAgentSigner. Neither is logged as an error during the referral write or the silent unified-account migration. - Lighter prepareTradingWallet logs only unexpected failures, with the original error, and the aggregated provider logs a provider that throws. - Document the chain IDs HyperLiquid payloads carry for accountSigner and what isHardwareWallet means for interactive wallets. --- packages/perps-controller/CHANGELOG.md | 6 +- .../PerpsController-method-action-types.ts | 14 +- .../perps-controller/src/PerpsController.ts | 52 +++-- .../src/providers/AggregatedPerpsProvider.ts | 34 ++-- .../src/providers/HyperLiquidProvider.ts | 103 +++------- .../src/providers/LighterProvider.ts | 50 ++++- .../src/services/HyperLiquidWalletService.ts | 23 ++- .../src/services/LighterWalletService.ts | 17 +- .../src/services/accountSigner.ts | 17 ++ .../src/services/agentSigner.ts | 84 ++++++++ .../src/services/causeChain.ts | 23 +++ packages/perps-controller/src/types/index.ts | 36 ++-- .../tests/helpers/serviceMocks.ts | 82 ++++++-- .../PerpsController.providers-cache.test.ts | 184 ++++++++++++++---- .../providers/AggregatedPerpsProvider.test.ts | 38 +++- .../HyperLiquidProvider.account-mode.test.ts | 169 +++++++++++++--- .../LighterProvider.account-signer.test.ts | 132 +++++++++++-- ...LiquidWalletService.account-signer.test.ts | 68 ++++++- ...ighterWalletService.account-signer.test.ts | 41 ++++ 19 files changed, 911 insertions(+), 262 deletions(-) create mode 100644 packages/perps-controller/src/services/agentSigner.ts create mode 100644 packages/perps-controller/src/services/causeChain.ts diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index fa08f5027d9..9f8ac40cfd8 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -18,10 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `isReady()` returning `false` fails signing with the existing `KEYRING_LOCKED` error code - `isHardwareWallet()` 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 it returns is kept for the provider's lifetime, while `null` and failures are asked again at the next L1 action - - 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` + - 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 it returns is kept for the provider's lifetime or until `setAgentSigner`/`clearAgentSigners`, while `null` and failures (including an agent whose signing rejects) are asked again at the next L1 action + - 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 and they can be set before `init` - 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.setAgentSigner` and `PerpsProvider.clearAgentSigners`, implemented by the HyperLiquid provider + - Add optional `PerpsProvider.clearAgentSigners`, implemented by the HyperLiquid provider - 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 setup that needs a main-account signature before the first order: account migration, builder fee and referral on HyperLiquid, venue-key registration on Lighter ([#10559](https://github.com/MetaMask/core/pull/10559)) diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index c094efdc6cf..52bb40c7077 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -910,11 +910,11 @@ export type PerpsControllerCalculateFeesAction = { * 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. - * Bindings last for the lifetime of the HyperLiquid provider instance; - * initialization and re-initialization (a network toggle, or a client - * reconnecting after an account switch) create a new one. Requires an - * initialized controller. + * 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 @@ -930,8 +930,8 @@ export type PerpsControllerSetAgentSignerAction = { * 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. Without an initialized HyperLiquid - * provider there are no agents, so it does nothing. + * 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`; diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 0d8e8c14e99..15b6ce2994e 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -52,6 +52,7 @@ 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 { AgentBindings } from './services/agentSigner.js'; import { DataLakeService } from './services/DataLakeService.js'; import { DepositService } from './services/DepositService.js'; import { EligibilityService } from './services/EligibilityService.js'; @@ -1133,6 +1134,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; @@ -1213,6 +1218,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 @@ -2367,9 +2375,7 @@ export class PerpsController extends BaseController< this.#options.clientConfig?.providerCredentials?.hyperliquid ?.subscriptionBuilderAddressMainnet, onChaseOrderMaxDistanceReached: this.#publishChaseOrderMaxDistanceReached, - getAgentSigner: - this.#options.clientConfig?.providerCredentials?.hyperliquid - ?.getAgentSigner, + getAgentSigner: this.#agentBindings.resolve, }); this.providers.set('hyperliquid', hyperLiquidProvider); @@ -5866,21 +5872,24 @@ export class PerpsController extends BaseController< * 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. - * Bindings last for the lifetime of the HyperLiquid provider instance; - * initialization and re-initialization (a network toggle, or a client - * reconnecting after an account switch) create a new one. Requires an - * initialized controller. + * 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. */ - async setAgentSigner( + setAgentSigner( account: PerpsAgentAccount, agentSigner: PerpsAgentSigner | null, - ): Promise { - (await this.#getAgentSignerSetter())(account, agentSigner); + ): void { + this.#agentBindings.set(account, agentSigner); + // Drop agents the provider already resolved so the binding applies to + // the next L1 action. + this.providers.get('hyperliquid')?.clearAgentSigners?.(); } /** @@ -5888,29 +5897,14 @@ export class PerpsController extends BaseController< * 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. Without an initialized HyperLiquid - * provider there are no agents, so it does nothing. + * 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.providers.get('hyperliquid')?.clearAgentSigners?.(); } - /** - * Resolve the HyperLiquid provider, which owns the agent bindings. - * - * @returns The HyperLiquid provider's setAgentSigner. - */ - async #getAgentSignerSetter(): Promise< - NonNullable - > { - await this.#getActiveProviderWhenReady(); - const provider = this.providers.get('hyperliquid'); - if (!provider?.setAgentSigner) { - throw new Error(PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE); - } - return provider.setAgentSigner.bind(provider); - } - /** * Run the active provider's setup that needs a main-account signature * (HyperLiquid account migration, builder fee and referral; Lighter diff --git a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts index 30340faa0e1..d1cd535bd42 100644 --- a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts +++ b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts @@ -17,6 +17,7 @@ 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 { WebSocketConnectionState } from '../types/index.js'; @@ -1050,24 +1051,35 @@ export class AggregatedPerpsProvider implements PerpsProvider { /** * Prepare every provider in turn, so a hardware wallet sees one prompt at a - * time. + * time. A provider that throws is logged with its provider ID and counts as + * not ready. * - * @returns The first provider readiness that is not ready, else 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 [, provider] of this.#getActiveProviders()) { + for (const [providerId, provider] of this.#getActiveProviders()) { let result: ReadyToTradeResult | undefined; try { result = await provider.prepareTradingWallet?.(); - } catch (error) { - result = { - ready: false, - error: ensureError( - error, - 'AggregatedPerpsProvider.prepareTradingWallet', - ).message, - }; + } catch (caughtError) { + // Providers report their own failures, so a throw here is unexpected. + const error = ensureError( + caughtError, + 'AggregatedPerpsProvider.prepareTradingWallet', + ); + this.#deps.logger.error(error, { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: providerId, + }, + context: { + name: 'AggregatedPerpsProvider.prepareTradingWallet', + data: { providerId }, + }, + }); + result = { ready: false, error: error.message }; } if (result && !result.ready) { notReady ??= result; diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 29adb89b6a2..19c559e8aab 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -48,6 +48,10 @@ 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, + isAgentSignerUnavailableError, +} from '../services/agentSigner.js'; import { DexDiscoveryCacheManager } from '../services/DexDiscoveryCacheManager.js'; import { HyperLiquidClientService, @@ -824,37 +828,6 @@ type ChaseOrderMaxDistanceReachedHandler = ( event: ChaseOrderMaxDistanceReached, ) => void; -/** - * The host's `getAgentSigner` failed. Like a locked keyring, it is retryable: - * the next L1 action asks again. - */ -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 could not be resolved. - */ -function isAgentSignerUnavailableError(error: unknown): boolean { - let current: unknown = error; - const seen = new Set(); - while (current instanceof Error && !seen.has(current)) { - if (current instanceof AgentSignerUnavailableError) { - return true; - } - seen.add(current); - current = current.cause; - } - return false; -} - type HyperLiquidProviderOptions = { isTestnet?: boolean; hip3Enabled?: boolean; @@ -1573,17 +1546,13 @@ export class HyperLiquidProvider implements PerpsProvider { readonly #getAgentSigner: HyperLiquidCredentials['getAgentSigner']; - // Agent per network and main account (see #getAgentKey): set through - // setAgentSigner (null pins the main account) or a pending or non-null - // getAgentSigner answer. An agent is only used for its account and network. + // Pending or non-null getAgentSigner answers per network and main account + // (see #getAgentKey), so an agent is only used for its account and network. // Incremented by clearAgentSigners so answers pending across a clear are // discarded and asked again. #agentSignersGeneration = 0; - readonly #agentSigners = new Map< - string, - { agentSigner: Promise; fromResolver: boolean } - >(); + readonly #agentSigners = new Map>(); // Promise-based lock to prevent race conditions in concurrent initialization #initializationPromise: Promise | null = null; @@ -2093,11 +2062,9 @@ export class HyperLiquidProvider implements PerpsProvider { /** * Resolve the agent that signs L1 actions for a main account on the - * current network: the one set through setAgentSigner, else the one - * `getAgentSigner` returns. A non-null answer is kept; null and failures are - * not, so the next L1 action asks again. If the binding changes while an - * answer is pending, the newer binding wins, and an answer pending across - * clearAgentSigners is discarded and asked again. + * 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. @@ -2116,18 +2083,16 @@ export class HyperLiquidProvider implements PerpsProvider { if (!this.#getAgentSigner) { return null; } - let answer: Promise; try { - answer = this.#getAgentSigner(account); + entry = this.#getAgentSigner(account); } catch (error) { - answer = Promise.reject(error); + entry = Promise.reject(error); } - entry = { agentSigner: answer, fromResolver: true }; this.#agentSigners.set(key, entry); } const pendingEntry = entry; - // A clear or a newer binding made while this answer was pending wins. + // A clear, or another answer stored while this one was pending, wins. const isSuperseded = (): boolean => { const latest = this.#agentSigners.get(key); return ( @@ -2138,7 +2103,7 @@ export class HyperLiquidProvider implements PerpsProvider { let agentSigner: PerpsAgentSigner | null; try { - agentSigner = await pendingEntry.agentSigner; + agentSigner = await pendingEntry; } catch (error) { if (isSuperseded()) { return await this.#resolveAgentSigner(mainAddress); @@ -2154,7 +2119,7 @@ export class HyperLiquidProvider implements PerpsProvider { if (isSuperseded()) { return await this.#resolveAgentSigner(mainAddress); } - if (!agentSigner && pendingEntry.fromResolver) { + if (!agentSigner) { this.#agentSigners.delete(key); } return agentSigner; @@ -2557,6 +2522,15 @@ export class HyperLiquidProvider implements PerpsProvider { return; } + if (isAgentSignerUnavailableError(error)) { + this.#deps.debugLogger.log( + '[ensureUnifiedAccountEnabled] Agent signer unavailable, will retry later', + ); + this.#unifiedAccountSetupNeedsRetry = true; + completeInFlight(); + return; + } + // Safety net: a Hyperliquid "user does not exist" rejection slipped // past the proactive probe (race with deposit confirmation, transient // probe failure that failed open, ...). Treat as benign — do NOT @@ -14218,28 +14192,8 @@ export class HyperLiquidProvider implements PerpsProvider { } /** - * Sign L1 actions for a main account on a network with an approved agent, - * or pin them to the main account when `agentSigner` is null (the resolver - * is then not asked for that account and network until clearAgentSigners). - * The binding is explicit so an account switch or re-initialization that is - * still pending cannot attach the agent to another account. - * - * @param account - The main account and network the agent is approved for. - * @param agentSigner - The host-owned agent signer, or null to clear it. - */ - setAgentSigner( - account: PerpsAgentAccount, - agentSigner: PerpsAgentSigner | null, - ): void { - this.#agentSigners.set(this.#getAgentKey(account), { - agentSigner: Promise.resolve(agentSigner), - fromResolver: false, - }); - } - - /** - * Forget every agent, whether set through setAgentSigner or resolved, so the - * next L1 action asks `getAgentSigner` again. Call it when the wallet locks. + * 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; @@ -15685,6 +15639,11 @@ export class HyperLiquidProvider implements PerpsProvider { ); throw error; } + // Retryable: `#ensureReferralSet` records it and retries at the next + // entry, so it is not an error to report. + if (isAgentSignerUnavailableError(error)) { + throw error; + } this.#deps.logger.error( ensureError(error, 'HyperLiquidProvider.setReferralCode'), this.#getErrorContext('setReferralCode', { diff --git a/packages/perps-controller/src/providers/LighterProvider.ts b/packages/perps-controller/src/providers/LighterProvider.ts index e3fb3ca2902..e588569700b 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,23 @@ class LighterAccountNotFoundError extends Error { } } +/** + * Whether preparing the wallet stopped for an expected reason: a locked or + * declined signature, or a wallet with no Lighter account yet. + * + * @param error - The caught error. + * @returns True when the outcome is not an error to report. + */ +const isExpectedPreparationFailure = (error: unknown): boolean => + isKeyringLockedError(error) || + hasErrorInCauseChain( + error, + (current) => + current instanceof LighterAccountNotFoundError || + (current as { code?: unknown }).code === 4001 || + /user (rejected|denied)/iu.test(current.message), + ); + /** * Empty account state returned when reads fail or no account exists. */ @@ -1280,21 +1298,35 @@ export class LighterProvider implements PerpsProvider { * Register the venue key ahead of the first order, so its main-account * `personal_sign` surfaces in a guided session instead of at order time. * - * @returns The readiness after registration: `KEYRING_LOCKED` whenever the - * main-account signer is not ready, even with a registered venue key. + * @returns `ready: true` once the venue key is registered; otherwise + * `ready: false` with `KEYRING_LOCKED` whenever the main-account signer is + * not ready (even with a registered venue key), or with the error that + * stopped registration. Unexpected errors are logged; a locked or declined + * signature and a wallet with no Lighter account yet are not. */ async prepareTradingWallet(): Promise { if (!this.#walletService.isMainAccountSignerReady()) { return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; } - const result = await this.isReadyToTrade(); - if (!result.ready && result.error) { - this.#deps.logger.error( - new Error(result.error), - this.#getErrorContext('prepareTradingWallet'), + if (!this.#signerBridge) { + return { ready: false, error: LIGHTER_SIGNER_UNAVAILABLE_ERROR }; + } + try { + await this.#ensureSignerReady(); + return { ready: true }; + } catch (caughtError) { + const error = ensureError( + caughtError, + 'LighterProvider.prepareTradingWallet', ); + if (!isExpectedPreparationFailure(caughtError)) { + this.#deps.logger.error( + error, + this.#getErrorContext('prepareTradingWallet'), + ); + } + return { ready: false, error: error.message }; } - return result; } async isReadyToTrade(): Promise { diff --git a/packages/perps-controller/src/services/HyperLiquidWalletService.ts b/packages/perps-controller/src/services/HyperLiquidWalletService.ts index e3ab88bbf24..bc5642c3b36 100644 --- a/packages/perps-controller/src/services/HyperLiquidWalletService.ts +++ b/packages/perps-controller/src/services/HyperLiquidWalletService.ts @@ -22,7 +22,11 @@ import { getSelectedEvmAccountDetailsFromMessenger, getSelectedEvmAccountFromMessenger, } from '../utils/accountUtils.js'; -import { isAccountSignerReady } from './accountSigner.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 @@ -86,11 +90,10 @@ export class HyperLiquidWalletService { * @returns True when the main account is available for signing. */ public isKeyringUnlocked(): boolean { - const { accountSigner } = this.#deps; - if (accountSigner) { - return isAccountSignerReady(accountSigner); - } - return this.#messenger.call('KeyringController:getState').isUnlocked; + return isMainAccountSignerReady( + this.#deps.accountSigner, + () => this.#messenger.call('KeyringController:getState').isUnlocked, + ); } /** @@ -219,7 +222,13 @@ export class HyperLiquidWalletService { 'HyperLiquidWalletService: Signing L1 action with agent', { address: mainAddress, agent: agentSigner.address }, ); - return await agentSigner.signTypedData(params); + 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 66703a3b7dd..3197ae849b7 100644 --- a/packages/perps-controller/src/services/LighterWalletService.ts +++ b/packages/perps-controller/src/services/LighterWalletService.ts @@ -26,7 +26,10 @@ import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; import type { PerpsPlatformDependencies } from '../types/index.js'; import type { LighterNetwork } from '../types/lighter-types.js'; import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils.js'; -import { isAccountSignerReady } from './accountSigner.js'; +import { + isAccountSignerReady, + isMainAccountSignerReady, +} from './accountSigner.js'; export class LighterWalletService { #isTestnet: boolean; @@ -73,13 +76,11 @@ export class LighterWalletService { * @returns True when the main account is available for signing. */ isMainAccountSignerReady(): boolean { - const { accountSigner } = this.#deps; - if (accountSigner) { - return isAccountSignerReady(accountSigner); - } - return this.#messenger - ? this.#messenger.call('KeyringController:getState').isUnlocked - : false; + return isMainAccountSignerReady( + this.#deps.accountSigner, + () => + this.#messenger?.call('KeyringController:getState').isUnlocked ?? false, + ); } /** diff --git a/packages/perps-controller/src/services/accountSigner.ts b/packages/perps-controller/src/services/accountSigner.ts index a8762e2c983..177e514ecb6 100644 --- a/packages/perps-controller/src/services/accountSigner.ts +++ b/packages/perps-controller/src/services/accountSigner.ts @@ -11,3 +11,20 @@ export function isAccountSignerReady( ): 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..3c25b1ca217 --- /dev/null +++ b/packages/perps-controller/src/services/agentSigner.ts @@ -0,0 +1,84 @@ +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, + ); +} + +/** + * 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(this.#getKey(account), agentSigner); + } + + /** Forget every binding, so `getAgentSigner` answers again. */ + clear(): void { + this.#bindings.clear(); + } + + /** + * 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 = this.#getKey(account); + if (this.#bindings.has(key)) { + return this.#bindings.get(key) ?? null; + } + return this.#getAgentSigner ? await this.#getAgentSigner(account) : null; + }; + + #getKey(account: PerpsAgentAccount): string { + return `${account.isTestnet ? 'testnet' : 'mainnet'}:${account.mainAddress.toLowerCase()}`; + } +} 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/types/index.ts b/packages/perps-controller/src/types/index.ts index cccb796d065..5394d0cefdc 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1120,10 +1120,14 @@ export type HyperLiquidCredentials = { * when there is none (for example while the wallet is locked). Called when * an L1 action (order, cancel, leverage, ...) is signed for that account and * network, including the unified-account migration the provider may sign - * while connecting. An agent it returns is kept for the lifetime of the - * HyperLiquid provider instance; null is not kept, so it is asked again at - * the next L1 action. With an agent, L1 actions are signed by the agent key - * and user-signed actions (builder fee, withdraw, ...) by the main account. + * while connecting. Not called for an account and network bound through + * `PerpsController:setAgentSigner`. An agent it returns is kept for the + * lifetime of the HyperLiquid provider instance, or until + * `setAgentSigner`/`clearAgentSigners`; null is not kept, so it is asked + * again at the next L1 action. With an agent, L1 actions are signed by the + * agent key and user-signed actions (builder fee, withdraw, ...) by the main + * account. A rejection, or an agent whose `signTypedData` rejects, fails + * that action and is retried at the next one. */ getAgentSigner?: ( account: PerpsAgentAccount, @@ -2152,17 +2156,8 @@ export type PerpsProvider = { */ prepareTradingWallet?(): Promise; /** - * Sign L1 actions for a main account on a network with an approved agent, - * or pin them to the main account with null. Providers without agents omit - * it. - */ - setAgentSigner?( - account: PerpsAgentAccount, - agentSigner: PerpsAgentSigner | null, - ): void; - /** - * Forget every agent so the next L1 action asks for one again. Providers - * without agents omit it. + * Forget every agent the provider resolved, so the next L1 action asks its + * resolver again. Providers without agents omit it. */ clearAgentSigners?(): void; disconnect(): Promise; @@ -2633,7 +2628,13 @@ export type PerpsTypedDataPayload = { */ export type PerpsAccountSigner = { /** - * Sign EIP-712 typed data as `address`. + * 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. @@ -2659,7 +2660,8 @@ export type PerpsAccountSigner = { isReady?(): boolean; /** - * True when every signature needs a physical confirmation. HyperLiquid then + * 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. */ diff --git a/packages/perps-controller/tests/helpers/serviceMocks.ts b/packages/perps-controller/tests/helpers/serviceMocks.ts index 7a23e699ead..cca6fdbb848 100644 --- a/packages/perps-controller/tests/helpers/serviceMocks.ts +++ b/packages/perps-controller/tests/helpers/serviceMocks.ts @@ -291,22 +291,27 @@ export const createMockMessenger = ( } as unknown as jest.Mocked; }; +type AccountMessenger = { + messenger: PerpsControllerMessenger; + call: jest.SpyInstance; + selectAccount: (address: `0x${string}`) => void; +}; + /** - * Create a real PerpsController messenger for a host without a - * KeyringController: only `AccountsController:getSelectedAccount` is - * delegated, so any `KeyringController:*` call throws. + * 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. * @returns The messenger, a spy on its `call`, and a way to switch the * selected account. */ -export const createKeyringlessMessenger = ( - keyringType = 'HD Key Tree', -): { - messenger: PerpsControllerMessenger; - call: jest.SpyInstance; - selectAccount: (address: `0x${string}`) => void; -} => { +const createAccountMessenger = ( + keyringType: string, + keyringSignature?: string, +): AccountMessenger => { const account = createMockEvmAccount(); let selectedAddress = account.address; const root = new Messenger< @@ -324,10 +329,34 @@ export const createKeyringlessMessenger = ( scopes: ['eip155:0'], metadata: { ...account.metadata, keyring: { type: keyringType } }, })); - root.delegate({ - actions: ['AccountsController:getSelectedAccount'], - messenger, - }); + if (keyringSignature === undefined) { + root.delegate({ + actions: ['AccountsController:getSelectedAccount'], + messenger, + }); + } else { + root.registerActionHandler('KeyringController:getState', () => ({ + isUnlocked: true, + 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, call: jest.spyOn(messenger, 'call'), @@ -337,6 +366,31 @@ export const createKeyringlessMessenger = ( }; }; +/** + * 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, a spy on its `call`, and a way to switch the + * selected account. + */ +export const createKeyringlessMessenger = ( + keyringType = 'HD Key Tree', +): AccountMessenger => createAccountMessenger(keyringType); + +/** + * Create a real PerpsController messenger for a host with an unlocked + * KeyringController that returns `signature` for typed data and personal + * messages. + * + * @param signature - Signature the keyring returns. + * @returns The messenger, a spy on its `call`, and a way to switch the + * selected account. + */ +export const createKeyringMessenger = (signature: string): AccountMessenger => + createAccountMessenger('HD Key Tree', signature); + /** * Names of the `KeyringController:*` actions a messenger spy saw. * 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 3b918c7b33f..cd6131fa789 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -38,6 +38,7 @@ import type { ServiceContext } from '../../src/services/ServiceContext.js'; import type { AccountState, GetAvailableDexsParams, + HyperLiquidCredentials, PerpsProvider, PerpsPlatformDependencies, PerpsMarketData, @@ -945,9 +946,40 @@ describe('PerpsController', () => { expect(MockLighterConstructor).toHaveBeenCalledWith(withAccountSigner); }); - it('passes providerCredentials.hyperliquid.getAgentSigner to the HyperLiquid provider', async () => { - const getAgentSigner = jest.fn(); - controller = new TestablePerpsController({ + const agentSigner = { + address: '0x00000000000000000000000000000000000a9e17', + 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; + } + + /** + * Build a controller whose host resolves agents with `getAgentSigner`. + * + * @param getAgentSigner - The host resolver. + * @returns The controller. + */ + function createAgentController( + getAgentSigner?: HyperLiquidCredentials['getAgentSigner'], + ): TestablePerpsController { + return new TestablePerpsController({ messenger: createMockMessenger(), state: getDefaultPerpsControllerState(), clientConfig: { @@ -955,74 +987,148 @@ describe('PerpsController', () => { }, infrastructure: mockInfrastructure, }); + } + it("asks the host's getAgentSigner through the HyperLiquid provider's resolver", async () => { + const getAgentSigner = jest.fn().mockResolvedValue(agentSigner); + controller = createAgentController(getAgentSigner); await controller.init(); - expect( - HyperLiquidProvider as jest.MockedClass, - ).toHaveBeenCalledWith(expect.objectContaining({ getAgentSigner })); + const resolved = await getProviderAgentResolver()(account); + + expect(resolved).toBe(agentSigner); + expect(getAgentSigner.mock.calls).toStrictEqual([[account]]); }); - it('setAgentSigner forwards the agent to the HyperLiquid provider', async () => { - const agentSigner = { - address: '0x00000000000000000000000000000000000a9e17' as const, - signTypedData: jest.fn(), - }; - mockProvider.setAgentSigner = jest.fn(); + it('resolves no agent without getAgentSigner or a binding', async () => { + controller = createAgentController(); await controller.init(); - await controller.setAgentSigner(account, agentSigner); - await controller.setAgentSigner(account, null); + expect(await getProviderAgentResolver()(account)).toBeNull(); + }); - expect(mockProvider.setAgentSigner).toHaveBeenNthCalledWith( - 1, - account, - agentSigner, - ); - expect(mockProvider.setAgentSigner).toHaveBeenNthCalledWith( - 2, - account, - null, - ); + it('answers with a setAgentSigner binding for that account and network only', async () => { + const getAgentSigner = jest.fn().mockResolvedValue(null); + controller = createAgentController(getAgentSigner); + await controller.init(); + const resolve = getProviderAgentResolver(); + + controller.setAgentSigner(account, agentSigner); + + expect(await resolve(account)).toBe(agentSigner); + expect( + await resolve({ + ...account, + mainAddress: '0x9999999999999999999999999999999999999999', + }), + ).toBeNull(); + expect(await resolve({ ...account, isTestnet: true })).toBeNull(); + expect(getAgentSigner).toHaveBeenCalledTimes(2); }); - it('setAgentSigner rejects when the hyperliquid provider has no agent support', async () => { + it('matches a binding whatever the main address casing', async () => { + controller = createAgentController(jest.fn().mockResolvedValue(null)); await controller.init(); - await expect(controller.setAgentSigner(account, null)).rejects.toThrow( - PERPS_ERROR_CODES.PROVIDER_NOT_AVAILABLE, + controller.setAgentSigner( + { + mainAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + isTestnet: false, + }, + agentSigner, ); + + expect( + await getProviderAgentResolver()({ + mainAddress: '0xABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCD', + isTestnet: false, + }), + ).toBe(agentSigner); }); - it('setAgentSigner rejects before the controller is initialized', async () => { - mockProvider.setAgentSigner = jest.fn(); + it('pins the main account with setAgentSigner(null) without asking getAgentSigner', async () => { + const getAgentSigner = jest.fn().mockResolvedValue(agentSigner); + controller = createAgentController(getAgentSigner); + await controller.init(); + + controller.setAgentSigner(account, null); - await expect(controller.setAgentSigner(account, null)).rejects.toThrow( - PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED, - ); - expect(mockProvider.setAgentSigner).not.toHaveBeenCalled(); + expect(await getProviderAgentResolver()(account)).toBeNull(); + expect(getAgentSigner).not.toHaveBeenCalled(); }); - it('clearAgentSigners forwards to the HyperLiquid provider', async () => { + it('drops the agents the provider already resolved when a binding changes', async () => { mockProvider.clearAgentSigners = jest.fn(); + controller = createAgentController(); await controller.init(); - controller.clearAgentSigners(); + controller.setAgentSigner(account, agentSigner); expect(mockProvider.clearAgentSigners).toHaveBeenCalledTimes(1); }); - it('clearAgentSigners does nothing before the controller is initialized', () => { + it('keeps a binding set before init', async () => { + controller = createAgentController(jest.fn().mockResolvedValue(null)); + + controller.setAgentSigner(account, agentSigner); + await controller.init(); + + expect(await getProviderAgentResolver()(account)).toBe(agentSigner); + }); + + it('keeps bindings when the HyperLiquid provider is re-created', async () => { + const getAgentSigner = jest.fn().mockResolvedValue(agentSigner); + controller = createAgentController(getAgentSigner); + await controller.init(); + controller.setAgentSigner(account, null); + + await controller.toggleTestnet(); + await controller.toggleTestnet(); + + expect( + HyperLiquidProvider as jest.MockedClass, + ).toHaveBeenCalledTimes(3); + expect(await getProviderAgentResolver()(account)).toBeNull(); + expect(getAgentSigner).not.toHaveBeenCalled(); + }); + + it('clearAgentSigners forgets bindings and drops resolved agents', async () => { + const getAgentSigner = jest.fn().mockResolvedValue(agentSigner); mockProvider.clearAgentSigners = jest.fn(); + controller = createAgentController(getAgentSigner); + await controller.init(); + controller.setAgentSigner(account, null); + + controller.clearAgentSigners(); + + expect(await getProviderAgentResolver()(account)).toBe(agentSigner); + expect(mockProvider.clearAgentSigners).toHaveBeenCalledTimes(2); + }); + + it('clearAgentSigners does not need an initialized provider', () => { + controller = createAgentController(); expect(() => controller.clearAgentSigners()).not.toThrow(); - expect(mockProvider.clearAgentSigners).not.toHaveBeenCalled(); }); - it('clearAgentSigners does nothing when the provider has no agent support', async () => { + it('exposes the agent and preparation actions through the messenger at init', async () => { + const messenger = createMockMessenger(); + controller = new TestablePerpsController({ + messenger, + state: getDefaultPerpsControllerState(), + infrastructure: mockInfrastructure, + }); + await controller.init(); - expect(() => controller.clearAgentSigners()).not.toThrow(); + expect(messenger.registerMethodActionHandlers).toHaveBeenCalledWith( + controller, + expect.arrayContaining([ + 'setAgentSigner', + 'clearAgentSigners', + 'prepareTradingWallet', + ]), + ); }); it("prepareTradingWallet returns the active provider's readiness", async () => { diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts index b7ad28061d7..646f02dc499 100644 --- a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -15,7 +15,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 = ( @@ -1124,6 +1127,39 @@ describe('AggregatedPerpsProvider', () => { expect(result).toStrictEqual({ ready: false, error: 'provider crashed' }); expect(prepareLighter).toHaveBeenCalledTimes(1); + expect(mockInfrastructure.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ message: 'provider crashed' }), + { + tags: { feature: 'perps', provider: 'hyperliquid' }, + context: { + name: 'AggregatedPerpsProvider.prepareTradingWallet', + data: { providerId: 'hyperliquid' }, + }, + }, + ); + }); + + it('prepares the next provider only after the previous one settles', async () => { + const firstPreparation = createDeferred<{ ready: boolean }>(); + const prepareLighter = jest.fn().mockResolvedValue({ ready: true }); + Object.assign(mockHLProvider, { + prepareTradingWallet: jest.fn( + async () => await firstPreparation.promise, + ), + }); + Object.assign(mockLighterProvider, { + prepareTradingWallet: prepareLighter, + }); + + const preparing = aggregatedProvider.prepareTradingWallet(); + await Promise.resolve(); + const startedBeforeFirstSettled = prepareLighter.mock.calls.length; + firstPreparation.resolve({ ready: true }); + const result = await preparing; + + expect(startedBeforeFirstSettled).toBe(0); + expect(prepareLighter).toHaveBeenCalledTimes(1); + expect(result).toStrictEqual({ ready: true }); }); it('delegates toggleTestnet to default provider', async () => { 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 31c75d8a755..80a3eacce42 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 @@ -11,6 +11,7 @@ import { import { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from '../../../src/constants/transactionsHistoryConfig.js'; import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider.js'; +import { 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'; @@ -23,7 +24,9 @@ import type { ClosePositionParams, DepositParams, Order, + HyperLiquidCredentials, PerpsAccountSigner, + PerpsAgentAccount, PerpsAgentSigner, PerpsPlatformDependencies, PerpsTypedDataPayload, @@ -41,6 +44,7 @@ import { import { createStandaloneInfoClient } from '../../../src/utils/standaloneInfoClient.js'; import { createDeferred, + createKeyringMessenger, createKeyringlessMessenger, createMockEvmAccount, createMockInfrastructure, @@ -2352,7 +2356,9 @@ describe('HyperLiquidProvider', () => { type Options = { signer?: { isReady?: () => boolean; isHardwareWallet?: () => boolean }; abstraction?: 'dexAbstraction' | 'default' | 'unifiedAccount'; - getAgentSigner?: jest.Mock; + getAgentSigner?: HyperLiquidCredentials['getAgentSigner']; + // Sign through a KeyringController instead of accountSigner. + keyring?: boolean; }; function createAccountSignerProvider(options: Options = {}) { @@ -2365,7 +2371,9 @@ describe('HyperLiquidProvider', () => { address: AGENT_ADDRESS, signTypedData: jest.fn().mockResolvedValue(AGENT_SIGNATURE), }; - const { messenger, call, selectAccount } = createKeyringlessMessenger(); + const { messenger, call, selectAccount } = options.keyring + ? createKeyringMessenger(SIGNATURE) + : createKeyringlessMessenger(); MockedHyperLiquidWalletService.mockImplementation( (deps, walletMessenger, walletOptions) => new RealHyperLiquidWalletService( @@ -2403,7 +2411,9 @@ describe('HyperLiquidProvider', () => { }), ); const accountSignerProvider = new HyperLiquidProvider({ - platformDependencies: { ...mockPlatformDependencies, accountSigner }, + platformDependencies: options.keyring + ? mockPlatformDependencies + : { ...mockPlatformDependencies, accountSigner }, messenger, initialAssetMapping: [ ['BTC', 0], @@ -2589,8 +2599,53 @@ describe('HyperLiquidProvider', () => { }); expect(mockPlatformDependencies.logger.error).toHaveBeenCalledWith( expect.objectContaining({ message: 'transport unavailable' }), - expect.anything(), + { + tags: { + feature: 'perps', + provider: 'hyperliquid', + network: 'mainnet', + }, + context: { + name: 'HyperLiquidProvider', + data: { method: 'prepareTradingWallet' }, + }, + }, + ); + }); + + it('does not log a provider replaced during preparation', async () => { + const { accountSignerProvider, initialize } = + createAccountSignerProvider(); + initialize.mockRejectedValue( + new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE), ); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + }); + expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled(); + }); + + it('signs the deferred setup through the keyring without accountSigner', async () => { + const { accountSignerProvider, accountSigner, call, exchangeClient } = + createAccountSignerProvider({ keyring: true }); + await accountSignerProvider.getMarketDataWithPrices(); + rememberMigration(); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: true }); + expect(exchangeClient.userSetAbstraction).toHaveBeenCalledTimes(1); + expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); + expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + expect( + keyringCalls(call).filter( + (action) => action === 'KeyringController:signTypedMessage', + ), + ).toHaveLength(2); }); it('reports KEYRING_LOCKED when the signer locks after setup completed', async () => { @@ -2618,6 +2673,25 @@ describe('HyperLiquidProvider', () => { isTestnet: false, } as const; + /** + * 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. + */ + function bind( + provider: HyperLiquidProvider, + bindings: AgentBindings, + account: PerpsAgentAccount, + agentSigner: PerpsAgentSigner | null, + ): void { + bindings.set(account, agentSigner); + provider.clearAgentSigners(); + } + it('resolves the agent at the first L1 signature and signs with it', async () => { const getAgentSigner = jest.fn(); const { @@ -2733,13 +2807,25 @@ describe('HyperLiquidProvider', () => { expect(getAgentSigner).toHaveBeenCalledTimes(4); expect(accountSigner.signTypedData).not.toHaveBeenCalled(); expect(result).toStrictEqual({ ready: false }); + // Retryable like a locked keyring: no failure metric, nothing logged. + expect( + mockPlatformDependencies.metrics.trackPerpsEvent, + ).not.toHaveBeenCalledWith( + 'Perp Account Setup', + expect.objectContaining({ status: 'failed' }), + ); + expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled(); }); - it('signs with the agent set for the selected account', async () => { + it('signs with the agent bound to the selected account', async () => { + const bindings = new AgentBindings(undefined); const { accountSignerProvider, agentSigner } = - createAccountSignerProvider({ abstraction: 'default' }); + createAccountSignerProvider({ + abstraction: 'default', + getAgentSigner: bindings.resolve, + }); - accountSignerProvider.setAgentSigner(MAINNET_ACCOUNT, agentSigner); + bindings.set(MAINNET_ACCOUNT, agentSigner); await accountSignerProvider.getMarketDataWithPrices(); expect(agentSigner.signTypedData.mock.calls).toStrictEqual([ @@ -2748,14 +2834,18 @@ describe('HyperLiquidProvider', () => { }); 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' }); + } = createAccountSignerProvider({ + abstraction: 'default', + getAgentSigner: bindings.resolve, + }); - accountSignerProvider.setAgentSigner( + bindings.set( { mainAddress: OTHER_ACCOUNT_ADDRESS, isTestnet: false }, agentSigner, ); @@ -2770,10 +2860,14 @@ describe('HyperLiquidProvider', () => { expect(agentSigner.signTypedData).toHaveBeenCalledWith(L1_PAYLOAD); }); - it('never signs on another network with the agent it was set for', async () => { + it('never signs on another network with the agent bound for mainnet', async () => { + const bindings = new AgentBindings(undefined); const { accountSignerProvider, accountSigner, agentSigner } = - createAccountSignerProvider({ abstraction: 'default' }); - accountSignerProvider.setAgentSigner(MAINNET_ACCOUNT, agentSigner); + createAccountSignerProvider({ + abstraction: 'default', + getAgentSigner: bindings.resolve, + }); + bindings.set(MAINNET_ACCOUNT, agentSigner); mockClientService.isTestnetMode.mockReturnValue(true); await accountSignerProvider.getMarketDataWithPrices(); @@ -2784,16 +2878,17 @@ describe('HyperLiquidProvider', () => { ]); }); - it('pins the main account with setAgentSigner(null) without asking getAgentSigner', async () => { + 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, + getAgentSigner: bindings.resolve, }); getAgentSigner.mockResolvedValue(agentSigner); - accountSignerProvider.setAgentSigner(MAINNET_ACCOUNT, null); + bindings.set(MAINNET_ACCOUNT, null); await accountSignerProvider.getMarketDataWithPrices(); rememberMigration(); await accountSignerProvider.prepareTradingWallet(); @@ -2805,15 +2900,16 @@ describe('HyperLiquidProvider', () => { 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, + getAgentSigner: bindings.resolve, }); const reading = accountSignerProvider.getMarketDataWithPrices(); await asked; - accountSignerProvider.setAgentSigner(MAINNET_ACCOUNT, null); + bind(accountSignerProvider, bindings, MAINNET_ACCOUNT, null); answer.resolve(agentSigner); await reading; @@ -2823,17 +2919,18 @@ describe('HyperLiquidProvider', () => { ]); }); - it('keeps an agent set while a failing getAgentSigner answer is pending', async () => { + 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 } = createAccountSignerProvider({ abstraction: 'default', - getAgentSigner, + getAgentSigner: bindings.resolve, }); const reading = accountSignerProvider.getMarketDataWithPrices(); await asked; - accountSignerProvider.setAgentSigner(MAINNET_ACCOUNT, agentSigner); + bind(accountSignerProvider, bindings, MAINNET_ACCOUNT, agentSigner); answer.reject(new Error('agent store unavailable')); await reading; rememberMigration(); @@ -2887,18 +2984,20 @@ describe('HyperLiquidProvider', () => { expect(getAgentSigner).toHaveBeenCalledTimes(3); }); - it('clears pins made with setAgentSigner(null)', async () => { + it('asks getAgentSigner again once the bindings are cleared', async () => { const getAgentSigner = jest.fn(); + const bindings = new AgentBindings(getAgentSigner); const { accountSignerProvider, agentSigner, initialize } = createAccountSignerProvider({ abstraction: 'unifiedAccount', - getAgentSigner, + getAgentSigner: bindings.resolve, }); getAgentSigner.mockResolvedValue(agentSigner); - accountSignerProvider.setAgentSigner(MAINNET_ACCOUNT, null); + bindings.set(MAINNET_ACCOUNT, null); await accountSignerProvider.getMarketDataWithPrices(); const [[wallet]] = initialize.mock.calls; + bindings.clear(); accountSignerProvider.clearAgentSigners(); await wallet.signTypedData(L1_PAYLOAD); @@ -2922,6 +3021,30 @@ describe('HyperLiquidProvider', () => { (PerpsSigningCache as jest.Mocked) .setReferral, ).not.toHaveBeenCalled(); + expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled(); + }); + + it('retries the referral instead of recording a failure when the agent fails to sign', async () => { + const getAgentSigner = jest.fn(); + const { accountSignerProvider, agentSigner, exchangeClient } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + }); + agentSigner.signTypedData.mockRejectedValue( + new Error('agent key locked'), + ); + getAgentSigner.mockResolvedValue(agentSigner); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: false }); + expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); + expect( + (PerpsSigningCache as jest.Mocked) + .setReferral, + ).not.toHaveBeenCalled(); + expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled(); }); it('treats a getAgentSigner that throws synchronously like a rejection', async () => { 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 index 1b2a2aaf054..2d7e373455c 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts @@ -1,7 +1,10 @@ import { LIGHTER_TX_TYPE_CHANGE_PUB_KEY } from '../../../src/constants/lighterConfig.js'; import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; import { LighterProvider } from '../../../src/providers/LighterProvider.js'; -import { LighterClientService } from '../../../src/services/LighterClientService.js'; +import { + LighterApiError, + LighterClientService, +} from '../../../src/services/LighterClientService.js'; import type { LighterSignerBridge, LighterSignerOperation, @@ -9,6 +12,7 @@ import type { LighterWasmCall, } from '../../../src/types/lighter-types.js'; import { + createKeyringMessenger, createKeyringlessMessenger, createMockEvmAccount, createMockInfrastructure, @@ -84,14 +88,28 @@ function createBridge(): { type BuiltProvider = { provider: LighterProvider; address: string; - client: { sendTx: jest.Mock }; + client: { sendTx: jest.Mock; getAccountsByL1Address: jest.Mock }; accountSigner: { signPersonalMessage: jest.Mock }; call: jest.SpyInstance; calls: LighterWasmCall[]; deps: ReturnType; }; -function buildProvider(isReady?: () => boolean): BuiltProvider { +type BuildOptions = { + isReady?: () => boolean; + // Sign through a KeyringController instead of accountSigner. + keyring?: boolean; + // Find the account by L1 address instead of a configured index. + findAccountByAddress?: boolean; + withoutBridge?: boolean; +}; + +function buildProvider({ + isReady, + keyring = false, + findAccountByAddress = false, + withoutBridge = false, +}: BuildOptions = {}): BuiltProvider { const { address } = createMockEvmAccount(); const account = { code: 0, @@ -126,18 +144,22 @@ function buildProvider(isReady?: () => boolean): BuiltProvider { signPersonalMessage: jest.fn().mockResolvedValue(L1_SIGNATURE), isReady, }; - const { messenger, call } = createKeyringlessMessenger(); + const { messenger, call } = keyring + ? createKeyringMessenger(L1_SIGNATURE) + : createKeyringlessMessenger(); const { bridge, calls } = createBridge(); - const deps = { ...createMockInfrastructure(), accountSigner }; + const deps = keyring + ? createMockInfrastructure() + : { ...createMockInfrastructure(), accountSigner }; const provider = new LighterProvider({ isTestnet: true, platformDependencies: deps, messenger, lighterAuthConfig: { - accountIndex: ACCOUNT_INDEX, + accountIndex: findAccountByAddress ? undefined : ACCOUNT_INDEX, apiKeyIndex: API_KEY_INDEX, }, - signerBridge: bridge, + signerBridge: withoutBridge ? undefined : bridge, webSocketCtor: null, }); return { provider, address, client, accountSigner, call, calls, deps }; @@ -172,9 +194,9 @@ describe('LighterProvider with accountSigner', () => { }); it('reports KEYRING_LOCKED and registers nothing when accountSigner is not ready', async () => { - const { provider, client, accountSigner, call, calls } = buildProvider( - () => false, - ); + const { provider, client, accountSigner, call, calls } = buildProvider({ + isReady: () => false, + }); const result = await provider.isReadyToTrade(); @@ -210,7 +232,7 @@ describe('LighterProvider with accountSigner', () => { it('reports KEYRING_LOCKED from prepareTradingWallet once the signer locks, even with a registered venue key', async () => { let signerReady = true; - const { provider } = buildProvider(() => signerReady); + const { provider } = buildProvider({ isReady: () => signerReady }); const firstResult = await provider.prepareTradingWallet(); signerReady = false; @@ -223,17 +245,95 @@ describe('LighterProvider with accountSigner', () => { }); }); - it('logs a failed prepareTradingWallet', async () => { + it('logs a failed prepareTradingWallet with the original error', async () => { const { provider, client, deps } = buildProvider(); - client.sendTx.mockRejectedValue(new Error('venue unavailable')); + const failure = new Error('venue unavailable'); + client.sendTx.mockRejectedValue(failure); + const loggerError = jest.spyOn(deps.logger, 'error'); + + const result = await provider.prepareTradingWallet(); + + expect(result.ready).toBe(false); + expect(loggerError).toHaveBeenCalledTimes(1); + const [[loggedError, context]] = loggerError.mock.calls; + expect(loggedError.message).toBe(result.error); + expect(loggedError === failure || loggedError.cause === failure).toBe(true); + expect(context).toStrictEqual({ + tags: { + feature: 'perps', + provider: 'LighterProvider', + network: 'testnet', + }, + context: { + name: 'LighterProvider.prepareTradingWallet', + data: { isTestnet: true }, + }, + }); + }); + + it('does not log a declined venue-key signature', async () => { + const { provider, accountSigner, deps } = buildProvider(); + accountSigner.signPersonalMessage.mockRejectedValue( + Object.assign(new Error('User rejected the request.'), { code: 4001 }), + ); const loggerError = jest.spyOn(deps.logger, 'error'); const result = await provider.prepareTradingWallet(); expect(result.ready).toBe(false); - expect(loggerError).toHaveBeenCalledWith( - expect.objectContaining({ message: result.error }), - expect.anything(), + expect(result.error).toContain('User rejected'); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('does not log a wallet that has no Lighter account yet', async () => { + const { provider, client, deps } = buildProvider({ + findAccountByAddress: true, + }); + client.getAccountsByL1Address.mockRejectedValue( + new LighterApiError('account not found', 21100), + ); + const loggerError = jest.spyOn(deps.logger, 'error'); + + const result = await provider.prepareTradingWallet(); + + expect(result.ready).toBe(false); + expect(result.error).toContain('No Lighter account exists'); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('reports a missing signer bridge without logging', async () => { + const { provider, deps } = buildProvider({ withoutBridge: true }); + const loggerError = jest.spyOn(deps.logger, 'error'); + + const result = await provider.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: 'Lighter signer bridge not configured', + }); + expect(loggerError).not.toHaveBeenCalled(); + }); +}); + +describe('LighterProvider with a KeyringController', () => { + it('registers the venue key through prepareTradingWallet with a keyring signature', async () => { + const { provider, client, call, calls } = buildProvider({ keyring: true }); + + const result = await provider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: true }); + expect(keyringCalls(call)).toStrictEqual([ + 'KeyringController:getState', + 'KeyringController:getState', + 'KeyringController:signPersonalMessage', + ]); + const changePubKey = calls.find( + (wasmCall) => wasmCall.function === '_signChangePubKey', + ); + expect(changePubKey?.params[1]).toBe(L1_SIGNATURE); + expect(client.sendTx).toHaveBeenCalledWith( + LIGHTER_TX_TYPE_CHANGE_PUB_KEY, + expect.stringContaining('"changePubKey":true'), ); }); }); 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 index 05502eba038..4691e7cc44b 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts @@ -5,6 +5,10 @@ import { recoverTypedDataAddress } from 'viem'; import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; 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, @@ -280,6 +284,20 @@ describe('HyperLiquidWalletService wallet adapter with an agent', () => { ); expect(mainSign).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(TYPED_DATA) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(AgentSignerUnavailableError); + expect((error as Error).cause).toBe(failure); + expect(mainSign).not.toHaveBeenCalled(); + }); }); describe('HyperLiquidWalletService wallet adapter with an agent and a keyring', () => { @@ -375,7 +393,11 @@ describeWithSdk( type RecordedSignature = { payload: PerpsTypedDataPayload; signature: Hex }; - function buildSdkAdapter(): { + function buildSdkAdapter( + resolveAgent: () => Promise = async () => + // A viem local account is a PerpsAgentSigner as it is. + agentAccount, + ): { adapter: ReturnType; signatures: RecordedSignature[]; agentSignatures: () => Promise; @@ -402,11 +424,7 @@ describeWithSdk( }, }, messenger, - { - isTestnet: true, - // A viem local account is a PerpsAgentSigner as it is. - resolveAgent: async (): Promise => agentAccount, - }, + { isTestnet: true, resolveAgent }, ); const agentSign = jest.spyOn(agentAccount, 'signTypedData'); return { @@ -479,5 +497,43 @@ describeWithSdk( 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/LighterWalletService.account-signer.test.ts b/packages/perps-controller/tests/src/services/LighterWalletService.account-signer.test.ts index 3f1ae27ea55..80e5ef3d707 100644 --- a/packages/perps-controller/tests/src/services/LighterWalletService.account-signer.test.ts +++ b/packages/perps-controller/tests/src/services/LighterWalletService.account-signer.test.ts @@ -1,6 +1,7 @@ import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; import { LighterWalletService } from '../../../src/services/LighterWalletService.js'; import { + createKeyringMessenger, createKeyringlessMessenger, createMockEvmAccount, createMockInfrastructure, @@ -68,3 +69,43 @@ describe('LighterWalletService with accountSigner', () => { 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("follows the keyring's unlock state without an account signer", () => { + const { messenger, call } = createKeyringMessenger(SIGNATURE); + const service = new LighterWalletService(createMockInfrastructure(), { + isTestnet: true, + messenger, + }); + + expect(service.isMainAccountSignerReady()).toBe(true); + 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); + }); +}); From 4f91876e39a1704f0bd5ffd621054ce1d6e9748b Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 29 Sep 2026 08:40:48 +0800 Subject: [PATCH 12/33] fix(perps-controller): recover from rejected agents and keep signer failures retryable - An agent the venue rejects as unknown (revoked or expired) is evicted, with a setAgentSigner binding to it, so the next L1 action asks getAgentSigner again. The rejection no longer reads as a wallet with no HyperLiquid account in orders, the silent migration or the referral. - Orders and other exchange writes that fail because the keyring is locked or the agent is unavailable fail with KEYRING_LOCKED, and a failed order for that reason is not reported as an error. - A provider waiting on another provider's referral attempt makes its own when that attempt cached nothing. - Lighter prepareTradingWallet resolves not ready without an error when the user declines or has no Lighter account yet, and logs a missing signer bridge. The aggregated provider tags logged errors with the network. - Rename PerpsAccountSigner.isHardwareWallet to requiresSignatureConfirmation, which covers interactive wallets too. - Add a controller-level test that drives agent bindings through a real HyperLiquid provider and wallet service. --- packages/perps-controller/CHANGELOG.md | 10 +- .../PerpsController-method-action-types.ts | 3 + .../perps-controller/src/PerpsController.ts | 6 + .../src/providers/AggregatedPerpsProvider.ts | 6 + .../src/providers/HyperLiquidProvider.ts | 118 +++++++- .../src/providers/LighterProvider.ts | 45 +-- .../src/services/HyperLiquidWalletService.ts | 12 +- .../src/services/agentSigner.ts | 16 ++ packages/perps-controller/src/types/index.ts | 10 +- .../perps-controller/src/utils/errorUtils.ts | 19 +- ...ntroller.agent-signing.integration.test.ts | 254 +++++++++++++++++ .../PerpsController.providers-cache.test.ts | 46 +++- .../providers/AggregatedPerpsProvider.test.ts | 34 ++- .../HyperLiquidProvider.account-mode.test.ts | 259 +++++++++++++++++- .../LighterProvider.account-signer.test.ts | 63 ++++- ...LiquidWalletService.account-signer.test.ts | 14 +- 16 files changed, 839 insertions(+), 76 deletions(-) create mode 100644 packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 9f8ac40cfd8..12404c54026 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -16,16 +16,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 - - `isHardwareWallet()` defers HyperLiquid's optional init-time signing prompts like a hardware keyring does; when omitted, the selected account's keyring type decides + - `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 it returns is kept for the provider's lifetime or until `setAgentSigner`/`clearAgentSigners`, while `null` and failures (including an agent whose signing rejects) are asked again at the next L1 action + - 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 it returns is kept for the provider's lifetime or until `setAgentSigner`/`clearAgentSigners`, while `null` and failures are asked again at the next L1 action; an agent whose signing rejects fails that action 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 and they can be set before `init` - 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` - 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 setup that needs a main-account signature before the first order: account migration, builder fee and referral on HyperLiquid, venue-key registration on Lighter ([#10559](https://github.com/MetaMask/core/pull/10559)) - Resolves a `ReadyToTradeResult` that is `ready: true` once the main-account signer is ready and none of these steps will ask it to sign again before the first order; the aggregated provider prepares every provider in turn + - Add optional `isTestnet` to `AggregatedProviderConfig`, which tags the errors the aggregated provider logs ### Removed @@ -33,6 +35,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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 orders and other exchange writes that fail because the signer cannot sign (a locked keyring) now fail with `KEYRING_LOCKED` instead of the SDK's "Failed to sign the typed data using the wallet" message, and a failed order for that reason is no longer reported as an error ([#10559](https://github.com/MetaMask/core/pull/10559)) + ## [18.0.1] ### Fixed diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index 52bb40c7077..e45ab7f97e7 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -948,6 +948,9 @@ export type PerpsControllerClearAgentSignersAction = { * @returns `ready: true` when none of these steps will ask the main account * to sign again before the first order; providers without deferred setup * are ready. + * @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`; diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 15b6ce2994e..8e4b9be2edb 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -2376,6 +2376,8 @@ export class PerpsController extends BaseController< ?.subscriptionBuilderAddressMainnet, onChaseOrderMaxDistanceReached: this.#publishChaseOrderMaxDistanceReached, getAgentSigner: this.#agentBindings.resolve, + onAgentRejected: (account, agentAddress): void => + this.#agentBindings.release(account, agentAddress), }); this.providers.set('hyperliquid', hyperLiquidProvider); @@ -2484,6 +2486,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)', @@ -5915,6 +5918,9 @@ export class PerpsController extends BaseController< * @returns `ready: true` when none of these steps will ask the main account * to sign again before the first order; providers without deferred setup * are ready. + * @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(); diff --git a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts index d1cd535bd42..966117e0da7 100644 --- a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts +++ b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts @@ -158,6 +158,8 @@ export class AggregatedPerpsProvider implements PerpsProvider { readonly #deps: PerpsPlatformDependencies; + readonly #isTestnet: boolean | undefined; + readonly #router: ProviderRouter; readonly #subscriptionMux: SubscriptionMultiplexer; @@ -167,6 +169,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({ @@ -1073,6 +1076,9 @@ export class AggregatedPerpsProvider implements PerpsProvider { tags: { feature: PERPS_CONSTANTS.FeatureName, provider: providerId, + ...(this.#isTestnet !== undefined && { + network: this.#isTestnet ? 'testnet' : 'mainnet', + }), }, context: { name: 'AggregatedPerpsProvider.prepareTradingWallet', diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 19c559e8aab..3cbae102272 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -844,6 +844,8 @@ type HyperLiquidProviderOptions = { subscriptionBuilderAddressMainnet?: string; onChaseOrderMaxDistanceReached?: ChaseOrderMaxDistanceReachedHandler; getAgentSigner?: HyperLiquidCredentials['getAgentSigner']; + // Told when the venue rejects a resolved agent (revoked or expired). + onAgentRejected?: (account: PerpsAgentAccount, agentAddress: Hex) => void; }; type HandleHip3PreOrderParams = { @@ -1554,6 +1556,15 @@ export class HyperLiquidProvider implements PerpsProvider { readonly #agentSigners = new Map>(); + // The agents those answers resolved to, so a venue rejection of one can be + // matched to its account and evicted. + readonly #resolvedAgents = new Map< + string, + { account: PerpsAgentAccount; agentSigner: PerpsAgentSigner } + >(); + + readonly #onAgentRejected: HyperLiquidProviderOptions['onAgentRejected']; + // Promise-based lock to prevent race conditions in concurrent initialization #initializationPromise: Promise | null = null; @@ -1589,6 +1600,7 @@ export class HyperLiquidProvider implements PerpsProvider { this.#subscriptionBuilderAddressMainnet = options.subscriptionBuilderAddressMainnet; this.#getAgentSigner = options.getAgentSigner; + this.#onAgentRejected = options.onAgentRejected; this.#onChaseOrderMaxDistanceReached = options.onChaseOrderMaxDistanceReached; this.#priceDeviationLimit = @@ -2119,12 +2131,65 @@ export class HyperLiquidProvider implements PerpsProvider { if (isSuperseded()) { return await this.#resolveAgentSigner(mainAddress); } - if (!agentSigner) { + if (agentSigner) { + this.#resolvedAgents.set(key, { account, agentSigner }); + } else { this.#agentSigners.delete(key); + this.#resolvedAgents.delete(key); } return agentSigner; } + /** + * The key of the resolved agent a venue rejection names. 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 agent key, or undefined when no resolved agent is rejected. + */ + #findRejectedAgentKey(error: unknown): string | undefined { + if (!isHyperLiquidUserNotFoundError(error)) { + return undefined; + } + const rejected = /user or api wallet (0x[0-9a-f]{40})/iu + .exec( + ensureError(error, 'HyperLiquidProvider.findRejectedAgentKey').message, + )?.[1] + ?.toLowerCase(); + for (const [key, { agentSigner }] of this.#resolvedAgents) { + if (agentSigner.address.toLowerCase() === rejected) { + return key; + } + } + return undefined; + } + + /** + * Evict a resolved agent the venue rejected, 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 a rejection of a resolved agent. + */ + #evictRejectedAgent(error: unknown): boolean { + const key = this.#findRejectedAgentKey(error); + const rejected = + key === undefined ? undefined : this.#resolvedAgents.get(key); + if (key === undefined || !rejected) { + return false; + } + this.#agentSigners.delete(key); + this.#resolvedAgents.delete(key); + this.#deps.debugLogger.log( + 'HyperLiquidProvider: agent rejected by the venue, asking again', + { agent: rejected.agentSigner.address }, + ); + this.#onAgentRejected?.(rejected.account, rejected.agentSigner.address); + return true; + } + /** * Decide whether the wallet has a Hyperliquid account. * @@ -2522,7 +2587,10 @@ export class HyperLiquidProvider implements PerpsProvider { return; } - if (isAgentSignerUnavailableError(error)) { + if ( + isAgentSignerUnavailableError(error) || + this.#evictRejectedAgent(error) + ) { this.#deps.debugLogger.log( '[ensureUnifiedAccountEnabled] Agent signer unavailable, will retry later', ); @@ -4128,6 +4196,17 @@ export class HyperLiquidProvider implements PerpsProvider { #mapError(error: unknown): Error { const { message } = ensureError(error, 'HyperLiquidProvider.mapError'); + // The signer could not sign this action: a locked keyring, an unavailable + // agent, or an agent the venue rejected (revoked or expired; evicted here + // so the next action asks for one again). The next attempt retries. + if ( + isKeyringLockedError(error) || + isAgentSignerUnavailableError(error) || + this.#evictRejectedAgent(error) + ) { + return new Error(PERPS_ERROR_CODES.KEYRING_LOCKED); + } + // "User or API Wallet 0x... does not exist." carries the user's address, so // it cannot be matched by the static substring table below. It means the // wallet has no Hyperliquid account yet — surface an actionable code the @@ -5530,6 +5609,15 @@ export class HyperLiquidProvider implements PerpsProvider { const { error, symbol, orderType, isBuy } = params; const mappedError = this.#mapError(error); + // The signer could not sign (see #mapError): retryable, not a defect. + if (mappedError.message === PERPS_ERROR_CODES.KEYRING_LOCKED) { + this.#deps.debugLogger.log( + '[handleOrderError] Signer unavailable, the order can be retried', + { symbol, orderType, isBuy }, + ); + return createErrorResult(mappedError, { success: false }); + } + // A wallet with no Hyperliquid account is an expected pre-account state, // not an app defect — same policy already applied to every other // user-scoped exchange write in this provider. Keep it out of Sentry; the @@ -14198,6 +14286,7 @@ export class HyperLiquidProvider implements PerpsProvider { clearAgentSigners(): void { this.#agentSignersGeneration += 1; this.#agentSigners.clear(); + this.#resolvedAgents.clear(); } /** @@ -15373,7 +15462,11 @@ export class HyperLiquidProvider implements PerpsProvider { { network }, ); await inFlightPromise; - return; + // The other attempt may have ended without caching a result (a locked + // keyring or an unavailable agent), so make our own attempt then. + if (PerpsSigningCache.getReferral(network, userAddress)?.attempted) { + return; + } } // Set global in-flight lock @@ -15456,7 +15549,10 @@ export class HyperLiquidProvider implements PerpsProvider { return; } - if (isAgentSignerUnavailableError(error)) { + if ( + isAgentSignerUnavailableError(error) || + this.#evictRejectedAgent(error) + ) { this.#deps.debugLogger.log( '[ensureReferralSet] Agent signer unavailable, will retry later', ); @@ -15626,6 +15722,15 @@ export class HyperLiquidProvider implements PerpsProvider { return result?.status === 'ok'; } catch (error) { + // Retryable (an unavailable agent, or one the venue rejected): + // `#ensureReferralSet` retries at the next entry, so it is not an error + // to report. + if ( + isAgentSignerUnavailableError(error) || + this.#findRejectedAgentKey(error) !== undefined + ) { + throw error; + } // Benign for unfunded wallets — downgrade and rethrow so the outer // `#ensureReferralSet` catch self-heals the walletRegistered gate // without forwarding to Sentry. @@ -15639,11 +15744,6 @@ export class HyperLiquidProvider implements PerpsProvider { ); throw error; } - // Retryable: `#ensureReferralSet` records it and retries at the next - // entry, so it is not an error to report. - if (isAgentSignerUnavailableError(error)) { - throw error; - } this.#deps.logger.error( ensureError(error, 'HyperLiquidProvider.setReferralCode'), this.#getErrorContext('setReferralCode', { diff --git a/packages/perps-controller/src/providers/LighterProvider.ts b/packages/perps-controller/src/providers/LighterProvider.ts index e588569700b..d2c34d801f5 100644 --- a/packages/perps-controller/src/providers/LighterProvider.ts +++ b/packages/perps-controller/src/providers/LighterProvider.ts @@ -993,20 +993,23 @@ class LighterAccountNotFoundError extends Error { } } +// EIP-1193 `userRejectedRequest` error code. +const USER_REJECTED_REQUEST_CODE = 4001; + /** - * Whether preparing the wallet stopped for an expected reason: a locked or - * declined signature, or a wallet with no Lighter account yet. + * Whether preparing the wallet stopped in a way the order path retries: the + * user declined the venue-key signature, or the wallet has no Lighter account + * yet. * * @param error - The caught error. - * @returns True when the outcome is not an error to report. + * @returns True when registration will be asked again, not a failure. */ -const isExpectedPreparationFailure = (error: unknown): boolean => - isKeyringLockedError(error) || +const isRetryablePreparationStop = (error: unknown): boolean => hasErrorInCauseChain( error, (current) => current instanceof LighterAccountNotFoundError || - (current as { code?: unknown }).code === 4001 || + (current as { code?: unknown }).code === USER_REJECTED_REQUEST_CODE || /user (rejected|denied)/iu.test(current.message), ); @@ -1299,32 +1302,42 @@ export class LighterProvider implements PerpsProvider { * `personal_sign` surfaces in a guided session instead of at order time. * * @returns `ready: true` once the venue key is registered; otherwise - * `ready: false` with `KEYRING_LOCKED` whenever the main-account signer is - * not ready (even with a registered venue key), or with the error that - * stopped registration. Unexpected errors are logged; a locked or declined - * signature and a wallet with no Lighter account yet are not. + * `ready: false`: with `KEYRING_LOCKED` whenever the main-account signer is + * not ready (even with a registered venue key), without an error when the + * order path will ask again (the user declined the signature, or the + * wallet has no Lighter account yet), and with the logged error when + * registration failed. */ async prepareTradingWallet(): Promise { if (!this.#walletService.isMainAccountSignerReady()) { return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; } if (!this.#signerBridge) { + // The host enabled Lighter without its signer: a configuration error. + this.#deps.logger.error( + new Error(LIGHTER_SIGNER_UNAVAILABLE_ERROR), + this.#getErrorContext('prepareTradingWallet'), + ); return { ready: false, error: LIGHTER_SIGNER_UNAVAILABLE_ERROR }; } try { await this.#ensureSignerReady(); return { ready: true }; } catch (caughtError) { + if (isKeyringLockedError(caughtError)) { + return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; + } + if (isRetryablePreparationStop(caughtError)) { + return { ready: false }; + } const error = ensureError( caughtError, 'LighterProvider.prepareTradingWallet', ); - if (!isExpectedPreparationFailure(caughtError)) { - this.#deps.logger.error( - error, - this.#getErrorContext('prepareTradingWallet'), - ); - } + this.#deps.logger.error( + error, + this.#getErrorContext('prepareTradingWallet'), + ); return { ready: false, error: error.message }; } } diff --git a/packages/perps-controller/src/services/HyperLiquidWalletService.ts b/packages/perps-controller/src/services/HyperLiquidWalletService.ts index bc5642c3b36..8bc38d36bf6 100644 --- a/packages/perps-controller/src/services/HyperLiquidWalletService.ts +++ b/packages/perps-controller/src/services/HyperLiquidWalletService.ts @@ -97,14 +97,16 @@ export class HyperLiquidWalletService { } /** - * Check whether the selected EVM account is backed by hardware. The - * injected account signer's `isHardwareWallet()` decides when it answers; - * otherwise the selected account's keyring type does. + * 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 hardware-backed accounts; false for software accounts. + * @returns True when signatures need a confirmation; false otherwise. */ public isSelectedHardwareWallet(): boolean { - const declared = this.#deps.accountSigner?.isHardwareWallet?.(); + const declared = + this.#deps.accountSigner?.requiresSignatureConfirmation?.(); if (declared !== undefined) { return declared; } diff --git a/packages/perps-controller/src/services/agentSigner.ts b/packages/perps-controller/src/services/agentSigner.ts index 3c25b1ca217..4abaf61edd0 100644 --- a/packages/perps-controller/src/services/agentSigner.ts +++ b/packages/perps-controller/src/services/agentSigner.ts @@ -61,6 +61,22 @@ export class AgentBindings { 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 = this.#getKey(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. diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 5394d0cefdc..ccb18bf495f 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1126,8 +1126,10 @@ export type HyperLiquidCredentials = { * `setAgentSigner`/`clearAgentSigners`; null is not kept, so it is asked * again at the next L1 action. With an agent, L1 actions are signed by the * agent key and user-signed actions (builder fee, withdraw, ...) by the main - * account. A rejection, or an agent whose `signTypedData` rejects, fails - * that action and is retried at the next one. + * account. A rejection fails that action and is asked again at the next + * one. An agent whose `signTypedData` rejects fails that action and stays + * in use, so call `PerpsController:clearAgentSigners` when the agent key + * locks. */ getAgentSigner?: ( account: PerpsAgentAccount, @@ -2264,6 +2266,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; }; /** @@ -2665,7 +2669,7 @@ export type PerpsAccountSigner = { * defers its optional init-time signing prompts to action time. When * omitted, the selected account's keyring type decides. */ - isHardwareWallet?(): boolean; + requiresSignatureConfirmation?(): boolean; }; /** 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/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..66953c9a321 --- /dev/null +++ b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts @@ -0,0 +1,254 @@ +import type { Hex } from '@metamask/utils'; + +import { + getDefaultPerpsControllerState, + PerpsController, +} from '../../src/PerpsController.js'; +import { HyperLiquidClientService } from '../../src/services/HyperLiquidClientService.js'; +import type { HyperLiquidWalletParams } from '../../src/services/HyperLiquidClientService.js'; +import type { + PerpsAgentAccount, + PerpsAgentSigner, + PerpsTypedDataPayload, +} from '../../src/types/index.js'; +import { + createMockEvmAccount, + createMockInfrastructure, + createMockMessenger, +} from '../helpers/serviceMocks.js'; + +// The controller builds a real HyperLiquidProvider and wallet service; only +// the SDK and its client service are mocked. +jest.mock('@nktkas/hyperliquid', () => ({})); +jest.mock('../../src/services/HyperLiquidClientService', () => ({ + HyperLiquidClientService: jest.fn(), + WebSocketConnectionState: jest.requireActual< + typeof import('../../src/types/index.js') + >('../../src/types/index').WebSocketConnectionState, +})); + +const MockedClientService = HyperLiquidClientService as jest.MockedClass< + typeof HyperLiquidClientService +>; + +const MAIN_ADDRESS = createMockEvmAccount().address; +const MAIN_SIGNATURE = `0x${'ab'.repeat(65)}` as const; +const AGENT_SIGNATURE = `0x${'cd'.repeat(65)}` as const; +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as const; +// The controller starts on mainnet (default state). +const MAINNET_ACCOUNT: PerpsAgentAccount = { + mainAddress: MAIN_ADDRESS, + isTestnet: false, +}; + +const USER_SIGNED_PAYLOAD: PerpsTypedDataPayload = { + domain: { + name: 'HyperliquidSignTransaction', + version: '1', + chainId: 1, + verifyingContract: ZERO_ADDRESS, + }, + types: { + '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, + }, +}; + +const L1_PAYLOAD: PerpsTypedDataPayload = { + domain: { + name: 'Exchange', + version: '1', + chainId: 1337, + verifyingContract: ZERO_ADDRESS, + }, + types: { + Agent: [ + { name: 'source', type: 'string' }, + { name: 'connectionId', type: 'bytes32' }, + ], + }, + primaryType: 'Agent', + message: { source: 'a', connectionId: `0x${'22'.repeat(32)}` }, +}; + +type ClientServiceMock = { + initialize: jest.Mock, [HyperLiquidWalletParams]>; + isTestnetMode: () => boolean; +}; + +describe('PerpsController agent signing with a real HyperLiquid provider', () => { + let clientServices: ClientServiceMock[]; + let accountSigner: { + signTypedData: jest.Mock; + signPersonalMessage: jest.Mock; + }; + let agentSigner: PerpsAgentSigner & { signTypedData: jest.Mock }; + let getAgentSigner: jest.Mock; + + beforeEach(() => { + clientServices = []; + MockedClientService.mockImplementation((_deps, options) => { + const isTestnet = options?.isTestnet ?? false; + const clientService = { + initialize: jest + .fn, [HyperLiquidWalletParams]>() + .mockResolvedValue(undefined), + isInitialized: jest.fn().mockReturnValue(true), + isTestnetMode: (): boolean => isTestnet, + ensureInitialized: jest.fn(), + getInfoClient: jest.fn().mockReturnValue({ + twapHistory: jest.fn().mockResolvedValue([]), + userTwapSliceFills: jest.fn().mockResolvedValue([]), + }), + getSubscriptionClient: jest.fn(), + setOnTerminateCallback: jest.fn(), + setOnReconnectCallback: jest.fn(), + disconnect: jest.fn().mockResolvedValue(undefined), + }; + clientServices.push(clientService); + return clientService as unknown as HyperLiquidClientService; + }); + accountSigner = { + signTypedData: jest.fn().mockResolvedValue(MAIN_SIGNATURE), + signPersonalMessage: jest.fn(), + }; + agentSigner = { + address: '0x00000000000000000000000000000000000a9e17', + signTypedData: jest.fn().mockResolvedValue(AGENT_SIGNATURE), + }; + getAgentSigner = jest.fn().mockResolvedValue(agentSigner); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + /** + * Build a controller whose host signs with `accountSigner` and resolves + * agents with `getAgentSigner`. + * + * @returns The controller. + */ + function createController(): PerpsController { + return new PerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + clientConfig: { + providerCredentials: { hyperliquid: { getAgentSigner } }, + }, + infrastructure: { ...createMockInfrastructure(), accountSigner }, + deferEligibilityCheck: true, + }); + } + + /** + * Make the active HyperLiquid provider initialize its SDK clients, and + * return the wallet adapter it handed to them. + * + * @param controller - The initialized controller. + * @returns The wallet adapter the SDK signs with. + */ + async function getSdkWallet( + controller: PerpsController, + ): Promise { + await controller.getTwapOrders(); + const initialized = clientServices.filter( + (clientService) => clientService.initialize.mock.calls.length > 0, + ); + const latest = initialized[initialized.length - 1]; + if (!latest) { + throw new Error('The provider never initialized its SDK clients'); + } + const [[wallet]] = latest.initialize.mock.calls; + return wallet; + } + + it("signs L1 actions with the host's agent and user-signed actions with the main account", async () => { + const controller = createController(); + await controller.init(); + const wallet = await getSdkWallet(controller); + + const l1Signature: Hex = await wallet.signTypedData(L1_PAYLOAD); + const userSignature: Hex = await wallet.signTypedData(USER_SIGNED_PAYLOAD); + + expect(l1Signature).toBe(AGENT_SIGNATURE); + expect(userSignature).toBe(MAIN_SIGNATURE); + expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); + expect(agentSigner.signTypedData.mock.calls).toStrictEqual([[L1_PAYLOAD]]); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [MAIN_ADDRESS, USER_SIGNED_PAYLOAD], + ]); + }); + + it('pins L1 actions to the main account with setAgentSigner(null) until clearAgentSigners', async () => { + const controller = createController(); + await controller.init(); + const wallet = await getSdkWallet(controller); + + controller.setAgentSigner(MAINNET_ACCOUNT, null); + const pinnedSignature = await wallet.signTypedData(L1_PAYLOAD); + controller.clearAgentSigners(); + const clearedSignature = await wallet.signTypedData(L1_PAYLOAD); + + expect(pinnedSignature).toBe(MAIN_SIGNATURE); + expect(clearedSignature).toBe(AGENT_SIGNATURE); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [MAIN_ADDRESS, L1_PAYLOAD], + ]); + expect(getAgentSigner).toHaveBeenCalledTimes(1); + }); + + it('keeps a setAgentSigner binding when the HyperLiquid provider is re-created', async () => { + getAgentSigner.mockResolvedValue(null); + const boundAgent = { + address: '0x00000000000000000000000000000000000b0a7d' as const, + signTypedData: jest.fn().mockResolvedValue(AGENT_SIGNATURE), + }; + const controller = createController(); + await controller.init(); + controller.setAgentSigner(MAINNET_ACCOUNT, boundAgent); + + await controller.toggleTestnet(); + await controller.toggleTestnet(); + const wallet = await getSdkWallet(controller); + const signature = await wallet.signTypedData(L1_PAYLOAD); + + // The initial, the testnet and the mainnet provider each own a client. + expect( + clientServices.map(({ isTestnetMode }) => isTestnetMode()), + ).toStrictEqual([false, true, false]); + expect(signature).toBe(AGENT_SIGNATURE); + expect(boundAgent.signTypedData.mock.calls).toStrictEqual([[L1_PAYLOAD]]); + expect(getAgentSigner).not.toHaveBeenCalled(); + expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + }); + + it('honors a setAgentSigner binding made before init', async () => { + getAgentSigner.mockResolvedValue(null); + const boundAgent = { + address: '0x00000000000000000000000000000000000b0a7d' as const, + signTypedData: jest.fn().mockResolvedValue(AGENT_SIGNATURE), + }; + const controller = createController(); + + controller.setAgentSigner(MAINNET_ACCOUNT, boundAgent); + await controller.init(); + const wallet = await getSdkWallet(controller); + const signature = await wallet.signTypedData(L1_PAYLOAD); + + expect(signature).toBe(AGENT_SIGNATURE); + expect(boundAgent.signTypedData.mock.calls).toStrictEqual([[L1_PAYLOAD]]); + expect(getAgentSigner).not.toHaveBeenCalled(); + }); +}); 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 cd6131fa789..abc2c17fc50 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -1105,6 +1105,40 @@ describe('PerpsController', () => { expect(mockProvider.clearAgentSigners).toHaveBeenCalledTimes(2); }); + it('drops only a binding to the agent the venue rejected', async () => { + const getAgentSigner = jest.fn().mockResolvedValue(null); + controller = createAgentController(getAgentSigner); + await controller.init(); + const { calls } = ( + HyperLiquidProvider as jest.MockedClass + ).mock; + const { onAgentRejected } = calls[calls.length - 1][0]; + const resolve = getProviderAgentResolver(); + const otherAgent = '0x00000000000000000000000000000000000b0b02'; + + controller.setAgentSigner(account, agentSigner); + onAgentRejected?.(account, otherAgent); + const keptForOtherAgent = await resolve(account); + onAgentRejected?.(account, agentSigner.address); + const afterRejection = await resolve(account); + controller.setAgentSigner(account, null); + onAgentRejected?.(account, agentSigner.address); + const pinKept = await resolve(account); + + expect(keptForOtherAgent).toBe(agentSigner); + expect(afterRejection).toBeNull(); + expect(pinKept).toBeNull(); + expect(getAgentSigner.mock.calls).toStrictEqual([[account]]); + }); + + it('prepareTradingWallet rejects before init like other provider actions', async () => { + controller = createAgentController(); + + await expect(controller.prepareTradingWallet()).rejects.toThrow( + PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED, + ); + }); + it('clearAgentSigners does not need an initialized provider', () => { controller = createAgentController(); @@ -1132,14 +1166,18 @@ describe('PerpsController', () => { }); it("prepareTradingWallet returns the active provider's readiness", async () => { - mockProvider.prepareTradingWallet = jest - .fn() - .mockResolvedValue({ ready: false, error: 'KEYRING_LOCKED' }); + mockProvider.prepareTradingWallet = jest.fn().mockResolvedValue({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); await controller.init(); const result = await controller.prepareTradingWallet(); - expect(result).toStrictEqual({ ready: false, error: 'KEYRING_LOCKED' }); + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); expect(mockProvider.prepareTradingWallet).toHaveBeenCalledTimes(1); }); diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts index 646f02dc499..50bb878684c 100644 --- a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -1067,7 +1067,7 @@ describe('AggregatedPerpsProvider', () => { const prepareHyperLiquid = jest.fn().mockResolvedValue({ ready: true }); const prepareLighter = jest.fn().mockResolvedValue({ ready: false, - error: 'KEYRING_LOCKED', + error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); Object.assign(mockHLProvider, { prepareTradingWallet: prepareHyperLiquid, @@ -1078,7 +1078,10 @@ describe('AggregatedPerpsProvider', () => { const result = await aggregatedProvider.prepareTradingWallet(); - expect(result).toStrictEqual({ ready: false, error: 'KEYRING_LOCKED' }); + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); expect(prepareHyperLiquid).toHaveBeenCalledTimes(1); expect(prepareLighter).toHaveBeenCalledTimes(1); }); @@ -1139,6 +1142,33 @@ describe('AggregatedPerpsProvider', () => { ); }); + it('tags a logged preparation failure with the network', async () => { + const testnetProvider = new AggregatedPerpsProvider({ + providers: new Map([['hyperliquid', mockHLProvider]]), + defaultProvider: 'hyperliquid', + infrastructure: mockInfrastructure, + isTestnet: true, + }); + Object.assign(mockHLProvider, { + prepareTradingWallet: jest + .fn() + .mockRejectedValue(new Error('provider crashed')), + }); + + await testnetProvider.prepareTradingWallet(); + + expect(mockInfrastructure.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ message: 'provider crashed' }), + expect.objectContaining({ + tags: { + feature: 'perps', + provider: 'hyperliquid', + network: 'testnet', + }, + }), + ); + }); + it('prepares the next provider only after the previous one settles', async () => { const firstPreparation = createDeferred<{ ready: boolean }>(); const prepareLighter = jest.fn().mockResolvedValue({ ready: 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 80a3eacce42..5b73d38f053 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 @@ -2354,9 +2354,13 @@ describe('HyperLiquidProvider', () => { } type Options = { - signer?: { isReady?: () => boolean; isHardwareWallet?: () => boolean }; + signer?: { + isReady?: () => boolean; + requiresSignatureConfirmation?: () => boolean; + }; abstraction?: 'dexAbstraction' | 'default' | 'unifiedAccount'; getAgentSigner?: HyperLiquidCredentials['getAgentSigner']; + onAgentRejected?: jest.Mock; // Sign through a KeyringController instead of accountSigner. keyring?: boolean; }; @@ -2388,17 +2392,28 @@ describe('HyperLiquidProvider', () => { }); Object.assign(mockClientService, { initialize }); const signThroughSdkWallet = - (payload: PerpsTypedDataPayload) => async () => { + ( + payload: PerpsTypedDataPayload, + response: Record = { status: 'ok' }, + ) => + async () => { if (!sdkWallet) { throw new Error('SDK used before initialize'); } await sdkWallet.signTypedData(payload); - return { status: 'ok' }; + 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(USER_SIGNED_PAYLOAD)), + order: jest.fn( + signThroughSdkWallet(L1_PAYLOAD, { + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 123 } }] } }, + }), + ), }); mockClientService.getExchangeClient = jest .fn() @@ -2420,6 +2435,7 @@ describe('HyperLiquidProvider', () => { ['ETH', 1], ], getAgentSigner: options.getAgentSigner, + onAgentRejected: options.onAgentRejected, }); return { accountSignerProvider, @@ -2452,7 +2468,7 @@ describe('HyperLiquidProvider', () => { it('defers the init-time migration when accountSigner reports a hardware wallet', async () => { const { accountSignerProvider, accountSigner, call, exchangeClient } = createAccountSignerProvider({ - signer: { isHardwareWallet: () => true }, + signer: { requiresSignatureConfirmation: () => true }, }); await accountSignerProvider.getMarketDataWithPrices(); @@ -2481,7 +2497,7 @@ describe('HyperLiquidProvider', () => { it('runs the deferred migration, builder fee and referral setup and reports ready', async () => { const { accountSignerProvider, accountSigner, exchangeClient } = createAccountSignerProvider({ - signer: { isHardwareWallet: () => true }, + signer: { requiresSignatureConfirmation: () => true }, }); await accountSignerProvider.getMarketDataWithPrices(); @@ -2499,10 +2515,65 @@ describe('HyperLiquidProvider', () => { ).toHaveBeenCalled(); }); + it('signs every setup step, so the first order signs only itself', async () => { + const { accountSignerProvider, accountSigner, exchangeClient } = + createAccountSignerProvider({ + signer: { requiresSignatureConfirmation: () => true }, + }); + await accountSignerProvider.getMarketDataWithPrices(); + // Not approved yet; the venue reports the approval once signed. + ( + mockClientService.getInfoClient().maxBuilderFee as jest.Mock + ).mockResolvedValueOnce(0); + + const result = await accountSignerProvider.prepareTradingWallet(); + const setupSignatures = [...accountSigner.signTypedData.mock.calls]; + accountSigner.signTypedData.mockClear(); + rememberMigration(); + const order = await accountSignerProvider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }); + + expect(result).toStrictEqual({ ready: true }); + // Migration, referral, builder fee approval. + expect(setupSignatures).toStrictEqual([ + [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], + [ACCOUNT_ADDRESS, L1_PAYLOAD], + [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], + ]); + expect(order.success).toBe(true); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [ACCOUNT_ADDRESS, L1_PAYLOAD], + ]); + expect(exchangeClient.userSetAbstraction).toHaveBeenCalledTimes(1); + expect(exchangeClient.approveBuilderFee).toHaveBeenCalledTimes(1); + expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); + }); + + it('makes its own referral attempt when another provider ended without a result', async () => { + const { accountSignerProvider, exchangeClient } = + createAccountSignerProvider({ abstraction: 'unifiedAccount' }); + const signingCache = PerpsSigningCache as jest.Mocked< + typeof PerpsSigningCache + >; + signingCache.isInFlight.mockImplementation((kind) => + kind === 'referral' ? Promise.resolve() : undefined, + ); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); + expect(result).toStrictEqual({ ready: true }); + }); + it('signs nothing more when called again', async () => { const { accountSignerProvider, accountSigner } = createAccountSignerProvider({ - signer: { isHardwareWallet: () => true }, + signer: { requiresSignatureConfirmation: () => true }, }); const readinessCache = TradingReadinessCache as jest.Mocked< typeof TradingReadinessCache @@ -2534,7 +2605,7 @@ describe('HyperLiquidProvider', () => { it('reports ready after the user declines the migration, since it is not asked again', async () => { const { accountSignerProvider, accountSigner } = createAccountSignerProvider({ - signer: { isHardwareWallet: () => true }, + signer: { requiresSignatureConfirmation: () => true }, }); accountSigner.signTypedData.mockImplementation( async (_address: string, payload: PerpsTypedDataPayload) => { @@ -3110,6 +3181,180 @@ describe('HyperLiquidProvider', () => { expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(2); }); + describe('when the venue rejects the agent', () => { + const rejection = (address: string): Error => + new Error(`User or API Wallet ${address} does not exist.`); + + /** + * 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' | 'agentSetAbstraction' | 'setReferrer', + ) { + 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.initialize.mock.calls[0][0].signTypedData(L1_PAYLOAD); + throw rejection(built.agentSigner.address); + }); + return { ...built, getAgentSigner, onAgentRejected }; + } + + it('fails the order with KEYRING_LOCKED, drops the agent and asks again', async () => { + const { + accountSignerProvider, + agentSigner, + getAgentSigner, + onAgentRejected, + initialize, + } = createRejectingProvider('order'); + await accountSignerProvider.getMarketDataWithPrices(); + + const order = await accountSignerProvider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }); + await initialize.mock.calls[0][0].signTypedData(L1_PAYLOAD); + + expect(order).toStrictEqual( + expect.objectContaining({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }), + ); + expect(onAgentRejected).toHaveBeenCalledWith( + MAINNET_ACCOUNT, + agentSigner.address, + ); + expect(getAgentSigner).toHaveBeenCalledTimes(2); + expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled(); + }); + + it('retries the silent migration instead of recording no HyperLiquid account', async () => { + const { + accountSignerProvider, + agentSigner, + onAgentRejected, + exchangeClient, + } = createRejectingProvider('agentSetAbstraction'); + + await accountSignerProvider.getMarketDataWithPrices(); + await accountSignerProvider.getMarketDataWithPrices(); + + expect(exchangeClient.agentSetAbstraction).toHaveBeenCalledTimes(2); + expect(onAgentRejected).toHaveBeenCalledWith( + MAINNET_ACCOUNT, + agentSigner.address, + ); + expect( + mockPlatformDependencies.metrics.trackPerpsEvent, + ).not.toHaveBeenCalledWith( + 'Perp Account Setup', + expect.objectContaining({ status: 'failed' }), + ); + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).not.toHaveBeenCalled(); + expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled(); + }); + + it('retries the referral instead of recording a failure', async () => { + const { + accountSignerProvider, + agentSigner, + onAgentRejected, + exchangeClient, + } = createRejectingProvider('setReferrer'); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: false }); + expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); + expect(onAgentRejected).toHaveBeenCalledWith( + MAINNET_ACCOUNT, + agentSigner.address, + ); + expect( + (PerpsSigningCache as jest.Mocked) + .setReferral, + ).not.toHaveBeenCalled(); + expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled(); + }); + + 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, initialize } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + onAgentRejected, + }); + exchangeClient.order.mockImplementation(async () => { + await initialize.mock.calls[0][0].signTypedData(L1_PAYLOAD); + throw rejection(ACCOUNT_ADDRESS); + }); + await accountSignerProvider.getMarketDataWithPrices(); + + const order = await accountSignerProvider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }); + + expect(order).toStrictEqual( + expect.objectContaining({ + success: false, + error: PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, + }), + ); + expect(onAgentRejected).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({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }); + + expect(order).toStrictEqual( + expect.objectContaining({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }), + ); + expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled(); + }); + it('reports a failed answer asked again after a clear as unavailable', async () => { const { getAgentSigner, answer, asked } = createPendingResolver(); const { accountSignerProvider, agentSigner, initialize } = 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 index 2d7e373455c..464ccd94a52 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts @@ -271,7 +271,7 @@ describe('LighterProvider with accountSigner', () => { }); }); - it('does not log a declined venue-key signature', async () => { + it('reports a declined venue-key signature as a retry without logging', async () => { const { provider, accountSigner, deps } = buildProvider(); accountSigner.signPersonalMessage.mockRejectedValue( Object.assign(new Error('User rejected the request.'), { code: 4001 }), @@ -280,12 +280,31 @@ describe('LighterProvider with accountSigner', () => { const result = await provider.prepareTradingWallet(); - expect(result.ready).toBe(false); - expect(result.error).toContain('User rejected'); + expect(result).toStrictEqual({ ready: false }); expect(loggerError).not.toHaveBeenCalled(); }); - it('does not log a wallet that has no Lighter account yet', async () => { + it.each([ + [ + 'an EIP-1193 rejection code', + Object.assign(new Error('Rejected'), { code: 4001 }), + ], + ['a "User denied" message', new Error('User denied message signature.')], + ])( + 'reports a decline signalled by %s as a retry without logging', + async (_signal, rejection) => { + const { provider, accountSigner, deps } = buildProvider(); + accountSigner.signPersonalMessage.mockRejectedValue(rejection); + const loggerError = jest.spyOn(deps.logger, 'error'); + + const result = await provider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: false }); + expect(loggerError).not.toHaveBeenCalled(); + }, + ); + + it('reports a wallet with no Lighter account yet as a retry without logging', async () => { const { provider, client, deps } = buildProvider({ findAccountByAddress: true, }); @@ -296,12 +315,11 @@ describe('LighterProvider with accountSigner', () => { const result = await provider.prepareTradingWallet(); - expect(result.ready).toBe(false); - expect(result.error).toContain('No Lighter account exists'); + expect(result).toStrictEqual({ ready: false }); expect(loggerError).not.toHaveBeenCalled(); }); - it('reports a missing signer bridge without logging', async () => { + it('reports and logs a missing signer bridge', async () => { const { provider, deps } = buildProvider({ withoutBridge: true }); const loggerError = jest.spyOn(deps.logger, 'error'); @@ -311,6 +329,37 @@ describe('LighterProvider with accountSigner', () => { ready: false, error: 'Lighter signer bridge not configured', }); + expect(loggerError).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Lighter signer bridge not configured', + }), + { + tags: { + feature: 'perps', + provider: 'LighterProvider', + network: 'testnet', + }, + context: { + name: 'LighterProvider.prepareTradingWallet', + data: { isTestnet: true }, + }, + }, + ); + }); + + it('reports KEYRING_LOCKED when the keyring locks during registration', async () => { + const { provider, accountSigner, deps } = buildProvider(); + accountSigner.signPersonalMessage.mockRejectedValue( + 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(loggerError).not.toHaveBeenCalled(); }); }); 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 index 4691e7cc44b..21650268f05 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts @@ -44,7 +44,7 @@ const TYPED_DATA: PerpsTypedDataPayload = { type SignerOverrides = { signTypedData?: jest.Mock; isReady?: () => boolean; - isHardwareWallet?: () => boolean; + requiresSignatureConfirmation?: () => boolean; }; type Built = { @@ -62,7 +62,7 @@ function buildService( overrides.signTypedData ?? jest.fn().mockResolvedValue(SIGNATURE), signPersonalMessage: jest.fn().mockResolvedValue(SIGNATURE), isReady: overrides.isReady, - isHardwareWallet: overrides.isHardwareWallet, + requiresSignatureConfirmation: overrides.requiresSignatureConfirmation, }; const { messenger, call } = createKeyringlessMessenger(keyringType); const service = new HyperLiquidWalletService( @@ -119,18 +119,18 @@ describe('HyperLiquidWalletService with accountSigner', () => { expect(keyringCalls(call)).toStrictEqual([]); }); - it('treats the account as hardware when isHardwareWallet returns true', () => { + it('treats the account as hardware when requiresSignatureConfirmation returns true', () => { const { service } = buildService( - { isHardwareWallet: () => true }, + { requiresSignatureConfirmation: () => true }, 'HD Key Tree', ); expect(service.isSelectedHardwareWallet()).toBe(true); }); - it('treats the account as software when isHardwareWallet returns false', () => { + it('treats the account as software when requiresSignatureConfirmation returns false', () => { const { service } = buildService( - { isHardwareWallet: () => false }, + { requiresSignatureConfirmation: () => false }, 'Ledger Hardware', ); @@ -141,7 +141,7 @@ describe('HyperLiquidWalletService with accountSigner', () => { ['Ledger Hardware', true], ['HD Key Tree', false], ])( - 'falls back to the %s keyring type when isHardwareWallet is omitted', + 'falls back to the %s keyring type when requiresSignatureConfirmation is omitted', (keyringType, expected) => { const { service } = buildService({}, keyringType); From 487015b1221201cc15c20ac66d0a4859b860e13e Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 29 Sep 2026 08:51:31 +0800 Subject: [PATCH 13/33] test(perps-controller): run the account-signer provider tests on the real wallet service and caches Moves the account-signer block out of the account-mode suite, whose file-wide mocks replaced the wallet service and the signing caches. The new suite mocks only the client, subscription and SDK boundaries and clears the real caches before each test, so the idempotency and retry tests read the cache the provider writes. --- .../tests/helpers/providerMocks.ts | 189 +++ .../HyperLiquidProvider.account-mode.test.ts | 1323 +---------------- ...HyperLiquidProvider.account-signer.test.ts | 1222 +++++++++++++++ 3 files changed, 1416 insertions(+), 1318 deletions(-) create mode 100644 packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts diff --git a/packages/perps-controller/tests/helpers/providerMocks.ts b/packages/perps-controller/tests/helpers/providerMocks.ts index 7f821295a68..53aa951c131 100644 --- a/packages/perps-controller/tests/helpers/providerMocks.ts +++ b/packages/perps-controller/tests/helpers/providerMocks.ts @@ -118,3 +118,192 @@ export const createMockPosition = (overrides = {}) => ({ timestamp: Date.now(), ...overrides, }); + +// HyperLiquid SDK info and exchange client mocks for provider tests. +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: '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, +}); + +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', + }), + 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/src/providers/HyperLiquidProvider.account-mode.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts index 5b73d38f053..29b2a0b4b8d 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 @@ -11,25 +11,15 @@ import { import { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from '../../../src/constants/transactionsHistoryConfig.js'; import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider.js'; -import { 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 { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService.js'; -import { - PerpsSigningCache, - TradingReadinessCache, -} from '../../../src/services/TradingReadinessCache.js'; +import { TradingReadinessCache } from '../../../src/services/TradingReadinessCache.js'; import type { ClosePositionParams, DepositParams, Order, - HyperLiquidCredentials, - PerpsAccountSigner, - PerpsAgentAccount, - PerpsAgentSigner, PerpsPlatformDependencies, - PerpsTypedDataPayload, LiveDataConfig, OrderParams, } from '../../../src/types/index.js'; @@ -43,13 +33,12 @@ import { } from '../../../src/utils/hyperLiquidValidation.js'; import { createStandaloneInfoClient } from '../../../src/utils/standaloneInfoClient.js'; import { - createDeferred, - createKeyringMessenger, - createKeyringlessMessenger, - createMockEvmAccount, + createMockExchangeClient, + createMockInfoClient, +} from '../../helpers/providerMocks.js'; +import { createMockInfrastructure, createMockMessenger, - keyringCalls, } from '../../helpers/serviceMocks.js'; jest.mock('../../../src/services/HyperLiquidClientService'); @@ -149,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(); @@ -2262,1120 +2065,4 @@ describe('HyperLiquidProvider', () => { expect(mockCompleteInFlight).toHaveBeenCalled(); }); }); - - describe('with a real wallet service and accountSigner', () => { - // The wallet service is real and the messenger has no KeyringController, - // 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. - const { HyperLiquidWalletService: RealHyperLiquidWalletService } = - jest.requireActual< - typeof import('../../../src/services/HyperLiquidWalletService.js') - >('../../../src/services/HyperLiquidWalletService'); - const ACCOUNT_ADDRESS = createMockEvmAccount().address; - const OTHER_ACCOUNT_ADDRESS = - '0x00000000000000000000000000000000000b0b01' as const; - const AGENT_ADDRESS = '0x00000000000000000000000000000000000a9e17' as const; - const SIGNATURE = `0x${'cd'.repeat(65)}` as const; - const AGENT_SIGNATURE = `0x${'ef'.repeat(65)}` as const; - const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as const; - // Shapes the SDK signs: user-signed actions use the - // HyperliquidSignTransaction domain, L1 actions the Exchange domain. - const USER_SIGNED_PAYLOAD: PerpsTypedDataPayload = { - domain: { - name: 'HyperliquidSignTransaction', - version: '1', - chainId: 1, - verifyingContract: ZERO_ADDRESS, - }, - types: { - '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: ACCOUNT_ADDRESS, - abstraction: 'unifiedAccount', - nonce: 1, - }, - }; - const L1_PAYLOAD: PerpsTypedDataPayload = { - domain: { - name: 'Exchange', - version: '1', - chainId: 1337, - verifyingContract: ZERO_ADDRESS, - }, - types: { - Agent: [ - { name: 'source', type: 'string' }, - { name: 'connectionId', type: 'bytes32' }, - ], - }, - primaryType: 'Agent', - message: { source: 'a', connectionId: `0x${'22'.repeat(32)}` }, - }; - - // The global readiness cache is mocked in this suite; replay the migration - // result the real cache would keep so later calls do not migrate again. - function rememberMigration(): void { - ( - TradingReadinessCache as jest.Mocked - ).get.mockReturnValue({ - attempted: true, - enabled: true, - timestamp: Date.now(), - }); - } - - /** - * 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. - */ - 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 }; - } - - type Options = { - signer?: { - isReady?: () => boolean; - requiresSignatureConfirmation?: () => boolean; - }; - abstraction?: 'dexAbstraction' | 'default' | 'unifiedAccount'; - getAgentSigner?: HyperLiquidCredentials['getAgentSigner']; - onAgentRejected?: jest.Mock; - // Sign through a KeyringController instead of accountSigner. - keyring?: boolean; - }; - - function createAccountSignerProvider(options: Options = {}) { - const accountSigner = { - signTypedData: jest.fn().mockResolvedValue(SIGNATURE), - signPersonalMessage: jest.fn(), - ...options.signer, - }; - const agentSigner = { - address: AGENT_ADDRESS, - signTypedData: jest.fn().mockResolvedValue(AGENT_SIGNATURE), - }; - const { messenger, call, selectAccount } = options.keyring - ? createKeyringMessenger(SIGNATURE) - : createKeyringlessMessenger(); - MockedHyperLiquidWalletService.mockImplementation( - (deps, walletMessenger, walletOptions) => - new RealHyperLiquidWalletService( - deps, - walletMessenger, - walletOptions, - ), - ); - let sdkWallet: HyperLiquidWalletParams | undefined; - const initialize = jest.fn(async (wallet: HyperLiquidWalletParams) => { - sdkWallet = wallet; - }); - Object.assign(mockClientService, { initialize }); - const signThroughSdkWallet = - ( - payload: PerpsTypedDataPayload, - response: Record = { status: 'ok' }, - ) => - async () => { - if (!sdkWallet) { - throw new Error('SDK used before initialize'); - } - await sdkWallet.signTypedData(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(USER_SIGNED_PAYLOAD)), - order: jest.fn( - signThroughSdkWallet(L1_PAYLOAD, { - status: 'ok', - response: { data: { statuses: [{ resting: { oid: 123 } }] } }, - }), - ), - }); - mockClientService.getExchangeClient = jest - .fn() - .mockReturnValue(exchangeClient); - mockClientService.getInfoClient = jest.fn().mockReturnValue( - createMockInfoClient({ - userAbstraction: jest - .fn() - .mockResolvedValue(options.abstraction ?? 'dexAbstraction'), - }), - ); - const accountSignerProvider = new HyperLiquidProvider({ - platformDependencies: options.keyring - ? mockPlatformDependencies - : { ...mockPlatformDependencies, accountSigner }, - messenger, - initialAssetMapping: [ - ['BTC', 0], - ['ETH', 1], - ], - getAgentSigner: options.getAgentSigner, - onAgentRejected: options.onAgentRejected, - }); - return { - accountSignerProvider, - accountSigner, - agentSigner, - call, - exchangeClient, - initialize, - selectAccount, - }; - } - - it('signs the init-time unified-account migration through accountSigner', async () => { - const { accountSignerProvider, accountSigner, call, exchangeClient } = - createAccountSignerProvider(); - - await accountSignerProvider.getMarketDataWithPrices(); - - expect(exchangeClient.userSetAbstraction).toHaveBeenCalledWith({ - user: ACCOUNT_ADDRESS, - abstraction: 'unifiedAccount', - }); - expect(accountSigner.signTypedData).toHaveBeenCalledWith( - ACCOUNT_ADDRESS, - USER_SIGNED_PAYLOAD, - ); - expect(keyringCalls(call)).toStrictEqual([]); - }); - - it('defers the init-time migration when accountSigner reports a hardware wallet', async () => { - const { accountSignerProvider, accountSigner, call, exchangeClient } = - createAccountSignerProvider({ - signer: { requiresSignatureConfirmation: () => true }, - }); - - await accountSignerProvider.getMarketDataWithPrices(); - - 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).toHaveBeenCalledTimes(1); - expect(accountSigner.signTypedData).not.toHaveBeenCalled(); - expect( - (TradingReadinessCache as jest.Mocked) - .set, - ).not.toHaveBeenCalled(); - expect(keyringCalls(call)).toStrictEqual([]); - }); - - describe('prepareTradingWallet', () => { - it('runs the deferred migration, builder fee and referral setup and reports ready', async () => { - const { accountSignerProvider, accountSigner, exchangeClient } = - createAccountSignerProvider({ - signer: { requiresSignatureConfirmation: () => true }, - }); - await accountSignerProvider.getMarketDataWithPrices(); - - const result = await accountSignerProvider.prepareTradingWallet(); - - expect(result).toStrictEqual({ ready: true }); - expect(exchangeClient.userSetAbstraction).toHaveBeenCalledTimes(1); - expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); - expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ - [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], - [ACCOUNT_ADDRESS, L1_PAYLOAD], - ]); - expect( - mockClientService.getInfoClient().maxBuilderFee, - ).toHaveBeenCalled(); - }); - - it('signs every setup step, so the first order signs only itself', async () => { - const { accountSignerProvider, accountSigner, exchangeClient } = - createAccountSignerProvider({ - signer: { requiresSignatureConfirmation: () => true }, - }); - await accountSignerProvider.getMarketDataWithPrices(); - // Not approved yet; the venue reports the approval once signed. - ( - mockClientService.getInfoClient().maxBuilderFee as jest.Mock - ).mockResolvedValueOnce(0); - - const result = await accountSignerProvider.prepareTradingWallet(); - const setupSignatures = [...accountSigner.signTypedData.mock.calls]; - accountSigner.signTypedData.mockClear(); - rememberMigration(); - const order = await accountSignerProvider.placeOrder({ - symbol: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'market', - currentPrice: 50000, - }); - - expect(result).toStrictEqual({ ready: true }); - // Migration, referral, builder fee approval. - expect(setupSignatures).toStrictEqual([ - [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], - [ACCOUNT_ADDRESS, L1_PAYLOAD], - [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], - ]); - expect(order.success).toBe(true); - expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ - [ACCOUNT_ADDRESS, L1_PAYLOAD], - ]); - expect(exchangeClient.userSetAbstraction).toHaveBeenCalledTimes(1); - expect(exchangeClient.approveBuilderFee).toHaveBeenCalledTimes(1); - expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); - }); - - it('makes its own referral attempt when another provider ended without a result', async () => { - const { accountSignerProvider, exchangeClient } = - createAccountSignerProvider({ abstraction: 'unifiedAccount' }); - const signingCache = PerpsSigningCache as jest.Mocked< - typeof PerpsSigningCache - >; - signingCache.isInFlight.mockImplementation((kind) => - kind === 'referral' ? Promise.resolve() : undefined, - ); - - const result = await accountSignerProvider.prepareTradingWallet(); - - expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); - expect(result).toStrictEqual({ ready: true }); - }); - - it('signs nothing more when called again', async () => { - const { accountSignerProvider, accountSigner } = - createAccountSignerProvider({ - signer: { requiresSignatureConfirmation: () => true }, - }); - const readinessCache = TradingReadinessCache as jest.Mocked< - typeof TradingReadinessCache - >; - await accountSignerProvider.prepareTradingWallet(); - const signaturesAfterFirstCall = - accountSigner.signTypedData.mock.calls.length; - // The global cache is mocked in this suite: replay what the first call - // wrote to it, as the real cache would. - expect(readinessCache.set).toHaveBeenCalledWith( - 'mainnet', - ACCOUNT_ADDRESS, - { attempted: true, enabled: true }, - ); - readinessCache.get.mockReturnValue({ - attempted: true, - enabled: true, - timestamp: Date.now(), - }); - - const result = await accountSignerProvider.prepareTradingWallet(); - - expect(result).toStrictEqual({ ready: true }); - expect(accountSigner.signTypedData).toHaveBeenCalledTimes( - signaturesAfterFirstCall, - ); - }); - - it('reports ready after the user declines the migration, since it is not asked again', async () => { - 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 SIGNATURE; - }, - ); - - const result = await accountSignerProvider.prepareTradingWallet(); - - expect(result).toStrictEqual({ ready: true }); - expect( - (TradingReadinessCache as jest.Mocked) - .set, - ).toHaveBeenCalledWith( - 'mainnet', - ACCOUNT_ADDRESS, - expect.objectContaining({ attempted: true, enabled: false }), - ); - }); - - it('reports not ready when the builder fee approval is rejected', async () => { - const { accountSignerProvider, exchangeClient } = - createAccountSignerProvider({ abstraction: 'unifiedAccount' }); - ( - mockClientService.getInfoClient().maxBuilderFee as jest.Mock - ).mockResolvedValue(0); - exchangeClient.approveBuilderFee.mockRejectedValue( - new Error('User rejected the request.'), - ); - - const result = await accountSignerProvider.prepareTradingWallet(); - - expect(result).toStrictEqual({ ready: false }); - }); - - it('reports KEYRING_LOCKED when accountSigner is not ready', async () => { - const { accountSignerProvider } = createAccountSignerProvider({ - signer: { isReady: () => false }, - }); - - const result = await accountSignerProvider.prepareTradingWallet(); - - expect(result).toStrictEqual({ - ready: false, - error: PERPS_ERROR_CODES.KEYRING_LOCKED, - }); - }); - - it('reports and logs the error when the clients cannot initialize', async () => { - const { accountSignerProvider, initialize } = - createAccountSignerProvider(); - initialize.mockRejectedValue(new Error('transport unavailable')); - - const result = await accountSignerProvider.prepareTradingWallet(); - - expect(result).toStrictEqual({ - ready: false, - error: 'transport unavailable', - }); - expect(mockPlatformDependencies.logger.error).toHaveBeenCalledWith( - expect.objectContaining({ message: 'transport unavailable' }), - { - tags: { - feature: 'perps', - provider: 'hyperliquid', - network: 'mainnet', - }, - context: { - name: 'HyperLiquidProvider', - data: { method: 'prepareTradingWallet' }, - }, - }, - ); - }); - - it('does not log a provider replaced during preparation', async () => { - const { accountSignerProvider, initialize } = - createAccountSignerProvider(); - initialize.mockRejectedValue( - new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE), - ); - - const result = await accountSignerProvider.prepareTradingWallet(); - - expect(result).toStrictEqual({ - ready: false, - error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, - }); - expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled(); - }); - - it('signs the deferred setup through the keyring without accountSigner', async () => { - const { accountSignerProvider, accountSigner, call, exchangeClient } = - createAccountSignerProvider({ keyring: true }); - await accountSignerProvider.getMarketDataWithPrices(); - rememberMigration(); - - const result = await accountSignerProvider.prepareTradingWallet(); - - expect(result).toStrictEqual({ ready: true }); - expect(exchangeClient.userSetAbstraction).toHaveBeenCalledTimes(1); - expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); - expect(accountSigner.signTypedData).not.toHaveBeenCalled(); - expect( - keyringCalls(call).filter( - (action) => action === 'KeyringController:signTypedMessage', - ), - ).toHaveLength(2); - }); - - it('reports KEYRING_LOCKED when the signer locks 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, - }); - }); - }); - - describe('with an agent', () => { - const MAINNET_ACCOUNT = { - mainAddress: ACCOUNT_ADDRESS, - isTestnet: false, - } as const; - - /** - * 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. - */ - function bind( - provider: HyperLiquidProvider, - bindings: AgentBindings, - account: PerpsAgentAccount, - agentSigner: PerpsAgentSigner | null, - ): void { - bindings.set(account, agentSigner); - provider.clearAgentSigners(); - } - - it('resolves the agent at the first L1 signature and signs with it', async () => { - const getAgentSigner = jest.fn(); - const { - accountSignerProvider, - accountSigner, - agentSigner, - initialize, - } = createAccountSignerProvider({ - abstraction: 'default', - getAgentSigner, - }); - getAgentSigner.mockResolvedValue(agentSigner); - - await accountSignerProvider.getMarketDataWithPrices(); - - expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); - expect(initialize.mock.calls[0][0].address).toBe(ACCOUNT_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(); - rememberMigration(); - await accountSignerProvider.prepareTradingWallet(); - - // Migration at connect, then referral setup. - expect(getAgentSigner).toHaveBeenCalledTimes(1); - expect(agentSigner.signTypedData).toHaveBeenCalledTimes(2); - }); - - 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(); - rememberMigration(); - await accountSignerProvider.prepareTradingWallet(); - - expect(getAgentSigner).toHaveBeenCalledTimes(2); - expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ - [ACCOUNT_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 } = createAccountSignerProvider({ - abstraction: 'unifiedAccount', - getAgentSigner, - }); - - await accountSignerProvider.getMarketDataWithPrices(); - - 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([ - [ACCOUNT_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 } = - createAccountSignerProvider({ - abstraction: 'default', - getAgentSigner, - }); - - const marketData = - await accountSignerProvider.getMarketDataWithPrices(); - const result = await accountSignerProvider.prepareTradingWallet(); - - expect(marketData).toHaveLength(2); - // 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).toHaveBeenCalledTimes(3); - expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); - expect(getAgentSigner).toHaveBeenCalledTimes(4); - expect(accountSigner.signTypedData).not.toHaveBeenCalled(); - expect(result).toStrictEqual({ ready: false }); - // Retryable like a locked keyring: no failure metric, nothing logged. - expect( - mockPlatformDependencies.metrics.trackPerpsEvent, - ).not.toHaveBeenCalledWith( - 'Perp Account Setup', - expect.objectContaining({ status: 'failed' }), - ); - expect(mockPlatformDependencies.logger.error).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_ACCOUNT_ADDRESS, isTestnet: false }, - agentSigner, - ); - await accountSignerProvider.getMarketDataWithPrices(); - selectAccount(OTHER_ACCOUNT_ADDRESS); - await accountSignerProvider.prepareTradingWallet(); - - expect(accountSigner.signTypedData.mock.calls[0]).toStrictEqual([ - ACCOUNT_ADDRESS, - L1_PAYLOAD, - ]); - expect(agentSigner.signTypedData).toHaveBeenCalledWith(L1_PAYLOAD); - }); - - it('never signs on another network with the agent bound for mainnet', async () => { - const bindings = new AgentBindings(undefined); - const { accountSignerProvider, accountSigner, agentSigner } = - createAccountSignerProvider({ - abstraction: 'default', - getAgentSigner: bindings.resolve, - }); - bindings.set(MAINNET_ACCOUNT, agentSigner); - - mockClientService.isTestnetMode.mockReturnValue(true); - await accountSignerProvider.getMarketDataWithPrices(); - - expect(agentSigner.signTypedData).not.toHaveBeenCalled(); - expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ - [ACCOUNT_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(); - rememberMigration(); - await accountSignerProvider.prepareTradingWallet(); - - expect(getAgentSigner).not.toHaveBeenCalled(); - expect(agentSigner.signTypedData).not.toHaveBeenCalled(); - expect(accountSigner.signTypedData).toHaveBeenCalledTimes(2); - }); - - 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([ - [ACCOUNT_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 } = - 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; - rememberMigration(); - await accountSignerProvider.prepareTradingWallet(); - - expect(getAgentSigner).toHaveBeenCalledTimes(1); - expect(agentSigner.signTypedData).toHaveBeenCalledTimes(2); - }); - - it('asks getAgentSigner with the network of the provider', async () => { - const getAgentSigner = jest.fn().mockResolvedValue(null); - const { accountSignerProvider } = createAccountSignerProvider({ - abstraction: 'default', - getAgentSigner, - }); - mockClientService.isTestnetMode.mockReturnValue(true); - - await accountSignerProvider.getMarketDataWithPrices(); - - expect(getAgentSigner.mock.calls).toStrictEqual([ - [{ mainAddress: ACCOUNT_ADDRESS, isTestnet: true }], - ]); - }); - - it('stops agent signing on lock and resumes after unlock', async () => { - const getAgentSigner = jest.fn(); - const { - accountSignerProvider, - accountSigner, - agentSigner, - initialize, - } = createAccountSignerProvider({ - abstraction: 'unifiedAccount', - getAgentSigner, - }); - getAgentSigner.mockResolvedValue(agentSigner); - await accountSignerProvider.getMarketDataWithPrices(); - const [[wallet]] = initialize.mock.calls; - - 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).toHaveBeenCalledTimes(2); - expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ - [ACCOUNT_ADDRESS, L1_PAYLOAD], - ]); - expect(getAgentSigner).toHaveBeenCalledTimes(3); - }); - - it('asks getAgentSigner again once the bindings are cleared', async () => { - const getAgentSigner = jest.fn(); - const bindings = new AgentBindings(getAgentSigner); - const { accountSignerProvider, agentSigner, initialize } = - createAccountSignerProvider({ - abstraction: 'unifiedAccount', - getAgentSigner: bindings.resolve, - }); - getAgentSigner.mockResolvedValue(agentSigner); - bindings.set(MAINNET_ACCOUNT, null); - await accountSignerProvider.getMarketDataWithPrices(); - const [[wallet]] = initialize.mock.calls; - - bindings.clear(); - accountSignerProvider.clearAgentSigners(); - await wallet.signTypedData(L1_PAYLOAD); - - expect(agentSigner.signTypedData).toHaveBeenCalledWith(L1_PAYLOAD); - }); - - it('retries the referral instead of recording a failure when getAgentSigner rejects', async () => { - const getAgentSigner = jest - .fn() - .mockRejectedValue(new Error('agent store unavailable')); - const { accountSignerProvider, exchangeClient } = - createAccountSignerProvider({ - abstraction: 'unifiedAccount', - getAgentSigner, - }); - - await accountSignerProvider.prepareTradingWallet(); - - expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); - expect( - (PerpsSigningCache as jest.Mocked) - .setReferral, - ).not.toHaveBeenCalled(); - expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled(); - }); - - it('retries the referral instead of recording a failure when the agent fails to sign', async () => { - const getAgentSigner = jest.fn(); - const { accountSignerProvider, agentSigner, exchangeClient } = - createAccountSignerProvider({ - abstraction: 'unifiedAccount', - getAgentSigner, - }); - agentSigner.signTypedData.mockRejectedValue( - new Error('agent key locked'), - ); - getAgentSigner.mockResolvedValue(agentSigner); - - const result = await accountSignerProvider.prepareTradingWallet(); - - expect(result).toStrictEqual({ ready: false }); - expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); - expect( - (PerpsSigningCache as jest.Mocked) - .setReferral, - ).not.toHaveBeenCalled(); - expect(mockPlatformDependencies.logger.error).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, initialize } = - createAccountSignerProvider({ - abstraction: 'unifiedAccount', - getAgentSigner, - }); - await accountSignerProvider.getMarketDataWithPrices(); - const [[wallet]] = initialize.mock.calls; - - await expect(wallet.signTypedData(L1_PAYLOAD)).rejects.toThrow( - 'HyperLiquid agent signer unavailable', - ); - await expect(wallet.signTypedData(L1_PAYLOAD)).rejects.toThrow( - 'HyperLiquid agent signer unavailable', - ); - expect(getAgentSigner).toHaveBeenCalledTimes(2); - 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).toHaveBeenCalledTimes(2); - expect(agentSigner.signTypedData).not.toHaveBeenCalled(); - expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ - [ACCOUNT_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 secondResult = await accountSignerProvider.prepareTradingWallet(); - - expect(firstResult).toStrictEqual({ ready: false }); - expect(secondResult).toStrictEqual({ ready: true }); - expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(2); - }); - - describe('when the venue rejects the agent', () => { - const rejection = (address: string): Error => - new Error(`User or API Wallet ${address} does not exist.`); - - /** - * 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' | 'agentSetAbstraction' | 'setReferrer', - ) { - 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.initialize.mock.calls[0][0].signTypedData(L1_PAYLOAD); - throw rejection(built.agentSigner.address); - }); - return { ...built, getAgentSigner, onAgentRejected }; - } - - it('fails the order with KEYRING_LOCKED, drops the agent and asks again', async () => { - const { - accountSignerProvider, - agentSigner, - getAgentSigner, - onAgentRejected, - initialize, - } = createRejectingProvider('order'); - await accountSignerProvider.getMarketDataWithPrices(); - - const order = await accountSignerProvider.placeOrder({ - symbol: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'market', - currentPrice: 50000, - }); - await initialize.mock.calls[0][0].signTypedData(L1_PAYLOAD); - - expect(order).toStrictEqual( - expect.objectContaining({ - success: false, - error: PERPS_ERROR_CODES.KEYRING_LOCKED, - }), - ); - expect(onAgentRejected).toHaveBeenCalledWith( - MAINNET_ACCOUNT, - agentSigner.address, - ); - expect(getAgentSigner).toHaveBeenCalledTimes(2); - expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled(); - }); - - it('retries the silent migration instead of recording no HyperLiquid account', async () => { - const { - accountSignerProvider, - agentSigner, - onAgentRejected, - exchangeClient, - } = createRejectingProvider('agentSetAbstraction'); - - await accountSignerProvider.getMarketDataWithPrices(); - await accountSignerProvider.getMarketDataWithPrices(); - - expect(exchangeClient.agentSetAbstraction).toHaveBeenCalledTimes(2); - expect(onAgentRejected).toHaveBeenCalledWith( - MAINNET_ACCOUNT, - agentSigner.address, - ); - expect( - mockPlatformDependencies.metrics.trackPerpsEvent, - ).not.toHaveBeenCalledWith( - 'Perp Account Setup', - expect.objectContaining({ status: 'failed' }), - ); - expect( - (TradingReadinessCache as jest.Mocked) - .set, - ).not.toHaveBeenCalled(); - expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled(); - }); - - it('retries the referral instead of recording a failure', async () => { - const { - accountSignerProvider, - agentSigner, - onAgentRejected, - exchangeClient, - } = createRejectingProvider('setReferrer'); - - const result = await accountSignerProvider.prepareTradingWallet(); - - expect(result).toStrictEqual({ ready: false }); - expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); - expect(onAgentRejected).toHaveBeenCalledWith( - MAINNET_ACCOUNT, - agentSigner.address, - ); - expect( - (PerpsSigningCache as jest.Mocked) - .setReferral, - ).not.toHaveBeenCalled(); - expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled(); - }); - - 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, initialize } = - createAccountSignerProvider({ - abstraction: 'unifiedAccount', - getAgentSigner, - onAgentRejected, - }); - exchangeClient.order.mockImplementation(async () => { - await initialize.mock.calls[0][0].signTypedData(L1_PAYLOAD); - throw rejection(ACCOUNT_ADDRESS); - }); - await accountSignerProvider.getMarketDataWithPrices(); - - const order = await accountSignerProvider.placeOrder({ - symbol: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'market', - currentPrice: 50000, - }); - - expect(order).toStrictEqual( - expect.objectContaining({ - success: false, - error: PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, - }), - ); - expect(onAgentRejected).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({ - symbol: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'market', - currentPrice: 50000, - }); - - expect(order).toStrictEqual( - expect.objectContaining({ - success: false, - error: PERPS_ERROR_CODES.KEYRING_LOCKED, - }), - ); - expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled(); - }); - - it('reports a failed answer asked again after a clear as unavailable', async () => { - const { getAgentSigner, answer, asked } = createPendingResolver(); - const { accountSignerProvider, agentSigner, initialize } = - createAccountSignerProvider({ - abstraction: 'unifiedAccount', - getAgentSigner, - }); - await accountSignerProvider.getMarketDataWithPrices(); - const [[wallet]] = initialize.mock.calls; - - const signing = wallet.signTypedData(L1_PAYLOAD); - await asked; - getAgentSigner.mockRejectedValue(new Error('agent store unavailable')); - accountSignerProvider.clearAgentSigners(); - answer.resolve(agentSigner); - - await expect(signing).rejects.toThrow( - 'HyperLiquid agent signer unavailable', - ); - expect(agentSigner.signTypedData).not.toHaveBeenCalled(); - }); - }); - }); }); 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..c87bd5ac112 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts @@ -0,0 +1,1222 @@ +import type { Hex } from '@metamask/utils'; + +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; +import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider.js'; +import { 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 type { + HyperLiquidCredentials, + PerpsAgentAccount, + PerpsAgentSigner, + PerpsPlatformDependencies, + PerpsTypedDataPayload, +} from '../../../src/types/index.js'; +import { + createMockExchangeClient, + createMockInfoClient, +} from '../../helpers/providerMocks.js'; +import { + createDeferred, + createKeyringMessenger, + createKeyringlessMessenger, + createMockEvmAccount, + createMockInfrastructure, + 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. +jest.mock('@nktkas/hyperliquid', () => ({})); + +// Only the I/O boundaries are mocked: the 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'); + +const CACHED_PRICES: Record = { BTC: '50000', ETH: '3000' }; + +const MockedHyperLiquidClientService = + HyperLiquidClientService as jest.MockedClass; +const MockedHyperLiquidSubscriptionService = + HyperLiquidSubscriptionService as jest.MockedClass< + typeof HyperLiquidSubscriptionService + >; + +describe('HyperLiquidProvider with a real wallet service and accountSigner', () => { + let mockClientService: jest.Mocked; + let mockPlatformDependencies: PerpsPlatformDependencies; + let loggerError: jest.SpyInstance; + let debugLog: jest.SpyInstance; + let trackPerpsEvent: jest.SpyInstance; + + beforeEach(() => { + TradingReadinessCache.clearAll(); + mockPlatformDependencies = createMockInfrastructure(); + loggerError = jest.spyOn(mockPlatformDependencies.logger, 'error'); + debugLog = jest.spyOn(mockPlatformDependencies.debugLogger, 'log'); + trackPerpsEvent = jest.spyOn( + mockPlatformDependencies.metrics, + 'trackPerpsEvent', + ); + 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, + ); + }); + + // The wallet service and the signing caches are real, and the messenger + // has no KeyringController, 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. + const ACCOUNT_ADDRESS = createMockEvmAccount().address; + const OTHER_ACCOUNT_ADDRESS = + '0x00000000000000000000000000000000000b0b01' as const; + const AGENT_ADDRESS = '0x00000000000000000000000000000000000a9e17' as const; + const SIGNATURE = `0x${'cd'.repeat(65)}` as const; + const AGENT_SIGNATURE = `0x${'ef'.repeat(65)}` as const; + const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as const; + // Shapes the SDK signs: user-signed actions use the + // HyperliquidSignTransaction domain, L1 actions the Exchange domain. + const USER_SIGNED_PAYLOAD: PerpsTypedDataPayload = { + domain: { + name: 'HyperliquidSignTransaction', + version: '1', + chainId: 1, + verifyingContract: ZERO_ADDRESS, + }, + types: { + '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: ACCOUNT_ADDRESS, + abstraction: 'unifiedAccount', + nonce: 1, + }, + }; + const L1_PAYLOAD: PerpsTypedDataPayload = { + domain: { + name: 'Exchange', + version: '1', + chainId: 1337, + verifyingContract: ZERO_ADDRESS, + }, + types: { + Agent: [ + { name: 'source', type: 'string' }, + { name: 'connectionId', type: 'bytes32' }, + ], + }, + primaryType: 'Agent', + message: { source: 'a', connectionId: `0x${'22'.repeat(32)}` }, + }; + + /** + * Whether the unified-account migration is recorded as attempted for the + * selected account on mainnet. + * + * @returns True once the migration result is cached. + */ + function migrationAttempted(): boolean { + return ( + TradingReadinessCache.get('mainnet', ACCOUNT_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. + */ + function referralAttempted(): boolean { + return ( + PerpsSigningCache.getReferral('mainnet', ACCOUNT_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. + */ + 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 }; + } + + type Options = { + signer?: { + isReady?: () => boolean; + requiresSignatureConfirmation?: () => boolean; + }; + abstraction?: 'dexAbstraction' | 'default' | 'unifiedAccount'; + getAgentSigner?: HyperLiquidCredentials['getAgentSigner']; + onAgentRejected?: jest.Mock; + // Sign through a KeyringController instead of accountSigner. + keyring?: boolean; + }; + + 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]>; + selectAccount: (address: Hex) => void; + }; + + function createAccountSignerProvider( + options: Options = {}, + ): AccountSignerFixture { + const accountSigner = { + signTypedData: jest.fn().mockResolvedValue(SIGNATURE), + signPersonalMessage: jest.fn(), + ...options.signer, + }; + const agentSigner = { + address: AGENT_ADDRESS, + signTypedData: jest.fn().mockResolvedValue(AGENT_SIGNATURE), + }; + const { messenger, call, selectAccount } = options.keyring + ? createKeyringMessenger(SIGNATURE) + : createKeyringlessMessenger(); + let sdkWallet: HyperLiquidWalletParams | undefined; + const initialize = jest.fn(async (wallet: HyperLiquidWalletParams) => { + sdkWallet = wallet; + }); + Object.assign(mockClientService, { initialize }); + const signThroughSdkWallet = + ( + payload: PerpsTypedDataPayload, + response: Record = { status: 'ok' }, + ): (() => Promise>) => + async () => { + if (!sdkWallet) { + throw new Error('SDK used before initialize'); + } + await sdkWallet.signTypedData(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(USER_SIGNED_PAYLOAD)), + order: jest.fn( + signThroughSdkWallet(L1_PAYLOAD, { + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 123 } }] } }, + }), + ), + }); + const infoClient = createMockInfoClient({ + userAbstraction: jest + .fn() + .mockResolvedValue(options.abstraction ?? 'dexAbstraction'), + }); + mockClientService.getExchangeClient.mockReturnValue( + exchangeClient as unknown as ReturnType< + HyperLiquidClientService['getExchangeClient'] + >, + ); + mockClientService.getInfoClient.mockReturnValue( + infoClient as unknown as ReturnType< + HyperLiquidClientService['getInfoClient'] + >, + ); + const accountSignerProvider = new HyperLiquidProvider({ + platformDependencies: options.keyring + ? mockPlatformDependencies + : { ...mockPlatformDependencies, accountSigner }, + messenger, + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ], + getAgentSigner: options.getAgentSigner, + onAgentRejected: options.onAgentRejected, + }); + return { + accountSignerProvider, + accountSigner, + agentSigner, + call, + exchangeClient, + infoClient, + initialize, + selectAccount, + }; + } + + it('signs the init-time unified-account migration through accountSigner', async () => { + const { accountSignerProvider, accountSigner, call, exchangeClient } = + createAccountSignerProvider(); + + await accountSignerProvider.getMarketDataWithPrices(); + + expect(exchangeClient.userSetAbstraction).toHaveBeenCalledWith({ + user: ACCOUNT_ADDRESS, + abstraction: 'unifiedAccount', + }); + expect(accountSigner.signTypedData).toHaveBeenCalledWith( + ACCOUNT_ADDRESS, + USER_SIGNED_PAYLOAD, + ); + expect(keyringCalls(call)).toStrictEqual([]); + }); + + it('defers the init-time migration when accountSigner reports a hardware wallet', async () => { + const { accountSignerProvider, accountSigner, call, exchangeClient } = + createAccountSignerProvider({ + signer: { requiresSignatureConfirmation: () => true }, + }); + + await accountSignerProvider.getMarketDataWithPrices(); + + 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).toHaveBeenCalledTimes(1); + expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + expect(migrationAttempted()).toBe(false); + expect(keyringCalls(call)).toStrictEqual([]); + }); + + describe('prepareTradingWallet', () => { + it('runs the deferred migration, builder fee and referral setup 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).toHaveBeenCalledTimes(1); + expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], + [ACCOUNT_ADDRESS, L1_PAYLOAD], + ]); + expect(infoClient.maxBuilderFee).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({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }); + + expect(result).toStrictEqual({ ready: true }); + // Migration, referral, builder fee approval. + expect(setupSignatures).toStrictEqual([ + [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], + [ACCOUNT_ADDRESS, L1_PAYLOAD], + [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], + ]); + expect(order.success).toBe(true); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [ACCOUNT_ADDRESS, L1_PAYLOAD], + ]); + expect(exchangeClient.userSetAbstraction).toHaveBeenCalledTimes(1); + expect(exchangeClient.approveBuilderFee).toHaveBeenCalledTimes(1); + expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); + }); + + it('makes its own referral attempt when another provider ended without a result', async () => { + const { accountSignerProvider, exchangeClient } = + createAccountSignerProvider({ abstraction: 'unifiedAccount' }); + // Another provider holds the referral lock and ends without caching a + // result; release it once this provider is waiting on it. + const release = PerpsSigningCache.setInFlight( + 'referral', + 'mainnet', + ACCOUNT_ADDRESS, + ); + const waiting = '[ensureReferralSet] Global in-flight, waiting...'; + (debugLog as jest.Mock).mockImplementation((message: string) => { + if (message === waiting) { + release(); + } + }); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(debugLog).toHaveBeenCalledWith(waiting, { network: 'mainnet' }); + expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); + expect( + PerpsSigningCache.getReferral('mainnet', ACCOUNT_ADDRESS), + ).toStrictEqual({ + attempted: true, + success: true, + }); + expect(result).toStrictEqual({ ready: true }); + }); + + it('signs nothing more when called again', async () => { + const { accountSignerProvider, accountSigner } = + createAccountSignerProvider({ + signer: { requiresSignatureConfirmation: () => true }, + }); + await accountSignerProvider.prepareTradingWallet(); + const signaturesAfterFirstCall = + accountSigner.signTypedData.mock.calls.length; + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: true }); + expect( + TradingReadinessCache.get('mainnet', ACCOUNT_ADDRESS), + ).toStrictEqual( + expect.objectContaining({ attempted: true, enabled: true }), + ); + expect(signaturesAfterFirstCall).toBeGreaterThan(0); + expect(accountSigner.signTypedData).toHaveBeenCalledTimes( + signaturesAfterFirstCall, + ); + }); + + it('reports ready after the user declines the migration, since it is not asked again', async () => { + 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 SIGNATURE; + }, + ); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: true }); + expect( + TradingReadinessCache.get('mainnet', ACCOUNT_ADDRESS), + ).toStrictEqual( + expect.objectContaining({ attempted: true, enabled: false }), + ); + }); + + it('reports not ready when the builder fee approval is rejected', async () => { + const { accountSignerProvider, exchangeClient, infoClient } = + createAccountSignerProvider({ abstraction: 'unifiedAccount' }); + infoClient.maxBuilderFee.mockResolvedValue(0); + exchangeClient.approveBuilderFee.mockRejectedValue( + new Error('User rejected the request.'), + ); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: false }); + }); + + it('reports KEYRING_LOCKED when accountSigner is not ready', async () => { + const { accountSignerProvider } = createAccountSignerProvider({ + signer: { isReady: () => false }, + }); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + }); + + it('reports and logs the error when the clients cannot initialize', async () => { + const { accountSignerProvider, initialize } = + createAccountSignerProvider(); + initialize.mockRejectedValue(new Error('transport unavailable')); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: 'transport unavailable', + }); + expect(loggerError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'transport unavailable' }), + { + tags: { + feature: 'perps', + provider: 'hyperliquid', + network: 'mainnet', + }, + context: { + name: 'HyperLiquidProvider', + data: { method: 'prepareTradingWallet' }, + }, + }, + ); + }); + + it('does not log a provider replaced during preparation', async () => { + const { accountSignerProvider, initialize } = + createAccountSignerProvider(); + initialize.mockRejectedValue( + new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE), + ); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + }); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('signs the deferred setup through the keyring without accountSigner', async () => { + const { accountSignerProvider, accountSigner, call, exchangeClient } = + createAccountSignerProvider({ keyring: true }); + await accountSignerProvider.getMarketDataWithPrices(); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: true }); + expect(exchangeClient.userSetAbstraction).toHaveBeenCalledTimes(1); + expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); + expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + expect( + keyringCalls(call).filter( + (action) => action === 'KeyringController:signTypedMessage', + ), + ).toHaveLength(2); + }); + + it('reports KEYRING_LOCKED when the signer locks 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, + }); + }); + }); + + describe('with an agent', () => { + const MAINNET_ACCOUNT = { + mainAddress: ACCOUNT_ADDRESS, + isTestnet: false, + } as const; + + /** + * 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. + */ + function bind( + provider: HyperLiquidProvider, + bindings: AgentBindings, + account: PerpsAgentAccount, + agentSigner: PerpsAgentSigner | null, + ): void { + bindings.set(account, agentSigner); + provider.clearAgentSigners(); + } + + it('resolves the agent at the first L1 signature and signs with it', async () => { + const getAgentSigner = jest.fn(); + const { accountSignerProvider, accountSigner, agentSigner, initialize } = + createAccountSignerProvider({ + abstraction: 'default', + getAgentSigner, + }); + getAgentSigner.mockResolvedValue(agentSigner); + + await accountSignerProvider.getMarketDataWithPrices(); + + expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); + expect(initialize.mock.calls[0][0].address).toBe(ACCOUNT_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).toHaveBeenCalledTimes(1); + expect(agentSigner.signTypedData).toHaveBeenCalledTimes(2); + }); + + 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).toHaveBeenCalledTimes(2); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [ACCOUNT_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 } = createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + }); + + await accountSignerProvider.getMarketDataWithPrices(); + + 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([ + [ACCOUNT_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 } = + createAccountSignerProvider({ + abstraction: 'default', + getAgentSigner, + }); + + const marketData = await accountSignerProvider.getMarketDataWithPrices(); + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(marketData).toHaveLength(2); + // 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).toHaveBeenCalledTimes(3); + expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); + expect(getAgentSigner).toHaveBeenCalledTimes(4); + expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ ready: false }); + // Retryable like a locked keyring: no failure metric, nothing logged. + expect(trackPerpsEvent).not.toHaveBeenCalledWith( + 'Perp Account Setup', + expect.objectContaining({ status: 'failed' }), + ); + 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_ACCOUNT_ADDRESS, isTestnet: false }, + agentSigner, + ); + await accountSignerProvider.getMarketDataWithPrices(); + selectAccount(OTHER_ACCOUNT_ADDRESS); + await accountSignerProvider.prepareTradingWallet(); + + expect(accountSigner.signTypedData.mock.calls[0]).toStrictEqual([ + ACCOUNT_ADDRESS, + L1_PAYLOAD, + ]); + expect(agentSigner.signTypedData).toHaveBeenCalledWith(L1_PAYLOAD); + }); + + it('never signs on another network with the agent bound for mainnet', async () => { + const bindings = new AgentBindings(undefined); + const { accountSignerProvider, accountSigner, agentSigner } = + createAccountSignerProvider({ + abstraction: 'default', + getAgentSigner: bindings.resolve, + }); + bindings.set(MAINNET_ACCOUNT, agentSigner); + + mockClientService.isTestnetMode.mockReturnValue(true); + await accountSignerProvider.getMarketDataWithPrices(); + + expect(agentSigner.signTypedData).not.toHaveBeenCalled(); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [ACCOUNT_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(); + expect(accountSigner.signTypedData).toHaveBeenCalledTimes(2); + }); + + 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([ + [ACCOUNT_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 } = + 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; + await accountSignerProvider.prepareTradingWallet(); + + expect(getAgentSigner).toHaveBeenCalledTimes(1); + expect(agentSigner.signTypedData).toHaveBeenCalledTimes(2); + }); + + it('asks getAgentSigner with the network of the provider', async () => { + const getAgentSigner = jest.fn().mockResolvedValue(null); + const { accountSignerProvider } = createAccountSignerProvider({ + abstraction: 'default', + getAgentSigner, + }); + mockClientService.isTestnetMode.mockReturnValue(true); + + await accountSignerProvider.getMarketDataWithPrices(); + + expect(getAgentSigner.mock.calls).toStrictEqual([ + [{ mainAddress: ACCOUNT_ADDRESS, isTestnet: true }], + ]); + }); + + it('stops agent signing on lock and resumes after unlock', async () => { + const getAgentSigner = jest.fn(); + const { accountSignerProvider, accountSigner, agentSigner, initialize } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + }); + getAgentSigner.mockResolvedValue(agentSigner); + await accountSignerProvider.getMarketDataWithPrices(); + const [[wallet]] = initialize.mock.calls; + + 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).toHaveBeenCalledTimes(2); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [ACCOUNT_ADDRESS, L1_PAYLOAD], + ]); + expect(getAgentSigner).toHaveBeenCalledTimes(3); + }); + + it('asks getAgentSigner again once the bindings are cleared', async () => { + const getAgentSigner = jest.fn(); + const bindings = new AgentBindings(getAgentSigner); + const { accountSignerProvider, agentSigner, initialize } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner: bindings.resolve, + }); + getAgentSigner.mockResolvedValue(agentSigner); + bindings.set(MAINNET_ACCOUNT, null); + await accountSignerProvider.getMarketDataWithPrices(); + const [[wallet]] = initialize.mock.calls; + + bindings.clear(); + accountSignerProvider.clearAgentSigners(); + await wallet.signTypedData(L1_PAYLOAD); + + expect(agentSigner.signTypedData).toHaveBeenCalledWith(L1_PAYLOAD); + }); + + it('retries the referral instead of recording a failure when getAgentSigner rejects', async () => { + const getAgentSigner = jest + .fn() + .mockRejectedValue(new Error('agent store unavailable')); + const { accountSignerProvider, exchangeClient } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + }); + + await accountSignerProvider.prepareTradingWallet(); + + expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); + expect(referralAttempted()).toBe(false); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('retries the referral instead of recording a failure when the agent fails to sign', async () => { + const getAgentSigner = jest.fn(); + const { accountSignerProvider, agentSigner, exchangeClient } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + }); + agentSigner.signTypedData.mockRejectedValue( + new Error('agent key locked'), + ); + getAgentSigner.mockResolvedValue(agentSigner); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: false }); + expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); + expect(referralAttempted()).toBe(false); + 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, initialize } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + }); + await accountSignerProvider.getMarketDataWithPrices(); + const [[wallet]] = initialize.mock.calls; + + await expect(wallet.signTypedData(L1_PAYLOAD)).rejects.toThrow( + 'HyperLiquid agent signer unavailable', + ); + await expect(wallet.signTypedData(L1_PAYLOAD)).rejects.toThrow( + 'HyperLiquid agent signer unavailable', + ); + expect(getAgentSigner).toHaveBeenCalledTimes(2); + 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).toHaveBeenCalledTimes(2); + expect(agentSigner.signTypedData).not.toHaveBeenCalled(); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [ACCOUNT_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 secondResult = await accountSignerProvider.prepareTradingWallet(); + + expect(firstResult).toStrictEqual({ ready: false }); + expect(secondResult).toStrictEqual({ ready: true }); + expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(2); + }); + + describe('when the venue rejects the agent', () => { + const rejection = (address: string): Error => + new Error(`User or API Wallet ${address} does not exist.`); + + /** + * 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' | '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.initialize.mock.calls[0][0].signTypedData(L1_PAYLOAD); + throw rejection(built.agentSigner.address); + }); + return { ...built, getAgentSigner, onAgentRejected }; + } + + it('fails the order with KEYRING_LOCKED, drops the agent and asks again', async () => { + const { + accountSignerProvider, + agentSigner, + getAgentSigner, + onAgentRejected, + initialize, + } = createRejectingProvider('order'); + await accountSignerProvider.getMarketDataWithPrices(); + + const order = await accountSignerProvider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }); + await initialize.mock.calls[0][0].signTypedData(L1_PAYLOAD); + + expect(order).toStrictEqual( + expect.objectContaining({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }), + ); + expect(onAgentRejected).toHaveBeenCalledWith( + MAINNET_ACCOUNT, + agentSigner.address, + ); + expect(getAgentSigner).toHaveBeenCalledTimes(2); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('retries the silent migration instead of recording no HyperLiquid account', async () => { + const { + accountSignerProvider, + agentSigner, + onAgentRejected, + exchangeClient, + } = createRejectingProvider('agentSetAbstraction'); + + await accountSignerProvider.getMarketDataWithPrices(); + await accountSignerProvider.getMarketDataWithPrices(); + + expect(exchangeClient.agentSetAbstraction).toHaveBeenCalledTimes(2); + expect(onAgentRejected).toHaveBeenCalledWith( + MAINNET_ACCOUNT, + agentSigner.address, + ); + expect(trackPerpsEvent).not.toHaveBeenCalledWith( + 'Perp Account Setup', + expect.objectContaining({ status: 'failed' }), + ); + expect(migrationAttempted()).toBe(false); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('retries the referral instead of recording a failure', async () => { + const { + accountSignerProvider, + agentSigner, + onAgentRejected, + exchangeClient, + } = createRejectingProvider('setReferrer'); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: false }); + expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); + expect(onAgentRejected).toHaveBeenCalledWith( + MAINNET_ACCOUNT, + agentSigner.address, + ); + expect(referralAttempted()).toBe(false); + expect(loggerError).not.toHaveBeenCalled(); + }); + + 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, initialize } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + onAgentRejected, + }); + exchangeClient.order.mockImplementation(async () => { + await initialize.mock.calls[0][0].signTypedData(L1_PAYLOAD); + throw rejection(ACCOUNT_ADDRESS); + }); + await accountSignerProvider.getMarketDataWithPrices(); + + const order = await accountSignerProvider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }); + + expect(order).toStrictEqual( + expect.objectContaining({ + success: false, + error: PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, + }), + ); + expect(onAgentRejected).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({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }); + + expect(order).toStrictEqual( + expect.objectContaining({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }), + ); + 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, initialize } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + }); + await accountSignerProvider.getMarketDataWithPrices(); + const [[wallet]] = initialize.mock.calls; + + const signing = wallet.signTypedData(L1_PAYLOAD); + await asked; + getAgentSigner.mockRejectedValue(new Error('agent store unavailable')); + accountSignerProvider.clearAgentSigners(); + answer.resolve(agentSigner); + + await expect(signing).rejects.toThrow( + 'HyperLiquid agent signer unavailable', + ); + expect(agentSigner.signTypedData).not.toHaveBeenCalled(); + }); + }); +}); From dc36612eb80ecf2a6b904eb332c425ac9fdde6aa Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 29 Sep 2026 09:04:37 +0800 Subject: [PATCH 14/33] fix(perps-controller): scope rejected-agent eviction and quiet retryable cancels - Match a venue rejection only against the agent of the selected account and network that signed the action, so an agent shared across accounts is evicted for that account alone. - Cancel and batch cancel no longer log a signer that could not sign (KEYRING_LOCKED) as an error. - Lighter prepareTradingWallet reports KEYRING_LOCKED when the signer locks while the venue key is being registered. --- .../src/providers/HyperLiquidProvider.ts | 84 ++++++++++++------ .../src/providers/LighterProvider.ts | 4 + .../src/services/HyperLiquidWalletService.ts | 6 +- ...HyperLiquidProvider.account-signer.test.ts | 85 ++++++++++++++++++- .../LighterProvider.account-signer.test.ts | 21 +++++ 5 files changed, 169 insertions(+), 31 deletions(-) diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 3cbae102272..81a2c189b29 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -2140,6 +2140,33 @@ export class HyperLiquidProvider implements PerpsProvider { return agentSigner; } + /** + * 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 mappedError - The error after #mapError. + * @param method - The write that failed. + * @param extra - Context for the log. + */ + async #logWriteError( + mappedError: Error, + method: string, + extra: Record, + ): Promise { + if (mappedError.message === PERPS_ERROR_CODES.KEYRING_LOCKED) { + this.#deps.debugLogger.log( + `[${method}] Signer unavailable, the write can be retried`, + extra, + ); + return; + } + this.#deps.logger.error( + mappedError, + await this.#getTradingErrorContext(method, mappedError, extra), + ); + } + /** * The key of the resolved agent a venue rejection names. HyperLiquid * answers "User or API Wallet 0x... does not exist." with the signer's @@ -2150,7 +2177,10 @@ export class HyperLiquidProvider implements PerpsProvider { * @returns The agent key, or undefined when no resolved agent is rejected. */ #findRejectedAgentKey(error: unknown): string | undefined { - if (!isHyperLiquidUserNotFoundError(error)) { + if ( + this.#resolvedAgents.size === 0 || + !isHyperLiquidUserNotFoundError(error) + ) { return undefined; } const rejected = /user or api wallet (0x[0-9a-f]{40})/iu @@ -2158,12 +2188,21 @@ export class HyperLiquidProvider implements PerpsProvider { ensureError(error, 'HyperLiquidProvider.findRejectedAgentKey').message, )?.[1] ?.toLowerCase(); - for (const [key, { agentSigner }] of this.#resolvedAgents) { - if (agentSigner.address.toLowerCase() === rejected) { - return key; - } + let mainAddress: Hex; + try { + mainAddress = this.#walletService.getSelectedMainAddress(); + } catch { + return undefined; } - return undefined; + // Only the agent of the account and network that signed the action. + const key = this.#getAgentKey({ + mainAddress, + isTestnet: this.#clientService.isTestnetMode(), + }); + const resolved = this.#resolvedAgents.get(key); + return resolved?.agentSigner.address.toLowerCase() === rejected + ? key + : undefined; } /** @@ -8219,14 +8258,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, - }), - ); + await this.#logWriteError(mappedError, 'cancelOrder', { + orderId: params.orderId, + coin: params.symbol, + orderType: params.orderType, + }); return createErrorResult(mappedError, { success: false, orderId: params.orderId, @@ -9326,13 +9362,10 @@ export class HyperLiquidProvider implements PerpsProvider { }); } catch (error) { const mappedError = this.#mapError(error); - this.#deps.logger.error( - mappedError, - await this.#getTradingErrorContext('cancelOrder', mappedError, { - orderId: params.orderId, - coin: params.symbol, - }), - ); + await this.#logWriteError(mappedError, 'cancelOrder', { + orderId: params.orderId, + coin: params.symbol, + }); return createErrorResult(mappedError, { success: false, orderId: params.orderId, @@ -9458,12 +9491,9 @@ export class HyperLiquidProvider implements PerpsProvider { } } catch (error) { const mappedError = this.#mapError(error); - this.#deps.logger.error( - mappedError, - await this.#getTradingErrorContext('cancelOrders', mappedError, { - orderCount: params.length, - }), - ); + await this.#logWriteError(mappedError, 'cancelOrders', { + orderCount: params.length, + }); for (const result of results) { if ( !result.success && diff --git a/packages/perps-controller/src/providers/LighterProvider.ts b/packages/perps-controller/src/providers/LighterProvider.ts index d2c34d801f5..a3e9a168870 100644 --- a/packages/perps-controller/src/providers/LighterProvider.ts +++ b/packages/perps-controller/src/providers/LighterProvider.ts @@ -1322,6 +1322,10 @@ export class LighterProvider implements PerpsProvider { } 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) { if (isKeyringLockedError(caughtError)) { diff --git a/packages/perps-controller/src/services/HyperLiquidWalletService.ts b/packages/perps-controller/src/services/HyperLiquidWalletService.ts index 8bc38d36bf6..59ad4e01ec8 100644 --- a/packages/perps-controller/src/services/HyperLiquidWalletService.ts +++ b/packages/perps-controller/src/services/HyperLiquidWalletService.ts @@ -153,7 +153,7 @@ export class HyperLiquidWalletService { * * @returns The selected main account address. */ - #getSelectedMainAddress(): Hex { + public getSelectedMainAddress(): Hex { const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); if (!evmAccount?.address) { @@ -210,9 +210,9 @@ export class HyperLiquidWalletService { */ public createWalletAdapter(): HyperLiquidWalletParams { return { - address: this.#getSelectedMainAddress(), + address: this.getSelectedMainAddress(), signTypedData: async (params: PerpsTypedDataPayload): Promise => { - const mainAddress = this.#getSelectedMainAddress(); + const mainAddress = this.getSelectedMainAddress(); const agentSigner = this.#resolveAgent && isL1Action(params) ? await this.#resolveAgent(mainAddress) 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 index c87bd5ac112..a80944ae5ad 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts @@ -1040,7 +1040,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () * @returns The provider, its mocks and the rejected agent. */ function createRejectingProvider( - write: 'order' | 'agentSetAbstraction' | 'setReferrer', + write: 'order' | 'cancel' | 'agentSetAbstraction' | 'setReferrer', ): AccountSignerFixture & { getAgentSigner: jest.Mock; onAgentRejected: jest.Mock; @@ -1061,6 +1061,89 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () return { ...built, getAgentSigner, onAgentRejected }; } + it('fails a cancel with KEYRING_LOCKED without logging it', async () => { + const { accountSignerProvider, agentSigner, onAgentRejected } = + createRejectingProvider('cancel'); + await accountSignerProvider.getMarketDataWithPrices(); + + const result = await accountSignerProvider.cancelOrder({ + orderId: '123', + symbol: 'BTC', + }); + + expect(result).toStrictEqual( + expect.objectContaining({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }), + ); + expect(onAgentRejected).toHaveBeenCalledWith( + MAINNET_ACCOUNT, + agentSigner.address, + ); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('fails a batch cancel with KEYRING_LOCKED without logging it', async () => { + const { accountSignerProvider } = createRejectingProvider('cancel'); + await accountSignerProvider.getMarketDataWithPrices(); + + const result = await accountSignerProvider.cancelOrders([ + { orderId: '123', symbol: 'BTC' }, + { orderId: '124', symbol: 'BTC' }, + ]); + + expect(result.success).toBe(false); + expect(result.results.map(({ error }) => error)).toStrictEqual([ + PERPS_ERROR_CODES.KEYRING_LOCKED, + PERPS_ERROR_CODES.KEYRING_LOCKED, + ]); + 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, + initialize, + selectAccount, + } = createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + onAgentRejected, + }); + // The same agent is approved for both accounts. + getAgentSigner.mockResolvedValue(agentSigner); + await accountSignerProvider.getMarketDataWithPrices(); + const [[wallet]] = initialize.mock.calls; + selectAccount(OTHER_ACCOUNT_ADDRESS); + await wallet.signTypedData(L1_PAYLOAD); + selectAccount(ACCOUNT_ADDRESS); + exchangeClient.cancel.mockImplementation(async () => { + await wallet.signTypedData(L1_PAYLOAD); + throw rejection(agentSigner.address); + }); + + await accountSignerProvider.cancelOrder({ + orderId: '123', + symbol: 'BTC', + }); + selectAccount(OTHER_ACCOUNT_ADDRESS); + await wallet.signTypedData(L1_PAYLOAD); + + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, agentSigner.address], + ]); + // The other account's agent stays cached, so it is not asked again. + expect(getAgentSigner.mock.calls).toStrictEqual([ + [{ mainAddress: OTHER_ACCOUNT_ADDRESS, isTestnet: false }], + [MAINNET_ACCOUNT], + ]); + }); + it('fails the order with KEYRING_LOCKED, drops the agent and asks again', async () => { const { accountSignerProvider, 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 index 464ccd94a52..92429fe7e2b 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts @@ -347,6 +347,25 @@ describe('LighterProvider with accountSigner', () => { ); }); + it('reports KEYRING_LOCKED when the signer locks once the venue key is registered', async () => { + let signerReady = true; + const { provider, accountSigner, client } = buildProvider({ + isReady: () => signerReady, + }); + accountSigner.signPersonalMessage.mockImplementation(async () => { + signerReady = false; + return L1_SIGNATURE; + }); + + const result = await provider.prepareTradingWallet(); + + expect(client.sendTx).toHaveBeenCalled(); + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + }); + it('reports KEYRING_LOCKED when the keyring locks during registration', async () => { const { provider, accountSigner, deps } = buildProvider(); accountSigner.signPersonalMessage.mockRejectedValue( @@ -371,10 +390,12 @@ describe('LighterProvider with a KeyringController', () => { const result = await provider.prepareTradingWallet(); expect(result).toStrictEqual({ ready: true }); + // Readiness before and after registration, around the signature. expect(keyringCalls(call)).toStrictEqual([ 'KeyringController:getState', 'KeyringController:getState', 'KeyringController:signPersonalMessage', + 'KeyringController:getState', ]); const changePubKey = calls.find( (wasmCall) => wasmCall.function === '_signChangePubKey', From 5b86138074e2db0f846c21f72319b5453b0026e3 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 29 Sep 2026 09:23:03 +0800 Subject: [PATCH 15/33] fix(perps-controller): classify signer failures on every HyperLiquid write - Edits, position closes, TP/SL and margin updates evict a rejected agent and fail with KEYRING_LOCKED without being logged, like orders and cancels. A TP/SL update whose cancel the signer could not sign keeps the old protection instead of reporting it lost. TradingService no longer logs KEYRING_LOCKED results. - A rejection of an agent replaced while its action was in flight is still recognized, from every agent address the provider signed with. - Add providerCredentials.hyperliquid.onAgentRejected so the client can re-check its approval, and document what KEYRING_LOCKED covers. - HyperLiquid prepareTradingWallet returns KEYRING_LOCKED before running any setup while the signer is not ready, and the referral write no longer logs a locked keyring. - Rename HyperLiquidWalletService.isKeyringUnlocked to isMainAccountSignerReady, like the Lighter wallet service. - Tests: controller-level agent flows through the real provider, every write path, shared agent fixtures, and stronger readiness assertions. --- packages/perps-controller/CHANGELOG.md | 3 +- .../perps-controller/src/PerpsController.ts | 9 +- .../perps-controller/src/perpsErrorCodes.ts | 3 + .../src/providers/HyperLiquidProvider.ts | 231 +++++++++---- .../src/services/HyperLiquidWalletService.ts | 4 +- .../src/services/TradingService.ts | 57 ++-- packages/perps-controller/src/types/index.ts | 8 + .../tests/helpers/agentFixtures.ts | 54 +++ .../tests/helpers/providerMocks.ts | 3 + .../tests/helpers/serviceMocks.ts | 17 +- ...ntroller.agent-signing.integration.test.ts | 284 +++++++++++++--- .../providers/AggregatedPerpsProvider.test.ts | 50 +-- .../HyperLiquidProvider.account-mode.test.ts | 8 +- ...HyperLiquidProvider.account-signer.test.ts | 320 +++++++++++++++--- ...yperLiquidProvider.advanced-orders.test.ts | 2 +- .../HyperLiquidProvider.builder-fees.test.ts | 2 +- .../HyperLiquidProvider.data.test.ts | 2 +- ...HyperLiquidProvider.error-handling.test.ts | 2 +- .../HyperLiquidProvider.history.test.ts | 2 +- .../HyperLiquidProvider.lifecycle.test.ts | 2 +- .../HyperLiquidProvider.misc.test.ts | 2 +- .../HyperLiquidProvider.standalone.test.ts | 2 +- ...yperLiquidProvider.strategy-orders.test.ts | 2 +- .../HyperLiquidProvider.trading.test.ts | 2 +- .../HyperLiquidProvider.validation.test.ts | 2 +- ...LiquidWalletService.account-signer.test.ts | 33 +- .../services/HyperLiquidWalletService.test.ts | 6 +- ...ighterWalletService.account-signer.test.ts | 26 +- .../tests/src/services/TradingService.test.ts | 101 ++++++ .../tests/src/services/agentSigner.test.ts | 61 ++++ 30 files changed, 1051 insertions(+), 249 deletions(-) create mode 100644 packages/perps-controller/tests/helpers/agentFixtures.ts create mode 100644 packages/perps-controller/tests/src/services/agentSigner.test.ts diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 12404c54026..04ac74a432e 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 when the venue rejects an agent so the client can re-check its approval - 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 setup that needs a main-account signature before the first order: account migration, builder fee and referral on HyperLiquid, venue-key registration on Lighter ([#10559](https://github.com/MetaMask/core/pull/10559)) @@ -37,7 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- HyperLiquid orders and other exchange writes that fail because the signer cannot sign (a locked keyring) now fail with `KEYRING_LOCKED` instead of the SDK's "Failed to sign the typed data using the wallet" message, and a failed order for that reason is no longer reported as an error ([#10559](https://github.com/MetaMask/core/pull/10559)) +- HyperLiquid orders, edits, cancels, position closes, TP/SL and margin updates that fail because the signer cannot sign (a locked keyring) now fail with `KEYRING_LOCKED` instead of the SDK's "Failed to sign the typed data using the wallet" message, and are no longer reported as errors by the provider or `TradingService` ([#10559](https://github.com/MetaMask/core/pull/10559)) ## [18.0.1] diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 8e4b9be2edb..dc2a0d47734 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -2376,8 +2376,13 @@ export class PerpsController extends BaseController< ?.subscriptionBuilderAddressMainnet, onChaseOrderMaxDistanceReached: this.#publishChaseOrderMaxDistanceReached, getAgentSigner: this.#agentBindings.resolve, - onAgentRejected: (account, agentAddress): void => - this.#agentBindings.release(account, agentAddress), + onAgentRejected: (account, agentAddress): void => { + this.#agentBindings.release(account, agentAddress); + this.#options.clientConfig?.providerCredentials?.hyperliquid?.onAgentRejected?.( + account, + agentAddress, + ); + }, }); this.providers.set('hyperliquid', hyperLiquidProvider); 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/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 81a2c189b29..7f79c4d6215 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -554,6 +554,8 @@ type CancelOrderBatchOutcome = { remainingOrderIds: number[]; cancelledOrderIds: number[]; responseComplete: boolean; + // Set when the signer could not sign the cancel, so nothing was cancelled. + signerFailure?: unknown; }; type OrderPlacementOutcome = { @@ -1556,12 +1558,13 @@ export class HyperLiquidProvider implements PerpsProvider { readonly #agentSigners = new Map>(); - // The agents those answers resolved to, so a venue rejection of one can be - // matched to its account and evicted. - readonly #resolvedAgents = new Map< - string, - { account: PerpsAgentAccount; agentSigner: PerpsAgentSigner } - >(); + // The agents those answers resolved to, dropped when the venue rejects one. + readonly #resolvedAgents = new Map(); + + // Every agent address this provider signed with, per network and main + // account. Kept across clearAgentSigners, so the rejection of an agent that + // was replaced while its action was in flight is still recognized. + readonly #signedAgentAddresses = new Map>(); readonly #onAgentRejected: HyperLiquidProviderOptions['onAgentRejected']; @@ -2132,7 +2135,10 @@ export class HyperLiquidProvider implements PerpsProvider { return await this.#resolveAgentSigner(mainAddress); } if (agentSigner) { - this.#resolvedAgents.set(key, { account, agentSigner }); + this.#resolvedAgents.set(key, agentSigner); + const signed = this.#signedAgentAddresses.get(key) ?? new Set(); + signed.add(agentSigner.address.toLowerCase()); + this.#signedAgentAddresses.set(key, signed); } else { this.#agentSigners.delete(key); this.#resolvedAgents.delete(key); @@ -2168,67 +2174,117 @@ export class HyperLiquidProvider implements PerpsProvider { } /** - * The key of the resolved agent a venue rejection names. 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. + * The agent a venue rejection names, when this provider signed with it for + * the selected account and network. 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 agent key, or undefined when no resolved agent is rejected. + * @returns The rejected agent with its account, or undefined. */ - #findRejectedAgentKey(error: unknown): string | undefined { + #findRejectedAgent( + error: unknown, + ): + | { account: PerpsAgentAccount; key: string; agentAddress: Hex } + | undefined { if ( - this.#resolvedAgents.size === 0 || + this.#signedAgentAddresses.size === 0 || !isHyperLiquidUserNotFoundError(error) ) { return undefined; } - const rejected = /user or api wallet (0x[0-9a-f]{40})/iu - .exec( - ensureError(error, 'HyperLiquidProvider.findRejectedAgentKey').message, - )?.[1] - ?.toLowerCase(); + const agentAddress = /user or api wallet (0x[0-9a-f]{40})/iu.exec( + ensureError(error, 'HyperLiquidProvider.findRejectedAgent').message, + )?.[1] as Hex | undefined; + if (!agentAddress) { + return undefined; + } let mainAddress: Hex; try { mainAddress = this.#walletService.getSelectedMainAddress(); } catch { return undefined; } - // Only the agent of the account and network that signed the action. - const key = this.#getAgentKey({ + // Only agents of the account and network that signed the action. + const account: PerpsAgentAccount = { mainAddress, isTestnet: this.#clientService.isTestnetMode(), - }); - const resolved = this.#resolvedAgents.get(key); - return resolved?.agentSigner.address.toLowerCase() === rejected - ? key + }; + const key = this.#getAgentKey(account); + return this.#signedAgentAddresses.get(key)?.has(agentAddress.toLowerCase()) + ? { account, key, agentAddress } : undefined; } /** - * Evict a resolved agent the venue rejected, so the next L1 action asks - * for one again, and tell the owner of the bindings. + * 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 a rejection of a resolved agent. + * @returns True when the error was the rejection of an agent. */ #evictRejectedAgent(error: unknown): boolean { - const key = this.#findRejectedAgentKey(error); - const rejected = - key === undefined ? undefined : this.#resolvedAgents.get(key); - if (key === undefined || !rejected) { + const rejected = this.#findRejectedAgent(error); + if (!rejected) { return false; } - this.#agentSigners.delete(key); - this.#resolvedAgents.delete(key); + const { account, key, agentAddress } = rejected; + if ( + this.#resolvedAgents.get(key)?.address.toLowerCase() === + agentAddress.toLowerCase() + ) { + this.#agentSigners.delete(key); + this.#resolvedAgents.delete(key); + } this.#deps.debugLogger.log( 'HyperLiquidProvider: agent rejected by the venue, asking again', - { agent: rejected.agentSigner.address }, + { agent: agentAddress }, ); - this.#onAgentRejected?.(rejected.account, rejected.agentSigner.address); + 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 { + return isKeyringLockedError(error) || + isAgentSignerUnavailableError(error) || + this.#evictRejectedAgent(error) + ? new Error(PERPS_ERROR_CODES.KEYRING_LOCKED) + : undefined; + } + /** * Decide whether the wallet has a Hyperliquid account. * @@ -2968,7 +3024,7 @@ export class HyperLiquidProvider implements PerpsProvider { // Only mark complete if keyring was unlocked (signing could actually // happen) and the referral does not need another attempt. if ( - this.#walletService.isKeyringUnlocked() && + this.#walletService.isMainAccountSignerReady() && !this.#referralSetupNeedsRetry ) { this.#tradingSetupComplete = true; @@ -4235,15 +4291,9 @@ export class HyperLiquidProvider implements PerpsProvider { #mapError(error: unknown): Error { const { message } = ensureError(error, 'HyperLiquidProvider.mapError'); - // The signer could not sign this action: a locked keyring, an unavailable - // agent, or an agent the venue rejected (revoked or expired; evicted here - // so the next action asks for one again). The next attempt retries. - if ( - isKeyringLockedError(error) || - isAgentSignerUnavailableError(error) || - this.#evictRejectedAgent(error) - ) { - return new Error(PERPS_ERROR_CODES.KEYRING_LOCKED); + const signerFailure = this.#classifySignerFailure(error); + if (signerFailure) { + return signerFailure; } // "User or API Wallet 0x... does not exist." carries the user's address, so @@ -8647,6 +8697,15 @@ export class HyperLiquidProvider implements PerpsProvider { return classifyStatuses(statuses); } catch (error) { + // The signer could not sign, so nothing was cancelled. + if (this.#isSignerFailure(error)) { + return { + remainingOrderIds: requests.map((request) => request.o), + cancelledOrderIds: [], + responseComplete: false, + signerFailure: error, + }; + } const statuses = getCancelStatusesFromError(error, requests.length); if (statuses) { return classifyStatuses(statuses); @@ -9258,6 +9317,14 @@ export class HyperLiquidProvider implements PerpsProvider { : { orderId: replacementOrderId }), }; } catch (error) { + const signerFailure = this.#classifySignerFailure(error); + if (signerFailure) { + this.#deps.debugLogger.log( + '[editOrder] Signer unavailable, the edit can be retried', + { orderId: params.orderId }, + ); + return createErrorResult(signerFailure, { success: false }); + } this.#deps.logger.error( ensureError(error, 'HyperLiquidProvider.editOrder'), this.#getErrorContext('editOrder', { @@ -9856,16 +9923,23 @@ 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.#classifySignerFailure(error); + const safeError = + signerFailure ?? + ensureError(error, 'HyperLiquidProvider.closePositions'); + if (signerFailure) { + this.#deps.debugLogger.log( + '[closePositions] Signer unavailable, the close can be retried', + { positionCount: positionsToClose.length }, + ); + } else { + this.#deps.logger.error( + safeError, + this.#getErrorContext('closePositions', { + positionCount: positionsToClose.length, + }), + ); + } // Return all selected positions as failed, including unavailable DEXes. return { success: false, @@ -10489,6 +10563,18 @@ export class HyperLiquidProvider implements PerpsProvider { exchangeClient, cancelRequests, ); + if (oldCancellation.signerFailure !== undefined) { + // Nothing was cancelled, so the old protection is still in place. + this.#deps.debugLogger.log( + '[updatePositionTPSL] Signer unavailable, the update can be retried', + { symbol }, + ); + return createErrorResult( + this.#classifySignerFailure(oldCancellation.signerFailure) ?? + new Error(PERPS_ERROR_CODES.KEYRING_LOCKED), + { success: false }, + ); + } if (!oldCancellation.responseComplete) { const requestedOrderIds = new Set( cancelRequests.map((request) => request.o), @@ -10652,6 +10738,14 @@ export class HyperLiquidProvider implements PerpsProvider { } throw new Error(PERPS_ERROR_CODES.TPSL_UPDATE_FAILED); } catch (error) { + const signerFailure = this.#classifySignerFailure(error); + if (signerFailure) { + this.#deps.debugLogger.log( + '[updatePositionTPSL] Signer unavailable, the update can be retried', + { symbol: params.symbol }, + ); + return createErrorResult(signerFailure, { success: false }); + } this.#deps.logger.error( ensureError(error, 'HyperLiquidProvider.updatePositionTPSL'), this.#getErrorContext('updatePositionTPSL', { @@ -10939,6 +11033,14 @@ export class HyperLiquidProvider implements PerpsProvider { success: true, }; } catch (error) { + const signerFailure = this.#classifySignerFailure(error); + if (signerFailure) { + this.#deps.debugLogger.log( + '[updateMargin] Signer unavailable, the update can be retried', + { symbol: params.symbol }, + ); + return { success: false, error: signerFailure.message }; + } const safeError = ensureError(error, 'HyperLiquidProvider.updateMargin'); this.#deps.logger.error( safeError, @@ -14334,11 +14436,15 @@ export class HyperLiquidProvider implements PerpsProvider { * HyperLiquid account yet). */ 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 { network, userAddress } = await this.#ensureReadyForTrading({ requiresBuilderFee: true, }); - if (!this.#walletService.isKeyringUnlocked()) { + if (!this.#walletService.isMainAccountSignerReady()) { return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; } const ready = @@ -15752,13 +15858,10 @@ export class HyperLiquidProvider implements PerpsProvider { return result?.status === 'ok'; } catch (error) { - // Retryable (an unavailable agent, or one the venue rejected): - // `#ensureReferralSet` retries at the next entry, so it is not an error - // to report. - if ( - isAgentSignerUnavailableError(error) || - this.#findRejectedAgentKey(error) !== undefined - ) { + // 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 diff --git a/packages/perps-controller/src/services/HyperLiquidWalletService.ts b/packages/perps-controller/src/services/HyperLiquidWalletService.ts index 59ad4e01ec8..afac213dde8 100644 --- a/packages/perps-controller/src/services/HyperLiquidWalletService.ts +++ b/packages/perps-controller/src/services/HyperLiquidWalletService.ts @@ -89,7 +89,7 @@ export class HyperLiquidWalletService { * * @returns True when the main account is available for signing. */ - public isKeyringUnlocked(): boolean { + public isMainAccountSignerReady(): boolean { return isMainAccountSignerReady( this.#deps.accountSigner, () => this.#messenger.call('KeyringController:getState').isUnlocked, @@ -133,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 diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index 388459527aa..60fa007af0b 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' }; } @@ -1933,8 +1948,9 @@ export class TradingService { if ( provider.cancelOrders && - operationResult && - operationResult.failureCount > 0 + operationResult?.results.some( + (result) => !result.success && !isSignerUnavailable(result.error), + ) ) { const failureSummary = operationResult.results .filter((result) => !result.success) @@ -2107,13 +2123,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) @@ -2310,8 +2328,9 @@ export class TradingService { if ( provider.closePositions && - operationResult && - operationResult.failureCount > 0 + operationResult?.results.some( + (result) => !result.success && !isSignerUnavailable(result.error), + ) ) { const failureSummary = operationResult.results .filter((result) => !result.success) diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index ccb18bf495f..a74deb90e14 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1134,6 +1134,14 @@ export type HyperLiquidCredentials = { 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`. + */ + onAgentRejected?: (account: PerpsAgentAccount, agentAddress: Hex) => void; }; export type LighterCredentials = { diff --git a/packages/perps-controller/tests/helpers/agentFixtures.ts b/packages/perps-controller/tests/helpers/agentFixtures.ts new file mode 100644 index 00000000000..12b76610497 --- /dev/null +++ b/packages/perps-controller/tests/helpers/agentFixtures.ts @@ -0,0 +1,54 @@ +import type { PerpsTypedDataPayload } from '../../src/types/index.js'; +import { createMockEvmAccount } from './serviceMocks.js'; + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as const; + +/** An agent address that is not the mock main account. */ +export const AGENT_ADDRESS = + '0x00000000000000000000000000000000000a9e17' 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: { + '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: createMockEvmAccount().address, + abstraction: 'unifiedAccount', + 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: { + Agent: [ + { name: 'source', type: 'string' }, + { name: 'connectionId', type: 'bytes32' }, + ], + }, + primaryType: 'Agent', + message: { source: 'a', connectionId: `0x${'22'.repeat(32)}` }, +}; diff --git a/packages/perps-controller/tests/helpers/providerMocks.ts b/packages/perps-controller/tests/helpers/providerMocks.ts index 53aa951c131..0a47f5fa977 100644 --- a/packages/perps-controller/tests/helpers/providerMocks.ts +++ b/packages/perps-controller/tests/helpers/providerMocks.ts @@ -290,6 +290,9 @@ export const createMockExchangeClient = ( updateLeverage: jest.fn().mockResolvedValue({ status: 'ok', }), + updateIsolatedMargin: jest.fn().mockResolvedValue({ + status: 'ok', + }), approveBuilderFee: jest.fn().mockResolvedValue({ status: 'ok', }), diff --git a/packages/perps-controller/tests/helpers/serviceMocks.ts b/packages/perps-controller/tests/helpers/serviceMocks.ts index cca6fdbb848..7322a9d4c25 100644 --- a/packages/perps-controller/tests/helpers/serviceMocks.ts +++ b/packages/perps-controller/tests/helpers/serviceMocks.ts @@ -305,12 +305,14 @@ type AccountMessenger = { * @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, a spy on its `call`, and a way to switch the * selected account. */ const createAccountMessenger = ( keyringType: string, keyringSignature?: string, + isUnlocked = true, ): AccountMessenger => { const account = createMockEvmAccount(); let selectedAddress = account.address; @@ -336,7 +338,7 @@ const createAccountMessenger = ( }); } else { root.registerActionHandler('KeyringController:getState', () => ({ - isUnlocked: true, + isUnlocked, keyrings: [], })); root.registerActionHandler( @@ -380,16 +382,19 @@ export const createKeyringlessMessenger = ( ): AccountMessenger => createAccountMessenger(keyringType); /** - * Create a real PerpsController messenger for a host with an unlocked - * KeyringController that returns `signature` for typed data and personal - * messages. + * 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, a spy on its `call`, and a way to switch the * selected account. */ -export const createKeyringMessenger = (signature: string): AccountMessenger => - createAccountMessenger('HD Key Tree', signature); +export const createKeyringMessenger = ( + signature: string, + isUnlocked = true, +): AccountMessenger => + createAccountMessenger('HD Key Tree', signature, isUnlocked); /** * Names of the `KeyringController:*` actions a messenger spy saw. diff --git a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts index 66953c9a321..551f7fc10ce 100644 --- a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts @@ -4,13 +4,19 @@ import { getDefaultPerpsControllerState, PerpsController, } from '../../src/PerpsController.js'; +import { PERPS_ERROR_CODES } from '../../src/perpsErrorCodes.js'; import { HyperLiquidClientService } from '../../src/services/HyperLiquidClientService.js'; import type { HyperLiquidWalletParams } from '../../src/services/HyperLiquidClientService.js'; +import { TradingReadinessCache } from '../../src/services/TradingReadinessCache.js'; import type { PerpsAgentAccount, PerpsAgentSigner, - PerpsTypedDataPayload, } from '../../src/types/index.js'; +import { L1_PAYLOAD, USER_SIGNED_PAYLOAD } from '../helpers/agentFixtures.js'; +import { + createMockExchangeClient, + createMockInfoClient, +} from '../helpers/providerMocks.js'; import { createMockEvmAccount, createMockInfrastructure, @@ -34,54 +40,12 @@ const MockedClientService = HyperLiquidClientService as jest.MockedClass< const MAIN_ADDRESS = createMockEvmAccount().address; const MAIN_SIGNATURE = `0x${'ab'.repeat(65)}` as const; const AGENT_SIGNATURE = `0x${'cd'.repeat(65)}` as const; -const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as const; // The controller starts on mainnet (default state). const MAINNET_ACCOUNT: PerpsAgentAccount = { mainAddress: MAIN_ADDRESS, isTestnet: false, }; -const USER_SIGNED_PAYLOAD: PerpsTypedDataPayload = { - domain: { - name: 'HyperliquidSignTransaction', - version: '1', - chainId: 1, - verifyingContract: ZERO_ADDRESS, - }, - types: { - '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, - }, -}; - -const L1_PAYLOAD: PerpsTypedDataPayload = { - domain: { - name: 'Exchange', - version: '1', - chainId: 1337, - verifyingContract: ZERO_ADDRESS, - }, - types: { - Agent: [ - { name: 'source', type: 'string' }, - { name: 'connectionId', type: 'bytes32' }, - ], - }, - primaryType: 'Agent', - message: { source: 'a', connectionId: `0x${'22'.repeat(32)}` }, -}; - type ClientServiceMock = { initialize: jest.Mock, [HyperLiquidWalletParams]>; isTestnetMode: () => boolean; @@ -95,9 +59,15 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => }; let agentSigner: PerpsAgentSigner & { signTypedData: jest.Mock }; let getAgentSigner: jest.Mock; + let onAgentRejected: jest.Mock; + let infrastructure: ReturnType; + let loggerError: jest.SpyInstance; + let exchangeClient: ReturnType; beforeEach(() => { + TradingReadinessCache.clearAll(); clientServices = []; + exchangeClient = createMockExchangeClient(); MockedClientService.mockImplementation((_deps, options) => { const isTestnet = options?.isTestnet ?? false; const clientService = { @@ -107,10 +77,13 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => isInitialized: jest.fn().mockReturnValue(true), isTestnetMode: (): boolean => isTestnet, ensureInitialized: jest.fn(), - getInfoClient: jest.fn().mockReturnValue({ - twapHistory: jest.fn().mockResolvedValue([]), - userTwapSliceFills: jest.fn().mockResolvedValue([]), - }), + getInfoClient: jest.fn().mockReturnValue( + createMockInfoClient({ + twapHistory: jest.fn().mockResolvedValue([]), + userTwapSliceFills: jest.fn().mockResolvedValue([]), + }), + ), + getExchangeClient: jest.fn(() => exchangeClient), getSubscriptionClient: jest.fn(), setOnTerminateCallback: jest.fn(), setOnReconnectCallback: jest.fn(), @@ -128,12 +101,45 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => signTypedData: jest.fn().mockResolvedValue(AGENT_SIGNATURE), }; getAgentSigner = jest.fn().mockResolvedValue(agentSigner); + onAgentRejected = jest.fn(); + infrastructure = createMockInfrastructure(); + loggerError = jest.spyOn(infrastructure.logger, 'error'); }); afterEach(() => { jest.clearAllMocks(); }); + /** + * A messenger whose remote feature flags are empty, so the controller + * reads its defaults instead of logging a missing flag state. + * + * @returns The messenger. + */ + function createMessenger(): ReturnType { + const messenger = createMockMessenger(); + const defaultCall = messenger.call.getMockImplementation(); + messenger.call.mockImplementation( + (action: string, ...args: unknown[]): unknown => + action === 'RemoteFeatureFlagController:getState' + ? { remoteFeatureFlags: {}, cacheTimestamp: 0 } + : defaultCall?.(action as never, ...(args as never[])), + ); + return messenger; + } + + /** + * The errors the HyperLiquid provider itself reported. + * + * @returns The logged errors whose context names the provider. + */ + function providerErrors(): unknown[] { + return loggerError.mock.calls.filter( + ([, options]: [unknown, { context?: { name?: string } } | undefined]) => + options?.context?.name === 'HyperLiquidProvider', + ); + } + /** * Build a controller whose host signs with `accountSigner` and resolves * agents with `getAgentSigner`. @@ -142,16 +148,53 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => */ function createController(): PerpsController { return new PerpsController({ - messenger: createMockMessenger(), + messenger: createMessenger(), state: getDefaultPerpsControllerState(), clientConfig: { - providerCredentials: { hyperliquid: { getAgentSigner } }, + providerCredentials: { + hyperliquid: { getAgentSigner, onAgentRejected }, + }, }, - infrastructure: { ...createMockInfrastructure(), accountSigner }, + infrastructure: { ...infrastructure, accountSigner }, deferEligibilityCheck: true, }); } + /** + * 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), + }; + } + + /** + * Make the venue sign each cancel with the SDK wallet, then reject it as + * an unknown wallet: what HyperLiquid answers for a revoked or expired + * agent. + * + * @param wallet - The SDK wallet the provider signs with. + * @param rejectedAddress - The address the venue rejects. + */ + function rejectCancelsAs( + wallet: HyperLiquidWalletParams, + rejectedAddress: Hex, + ): void { + exchangeClient.cancel.mockImplementation(async () => { + await wallet.signTypedData(L1_PAYLOAD); + throw new Error(`User or API Wallet ${rejectedAddress} does not exist.`); + }); + } + /** * Make the active HyperLiquid provider initialize its SDK clients, and * return the wallet adapter it handed to them. @@ -251,4 +294,145 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => expect(boundAgent.signTypedData.mock.calls).toStrictEqual([[L1_PAYLOAD]]); expect(getAgentSigner).not.toHaveBeenCalled(); }); + it('signs with the agent bound through setAgentSigner after another was resolved', async () => { + const reboundAgent = createAgent( + '0x00000000000000000000000000000000000b0a7d', + `0x${'ef'.repeat(65)}`, + ); + const controller = createController(); + await controller.init(); + const wallet = await getSdkWallet(controller); + + const resolvedSignature = await wallet.signTypedData(L1_PAYLOAD); + controller.setAgentSigner(MAINNET_ACCOUNT, reboundAgent); + const reboundSignature = await wallet.signTypedData(L1_PAYLOAD); + controller.setAgentSigner(MAINNET_ACCOUNT, null); + const pinnedSignature = await wallet.signTypedData(L1_PAYLOAD); + + expect([ + resolvedSignature, + reboundSignature, + pinnedSignature, + ]).toStrictEqual([AGENT_SIGNATURE, `0x${'ef'.repeat(65)}`, MAIN_SIGNATURE]); + expect(agentSigner.signTypedData.mock.calls).toStrictEqual([[L1_PAYLOAD]]); + expect(reboundAgent.signTypedData.mock.calls).toStrictEqual([[L1_PAYLOAD]]); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [MAIN_ADDRESS, L1_PAYLOAD], + ]); + expect(getAgentSigner).toHaveBeenCalledTimes(1); + }); + + it('tells the host about an agent the venue rejects and asks for another', async () => { + const replacementAgent = createAgent( + '0x00000000000000000000000000000000000b0a7d', + `0x${'ef'.repeat(65)}`, + ); + getAgentSigner + .mockResolvedValueOnce(agentSigner) + .mockResolvedValueOnce(replacementAgent); + const controller = createController(); + await controller.init(); + const wallet = await getSdkWallet(controller); + rejectCancelsAs(wallet, agentSigner.address); + + const result = await controller.cancelOrder({ + orderId: '1', + symbol: 'BTC', + }); + const nextSignature = await wallet.signTypedData(L1_PAYLOAD); + + expect(result).toStrictEqual( + expect.objectContaining({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }), + ); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, agentSigner.address], + ]); + expect(nextSignature).toBe(`0x${'ef'.repeat(65)}`); + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); + // The provider does not report the retryable signer failure. + expect(providerErrors()).toStrictEqual([]); + }); + + it('releases a setAgentSigner binding to an agent the venue rejects', async () => { + const boundAgent = createAgent( + '0x00000000000000000000000000000000000b0a7d', + `0x${'ef'.repeat(65)}`, + ); + const controller = createController(); + await controller.init(); + controller.setAgentSigner(MAINNET_ACCOUNT, boundAgent); + const wallet = await getSdkWallet(controller); + rejectCancelsAs(wallet, boundAgent.address); + + const result = await controller.cancelOrder({ + orderId: '1', + symbol: 'BTC', + }); + const nextSignature = await wallet.signTypedData(L1_PAYLOAD); + + expect(result).toStrictEqual( + expect.objectContaining({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }), + ); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, boundAgent.address], + ]); + // The binding is gone, so the host's getAgentSigner answers. + expect(nextSignature).toBe(AGENT_SIGNATURE); + expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); + }); + + it('prepares nothing and reports KEYRING_LOCKED while the account signer is not ready', async () => { + const controller = new PerpsController({ + messenger: createMessenger(), + state: getDefaultPerpsControllerState(), + clientConfig: { + providerCredentials: { + hyperliquid: { getAgentSigner, onAgentRejected }, + }, + }, + infrastructure: { + ...infrastructure, + accountSigner: { ...accountSigner, isReady: (): boolean => false }, + }, + deferEligibilityCheck: true, + }); + await controller.init(); + + const result = await controller.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect( + clientServices.flatMap(({ initialize }) => initialize.mock.calls), + ).toStrictEqual([]); + expect( + Object.values(exchangeClient).filter( + (write) => write.mock.calls.length > 0, + ), + ).toStrictEqual([]); + expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + expect(getAgentSigner).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it("resolves the provider's readiness once the account is ready to trade", async () => { + const controller = createController(); + await controller.init(); + + const result = await controller.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: true }); + expect(loggerError).not.toHaveBeenCalled(); + }); }); diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts index 50bb878684c..0a72f86ee74 100644 --- a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -1142,32 +1142,34 @@ describe('AggregatedPerpsProvider', () => { ); }); - it('tags a logged preparation failure with the network', async () => { - const testnetProvider = new AggregatedPerpsProvider({ - providers: new Map([['hyperliquid', mockHLProvider]]), - defaultProvider: 'hyperliquid', - infrastructure: mockInfrastructure, - isTestnet: true, - }); - Object.assign(mockHLProvider, { - prepareTradingWallet: jest - .fn() - .mockRejectedValue(new Error('provider crashed')), - }); + 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, + }); + Object.assign(mockHLProvider, { + prepareTradingWallet: jest + .fn() + .mockRejectedValue(new Error('provider crashed')), + }); - await testnetProvider.prepareTradingWallet(); + await networkProvider.prepareTradingWallet(); - expect(mockInfrastructure.logger.error).toHaveBeenCalledWith( - expect.objectContaining({ message: 'provider crashed' }), - expect.objectContaining({ - tags: { - feature: 'perps', - provider: 'hyperliquid', - network: 'testnet', - }, - }), - ); - }); + expect(mockInfrastructure.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ message: 'provider crashed' }), + expect.objectContaining({ + tags: { feature: 'perps', provider: 'hyperliquid', network }, + }), + ); + }, + ); it('prepares the next provider only after the previous one settles', async () => { const firstPreparation = createDeferred<{ ready: boolean }>(); 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 29b2a0b4b8d..4507124698f 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 @@ -248,7 +248,7 @@ describe('HyperLiquidProvider', () => { getUserAddressWithDefault: jest .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), - isKeyringUnlocked: jest.fn().mockReturnValue(true), + isMainAccountSignerReady: jest.fn().mockReturnValue(true), isSelectedHardwareWallet: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; @@ -745,8 +745,10 @@ describe('HyperLiquidProvider', () => { }); // Keyring is locked ( - mockWalletService as unknown as { isKeyringUnlocked: jest.Mock } - ).isKeyringUnlocked.mockReturnValue(false); + mockWalletService as unknown as { + isMainAccountSignerReady: jest.Mock; + } + ).isMainAccountSignerReady.mockReturnValue(false); // Act await testableProvider.ensureReadyForTrading(); 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 index a80944ae5ad..90a8a47db66 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts @@ -17,6 +17,11 @@ import type { PerpsPlatformDependencies, PerpsTypedDataPayload, } from '../../../src/types/index.js'; +import { + AGENT_ADDRESS, + L1_PAYLOAD, + USER_SIGNED_PAYLOAD, +} from '../../helpers/agentFixtures.js'; import { createMockExchangeClient, createMockInfoClient, @@ -117,51 +122,8 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () const ACCOUNT_ADDRESS = createMockEvmAccount().address; const OTHER_ACCOUNT_ADDRESS = '0x00000000000000000000000000000000000b0b01' as const; - const AGENT_ADDRESS = '0x00000000000000000000000000000000000a9e17' as const; const SIGNATURE = `0x${'cd'.repeat(65)}` as const; const AGENT_SIGNATURE = `0x${'ef'.repeat(65)}` as const; - const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as const; - // Shapes the SDK signs: user-signed actions use the - // HyperliquidSignTransaction domain, L1 actions the Exchange domain. - const USER_SIGNED_PAYLOAD: PerpsTypedDataPayload = { - domain: { - name: 'HyperliquidSignTransaction', - version: '1', - chainId: 1, - verifyingContract: ZERO_ADDRESS, - }, - types: { - '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: ACCOUNT_ADDRESS, - abstraction: 'unifiedAccount', - nonce: 1, - }, - }; - const L1_PAYLOAD: PerpsTypedDataPayload = { - domain: { - name: 'Exchange', - version: '1', - chainId: 1337, - verifyingContract: ZERO_ADDRESS, - }, - types: { - Agent: [ - { name: 'source', type: 'string' }, - { name: 'connectionId', type: 'bytes32' }, - ], - }, - primaryType: 'Agent', - message: { source: 'a', connectionId: `0x${'22'.repeat(32)}` }, - }; /** * Whether the unified-account migration is recorded as attempted for the @@ -440,7 +402,13 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () } }); - const result = await accountSignerProvider.prepareTradingWallet(); + let result; + try { + result = await accountSignerProvider.prepareTradingWallet(); + } finally { + // Never leak the global lock into later tests. + release(); + } expect(debugLog).toHaveBeenCalledWith(waiting, { network: 'mainnet' }); expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); @@ -459,8 +427,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () signer: { requiresSignatureConfirmation: () => true }, }); await accountSignerProvider.prepareTradingWallet(); - const signaturesAfterFirstCall = - accountSigner.signTypedData.mock.calls.length; + const firstSignatures = accountSigner.signTypedData.mock.calls.slice(); const result = await accountSignerProvider.prepareTradingWallet(); @@ -470,9 +437,13 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () ).toStrictEqual( expect.objectContaining({ attempted: true, enabled: true }), ); - expect(signaturesAfterFirstCall).toBeGreaterThan(0); - expect(accountSigner.signTypedData).toHaveBeenCalledTimes( - signaturesAfterFirstCall, + // Migration, then referral; nothing on the second call. + expect(firstSignatures).toStrictEqual([ + [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], + [ACCOUNT_ADDRESS, L1_PAYLOAD], + ]); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual( + firstSignatures, ); }); @@ -491,13 +462,21 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () ); const result = await accountSignerProvider.prepareTradingWallet(); + const secondResult = await accountSignerProvider.prepareTradingWallet(); expect(result).toStrictEqual({ ready: true }); + expect(secondResult).toStrictEqual({ ready: true }); expect( TradingReadinessCache.get('mainnet', ACCOUNT_ADDRESS), ).toStrictEqual( expect.objectContaining({ attempted: true, enabled: false }), ); + // Declined once, not asked again. + expect( + accountSigner.signTypedData.mock.calls.filter( + ([, payload]) => payload === USER_SIGNED_PAYLOAD, + ), + ).toHaveLength(1); }); it('reports not ready when the builder fee approval is rejected', async () => { @@ -511,10 +490,42 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () const result = await accountSignerProvider.prepareTradingWallet(); expect(result).toStrictEqual({ ready: false }); + expect(exchangeClient.approveBuilderFee).toHaveBeenCalledTimes(1); }); - it('reports KEYRING_LOCKED when accountSigner is not ready', async () => { - const { accountSignerProvider } = createAccountSignerProvider({ + 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({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }); + + expect(order).toStrictEqual( + expect.objectContaining({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }), + ); + expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + expect(referralAttempted()).toBe(false); + }); + + it('reports KEYRING_LOCKED when accountSigner is not ready, without running or logging setup', async () => { + const { + accountSignerProvider, + accountSigner, + exchangeClient, + initialize, + } = createAccountSignerProvider({ signer: { isReady: () => false }, }); @@ -524,6 +535,13 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () 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 () => { @@ -1040,7 +1058,13 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () * @returns The provider, its mocks and the rejected agent. */ function createRejectingProvider( - write: 'order' | 'cancel' | 'agentSetAbstraction' | 'setReferrer', + write: + | 'order' + | 'cancel' + | 'modify' + | 'updateIsolatedMargin' + | 'agentSetAbstraction' + | 'setReferrer', ): AccountSignerFixture & { getAgentSigner: jest.Mock; onAgentRejected: jest.Mock; @@ -1084,6 +1108,198 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () 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([ + { + 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: [], + }, + ]); + 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( + expect.objectContaining({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }), + ); + expect(onAgentRejected).toHaveBeenCalledTimes(1); + 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.success).toBe(false); + expect(result.results.map(({ error }) => error)).toStrictEqual([ + PERPS_ERROR_CODES.KEYRING_LOCKED, + ]); + expect(onAgentRejected).toHaveBeenCalledTimes(1); + 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( + expect.objectContaining({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }), + ); + expect(onAgentRejected).toHaveBeenCalledTimes(1); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('keeps the old protection and fails with KEYRING_LOCKED when its cancel is rejected', async () => { + const { accountSignerProvider, exchangeClient, infoClient } = + createRejectingProvider('cancel'); + infoClient.frontendOpenOrders.mockResolvedValue([ + { + coin: 'BTC', + side: 'A', + limitPx: '58000', + sz: '0.1', + origSz: '0.1', + oid: 456, + timestamp: 1, + orderType: 'Take Profit Market', + tif: null, + isTrigger: true, + triggerPx: '58000', + triggerCondition: 'Price above 58000', + reduceOnly: true, + isPositionTpsl: true, + cloid: null, + children: [], + }, + ]); + await accountSignerProvider.getMarketDataWithPrices(); + + const result = await accountSignerProvider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '60000', + }); + + expect(result).toStrictEqual( + expect.objectContaining({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }), + ); + expect(exchangeClient.cancel).toHaveBeenCalledTimes(1); + expect(exchangeClient.order).not.toHaveBeenCalled(); + 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).toHaveBeenCalledTimes(1); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('recognizes the rejection of an agent replaced while its action was in flight', async () => { + const getAgentSigner = jest.fn(); + const onAgentRejected = jest.fn(); + const { + accountSignerProvider, + agentSigner, + exchangeClient, + initialize, + } = createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + onAgentRejected, + }); + getAgentSigner.mockResolvedValue(agentSigner); + await accountSignerProvider.getMarketDataWithPrices(); + const signed = createDeferred(); + const venue = createDeferred(); + exchangeClient.order.mockImplementation(async () => { + await initialize.mock.calls[0][0].signTypedData(L1_PAYLOAD); + signed.resolve(); + await venue.promise; + throw rejection(agentSigner.address); + }); + + const ordering = accountSignerProvider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }); + await signed.promise; + // A binding change (setAgentSigner) drops the resolved agents. + accountSignerProvider.clearAgentSigners(); + venue.resolve(); + const order = await ordering; + + expect(order).toStrictEqual( + expect.objectContaining({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }), + ); + expect(onAgentRejected).toHaveBeenCalledWith( + MAINNET_ACCOUNT, + agentSigner.address, + ); + expect(loggerError).not.toHaveBeenCalled(); + }); + it('fails a batch cancel with KEYRING_LOCKED without logging it', async () => { const { accountSignerProvider } = createRejectingProvider('cancel'); await accountSignerProvider.getMarketDataWithPrices(); 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..5862045e42d 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,7 +438,7 @@ describe('HyperLiquidProvider', () => { getUserAddressWithDefault: jest .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), - isKeyringUnlocked: jest.fn().mockReturnValue(true), + isMainAccountSignerReady: jest.fn().mockReturnValue(true), isSelectedHardwareWallet: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; 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..1896be739b5 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,7 +434,7 @@ describe('HyperLiquidProvider', () => { getUserAddressWithDefault: jest .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), - isKeyringUnlocked: jest.fn().mockReturnValue(true), + isMainAccountSignerReady: jest.fn().mockReturnValue(true), isSelectedHardwareWallet: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; 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..44be1d99cac 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.data.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.data.test.ts @@ -427,7 +427,7 @@ describe('HyperLiquidProvider', () => { getUserAddressWithDefault: jest .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), - isKeyringUnlocked: jest.fn().mockReturnValue(true), + isMainAccountSignerReady: jest.fn().mockReturnValue(true), isSelectedHardwareWallet: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; 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..842a6d3b934 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,7 +439,7 @@ describe('HyperLiquidProvider', () => { getUserAddressWithDefault: jest .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), - isKeyringUnlocked: jest.fn().mockReturnValue(true), + isMainAccountSignerReady: jest.fn().mockReturnValue(true), isSelectedHardwareWallet: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; 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..e30f899a2b6 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.history.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.history.test.ts @@ -427,7 +427,7 @@ describe('HyperLiquidProvider', () => { getUserAddressWithDefault: jest .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), - isKeyringUnlocked: jest.fn().mockReturnValue(true), + isMainAccountSignerReady: jest.fn().mockReturnValue(true), isSelectedHardwareWallet: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; 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..6561179b963 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.lifecycle.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.lifecycle.test.ts @@ -427,7 +427,7 @@ describe('HyperLiquidProvider', () => { getUserAddressWithDefault: jest .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), - isKeyringUnlocked: jest.fn().mockReturnValue(true), + isMainAccountSignerReady: jest.fn().mockReturnValue(true), isSelectedHardwareWallet: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; 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..1b52fe1b67b 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.misc.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.misc.test.ts @@ -427,7 +427,7 @@ describe('HyperLiquidProvider', () => { getUserAddressWithDefault: jest .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), - isKeyringUnlocked: jest.fn().mockReturnValue(true), + isMainAccountSignerReady: jest.fn().mockReturnValue(true), isSelectedHardwareWallet: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; 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..1203b2c8392 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts @@ -428,7 +428,7 @@ describe('HyperLiquidProvider', () => { getUserAddressWithDefault: jest .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), - isKeyringUnlocked: jest.fn().mockReturnValue(true), + isMainAccountSignerReady: jest.fn().mockReturnValue(true), isSelectedHardwareWallet: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; 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..e5d7887d2ec 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,7 +502,7 @@ describe('HyperLiquidProvider - strategy order types', () => { getUserAddressWithDefault: jest .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), - isKeyringUnlocked: jest.fn().mockReturnValue(true), + isMainAccountSignerReady: jest.fn().mockReturnValue(true), isSelectedHardwareWallet: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; 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..16778606bde 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts @@ -459,7 +459,7 @@ describe('HyperLiquidProvider', () => { getUserAddressWithDefault: jest .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), - isKeyringUnlocked: jest.fn().mockReturnValue(true), + isMainAccountSignerReady: jest.fn().mockReturnValue(true), isSelectedHardwareWallet: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; 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..f0f966a2c62 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts @@ -427,7 +427,7 @@ describe('HyperLiquidProvider', () => { getUserAddressWithDefault: jest .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), - isKeyringUnlocked: jest.fn().mockReturnValue(true), + isMainAccountSignerReady: jest.fn().mockReturnValue(true), isSelectedHardwareWallet: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; 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 index 21650268f05..2bf7b8d8a41 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts @@ -104,14 +104,14 @@ describe('HyperLiquidWalletService with accountSigner', () => { it('reports ready when isReady is omitted', () => { const { service, call } = buildService(); - expect(service.isKeyringUnlocked()).toBe(true); + 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.isKeyringUnlocked()).toBe(false); + expect(service.isMainAccountSignerReady()).toBe(false); await expect( service.createWalletAdapter().signTypedData(TYPED_DATA), ).rejects.toThrow(PERPS_ERROR_CODES.KEYRING_LOCKED); @@ -285,6 +285,35 @@ describe('HyperLiquidWalletService wallet adapter with an agent', () => { 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(TYPED_DATA); + + expect(signature).toBe(AGENT_SIGNATURE); + await expect(adapter.signTypedData(USER_SIGNED_ACTION)).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'); diff --git a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts index 72fb99734bc..c23181568e7 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts @@ -469,8 +469,8 @@ describe('HyperLiquidWalletService', () => { ); }); - it('should return keyring unlocked status via isKeyringUnlocked()', () => { - expect(service.isKeyringUnlocked()).toBe(true); + it('should return keyring unlocked status via isMainAccountSignerReady()', () => { + 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 index 80e5ef3d707..69b4ce0c133 100644 --- a/packages/perps-controller/tests/src/services/LighterWalletService.account-signer.test.ts +++ b/packages/perps-controller/tests/src/services/LighterWalletService.account-signer.test.ts @@ -90,16 +90,22 @@ describe('LighterWalletService.isMainAccountSignerReady', () => { expect(keyringCalls(call)).toStrictEqual([]); }); - it("follows the keyring's unlock state without an account signer", () => { - const { messenger, call } = createKeyringMessenger(SIGNATURE); - const service = new LighterWalletService(createMockInfrastructure(), { - isTestnet: true, - messenger, - }); - - expect(service.isMainAccountSignerReady()).toBe(true); - expect(keyringCalls(call)).toStrictEqual(['KeyringController:getState']); - }); + it.each([ + ['unlocked', true], + ['locked', false], + ])( + "follows the keyring's unlock state without an account signer (%s)", + (_state, isUnlocked) => { + const { messenger, call } = createKeyringMessenger(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(), { diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index 9ee86c01839..755d2d3a361 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,22 @@ 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.error).toBe(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', @@ -1829,6 +1846,42 @@ describe('TradingService', () => { ); }); + 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.success).toBe(false); + expect(mockDeps.logger.error).not.toHaveBeenCalled(); + }); + it('does NOT log batch error when using fallback path (provider.cancelOrders undefined)', async () => { const params: CancelOrdersParams = { cancelAll: true }; mockGetOpenOrders.mockResolvedValue(mockOrders); @@ -2168,6 +2221,27 @@ 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.error).toBe(PERPS_ERROR_CODES.KEYRING_LOCKED); + expect(mockDeps.logger.error).not.toHaveBeenCalled(); + }); }); describe('closePositions', () => { @@ -2483,6 +2557,33 @@ describe('TradingService', () => { ); }); + 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.success).toBe(false); + expect(mockDeps.logger.error).not.toHaveBeenCalled(); + }); + it('does NOT log batch error when using fallback path (provider.closePositions undefined)', async () => { const params: ClosePositionsParams = { symbols: ['BTC'] }; mockGetPositions.mockResolvedValue(mockPositions); 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..36c96b20207 --- /dev/null +++ b/packages/perps-controller/tests/src/services/agentSigner.test.ts @@ -0,0 +1,61 @@ +import { + AgentBindings, + AgentSignerUnavailableError, + isAgentSignerUnavailableError, +} from '../../../src/services/agentSigner.js'; +import type { PerpsAgentAccount } from '../../../src/types/index.js'; + +const ACCOUNT: PerpsAgentAccount = { + mainAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + isTestnet: false, +}; +const AGENT = { + address: '0x00000000000000000000000000000000000a9e17', + signTypedData: jest.fn(), +} as const; + +describe('AgentBindings', () => { + 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(ACCOUNT, AGENT); + + bindings.release( + { ...ACCOUNT, mainAddress: '0xABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCD' }, + AGENT.address.toUpperCase().replace('0X', '0x'), + ); + + expect(await bindings.resolve(ACCOUNT)).toBeNull(); + expect(getAgentSigner).toHaveBeenCalledWith(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); + const otherAccount: PerpsAgentAccount = { ...ACCOUNT, isTestnet: true }; + bindings.set(ACCOUNT, AGENT); + bindings.set(otherAccount, null); + + bindings.release(ACCOUNT, '0x00000000000000000000000000000000000b0b02'); + bindings.release(otherAccount, AGENT.address); + + expect(await bindings.resolve(ACCOUNT)).toBe(AGENT); + expect(await bindings.resolve(otherAccount)).toBeNull(); + expect(getAgentSigner).not.toHaveBeenCalled(); + }); +}); + +describe('isAgentSignerUnavailableError', () => { + it('finds the error anywhere in the cause chain', () => { + const unavailable = new AgentSignerUnavailableError(new Error('down')); + const wrapped = new Error( + 'Failed to sign the typed data using the wallet', + { + cause: unavailable, + }, + ); + + expect(isAgentSignerUnavailableError(wrapped)).toBe(true); + expect(isAgentSignerUnavailableError(new Error('other'))).toBe(false); + }); +}); From 8a4dd451fdf1d7200cf5816eb767780ffb929ffd Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 29 Sep 2026 09:58:05 +0800 Subject: [PATCH 16/33] fix(perps-controller): classify signer failures on strategy cancels and TP/SL clears - Scale cancels report a signer failure from either batch (order IDs or client order IDs) as KEYRING_LOCKED, keep the ladder registered for a retry, and evict a rejected agent. - Clearing TP/SL shares the pre-cancel with the replacement path, so a signer failure keeps the protection and fails with KEYRING_LOCKED. - A chase tick whose cancel is rejected evicts the agent. - Attribute a rejected agent to the account it signed for, not the account selected when the rejection arrives. - One helper handles signer failures for every write; the wallet service's isSelectedHardwareWallet becomes requiresSignatureConfirmation. - The controller clears the agents of every provider. - Lighter prepareTradingWallet returns a quiet not-ready result when its session is cancelled; the aggregated provider logs with the provider context convention; TradingService batch logs count only the failures they report. - Tests use the shared agent fixtures and pin the callback arguments. --- packages/perps-controller/CHANGELOG.md | 9 +- .../perps-controller/src/PerpsController.ts | 15 +- .../src/providers/AggregatedPerpsProvider.ts | 4 +- .../src/providers/HyperLiquidProvider.ts | 288 +++++--- .../src/providers/LighterProvider.ts | 40 +- .../src/services/HyperLiquidWalletService.ts | 8 +- .../src/services/TradingService.ts | 36 +- .../tests/helpers/agentFixtures.ts | 50 ++ ...ntroller.agent-signing.integration.test.ts | 181 +++-- .../PerpsController.providers-cache.test.ts | 86 +-- .../providers/AggregatedPerpsProvider.test.ts | 4 +- .../HyperLiquidProvider.account-mode.test.ts | 4 +- ...HyperLiquidProvider.account-signer.test.ts | 682 +++++++++++++++--- ...yperLiquidProvider.advanced-orders.test.ts | 2 +- .../HyperLiquidProvider.builder-fees.test.ts | 2 +- .../HyperLiquidProvider.data.test.ts | 2 +- ...HyperLiquidProvider.error-handling.test.ts | 2 +- .../HyperLiquidProvider.history.test.ts | 2 +- .../HyperLiquidProvider.lifecycle.test.ts | 2 +- .../HyperLiquidProvider.misc.test.ts | 2 +- .../HyperLiquidProvider.standalone.test.ts | 2 +- ...yperLiquidProvider.strategy-orders.test.ts | 2 +- .../HyperLiquidProvider.trading.test.ts | 2 +- .../HyperLiquidProvider.validation.test.ts | 2 +- .../LighterProvider.account-signer.test.ts | 141 ++-- ...LiquidWalletService.account-signer.test.ts | 125 +--- .../services/HyperLiquidWalletService.test.ts | 6 +- .../tests/src/services/TradingService.test.ts | 87 +++ .../tests/src/services/agentSigner.test.ts | 8 +- 29 files changed, 1243 insertions(+), 553 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 04ac74a432e..99a4471a52a 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -18,7 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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 it returns is kept for the provider's lifetime or until `setAgentSigner`/`clearAgentSigners`, while `null` and failures are asked again at the next L1 action; an agent whose signing rejects fails that action and stays in use, so a host calls `clearAgentSigners` when its agent key locks + - 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 and they can be set before `init` - 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 @@ -28,7 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 setup that needs a main-account signature before the first order: account migration, builder fee and referral on HyperLiquid, venue-key registration on Lighter ([#10559](https://github.com/MetaMask/core/pull/10559)) - Resolves a `ReadyToTradeResult` that is `ready: true` once the main-account signer is ready and none of these steps will ask it to sign again before the first order; the aggregated provider prepares every provider in turn - - Add optional `isTestnet` to `AggregatedProviderConfig`, which tags the errors the aggregated provider logs +- 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 @@ -38,7 +40,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- HyperLiquid orders, edits, cancels, position closes, TP/SL and margin updates that fail because the signer cannot sign (a locked keyring) now fail with `KEYRING_LOCKED` instead of the SDK's "Failed to sign the typed data using the wallet" message, and are no longer reported as errors by the provider or `TradingService` ([#10559](https://github.com/MetaMask/core/pull/10559)) +- HyperLiquid writes that fail because the keyring is locked now fail with `KEYRING_LOCKED` instead of the SDK's "Failed to sign the typed data using the wallet" message, and are no longer reported as errors by the provider or `TradingService` ([#10559](https://github.com/MetaMask/core/pull/10559)) + - Covers orders, edits, single and batch cancels (TWAP, scale and chase cancels included), position closes, TP/SL updates and clears, and margin updates ## [18.0.1] diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index dc2a0d47734..91b4d2a529a 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -5895,9 +5895,9 @@ export class PerpsController extends BaseController< agentSigner: PerpsAgentSigner | null, ): void { this.#agentBindings.set(account, agentSigner); - // Drop agents the provider already resolved so the binding applies to + // Drop agents the providers already resolved so the binding applies to // the next L1 action. - this.providers.get('hyperliquid')?.clearAgentSigners?.(); + this.#clearProviderAgentSigners(); } /** @@ -5910,7 +5910,16 @@ export class PerpsController extends BaseController< */ clearAgentSigners(): void { this.#agentBindings.clear(); - this.providers.get('hyperliquid')?.clearAgentSigners?.(); + this.#clearProviderAgentSigners(); + } + + /** + * Drop the agents every provider resolved. + */ + #clearProviderAgentSigners(): void { + for (const provider of this.providers.values()) { + provider.clearAgentSigners?.(); + } } /** diff --git a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts index 966117e0da7..e24e4f0dd87 100644 --- a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts +++ b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts @@ -1081,8 +1081,8 @@ export class AggregatedPerpsProvider implements PerpsProvider { }), }, context: { - name: 'AggregatedPerpsProvider.prepareTradingWallet', - data: { providerId }, + name: 'AggregatedPerpsProvider', + data: { method: 'prepareTradingWallet', providerId }, }, }); result = { ready: false, error: error.message }; diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 7f79c4d6215..41cc2c4fe18 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -847,7 +847,7 @@ type HyperLiquidProviderOptions = { onChaseOrderMaxDistanceReached?: ChaseOrderMaxDistanceReachedHandler; getAgentSigner?: HyperLiquidCredentials['getAgentSigner']; // Told when the venue rejects a resolved agent (revoked or expired). - onAgentRejected?: (account: PerpsAgentAccount, agentAddress: Hex) => void; + onAgentRejected?: HyperLiquidCredentials['onAgentRejected']; }; type HandleHip3PreOrderParams = { @@ -1561,10 +1561,16 @@ export class HyperLiquidProvider implements PerpsProvider { // The agents those answers resolved to, dropped when the venue rejects one. readonly #resolvedAgents = new Map(); - // Every agent address this provider signed with, per network and main - // account. Kept across clearAgentSigners, so the rejection of an agent that - // was replaced while its action was in flight is still recognized. - readonly #signedAgentAddresses = new Map>(); + // The account and network each agent address was last resolved to sign an + // L1 action for. + // A rejection is attributed from this record rather than from the selected + // account, which may have changed while the write was in flight. Kept + // across clearAgentSigners, so the rejection of an agent that was replaced + // while its action was in flight is still recognized. + readonly #agentSignedFor = new Map< + string, + { key: string; account: PerpsAgentAccount } + >(); readonly #onAgentRejected: HyperLiquidProviderOptions['onAgentRejected']; @@ -2136,9 +2142,12 @@ export class HyperLiquidProvider implements PerpsProvider { } if (agentSigner) { this.#resolvedAgents.set(key, agentSigner); - const signed = this.#signedAgentAddresses.get(key) ?? new Set(); - signed.add(agentSigner.address.toLowerCase()); - this.#signedAgentAddresses.set(key, signed); + // 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, + }); } else { this.#agentSigners.delete(key); this.#resolvedAgents.delete(key); @@ -2147,37 +2156,37 @@ export class HyperLiquidProvider implements PerpsProvider { } /** - * Log a failed exchange write, unless the signer could not sign it + * 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 mappedError - The error after #mapError. + * @param error - The caught error. * @param method - The write that failed. * @param extra - Context for the log. + * @returns The mapped error. */ - async #logWriteError( - mappedError: Error, + async #reportWriteError( + error: unknown, method: string, extra: Record, - ): Promise { - if (mappedError.message === PERPS_ERROR_CODES.KEYRING_LOCKED) { - this.#deps.debugLogger.log( - `[${method}] Signer unavailable, the write can be retried`, - extra, - ); - return; + ): 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, when this provider signed with it for - * the selected account and network. 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. + * 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 account, or undefined. @@ -2188,7 +2197,7 @@ export class HyperLiquidProvider implements PerpsProvider { | { account: PerpsAgentAccount; key: string; agentAddress: Hex } | undefined { if ( - this.#signedAgentAddresses.size === 0 || + this.#agentSignedFor.size === 0 || !isHyperLiquidUserNotFoundError(error) ) { return undefined; @@ -2196,23 +2205,12 @@ export class HyperLiquidProvider implements PerpsProvider { const agentAddress = /user or api wallet (0x[0-9a-f]{40})/iu.exec( ensureError(error, 'HyperLiquidProvider.findRejectedAgent').message, )?.[1] as Hex | undefined; - if (!agentAddress) { - return undefined; - } - let mainAddress: Hex; - try { - mainAddress = this.#walletService.getSelectedMainAddress(); - } catch { - return undefined; - } - // Only agents of the account and network that signed the action. - const account: PerpsAgentAccount = { - mainAddress, - isTestnet: this.#clientService.isTestnetMode(), - }; - const key = this.#getAgentKey(account); - return this.#signedAgentAddresses.get(key)?.has(agentAddress.toLowerCase()) - ? { account, key, agentAddress } + const signedFor = + agentAddress === undefined + ? undefined + : this.#agentSignedFor.get(agentAddress.toLowerCase()); + return agentAddress && signedFor + ? { ...signedFor, agentAddress } : undefined; } @@ -2278,11 +2276,35 @@ export class HyperLiquidProvider implements PerpsProvider { * @returns `KEYRING_LOCKED` for a signer failure, else undefined. */ #classifySignerFailure(error: unknown): Error | undefined { - return isKeyringLockedError(error) || - isAgentSignerUnavailableError(error) || - this.#evictRejectedAgent(error) - ? new Error(PERPS_ERROR_CODES.KEYRING_LOCKED) - : undefined; + if (!this.#isSignerFailure(error)) { + return undefined; + } + this.#evictRejectedAgent(error); + return new Error(PERPS_ERROR_CODES.KEYRING_LOCKED); + } + + /** + * 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.#deps.debugLogger.log( + `[${method}] Signer unavailable, the write can be retried`, + extra, + ); + } + return signerFailure; } /** @@ -2852,7 +2874,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(), }); })(); @@ -5696,16 +5718,15 @@ export class HyperLiquidProvider implements PerpsProvider { params: HandleOrderErrorParams, ): Promise { const { error, symbol, orderType, isBuy } = params; - const mappedError = this.#mapError(error); - - // The signer could not sign (see #mapError): retryable, not a defect. - if (mappedError.message === PERPS_ERROR_CODES.KEYRING_LOCKED) { - this.#deps.debugLogger.log( - '[handleOrderError] Signer unavailable, the order can be retried', - { symbol, orderType, isBuy }, - ); - return createErrorResult(mappedError, { success: false }); + 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, // not an app defect — same policy already applied to every other @@ -7173,6 +7194,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.#classifySignerFailure(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', { @@ -8307,8 +8330,7 @@ export class HyperLiquidProvider implements PerpsProvider { } return await this.#cancelChaseOrder(params); } catch (error) { - const mappedError = this.#mapError(error); - await this.#logWriteError(mappedError, 'cancelOrder', { + const mappedError = await this.#reportWriteError(error, 'cancelOrder', { orderId: params.orderId, coin: params.symbol, orderType: params.orderType, @@ -8448,11 +8470,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 @@ -8474,6 +8497,18 @@ 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 !== undefined) { + return createErrorResult( + this.#handleSignerFailure(signerFailure, 'cancelOrder', { + orderId: params.orderId, + orderType: params.orderType, + }) ?? new Error(PERPS_ERROR_CODES.KEYRING_LOCKED), + { success: false, orderId: params.orderId }, + ); + } this.#deps.debugLogger.log('Scale group cancel left children resting', { groupId: params.orderId, remainingOrderIds: remaining.length, @@ -8607,14 +8642,31 @@ 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 nothing could be cancelled for that reason. + */ + async #cancelOrderCloidRequestBatch( + exchangeClient: ExchangeClient, + requests: ExchangeCancelByCloidRequest[], + ): Promise<{ remainingClientOrderIds: Hex[]; signerFailure?: unknown }> { if (requests.length === 0) { - return []; + return { remainingClientOrderIds: [] }; } const getRemainingClientOrderIds = (statuses: unknown[]): Hex[] => @@ -8630,14 +8682,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 { remainingClientOrderIds: getRemainingClientOrderIds(statuses) }; } catch (error) { + // The signer could not sign, so nothing was cancelled. + if (this.#isSignerFailure(error)) { + return { + remainingClientOrderIds: requests.map((request) => request.cloid), + signerFailure: error, + }; + } const statuses = getCancelStatusesFromError(error, requests.length); if (statuses) { - return getRemainingClientOrderIds(statuses); + return { + remainingClientOrderIds: getRemainingClientOrderIds(statuses), + }; } this.#deps.debugLogger.log('Order cancellation by CLOID failed', { error: ensureError( @@ -8646,7 +8709,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), + }; } } @@ -9317,12 +9382,10 @@ export class HyperLiquidProvider implements PerpsProvider { : { orderId: replacementOrderId }), }; } catch (error) { - const signerFailure = this.#classifySignerFailure(error); + const signerFailure = this.#handleSignerFailure(error, 'editOrder', { + orderId: params.orderId, + }); if (signerFailure) { - this.#deps.debugLogger.log( - '[editOrder] Signer unavailable, the edit can be retried', - { orderId: params.orderId }, - ); return createErrorResult(signerFailure, { success: false }); } this.#deps.logger.error( @@ -9428,8 +9491,7 @@ export class HyperLiquidProvider implements PerpsProvider { orderId: params.orderId, }); } catch (error) { - const mappedError = this.#mapError(error); - await this.#logWriteError(mappedError, 'cancelOrder', { + const mappedError = await this.#reportWriteError(error, 'cancelOrder', { orderId: params.orderId, coin: params.symbol, }); @@ -9557,8 +9619,7 @@ export class HyperLiquidProvider implements PerpsProvider { } } } catch (error) { - const mappedError = this.#mapError(error); - await this.#logWriteError(mappedError, 'cancelOrders', { + const mappedError = await this.#reportWriteError(error, 'cancelOrders', { orderCount: params.length, }); for (const result of results) { @@ -9923,16 +9984,13 @@ export class HyperLiquidProvider implements PerpsProvider { ], }; } catch (error) { - const signerFailure = this.#classifySignerFailure(error); + const signerFailure = this.#handleSignerFailure(error, 'closePositions', { + positionCount: positionsToClose.length, + }); const safeError = signerFailure ?? ensureError(error, 'HyperLiquidProvider.closePositions'); - if (signerFailure) { - this.#deps.debugLogger.log( - '[closePositions] Signer unavailable, the close can be retried', - { positionCount: positionsToClose.length }, - ); - } else { + if (!signerFailure) { this.#deps.logger.error( safeError, this.#getErrorContext('closePositions', { @@ -10536,14 +10594,29 @@ 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, + ); + if (oldCancellation.signerFailure !== undefined) { + // Nothing was cancelled, so the old protection is still in place. + return createErrorResult( + this.#handleSignerFailure( + oldCancellation.signerFailure, + 'updatePositionTPSL', + { symbol }, + ) ?? new Error(PERPS_ERROR_CODES.KEYRING_LOCKED), + { 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( @@ -10555,26 +10628,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.signerFailure !== undefined) { - // Nothing was cancelled, so the old protection is still in place. - this.#deps.debugLogger.log( - '[updatePositionTPSL] Signer unavailable, the update can be retried', - { symbol }, - ); - return createErrorResult( - this.#classifySignerFailure(oldCancellation.signerFailure) ?? - new Error(PERPS_ERROR_CODES.KEYRING_LOCKED), - { success: false }, - ); - } if (!oldCancellation.responseComplete) { const requestedOrderIds = new Set( cancelRequests.map((request) => request.o), @@ -10738,12 +10792,12 @@ export class HyperLiquidProvider implements PerpsProvider { } throw new Error(PERPS_ERROR_CODES.TPSL_UPDATE_FAILED); } catch (error) { - const signerFailure = this.#classifySignerFailure(error); + const signerFailure = this.#handleSignerFailure( + error, + 'updatePositionTPSL', + { symbol: params.symbol }, + ); if (signerFailure) { - this.#deps.debugLogger.log( - '[updatePositionTPSL] Signer unavailable, the update can be retried', - { symbol: params.symbol }, - ); return createErrorResult(signerFailure, { success: false }); } this.#deps.logger.error( @@ -11033,12 +11087,10 @@ export class HyperLiquidProvider implements PerpsProvider { success: true, }; } catch (error) { - const signerFailure = this.#classifySignerFailure(error); + const signerFailure = this.#handleSignerFailure(error, 'updateMargin', { + symbol: params.symbol, + }); if (signerFailure) { - this.#deps.debugLogger.log( - '[updateMargin] Signer unavailable, the update can be retried', - { symbol: params.symbol }, - ); return { success: false, error: signerFailure.message }; } const safeError = ensureError(error, 'HyperLiquidProvider.updateMargin'); diff --git a/packages/perps-controller/src/providers/LighterProvider.ts b/packages/perps-controller/src/providers/LighterProvider.ts index a3e9a168870..e9ee948e90c 100644 --- a/packages/perps-controller/src/providers/LighterProvider.ts +++ b/packages/perps-controller/src/providers/LighterProvider.ts @@ -993,6 +993,17 @@ 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; @@ -1305,8 +1316,9 @@ export class LighterProvider implements PerpsProvider { * `ready: false`: with `KEYRING_LOCKED` whenever the main-account signer is * not ready (even with a registered venue key), without an error when the * order path will ask again (the user declined the signature, or the - * wallet has no Lighter account yet), and with the logged error when - * registration failed. + * wallet has no Lighter account yet), with the unlogged cancellation when + * the provider disconnected or the wallet switched accounts meanwhile, and + * with the logged error when registration failed. */ async prepareTradingWallet(): Promise { if (!this.#walletService.isMainAccountSignerReady()) { @@ -1334,6 +1346,14 @@ export class LighterProvider implements PerpsProvider { if (isRetryablePreparationStop(caughtError)) { return { ready: false }; } + // 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', + ); + return { ready: false, error: caughtError.message }; + } const error = ensureError( caughtError, 'LighterProvider.prepareTradingWallet', @@ -4187,13 +4207,13 @@ 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 @@ -4203,8 +4223,8 @@ export class LighterProvider implements PerpsProvider { // 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; @@ -4224,8 +4244,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/HyperLiquidWalletService.ts b/packages/perps-controller/src/services/HyperLiquidWalletService.ts index afac213dde8..10c8f5885a7 100644 --- a/packages/perps-controller/src/services/HyperLiquidWalletService.ts +++ b/packages/perps-controller/src/services/HyperLiquidWalletService.ts @@ -104,7 +104,7 @@ export class HyperLiquidWalletService { * * @returns True when signatures need a confirmation; false otherwise. */ - public isSelectedHardwareWallet(): boolean { + public requiresSignatureConfirmation(): boolean { const declared = this.#deps.accountSigner?.requiresSignatureConfirmation?.(); if (declared !== undefined) { @@ -153,7 +153,7 @@ export class HyperLiquidWalletService { * * @returns The selected main account address. */ - public getSelectedMainAddress(): Hex { + #getSelectedMainAddress(): Hex { const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); if (!evmAccount?.address) { @@ -210,9 +210,9 @@ export class HyperLiquidWalletService { */ public createWalletAdapter(): HyperLiquidWalletParams { return { - address: this.getSelectedMainAddress(), + address: this.#getSelectedMainAddress(), signTypedData: async (params: PerpsTypedDataPayload): Promise => { - const mainAddress = this.getSelectedMainAddress(); + const mainAddress = this.#getSelectedMainAddress(); const agentSigner = this.#resolveAgent && isL1Action(params) ? await this.#resolveAgent(mainAddress) diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index 60fa007af0b..e77cca1c83f 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -1946,14 +1946,12 @@ export class TradingService { }; }, ['orders']); // Disconnect orders stream during operation - if ( - provider.cancelOrders && - operationResult?.results.some( - (result) => !result.success && !isSignerUnavailable(result.error), - ) - ) { - 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'}`, @@ -1962,11 +1960,11 @@ export class TradingService { this.#deps.logger.error( new Error( - `cancelOrders batch failure: ${operationResult.failureCount}/${operationResult.results.length} failed - ${failureSummary}`, + `cancelOrders batch failure: ${reportedFailures.length}/${operationResult.results.length} failed - ${failureSummary}`, ), this.#getErrorContext('cancelOrders', { successCount: operationResult.successCount, - failureCount: operationResult.failureCount, + failureCount: reportedFailures.length, cancelAll: params.cancelAll, }), ); @@ -2326,14 +2324,12 @@ export class TradingService { }; } - if ( - provider.closePositions && - operationResult?.results.some( - (result) => !result.success && !isSignerUnavailable(result.error), - ) - ) { - 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'}`, ) @@ -2341,11 +2337,11 @@ export class TradingService { this.#deps.logger.error( new Error( - `closePositions batch failure: ${operationResult.failureCount}/${operationResult.results.length} failed - ${failureSummary}`, + `closePositions batch failure: ${reportedFailures.length}/${operationResult.results.length} failed - ${failureSummary}`, ), this.#getErrorContext('closePositions', { successCount: operationResult.successCount, - failureCount: operationResult.failureCount, + failureCount: reportedFailures.length, symbols: params.symbols?.length ?? 0, closeAll: params.closeAll, }), diff --git a/packages/perps-controller/tests/helpers/agentFixtures.ts b/packages/perps-controller/tests/helpers/agentFixtures.ts index 12b76610497..1fbf0d2f6c5 100644 --- a/packages/perps-controller/tests/helpers/agentFixtures.ts +++ b/packages/perps-controller/tests/helpers/agentFixtures.ts @@ -3,10 +3,28 @@ import { createMockEvmAccount } from './serviceMocks.js'; const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as const; +// 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' }, +]; + /** 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 user-signed action as the HyperLiquid SDK builds it (the * HyperliquidSignTransaction domain), for the mock main account. @@ -19,6 +37,7 @@ export const USER_SIGNED_PAYLOAD: PerpsTypedDataPayload = { verifyingContract: ZERO_ADDRESS, }, types: { + EIP712Domain: EIP712_DOMAIN_TYPE, 'HyperliquidTransaction:UserSetAbstraction': [ { name: 'hyperliquidChain', type: 'string' }, { name: 'user', type: 'address' }, @@ -44,6 +63,7 @@ export const L1_PAYLOAD: PerpsTypedDataPayload = { verifyingContract: ZERO_ADDRESS, }, types: { + EIP712Domain: EIP712_DOMAIN_TYPE, Agent: [ { name: 'source', type: 'string' }, { name: 'connectionId', type: 'bytes32' }, @@ -52,3 +72,33 @@ export const L1_PAYLOAD: PerpsTypedDataPayload = { primaryType: 'Agent', message: { source: 'a', connectionId: `0x${'22'.repeat(32)}` }, }; + +/** + * 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, + }; +} diff --git a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts index 551f7fc10ce..8feafe8da51 100644 --- a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts @@ -1,5 +1,9 @@ import type { Hex } from '@metamask/utils'; +import { + BUILDER_FEE_CONFIG, + REFERRAL_CONFIG, +} from '../../src/constants/hyperLiquidConfig.js'; import { getDefaultPerpsControllerState, PerpsController, @@ -11,8 +15,16 @@ import { TradingReadinessCache } from '../../src/services/TradingReadinessCache. import type { PerpsAgentAccount, PerpsAgentSigner, + PerpsTypedDataPayload, } from '../../src/types/index.js'; -import { L1_PAYLOAD, USER_SIGNED_PAYLOAD } from '../helpers/agentFixtures.js'; +import { + AGENT_ADDRESS, + AGENT_SIGNATURE, + L1_PAYLOAD, + MAIN_SIGNATURE, + OTHER_AGENT_ADDRESS, + USER_SIGNED_PAYLOAD, +} from '../helpers/agentFixtures.js'; import { createMockExchangeClient, createMockInfoClient, @@ -38,8 +50,8 @@ const MockedClientService = HyperLiquidClientService as jest.MockedClass< >; const MAIN_ADDRESS = createMockEvmAccount().address; -const MAIN_SIGNATURE = `0x${'ab'.repeat(65)}` as const; -const AGENT_SIGNATURE = `0x${'cd'.repeat(65)}` as const; +// The second agent's signature, told apart from the first agent's. +const OTHER_AGENT_SIGNATURE = `0x${'0b'.repeat(65)}` as const; // The controller starts on mainnet (default state). const MAINNET_ACCOUNT: PerpsAgentAccount = { mainAddress: MAIN_ADDRESS, @@ -63,11 +75,16 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => let infrastructure: ReturnType; let loggerError: jest.SpyInstance; let exchangeClient: ReturnType; + let infoClient: ReturnType; beforeEach(() => { TradingReadinessCache.clearAll(); clientServices = []; exchangeClient = createMockExchangeClient(); + infoClient = createMockInfoClient({ + twapHistory: jest.fn().mockResolvedValue([]), + userTwapSliceFills: jest.fn().mockResolvedValue([]), + }); MockedClientService.mockImplementation((_deps, options) => { const isTestnet = options?.isTestnet ?? false; const clientService = { @@ -77,12 +94,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => isInitialized: jest.fn().mockReturnValue(true), isTestnetMode: (): boolean => isTestnet, ensureInitialized: jest.fn(), - getInfoClient: jest.fn().mockReturnValue( - createMockInfoClient({ - twapHistory: jest.fn().mockResolvedValue([]), - userTwapSliceFills: jest.fn().mockResolvedValue([]), - }), - ), + getInfoClient: jest.fn(() => infoClient), getExchangeClient: jest.fn(() => exchangeClient), getSubscriptionClient: jest.fn(), setOnTerminateCallback: jest.fn(), @@ -96,10 +108,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => signTypedData: jest.fn().mockResolvedValue(MAIN_SIGNATURE), signPersonalMessage: jest.fn(), }; - agentSigner = { - address: '0x00000000000000000000000000000000000a9e17', - signTypedData: jest.fn().mockResolvedValue(AGENT_SIGNATURE), - }; + agentSigner = createAgent(AGENT_ADDRESS, AGENT_SIGNATURE); getAgentSigner = jest.fn().mockResolvedValue(agentSigner); onAgentRejected = jest.fn(); infrastructure = createMockInfrastructure(); @@ -117,27 +126,17 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => * @returns The messenger. */ function createMessenger(): ReturnType { - const messenger = createMockMessenger(); - const defaultCall = messenger.call.getMockImplementation(); - messenger.call.mockImplementation( - (action: string, ...args: unknown[]): unknown => - action === 'RemoteFeatureFlagController:getState' - ? { remoteFeatureFlags: {}, cacheTimestamp: 0 } - : defaultCall?.(action as never, ...(args as never[])), - ); - return messenger; - } - - /** - * The errors the HyperLiquid provider itself reported. - * - * @returns The logged errors whose context names the provider. - */ - function providerErrors(): unknown[] { - return loggerError.mock.calls.filter( - ([, options]: [unknown, { context?: { name?: string } } | undefined]) => - options?.context?.name === 'HyperLiquidProvider', - ); + // The default mock answers by action type alone. + const defaultCall = createMockMessenger().call.getMockImplementation(); + return createMockMessenger({ + call: jest + .fn() + .mockImplementation((action: string): unknown => + action === 'RemoteFeatureFlagController:getState' + ? { remoteFeatureFlags: {}, cacheTimestamp: 0 } + : defaultCall?.(action as never), + ), + }); } /** @@ -196,16 +195,11 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => } /** - * Make the active HyperLiquid provider initialize its SDK clients, and - * return the wallet adapter it handed to them. + * The wallet adapter the latest initialized SDK clients sign with. * - * @param controller - The initialized controller. - * @returns The wallet adapter the SDK signs with. + * @returns The wallet adapter. */ - async function getSdkWallet( - controller: PerpsController, - ): Promise { - await controller.getTwapOrders(); + function getLatestSdkWallet(): HyperLiquidWalletParams { const initialized = clientServices.filter( (clientService) => clientService.initialize.mock.calls.length > 0, ); @@ -217,6 +211,36 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => return wallet; } + /** + * Make the active HyperLiquid provider initialize its SDK clients, and + * return the wallet adapter it handed to them. + * + * @param controller - The initialized controller. + * @returns The wallet adapter the SDK signs with. + */ + async function getSdkWallet( + controller: PerpsController, + ): Promise { + await controller.getTwapOrders(); + return getLatestSdkWallet(); + } + + /** + * An SDK write that signs its action with the provider's wallet adapter, + * as the SDK does, and succeeds. + * + * @param payload - The typed data the SDK builds for the action. + * @returns The write's mock implementation. + */ + function signThroughSdkWallet( + payload: PerpsTypedDataPayload, + ): () => Promise<{ status: 'ok' }> { + return async () => { + await getLatestSdkWallet().signTypedData(payload); + return { status: 'ok' }; + }; + } + it("signs L1 actions with the host's agent and user-signed actions with the main account", async () => { const controller = createController(); await controller.init(); @@ -254,10 +278,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => it('keeps a setAgentSigner binding when the HyperLiquid provider is re-created', async () => { getAgentSigner.mockResolvedValue(null); - const boundAgent = { - address: '0x00000000000000000000000000000000000b0a7d' as const, - signTypedData: jest.fn().mockResolvedValue(AGENT_SIGNATURE), - }; + const boundAgent = createAgent(OTHER_AGENT_ADDRESS, OTHER_AGENT_SIGNATURE); const controller = createController(); await controller.init(); controller.setAgentSigner(MAINNET_ACCOUNT, boundAgent); @@ -271,7 +292,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => expect( clientServices.map(({ isTestnetMode }) => isTestnetMode()), ).toStrictEqual([false, true, false]); - expect(signature).toBe(AGENT_SIGNATURE); + expect(signature).toBe(OTHER_AGENT_SIGNATURE); expect(boundAgent.signTypedData.mock.calls).toStrictEqual([[L1_PAYLOAD]]); expect(getAgentSigner).not.toHaveBeenCalled(); expect(accountSigner.signTypedData).not.toHaveBeenCalled(); @@ -279,10 +300,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => it('honors a setAgentSigner binding made before init', async () => { getAgentSigner.mockResolvedValue(null); - const boundAgent = { - address: '0x00000000000000000000000000000000000b0a7d' as const, - signTypedData: jest.fn().mockResolvedValue(AGENT_SIGNATURE), - }; + const boundAgent = createAgent(OTHER_AGENT_ADDRESS, OTHER_AGENT_SIGNATURE); const controller = createController(); controller.setAgentSigner(MAINNET_ACCOUNT, boundAgent); @@ -290,14 +308,15 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => const wallet = await getSdkWallet(controller); const signature = await wallet.signTypedData(L1_PAYLOAD); - expect(signature).toBe(AGENT_SIGNATURE); + expect(signature).toBe(OTHER_AGENT_SIGNATURE); expect(boundAgent.signTypedData.mock.calls).toStrictEqual([[L1_PAYLOAD]]); expect(getAgentSigner).not.toHaveBeenCalled(); }); + it('signs with the agent bound through setAgentSigner after another was resolved', async () => { const reboundAgent = createAgent( - '0x00000000000000000000000000000000000b0a7d', - `0x${'ef'.repeat(65)}`, + OTHER_AGENT_ADDRESS, + OTHER_AGENT_SIGNATURE, ); const controller = createController(); await controller.init(); @@ -313,7 +332,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => resolvedSignature, reboundSignature, pinnedSignature, - ]).toStrictEqual([AGENT_SIGNATURE, `0x${'ef'.repeat(65)}`, MAIN_SIGNATURE]); + ]).toStrictEqual([AGENT_SIGNATURE, OTHER_AGENT_SIGNATURE, MAIN_SIGNATURE]); expect(agentSigner.signTypedData.mock.calls).toStrictEqual([[L1_PAYLOAD]]); expect(reboundAgent.signTypedData.mock.calls).toStrictEqual([[L1_PAYLOAD]]); expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ @@ -324,8 +343,8 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => it('tells the host about an agent the venue rejects and asks for another', async () => { const replacementAgent = createAgent( - '0x00000000000000000000000000000000000b0a7d', - `0x${'ef'.repeat(65)}`, + OTHER_AGENT_ADDRESS, + OTHER_AGENT_SIGNATURE, ); getAgentSigner .mockResolvedValueOnce(agentSigner) @@ -350,20 +369,17 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => expect(onAgentRejected.mock.calls).toStrictEqual([ [MAINNET_ACCOUNT, agentSigner.address], ]); - expect(nextSignature).toBe(`0x${'ef'.repeat(65)}`); + expect(nextSignature).toBe(OTHER_AGENT_SIGNATURE); expect(getAgentSigner.mock.calls).toStrictEqual([ [MAINNET_ACCOUNT], [MAINNET_ACCOUNT], ]); - // The provider does not report the retryable signer failure. - expect(providerErrors()).toStrictEqual([]); + // Nothing reports the retryable signer failure. + expect(loggerError).not.toHaveBeenCalled(); }); it('releases a setAgentSigner binding to an agent the venue rejects', async () => { - const boundAgent = createAgent( - '0x00000000000000000000000000000000000b0a7d', - `0x${'ef'.repeat(65)}`, - ); + const boundAgent = createAgent(OTHER_AGENT_ADDRESS, OTHER_AGENT_SIGNATURE); const controller = createController(); await controller.init(); controller.setAgentSigner(MAINNET_ACCOUNT, boundAgent); @@ -388,6 +404,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => // The binding is gone, so the host's getAgentSigner answers. expect(nextSignature).toBe(AGENT_SIGNATURE); expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); + expect(loggerError).not.toHaveBeenCalled(); }); it('prepares nothing and reports KEYRING_LOCKED while the account signer is not ready', async () => { @@ -426,13 +443,47 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => expect(loggerError).not.toHaveBeenCalled(); }); - it("resolves the provider's readiness once the account is ready to trade", async () => { + it('prepares the migration and builder fee on the main account and the referral on the agent', async () => { + // A legacy account that has not approved the builder fee yet. + infoClient.userAbstraction.mockResolvedValue('dexAbstraction'); + infoClient.maxBuilderFee.mockResolvedValueOnce(0); + exchangeClient.userSetAbstraction.mockImplementation( + signThroughSdkWallet(USER_SIGNED_PAYLOAD), + ); + exchangeClient.approveBuilderFee.mockImplementation( + signThroughSdkWallet(USER_SIGNED_PAYLOAD), + ); + exchangeClient.setReferrer.mockImplementation( + signThroughSdkWallet(L1_PAYLOAD), + ); const controller = createController(); await controller.init(); const result = await controller.prepareTradingWallet(); expect(result).toStrictEqual({ ready: true }); + expect(exchangeClient.userSetAbstraction.mock.calls).toStrictEqual([ + [{ user: MAIN_ADDRESS, abstraction: 'unifiedAccount' }], + ]); + expect(exchangeClient.approveBuilderFee.mock.calls).toStrictEqual([ + [ + { + builder: BUILDER_FEE_CONFIG.MainnetBuilder, + maxFeeRate: BUILDER_FEE_CONFIG.MaxFeeRate, + }, + ], + ]); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + [{ code: REFERRAL_CONFIG.MainnetCode }], + ]); + // The user-signed migration and approval stay on the main account; the + // referral is an L1 action, so the agent signs it. + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [MAIN_ADDRESS, USER_SIGNED_PAYLOAD], + [MAIN_ADDRESS, USER_SIGNED_PAYLOAD], + ]); + expect(agentSigner.signTypedData.mock.calls).toStrictEqual([[L1_PAYLOAD]]); + expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); expect(loggerError).not.toHaveBeenCalled(); }); }); 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 abc2c17fc50..2efaa459d75 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -6,6 +6,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { + AGENT_ADDRESS, + OTHER_AGENT_ADDRESS, +} from '../helpers/agentFixtures.js'; import { createMockHyperLiquidProvider, createMockPosition, @@ -947,7 +951,7 @@ describe('PerpsController', () => { }); const agentSigner = { - address: '0x00000000000000000000000000000000000a9e17', + address: AGENT_ADDRESS, signTypedData: jest.fn(), } as const; @@ -1046,65 +1050,6 @@ describe('PerpsController', () => { ).toBe(agentSigner); }); - it('pins the main account with setAgentSigner(null) without asking getAgentSigner', async () => { - const getAgentSigner = jest.fn().mockResolvedValue(agentSigner); - controller = createAgentController(getAgentSigner); - await controller.init(); - - controller.setAgentSigner(account, null); - - expect(await getProviderAgentResolver()(account)).toBeNull(); - expect(getAgentSigner).not.toHaveBeenCalled(); - }); - - it('drops the agents the provider already resolved when a binding changes', async () => { - mockProvider.clearAgentSigners = jest.fn(); - controller = createAgentController(); - await controller.init(); - - controller.setAgentSigner(account, agentSigner); - - expect(mockProvider.clearAgentSigners).toHaveBeenCalledTimes(1); - }); - - it('keeps a binding set before init', async () => { - controller = createAgentController(jest.fn().mockResolvedValue(null)); - - controller.setAgentSigner(account, agentSigner); - await controller.init(); - - expect(await getProviderAgentResolver()(account)).toBe(agentSigner); - }); - - it('keeps bindings when the HyperLiquid provider is re-created', async () => { - const getAgentSigner = jest.fn().mockResolvedValue(agentSigner); - controller = createAgentController(getAgentSigner); - await controller.init(); - controller.setAgentSigner(account, null); - - await controller.toggleTestnet(); - await controller.toggleTestnet(); - - expect( - HyperLiquidProvider as jest.MockedClass, - ).toHaveBeenCalledTimes(3); - expect(await getProviderAgentResolver()(account)).toBeNull(); - expect(getAgentSigner).not.toHaveBeenCalled(); - }); - - it('clearAgentSigners forgets bindings and drops resolved agents', async () => { - const getAgentSigner = jest.fn().mockResolvedValue(agentSigner); - mockProvider.clearAgentSigners = jest.fn(); - controller = createAgentController(getAgentSigner); - await controller.init(); - controller.setAgentSigner(account, null); - - controller.clearAgentSigners(); - - expect(await getProviderAgentResolver()(account)).toBe(agentSigner); - expect(mockProvider.clearAgentSigners).toHaveBeenCalledTimes(2); - }); - it('drops only a binding to the agent the venue rejected', async () => { const getAgentSigner = jest.fn().mockResolvedValue(null); controller = createAgentController(getAgentSigner); @@ -1114,10 +1059,9 @@ describe('PerpsController', () => { ).mock; const { onAgentRejected } = calls[calls.length - 1][0]; const resolve = getProviderAgentResolver(); - const otherAgent = '0x00000000000000000000000000000000000b0b02'; controller.setAgentSigner(account, agentSigner); - onAgentRejected?.(account, otherAgent); + onAgentRejected?.(account, OTHER_AGENT_ADDRESS); const keptForOtherAgent = await resolve(account); onAgentRejected?.(account, agentSigner.address); const afterRejection = await resolve(account); @@ -1131,7 +1075,7 @@ describe('PerpsController', () => { expect(getAgentSigner.mock.calls).toStrictEqual([[account]]); }); - it('prepareTradingWallet rejects before init like other provider actions', async () => { + it('rejects trading wallet preparation before init like other provider actions', async () => { controller = createAgentController(); await expect(controller.prepareTradingWallet()).rejects.toThrow( @@ -1139,10 +1083,16 @@ describe('PerpsController', () => { ); }); - it('clearAgentSigners does not need an initialized provider', () => { - controller = createAgentController(); + it('forgets agent bindings cleared before init', async () => { + const getAgentSigner = jest.fn().mockResolvedValue(agentSigner); + controller = createAgentController(getAgentSigner); + controller.setAgentSigner(account, null); + + controller.clearAgentSigners(); + await controller.init(); - expect(() => controller.clearAgentSigners()).not.toThrow(); + expect(await getProviderAgentResolver()(account)).toBe(agentSigner); + expect(getAgentSigner.mock.calls).toStrictEqual([[account]]); }); it('exposes the agent and preparation actions through the messenger at init', async () => { @@ -1165,7 +1115,7 @@ describe('PerpsController', () => { ); }); - it("prepareTradingWallet returns the active provider's readiness", async () => { + it("returns the active provider's trading wallet readiness", async () => { mockProvider.prepareTradingWallet = jest.fn().mockResolvedValue({ ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED, @@ -1181,7 +1131,7 @@ describe('PerpsController', () => { expect(mockProvider.prepareTradingWallet).toHaveBeenCalledTimes(1); }); - it('prepareTradingWallet reports ready when the provider has no deferred setup', async () => { + it('reports a trading wallet ready when the provider has no deferred setup', async () => { await controller.init(); const result = await controller.prepareTradingWallet(); diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts index 0a72f86ee74..03931dc8f8f 100644 --- a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -1135,8 +1135,8 @@ describe('AggregatedPerpsProvider', () => { { tags: { feature: 'perps', provider: 'hyperliquid' }, context: { - name: 'AggregatedPerpsProvider.prepareTradingWallet', - data: { providerId: 'hyperliquid' }, + name: 'AggregatedPerpsProvider', + data: { method: 'prepareTradingWallet', providerId: 'hyperliquid' }, }, }, ); 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 4507124698f..4beaab3a9f5 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 @@ -249,7 +249,7 @@ describe('HyperLiquidProvider', () => { .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), isMainAccountSignerReady: jest.fn().mockReturnValue(true), - isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + requiresSignatureConfirmation: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; mockSubscriptionService = { @@ -1717,7 +1717,7 @@ describe('HyperLiquidProvider', () => { 'defers %s migration on init for hardware wallets', 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 index 90a8a47db66..f7b51a4352b 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts @@ -1,5 +1,6 @@ import type { Hex } from '@metamask/utils'; +import { BUILDER_FEE_CONFIG } from '../../../src/constants/hyperLiquidConfig.js'; import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider.js'; import { AgentBindings } from '../../../src/services/agentSigner.js'; @@ -19,8 +20,11 @@ import type { } from '../../../src/types/index.js'; import { AGENT_ADDRESS, + AGENT_SIGNATURE, L1_PAYLOAD, + MAIN_SIGNATURE, USER_SIGNED_PAYLOAD, + createFrontendOpenOrder, } from '../../helpers/agentFixtures.js'; import { createMockExchangeClient, @@ -58,14 +62,12 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () let mockClientService: jest.Mocked; let mockPlatformDependencies: PerpsPlatformDependencies; let loggerError: jest.SpyInstance; - let debugLog: jest.SpyInstance; let trackPerpsEvent: jest.SpyInstance; beforeEach(() => { TradingReadinessCache.clearAll(); mockPlatformDependencies = createMockInfrastructure(); loggerError = jest.spyOn(mockPlatformDependencies.logger, 'error'); - debugLog = jest.spyOn(mockPlatformDependencies.debugLogger, 'log'); trackPerpsEvent = jest.spyOn( mockPlatformDependencies.metrics, 'trackPerpsEvent', @@ -122,8 +124,6 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () const ACCOUNT_ADDRESS = createMockEvmAccount().address; const OTHER_ACCOUNT_ADDRESS = '0x00000000000000000000000000000000000b0b01' as const; - const SIGNATURE = `0x${'cd'.repeat(65)}` as const; - const AGENT_SIGNATURE = `0x${'ef'.repeat(65)}` as const; /** * Whether the unified-account migration is recorded as attempted for the @@ -180,6 +180,9 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () onAgentRejected?: jest.Mock; // Sign through a KeyringController instead of accountSigner. keyring?: boolean; + // Extra SDK client methods, for the strategy order endpoints. + exchange?: Record; + info?: Record; }; type AccountSignerFixture = { @@ -200,7 +203,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () options: Options = {}, ): AccountSignerFixture { const accountSigner = { - signTypedData: jest.fn().mockResolvedValue(SIGNATURE), + signTypedData: jest.fn().mockResolvedValue(MAIN_SIGNATURE), signPersonalMessage: jest.fn(), ...options.signer, }; @@ -209,7 +212,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () signTypedData: jest.fn().mockResolvedValue(AGENT_SIGNATURE), }; const { messenger, call, selectAccount } = options.keyring - ? createKeyringMessenger(SIGNATURE) + ? createKeyringMessenger(MAIN_SIGNATURE) : createKeyringlessMessenger(); let sdkWallet: HyperLiquidWalletParams | undefined; const initialize = jest.fn(async (wallet: HyperLiquidWalletParams) => { @@ -239,11 +242,13 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () response: { data: { statuses: [{ resting: { oid: 123 } }] } }, }), ), + ...options.exchange, }); const infoClient = createMockInfoClient({ userAbstraction: jest .fn() .mockResolvedValue(options.abstraction ?? 'dexAbstraction'), + ...options.info, }); mockClientService.getExchangeClient.mockReturnValue( exchangeClient as unknown as ReturnType< @@ -319,6 +324,37 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(accountSigner.signTypedData).not.toHaveBeenCalled(); expect(migrationAttempted()).toBe(false); expect(keyringCalls(call)).toStrictEqual([]); + expect(loggerError).not.toHaveBeenCalled(); + expect(trackPerpsEvent).not.toHaveBeenCalledWith( + 'Perp Account Setup', + expect.objectContaining({ status: 'failed' }), + ); + }); + + 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({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }); + + expect(order).toStrictEqual( + expect.objectContaining({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }), + ); + expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + expect(referralAttempted()).toBe(false); }); describe('prepareTradingWallet', () => { @@ -342,7 +378,10 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], [ACCOUNT_ADDRESS, L1_PAYLOAD], ]); - expect(infoClient.maxBuilderFee).toHaveBeenCalled(); + expect(infoClient.maxBuilderFee).toHaveBeenCalledWith({ + user: ACCOUNT_ADDRESS, + builder: BUILDER_FEE_CONFIG.MainnetBuilder, + }); }); it('signs every setup step, so the first order signs only itself', async () => { @@ -389,28 +428,28 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () const { accountSignerProvider, exchangeClient } = createAccountSignerProvider({ abstraction: 'unifiedAccount' }); // Another provider holds the referral lock and ends without caching a - // result; release it once this provider is waiting on it. - const release = PerpsSigningCache.setInFlight( - 'referral', - 'mainnet', - ACCOUNT_ADDRESS, - ); - const waiting = '[ensureReferralSet] Global in-flight, waiting...'; - (debugLog as jest.Mock).mockImplementation((message: string) => { - if (message === waiting) { - release(); - } - }); + // result. + const otherAttempt = createDeferred(); + const waiting = createDeferred(); + const isInFlight = PerpsSigningCache.isInFlight.bind(PerpsSigningCache); + jest + .spyOn(PerpsSigningCache, 'isInFlight') + .mockImplementation((operationType, network, userAddress) => { + if (operationType !== 'referral') { + return isInFlight(operationType, network, userAddress); + } + waiting.resolve(); + return otherAttempt.promise; + }); - let result; - try { - result = await accountSignerProvider.prepareTradingWallet(); - } finally { - // Never leak the global lock into later tests. - release(); - } + const preparing = accountSignerProvider.prepareTradingWallet(); + await waiting.promise; + const referrerCallsWhileWaiting = + exchangeClient.setReferrer.mock.calls.length; + otherAttempt.resolve(); + const result = await preparing; - expect(debugLog).toHaveBeenCalledWith(waiting, { network: 'mainnet' }); + expect(referrerCallsWhileWaiting).toBe(0); expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); expect( PerpsSigningCache.getReferral('mainnet', ACCOUNT_ADDRESS), @@ -457,7 +496,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () if (payload === USER_SIGNED_PAYLOAD) { throw new Error('User rejected the request.'); } - return SIGNATURE; + return MAIN_SIGNATURE; }, ); @@ -493,32 +532,6 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(exchangeClient.approveBuilderFee).toHaveBeenCalledTimes(1); }); - 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({ - symbol: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'market', - currentPrice: 50000, - }); - - expect(order).toStrictEqual( - expect.objectContaining({ - success: false, - error: PERPS_ERROR_CODES.KEYRING_LOCKED, - }), - ); - expect(accountSigner.signTypedData).not.toHaveBeenCalled(); - expect(loggerError).not.toHaveBeenCalled(); - expect(referralAttempted()).toBe(false); - }); - it('reports KEYRING_LOCKED when accountSigner is not ready, without running or logging setup', async () => { const { accountSignerProvider, @@ -1046,9 +1059,23 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(2); }); + const rejection = (address: string): Error => + new Error(`User or API Wallet ${address} does not exist.`); + describe('when the venue rejects the agent', () => { - const rejection = (address: string): Error => - new Error(`User or API Wallet ${address} does not exist.`); + // 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, + }); /** * A provider whose L1 writes are signed by the agent, then rejected @@ -1112,24 +1139,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () const { accountSignerProvider, infoClient, onAgentRejected } = createRejectingProvider('modify'); infoClient.frontendOpenOrders.mockResolvedValue([ - { - 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: [], - }, + createFrontendOpenOrder(), ]); await accountSignerProvider.getMarketDataWithPrices(); @@ -1150,7 +1160,9 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () error: PERPS_ERROR_CODES.KEYRING_LOCKED, }), ); - expect(onAgentRejected).toHaveBeenCalledTimes(1); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); expect(loggerError).not.toHaveBeenCalled(); }); @@ -1167,7 +1179,9 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(result.results.map(({ error }) => error)).toStrictEqual([ PERPS_ERROR_CODES.KEYRING_LOCKED, ]); - expect(onAgentRejected).toHaveBeenCalledTimes(1); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); expect(loggerError).not.toHaveBeenCalled(); }); @@ -1187,33 +1201,16 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () error: PERPS_ERROR_CODES.KEYRING_LOCKED, }), ); - expect(onAgentRejected).toHaveBeenCalledTimes(1); + 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 } = createRejectingProvider('cancel'); - infoClient.frontendOpenOrders.mockResolvedValue([ - { - coin: 'BTC', - side: 'A', - limitPx: '58000', - sz: '0.1', - origSz: '0.1', - oid: 456, - timestamp: 1, - orderType: 'Take Profit Market', - tif: null, - isTrigger: true, - triggerPx: '58000', - triggerCondition: 'Price above 58000', - reduceOnly: true, - isPositionTpsl: true, - cloid: null, - children: [], - }, - ]); + infoClient.frontendOpenOrders.mockResolvedValue([TAKE_PROFIT_ORDER]); await accountSignerProvider.getMarketDataWithPrices(); const result = await accountSignerProvider.updatePositionTPSL({ @@ -1232,6 +1229,63 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () 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).toHaveBeenCalledWith({ + cancels: [{ a: 0, o: 456 }], + }); + expect(exchangeClient.order).not.toHaveBeenCalled(); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [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, + initialize, + onAgentRejected, + } = createRejectingProvider('cancel'); + onAgentRejected.mockImplementation(() => { + throw new Error('host callback failed'); + }); + await accountSignerProvider.getMarketDataWithPrices(); + + const result = await accountSignerProvider.cancelOrder({ + orderId: '123', + symbol: 'BTC', + }); + await initialize.mock.calls[0][0].signTypedData(L1_PAYLOAD); + + expect(result).toStrictEqual({ + success: false, + orderId: '123', + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(onAgentRejected).toHaveBeenCalledTimes(1); + // Dropped despite the throw, so the next L1 action asks again. + expect(getAgentSigner).toHaveBeenCalledTimes(2); + expect(loggerError).not.toHaveBeenCalled(); + }); + it('fails a margin update with KEYRING_LOCKED without logging it', async () => { const { accountSignerProvider, onAgentRejected } = createRejectingProvider('updateIsolatedMargin'); @@ -1246,10 +1300,65 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () success: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); - expect(onAgentRejected).toHaveBeenCalledTimes(1); + 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, + initialize, + selectAccount, + } = createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + onAgentRejected, + }); + getAgentSigner.mockResolvedValue(agentSigner); + await accountSignerProvider.getMarketDataWithPrices(); + const [[wallet]] = initialize.mock.calls; + const signed = createDeferred(); + const venue = createDeferred(); + exchangeClient.cancel.mockImplementation(async () => { + await wallet.signTypedData(L1_PAYLOAD); + signed.resolve(); + await venue.promise; + throw rejection(agentSigner.address); + }); + + const cancelling = accountSignerProvider.cancelOrder({ + orderId: '123', + symbol: 'BTC', + }); + await signed.promise; + selectAccount(OTHER_ACCOUNT_ADDRESS); + venue.resolve(); + const result = await cancelling; + selectAccount(ACCOUNT_ADDRESS); + await wallet.signTypedData(L1_PAYLOAD); + + expect(result).toStrictEqual( + expect.objectContaining({ + success: false, + 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', async () => { const getAgentSigner = jest.fn(); const onAgentRejected = jest.fn(); @@ -1301,7 +1410,8 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () }); it('fails a batch cancel with KEYRING_LOCKED without logging it', async () => { - const { accountSignerProvider } = createRejectingProvider('cancel'); + const { accountSignerProvider, onAgentRejected } = + createRejectingProvider('cancel'); await accountSignerProvider.getMarketDataWithPrices(); const result = await accountSignerProvider.cancelOrders([ @@ -1314,6 +1424,10 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () PERPS_ERROR_CODES.KEYRING_LOCKED, PERPS_ERROR_CODES.KEYRING_LOCKED, ]); + // One batch, so one rejection. + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); expect(loggerError).not.toHaveBeenCalled(); }); @@ -1470,6 +1584,376 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () }); }); + 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: 1_700_000_000_000, + user: ACCOUNT_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'; + + /** + * A provider whose strategy orders are placed while signing works, and + * whose later cancels sign through the SDK wallet: `failSigning` locks + * the keyring (no agent), makes the agent fail to sign, or has the venue + * reject the agent. + * + * @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; + 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 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 }, + 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.initialize.mock.calls[0][0].signTypedData(L1_PAYLOAD); + const signedCancel = async (): Promise => { + await signL1Action(); + // Only a rejected agent gets this far. + throw rejection(fixture.agentSigner.address); + }; + return { + provider: fixture.accountSignerProvider, + order: fixture.exchangeClient.order, + cancel, + cancelByCloid, + twapCancel, + l2Book, + getAgentSigner, + onAgentRejected, + signL1Action, + failSigning: (): void => { + signerReady = failure !== 'locked'; + if (failure === 'unavailable') { + fixture.agentSigner.signTypedData.mockRejectedValue( + new Error('agent key locked'), + ); + } + for (const endpoint of [cancel, cancelByCloid, twapCancel]) { + endpoint.mockImplementation(signedCancel); + } + }, + }; + } + + const SIGNER_FAILURES = [ + { failure: 'locked', rejectedAgents: [] }, + { failure: 'unavailable', rejectedAgents: [] }, + { + failure: 'rejected', + rejectedAgents: [[MAINNET_ACCOUNT, AGENT_ADDRESS]], + }, + ] as const; + + it.each(SIGNER_FAILURES)( + 'fails a TWAP cancel with KEYRING_LOCKED without logging it ($failure signer)', + 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).toHaveBeenCalledWith({ 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 ($failure signer)', + 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: placed.orderId as string, + symbol: 'ETH', + orderType: 'scale', + }); + cancel.mockResolvedValue(withStatuses('success', 'success')); + const retry = await provider.cancelOrder({ + orderId: placed.orderId as string, + 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(SIGNER_FAILURES)( + 'fails a scale cancel by client order ID with KEYRING_LOCKED ($failure signer)', + 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: placed.orderId as string, + symbol: 'ETH', + orderType: 'scale', + }); + + expect(placed).toStrictEqual( + expect.objectContaining({ + success: false, + error: PERPS_ERROR_CODES.ORDER_STRATEGY_CANCEL_INCOMPLETE, + }), + ); + expect(result).toStrictEqual({ + success: false, + orderId: placed.orderId, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(cancelByCloid).toHaveBeenLastCalledWith({ + cancels: orders.map(({ c }) => ({ + asset: 1, + cloid: c, + })), + }); + expect(onAgentRejected.mock.calls).toStrictEqual(rejectedAgents); + expect(loggerError).not.toHaveBeenCalled(); + }, + ); + + it.each(SIGNER_FAILURES)( + 'fails a chase cancel with KEYRING_LOCKED without logging it ($failure signer)', + 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: placed.orderId as string, + symbol: 'ETH', + orderType: 'chase', + }); + + expect(result).toStrictEqual({ + success: false, + orderId: placed.orderId, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(cancel).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 123 }], + }); + expect(onAgentRejected.mock.calls).toStrictEqual(rejectedAgents); + 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).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 123 }], + }); + 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(); + }); + }); + }); + it('fails an order with KEYRING_LOCKED without reporting it when getAgentSigner rejects', async () => { const getAgentSigner = jest.fn().mockResolvedValue(null); const { accountSignerProvider } = createAccountSignerProvider({ 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 5862045e42d..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 @@ -439,7 +439,7 @@ describe('HyperLiquidProvider', () => { .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), isMainAccountSignerReady: jest.fn().mockReturnValue(true), - isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + requiresSignatureConfirmation: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; mockSubscriptionService = { 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 1896be739b5..78131f212b5 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 @@ -435,7 +435,7 @@ describe('HyperLiquidProvider', () => { .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), isMainAccountSignerReady: jest.fn().mockReturnValue(true), - isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + requiresSignatureConfirmation: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; mockSubscriptionService = { 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 44be1d99cac..c67c5b2db47 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.data.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.data.test.ts @@ -428,7 +428,7 @@ describe('HyperLiquidProvider', () => { .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), isMainAccountSignerReady: jest.fn().mockReturnValue(true), - isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + 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 842a6d3b934..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 @@ -440,7 +440,7 @@ describe('HyperLiquidProvider', () => { .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), isMainAccountSignerReady: jest.fn().mockReturnValue(true), - isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + 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 e30f899a2b6..3bcb82f221b 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.history.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.history.test.ts @@ -428,7 +428,7 @@ describe('HyperLiquidProvider', () => { .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), isMainAccountSignerReady: jest.fn().mockReturnValue(true), - isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + 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 6561179b963..4881383d7ca 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.lifecycle.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.lifecycle.test.ts @@ -428,7 +428,7 @@ describe('HyperLiquidProvider', () => { .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), isMainAccountSignerReady: jest.fn().mockReturnValue(true), - isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + 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 1b52fe1b67b..19dfeb6cbd6 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.misc.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.misc.test.ts @@ -428,7 +428,7 @@ describe('HyperLiquidProvider', () => { .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), isMainAccountSignerReady: jest.fn().mockReturnValue(true), - isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + requiresSignatureConfirmation: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; mockSubscriptionService = { 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 1203b2c8392..49bfbfb5790 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts @@ -429,7 +429,7 @@ describe('HyperLiquidProvider', () => { .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), isMainAccountSignerReady: jest.fn().mockReturnValue(true), - isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + 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 e5d7887d2ec..010fc2a43ef 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 @@ -503,7 +503,7 @@ describe('HyperLiquidProvider - strategy order types', () => { .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), isMainAccountSignerReady: jest.fn().mockReturnValue(true), - isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + requiresSignatureConfirmation: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; mockSubscriptionService = { 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 16778606bde..636f00639d2 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts @@ -460,7 +460,7 @@ describe('HyperLiquidProvider', () => { .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), isMainAccountSignerReady: jest.fn().mockReturnValue(true), - isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + requiresSignatureConfirmation: jest.fn().mockReturnValue(false), } as Partial as jest.Mocked; mockSubscriptionService = { 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 f0f966a2c62..3c0259113e4 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts @@ -428,7 +428,7 @@ describe('HyperLiquidProvider', () => { .fn() .mockResolvedValue('0x1234567890123456789012345678901234567890'), isMainAccountSignerReady: jest.fn().mockReturnValue(true), - isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + 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 index 92429fe7e2b..6ee93ad0418 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts @@ -40,6 +40,13 @@ const TX_EXPIRY_MS = 9 * 60 * 1000; const L1_SIGNATURE = `0x${'ab'.repeat(65)}` as const; 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; +// The registration transaction the mocked signer submits. +const CHANGE_PUB_KEY_TX = [ + LIGHTER_TX_TYPE_CHANGE_PUB_KEY, + expect.stringContaining('"changePubKey":true'), +]; function createBridge(): { bridge: LighterSignerBridge; @@ -91,6 +98,7 @@ type BuiltProvider = { client: { sendTx: jest.Mock; getAccountsByL1Address: jest.Mock }; accountSigner: { signPersonalMessage: jest.Mock }; call: jest.SpyInstance; + selectAccount: (address: `0x${string}`) => void; calls: LighterWasmCall[]; deps: ReturnType; }; @@ -144,7 +152,7 @@ function buildProvider({ signPersonalMessage: jest.fn().mockResolvedValue(L1_SIGNATURE), isReady, }; - const { messenger, call } = keyring + const { messenger, call, selectAccount } = keyring ? createKeyringMessenger(L1_SIGNATURE) : createKeyringlessMessenger(); const { bridge, calls } = createBridge(); @@ -162,7 +170,16 @@ function buildProvider({ signerBridge: withoutBridge ? undefined : bridge, webSocketCtor: null, }); - return { provider, address, client, accountSigner, call, calls, deps }; + return { + provider, + address, + client, + accountSigner, + call, + selectAccount, + calls, + deps, + }; } describe('LighterProvider with accountSigner', () => { @@ -172,7 +189,12 @@ describe('LighterProvider with accountSigner', () => { const result = await provider.isReadyToTrade(); - expect(result.ready).toBe(true); + expect(result).toStrictEqual({ + ready: true, + walletConnected: true, + networkSupported: true, + authenticatedAddress: address, + }); expect(accountSigner.signPersonalMessage).toHaveBeenCalledWith( address, CHANGE_PUB_KEY_BODY, @@ -186,10 +208,7 @@ describe('LighterProvider with accountSigner', () => { NEXT_NONCE, API_KEY_INDEX, ]); - expect(client.sendTx).toHaveBeenCalledWith( - LIGHTER_TX_TYPE_CHANGE_PUB_KEY, - expect.stringContaining('"changePubKey":true'), - ); + expect(client.sendTx.mock.calls).toStrictEqual([CHANGE_PUB_KEY_TX]); expect(keyringCalls(call)).toStrictEqual([]); }); @@ -219,15 +238,11 @@ describe('LighterProvider with accountSigner', () => { const result = await provider.prepareTradingWallet(); - expect(result.ready).toBe(true); - expect(accountSigner.signPersonalMessage).toHaveBeenCalledWith( - address, - CHANGE_PUB_KEY_BODY, - ); - expect(client.sendTx).toHaveBeenCalledWith( - LIGHTER_TX_TYPE_CHANGE_PUB_KEY, - expect.stringContaining('"changePubKey":true'), - ); + 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('reports KEYRING_LOCKED from prepareTradingWallet once the signer locks, even with a registered venue key', async () => { @@ -238,7 +253,7 @@ describe('LighterProvider with accountSigner', () => { signerReady = false; const lockedResult = await provider.prepareTradingWallet(); - expect(firstResult.ready).toBe(true); + expect(firstResult).toStrictEqual({ ready: true }); expect(lockedResult).toStrictEqual({ ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED, @@ -253,35 +268,23 @@ describe('LighterProvider with accountSigner', () => { const result = await provider.prepareTradingWallet(); - expect(result.ready).toBe(false); - expect(loggerError).toHaveBeenCalledTimes(1); - const [[loggedError, context]] = loggerError.mock.calls; - expect(loggedError.message).toBe(result.error); - expect(loggedError === failure || loggedError.cause === failure).toBe(true); - expect(context).toStrictEqual({ - tags: { - feature: 'perps', - provider: 'LighterProvider', - network: 'testnet', - }, - context: { - name: 'LighterProvider.prepareTradingWallet', - data: { isTestnet: true }, - }, - }); - }); - - it('reports a declined venue-key signature as a retry without logging', async () => { - const { provider, accountSigner, deps } = buildProvider(); - accountSigner.signPersonalMessage.mockRejectedValue( - Object.assign(new Error('User rejected the request.'), { code: 4001 }), - ); - const loggerError = jest.spyOn(deps.logger, 'error'); - - const result = await provider.prepareTradingWallet(); - - expect(result).toStrictEqual({ ready: false }); - expect(loggerError).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ ready: false, error: 'venue unavailable' }); + expect(loggerError.mock.calls).toStrictEqual([ + [ + failure, + { + tags: { + feature: 'perps', + provider: 'LighterProvider', + network: 'testnet', + }, + context: { + name: 'LighterProvider.prepareTradingWallet', + data: { isTestnet: true }, + }, + }, + ], + ]); }); it.each([ @@ -289,6 +292,7 @@ describe('LighterProvider with accountSigner', () => { 'an EIP-1193 rejection code', Object.assign(new Error('Rejected'), { code: 4001 }), ], + ['a "User rejected" message', new Error('User rejected the request.')], ['a "User denied" message', new Error('User denied message signature.')], ])( 'reports a decline signalled by %s as a retry without logging', @@ -309,7 +313,7 @@ describe('LighterProvider with accountSigner', () => { findAccountByAddress: true, }); client.getAccountsByL1Address.mockRejectedValue( - new LighterApiError('account not found', 21100), + new LighterApiError('account not found', ACCOUNT_NOT_FOUND_CODE), ); const loggerError = jest.spyOn(deps.logger, 'error'); @@ -319,6 +323,40 @@ describe('LighterProvider with accountSigner', () => { expect(loggerError).not.toHaveBeenCalled(); }); + it.each([ + [ + 'the provider disconnects', + 'Operation cancelled: the Lighter provider was disconnected', + async ({ provider }: BuiltProvider): Promise => { + await provider.disconnect(); + }, + ], + [ + 'the wallet switches accounts', + 'Operation cancelled: the wallet switched accounts (or the signer reset) while this operation was in flight', + async ({ selectAccount }: BuiltProvider): Promise => { + selectAccount('0x00000000000000000000000000000000000c0ffe'); + }, + ], + ])( + 'reports a registration cancelled because %s as not ready without logging', + async (_cause, cancellation, cancelSession) => { + const built = buildProvider(); + const { provider, accountSigner, client, deps } = built; + accountSigner.signPersonalMessage.mockImplementation(async () => { + await cancelSession(built); + return L1_SIGNATURE; + }); + const loggerError = jest.spyOn(deps.logger, 'error'); + + const result = await provider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: false, error: cancellation }); + expect(client.sendTx).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + }, + ); + it('reports and logs a missing signer bridge', async () => { const { provider, deps } = buildProvider({ withoutBridge: true }); const loggerError = jest.spyOn(deps.logger, 'error'); @@ -359,14 +397,14 @@ describe('LighterProvider with accountSigner', () => { const result = await provider.prepareTradingWallet(); - expect(client.sendTx).toHaveBeenCalled(); + expect(client.sendTx.mock.calls).toStrictEqual([CHANGE_PUB_KEY_TX]); expect(result).toStrictEqual({ ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); }); - it('reports KEYRING_LOCKED when the keyring locks during registration', async () => { + it('reports KEYRING_LOCKED when the account signer locks during registration', async () => { const { provider, accountSigner, deps } = buildProvider(); accountSigner.signPersonalMessage.mockRejectedValue( new Error(PERPS_ERROR_CODES.KEYRING_LOCKED), @@ -401,9 +439,6 @@ describe('LighterProvider with a KeyringController', () => { (wasmCall) => wasmCall.function === '_signChangePubKey', ); expect(changePubKey?.params[1]).toBe(L1_SIGNATURE); - expect(client.sendTx).toHaveBeenCalledWith( - LIGHTER_TX_TYPE_CHANGE_PUB_KEY, - expect.stringContaining('"changePubKey":true'), - ); + 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 index 2bf7b8d8a41..c2a00fbb970 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts @@ -14,6 +14,13 @@ import type { PerpsAgentSigner, PerpsTypedDataPayload, } from '../../../src/types/index.js'; +import { + AGENT_ADDRESS, + AGENT_SIGNATURE, + L1_PAYLOAD, + MAIN_SIGNATURE, + USER_SIGNED_PAYLOAD, +} from '../../helpers/agentFixtures.js'; import { createKeyringlessMessenger, createMockEvmAccount, @@ -22,25 +29,6 @@ import { keyringCalls, } from '../../helpers/serviceMocks.js'; -const SIGNATURE = `0x${'cd'.repeat(65)}` as const; - -const TYPED_DATA: PerpsTypedDataPayload = { - domain: { - name: 'Exchange', - version: '1', - chainId: 1337, - verifyingContract: '0x0000000000000000000000000000000000000000', - }, - types: { - Agent: [ - { name: 'source', type: 'string' }, - { name: 'connectionId', type: 'bytes32' }, - ], - }, - primaryType: 'Agent', - message: { source: 'b', connectionId: `0x${'11'.repeat(32)}` }, -}; - type SignerOverrides = { signTypedData?: jest.Mock; isReady?: () => boolean; @@ -59,8 +47,8 @@ function buildService( ): Built { const signer = { signTypedData: - overrides.signTypedData ?? jest.fn().mockResolvedValue(SIGNATURE), - signPersonalMessage: jest.fn().mockResolvedValue(SIGNATURE), + overrides.signTypedData ?? jest.fn().mockResolvedValue(MAIN_SIGNATURE), + signPersonalMessage: jest.fn().mockResolvedValue(MAIN_SIGNATURE), isReady: overrides.isReady, requiresSignatureConfirmation: overrides.requiresSignatureConfirmation, }; @@ -81,11 +69,11 @@ describe('HyperLiquidWalletService with accountSigner', () => { const signature = await service .createWalletAdapter() - .signTypedData(TYPED_DATA); + .signTypedData(L1_PAYLOAD); - expect(signature).toBe(SIGNATURE); + expect(signature).toBe(MAIN_SIGNATURE); expect(signer.signTypedData).toHaveBeenCalledTimes(1); - expect(signer.signTypedData).toHaveBeenCalledWith(address, TYPED_DATA); + expect(signer.signTypedData).toHaveBeenCalledWith(address, L1_PAYLOAD); expect(keyringCalls(call)).toStrictEqual([]); }); @@ -97,7 +85,7 @@ describe('HyperLiquidWalletService with accountSigner', () => { }); await expect( - service.createWalletAdapter().signTypedData(TYPED_DATA), + service.createWalletAdapter().signTypedData(L1_PAYLOAD), ).rejects.toThrow('User rejected the request.'); }); @@ -113,7 +101,7 @@ describe('HyperLiquidWalletService with accountSigner', () => { expect(service.isMainAccountSignerReady()).toBe(false); await expect( - service.createWalletAdapter().signTypedData(TYPED_DATA), + service.createWalletAdapter().signTypedData(L1_PAYLOAD), ).rejects.toThrow(PERPS_ERROR_CODES.KEYRING_LOCKED); expect(signer.signTypedData).not.toHaveBeenCalled(); expect(keyringCalls(call)).toStrictEqual([]); @@ -125,7 +113,7 @@ describe('HyperLiquidWalletService with accountSigner', () => { 'HD Key Tree', ); - expect(service.isSelectedHardwareWallet()).toBe(true); + expect(service.requiresSignatureConfirmation()).toBe(true); }); it('treats the account as software when requiresSignatureConfirmation returns false', () => { @@ -134,7 +122,7 @@ describe('HyperLiquidWalletService with accountSigner', () => { 'Ledger Hardware', ); - expect(service.isSelectedHardwareWallet()).toBe(false); + expect(service.requiresSignatureConfirmation()).toBe(false); }); it.each([ @@ -145,7 +133,7 @@ describe('HyperLiquidWalletService with accountSigner', () => { (keyringType, expected) => { const { service } = buildService({}, keyringType); - expect(service.isSelectedHardwareWallet()).toBe(expected); + expect(service.requiresSignatureConfirmation()).toBe(expected); }, ); }); @@ -153,32 +141,6 @@ describe('HyperLiquidWalletService with accountSigner', () => { describe('HyperLiquidWalletService wallet adapter with an agent', () => { const { address: mainAddress } = createMockEvmAccount(); const OTHER_MAIN_ADDRESS = '0x00000000000000000000000000000000000b0b01'; - const AGENT_ADDRESS = '0x00000000000000000000000000000000000a9e17'; - const AGENT_SIGNATURE = `0x${'ef'.repeat(65)}` as const; - const USER_SIGNED_ACTION: PerpsTypedDataPayload = { - domain: { - name: 'HyperliquidSignTransaction', - version: '1', - chainId: 1, - verifyingContract: '0x0000000000000000000000000000000000000000', - }, - types: { - 'HyperliquidTransaction:ApproveBuilderFee': [ - { name: 'hyperliquidChain', type: 'string' }, - { name: 'maxFeeRate', type: 'string' }, - { name: 'builder', type: 'address' }, - { name: 'nonce', type: 'uint64' }, - ], - }, - primaryType: 'HyperliquidTransaction:ApproveBuilderFee', - message: { - hyperliquidChain: 'Mainnet', - maxFeeRate: '0.1%', - builder: AGENT_ADDRESS, - nonce: 1, - }, - }; - function buildAdapter(agentAvailable = true): { adapter: ReturnType; resolveAgent: jest.Mock; @@ -188,7 +150,7 @@ describe('HyperLiquidWalletService wallet adapter with an agent', () => { selectAccount: (address: `0x${string}`) => void; } { const signer = { - signTypedData: jest.fn().mockResolvedValue(SIGNATURE), + signTypedData: jest.fn().mockResolvedValue(MAIN_SIGNATURE), signPersonalMessage: jest.fn(), }; const { messenger, call, selectAccount } = createKeyringlessMessenger(); @@ -224,11 +186,11 @@ describe('HyperLiquidWalletService wallet adapter with an agent', () => { 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(TYPED_DATA); + const signature = await adapter.signTypedData(L1_PAYLOAD); expect(signature).toBe(AGENT_SIGNATURE); expect(resolveAgent).toHaveBeenCalledWith(mainAddress); - expect(agentSign).toHaveBeenCalledWith(TYPED_DATA); + expect(agentSign).toHaveBeenCalledWith(L1_PAYLOAD); expect(mainSign).not.toHaveBeenCalled(); expect(keyringCalls(call)).toStrictEqual([]); }); @@ -236,10 +198,10 @@ describe('HyperLiquidWalletService wallet adapter with an agent', () => { 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_ACTION); + const signature = await adapter.signTypedData(USER_SIGNED_PAYLOAD); - expect(signature).toBe(SIGNATURE); - expect(mainSign).toHaveBeenCalledWith(mainAddress, USER_SIGNED_ACTION); + expect(signature).toBe(MAIN_SIGNATURE); + expect(mainSign).toHaveBeenCalledWith(mainAddress, USER_SIGNED_PAYLOAD); expect(resolveAgent).not.toHaveBeenCalled(); expect(agentSign).not.toHaveBeenCalled(); }); @@ -247,8 +209,8 @@ describe('HyperLiquidWalletService wallet adapter with an agent', () => { it('keeps an Agent primary type outside the Exchange domain on the main account', async () => { const { adapter, agentSign, mainSign } = buildAdapter(); const lookalike = { - ...TYPED_DATA, - domain: { ...TYPED_DATA.domain, name: 'HyperliquidSignTransaction' }, + ...L1_PAYLOAD, + domain: { ...L1_PAYLOAD.domain, name: 'HyperliquidSignTransaction' }, }; await adapter.signTypedData(lookalike); @@ -260,16 +222,16 @@ describe('HyperLiquidWalletService wallet adapter with an agent', () => { it('signs L1 actions with the main account when no agent is resolved', async () => { const { adapter, mainSign } = buildAdapter(false); - await adapter.signTypedData(TYPED_DATA); + await adapter.signTypedData(L1_PAYLOAD); - expect(mainSign).toHaveBeenCalledWith(mainAddress, TYPED_DATA); + expect(mainSign).toHaveBeenCalledWith(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(TYPED_DATA); + await adapter.signTypedData(L1_PAYLOAD); expect(resolveAgent).toHaveBeenCalledWith(OTHER_MAIN_ADDRESS); expect(resolveAgent).not.toHaveBeenCalledWith(mainAddress); @@ -279,7 +241,7 @@ describe('HyperLiquidWalletService wallet adapter with an agent', () => { const { adapter, resolveAgent, mainSign } = buildAdapter(); resolveAgent.mockRejectedValue(new Error('agent store unavailable')); - await expect(adapter.signTypedData(TYPED_DATA)).rejects.toThrow( + await expect(adapter.signTypedData(L1_PAYLOAD)).rejects.toThrow( 'agent store unavailable', ); expect(mainSign).not.toHaveBeenCalled(); @@ -305,10 +267,10 @@ describe('HyperLiquidWalletService wallet adapter with an agent', () => { }, ).createWalletAdapter(); - const signature = await adapter.signTypedData(TYPED_DATA); + const signature = await adapter.signTypedData(L1_PAYLOAD); expect(signature).toBe(AGENT_SIGNATURE); - await expect(adapter.signTypedData(USER_SIGNED_ACTION)).rejects.toThrow( + await expect(adapter.signTypedData(USER_SIGNED_PAYLOAD)).rejects.toThrow( PERPS_ERROR_CODES.KEYRING_LOCKED, ); expect(signer.signTypedData).not.toHaveBeenCalled(); @@ -320,7 +282,7 @@ describe('HyperLiquidWalletService wallet adapter with an agent', () => { agentSign.mockRejectedValue(failure); const error: unknown = await adapter - .signTypedData(TYPED_DATA) + .signTypedData(L1_PAYLOAD) .catch((caught: unknown) => caught); expect(error).toBeInstanceOf(AgentSignerUnavailableError); @@ -333,19 +295,6 @@ 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(); - const AGENT_SIGNATURE = `0x${'ef'.repeat(65)}` as const; - const USER_SIGNED_ACTION: PerpsTypedDataPayload = { - ...TYPED_DATA, - domain: { ...TYPED_DATA.domain, name: 'HyperliquidSignTransaction' }, - primaryType: 'HyperliquidTransaction:ApproveBuilderFee', - types: { - 'HyperliquidTransaction:ApproveBuilderFee': [ - { name: 'hyperliquidChain', type: 'string' }, - { name: 'nonce', type: 'uint64' }, - ], - }, - }; - function buildKeyringAdapter(): { adapter: ReturnType; agentSign: jest.Mock; @@ -359,7 +308,7 @@ describe('HyperLiquidWalletService wallet adapter with an agent and a keyring', messenger, { resolveAgent: async (): Promise => ({ - address: '0x00000000000000000000000000000000000a9e17', + address: AGENT_ADDRESS, signTypedData: agentSign, }), }, @@ -370,23 +319,23 @@ describe('HyperLiquidWalletService wallet adapter with an agent and a keyring', it('signs L1 actions with the agent without calling KeyringController', async () => { const { adapter, agentSign, call } = buildKeyringAdapter(); - const signature = await adapter.signTypedData(TYPED_DATA); + const signature = await adapter.signTypedData(L1_PAYLOAD); expect(signature).toBe(AGENT_SIGNATURE); - expect(agentSign).toHaveBeenCalledWith(TYPED_DATA); + expect(agentSign).toHaveBeenCalledWith(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_ACTION); + const signature = await adapter.signTypedData(USER_SIGNED_PAYLOAD); expect(signature).toBe('0xSignatureResult'); expect(agentSign).not.toHaveBeenCalled(); expect(call).toHaveBeenCalledWith( 'KeyringController:signTypedMessage', - { from: mainAddress, data: USER_SIGNED_ACTION }, + { from: mainAddress, data: USER_SIGNED_PAYLOAD }, 'V4', ); }); diff --git a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts index c23181568e7..46a19bc73fc 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts @@ -346,7 +346,7 @@ describe('HyperLiquidWalletService', () => { }); it('returns false for software wallet', () => { - expect(service.isSelectedHardwareWallet()).toBe(false); + expect(service.requiresSignatureConfirmation()).toBe(false); }); it.each([ @@ -373,7 +373,7 @@ describe('HyperLiquidWalletService', () => { return undefined; }); - expect(service.isSelectedHardwareWallet()).toBe(true); + expect(service.requiresSignatureConfirmation()).toBe(true); }); }); @@ -469,7 +469,7 @@ describe('HyperLiquidWalletService', () => { ); }); - it('should return keyring unlocked status via isMainAccountSignerReady()', () => { + 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) => { diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index 755d2d3a361..e46040cb16c 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -1882,6 +1882,54 @@ describe('TradingService', () => { 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: 1/2 failed - ETH/order-2: rate limit', + ), + { + controller: 'TradingService', + method: 'cancelOrders', + successCount: 0, + failureCount: 1, + cancelAll: true, + }, + ], + ]); + }); + it('does NOT log batch error when using fallback path (provider.cancelOrders undefined)', async () => { const params: CancelOrdersParams = { cancelAll: true }; mockGetOpenOrders.mockResolvedValue(mockOrders); @@ -2584,6 +2632,45 @@ describe('TradingService', () => { 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: 1/2 failed - ETH: min size'), + { + controller: 'TradingService', + method: 'closePositions', + successCount: 0, + failureCount: 1, + symbols: 0, + closeAll: true, + }, + ], + ]); + }); + it('does NOT log batch error when using fallback path (provider.closePositions undefined)', async () => { const params: ClosePositionsParams = { symbols: ['BTC'] }; mockGetPositions.mockResolvedValue(mockPositions); diff --git a/packages/perps-controller/tests/src/services/agentSigner.test.ts b/packages/perps-controller/tests/src/services/agentSigner.test.ts index 36c96b20207..9ee6ac17f36 100644 --- a/packages/perps-controller/tests/src/services/agentSigner.test.ts +++ b/packages/perps-controller/tests/src/services/agentSigner.test.ts @@ -4,13 +4,17 @@ import { isAgentSignerUnavailableError, } from '../../../src/services/agentSigner.js'; import type { PerpsAgentAccount } from '../../../src/types/index.js'; +import { + AGENT_ADDRESS, + OTHER_AGENT_ADDRESS, +} from '../../helpers/agentFixtures.js'; const ACCOUNT: PerpsAgentAccount = { mainAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', isTestnet: false, }; const AGENT = { - address: '0x00000000000000000000000000000000000a9e17', + address: AGENT_ADDRESS, signTypedData: jest.fn(), } as const; @@ -36,7 +40,7 @@ describe('AgentBindings', () => { bindings.set(ACCOUNT, AGENT); bindings.set(otherAccount, null); - bindings.release(ACCOUNT, '0x00000000000000000000000000000000000b0b02'); + bindings.release(ACCOUNT, OTHER_AGENT_ADDRESS); bindings.release(otherAccount, AGENT.address); expect(await bindings.resolve(ACCOUNT)).toBe(AGENT); From 437713817a54fc0d0daf3e4811bf381e6403bab8 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 29 Sep 2026 10:32:50 +0800 Subject: [PATCH 17/33] fix(perps-controller): keep every signer failure retryable, and test through the SDK boundary - Withdrawals and transfers between DEXs fail with KEYRING_LOCKED without logging when the signer cannot sign. - The referral retries after any signer failure, a locked keyring included, so preparation is not reported ready without it. - prepareTradingWallet reports KEYRING_LOCKED when the signer locked while a step failed (HyperLiquid and Lighter); Lighter reports a cancelled session as PROVIDER_LIFECYCLE_STALE. - Cancel batches classify signer failures themselves; #mapError has no side effects, and rejections reported in status entries still drop the agent. - One agent key helper; named patterns; the aggregated provider tags a testnet-pinned Lighter with its network. - The agent-signing integration test mocks only the SDK and drives writes through the controller; more rejection, readiness and referral cases are covered, with exact assertions. --- packages/perps-controller/CHANGELOG.md | 2 +- .../src/providers/AggregatedPerpsProvider.ts | 12 +- .../src/providers/HyperLiquidProvider.ts | 194 ++--- .../src/providers/LighterProvider.ts | 26 +- .../src/services/agentSigner.ts | 20 +- .../tests/helpers/agentFixtures.ts | 27 + ...ntroller.agent-signing.integration.test.ts | 512 +++++++------ .../PerpsController.providers-cache.test.ts | 148 +++- .../providers/AggregatedPerpsProvider.test.ts | 46 +- ...HyperLiquidProvider.account-signer.test.ts | 670 ++++++++++++++---- .../HyperLiquidProvider.builder-fees.test.ts | 8 +- .../LighterProvider.account-signer.test.ts | 63 +- ...LiquidWalletService.account-signer.test.ts | 14 + ...ighterWalletService.account-signer.test.ts | 12 +- 14 files changed, 1235 insertions(+), 519 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 99a4471a52a..0912723181a 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -41,7 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - HyperLiquid writes that fail because the keyring is locked now fail with `KEYRING_LOCKED` instead of the SDK's "Failed to sign the typed data using the wallet" message, and are no longer reported as errors by the provider or `TradingService` ([#10559](https://github.com/MetaMask/core/pull/10559)) - - Covers orders, edits, single and batch cancels (TWAP, scale and chase cancels included), position closes, TP/SL updates and clears, and margin updates + - 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 ## [18.0.1] diff --git a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts index e24e4f0dd87..4da3bfe21c0 100644 --- a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts +++ b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts @@ -17,7 +17,7 @@ import type { CaipAccountId } from '@metamask/utils'; import { SubscriptionMultiplexer } from '../aggregation/SubscriptionMultiplexer.js'; -import { PERPS_CONSTANTS } from '../constants/perpsConfig.js'; +import { PERPS_CONSTANTS, PROVIDER_CONFIG } from '../constants/perpsConfig.js'; import { PERPS_ERROR_CODES } from '../perpsErrorCodes.js'; import { ProviderRouter } from '../routing/ProviderRouter.js'; import { WebSocketConnectionState } from '../types/index.js'; @@ -1072,12 +1072,18 @@ export class AggregatedPerpsProvider implements PerpsProvider { caughtError, 'AggregatedPerpsProvider.prepareTradingWallet', ); + // The provider's own network: Lighter can be pinned to testnet, as + // in buildProviderCacheKey. + const isProviderTestnet = + providerId === 'lighter' && PROVIDER_CONFIG.LIGHTER_TESTNET_ONLY + ? true + : this.#isTestnet; this.#deps.logger.error(error, { tags: { feature: PERPS_CONSTANTS.FeatureName, provider: providerId, - ...(this.#isTestnet !== undefined && { - network: this.#isTestnet ? 'testnet' : 'mainnet', + ...(isProviderTestnet !== undefined && { + network: isProviderTestnet ? 'testnet' : 'mainnet', }), }, context: { diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 41cc2c4fe18..29440630771 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -50,6 +50,7 @@ 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'; @@ -451,6 +452,9 @@ const getCancelStatusesFromError = ( * Every other rejection (multi-sig, a stale nonce, a rate limit) leaves the * order exactly where it was, which is a materially different outcome. */ +// 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; + const ALREADY_GONE_CANCEL_MARKERS = [ 'never placed', 'already canceled', @@ -555,7 +559,8 @@ type CancelOrderBatchOutcome = { cancelledOrderIds: number[]; responseComplete: boolean; // Set when the signer could not sign the cancel, so nothing was cancelled. - signerFailure?: unknown; + // KEYRING_LOCKED when the signer could not sign, so nothing was cancelled. + signerFailure?: Error; }; type OrderPlacementOutcome = { @@ -1551,7 +1556,7 @@ export class HyperLiquidProvider implements PerpsProvider { readonly #getAgentSigner: HyperLiquidCredentials['getAgentSigner']; // Pending or non-null getAgentSigner answers per network and main account - // (see #getAgentKey), so an agent is only used for its account and network. + // (see getAgentAccountKey), so an agent is only used for its account and network. // Incremented by clearAgentSigners so answers pending across a clear are // discarded and asked again. #agentSignersGeneration = 0; @@ -1562,11 +1567,13 @@ export class HyperLiquidProvider implements PerpsProvider { readonly #resolvedAgents = new Map(); // The account and network each agent address was last resolved to sign an - // L1 action for. - // A rejection is attributed from this record rather than from the selected - // account, which may have changed while the write was in flight. Kept - // across clearAgentSigners, so the rejection of an agent that was replaced - // while its action was in flight is still recognized. + // L1 action for. One entry per address is enough: an L1 signature covers + // no user, so the venue resolves the account from the agent address, and an + // agent acts for a single account. A rejection is attributed from this + // record rather than from the selected account, which may have changed + // while the write was in flight. Kept across clearAgentSigners, so the + // rejection of an agent that was replaced while its action was in flight is + // still recognized. readonly #agentSignedFor = new Map< string, { key: string; account: PerpsAgentAccount } @@ -2068,19 +2075,6 @@ export class HyperLiquidProvider implements PerpsProvider { } } - /** - * Key an agent by network and main account. - * - * @param account - The main account and network. - * @returns The agent key. - */ - #getAgentKey(account: PerpsAgentAccount): string { - return this.#getCacheKey( - account.isTestnet ? 'testnet' : 'mainnet', - account.mainAddress, - ); - } - /** * Resolve the agent that signs L1 actions for a main account on the * current network through `getAgentSigner`. A non-null answer is kept; @@ -2097,7 +2091,7 @@ export class HyperLiquidProvider implements PerpsProvider { mainAddress, isTestnet: this.#clientService.isTestnetMode(), }; - const key = this.#getAgentKey(account); + const key = getAgentAccountKey(account); const generation = this.#agentSignersGeneration; let entry = this.#agentSigners.get(key); if (!entry) { @@ -2202,7 +2196,7 @@ export class HyperLiquidProvider implements PerpsProvider { ) { return undefined; } - const agentAddress = /user or api wallet (0x[0-9a-f]{40})/iu.exec( + const agentAddress = UNKNOWN_WALLET_ADDRESS_PATTERN.exec( ensureError(error, 'HyperLiquidProvider.findRejectedAgent').message, )?.[1] as Hex | undefined; const signedFor = @@ -2299,14 +2293,28 @@ export class HyperLiquidProvider implements PerpsProvider { ): Error | undefined { const signerFailure = this.#classifySignerFailure(error); if (signerFailure) { - this.#deps.debugLogger.log( - `[${method}] Signer unavailable, the write can be retried`, - extra, - ); + 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. * @@ -2693,23 +2701,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', - ); - this.#unifiedAccountSetupNeedsRetry = true; - completeInFlight(); - return; - } - - if ( - isAgentSignerUnavailableError(error) || - this.#evictRejectedAgent(error) - ) { - this.#deps.debugLogger.log( - '[ensureUnifiedAccountEnabled] Agent signer unavailable, will retry later', + '[ensureUnifiedAccountEnabled] Signer unavailable, will retry later', ); this.#unifiedAccountSetupNeedsRetry = true; completeInFlight(); @@ -4313,9 +4310,10 @@ export class HyperLiquidProvider implements PerpsProvider { #mapError(error: unknown): Error { const { message } = ensureError(error, 'HyperLiquidProvider.mapError'); - const signerFailure = this.#classifySignerFailure(error); - if (signerFailure) { - return signerFailure; + // A write whose signer could not sign it. Mapping has no side effects: + // the caller that reports the failure classifies it (#handleSignerFailure). + if (this.#isSignerFailure(error)) { + return new Error(PERPS_ERROR_CODES.KEYRING_LOCKED); } // "User or API Wallet 0x... does not exist." carries the user's address, so @@ -4337,6 +4335,18 @@ 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); + } + /** * Get error context for logging with searchable tags and context. * Enables Sentry dashboard filtering by feature, provider, and network. @@ -8435,7 +8445,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, }); @@ -8500,14 +8510,15 @@ export class HyperLiquidProvider implements PerpsProvider { // The signer could not sign a cancel: retryable, not a defect. const signerFailure = orderCancellation.signerFailure ?? cloidCancellation.signerFailure; - if (signerFailure !== undefined) { - return createErrorResult( - this.#handleSignerFailure(signerFailure, 'cancelOrder', { - orderId: params.orderId, - orderType: params.orderType, - }) ?? new Error(PERPS_ERROR_CODES.KEYRING_LOCKED), - { success: false, orderId: params.orderId }, - ); + 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, @@ -8664,7 +8675,7 @@ export class HyperLiquidProvider implements PerpsProvider { async #cancelOrderCloidRequestBatch( exchangeClient: ExchangeClient, requests: ExchangeCancelByCloidRequest[], - ): Promise<{ remainingClientOrderIds: Hex[]; signerFailure?: unknown }> { + ): Promise<{ remainingClientOrderIds: Hex[]; signerFailure?: Error }> { if (requests.length === 0) { return { remainingClientOrderIds: [] }; } @@ -8690,10 +8701,11 @@ export class HyperLiquidProvider implements PerpsProvider { return { remainingClientOrderIds: getRemainingClientOrderIds(statuses) }; } catch (error) { // The signer could not sign, so nothing was cancelled. - if (this.#isSignerFailure(error)) { + const signerFailure = this.#classifySignerFailure(error); + if (signerFailure) { return { remainingClientOrderIds: requests.map((request) => request.cloid), - signerFailure: error, + signerFailure, }; } const statuses = getCancelStatusesFromError(error, requests.length); @@ -8763,12 +8775,13 @@ export class HyperLiquidProvider implements PerpsProvider { return classifyStatuses(statuses); } catch (error) { // The signer could not sign, so nothing was cancelled. - if (this.#isSignerFailure(error)) { + const signerFailure = this.#classifySignerFailure(error); + if (signerFailure) { return { remainingOrderIds: requests.map((request) => request.o), cancelledOrderIds: [], responseComplete: false, - signerFailure: error, + signerFailure, }; } const statuses = getCancelStatusesFromError(error, requests.length); @@ -9486,7 +9499,7 @@ 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, }); @@ -9612,7 +9625,7 @@ export class HyperLiquidProvider implements PerpsProvider { error: statusError === undefined ? PERPS_ERROR_CODES.BATCH_CANCEL_FAILED - : this.#mapError(new Error(statusError)).message, + : this.#mapStatusError(statusError).message, }), }; }); @@ -10601,16 +10614,12 @@ export class HyperLiquidProvider implements PerpsProvider { exchangeClient, cancelRequests, ); - if (oldCancellation.signerFailure !== undefined) { + if (oldCancellation.signerFailure) { // Nothing was cancelled, so the old protection is still in place. - return createErrorResult( - this.#handleSignerFailure( - oldCancellation.signerFailure, - 'updatePositionTPSL', - { symbol }, - ) ?? new Error(PERPS_ERROR_CODES.KEYRING_LOCKED), - { success: false }, - ); + this.#logRetryableSignerFailure('updatePositionTPSL', { symbol }); + return createErrorResult(oldCancellation.signerFailure, { + success: false, + }); } // Clearing has no replacement batch to preserve. A partial cancellation @@ -14046,6 +14055,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', @@ -14153,6 +14168,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', @@ -14505,6 +14528,10 @@ export class HyperLiquidProvider implements PerpsProvider { this.#builderFeeCheckCache.has(this.#getCacheKey(network, userAddress)); return { ready }; } catch (error) { + // The signer locked while a step ran, so that step failed for it. + if (!this.#walletService.isMainAccountSignerReady()) { + return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; + } const caughtError = ensureError( error, 'HyperLiquidProvider.prepareTradingWallet', @@ -15649,12 +15676,9 @@ export class HyperLiquidProvider implements PerpsProvider { '[ensureReferralSet] Global in-flight, waiting...', { network }, ); + // The other attempt may end without caching a result (the signer could + // not sign); the re-check below, under our own lock, uses one it cached. await inFlightPromise; - // The other attempt may have ended without caching a result (a locked - // keyring or an unavailable agent), so make our own attempt then. - if (PerpsSigningCache.getReferral(network, userAddress)?.attempted) { - return; - } } // Set global in-flight lock @@ -15727,22 +15751,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)) { - this.#deps.debugLogger.log( - '[ensureReferralSet] Keyring locked, will retry later', - ); - completeInFlight(); - return; - } - - if ( - isAgentSignerUnavailableError(error) || - this.#evictRejectedAgent(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] Agent signer unavailable, will retry later', + '[ensureReferralSet] Signer unavailable, will retry later', ); this.#referralSetupNeedsRetry = true; completeInFlight(); diff --git a/packages/perps-controller/src/providers/LighterProvider.ts b/packages/perps-controller/src/providers/LighterProvider.ts index e9ee948e90c..4a9c8dfc5dc 100644 --- a/packages/perps-controller/src/providers/LighterProvider.ts +++ b/packages/perps-controller/src/providers/LighterProvider.ts @@ -979,7 +979,8 @@ const deriveLighterExecutionPrice = ( : referencePrice * (1 - slippageFraction); const LIGHTER_NOT_SUPPORTED_ERROR = 'Lighter operation not yet supported'; -const LIGHTER_SIGNER_UNAVAILABLE_ERROR = 'Lighter signer bridge not configured'; +export const LIGHTER_SIGNER_UNAVAILABLE_ERROR = + 'Lighter signer bridge not configured'; const LIGHTER_MAINNET_EXPLORER_URL = 'https://scan.lighter.xyz'; const LIGHTER_TESTNET_EXPLORER_URL = 'https://testnet.zklighter.elliot.ai'; @@ -1007,6 +1008,9 @@ class LighterSessionCancelledError extends Error { // EIP-1193 `userRejectedRequest` error code. const USER_REJECTED_REQUEST_CODE = 4001; +// How wallets word a declined signature when they set no code. +const USER_REJECTED_MESSAGE_PATTERN = /user (rejected|denied)/iu; + /** * Whether preparing the wallet stopped in a way the order path retries: the * user declined the venue-key signature, or the wallet has no Lighter account @@ -1021,7 +1025,7 @@ const isRetryablePreparationStop = (error: unknown): boolean => (current) => current instanceof LighterAccountNotFoundError || (current as { code?: unknown }).code === USER_REJECTED_REQUEST_CODE || - /user (rejected|denied)/iu.test(current.message), + USER_REJECTED_MESSAGE_PATTERN.test(current.message), ); /** @@ -1316,9 +1320,9 @@ export class LighterProvider implements PerpsProvider { * `ready: false`: with `KEYRING_LOCKED` whenever the main-account signer is * not ready (even with a registered venue key), without an error when the * order path will ask again (the user declined the signature, or the - * wallet has no Lighter account yet), with the unlogged cancellation when - * the provider disconnected or the wallet switched accounts meanwhile, and - * with the logged error when registration failed. + * wallet has no Lighter account yet), with `PROVIDER_LIFECYCLE_STALE` + * (unlogged) when the provider disconnected or the wallet switched accounts + * meanwhile, and with the logged error when registration failed. */ async prepareTradingWallet(): Promise { if (!this.#walletService.isMainAccountSignerReady()) { @@ -1340,7 +1344,11 @@ export class LighterProvider implements PerpsProvider { } return { ready: true }; } catch (caughtError) { - if (isKeyringLockedError(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 (isRetryablePreparationStop(caughtError)) { @@ -1351,8 +1359,12 @@ export class LighterProvider implements PerpsProvider { if (caughtError instanceof LighterSessionCancelledError) { this.#deps.debugLogger.log( '[prepareTradingWallet] Session changed during preparation', + { reason: caughtError.message }, ); - return { ready: false, error: caughtError.message }; + return { + ready: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + }; } const error = ensureError( caughtError, diff --git a/packages/perps-controller/src/services/agentSigner.ts b/packages/perps-controller/src/services/agentSigner.ts index 4abaf61edd0..83d95dcb1e9 100644 --- a/packages/perps-controller/src/services/agentSigner.ts +++ b/packages/perps-controller/src/services/agentSigner.ts @@ -30,6 +30,16 @@ export function isAgentSignerUnavailableError(error: unknown): boolean { ); } +/** + * 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 @@ -53,7 +63,7 @@ export class AgentBindings { * @param agentSigner - The agent, or null to pin the main account. */ set(account: PerpsAgentAccount, agentSigner: PerpsAgentSigner | null): void { - this.#bindings.set(this.#getKey(account), agentSigner); + this.#bindings.set(getAgentAccountKey(account), agentSigner); } /** Forget every binding, so `getAgentSigner` answers again. */ @@ -70,7 +80,7 @@ export class AgentBindings { * @param agentAddress - The rejected agent's address. */ release(account: PerpsAgentAccount, agentAddress: string): void { - const key = this.#getKey(account); + const key = getAgentAccountKey(account); const bound = this.#bindings.get(key); if (bound && bound.address.toLowerCase() === agentAddress.toLowerCase()) { this.#bindings.delete(key); @@ -87,14 +97,10 @@ export class AgentBindings { readonly resolve = async ( account: PerpsAgentAccount, ): Promise => { - const key = this.#getKey(account); + const key = getAgentAccountKey(account); if (this.#bindings.has(key)) { return this.#bindings.get(key) ?? null; } return this.#getAgentSigner ? await this.#getAgentSigner(account) : null; }; - - #getKey(account: PerpsAgentAccount): string { - return `${account.isTestnet ? 'testnet' : 'mainnet'}:${account.mainAddress.toLowerCase()}`; - } } diff --git a/packages/perps-controller/tests/helpers/agentFixtures.ts b/packages/perps-controller/tests/helpers/agentFixtures.ts index 1fbf0d2f6c5..ff615f222ea 100644 --- a/packages/perps-controller/tests/helpers/agentFixtures.ts +++ b/packages/perps-controller/tests/helpers/agentFixtures.ts @@ -25,6 +25,9 @@ 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. @@ -54,6 +57,30 @@ export const USER_SIGNED_PAYLOAD: PerpsTypedDataPayload = { }, }; +/** + * A builder fee approval as the HyperLiquid SDK builds it: 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: '0.1%', + builder: ZERO_ADDRESS, + nonce: 1, + }, +}; + /** An L1 action as the HyperLiquid SDK builds it (the Exchange domain). */ export const L1_PAYLOAD: PerpsTypedDataPayload = { domain: { diff --git a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts index 8feafe8da51..9fef62bb9a6 100644 --- a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts @@ -9,10 +9,10 @@ import { PerpsController, } from '../../src/PerpsController.js'; import { PERPS_ERROR_CODES } from '../../src/perpsErrorCodes.js'; -import { HyperLiquidClientService } from '../../src/services/HyperLiquidClientService.js'; import type { HyperLiquidWalletParams } from '../../src/services/HyperLiquidClientService.js'; import { TradingReadinessCache } from '../../src/services/TradingReadinessCache.js'; import type { + PerpsAccountSigner, PerpsAgentAccount, PerpsAgentSigner, PerpsTypedDataPayload, @@ -20,51 +20,157 @@ import type { import { AGENT_ADDRESS, AGENT_SIGNATURE, + APPROVE_BUILDER_FEE_PAYLOAD, L1_PAYLOAD, MAIN_SIGNATURE, OTHER_AGENT_ADDRESS, + OTHER_AGENT_SIGNATURE, USER_SIGNED_PAYLOAD, } from '../helpers/agentFixtures.js'; -import { - createMockExchangeClient, - createMockInfoClient, -} from '../helpers/providerMocks.js'; +import { createMockInfoClient } from '../helpers/providerMocks.js'; import { createMockEvmAccount, createMockInfrastructure, createMockMessenger, } from '../helpers/serviceMocks.js'; -// The controller builds a real HyperLiquidProvider and wallet service; only -// the SDK and its client service are mocked. -jest.mock('@nktkas/hyperliquid', () => ({})); -jest.mock('../../src/services/HyperLiquidClientService', () => ({ - HyperLiquidClientService: jest.fn(), - WebSocketConnectionState: jest.requireActual< - typeof import('../../src/types/index.js') - >('../../src/types/index').WebSocketConnectionState, -})); - -const MockedClientService = HyperLiquidClientService as jest.MockedClass< - typeof HyperLiquidClientService ->; - const MAIN_ADDRESS = createMockEvmAccount().address; -// The second agent's signature, told apart from the first agent's. -const OTHER_AGENT_SIGNATURE = `0x${'0b'.repeat(65)}` as const; // The controller starts on mainnet (default state). const MAINNET_ACCOUNT: PerpsAgentAccount = { mainAddress: MAIN_ADDRESS, isTestnet: false, }; +// 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; + +type VenueWrite = { + write: string; + params: unknown; + signer: Hex | undefined; +}; -type ClientServiceMock = { - initialize: jest.Mock, [HyperLiquidWalletParams]>; - isTestnetMode: () => boolean; +// 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 this.#wallet.signTypedData(payload)); + mockVenue.writes.push({ write, params, signer }); + if (signer && mockVenue.revokedAgents.has(signer)) { + throw new Error(`User or API Wallet ${signer} does not exist.`); + } + } +} + +// 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', () => ({ + 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 clientServices: ClientServiceMock[]; let accountSigner: { signTypedData: jest.Mock; signPersonalMessage: jest.Mock; @@ -74,36 +180,23 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => let onAgentRejected: jest.Mock; let infrastructure: ReturnType; let loggerError: jest.SpyInstance; - let exchangeClient: ReturnType; - let infoClient: ReturnType; beforeEach(() => { TradingReadinessCache.clearAll(); - clientServices = []; - exchangeClient = createMockExchangeClient(); - infoClient = createMockInfoClient({ - twapHistory: jest.fn().mockResolvedValue([]), - userTwapSliceFills: jest.fn().mockResolvedValue([]), - }); - MockedClientService.mockImplementation((_deps, options) => { - const isTestnet = options?.isTestnet ?? false; - const clientService = { - initialize: jest - .fn, [HyperLiquidWalletParams]>() - .mockResolvedValue(undefined), - isInitialized: jest.fn().mockReturnValue(true), - isTestnetMode: (): boolean => isTestnet, - ensureInitialized: jest.fn(), - getInfoClient: jest.fn(() => infoClient), - getExchangeClient: jest.fn(() => exchangeClient), - getSubscriptionClient: jest.fn(), - setOnTerminateCallback: jest.fn(), - setOnReconnectCallback: jest.fn(), - disconnect: jest.fn().mockResolvedValue(undefined), - }; - clientServices.push(clientService); - return clientService as unknown as HyperLiquidClientService; + // 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({ + referral: jest.fn().mockResolvedValue({ + referredBy: { code: REFERRAL_CONFIG.MainnetCode }, + referrerState: { + stage: 'ready', + data: { code: REFERRAL_CONFIG.MainnetCode }, + }, + }), }); + mockVenue.networks = []; + mockVenue.writes = []; + mockVenue.revokedAgents.clear(); accountSigner = { signTypedData: jest.fn().mockResolvedValue(MAIN_SIGNATURE), signPersonalMessage: jest.fn(), @@ -115,27 +208,31 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => loggerError = jest.spyOn(infrastructure.logger, 'error'); }); - afterEach(() => { - jest.clearAllMocks(); - }); - /** - * A messenger whose remote feature flags are empty, so the controller - * reads its defaults instead of logging a missing flag state. + * A messenger that answers the host actions these flows call: an empty + * remote feature flag state, so the controller reads its defaults, the + * selected account, and the network the fee discount looks up. * * @returns The messenger. */ function createMessenger(): ReturnType { - // The default mock answers by action type alone. - const defaultCall = createMockMessenger().call.getMockImplementation(); + const answers: Record = { + 'RemoteFeatureFlagController:getState': { + remoteFeatureFlags: {}, + cacheTimestamp: 0, + }, + 'AccountTreeController:getAccountsFromSelectedAccountGroup': [ + createMockEvmAccount(), + ], + 'NetworkController:getState': { selectedNetworkClientId: 'mainnet' }, + 'NetworkController:getNetworkClientById': { + configuration: { chainId: '0x1' }, + }, + }; return createMockMessenger({ call: jest .fn() - .mockImplementation((action: string): unknown => - action === 'RemoteFeatureFlagController:getState' - ? { remoteFeatureFlags: {}, cacheTimestamp: 0 } - : defaultCall?.(action as never), - ), + .mockImplementation((action: string): unknown => answers[action]), }); } @@ -143,9 +240,12 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => * Build a controller whose host signs with `accountSigner` and resolves * agents with `getAgentSigner`. * + * @param signer - The host's account signer. * @returns The controller. */ - function createController(): PerpsController { + function createController( + signer: PerpsAccountSigner = accountSigner, + ): PerpsController { return new PerpsController({ messenger: createMessenger(), state: getDefaultPerpsControllerState(), @@ -154,7 +254,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => hyperliquid: { getAgentSigner, onAgentRejected }, }, }, - infrastructure: { ...infrastructure, accountSigner }, + infrastructure: { ...infrastructure, accountSigner: signer }, deferEligibilityCheck: true, }); } @@ -177,103 +277,68 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => } /** - * Make the venue sign each cancel with the SDK wallet, then reject it as - * an unknown wallet: what HyperLiquid answers for a revoked or expired - * agent. - * - * @param wallet - The SDK wallet the provider signs with. - * @param rejectedAddress - The address the venue rejects. - */ - function rejectCancelsAs( - wallet: HyperLiquidWalletParams, - rejectedAddress: Hex, - ): void { - exchangeClient.cancel.mockImplementation(async () => { - await wallet.signTypedData(L1_PAYLOAD); - throw new Error(`User or API Wallet ${rejectedAddress} does not exist.`); - }); - } - - /** - * The wallet adapter the latest initialized SDK clients sign with. - * - * @returns The wallet adapter. - */ - function getLatestSdkWallet(): HyperLiquidWalletParams { - const initialized = clientServices.filter( - (clientService) => clientService.initialize.mock.calls.length > 0, - ); - const latest = initialized[initialized.length - 1]; - if (!latest) { - throw new Error('The provider never initialized its SDK clients'); - } - const [[wallet]] = latest.initialize.mock.calls; - return wallet; - } - - /** - * Make the active HyperLiquid provider initialize its SDK clients, and - * return the wallet adapter it handed to them. + * Place a BTC market buy through the controller. * * @param controller - The initialized controller. - * @returns The wallet adapter the SDK signs with. + * @returns Whether the venue accepted it. */ - async function getSdkWallet( - controller: PerpsController, - ): Promise { - await controller.getTwapOrders(); - return getLatestSdkWallet(); + async function placeOrder(controller: PerpsController): Promise { + const result = await controller.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }); + return result.success === true; } /** - * An SDK write that signs its action with the provider's wallet adapter, - * as the SDK does, and succeeds. + * The writes the venue saw, with who signed each. * - * @param payload - The typed data the SDK builds for the action. - * @returns The write's mock implementation. + * @returns The write names and signers, in order. */ - function signThroughSdkWallet( - payload: PerpsTypedDataPayload, - ): () => Promise<{ status: 'ok' }> { - return async () => { - await getLatestSdkWallet().signTypedData(payload); - return { status: 'ok' }; - }; + 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 = createController(); await controller.init(); - const wallet = await getSdkWallet(controller); - const l1Signature: Hex = await wallet.signTypedData(L1_PAYLOAD); - const userSignature: Hex = await wallet.signTypedData(USER_SIGNED_PAYLOAD); + const placed = await placeOrder(controller); - expect(l1Signature).toBe(AGENT_SIGNATURE); - expect(userSignature).toBe(MAIN_SIGNATURE); + expect(placed).toBe(true); + expect(signedWrites()).toStrictEqual([ + ['approveBuilderFee', MAIN_ADDRESS], + ['order', AGENT_ADDRESS], + ]); expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); expect(agentSigner.signTypedData.mock.calls).toStrictEqual([[L1_PAYLOAD]]); expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ - [MAIN_ADDRESS, USER_SIGNED_PAYLOAD], + [MAIN_ADDRESS, APPROVE_BUILDER_FEE_PAYLOAD], ]); + expect(loggerError).not.toHaveBeenCalled(); }); it('pins L1 actions to the main account with setAgentSigner(null) until clearAgentSigners', async () => { const controller = createController(); await controller.init(); - const wallet = await getSdkWallet(controller); controller.setAgentSigner(MAINNET_ACCOUNT, null); - const pinnedSignature = await wallet.signTypedData(L1_PAYLOAD); + const pinnedPlaced = await placeOrder(controller); controller.clearAgentSigners(); - const clearedSignature = await wallet.signTypedData(L1_PAYLOAD); + const clearedPlaced = await placeOrder(controller); - expect(pinnedSignature).toBe(MAIN_SIGNATURE); - expect(clearedSignature).toBe(AGENT_SIGNATURE); - expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ - [MAIN_ADDRESS, L1_PAYLOAD], + expect([pinnedPlaced, clearedPlaced]).toStrictEqual([true, true]); + expect(signedWrites()).toStrictEqual([ + ['order', MAIN_ADDRESS], + ['order', AGENT_ADDRESS], ]); - expect(getAgentSigner).toHaveBeenCalledTimes(1); + expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); + expect(loggerError).not.toHaveBeenCalled(); }); it('keeps a setAgentSigner binding when the HyperLiquid provider is re-created', async () => { @@ -283,19 +348,21 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => await controller.init(); controller.setAgentSigner(MAINNET_ACCOUNT, boundAgent); + await placeOrder(controller); await controller.toggleTestnet(); await controller.toggleTestnet(); - const wallet = await getSdkWallet(controller); - const signature = await wallet.signTypedData(L1_PAYLOAD); - - // The initial, the testnet and the mainnet provider each own a client. - expect( - clientServices.map(({ isTestnetMode }) => isTestnetMode()), - ).toStrictEqual([false, true, false]); - expect(signature).toBe(OTHER_AGENT_SIGNATURE); - expect(boundAgent.signTypedData.mock.calls).toStrictEqual([[L1_PAYLOAD]]); + await placeOrder(controller); + + // The original and the re-created mainnet provider each built SDK + // clients. + expect(mockVenue.networks).toStrictEqual(['mainnet', 'mainnet']); + expect(signedWrites()).toStrictEqual([ + ['order', OTHER_AGENT_ADDRESS], + ['order', OTHER_AGENT_ADDRESS], + ]); expect(getAgentSigner).not.toHaveBeenCalled(); expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); }); it('honors a setAgentSigner binding made before init', async () => { @@ -305,12 +372,12 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => controller.setAgentSigner(MAINNET_ACCOUNT, boundAgent); await controller.init(); - const wallet = await getSdkWallet(controller); - const signature = await wallet.signTypedData(L1_PAYLOAD); + const placed = await placeOrder(controller); - expect(signature).toBe(OTHER_AGENT_SIGNATURE); - expect(boundAgent.signTypedData.mock.calls).toStrictEqual([[L1_PAYLOAD]]); + expect(placed).toBe(true); + expect(signedWrites()).toStrictEqual([['order', OTHER_AGENT_ADDRESS]]); expect(getAgentSigner).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); }); it('signs with the agent bound through setAgentSigner after another was resolved', async () => { @@ -320,25 +387,20 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => ); const controller = createController(); await controller.init(); - const wallet = await getSdkWallet(controller); - const resolvedSignature = await wallet.signTypedData(L1_PAYLOAD); + await placeOrder(controller); controller.setAgentSigner(MAINNET_ACCOUNT, reboundAgent); - const reboundSignature = await wallet.signTypedData(L1_PAYLOAD); + await placeOrder(controller); controller.setAgentSigner(MAINNET_ACCOUNT, null); - const pinnedSignature = await wallet.signTypedData(L1_PAYLOAD); + await placeOrder(controller); - expect([ - resolvedSignature, - reboundSignature, - pinnedSignature, - ]).toStrictEqual([AGENT_SIGNATURE, OTHER_AGENT_SIGNATURE, MAIN_SIGNATURE]); - expect(agentSigner.signTypedData.mock.calls).toStrictEqual([[L1_PAYLOAD]]); - expect(reboundAgent.signTypedData.mock.calls).toStrictEqual([[L1_PAYLOAD]]); - expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ - [MAIN_ADDRESS, L1_PAYLOAD], + expect(signedWrites()).toStrictEqual([ + ['order', AGENT_ADDRESS], + ['order', OTHER_AGENT_ADDRESS], + ['order', MAIN_ADDRESS], ]); - expect(getAgentSigner).toHaveBeenCalledTimes(1); + expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); + expect(loggerError).not.toHaveBeenCalled(); }); it('tells the host about an agent the venue rejects and asks for another', async () => { @@ -349,27 +411,29 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => getAgentSigner .mockResolvedValueOnce(agentSigner) .mockResolvedValueOnce(replacementAgent); + mockVenue.revokedAgents.add(AGENT_ADDRESS); const controller = createController(); await controller.init(); - const wallet = await getSdkWallet(controller); - rejectCancelsAs(wallet, agentSigner.address); - const result = await controller.cancelOrder({ + const cancelled = await controller.cancelOrder({ orderId: '1', symbol: 'BTC', }); - const nextSignature = await wallet.signTypedData(L1_PAYLOAD); + const placed = await placeOrder(controller); - expect(result).toStrictEqual( - expect.objectContaining({ - success: false, - error: PERPS_ERROR_CODES.KEYRING_LOCKED, - }), - ); + expect(cancelled).toStrictEqual({ + success: false, + orderId: '1', + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); expect(onAgentRejected.mock.calls).toStrictEqual([ - [MAINNET_ACCOUNT, agentSigner.address], + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + expect(placed).toBe(true); + expect(signedWrites()).toStrictEqual([ + ['cancel', AGENT_ADDRESS], + ['order', OTHER_AGENT_ADDRESS], ]); - expect(nextSignature).toBe(OTHER_AGENT_SIGNATURE); expect(getAgentSigner.mock.calls).toStrictEqual([ [MAINNET_ACCOUNT], [MAINNET_ACCOUNT], @@ -380,47 +444,39 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => 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 = createController(); await controller.init(); controller.setAgentSigner(MAINNET_ACCOUNT, boundAgent); - const wallet = await getSdkWallet(controller); - rejectCancelsAs(wallet, boundAgent.address); - const result = await controller.cancelOrder({ + const cancelled = await controller.cancelOrder({ orderId: '1', symbol: 'BTC', }); - const nextSignature = await wallet.signTypedData(L1_PAYLOAD); + const placed = await placeOrder(controller); - expect(result).toStrictEqual( - expect.objectContaining({ - success: false, - error: PERPS_ERROR_CODES.KEYRING_LOCKED, - }), - ); + expect(cancelled).toStrictEqual({ + success: false, + orderId: '1', + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); expect(onAgentRejected.mock.calls).toStrictEqual([ - [MAINNET_ACCOUNT, boundAgent.address], + [MAINNET_ACCOUNT, OTHER_AGENT_ADDRESS], ]); // The binding is gone, so the host's getAgentSigner answers. - expect(nextSignature).toBe(AGENT_SIGNATURE); + expect(placed).toBe(true); + expect(signedWrites()).toStrictEqual([ + ['cancel', OTHER_AGENT_ADDRESS], + ['order', AGENT_ADDRESS], + ]); expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); expect(loggerError).not.toHaveBeenCalled(); }); it('prepares nothing and reports KEYRING_LOCKED while the account signer is not ready', async () => { - const controller = new PerpsController({ - messenger: createMessenger(), - state: getDefaultPerpsControllerState(), - clientConfig: { - providerCredentials: { - hyperliquid: { getAgentSigner, onAgentRejected }, - }, - }, - infrastructure: { - ...infrastructure, - accountSigner: { ...accountSigner, isReady: (): boolean => false }, - }, - deferEligibilityCheck: true, + const controller = createController({ + ...accountSigner, + isReady: (): boolean => false, }); await controller.init(); @@ -430,57 +486,51 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); - expect( - clientServices.flatMap(({ initialize }) => initialize.mock.calls), - ).toStrictEqual([]); - expect( - Object.values(exchangeClient).filter( - (write) => write.mock.calls.length > 0, - ), - ).toStrictEqual([]); + expect(mockVenue.networks).toStrictEqual([]); + expect(mockVenue.writes).toStrictEqual([]); expect(accountSigner.signTypedData).not.toHaveBeenCalled(); expect(getAgentSigner).not.toHaveBeenCalled(); expect(loggerError).not.toHaveBeenCalled(); }); it('prepares the migration and builder fee on the main account and the referral on the agent', async () => { - // A legacy account that has not approved the builder fee yet. - infoClient.userAbstraction.mockResolvedValue('dexAbstraction'); - infoClient.maxBuilderFee.mockResolvedValueOnce(0); - exchangeClient.userSetAbstraction.mockImplementation( - signThroughSdkWallet(USER_SIGNED_PAYLOAD), - ); - exchangeClient.approveBuilderFee.mockImplementation( - signThroughSdkWallet(USER_SIGNED_PAYLOAD), - ); - exchangeClient.setReferrer.mockImplementation( - signThroughSdkWallet(L1_PAYLOAD), - ); + // 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 = createController(); await controller.init(); const result = await controller.prepareTradingWallet(); expect(result).toStrictEqual({ ready: true }); - expect(exchangeClient.userSetAbstraction.mock.calls).toStrictEqual([ - [{ user: MAIN_ADDRESS, abstraction: 'unifiedAccount' }], - ]); - expect(exchangeClient.approveBuilderFee.mock.calls).toStrictEqual([ - [ - { + // 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, }, - ], - ]); - expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ - [{ code: REFERRAL_CONFIG.MainnetCode }], + signer: MAIN_ADDRESS, + }, ]); - // The user-signed migration and approval stay on the main account; the - // referral is an L1 action, so the agent signs it. expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ [MAIN_ADDRESS, USER_SIGNED_PAYLOAD], - [MAIN_ADDRESS, USER_SIGNED_PAYLOAD], + [MAIN_ADDRESS, APPROVE_BUILDER_FEE_PAYLOAD], ]); expect(agentSigner.signTypedData.mock.calls).toStrictEqual([[L1_PAYLOAD]]); expect(getAgentSigner.mock.calls).toStrictEqual([[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 2efaa459d75..0c2ab227a84 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -6,6 +6,13 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; + import { AGENT_ADDRESS, OTHER_AGENT_ADDRESS, @@ -35,9 +42,14 @@ import { getDefaultPerpsControllerState, InitializationState, } from '../../src/PerpsController.js'; -import type { PerpsControllerState } from '../../src/PerpsController.js'; +import type { + PerpsControllerMessenger, + 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, @@ -925,9 +937,6 @@ describe('PerpsController', () => { signTypedData: jest.fn(), signPersonalMessage: jest.fn(), }; - const MockLighterConstructor = jest.fn(() => - createMockHyperLiquidProvider(), - ); controller = new TestablePerpsController({ messenger: createMockMessenger(), state: getDefaultPerpsControllerState(), @@ -935,11 +944,7 @@ describe('PerpsController', () => { }); await controller.init(); - controller.testRegisterLighterProvider( - MockLighterConstructor as unknown as new ( - opts: Record, - ) => PerpsProvider, - ); + registerMockLighterProvider(controller); const withAccountSigner = expect.objectContaining({ platformDependencies: expect.objectContaining({ accountSigner }), @@ -947,7 +952,9 @@ describe('PerpsController', () => { expect( HyperLiquidProvider as jest.MockedClass, ).toHaveBeenCalledWith(withAccountSigner); - expect(MockLighterConstructor).toHaveBeenCalledWith(withAccountSigner); + expect( + LighterProvider as jest.MockedClass, + ).toHaveBeenCalledWith(withAccountSigner); }); const agentSigner = { @@ -974,6 +981,22 @@ describe('PerpsController', () => { 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, + ); + } + /** * Build a controller whose host resolves agents with `getAgentSigner`. * @@ -1027,7 +1050,15 @@ describe('PerpsController', () => { }), ).toBeNull(); expect(await resolve({ ...account, isTestnet: true })).toBeNull(); - expect(getAgentSigner).toHaveBeenCalledTimes(2); + expect(getAgentSigner.mock.calls).toStrictEqual([ + [ + { + ...account, + mainAddress: '0x9999999999999999999999999999999999999999', + }, + ], + [{ ...account, isTestnet: true }], + ]); }); it('matches a binding whatever the main address casing', async () => { @@ -1095,24 +1126,97 @@ describe('PerpsController', () => { expect(getAgentSigner.mock.calls).toStrictEqual([[account]]); }); - it('exposes the agent and preparation actions through the messenger at init', async () => { - const messenger = createMockMessenger(); + it('runs the agent and preparation actions called through the messenger after init', async () => { + const getAgentSigner = jest.fn().mockResolvedValue(agentSigner); + const rootMessenger = new Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents + >({ namespace: MOCK_ANY_NAMESPACE }); + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + () => ({ remoteFeatureFlags: {}, cacheTimestamp: 0 }), + ); + const messenger: PerpsControllerMessenger = new Messenger({ + namespace: 'PerpsController', + parent: rootMessenger, + }); + 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(); - expect(messenger.registerMethodActionHandlers).toHaveBeenCalledWith( - controller, - expect.arrayContaining([ - 'setAgentSigner', - 'clearAgentSigners', - 'prepareTradingWallet', - ]), + rootMessenger.call('PerpsController:setAgentSigner', account, null); + const pinned = await resolve(account); + rootMessenger.call('PerpsController:clearAgentSigners'); + const cleared = await resolve(account); + const readiness = await rootMessenger.call( + 'PerpsController:prepareTradingWallet', ); + + expect(pinned).toBeNull(); + expect(cleared).toBe(agentSigner); + expect(getAgentSigner.mock.calls).toStrictEqual([[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 () => { + const RealAggregatedPerpsProvider = + AggregatedPerpsProviderModule.AggregatedPerpsProvider; + const aggregatedConstructor = jest + .spyOn(AggregatedPerpsProviderModule, 'AggregatedPerpsProvider') + .mockImplementation( + (config) => new RealAggregatedPerpsProvider(config), + ); + mockProvider.clearAgentSigners = jest.fn(); + controller = new TestablePerpsController({ + messenger: createMockMessenger(), + state: { + ...getDefaultPerpsControllerState(), + activeProvider: 'aggregated', + isTestnet: true, + }, + infrastructure: mockInfrastructure, + }); + await controller.init(); + registerMockLighterProvider(controller); + const providers = controller.testGetProviders(); + + controller.setAgentSigner(account, agentSigner); + controller.clearAgentSigners(); + + expect([...providers.keys()]).toStrictEqual(['hyperliquid', 'lighter']); + expect(providers.get('lighter')).not.toHaveProperty('clearAgentSigners'); + expect(mockProvider.clearAgentSigners.mock.calls).toStrictEqual([[], []]); + expect(aggregatedConstructor.mock.calls).toStrictEqual([ + [ + { + providers, + defaultProvider: 'hyperliquid', + infrastructure: mockInfrastructure, + isTestnet: true, + }, + ], + ]); }); it("returns the active provider's trading wallet readiness", async () => { @@ -1128,7 +1232,7 @@ describe('PerpsController', () => { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); - expect(mockProvider.prepareTradingWallet).toHaveBeenCalledTimes(1); + expect(mockProvider.prepareTradingWallet.mock.calls).toStrictEqual([[]]); }); it('reports a trading wallet ready when the provider has no deferred setup', async () => { diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts index 03931dc8f8f..868c547af35 100644 --- a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -1,4 +1,5 @@ import { CandlePeriod } from '../../../src/constants/chartConfig.js'; +import { PROVIDER_CONFIG } from '../../../src/constants/perpsConfig.js'; import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; import { AggregatedPerpsProvider } from '../../../src/providers/AggregatedPerpsProvider.js'; import type { @@ -1163,14 +1164,53 @@ describe('AggregatedPerpsProvider', () => { await networkProvider.prepareTradingWallet(); expect(mockInfrastructure.logger.error).toHaveBeenCalledWith( - expect.objectContaining({ message: 'provider crashed' }), - expect.objectContaining({ + new Error('provider crashed'), + { tags: { feature: 'perps', provider: 'hyperliquid', network }, - }), + context: { + name: 'AggregatedPerpsProvider', + data: { + method: 'prepareTradingWallet', + providerId: 'hyperliquid', + }, + }, + }, ); }, ); + 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, + }); + Object.assign(mockLighterProvider, { + prepareTradingWallet: jest + .fn() + .mockRejectedValue(new Error('provider crashed')), + }); + + await networkProvider.prepareTradingWallet(); + + expect(mockInfrastructure.logger.error).toHaveBeenCalledWith( + new Error('provider crashed'), + { + tags: { feature: 'perps', 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 firstPreparation = createDeferred<{ ready: boolean }>(); const prepareLighter = jest.fn().mockResolvedValue({ ready: true }); 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 index f7b51a4352b..2e604e4ce7e 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts @@ -1,9 +1,16 @@ import type { Hex } from '@metamask/utils'; +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '../../../src/constants/eventNames.js'; import { BUILDER_FEE_CONFIG } from '../../../src/constants/hyperLiquidConfig.js'; import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider.js'; -import { AgentBindings } from '../../../src/services/agentSigner.js'; +import { + AgentBindings, + AgentSignerUnavailableError, +} 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'; @@ -11,6 +18,7 @@ import { PerpsSigningCache, TradingReadinessCache, } from '../../../src/services/TradingReadinessCache.js'; +import { PerpsAnalyticsEvent } from '../../../src/types/index.js'; import type { HyperLiquidCredentials, PerpsAgentAccount, @@ -23,6 +31,8 @@ import { AGENT_SIGNATURE, L1_PAYLOAD, MAIN_SIGNATURE, + OTHER_AGENT_ADDRESS, + OTHER_AGENT_SIGNATURE, USER_SIGNED_PAYLOAD, createFrontendOpenOrder, } from '../../helpers/agentFixtures.js'; @@ -326,8 +336,10 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(keyringCalls(call)).toStrictEqual([]); expect(loggerError).not.toHaveBeenCalled(); expect(trackPerpsEvent).not.toHaveBeenCalledWith( - 'Perp Account Setup', - expect.objectContaining({ status: 'failed' }), + PerpsAnalyticsEvent.AccountSetup, + expect.objectContaining({ + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, + }), ); }); @@ -346,17 +358,93 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () currentPrice: 50000, }); - expect(order).toStrictEqual( - expect.objectContaining({ - success: false, - error: PERPS_ERROR_CODES.KEYRING_LOCKED, - }), - ); + expect(order).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); expect(accountSigner.signTypedData).not.toHaveBeenCalled(); expect(loggerError).not.toHaveBeenCalled(); expect(referralAttempted()).toBe(false); }); + 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.initialize.mock.calls[0][0].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: ACCOUNT_ADDRESS, + assetId, + }); + + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(withdraw3).toHaveBeenCalledTimes(1); + 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).toHaveBeenCalledTimes(1); + expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + }); + }); + describe('prepareTradingWallet', () => { it('runs the deferred migration, builder fee and referral setup and reports ready', async () => { const { @@ -424,11 +512,15 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); }); - it('makes its own referral attempt when another provider ended without a result', async () => { - const { accountSignerProvider, exchangeClient } = - createAccountSignerProvider({ abstraction: 'unifiedAccount' }); - // Another provider holds the referral lock and ends without caching a - // result. + /** + * Have another provider hold the referral lock until released. + * + * @returns Resolves once this provider waits on the lock, and the release. + */ + function holdReferralLock(): { + waiting: Promise; + release: () => void; + } { const otherAttempt = createDeferred(); const waiting = createDeferred(); const isInFlight = PerpsSigningCache.isInFlight.bind(PerpsSigningCache); @@ -441,12 +533,19 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () waiting.resolve(); return otherAttempt.promise; }); + return { waiting: waiting.promise, release: otherAttempt.resolve }; + } + + it('makes its own referral attempt when another provider ended without a result', async () => { + const { accountSignerProvider, exchangeClient } = + createAccountSignerProvider({ abstraction: 'unifiedAccount' }); + const lock = holdReferralLock(); const preparing = accountSignerProvider.prepareTradingWallet(); - await waiting.promise; + await lock.waiting; const referrerCallsWhileWaiting = exchangeClient.setReferrer.mock.calls.length; - otherAttempt.resolve(); + lock.release(); const result = await preparing; expect(referrerCallsWhileWaiting).toBe(0); @@ -460,6 +559,24 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () 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(); + + const preparing = accountSignerProvider.prepareTradingWallet(); + await lock.waiting; + PerpsSigningCache.setReferral('mainnet', ACCOUNT_ADDRESS, { + attempted: true, + success: true, + }); + lock.release(); + const result = await preparing; + + expect(exchangeClient.setReferrer).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ ready: true }); + }); + it('signs nothing more when called again', async () => { const { accountSignerProvider, accountSigner } = createAccountSignerProvider({ @@ -471,11 +588,13 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () const result = await accountSignerProvider.prepareTradingWallet(); expect(result).toStrictEqual({ ready: true }); - expect( - TradingReadinessCache.get('mainnet', ACCOUNT_ADDRESS), - ).toStrictEqual( - expect.objectContaining({ attempted: true, enabled: true }), - ); + const migration = TradingReadinessCache.get('mainnet', ACCOUNT_ADDRESS); + expect(migration).toStrictEqual({ + attempted: true, + enabled: true, + reason: undefined, + timestamp: migration?.timestamp, + }); // Migration, then referral; nothing on the second call. expect(firstSignatures).toStrictEqual([ [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], @@ -505,11 +624,13 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(result).toStrictEqual({ ready: true }); expect(secondResult).toStrictEqual({ ready: true }); - expect( - TradingReadinessCache.get('mainnet', ACCOUNT_ADDRESS), - ).toStrictEqual( - expect.objectContaining({ attempted: true, enabled: false }), - ); + const migration = TradingReadinessCache.get('mainnet', ACCOUNT_ADDRESS); + expect(migration).toStrictEqual({ + attempted: true, + enabled: false, + reason: undefined, + timestamp: migration?.timestamp, + }); // Declined once, not asked again. expect( accountSigner.signTypedData.mock.calls.filter( @@ -560,7 +681,8 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () it('reports and logs the error when the clients cannot initialize', async () => { const { accountSignerProvider, initialize } = createAccountSignerProvider(); - initialize.mockRejectedValue(new Error('transport unavailable')); + const failure = new Error('transport unavailable'); + initialize.mockRejectedValue(failure); const result = await accountSignerProvider.prepareTradingWallet(); @@ -568,20 +690,22 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () ready: false, error: 'transport unavailable', }); - expect(loggerError).toHaveBeenCalledWith( - expect.objectContaining({ message: 'transport unavailable' }), - { - tags: { - feature: 'perps', - provider: 'hyperliquid', - network: 'mainnet', - }, - context: { - name: 'HyperLiquidProvider', - data: { method: 'prepareTradingWallet' }, + expect(loggerError.mock.calls).toStrictEqual([ + [ + failure, + { + tags: { + feature: 'perps', + provider: 'hyperliquid', + network: 'mainnet', + }, + context: { + name: 'HyperLiquidProvider', + data: { method: 'prepareTradingWallet' }, + }, }, - }, - ); + ], + ]); }); it('does not log a provider replaced during preparation', async () => { @@ -618,7 +742,105 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () ).toHaveLength(2); }); - it('reports KEYRING_LOCKED when the signer locks after setup completed', async () => { + it('attempts the referral again when the signer locks while signing it', async () => { + const { accountSignerProvider, accountSigner, exchangeClient } = + createAccountSignerProvider({ abstraction: 'unifiedAccount' }); + // The signer still reports ready, but this signature fails as locked. + accountSigner.signTypedData.mockRejectedValueOnce( + new Error(PERPS_ERROR_CODES.KEYRING_LOCKED), + ); + + const firstResult = await accountSignerProvider.prepareTradingWallet(); + const referralAfterLock = referralAttempted(); + const secondResult = await accountSignerProvider.prepareTradingWallet(); + + expect(firstResult).toStrictEqual({ ready: false }); + expect(referralAfterLock).toBe(false); + expect(secondResult).toStrictEqual({ ready: true }); + expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(2); + 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([ + [ACCOUNT_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).toHaveBeenCalledTimes(1); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('reports not ready, without an error, for a wallet with no HyperLiquid account yet', async () => { + const { accountSignerProvider, accountSigner, exchangeClient } = + createAccountSignerProvider({ + abstraction: 'default', + info: { + userNonFundingLedgerUpdates: jest.fn().mockResolvedValue([]), + }, + }); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: false }); + expect(exchangeClient.agentSetAbstraction).not.toHaveBeenCalled(); + expect(exchangeClient.setReferrer).not.toHaveBeenCalled(); + 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', @@ -694,8 +916,11 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () await accountSignerProvider.prepareTradingWallet(); // Migration at connect, then referral setup. - expect(getAgentSigner).toHaveBeenCalledTimes(1); - expect(agentSigner.signTypedData).toHaveBeenCalledTimes(2); + 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 () => { @@ -712,7 +937,10 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () await accountSignerProvider.getMarketDataWithPrices(); await accountSignerProvider.prepareTradingWallet(); - expect(getAgentSigner).toHaveBeenCalledTimes(2); + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ [ACCOUNT_ADDRESS, L1_PAYLOAD], ]); @@ -767,13 +995,20 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () // referral write is the fourth L1 action. Each asks getAgentSigner. expect(exchangeClient.agentSetAbstraction).toHaveBeenCalledTimes(3); expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); - expect(getAgentSigner).toHaveBeenCalledTimes(4); + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); expect(accountSigner.signTypedData).not.toHaveBeenCalled(); expect(result).toStrictEqual({ ready: false }); // Retryable like a locked keyring: no failure metric, nothing logged. expect(trackPerpsEvent).not.toHaveBeenCalledWith( - 'Perp Account Setup', - expect.objectContaining({ status: 'failed' }), + PerpsAnalyticsEvent.AccountSetup, + expect.objectContaining({ + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, + }), ); expect(loggerError).not.toHaveBeenCalled(); }); @@ -895,8 +1130,11 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () await reading; await accountSignerProvider.prepareTradingWallet(); - expect(getAgentSigner).toHaveBeenCalledTimes(1); - expect(agentSigner.signTypedData).toHaveBeenCalledTimes(2); + 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 () => { @@ -932,11 +1170,18 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () getAgentSigner.mockResolvedValue(agentSigner); await wallet.signTypedData(L1_PAYLOAD); - expect(agentSigner.signTypedData).toHaveBeenCalledTimes(2); + expect(agentSigner.signTypedData.mock.calls).toStrictEqual([ + [L1_PAYLOAD], + [L1_PAYLOAD], + ]); expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ [ACCOUNT_ADDRESS, L1_PAYLOAD], ]); - expect(getAgentSigner).toHaveBeenCalledTimes(3); + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); }); it('asks getAgentSigner again once the bindings are cleared', async () => { @@ -1008,13 +1253,16 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () await accountSignerProvider.getMarketDataWithPrices(); const [[wallet]] = initialize.mock.calls; - await expect(wallet.signTypedData(L1_PAYLOAD)).rejects.toThrow( - 'HyperLiquid agent signer unavailable', + await expect(wallet.signTypedData(L1_PAYLOAD)).rejects.toBeInstanceOf( + AgentSignerUnavailableError, ); - await expect(wallet.signTypedData(L1_PAYLOAD)).rejects.toThrow( - 'HyperLiquid agent signer unavailable', + await expect(wallet.signTypedData(L1_PAYLOAD)).rejects.toBeInstanceOf( + AgentSignerUnavailableError, ); - expect(getAgentSigner).toHaveBeenCalledTimes(2); + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); expect(accountSigner.signTypedData).not.toHaveBeenCalled(); }); @@ -1033,7 +1281,10 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () answer.resolve(agentSigner); await reading; - expect(getAgentSigner).toHaveBeenCalledTimes(2); + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); expect(agentSigner.signTypedData).not.toHaveBeenCalled(); expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ [ACCOUNT_ADDRESS, L1_PAYLOAD], @@ -1122,12 +1373,11 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () symbol: 'BTC', }); - expect(result).toStrictEqual( - expect.objectContaining({ - success: false, - error: PERPS_ERROR_CODES.KEYRING_LOCKED, - }), - ); + expect(result).toStrictEqual({ + success: false, + orderId: '123', + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); expect(onAgentRejected).toHaveBeenCalledWith( MAINNET_ACCOUNT, agentSigner.address, @@ -1135,6 +1385,51 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(loggerError).not.toHaveBeenCalled(); }); + it('drops an agent the venue rejects in a cancel status entry', async () => { + const { + accountSignerProvider, + agentSigner, + exchangeClient, + getAgentSigner, + initialize, + onAgentRejected, + } = createRejectingProvider('cancel'); + await accountSignerProvider.getMarketDataWithPrices(); + const [[wallet]] = initialize.mock.calls; + exchangeClient.cancel.mockImplementation(async () => { + await wallet.signTypedData(L1_PAYLOAD); + return { + status: 'ok', + response: { + data: { + statuses: [{ error: rejection(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('fails an order edit with KEYRING_LOCKED without logging it', async () => { const { accountSignerProvider, infoClient, onAgentRejected } = createRejectingProvider('modify'); @@ -1154,12 +1449,10 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () }, }); - expect(result).toStrictEqual( - expect.objectContaining({ - success: false, - error: PERPS_ERROR_CODES.KEYRING_LOCKED, - }), - ); + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); expect(onAgentRejected.mock.calls).toStrictEqual([ [MAINNET_ACCOUNT, AGENT_ADDRESS], ]); @@ -1195,12 +1488,10 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () takeProfitPrice: '60000', }); - expect(result).toStrictEqual( - expect.objectContaining({ - success: false, - error: PERPS_ERROR_CODES.KEYRING_LOCKED, - }), - ); + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); expect(onAgentRejected.mock.calls).toStrictEqual([ [MAINNET_ACCOUNT, AGENT_ADDRESS], ]); @@ -1218,12 +1509,10 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () takeProfitPrice: '60000', }); - expect(result).toStrictEqual( - expect.objectContaining({ - success: false, - error: PERPS_ERROR_CODES.KEYRING_LOCKED, - }), - ); + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); expect(exchangeClient.cancel).toHaveBeenCalledTimes(1); expect(exchangeClient.order).not.toHaveBeenCalled(); expect(loggerError).not.toHaveBeenCalled(); @@ -1282,7 +1571,10 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () }); expect(onAgentRejected).toHaveBeenCalledTimes(1); // Dropped despite the throw, so the next L1 action asks again. - expect(getAgentSigner).toHaveBeenCalledTimes(2); + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); expect(loggerError).not.toHaveBeenCalled(); }); @@ -1343,12 +1635,11 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () selectAccount(ACCOUNT_ADDRESS); await wallet.signTypedData(L1_PAYLOAD); - expect(result).toStrictEqual( - expect.objectContaining({ - success: false, - error: PERPS_ERROR_CODES.KEYRING_LOCKED, - }), - ); + expect(result).toStrictEqual({ + success: false, + orderId: '123', + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); expect(onAgentRejected.mock.calls).toStrictEqual([ [MAINNET_ACCOUNT, agentSigner.address], ]); @@ -1359,7 +1650,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () ]); }); - it('recognizes the rejection of an agent replaced while its action was in flight', async () => { + 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 { @@ -1372,12 +1663,17 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () getAgentSigner, onAgentRejected, }); + const replacement = { + address: OTHER_AGENT_ADDRESS, + signTypedData: jest.fn().mockResolvedValue(OTHER_AGENT_SIGNATURE), + }; getAgentSigner.mockResolvedValue(agentSigner); await accountSignerProvider.getMarketDataWithPrices(); + const [[wallet]] = initialize.mock.calls; const signed = createDeferred(); const venue = createDeferred(); exchangeClient.order.mockImplementation(async () => { - await initialize.mock.calls[0][0].signTypedData(L1_PAYLOAD); + await wallet.signTypedData(L1_PAYLOAD); signed.resolve(); await venue.promise; throw rejection(agentSigner.address); @@ -1391,21 +1687,132 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () currentPrice: 50000, }); await signed.promise; - // A binding change (setAgentSigner) drops the resolved agents. + // 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( - expect.objectContaining({ + 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, + initialize, + } = createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + onAgentRejected, + }); + getAgentSigner.mockResolvedValue(agentSigner); + await accountSignerProvider.getMarketDataWithPrices(); + const [[wallet]] = initialize.mock.calls; + exchangeClient.order.mockImplementation(async () => { + await wallet.signTypedData(L1_PAYLOAD); + throw rejection(ACCOUNT_ADDRESS); + }); + + const order = await accountSignerProvider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }); + 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, + initialize, + } = createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + onAgentRejected, + }); + getAgentSigner.mockResolvedValue(agentSigner); + await accountSignerProvider.getMarketDataWithPrices(); + const [[wallet]] = initialize.mock.calls; + 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 rejection(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); + + expect(results).toStrictEqual([ + { success: false, + orderId: '123', error: PERPS_ERROR_CODES.KEYRING_LOCKED, - }), - ); - expect(onAgentRejected).toHaveBeenCalledWith( - MAINNET_ACCOUNT, - agentSigner.address, - ); + }, + { + success: false, + orderId: '124', + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }, + ]); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); expect(loggerError).not.toHaveBeenCalled(); }); @@ -1493,17 +1900,18 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () }); await initialize.mock.calls[0][0].signTypedData(L1_PAYLOAD); - expect(order).toStrictEqual( - expect.objectContaining({ - success: false, - error: PERPS_ERROR_CODES.KEYRING_LOCKED, - }), - ); + expect(order).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); expect(onAgentRejected).toHaveBeenCalledWith( MAINNET_ACCOUNT, agentSigner.address, ); - expect(getAgentSigner).toHaveBeenCalledTimes(2); + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); expect(loggerError).not.toHaveBeenCalled(); }); @@ -1524,8 +1932,10 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () agentSigner.address, ); expect(trackPerpsEvent).not.toHaveBeenCalledWith( - 'Perp Account Setup', - expect.objectContaining({ status: 'failed' }), + PerpsAnalyticsEvent.AccountSetup, + expect.objectContaining({ + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, + }), ); expect(migrationAttempted()).toBe(false); expect(loggerError).not.toHaveBeenCalled(); @@ -1574,12 +1984,10 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () currentPrice: 50000, }); - expect(order).toStrictEqual( - expect.objectContaining({ - success: false, - error: PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, - }), - ); + expect(order).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, + }); expect(onAgentRejected).not.toHaveBeenCalled(); }); }); @@ -1855,12 +2263,20 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () orderType: 'scale', }); - expect(placed).toStrictEqual( - expect.objectContaining({ - success: false, - error: PERPS_ERROR_CODES.ORDER_STRATEGY_CANCEL_INCOMPLETE, - }), - ); + expect(placed.orderId).toMatch(/^scale:/u); + expect(placed).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.ORDER_STRATEGY_CANCEL_INCOMPLETE, + orderId: placed.orderId, + acceptedChildren: [ + { state: 'waitingForFill' }, + { state: 'waitingForFill' }, + ], + acceptedSize: '1', + submittedSize: '1', + weightedAverageLimitPrice: '2500', + childOrderIds: [], + }); expect(result).toStrictEqual({ success: false, orderId: placed.orderId, @@ -1971,12 +2387,10 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () currentPrice: 50000, }); - expect(order).toStrictEqual( - expect.objectContaining({ - success: false, - error: PERPS_ERROR_CODES.KEYRING_LOCKED, - }), - ); + expect(order).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); expect(loggerError).not.toHaveBeenCalled(); }); @@ -1996,9 +2410,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () accountSignerProvider.clearAgentSigners(); answer.resolve(agentSigner); - await expect(signing).rejects.toThrow( - 'HyperLiquid agent signer unavailable', - ); + await expect(signing).rejects.toBeInstanceOf(AgentSignerUnavailableError); 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 78131f212b5..7732ba09bc1 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 @@ -1710,10 +1710,10 @@ describe('HyperLiquidProvider', () => { }); 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); + // Arrange - not cached yet; the other provider caches its result + (PerpsSigningCache as jest.Mocked).getReferral + .mockReturnValueOnce(undefined) + .mockReturnValue({ attempted: true, success: true }); // Simulate in-flight operation from another provider let resolveInFlight: () => void = () => undefined; 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 index 6ee93ad0418..0a3dbfa5df0 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts @@ -1,6 +1,9 @@ import { LIGHTER_TX_TYPE_CHANGE_PUB_KEY } from '../../../src/constants/lighterConfig.js'; import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; -import { LighterProvider } from '../../../src/providers/LighterProvider.js'; +import { + LIGHTER_SIGNER_UNAVAILABLE_ERROR, + LighterProvider, +} from '../../../src/providers/LighterProvider.js'; import { LighterApiError, LighterClientService, @@ -11,6 +14,7 @@ import type { LighterSignerResult, LighterWasmCall, } from '../../../src/types/lighter-types.js'; +import { MAIN_SIGNATURE } from '../../helpers/agentFixtures.js'; import { createKeyringMessenger, createKeyringlessMessenger, @@ -37,7 +41,6 @@ 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 L1_SIGNATURE = `0x${'ab'.repeat(65)}` as const; 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. @@ -149,11 +152,11 @@ function buildProvider({ ); const accountSigner = { signTypedData: jest.fn(), - signPersonalMessage: jest.fn().mockResolvedValue(L1_SIGNATURE), + signPersonalMessage: jest.fn().mockResolvedValue(MAIN_SIGNATURE), isReady, }; const { messenger, call, selectAccount } = keyring - ? createKeyringMessenger(L1_SIGNATURE) + ? createKeyringMessenger(MAIN_SIGNATURE) : createKeyringlessMessenger(); const { bridge, calls } = createBridge(); const deps = keyring @@ -204,7 +207,7 @@ describe('LighterProvider with accountSigner', () => { ); expect(changePubKey?.params).toStrictEqual([ ACCOUNT_INDEX, - L1_SIGNATURE, + MAIN_SIGNATURE, NEXT_NONCE, API_KEY_INDEX, ]); @@ -294,6 +297,12 @@ describe('LighterProvider with accountSigner', () => { ], ['a "User rejected" message', new Error('User rejected the request.')], ['a "User denied" message', new Error('User denied message signature.')], + [ + 'a rejection code wrapped in the cause chain', + new Error('Signing failed', { + cause: Object.assign(new Error('Rejected'), { code: 4001 }), + }), + ], ])( 'reports a decline signalled by %s as a retry without logging', async (_signal, rejection) => { @@ -326,32 +335,33 @@ describe('LighterProvider with accountSigner', () => { it.each([ [ 'the provider disconnects', - 'Operation cancelled: the Lighter provider was disconnected', async ({ provider }: BuiltProvider): Promise => { await provider.disconnect(); }, ], [ 'the wallet switches accounts', - 'Operation cancelled: the wallet switched accounts (or the signer reset) while this operation was in flight', async ({ selectAccount }: BuiltProvider): Promise => { selectAccount('0x00000000000000000000000000000000000c0ffe'); }, ], ])( - 'reports a registration cancelled because %s as not ready without logging', - async (_cause, cancellation, cancelSession) => { + '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 L1_SIGNATURE; + return MAIN_SIGNATURE; }); const loggerError = jest.spyOn(deps.logger, 'error'); const result = await provider.prepareTradingWallet(); - expect(result).toStrictEqual({ ready: false, error: cancellation }); + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + }); expect(client.sendTx).not.toHaveBeenCalled(); expect(loggerError).not.toHaveBeenCalled(); }, @@ -365,12 +375,10 @@ describe('LighterProvider with accountSigner', () => { expect(result).toStrictEqual({ ready: false, - error: 'Lighter signer bridge not configured', + error: LIGHTER_SIGNER_UNAVAILABLE_ERROR, }); expect(loggerError).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Lighter signer bridge not configured', - }), + new Error(LIGHTER_SIGNER_UNAVAILABLE_ERROR), { tags: { feature: 'perps', @@ -392,7 +400,7 @@ describe('LighterProvider with accountSigner', () => { }); accountSigner.signPersonalMessage.mockImplementation(async () => { signerReady = false; - return L1_SIGNATURE; + return MAIN_SIGNATURE; }); const result = await provider.prepareTradingWallet(); @@ -404,6 +412,27 @@ describe('LighterProvider with accountSigner', () => { }); }); + it('reports KEYRING_LOCKED without logging when the signer locks as registration fails', async () => { + let signerReady = true; + const { provider, accountSigner, client, deps } = buildProvider({ + isReady: () => signerReady, + }); + accountSigner.signPersonalMessage.mockImplementation(async () => { + signerReady = false; + throw new Error('wallet disconnected'); + }); + 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 when the account signer locks during registration', async () => { const { provider, accountSigner, deps } = buildProvider(); accountSigner.signPersonalMessage.mockRejectedValue( @@ -438,7 +467,7 @@ describe('LighterProvider with a KeyringController', () => { const changePubKey = calls.find( (wasmCall) => wasmCall.function === '_signChangePubKey', ); - expect(changePubKey?.params[1]).toBe(L1_SIGNATURE); + expect(changePubKey?.params[1]).toBe(MAIN_SIGNATURE); 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 index c2a00fbb970..acc599966e8 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts @@ -219,6 +219,20 @@ describe('HyperLiquidWalletService wallet adapter with an agent', () => { 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, + }; + + await adapter.signTypedData(lookalike); + + 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, mainSign } = buildAdapter(false); 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 index 69b4ce0c133..1613282bda3 100644 --- a/packages/perps-controller/tests/src/services/LighterWalletService.account-signer.test.ts +++ b/packages/perps-controller/tests/src/services/LighterWalletService.account-signer.test.ts @@ -1,5 +1,6 @@ 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, @@ -8,8 +9,6 @@ import { keyringCalls, } from '../../helpers/serviceMocks.js'; -const SIGNATURE = `0x${'ab'.repeat(65)}` as const; - function createSigner(isReady?: () => boolean): { signTypedData: jest.Mock; signPersonalMessage: jest.Mock; @@ -17,7 +16,7 @@ function createSigner(isReady?: () => boolean): { } { return { signTypedData: jest.fn(), - signPersonalMessage: jest.fn().mockResolvedValue(SIGNATURE), + signPersonalMessage: jest.fn().mockResolvedValue(MAIN_SIGNATURE), isReady, }; } @@ -33,7 +32,7 @@ describe('LighterWalletService with accountSigner', () => { const signature = await service.signPersonalMessage('hello'); - expect(signature).toBe(SIGNATURE); + expect(signature).toBe(MAIN_SIGNATURE); expect(signer.signPersonalMessage).toHaveBeenCalledWith( createMockEvmAccount().address, 'hello', @@ -96,7 +95,10 @@ describe('LighterWalletService.isMainAccountSignerReady', () => { ])( "follows the keyring's unlock state without an account signer (%s)", (_state, isUnlocked) => { - const { messenger, call } = createKeyringMessenger(SIGNATURE, isUnlocked); + const { messenger, call } = createKeyringMessenger( + MAIN_SIGNATURE, + isUnlocked, + ); const service = new LighterWalletService(createMockInfrastructure(), { isTestnet: true, messenger, From e36665b2bb0a3c368ed9b12c72e97fd70528fa95 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 29 Sep 2026 10:57:40 +0800 Subject: [PATCH 18/33] test(perps-controller): pin exact signer, write and result assertions - #mapError no longer carries an unreachable signer branch; every caller classifies signer failures first. - Status-entry rejections of an agent are covered for TWAP and batch cancels; the referral lock tests hold a real lock. - Provider tests assert exact SDK writes, signatures, results and analytics instead of call counts; the builder fee signs its own payload. - Lighter: the first readiness check and the keyring payload encoding are pinned; a stale skipped referral test is removed, with its suppressions. --- oxlint-suppressions.json | 8 - .../src/providers/HyperLiquidProvider.ts | 22 +- .../providers/AggregatedPerpsProvider.test.ts | 32 +- ...HyperLiquidProvider.account-signer.test.ts | 566 +++++++++++++----- .../HyperLiquidProvider.builder-fees.test.ts | 38 -- .../LighterProvider.account-signer.test.ts | 75 ++- ...LiquidWalletService.account-signer.test.ts | 17 +- .../src/services/LighterWalletService.test.ts | 29 +- 8 files changed, 541 insertions(+), 246 deletions(-) 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/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 29440630771..42fef463111 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -442,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. @@ -452,9 +455,6 @@ const getCancelStatusesFromError = ( * Every other rejection (multi-sig, a stale nonce, a rate limit) leaves the * order exactly where it was, which is a materially different outcome. */ -// 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; - const ALREADY_GONE_CANCEL_MARKERS = [ 'never placed', 'already canceled', @@ -558,7 +558,6 @@ type CancelOrderBatchOutcome = { remainingOrderIds: number[]; cancelledOrderIds: number[]; responseComplete: boolean; - // Set when the signer could not sign the cancel, so nothing was cancelled. // KEYRING_LOCKED when the signer could not sign, so nothing was cancelled. signerFailure?: Error; }; @@ -2917,8 +2916,8 @@ export class HyperLiquidProvider implements PerpsProvider { #tradingSetupComplete = false; - // Set when the referral write failed in a way that must be retried (the - // agent signer was unavailable), so trading setup is not marked complete. + // Set when the referral write failed because its signer could not sign, so + // trading setup is not marked complete and the referral is attempted again. #referralSetupNeedsRetry = false; readonly #builderFeeSetupPromises = new Map>(); @@ -3040,8 +3039,9 @@ export class HyperLiquidProvider implements PerpsProvider { 'Trading setup completion', ); - // Only mark complete if keyring was unlocked (signing could actually - // happen) and the referral does not need another attempt. + // 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 @@ -4310,12 +4310,6 @@ export class HyperLiquidProvider implements PerpsProvider { #mapError(error: unknown): Error { const { message } = ensureError(error, 'HyperLiquidProvider.mapError'); - // A write whose signer could not sign it. Mapping has no side effects: - // the caller that reports the failure classifies it (#handleSignerFailure). - if (this.#isSignerFailure(error)) { - return new Error(PERPS_ERROR_CODES.KEYRING_LOCKED); - } - // "User or API Wallet 0x... does not exist." carries the user's address, so // it cannot be matched by the static substring table below. It means the // wallet has no Hyperliquid account yet — surface an actionable code the diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts index 868c547af35..be10e8017ee 100644 --- a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -1117,11 +1117,10 @@ describe('AggregatedPerpsProvider', () => { }); 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(new Error('provider crashed')), + prepareTradingWallet: jest.fn().mockRejectedValue(crash), }); Object.assign(mockLighterProvider, { prepareTradingWallet: prepareLighter, @@ -1130,17 +1129,24 @@ describe('AggregatedPerpsProvider', () => { const result = await aggregatedProvider.prepareTradingWallet(); expect(result).toStrictEqual({ ready: false, error: 'provider crashed' }); - expect(prepareLighter).toHaveBeenCalledTimes(1); - expect(mockInfrastructure.logger.error).toHaveBeenCalledWith( - expect.objectContaining({ message: 'provider crashed' }), - { - tags: { feature: 'perps', provider: 'hyperliquid' }, - context: { - name: 'AggregatedPerpsProvider', - data: { method: 'prepareTradingWallet', providerId: 'hyperliquid' }, + expect(prepareLighter.mock.calls).toStrictEqual([[]]); + expect( + (mockInfrastructure.logger.error as jest.Mock).mock.calls, + ).toStrictEqual([ + [ + crash, + { + tags: { feature: 'perps', provider: 'hyperliquid' }, + context: { + name: 'AggregatedPerpsProvider', + data: { + method: 'prepareTradingWallet', + providerId: 'hyperliquid', + }, + }, }, - }, - ); + ], + ]); }); it.each([ 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 index 2e604e4ce7e..bb401767b77 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts @@ -4,7 +4,10 @@ import { PERPS_EVENT_PROPERTY, PERPS_EVENT_VALUE, } from '../../../src/constants/eventNames.js'; -import { BUILDER_FEE_CONFIG } from '../../../src/constants/hyperLiquidConfig.js'; +import { + BUILDER_FEE_CONFIG, + REFERRAL_CONFIG, +} from '../../../src/constants/hyperLiquidConfig.js'; import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes.js'; import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider.js'; import { @@ -18,6 +21,7 @@ import { PerpsSigningCache, TradingReadinessCache, } from '../../../src/services/TradingReadinessCache.js'; +import { HL_ABSTRACTION_WIRE } from '../../../src/types/hyperliquid-types.js'; import { PerpsAnalyticsEvent } from '../../../src/types/index.js'; import type { HyperLiquidCredentials, @@ -29,6 +33,7 @@ import type { import { AGENT_ADDRESS, AGENT_SIGNATURE, + APPROVE_BUILDER_FEE_PAYLOAD, L1_PAYLOAD, MAIN_SIGNATURE, OTHER_AGENT_ADDRESS, @@ -126,15 +131,33 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () ); }); - // The wallet service and the signing caches are real, and the messenger - // has no KeyringController, so every main-account signature must reach - // the injected accountSigner. The SDK exchange client is the mocked + // The wallet service and the signing caches are real. 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. const ACCOUNT_ADDRESS = createMockEvmAccount().address; const OTHER_ACCOUNT_ADDRESS = '0x00000000000000000000000000000000000b0b01' as const; + // A fixed clock for cache timestamps. + const NOW = 1_700_000_000_000; + + // The SDK writes the provider makes for the selected account on mainnet. + const MIGRATION_WRITE = [ + { user: ACCOUNT_ADDRESS, abstraction: 'unifiedAccount' }, + ]; + const SILENT_MIGRATION_WRITE = [ + { abstraction: HL_ABSTRACTION_WIRE.unifiedAccount }, + ]; + const REFERRAL_WRITE = [{ code: REFERRAL_CONFIG.MainnetCode }]; + const BUILDER_FEE_WRITE = [ + { + builder: BUILDER_FEE_CONFIG.MainnetBuilder, + maxFeeRate: BUILDER_FEE_CONFIG.MaxFeeRate, + }, + ]; + /** * Whether the unified-account migration is recorded as attempted for the * selected account on mainnet. @@ -245,7 +268,9 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () userSetAbstraction: jest.fn(signThroughSdkWallet(USER_SIGNED_PAYLOAD)), agentSetAbstraction: jest.fn(signThroughSdkWallet(L1_PAYLOAD)), setReferrer: jest.fn(signThroughSdkWallet(L1_PAYLOAD)), - approveBuilderFee: jest.fn(signThroughSdkWallet(USER_SIGNED_PAYLOAD)), + approveBuilderFee: jest.fn( + signThroughSdkWallet(APPROVE_BUILDER_FEE_PAYLOAD), + ), order: jest.fn( signThroughSdkWallet(L1_PAYLOAD, { status: 'ok', @@ -300,14 +325,12 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () await accountSignerProvider.getMarketDataWithPrices(); - expect(exchangeClient.userSetAbstraction).toHaveBeenCalledWith({ - user: ACCOUNT_ADDRESS, - abstraction: 'unifiedAccount', - }); - expect(accountSigner.signTypedData).toHaveBeenCalledWith( - ACCOUNT_ADDRESS, - USER_SIGNED_PAYLOAD, - ); + expect(exchangeClient.userSetAbstraction.mock.calls).toStrictEqual([ + MIGRATION_WRITE, + ]); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], + ]); expect(keyringCalls(call)).toStrictEqual([]); }); @@ -330,17 +353,24 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () await accountSignerProvider.getMarketDataWithPrices(); - expect(exchangeClient.userSetAbstraction).toHaveBeenCalledTimes(1); + 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(); - expect(trackPerpsEvent).not.toHaveBeenCalledWith( - PerpsAnalyticsEvent.AccountSetup, - expect.objectContaining({ - [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, - }), - ); + // 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 () => { @@ -418,7 +448,9 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () success: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); - expect(withdraw3).toHaveBeenCalledTimes(1); + expect(withdraw3.mock.calls).toStrictEqual([ + [{ destination: ACCOUNT_ADDRESS, amount: '10' }], + ]); expect(accountSigner.signTypedData).not.toHaveBeenCalled(); expect(loggerError).not.toHaveBeenCalled(); }); @@ -439,7 +471,17 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () success: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); - expect(exchangeClient.sendAsset).toHaveBeenCalledTimes(1); + expect(exchangeClient.sendAsset.mock.calls).toStrictEqual([ + [ + { + destination: ACCOUNT_ADDRESS, + sourceDex: '', + destinationDex: 'xyz', + token: 'USDC:0xdef456', + amount: '10', + }, + ], + ]); expect(accountSigner.signTypedData).not.toHaveBeenCalled(); expect(loggerError).not.toHaveBeenCalled(); }); @@ -460,8 +502,12 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () const result = await accountSignerProvider.prepareTradingWallet(); expect(result).toStrictEqual({ ready: true }); - expect(exchangeClient.userSetAbstraction).toHaveBeenCalledTimes(1); - expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); + expect(exchangeClient.userSetAbstraction.mock.calls).toStrictEqual([ + MIGRATION_WRITE, + ]); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + REFERRAL_WRITE, + ]); expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], [ACCOUNT_ADDRESS, L1_PAYLOAD], @@ -501,39 +547,57 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(setupSignatures).toStrictEqual([ [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], [ACCOUNT_ADDRESS, L1_PAYLOAD], - [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], + [ACCOUNT_ADDRESS, APPROVE_BUILDER_FEE_PAYLOAD], ]); - expect(order.success).toBe(true); + expect(order).toStrictEqual({ + success: true, + orderId: '123', + submittedSize: '0.1', + averagePrice: undefined, + filledSize: undefined, + }); expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ [ACCOUNT_ADDRESS, L1_PAYLOAD], ]); - expect(exchangeClient.userSetAbstraction).toHaveBeenCalledTimes(1); - expect(exchangeClient.approveBuilderFee).toHaveBeenCalledTimes(1); - expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); + 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 referral lock until released. + * Have another provider hold the real referral lock until released. * - * @returns Resolves once this provider waits on the lock, and the release. + * @returns Resolves once this provider finds the lock and waits on it, + * and the release. */ function holdReferralLock(): { waiting: Promise; release: () => void; } { - const otherAttempt = createDeferred(); + const release = PerpsSigningCache.setInFlight( + 'referral', + 'mainnet', + ACCOUNT_ADDRESS, + ); const waiting = createDeferred(); 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) => { - if (operationType !== 'referral') { - return isInFlight(operationType, network, userAddress); + const pending = isInFlight(operationType, network, userAddress); + if (operationType === 'referral' && pending) { + waiting.resolve(); } - waiting.resolve(); - return otherAttempt.promise; + return pending; }); - return { waiting: waiting.promise, release: otherAttempt.resolve }; + return { waiting: waiting.promise, release }; } it('makes its own referral attempt when another provider ended without a result', async () => { @@ -541,15 +605,26 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () createAccountSignerProvider({ abstraction: 'unifiedAccount' }); const lock = holdReferralLock(); - const preparing = accountSignerProvider.prepareTradingWallet(); - await lock.waiting; - const referrerCallsWhileWaiting = - exchangeClient.setReferrer.mock.calls.length; - lock.release(); - const result = await preparing; + let referrerCallsWhileWaiting; + let result; + try { + const preparing = accountSignerProvider.prepareTradingWallet(); + await lock.waiting; + // A provider that did not wait would reach its write by now. + await new Promise((resolve) => setTimeout(resolve, 0)); + referrerCallsWhileWaiting = + exchangeClient.setReferrer.mock.calls.length; + lock.release(); + result = await preparing; + } finally { + // Never leak the global lock into later tests. + lock.release(); + } expect(referrerCallsWhileWaiting).toBe(0); - expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + REFERRAL_WRITE, + ]); expect( PerpsSigningCache.getReferral('mainnet', ACCOUNT_ADDRESS), ).toStrictEqual({ @@ -564,20 +639,26 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () createAccountSignerProvider({ abstraction: 'unifiedAccount' }); const lock = holdReferralLock(); - const preparing = accountSignerProvider.prepareTradingWallet(); - await lock.waiting; - PerpsSigningCache.setReferral('mainnet', ACCOUNT_ADDRESS, { - attempted: true, - success: true, - }); - lock.release(); - const result = await preparing; + let result; + try { + const preparing = accountSignerProvider.prepareTradingWallet(); + await lock.waiting; + PerpsSigningCache.setReferral('mainnet', ACCOUNT_ADDRESS, { + attempted: true, + success: true, + }); + lock.release(); + result = await preparing; + } finally { + lock.release(); + } expect(exchangeClient.setReferrer).not.toHaveBeenCalled(); expect(result).toStrictEqual({ ready: true }); }); it('signs nothing more when called again', async () => { + jest.spyOn(Date, 'now').mockReturnValue(NOW); const { accountSignerProvider, accountSigner } = createAccountSignerProvider({ signer: { requiresSignatureConfirmation: () => true }, @@ -588,12 +669,13 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () const result = await accountSignerProvider.prepareTradingWallet(); expect(result).toStrictEqual({ ready: true }); - const migration = TradingReadinessCache.get('mainnet', ACCOUNT_ADDRESS); - expect(migration).toStrictEqual({ + expect( + TradingReadinessCache.get('mainnet', ACCOUNT_ADDRESS), + ).toStrictEqual({ attempted: true, enabled: true, reason: undefined, - timestamp: migration?.timestamp, + timestamp: NOW, }); // Migration, then referral; nothing on the second call. expect(firstSignatures).toStrictEqual([ @@ -606,6 +688,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () }); 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 }, @@ -624,12 +707,13 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(result).toStrictEqual({ ready: true }); expect(secondResult).toStrictEqual({ ready: true }); - const migration = TradingReadinessCache.get('mainnet', ACCOUNT_ADDRESS); - expect(migration).toStrictEqual({ + expect( + TradingReadinessCache.get('mainnet', ACCOUNT_ADDRESS), + ).toStrictEqual({ attempted: true, enabled: false, reason: undefined, - timestamp: migration?.timestamp, + timestamp: NOW, }); // Declined once, not asked again. expect( @@ -650,7 +734,9 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () const result = await accountSignerProvider.prepareTradingWallet(); expect(result).toStrictEqual({ ready: false }); - expect(exchangeClient.approveBuilderFee).toHaveBeenCalledTimes(1); + expect(exchangeClient.approveBuilderFee.mock.calls).toStrictEqual([ + BUILDER_FEE_WRITE, + ]); }); it('reports KEYRING_LOCKED when accountSigner is not ready, without running or logging setup', async () => { @@ -732,8 +818,12 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () const result = await accountSignerProvider.prepareTradingWallet(); expect(result).toStrictEqual({ ready: true }); - expect(exchangeClient.userSetAbstraction).toHaveBeenCalledTimes(1); - expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); + expect(exchangeClient.userSetAbstraction.mock.calls).toStrictEqual([ + MIGRATION_WRITE, + ]); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + REFERRAL_WRITE, + ]); expect(accountSigner.signTypedData).not.toHaveBeenCalled(); expect( keyringCalls(call).filter( @@ -757,7 +847,10 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(firstResult).toStrictEqual({ ready: false }); expect(referralAfterLock).toBe(false); expect(secondResult).toStrictEqual({ ready: true }); - expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(2); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + REFERRAL_WRITE, + REFERRAL_WRITE, + ]); expect(referralAttempted()).toBe(true); expect(loggerError).not.toHaveBeenCalled(); }); @@ -818,7 +911,9 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(result).toStrictEqual({ ready: false }); expect(migrationAttempted()).toBe(false); expect(referralAttempted()).toBe(true); - expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + REFERRAL_WRITE, + ]); expect(loggerError).not.toHaveBeenCalled(); }); @@ -993,8 +1088,14 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () // 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).toHaveBeenCalledTimes(3); - expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); + 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], @@ -1004,12 +1105,32 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(accountSigner.signTypedData).not.toHaveBeenCalled(); expect(result).toStrictEqual({ ready: false }); // Retryable like a locked keyring: no failure metric, nothing logged. - expect(trackPerpsEvent).not.toHaveBeenCalledWith( - PerpsAnalyticsEvent.AccountSetup, - expect.objectContaining({ - [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, - }), - ); + 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(); }); @@ -1049,11 +1170,15 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () selectAccount(OTHER_ACCOUNT_ADDRESS); await accountSignerProvider.prepareTradingWallet(); - expect(accountSigner.signTypedData.mock.calls[0]).toStrictEqual([ - ACCOUNT_ADDRESS, - L1_PAYLOAD, + // 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([ + [ACCOUNT_ADDRESS, L1_PAYLOAD], + ]); + expect(agentSigner.signTypedData.mock.calls).toStrictEqual([ + [L1_PAYLOAD], + [L1_PAYLOAD], ]); - expect(agentSigner.signTypedData).toHaveBeenCalledWith(L1_PAYLOAD); }); it('never signs on another network with the agent bound for mainnet', async () => { @@ -1090,7 +1215,11 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(getAgentSigner).not.toHaveBeenCalled(); expect(agentSigner.signTypedData).not.toHaveBeenCalled(); - expect(accountSigner.signTypedData).toHaveBeenCalledTimes(2); + // Migration, then referral. + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [ACCOUNT_ADDRESS, L1_PAYLOAD], + [ACCOUNT_ADDRESS, L1_PAYLOAD], + ]); }); it('lets a pin made while getAgentSigner is pending win', async () => { @@ -1201,10 +1330,13 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () accountSignerProvider.clearAgentSigners(); await wallet.signTypedData(L1_PAYLOAD); - expect(agentSigner.signTypedData).toHaveBeenCalledWith(L1_PAYLOAD); + expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); + expect(agentSigner.signTypedData.mock.calls).toStrictEqual([ + [L1_PAYLOAD], + ]); }); - it('retries the referral instead of recording a failure when getAgentSigner rejects', async () => { + it('leaves the referral to retry, unrecorded, when getAgentSigner rejects', async () => { const getAgentSigner = jest .fn() .mockRejectedValue(new Error('agent store unavailable')); @@ -1216,12 +1348,14 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () await accountSignerProvider.prepareTradingWallet(); - expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + REFERRAL_WRITE, + ]); expect(referralAttempted()).toBe(false); expect(loggerError).not.toHaveBeenCalled(); }); - it('retries the referral instead of recording a failure when the agent fails to sign', async () => { + it('leaves the referral to retry, unrecorded, when the agent fails to sign', async () => { const getAgentSigner = jest.fn(); const { accountSignerProvider, agentSigner, exchangeClient } = createAccountSignerProvider({ @@ -1236,7 +1370,9 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () const result = await accountSignerProvider.prepareTradingWallet(); expect(result).toStrictEqual({ ready: false }); - expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(1); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + REFERRAL_WRITE, + ]); expect(referralAttempted()).toBe(false); expect(loggerError).not.toHaveBeenCalled(); }); @@ -1307,7 +1443,10 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(firstResult).toStrictEqual({ ready: false }); expect(secondResult).toStrictEqual({ ready: true }); - expect(exchangeClient.setReferrer).toHaveBeenCalledTimes(2); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + REFERRAL_WRITE, + REFERRAL_WRITE, + ]); }); const rejection = (address: string): Error => @@ -1364,7 +1503,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () } it('fails a cancel with KEYRING_LOCKED without logging it', async () => { - const { accountSignerProvider, agentSigner, onAgentRejected } = + const { accountSignerProvider, onAgentRejected } = createRejectingProvider('cancel'); await accountSignerProvider.getMarketDataWithPrices(); @@ -1378,10 +1517,9 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () orderId: '123', error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); - expect(onAgentRejected).toHaveBeenCalledWith( - MAINNET_ACCOUNT, - agentSigner.address, - ); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); expect(loggerError).not.toHaveBeenCalled(); }); @@ -1430,6 +1568,62 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(loggerError).not.toHaveBeenCalled(); }); + it('drops an agent the venue rejects in a batch cancel status entry', async () => { + const { + accountSignerProvider, + exchangeClient, + getAgentSigner, + initialize, + onAgentRejected, + } = createRejectingProvider('cancel'); + await accountSignerProvider.getMarketDataWithPrices(); + const [[wallet]] = initialize.mock.calls; + exchangeClient.cancel.mockImplementation(async () => { + await wallet.signTypedData(L1_PAYLOAD); + return { + status: 'ok', + response: { + data: { + statuses: [ + { error: rejection(AGENT_ADDRESS).message }, + 'success', + ], + }, + }, + }; + }); + + const result = await accountSignerProvider.cancelOrders([ + { orderId: '123', symbol: 'BTC' }, + { orderId: '124', symbol: 'BTC' }, + ]); + await wallet.signTypedData(L1_PAYLOAD); + + expect(result).toStrictEqual({ + success: true, + successCount: 1, + failureCount: 1, + results: [ + { + orderId: '123', + symbol: 'BTC', + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }, + { orderId: '124', symbol: 'BTC', success: true }, + ], + }); + 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('fails an order edit with KEYRING_LOCKED without logging it', async () => { const { accountSignerProvider, infoClient, onAgentRejected } = createRejectingProvider('modify'); @@ -1468,10 +1662,19 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () symbols: ['BTC'], }); - expect(result.success).toBe(false); - expect(result.results.map(({ error }) => error)).toStrictEqual([ - PERPS_ERROR_CODES.KEYRING_LOCKED, - ]); + 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], ]); @@ -1499,8 +1702,12 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () }); it('keeps the old protection and fails with KEYRING_LOCKED when its cancel is rejected', async () => { - const { accountSignerProvider, exchangeClient, infoClient } = - createRejectingProvider('cancel'); + const { + accountSignerProvider, + exchangeClient, + infoClient, + onAgentRejected, + } = createRejectingProvider('cancel'); infoClient.frontendOpenOrders.mockResolvedValue([TAKE_PROFIT_ORDER]); await accountSignerProvider.getMarketDataWithPrices(); @@ -1513,8 +1720,13 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () success: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); - expect(exchangeClient.cancel).toHaveBeenCalledTimes(1); + expect(exchangeClient.cancel.mock.calls).toStrictEqual([ + [{ cancels: [{ a: 0, o: 456 }] }], + ]); expect(exchangeClient.order).not.toHaveBeenCalled(); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); expect(loggerError).not.toHaveBeenCalled(); }); @@ -1536,9 +1748,9 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () success: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); - expect(exchangeClient.cancel).toHaveBeenCalledWith({ - cancels: [{ a: 0, o: 456 }], - }); + expect(exchangeClient.cancel.mock.calls).toStrictEqual([ + [{ cancels: [{ a: 0, o: 456 }] }], + ]); expect(exchangeClient.order).not.toHaveBeenCalled(); expect(onAgentRejected.mock.calls).toStrictEqual([ [MAINNET_ACCOUNT, AGENT_ADDRESS], @@ -1569,7 +1781,9 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () orderId: '123', error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); - expect(onAgentRejected).toHaveBeenCalledTimes(1); + 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], @@ -1826,11 +2040,25 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () { orderId: '124', symbol: 'BTC' }, ]); - expect(result.success).toBe(false); - expect(result.results.map(({ error }) => error)).toStrictEqual([ - PERPS_ERROR_CODES.KEYRING_LOCKED, - PERPS_ERROR_CODES.KEYRING_LOCKED, - ]); + 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], @@ -1884,7 +2112,6 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () it('fails the order with KEYRING_LOCKED, drops the agent and asks again', async () => { const { accountSignerProvider, - agentSigner, getAgentSigner, onAgentRejected, initialize, @@ -1904,10 +2131,9 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () success: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); - expect(onAgentRejected).toHaveBeenCalledWith( - MAINNET_ACCOUNT, - agentSigner.address, - ); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); expect(getAgentSigner.mock.calls).toStrictEqual([ [MAINNET_ACCOUNT], [MAINNET_ACCOUNT], @@ -1916,47 +2142,56 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () }); it('retries the silent migration instead of recording no HyperLiquid account', async () => { - const { - accountSignerProvider, - agentSigner, - onAgentRejected, - exchangeClient, - } = createRejectingProvider('agentSetAbstraction'); + const { accountSignerProvider, onAgentRejected, exchangeClient } = + createRejectingProvider('agentSetAbstraction'); await accountSignerProvider.getMarketDataWithPrices(); await accountSignerProvider.getMarketDataWithPrices(); - expect(exchangeClient.agentSetAbstraction).toHaveBeenCalledTimes(2); - expect(onAgentRejected).toHaveBeenCalledWith( - MAINNET_ACCOUNT, - agentSigner.address, - ); - expect(trackPerpsEvent).not.toHaveBeenCalledWith( - PerpsAnalyticsEvent.AccountSetup, - expect.objectContaining({ - [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, - }), - ); + 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('retries the referral instead of recording a failure', async () => { - const { - accountSignerProvider, - agentSigner, - onAgentRejected, - exchangeClient, - } = createRejectingProvider('setReferrer'); + 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).toHaveBeenCalledTimes(1); - expect(onAgentRejected).toHaveBeenCalledWith( - MAINNET_ACCOUNT, - agentSigner.address, - ); + 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(); }); @@ -2171,7 +2406,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () orderId: '987', error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); - expect(twapCancel).toHaveBeenCalledWith({ a: 1, t: 987 }); + expect(twapCancel.mock.calls).toStrictEqual([[{ a: 1, t: 987 }]]); expect(onAgentRejected.mock.calls).toStrictEqual(rejectedAgents); expect(loggerError).not.toHaveBeenCalled(); }, @@ -2315,14 +2550,57 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () orderId: placed.orderId, error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); - expect(cancel).toHaveBeenCalledWith({ - cancels: [{ a: 1, o: 123 }], - }); + expect(cancel.mock.calls).toStrictEqual([ + [{ cancels: [{ a: 1, o: 123 }] }], + ]); expect(onAgentRejected.mock.calls).toStrictEqual(rejectedAgents); expect(loggerError).not.toHaveBeenCalled(); }, ); + it('drops an agent the venue rejects in a TWAP cancel status entry', async () => { + const { + provider, + twapCancel, + getAgentSigner, + onAgentRejected, + signL1Action, + } = createStrategyProvider('rejected'); + await provider.getMarketDataWithPrices(); + twapCancel.mockImplementation(async () => { + await signL1Action(); + return { + status: 'ok', + response: { + type: 'twapCancel', + data: { status: { error: rejection(AGENT_ADDRESS).message } }, + }, + }; + }); + + const result = await provider.cancelOrder({ + orderId: '987', + symbol: 'ETH', + orderType: 'twap', + }); + await signL1Action(); + + expect(result).toStrictEqual({ + success: false, + orderId: '987', + 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(); + }); + describe('during a chase re-price', () => { beforeEach(() => { jest.useFakeTimers(); @@ -2354,9 +2632,9 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () await jest.advanceTimersByTimeAsync(1000); await signL1Action(); - expect(cancel).toHaveBeenCalledWith({ - cancels: [{ a: 1, o: 123 }], - }); + expect(cancel.mock.calls).toStrictEqual([ + [{ cancels: [{ a: 1, o: 123 }] }], + ]); expect(onAgentRejected.mock.calls).toStrictEqual([ [MAINNET_ACCOUNT, AGENT_ADDRESS], ]); 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 7732ba09bc1..f3519dcef05 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 @@ -1709,44 +1709,6 @@ describe('HyperLiquidProvider', () => { expect(mockClientService.getInfoClient).not.toHaveBeenCalled(); }); - it('waits for in-flight operation instead of duplicating request', async () => { - // Arrange - not cached yet; the other provider caches its result - (PerpsSigningCache as jest.Mocked).getReferral - .mockReturnValueOnce(undefined) - .mockReturnValue({ attempted: true, success: true }); - - // 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/LighterProvider.account-signer.test.ts b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts index 0a3dbfa5df0..74a6c0d3192 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts @@ -98,7 +98,11 @@ function createBridge(): { type BuiltProvider = { provider: LighterProvider; address: string; - client: { sendTx: jest.Mock; getAccountsByL1Address: jest.Mock }; + client: { + sendTx: jest.Mock; + getAccountsByL1Address: jest.Mock; + getNextNonce: jest.Mock; + }; accountSigner: { signPersonalMessage: jest.Mock }; call: jest.SpyInstance; selectAccount: (address: `0x${string}`) => void; @@ -248,6 +252,41 @@ describe('LighterProvider with accountSigner', () => { 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 without logging a missing signer bridge while the account signer is locked', 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 }); @@ -452,22 +491,40 @@ describe('LighterProvider with accountSigner', () => { describe('LighterProvider with a KeyringController', () => { it('registers the venue key through prepareTradingWallet with a keyring signature', async () => { - const { provider, client, call, calls } = buildProvider({ keyring: true }); + 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. - expect(keyringCalls(call)).toStrictEqual([ - 'KeyringController:getState', - 'KeyringController:getState', - 'KeyringController:signPersonalMessage', - 'KeyringController:getState', + // 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[1]).toBe(MAIN_SIGNATURE); + 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 index acc599966e8..91ea0e0f7a3 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts @@ -107,7 +107,7 @@ describe('HyperLiquidWalletService with accountSigner', () => { expect(keyringCalls(call)).toStrictEqual([]); }); - it('treats the account as hardware when requiresSignatureConfirmation returns true', () => { + it("requires signature confirmation when the account signer's requiresSignatureConfirmation says so, whatever the keyring type", () => { const { service } = buildService( { requiresSignatureConfirmation: () => true }, 'HD Key Tree', @@ -116,7 +116,7 @@ describe('HyperLiquidWalletService with accountSigner', () => { expect(service.requiresSignatureConfirmation()).toBe(true); }); - it('treats the account as software when requiresSignatureConfirmation returns false', () => { + it("does not require signature confirmation when the account signer's requiresSignatureConfirmation says so, whatever the keyring type", () => { const { service } = buildService( { requiresSignatureConfirmation: () => false }, 'Ledger Hardware', @@ -207,7 +207,7 @@ describe('HyperLiquidWalletService wallet adapter with an agent', () => { }); it('keeps an Agent primary type outside the Exchange domain on the main account', async () => { - const { adapter, agentSign, mainSign } = buildAdapter(); + const { adapter, resolveAgent, agentSign, mainSign } = buildAdapter(); const lookalike = { ...L1_PAYLOAD, domain: { ...L1_PAYLOAD.domain, name: 'HyperliquidSignTransaction' }, @@ -215,7 +215,8 @@ describe('HyperLiquidWalletService wallet adapter with an agent', () => { await adapter.signTypedData(lookalike); - expect(mainSign).toHaveBeenCalledWith(mainAddress, lookalike); + expect(mainSign.mock.calls).toStrictEqual([[mainAddress, lookalike]]); + expect(resolveAgent).not.toHaveBeenCalled(); expect(agentSign).not.toHaveBeenCalled(); }); @@ -234,11 +235,13 @@ describe('HyperLiquidWalletService wallet adapter with an agent', () => { }); it('signs L1 actions with the main account when no agent is resolved', async () => { - const { adapter, mainSign } = buildAdapter(false); + const { adapter, resolveAgent, mainSign } = buildAdapter(false); - await adapter.signTypedData(L1_PAYLOAD); + const signature = await adapter.signTypedData(L1_PAYLOAD); - expect(mainSign).toHaveBeenCalledWith(mainAddress, 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 () => { diff --git a/packages/perps-controller/tests/src/services/LighterWalletService.test.ts b/packages/perps-controller/tests/src/services/LighterWalletService.test.ts index 21234372ca5..1a40e558e05 100644 --- a/packages/perps-controller/tests/src/services/LighterWalletService.test.ts +++ b/packages/perps-controller/tests/src/services/LighterWalletService.test.ts @@ -1,12 +1,11 @@ import type { PerpsControllerMessenger } from '../../../src/PerpsController.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 SELECTED_ADDRESS = '0x8D7f03FdE1A626223364E592740a233b72395235'; describe('LighterWalletService', () => { @@ -49,7 +48,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}`); }); @@ -60,17 +59,21 @@ 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: SELECTED_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 () => { From 563bf125fbbfd4f803b5e2f7f995de98d73c18f7 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 29 Sep 2026 11:25:59 +0800 Subject: [PATCH 19/33] fix(perps-controller): read signer failures from cancel status entries, and serialize referral waiters - A rejected agent the venue reports in cancel status entries fails scale, TP/SL and chase cancels with KEYRING_LOCKED, drops the agent and notifies the host, as a thrown rejection does. - Providers waiting on another's referral attempt take the lock one at a time, so only one writes the referral. - Lighter prepareTradingWallet: a read-only provider (no signer bridge) is ready at once without logging; no selected account returns NO_ACCOUNT_SELECTED; "User cancelled" declines are retried. - TradingService batch logs keep the total failure count and add the reported count. - onAgentRejected is documented as called per rejected write. - Tests cover status-entry rejections, several waiters, address case, re-asks after drops, the Lighter retry and read-only paths, and move the controller binding cases to integration and AgentBindings tests. --- packages/perps-controller/CHANGELOG.md | 4 +- .../src/providers/HyperLiquidProvider.ts | 84 ++++- .../src/providers/LighterProvider.ts | 35 +- .../src/services/TradingService.ts | 10 +- packages/perps-controller/src/types/index.ts | 9 +- ...ntroller.agent-signing.integration.test.ts | 97 ++++-- .../PerpsController.providers-cache.test.ts | 159 +-------- .../providers/AggregatedPerpsProvider.test.ts | 76 +++-- ...HyperLiquidProvider.account-signer.test.ts | 303 +++++++++++++----- .../LighterProvider.account-signer.test.ts | 111 +++++-- ...LiquidWalletService.account-signer.test.ts | 17 +- .../src/services/LighterWalletService.test.ts | 9 +- .../tests/src/services/TradingService.test.ts | 61 ++-- .../tests/src/services/agentSigner.test.ts | 68 +++- 14 files changed, 667 insertions(+), 376 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 0912723181a..0d878be7d1b 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -25,11 +25,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 when the venue rejects an agent so the client can re-check its approval + - Add optional `providerCredentials.hyperliquid.onAgentRejected(account, agentAddress)`, called for each write the venue rejects with an 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 setup that needs a main-account signature before the first order: account migration, builder fee and referral on HyperLiquid, venue-key registration on Lighter ([#10559](https://github.com/MetaMask/core/pull/10559)) - Resolves a `ReadyToTradeResult` that is `ready: true` once the main-account signer is ready and none of these steps will ask it to sign again before the first order; the aggregated provider prepares every provider in turn + - Implemented by the exported `HyperLiquidProvider` and by the Lighter provider, which resolves `ready: true` at once when it is read-only (no signer bridge) - 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 diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 42fef463111..58f09774e7e 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -4341,6 +4341,28 @@ export class HyperLiquidProvider implements PerpsProvider { 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 a rejected signer fails + * every entry. + * + * @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. @@ -7849,7 +7871,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); } /** @@ -8674,12 +8701,24 @@ export class HyperLiquidProvider implements PerpsProvider { 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 } => { + const signerFailure = this.#classifyStatusSignerFailure(statuses); + if (signerFailure) { + return { + remainingClientOrderIds: requests.map((request) => request.cloid), + signerFailure, + }; + } + return { + remainingClientOrderIds: requests.flatMap((request, index) => + classifyCancelStatus(statuses[index]) === CancelChildOutcome.Refused + ? [request.cloid] + : [], + ), + }; + }; try { const result = await exchangeClient.cancelByCloid({ @@ -8692,7 +8731,7 @@ export class HyperLiquidProvider implements PerpsProvider { }; } - return { remainingClientOrderIds: getRemainingClientOrderIds(statuses) }; + return classifyStatuses(statuses); } catch (error) { // The signer could not sign, so nothing was cancelled. const signerFailure = this.#classifySignerFailure(error); @@ -8704,9 +8743,7 @@ export class HyperLiquidProvider implements PerpsProvider { } const statuses = getCancelStatusesFromError(error, requests.length); if (statuses) { - return { - remainingClientOrderIds: getRemainingClientOrderIds(statuses), - }; + return classifyStatuses(statuses); } this.#deps.debugLogger.log('Order cancellation by CLOID failed', { error: ensureError( @@ -8742,6 +8779,15 @@ export class HyperLiquidProvider implements PerpsProvider { } const classifyStatuses = (statuses: unknown[]): CancelOrderBatchOutcome => { + const signerFailure = this.#classifyStatusSignerFailure(statuses); + if (signerFailure) { + return { + remainingOrderIds: requests.map((request) => request.o), + cancelledOrderIds: [], + responseComplete: false, + signerFailure, + }; + } const remainingOrderIds: number[] = []; const cancelledOrderIds: number[] = []; requests.forEach((request, index) => { @@ -15659,20 +15705,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 }, ); - // The other attempt may end without caching a result (the signer could - // not sign); the re-check below, under our own lock, uses one it cached. await inFlightPromise; + inFlightPromise = PerpsSigningCache.isInFlight( + 'referral', + network, + userAddress, + ); } // Set global in-flight lock diff --git a/packages/perps-controller/src/providers/LighterProvider.ts b/packages/perps-controller/src/providers/LighterProvider.ts index 4a9c8dfc5dc..8bd3edeb6b9 100644 --- a/packages/perps-controller/src/providers/LighterProvider.ts +++ b/packages/perps-controller/src/providers/LighterProvider.ts @@ -1006,10 +1006,11 @@ class LighterSessionCancelledError extends Error { } // EIP-1193 `userRejectedRequest` error code. -const USER_REJECTED_REQUEST_CODE = 4001; +export const USER_REJECTED_REQUEST_CODE = 4001; -// How wallets word a declined signature when they set no code. -const USER_REJECTED_MESSAGE_PATTERN = /user (rejected|denied)/iu; +// 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 preparing the wallet stopped in a way the order path retries: the @@ -1316,25 +1317,29 @@ export class LighterProvider implements PerpsProvider { * Register the venue key ahead of the first order, so its main-account * `personal_sign` surfaces in a guided session instead of at order time. * - * @returns `ready: true` once the venue key is registered; otherwise + * @returns `ready: true` once the venue key is registered, or at once for a + * read-only provider (no signer bridge): it has nothing to prepare and + * never asks the signer, so it does not hold back an aggregated result, and + * `isReadyToTrade` still reports that it cannot trade. Otherwise * `ready: false`: with `KEYRING_LOCKED` whenever the main-account signer is - * not ready (even with a registered venue key), without an error when the - * order path will ask again (the user declined the signature, or the - * wallet has no Lighter account yet), with `PROVIDER_LIFECYCLE_STALE` - * (unlogged) when the provider disconnected or the wallet switched accounts - * meanwhile, and with the logged error when registration failed. + * not ready (even with a registered venue key), with `NO_ACCOUNT_SELECTED` + * when no account is selected, without an error when the order path will + * ask again (the user declined the signature, or the wallet has no Lighter + * account yet), with `PROVIDER_LIFECYCLE_STALE` (unlogged) when the + * provider disconnected or the wallet switched accounts meanwhile, and with + * the logged error when registration failed. */ async prepareTradingWallet(): Promise { if (!this.#walletService.isMainAccountSignerReady()) { return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; } if (!this.#signerBridge) { - // The host enabled Lighter without its signer: a configuration error. - this.#deps.logger.error( - new Error(LIGHTER_SIGNER_UNAVAILABLE_ERROR), - this.#getErrorContext('prepareTradingWallet'), - ); - return { ready: false, error: LIGHTER_SIGNER_UNAVAILABLE_ERROR }; + return { ready: true }; + } + try { + this.#walletService.getUserAddress(); + } catch { + return { ready: false, error: PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED }; } try { await this.#ensureSignerReady(); diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index e77cca1c83f..6a0c587e132 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -1960,11 +1960,12 @@ export class TradingService { this.#deps.logger.error( new Error( - `cancelOrders batch failure: ${reportedFailures.length}/${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: reportedFailures.length, + failureCount: operationResult.failureCount, + reportedFailureCount: reportedFailures.length, cancelAll: params.cancelAll, }), ); @@ -2337,11 +2338,12 @@ export class TradingService { this.#deps.logger.error( new Error( - `closePositions batch failure: ${reportedFailures.length}/${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: reportedFailures.length, + failureCount: operationResult.failureCount, + reportedFailureCount: reportedFailures.length, symbols: params.symbols?.length ?? 0, closeAll: params.closeAll, }), diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index a74deb90e14..0fc69d063ac 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1139,7 +1139,9 @@ export type HyperLiquidCredentials = { * 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`. + * answering. The rejected action failed with `KEYRING_LOCKED`. 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; }; @@ -2161,8 +2163,9 @@ export type PerpsProvider = { * account migration, builder fee, referral or venue-key registration) ahead * of the first order, so the signatures surface in a guided session instead * of at order time. Resolves `ready: true` when none of these steps will ask - * the main account to sign again before the first order. Providers without - * such setup omit it. + * the main account to sign again before the first order (a read-only + * provider, which never asks, resolves it at once). Providers without such + * setup omit it. */ prepareTradingWallet?(): Promise; /** diff --git a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts index 9fef62bb9a6..a658978a635 100644 --- a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts @@ -12,6 +12,8 @@ import { PERPS_ERROR_CODES } from '../../src/perpsErrorCodes.js'; import type { HyperLiquidWalletParams } from '../../src/services/HyperLiquidClientService.js'; import { TradingReadinessCache } from '../../src/services/TradingReadinessCache.js'; import type { + HyperLiquidCredentials, + OrderResult, PerpsAccountSigner, PerpsAgentAccount, PerpsAgentSigner, @@ -47,6 +49,14 @@ const SIGNERS = new Map([ [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; @@ -147,6 +157,8 @@ class MockExchangeClient { // 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 { @@ -241,19 +253,17 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => * agents with `getAgentSigner`. * * @param signer - The host's account signer. + * @param hyperliquid - The host's HyperLiquid credentials. * @returns The controller. */ function createController( signer: PerpsAccountSigner = accountSigner, + hyperliquid: HyperLiquidCredentials = { getAgentSigner, onAgentRejected }, ): PerpsController { return new PerpsController({ messenger: createMessenger(), state: getDefaultPerpsControllerState(), - clientConfig: { - providerCredentials: { - hyperliquid: { getAgentSigner, onAgentRejected }, - }, - }, + clientConfig: { providerCredentials: { hyperliquid } }, infrastructure: { ...infrastructure, accountSigner: signer }, deferEligibilityCheck: true, }); @@ -280,17 +290,16 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => * Place a BTC market buy through the controller. * * @param controller - The initialized controller. - * @returns Whether the venue accepted it. + * @returns The order result. */ - async function placeOrder(controller: PerpsController): Promise { - const result = await controller.placeOrder({ + async function placeOrder(controller: PerpsController): Promise { + return await controller.placeOrder({ symbol: 'BTC', isBuy: true, size: '0.1', orderType: 'market', currentPrice: 50000, }); - return result.success === true; } /** @@ -310,7 +319,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => const placed = await placeOrder(controller); - expect(placed).toBe(true); + expect(placed).toStrictEqual(PLACED_ORDER); expect(signedWrites()).toStrictEqual([ ['approveBuilderFee', MAIN_ADDRESS], ['order', AGENT_ADDRESS], @@ -320,6 +329,22 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ [MAIN_ADDRESS, APPROVE_BUILDER_FEE_PAYLOAD], ]); + expect(onAgentRejected).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('signs L1 actions with the main account when the host has no getAgentSigner', async () => { + const controller = createController(accountSigner, {}); + 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], + ]); + expect(getAgentSigner).not.toHaveBeenCalled(); expect(loggerError).not.toHaveBeenCalled(); }); @@ -332,12 +357,16 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => controller.clearAgentSigners(); const clearedPlaced = await placeOrder(controller); - expect([pinnedPlaced, clearedPlaced]).toStrictEqual([true, true]); + expect([pinnedPlaced, clearedPlaced]).toStrictEqual([ + PLACED_ORDER, + PLACED_ORDER, + ]); expect(signedWrites()).toStrictEqual([ ['order', MAIN_ADDRESS], ['order', AGENT_ADDRESS], ]); expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); + expect(onAgentRejected).not.toHaveBeenCalled(); expect(loggerError).not.toHaveBeenCalled(); }); @@ -348,11 +377,15 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => await controller.init(); controller.setAgentSigner(MAINNET_ACCOUNT, boundAgent); - await placeOrder(controller); + const placedBefore = await placeOrder(controller); await controller.toggleTestnet(); await controller.toggleTestnet(); - await placeOrder(controller); + const placedAfter = await placeOrder(controller); + expect([placedBefore, placedAfter]).toStrictEqual([ + PLACED_ORDER, + PLACED_ORDER, + ]); // The original and the re-created mainnet provider each built SDK // clients. expect(mockVenue.networks).toStrictEqual(['mainnet', 'mainnet']); @@ -362,6 +395,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => ]); expect(getAgentSigner).not.toHaveBeenCalled(); expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + expect(onAgentRejected).not.toHaveBeenCalled(); expect(loggerError).not.toHaveBeenCalled(); }); @@ -374,9 +408,26 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => await controller.init(); const placed = await placeOrder(controller); - expect(placed).toBe(true); + expect(placed).toStrictEqual(PLACED_ORDER); expect(signedWrites()).toStrictEqual([['order', OTHER_AGENT_ADDRESS]]); expect(getAgentSigner).not.toHaveBeenCalled(); + expect(onAgentRejected).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('forgets a setAgentSigner binding cleared before init', async () => { + const boundAgent = createAgent(OTHER_AGENT_ADDRESS, OTHER_AGENT_SIGNATURE); + const controller = 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(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); + expect(boundAgent.signTypedData).not.toHaveBeenCalled(); expect(loggerError).not.toHaveBeenCalled(); }); @@ -388,18 +439,24 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => const controller = createController(); await controller.init(); - await placeOrder(controller); + const resolvedPlaced = await placeOrder(controller); controller.setAgentSigner(MAINNET_ACCOUNT, reboundAgent); - await placeOrder(controller); + const reboundPlaced = await placeOrder(controller); controller.setAgentSigner(MAINNET_ACCOUNT, null); - await placeOrder(controller); + 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], ]); expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); + expect(onAgentRejected).not.toHaveBeenCalled(); expect(loggerError).not.toHaveBeenCalled(); }); @@ -429,7 +486,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => expect(onAgentRejected.mock.calls).toStrictEqual([ [MAINNET_ACCOUNT, AGENT_ADDRESS], ]); - expect(placed).toBe(true); + expect(placed).toStrictEqual(PLACED_ORDER); expect(signedWrites()).toStrictEqual([ ['cancel', AGENT_ADDRESS], ['order', OTHER_AGENT_ADDRESS], @@ -464,7 +521,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => [MAINNET_ACCOUNT, OTHER_AGENT_ADDRESS], ]); // The binding is gone, so the host's getAgentSigner answers. - expect(placed).toBe(true); + expect(placed).toStrictEqual(PLACED_ORDER); expect(signedWrites()).toStrictEqual([ ['cancel', OTHER_AGENT_ADDRESS], ['order', AGENT_ADDRESS], @@ -490,6 +547,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => expect(mockVenue.writes).toStrictEqual([]); expect(accountSigner.signTypedData).not.toHaveBeenCalled(); expect(getAgentSigner).not.toHaveBeenCalled(); + expect(onAgentRejected).not.toHaveBeenCalled(); expect(loggerError).not.toHaveBeenCalled(); }); @@ -534,6 +592,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => ]); expect(agentSigner.signTypedData.mock.calls).toStrictEqual([[L1_PAYLOAD]]); expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); + expect(onAgentRejected).not.toHaveBeenCalled(); expect(loggerError).not.toHaveBeenCalled(); }); }); 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 0c2ab227a84..4875bce25f9 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -13,10 +13,7 @@ import type { MockAnyNamespace, } from '@metamask/messenger'; -import { - AGENT_ADDRESS, - OTHER_AGENT_ADDRESS, -} from '../helpers/agentFixtures.js'; +import { AGENT_ADDRESS } from '../helpers/agentFixtures.js'; import { createMockHyperLiquidProvider, createMockPosition, @@ -997,135 +994,12 @@ describe('PerpsController', () => { ); } - /** - * Build a controller whose host resolves agents with `getAgentSigner`. - * - * @param getAgentSigner - The host resolver. - * @returns The controller. - */ - function createAgentController( - getAgentSigner?: HyperLiquidCredentials['getAgentSigner'], - ): TestablePerpsController { - return new TestablePerpsController({ - messenger: createMockMessenger(), - state: getDefaultPerpsControllerState(), - clientConfig: { - providerCredentials: { hyperliquid: { getAgentSigner } }, - }, - infrastructure: mockInfrastructure, - }); - } - - it("asks the host's getAgentSigner through the HyperLiquid provider's resolver", async () => { - const getAgentSigner = jest.fn().mockResolvedValue(agentSigner); - controller = createAgentController(getAgentSigner); - await controller.init(); - - const resolved = await getProviderAgentResolver()(account); - - expect(resolved).toBe(agentSigner); - expect(getAgentSigner.mock.calls).toStrictEqual([[account]]); - }); - - it('resolves no agent without getAgentSigner or a binding', async () => { - controller = createAgentController(); - await controller.init(); - - expect(await getProviderAgentResolver()(account)).toBeNull(); - }); - - it('answers with a setAgentSigner binding for that account and network only', async () => { - const getAgentSigner = jest.fn().mockResolvedValue(null); - controller = createAgentController(getAgentSigner); - await controller.init(); - const resolve = getProviderAgentResolver(); - - controller.setAgentSigner(account, agentSigner); - - expect(await resolve(account)).toBe(agentSigner); - expect( - await resolve({ - ...account, - mainAddress: '0x9999999999999999999999999999999999999999', - }), - ).toBeNull(); - expect(await resolve({ ...account, isTestnet: true })).toBeNull(); - expect(getAgentSigner.mock.calls).toStrictEqual([ - [ - { - ...account, - mainAddress: '0x9999999999999999999999999999999999999999', - }, - ], - [{ ...account, isTestnet: true }], - ]); - }); - - it('matches a binding whatever the main address casing', async () => { - controller = createAgentController(jest.fn().mockResolvedValue(null)); - await controller.init(); - - controller.setAgentSigner( - { - mainAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', - isTestnet: false, - }, - agentSigner, - ); - - expect( - await getProviderAgentResolver()({ - mainAddress: '0xABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCD', - isTestnet: false, - }), - ).toBe(agentSigner); - }); - - it('drops only a binding to the agent the venue rejected', async () => { - const getAgentSigner = jest.fn().mockResolvedValue(null); - controller = createAgentController(getAgentSigner); - await controller.init(); - const { calls } = ( - HyperLiquidProvider as jest.MockedClass - ).mock; - const { onAgentRejected } = calls[calls.length - 1][0]; - const resolve = getProviderAgentResolver(); - - controller.setAgentSigner(account, agentSigner); - onAgentRejected?.(account, OTHER_AGENT_ADDRESS); - const keptForOtherAgent = await resolve(account); - onAgentRejected?.(account, agentSigner.address); - const afterRejection = await resolve(account); - controller.setAgentSigner(account, null); - onAgentRejected?.(account, agentSigner.address); - const pinKept = await resolve(account); - - expect(keptForOtherAgent).toBe(agentSigner); - expect(afterRejection).toBeNull(); - expect(pinKept).toBeNull(); - expect(getAgentSigner.mock.calls).toStrictEqual([[account]]); - }); - it('rejects trading wallet preparation before init like other provider actions', async () => { - controller = createAgentController(); - await expect(controller.prepareTradingWallet()).rejects.toThrow( PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED, ); }); - it('forgets agent bindings cleared before init', async () => { - const getAgentSigner = jest.fn().mockResolvedValue(agentSigner); - controller = createAgentController(getAgentSigner); - controller.setAgentSigner(account, null); - - controller.clearAgentSigners(); - await controller.init(); - - expect(await getProviderAgentResolver()(account)).toBe(agentSigner); - expect(getAgentSigner.mock.calls).toStrictEqual([[account]]); - }); - it('runs the agent and preparation actions called through the messenger after init', async () => { const getAgentSigner = jest.fn().mockResolvedValue(agentSigner); const rootMessenger = new Messenger< @@ -1182,11 +1056,13 @@ describe('PerpsController', () => { it('drops resolved agents on every provider in aggregated mode, skipping one without clearAgentSigners', async () => { const RealAggregatedPerpsProvider = AggregatedPerpsProviderModule.AggregatedPerpsProvider; + let providerIdsAtConstruction: string[] = []; const aggregatedConstructor = jest .spyOn(AggregatedPerpsProviderModule, 'AggregatedPerpsProvider') - .mockImplementation( - (config) => new RealAggregatedPerpsProvider(config), - ); + .mockImplementation((config) => { + providerIdsAtConstruction = [...config.providers.keys()]; + return new RealAggregatedPerpsProvider(config); + }); mockProvider.clearAgentSigners = jest.fn(); controller = new TestablePerpsController({ messenger: createMockMessenger(), @@ -1210,29 +1086,18 @@ describe('PerpsController', () => { expect(aggregatedConstructor.mock.calls).toStrictEqual([ [ { - providers, + providers: expect.any(Map), defaultProvider: 'hyperliquid', infrastructure: mockInfrastructure, isTestnet: true, }, ], ]); - }); - - it("returns the active provider's trading wallet readiness", async () => { - mockProvider.prepareTradingWallet = jest.fn().mockResolvedValue({ - ready: false, - error: PERPS_ERROR_CODES.KEYRING_LOCKED, - }); - await controller.init(); - - const result = await controller.prepareTradingWallet(); - - expect(result).toStrictEqual({ - ready: false, - error: PERPS_ERROR_CODES.KEYRING_LOCKED, - }); - expect(mockProvider.prepareTradingWallet.mock.calls).toStrictEqual([[]]); + // 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(providers); }); it('reports a trading wallet ready when the provider has no deferred setup', async () => { diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts index be10e8017ee..d57f778ae08 100644 --- a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -1,5 +1,8 @@ import { CandlePeriod } from '../../../src/constants/chartConfig.js'; -import { PROVIDER_CONFIG } from '../../../src/constants/perpsConfig.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 { @@ -1136,7 +1139,10 @@ describe('AggregatedPerpsProvider', () => { [ crash, { - tags: { feature: 'perps', provider: 'hyperliquid' }, + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: 'hyperliquid', + }, context: { name: 'AggregatedPerpsProvider', data: { @@ -1161,27 +1167,34 @@ describe('AggregatedPerpsProvider', () => { infrastructure: mockInfrastructure, isTestnet, }); + const crash = new Error('provider crashed'); Object.assign(mockHLProvider, { - prepareTradingWallet: jest - .fn() - .mockRejectedValue(new Error('provider crashed')), + prepareTradingWallet: jest.fn().mockRejectedValue(crash), }); await networkProvider.prepareTradingWallet(); - expect(mockInfrastructure.logger.error).toHaveBeenCalledWith( - new Error('provider crashed'), - { - tags: { feature: 'perps', provider: 'hyperliquid', network }, - context: { - name: 'AggregatedPerpsProvider', - data: { - method: 'prepareTradingWallet', - providerId: 'hyperliquid', + 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', + }, }, }, - }, - ); + ], + ]); }, ); @@ -1197,24 +1210,31 @@ describe('AggregatedPerpsProvider', () => { infrastructure: mockInfrastructure, isTestnet: false, }); + const crash = new Error('provider crashed'); Object.assign(mockLighterProvider, { - prepareTradingWallet: jest - .fn() - .mockRejectedValue(new Error('provider crashed')), + prepareTradingWallet: jest.fn().mockRejectedValue(crash), }); await networkProvider.prepareTradingWallet(); - expect(mockInfrastructure.logger.error).toHaveBeenCalledWith( - new Error('provider crashed'), - { - tags: { feature: 'perps', provider: 'lighter', network: 'testnet' }, - context: { - name: 'AggregatedPerpsProvider', - data: { method: 'prepareTradingWallet', providerId: 'lighter' }, + 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 () => { 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 index bb401767b77..3191ce66f40 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts @@ -8,6 +8,7 @@ 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 { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider.js'; import { @@ -21,7 +22,10 @@ import { PerpsSigningCache, TradingReadinessCache, } from '../../../src/services/TradingReadinessCache.js'; -import { HL_ABSTRACTION_WIRE } from '../../../src/types/hyperliquid-types.js'; +import { + HL_ABSTRACTION_WIRE, + HL_UNIFIED_ACCOUNT_MODE, +} from '../../../src/types/hyperliquid-types.js'; import { PerpsAnalyticsEvent } from '../../../src/types/index.js'; import type { HyperLiquidCredentials, @@ -145,7 +149,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () // The SDK writes the provider makes for the selected account on mainnet. const MIGRATION_WRITE = [ - { user: ACCOUNT_ADDRESS, abstraction: 'unifiedAccount' }, + { user: ACCOUNT_ADDRESS, abstraction: HL_UNIFIED_ACCOUNT_MODE }, ]; const SILENT_MIGRATION_WRITE = [ { abstraction: HL_ABSTRACTION_WIRE.unifiedAccount }, @@ -657,6 +661,36 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(result).toStrictEqual({ ready: true }); }); + it('lets only one of several waiting providers make the referral attempt', async () => { + const first = createAccountSignerProvider({ + abstraction: 'unifiedAccount', + }); + // Both providers write through the last fixture's exchange client. + const { accountSignerProvider: second, exchangeClient } = + createAccountSignerProvider({ abstraction: 'unifiedAccount' }); + const lock = holdReferralLock(); + + let results; + try { + const preparing = [ + first.accountSignerProvider.prepareTradingWallet(), + second.prepareTradingWallet(), + ]; + await lock.waiting; + // Let both providers reach the lock. + await new Promise((resolve) => setTimeout(resolve, 0)); + lock.release(); + results = await Promise.all(preparing); + } finally { + lock.release(); + } + + expect(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 } = @@ -781,7 +815,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () failure, { tags: { - feature: 'perps', + feature: PERPS_CONSTANTS.FeatureName, provider: 'hyperliquid', network: 'mainnet', }, @@ -825,11 +859,23 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () REFERRAL_WRITE, ]); expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + // Migration, then referral. expect( - keyringCalls(call).filter( - (action) => action === 'KeyringController:signTypedMessage', + call.mock.calls.filter( + ([action]) => action === 'KeyringController:signTypedMessage', ), - ).toHaveLength(2); + ).toStrictEqual([ + [ + 'KeyringController:signTypedMessage', + { from: ACCOUNT_ADDRESS, data: USER_SIGNED_PAYLOAD }, + 'V4', + ], + [ + 'KeyringController:signTypedMessage', + { from: ACCOUNT_ADDRESS, data: L1_PAYLOAD }, + 'V4', + ], + ]); }); it('attempts the referral again when the signer locks while signing it', async () => { @@ -1075,11 +1121,17 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () const getAgentSigner = jest .fn() .mockRejectedValue(new Error('agent store unavailable')); - const { accountSignerProvider, accountSigner, exchangeClient } = - createAccountSignerProvider({ - abstraction: 'default', - getAgentSigner, - }); + 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(); @@ -1102,7 +1154,13 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () [MAINNET_ACCOUNT], [MAINNET_ACCOUNT], ]); - expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + // 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([ + [ACCOUNT_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([ @@ -1281,7 +1339,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () ]); }); - it('stops agent signing on lock and resumes after unlock', async () => { + 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, initialize } = createAccountSignerProvider({ @@ -1449,6 +1507,9 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () ]); }); + const CHECKSUMMED_AGENT_ADDRESS = + '0x00000000000000000000000000000000000A9E17' as const; + const rejection = (address: string): Error => new Error(`User or API Wallet ${address} does not exist.`); @@ -1758,6 +1819,48 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(loggerError).not.toHaveBeenCalled(); }); + it('keeps the old protection and fails with KEYRING_LOCKED when its cancel is rejected in a status entry', async () => { + const { + accountSignerProvider, + exchangeClient, + infoClient, + initialize, + onAgentRejected, + } = createRejectingProvider('cancel'); + infoClient.frontendOpenOrders.mockResolvedValue([TAKE_PROFIT_ORDER]); + await accountSignerProvider.getMarketDataWithPrices(); + const [[wallet]] = initialize.mock.calls; + exchangeClient.cancel.mockImplementation(async () => { + await wallet.signTypedData(L1_PAYLOAD); + return { + status: 'ok', + response: { + data: { + statuses: [{ error: rejection(AGENT_ADDRESS).message }], + }, + }, + }; + }); + + 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: 456 }] }], + ]); + expect(exchangeClient.order).not.toHaveBeenCalled(); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + it('still drops the agent and fails with KEYRING_LOCKED when onAgentRejected throws', async () => { const { accountSignerProvider, @@ -2010,6 +2113,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () await bothSigned.promise; venue.resolve(); const results = await Promise.all(cancelling); + await wallet.signTypedData(L1_PAYLOAD); expect(results).toStrictEqual([ { @@ -2027,9 +2131,57 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () [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, initialize } = + 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]] = initialize.mock.calls; + exchangeClient.cancel.mockImplementation(async () => { + await wallet.signTypedData(L1_PAYLOAD); + throw rejection(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, + }); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, CHECKSUMMED_AGENT_ADDRESS.toLowerCase()], + ]); + // Dropped, so the next L1 action asks again. + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); + }); + it('fails a batch cancel with KEYRING_LOCKED without logging it', async () => { const { accountSignerProvider, onAgentRejected } = createRejectingProvider('cancel'); @@ -2098,14 +2250,18 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () }); selectAccount(OTHER_ACCOUNT_ADDRESS); await wallet.signTypedData(L1_PAYLOAD); + selectAccount(ACCOUNT_ADDRESS); + await wallet.signTypedData(L1_PAYLOAD); expect(onAgentRejected.mock.calls).toStrictEqual([ - [MAINNET_ACCOUNT, agentSigner.address], + [MAINNET_ACCOUNT, AGENT_ADDRESS], ]); - // The other account's agent stays cached, so it is not asked again. + // 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_ACCOUNT_ADDRESS, isTestnet: false }], [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], ]); }); @@ -2254,7 +2410,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () reduceOnly: false, side: 'B', sz: '1', - timestamp: 1_700_000_000_000, + timestamp: NOW, user: ACCOUNT_ADDRESS, }, status: { status: 'activated' }, @@ -2288,13 +2444,13 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () response: { data: { statuses } }, }); - type SignerFailure = 'locked' | 'unavailable' | 'rejected'; + 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` locks * the keyring (no agent), makes the agent fail to sign, or has the venue - * reject the agent. + * 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. @@ -2354,6 +2510,27 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () // Only a rejected agent gets this far. throw rejection(fixture.agentSigner.address); }; + // The venue answers with a rejection in every status entry. + const rejectedEntry = { + error: rejection(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, @@ -2371,6 +2548,12 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () 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); } @@ -2379,16 +2562,26 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () } const SIGNER_FAILURES = [ - { failure: 'locked', rejectedAgents: [] }, - { failure: 'unavailable', rejectedAgents: [] }, + { name: 'a locked keyring', 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 ($failure signer)', + 'fails a TWAP cancel with KEYRING_LOCKED without logging it, for $name', async ({ failure, rejectedAgents }) => { const { provider, twapCancel, onAgentRejected, failSigning } = createStrategyProvider(failure); @@ -2413,7 +2606,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () ); it.each(SIGNER_FAILURES)( - 'fails a scale cancel with KEYRING_LOCKED and keeps the ladder cancellable ($failure signer)', + '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); @@ -2468,7 +2661,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () ); it.each(SIGNER_FAILURES)( - 'fails a scale cancel by client order ID with KEYRING_LOCKED ($failure signer)', + 'fails a scale cancel by client order ID with KEYRING_LOCKED, for $name', async ({ failure, rejectedAgents }) => { const { provider, @@ -2517,19 +2710,21 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () orderId: placed.orderId, error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); - expect(cancelByCloid).toHaveBeenLastCalledWith({ - cancels: orders.map(({ c }) => ({ - asset: 1, - cloid: c, - })), - }); + 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 ($failure signer)', + 'fails a chase cancel with KEYRING_LOCKED without logging it, for $name', async ({ failure, rejectedAgents }) => { const { provider, cancel, onAgentRejected, failSigning } = createStrategyProvider(failure); @@ -2558,49 +2753,6 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () }, ); - it('drops an agent the venue rejects in a TWAP cancel status entry', async () => { - const { - provider, - twapCancel, - getAgentSigner, - onAgentRejected, - signL1Action, - } = createStrategyProvider('rejected'); - await provider.getMarketDataWithPrices(); - twapCancel.mockImplementation(async () => { - await signL1Action(); - return { - status: 'ok', - response: { - type: 'twapCancel', - data: { status: { error: rejection(AGENT_ADDRESS).message } }, - }, - }; - }); - - const result = await provider.cancelOrder({ - orderId: '987', - symbol: 'ETH', - orderType: 'twap', - }); - await signL1Action(); - - expect(result).toStrictEqual({ - success: false, - orderId: '987', - 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(); - }); - describe('during a chase re-price', () => { beforeEach(() => { jest.useFakeTimers(); @@ -2689,6 +2841,11 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () 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/LighterProvider.account-signer.test.ts b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts index 74a6c0d3192..37b524bdce7 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts @@ -1,8 +1,11 @@ +import type { Hex } from '@metamask/utils'; + 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 { - LIGHTER_SIGNER_UNAVAILABLE_ERROR, LighterProvider, + USER_REJECTED_REQUEST_CODE, } from '../../../src/providers/LighterProvider.js'; import { LighterApiError, @@ -101,6 +104,7 @@ type BuiltProvider = { client: { sendTx: jest.Mock; getAccountsByL1Address: jest.Mock; + getApiKeys: jest.Mock; getNextNonce: jest.Mock; }; accountSigner: { signPersonalMessage: jest.Mock }; @@ -202,10 +206,9 @@ describe('LighterProvider with accountSigner', () => { networkSupported: true, authenticatedAddress: address, }); - expect(accountSigner.signPersonalMessage).toHaveBeenCalledWith( - address, - CHANGE_PUB_KEY_BODY, - ); + expect(accountSigner.signPersonalMessage.mock.calls).toStrictEqual([ + [address, CHANGE_PUB_KEY_BODY], + ]); const changePubKey = calls.find( (wasmCall) => wasmCall.function === '_signChangePubKey', ); @@ -316,7 +319,7 @@ describe('LighterProvider with accountSigner', () => { failure, { tags: { - feature: 'perps', + feature: PERPS_CONSTANTS.FeatureName, provider: 'LighterProvider', network: 'testnet', }, @@ -332,26 +335,39 @@ describe('LighterProvider with accountSigner', () => { it.each([ [ 'an EIP-1193 rejection code', - Object.assign(new Error('Rejected'), { code: 4001 }), + Object.assign(new Error('Rejected'), { + code: USER_REJECTED_REQUEST_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 rejection code wrapped in the cause chain', new Error('Signing failed', { - cause: Object.assign(new Error('Rejected'), { code: 4001 }), + cause: Object.assign(new Error('Rejected'), { + code: USER_REJECTED_REQUEST_CODE, + }), }), ], ])( - 'reports a decline signalled by %s as a retry without logging', + 'reports a decline signalled by %s as a retry without logging, and asks again', async (_signal, rejection) => { - const { provider, accountSigner, deps } = buildProvider(); - accountSigner.signPersonalMessage.mockRejectedValue(rejection); + const { provider, address, accountSigner, client, deps } = + buildProvider(); + accountSigner.signPersonalMessage.mockRejectedValueOnce(rejection); const loggerError = jest.spyOn(deps.logger, 'error'); - const result = await provider.prepareTradingWallet(); + const declined = await provider.prepareTradingWallet(); + const retried = await provider.prepareTradingWallet(); - expect(result).toStrictEqual({ ready: false }); + 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(); }, ); @@ -360,14 +376,51 @@ describe('LighterProvider with accountSigner', () => { const { provider, client, deps } = buildProvider({ findAccountByAddress: true, }); - client.getAccountsByL1Address.mockRejectedValue( + 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 }); + 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: false }); + 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, selectAccount } = + buildProvider(); + // An empty selection: the wallet service finds no account. + selectAccount('' as Hex); + 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(); }); @@ -406,30 +459,18 @@ describe('LighterProvider with accountSigner', () => { }, ); - it('reports and logs a missing signer bridge', async () => { - const { provider, deps } = buildProvider({ withoutBridge: true }); + 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: false, - error: LIGHTER_SIGNER_UNAVAILABLE_ERROR, - }); - expect(loggerError).toHaveBeenCalledWith( - new Error(LIGHTER_SIGNER_UNAVAILABLE_ERROR), - { - tags: { - feature: 'perps', - provider: 'LighterProvider', - network: 'testnet', - }, - context: { - name: 'LighterProvider.prepareTradingWallet', - data: { isTestnet: true }, - }, - }, - ); + expect(result).toStrictEqual({ ready: true }); + expect(accountSigner.signPersonalMessage).not.toHaveBeenCalled(); + expect(client.sendTx).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); }); it('reports KEYRING_LOCKED when the signer locks once the venue key is registered', async () => { 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 index 91ea0e0f7a3..b41015c4a0a 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts @@ -4,6 +4,10 @@ import type * as HyperLiquidSigning from '@nktkas/hyperliquid/signing'; import { recoverTypedDataAddress } from 'viem'; import { generatePrivateKey, 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, @@ -189,8 +193,8 @@ describe('HyperLiquidWalletService wallet adapter with an agent', () => { const signature = await adapter.signTypedData(L1_PAYLOAD); expect(signature).toBe(AGENT_SIGNATURE); - expect(resolveAgent).toHaveBeenCalledWith(mainAddress); - expect(agentSign).toHaveBeenCalledWith(L1_PAYLOAD); + expect(resolveAgent.mock.calls).toStrictEqual([[mainAddress]]); + expect(agentSign.mock.calls).toStrictEqual([[L1_PAYLOAD]]); expect(mainSign).not.toHaveBeenCalled(); expect(keyringCalls(call)).toStrictEqual([]); }); @@ -250,8 +254,7 @@ describe('HyperLiquidWalletService wallet adapter with an agent', () => { selectAccount(OTHER_MAIN_ADDRESS); await adapter.signTypedData(L1_PAYLOAD); - expect(resolveAgent).toHaveBeenCalledWith(OTHER_MAIN_ADDRESS); - expect(resolveAgent).not.toHaveBeenCalledWith(mainAddress); + expect(resolveAgent.mock.calls).toStrictEqual([[OTHER_MAIN_ADDRESS]]); }); it('propagates agent resolution failures', async () => { @@ -339,7 +342,7 @@ describe('HyperLiquidWalletService wallet adapter with an agent and a keyring', const signature = await adapter.signTypedData(L1_PAYLOAD); expect(signature).toBe(AGENT_SIGNATURE); - expect(agentSign).toHaveBeenCalledWith(L1_PAYLOAD); + expect(agentSign.mock.calls).toStrictEqual([[L1_PAYLOAD]]); expect(keyringCalls(call)).toStrictEqual([]); }); @@ -479,9 +482,9 @@ describeWithSdk( wallet: adapter, action: { type: 'approveBuilderFee', - signatureChainId: '0x66eee', + signatureChainId: ARBITRUM_SEPOLIA_CHAIN_ID, hyperliquidChain: 'Testnet', - maxFeeRate: '0.1%', + maxFeeRate: BUILDER_FEE_CONFIG.MaxFeeRate, builder: agentAccount.address, nonce: 1, }, diff --git a/packages/perps-controller/tests/src/services/LighterWalletService.test.ts b/packages/perps-controller/tests/src/services/LighterWalletService.test.ts index 1a40e558e05..a6ce56c6ad0 100644 --- a/packages/perps-controller/tests/src/services/LighterWalletService.test.ts +++ b/packages/perps-controller/tests/src/services/LighterWalletService.test.ts @@ -1,4 +1,5 @@ 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 { @@ -79,7 +80,7 @@ describe('LighterWalletService', () => { 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, ); }); }); @@ -90,7 +91,7 @@ describe('LighterWalletService', () => { isTestnet: true, }); await expect(service.signPersonalMessage('x')).rejects.toThrow( - 'NO_ACCOUNT_SELECTED', + PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED, ); }); @@ -98,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 e46040cb16c..b59df964113 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -1833,17 +1833,21 @@ 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 () => { @@ -1917,13 +1921,14 @@ describe('TradingService', () => { expect((mockDeps.logger.error as jest.Mock).mock.calls).toStrictEqual([ [ new Error( - 'cancelOrders batch failure: 1/2 failed - ETH/order-2: rate limit', + 'cancelOrders batch failure: 2/2 failed (1 reported) - ETH/order-2: rate limit', ), { controller: 'TradingService', method: 'cancelOrders', successCount: 0, - failureCount: 1, + failureCount: 2, + reportedFailureCount: 1, cancelAll: true, }, ], @@ -2592,17 +2597,22 @@ 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 () => { @@ -2658,12 +2668,15 @@ describe('TradingService', () => { expect((mockDeps.logger.error as jest.Mock).mock.calls).toStrictEqual([ [ - new Error('closePositions batch failure: 1/2 failed - ETH: min size'), + new Error( + 'closePositions batch failure: 2/2 failed (1 reported) - ETH: min size', + ), { controller: 'TradingService', method: 'closePositions', successCount: 0, - failureCount: 1, + failureCount: 2, + reportedFailureCount: 1, symbols: 0, closeAll: true, }, diff --git a/packages/perps-controller/tests/src/services/agentSigner.test.ts b/packages/perps-controller/tests/src/services/agentSigner.test.ts index 9ee6ac17f36..1b2428a322d 100644 --- a/packages/perps-controller/tests/src/services/agentSigner.test.ts +++ b/packages/perps-controller/tests/src/services/agentSigner.test.ts @@ -19,6 +19,58 @@ const AGENT = { } as const; describe('AgentBindings', () => { + it('resolves no agent without getAgentSigner or a binding', async () => { + const bindings = new AgentBindings(undefined); + + expect(await bindings.resolve(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 = { + ...ACCOUNT, + mainAddress: '0x9999999999999999999999999999999999999999', + }; + const testnetAccount: PerpsAgentAccount = { ...ACCOUNT, isTestnet: true }; + + bindings.set(ACCOUNT, AGENT); + + expect(await bindings.resolve(ACCOUNT)).toBe(AGENT); + expect(await bindings.resolve(otherAccount)).toBeNull(); + expect(await bindings.resolve(testnetAccount)).toBeNull(); + expect(getAgentSigner.mock.calls).toStrictEqual([ + [otherAccount], + [testnetAccount], + ]); + }); + + it('matches a binding whatever the main address casing', async () => { + const getAgentSigner = jest.fn().mockResolvedValue(null); + const bindings = new AgentBindings(getAgentSigner); + + bindings.set(ACCOUNT, AGENT); + + expect( + await bindings.resolve({ + ...ACCOUNT, + mainAddress: '0xABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCD', + }), + ).toBe(AGENT); + expect(getAgentSigner).not.toHaveBeenCalled(); + }); + + it('forgets bindings and pins once cleared', async () => { + const getAgentSigner = jest.fn().mockResolvedValue(AGENT); + const bindings = new AgentBindings(getAgentSigner); + bindings.set(ACCOUNT, null); + + bindings.clear(); + + expect(await bindings.resolve(ACCOUNT)).toBe(AGENT); + expect(getAgentSigner.mock.calls).toStrictEqual([[ACCOUNT]]); + }); + it('releases a binding to the rejected agent whatever the address casing', async () => { const getAgentSigner = jest.fn().mockResolvedValue(null); const bindings = new AgentBindings(getAgentSigner); @@ -30,7 +82,21 @@ describe('AgentBindings', () => { ); expect(await bindings.resolve(ACCOUNT)).toBeNull(); - expect(getAgentSigner).toHaveBeenCalledWith(ACCOUNT); + expect(getAgentSigner.mock.calls).toStrictEqual([[ACCOUNT]]); + }); + + it("releases only the rejecting account's binding to the agent", async () => { + const getAgentSigner = jest.fn().mockResolvedValue(null); + const bindings = new AgentBindings(getAgentSigner); + const testnetAccount: PerpsAgentAccount = { ...ACCOUNT, isTestnet: true }; + bindings.set(ACCOUNT, AGENT); + bindings.set(testnetAccount, AGENT); + + bindings.release(testnetAccount, AGENT.address); + + expect(await bindings.resolve(ACCOUNT)).toBe(AGENT); + expect(await bindings.resolve(testnetAccount)).toBeNull(); + expect(getAgentSigner.mock.calls).toStrictEqual([[testnetAccount]]); }); it('keeps a binding to another agent and a pin when an agent is rejected', async () => { From 4e8abc62338eb9a584c0150c938a8343f6f283d4 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 29 Sep 2026 11:49:43 +0800 Subject: [PATCH 20/33] fix(perps-controller): report a rejected agent once, with the host's address - A batch cancel whose status entries reject the agent reports it once per signed request. - onAgentRejected receives the agent's address as the host supplied it, not the venue's lowercased form. - prepareTradingWallet docs say which steps the main account signs and that the agent signs the referral and silent migration. - LIGHTER_SIGNER_UNAVAILABLE_ERROR is module-private again. - Tests give each fixture provider its own client service, pin exact calls and results, and cover the one-l "User canceled" decline. --- packages/perps-controller/CHANGELOG.md | 7 +- .../PerpsController-method-action-types.ts | 19 ++--- .../perps-controller/src/PerpsController.ts | 19 ++--- .../src/providers/HyperLiquidProvider.ts | 36 +++++----- .../src/providers/LighterProvider.ts | 3 +- packages/perps-controller/src/types/index.ts | 19 ++--- .../tests/helpers/providerMocks.ts | 4 +- ...ntroller.agent-signing.integration.test.ts | 1 - .../providers/AggregatedPerpsProvider.test.ts | 55 ++++++++++++-- ...HyperLiquidProvider.account-signer.test.ts | 72 +++++++++++-------- .../LighterProvider.account-signer.test.ts | 7 ++ .../tests/src/services/TradingService.test.ts | 10 ++- 12 files changed, 165 insertions(+), 87 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 0d878be7d1b..580c15803b3 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -25,12 +25,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 for each write the venue rejects with an agent, so the client can re-check its approval + - 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 setup that needs a main-account signature before the first order: account migration, builder fee and referral on HyperLiquid, venue-key registration on Lighter ([#10559](https://github.com/MetaMask/core/pull/10559)) - - Resolves a `ReadyToTradeResult` that is `ready: true` once the main-account signer is ready and none of these steps will ask it to sign again before the first order; the aggregated provider prepares every provider in turn +- 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 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; the aggregated provider prepares every provider in turn - Implemented by the exported `HyperLiquidProvider` and by the Lighter provider, which resolves `ready: true` at once when it is read-only (no signer bridge) - Add optional `isTestnet` to `AggregatedProviderConfig`, which tags the errors the aggregated provider logs with the network ([#10559](https://github.com/MetaMask/core/pull/10559)) diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index e45ab7f97e7..6abbe42e908 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -939,15 +939,18 @@ export type PerpsControllerClearAgentSignersAction = { }; /** - * Run the active provider's setup that needs a main-account signature + * Run the active provider's deferred trading setup ahead of the first order * (HyperLiquid account migration, builder fee and referral; Lighter - * venue-key registration) ahead of the first order, so a hardware wallet - * signs it in one guided session, such as agent setup, instead of at order - * time. - * - * @returns `ready: true` when none of these steps will ask the main account - * to sign again before the first order; providers without deferred setup - * are ready. + * 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; `ready: false` while one will be retried + * (it was declined, or its signer, the main account or the agent, could not + * sign). Providers without deferred setup are ready. * @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. diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 91b4d2a529a..012d8c01d83 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -5923,15 +5923,18 @@ export class PerpsController extends BaseController< } /** - * Run the active provider's setup that needs a main-account signature + * Run the active provider's deferred trading setup ahead of the first order * (HyperLiquid account migration, builder fee and referral; Lighter - * venue-key registration) ahead of the first order, so a hardware wallet - * signs it in one guided session, such as agent setup, instead of at order - * time. - * - * @returns `ready: true` when none of these steps will ask the main account - * to sign again before the first order; providers without deferred setup - * are ready. + * 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; `ready: false` while one will be retried + * (it was declined, or its signer, the main account or the agent, could not + * sign). Providers without deferred setup are ready. * @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. diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 58f09774e7e..471be95a3a5 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -1572,10 +1572,11 @@ export class HyperLiquidProvider implements PerpsProvider { // record rather than from the selected account, which may have changed // while the write was in flight. Kept across clearAgentSigners, so the // rejection of an agent that was replaced while its action was in flight is - // still recognized. + // still recognized. Keyed by the lowercased address the venue reports; the + // value keeps the agent's own address, as the host supplied it. readonly #agentSignedFor = new Map< string, - { key: string; account: PerpsAgentAccount } + { key: string; account: PerpsAgentAccount; agentAddress: Hex } >(); readonly #onAgentRejected: HyperLiquidProviderOptions['onAgentRejected']; @@ -2140,6 +2141,7 @@ export class HyperLiquidProvider implements PerpsProvider { this.#agentSignedFor.set(agentSigner.address.toLowerCase(), { key, account, + agentAddress: agentSigner.address, }); } else { this.#agentSigners.delete(key); @@ -2182,7 +2184,8 @@ export class HyperLiquidProvider implements PerpsProvider { * reads like a wallet with no account. * * @param error - The caught error. - * @returns The rejected agent with its account, or undefined. + * @returns The rejected agent, with its address as the host supplied it + * and the account it signed for, or undefined. */ #findRejectedAgent( error: unknown, @@ -2195,16 +2198,12 @@ export class HyperLiquidProvider implements PerpsProvider { ) { return undefined; } - const agentAddress = UNKNOWN_WALLET_ADDRESS_PATTERN.exec( + const reportedAddress = UNKNOWN_WALLET_ADDRESS_PATTERN.exec( ensureError(error, 'HyperLiquidProvider.findRejectedAgent').message, - )?.[1] as Hex | undefined; - const signedFor = - agentAddress === undefined - ? undefined - : this.#agentSignedFor.get(agentAddress.toLowerCase()); - return agentAddress && signedFor - ? { ...signedFor, agentAddress } - : undefined; + )?.[1]; + return reportedAddress === undefined + ? undefined + : this.#agentSignedFor.get(reportedAddress.toLowerCase()); } /** @@ -2221,10 +2220,7 @@ export class HyperLiquidProvider implements PerpsProvider { return false; } const { account, key, agentAddress } = rejected; - if ( - this.#resolvedAgents.get(key)?.address.toLowerCase() === - agentAddress.toLowerCase() - ) { + if (this.#resolvedAgents.get(key)?.address === agentAddress) { this.#agentSigners.delete(key); this.#resolvedAgents.delete(key); } @@ -9648,6 +9644,9 @@ export class HyperLiquidProvider implements PerpsProvider { result.status === 'ok' && statuses.length === ordinaryOrders.length ) { + // One signature covers the batch, so a signer failure is classified + // (and reported to the host) once, and fails every entry. + const signerFailure = this.#classifyStatusSignerFailure(statuses); ordinaryOrders.forEach(({ index, order }, statusIndex) => { const status: unknown = statuses[statusIndex]; const success = status === 'success'; @@ -9665,7 +9664,10 @@ export class HyperLiquidProvider implements PerpsProvider { error: statusError === undefined ? PERPS_ERROR_CODES.BATCH_CANCEL_FAILED - : this.#mapStatusError(statusError).message, + : ( + signerFailure ?? + this.#mapError(new Error(statusError)) + ).message, }), }; }); diff --git a/packages/perps-controller/src/providers/LighterProvider.ts b/packages/perps-controller/src/providers/LighterProvider.ts index 8bd3edeb6b9..c5d3d48b883 100644 --- a/packages/perps-controller/src/providers/LighterProvider.ts +++ b/packages/perps-controller/src/providers/LighterProvider.ts @@ -979,8 +979,7 @@ const deriveLighterExecutionPrice = ( : referencePrice * (1 - slippageFraction); const LIGHTER_NOT_SUPPORTED_ERROR = 'Lighter operation not yet supported'; -export const LIGHTER_SIGNER_UNAVAILABLE_ERROR = - 'Lighter signer bridge not configured'; +const LIGHTER_SIGNER_UNAVAILABLE_ERROR = 'Lighter signer bridge not configured'; const LIGHTER_MAINNET_EXPLORER_URL = 'https://scan.lighter.xyz'; const LIGHTER_TESTNET_EXPLORER_URL = 'https://testnet.zklighter.elliot.ai'; diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 0fc69d063ac..d23d1bd8e07 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1139,7 +1139,8 @@ export type HyperLiquidCredentials = { * 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 is called + * 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. */ @@ -2159,13 +2160,15 @@ export type PerpsProvider = { initialize(): Promise; isReadyToTrade(): Promise; /** - * Run the deferred setup that needs a main-account signature (for example - * account migration, builder fee, referral or venue-key registration) ahead - * of the first order, so the signatures surface in a guided session instead - * of at order time. Resolves `ready: true` when none of these steps will ask - * the main account to sign again before the first order (a read-only - * provider, which never asks, resolves it at once). Providers without such - * setup omit it. + * Run the deferred trading setup (for example account migration, builder + * fee, referral or venue-key registration) ahead of the first order, so its + * signatures happen in a guided session instead of at order time. User-signed + * steps need the main account; HyperLiquid L1 steps (the referral, the + * silent migration) are signed by an agent when one resolves. Resolves + * `ready: true` when 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 (a read-only provider, which never signs, + * resolves `ready: true` at once). Providers without such setup omit it. */ prepareTradingWallet?(): Promise; /** diff --git a/packages/perps-controller/tests/helpers/providerMocks.ts b/packages/perps-controller/tests/helpers/providerMocks.ts index 0a47f5fa977..a5099684fd2 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 => ({ @@ -217,7 +219,7 @@ export const createMockInfoClient = ( referral: jest.fn().mockResolvedValue({ referrerState: { stage: 'ready', - data: { code: 'MMCSI' }, + data: { code: REFERRAL_CONFIG.MainnetCode }, }, }), maxBuilderFee: jest.fn().mockResolvedValue(1), diff --git a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts index a658978a635..ecf94ce6b2b 100644 --- a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts @@ -344,7 +344,6 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ [MAIN_ADDRESS, L1_PAYLOAD], ]); - expect(getAgentSigner).not.toHaveBeenCalled(); expect(loggerError).not.toHaveBeenCalled(); }); diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts index d57f778ae08..ea4ef6dc360 100644 --- a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -1198,6 +1198,45 @@ describe('AggregatedPerpsProvider', () => { }, ); + 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 }, @@ -1239,11 +1278,12 @@ describe('AggregatedPerpsProvider', () => { it('prepares the next provider only after the previous one settles', async () => { const firstPreparation = createDeferred<{ ready: boolean }>(); + const prepareHyperLiquid = jest.fn( + async () => await firstPreparation.promise, + ); const prepareLighter = jest.fn().mockResolvedValue({ ready: true }); Object.assign(mockHLProvider, { - prepareTradingWallet: jest.fn( - async () => await firstPreparation.promise, - ), + prepareTradingWallet: prepareHyperLiquid, }); Object.assign(mockLighterProvider, { prepareTradingWallet: prepareLighter, @@ -1251,12 +1291,15 @@ describe('AggregatedPerpsProvider', () => { const preparing = aggregatedProvider.prepareTradingWallet(); await Promise.resolve(); - const startedBeforeFirstSettled = prepareLighter.mock.calls.length; + const hyperLiquidCallsBeforeSettling = [...prepareHyperLiquid.mock.calls]; + const lighterCallsBeforeSettling = [...prepareLighter.mock.calls]; firstPreparation.resolve({ ready: true }); const result = await preparing; - expect(startedBeforeFirstSettled).toBe(0); - expect(prepareLighter).toHaveBeenCalledTimes(1); + expect(hyperLiquidCallsBeforeSettling).toStrictEqual([[]]); + expect(lighterCallsBeforeSettling).toStrictEqual([]); + expect(prepareHyperLiquid.mock.calls).toStrictEqual([[]]); + expect(prepareLighter.mock.calls).toStrictEqual([[]]); expect(result).toStrictEqual({ ready: true }); }); 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 index 3191ce66f40..7506a70da1c 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts @@ -255,7 +255,6 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () const initialize = jest.fn(async (wallet: HyperLiquidWalletParams) => { sdkWallet = wallet; }); - Object.assign(mockClientService, { initialize }); const signThroughSdkWallet = ( payload: PerpsTypedDataPayload, @@ -289,16 +288,15 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () .mockResolvedValue(options.abstraction ?? 'dexAbstraction'), ...options.info, }); - mockClientService.getExchangeClient.mockReturnValue( - exchangeClient as unknown as ReturnType< - HyperLiquidClientService['getExchangeClient'] - >, - ); - mockClientService.getInfoClient.mockReturnValue( - infoClient as unknown as ReturnType< - HyperLiquidClientService['getInfoClient'] - >, - ); + // 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 @@ -338,7 +336,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(keyringCalls(call)).toStrictEqual([]); }); - it('defers the init-time migration when accountSigner reports a hardware wallet', async () => { + it('defers the init-time migration when accountSigner requires signature confirmation', async () => { const { accountSignerProvider, accountSigner, call, exchangeClient } = createAccountSignerProvider({ signer: { requiresSignatureConfirmation: () => true }, @@ -492,7 +490,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () }); describe('prepareTradingWallet', () => { - it('runs the deferred migration, builder fee and referral setup and reports ready', async () => { + it('runs the deferred migration and referral, finds the builder fee approved, and reports ready', async () => { const { accountSignerProvider, accountSigner, @@ -516,10 +514,11 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], [ACCOUNT_ADDRESS, L1_PAYLOAD], ]); - expect(infoClient.maxBuilderFee).toHaveBeenCalledWith({ - user: ACCOUNT_ADDRESS, - builder: BUILDER_FEE_CONFIG.MainnetBuilder, - }); + // Already approved, so nothing is signed for it. + expect(infoClient.maxBuilderFee.mock.calls).toStrictEqual([ + [{ user: ACCOUNT_ADDRESS, builder: BUILDER_FEE_CONFIG.MainnetBuilder }], + ]); + expect(exchangeClient.approveBuilderFee).not.toHaveBeenCalled(); }); it('signs every setup step, so the first order signs only itself', async () => { @@ -665,16 +664,16 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () const first = createAccountSignerProvider({ abstraction: 'unifiedAccount', }); - // Both providers write through the last fixture's exchange client. - const { accountSignerProvider: second, exchangeClient } = - createAccountSignerProvider({ abstraction: 'unifiedAccount' }); + const second = createAccountSignerProvider({ + abstraction: 'unifiedAccount', + }); const lock = holdReferralLock(); let results; try { const preparing = [ first.accountSignerProvider.prepareTradingWallet(), - second.prepareTradingWallet(), + second.accountSignerProvider.prepareTradingWallet(), ]; await lock.waiting; // Let both providers reach the lock. @@ -685,9 +684,13 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () lock.release(); } - expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ - REFERRAL_WRITE, - ]); + // 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 }]); }); @@ -1629,7 +1632,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(loggerError).not.toHaveBeenCalled(); }); - it('drops an agent the venue rejects in a batch cancel status entry', async () => { + it('drops an agent the venue rejects in batch cancel status entries, and reports it once', async () => { const { accountSignerProvider, exchangeClient, @@ -1647,7 +1650,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () data: { statuses: [ { error: rejection(AGENT_ADDRESS).message }, - 'success', + { error: rejection(AGENT_ADDRESS).message }, ], }, }, @@ -1661,9 +1664,9 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () await wallet.signTypedData(L1_PAYLOAD); expect(result).toStrictEqual({ - success: true, - successCount: 1, - failureCount: 1, + success: false, + successCount: 0, + failureCount: 2, results: [ { orderId: '123', @@ -1671,9 +1674,15 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () success: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED, }, - { orderId: '124', symbol: 'BTC', success: true }, + { + 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], ]); @@ -2172,8 +2181,9 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () 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.toLowerCase()], + [MAINNET_ACCOUNT, CHECKSUMMED_AGENT_ADDRESS], ]); // Dropped, so the next L1 action asks again. expect(getAgentSigner.mock.calls).toStrictEqual([ 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 index 37b524bdce7..9406f7961e2 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts @@ -342,6 +342,13 @@ describe('LighterProvider with accountSigner', () => { ['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', { diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index b59df964113..c25894c60cd 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -1538,7 +1538,10 @@ describe('TradingService', () => { context: mockContext, }); - expect(result.error).toBe(PERPS_ERROR_CODES.KEYRING_LOCKED); + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); expect(mockDeps.logger.error).not.toHaveBeenCalled(); }); @@ -2292,7 +2295,10 @@ describe('TradingService', () => { reportOrderToDataLake: mockReportOrderToDataLake, }); - expect(result.error).toBe(PERPS_ERROR_CODES.KEYRING_LOCKED); + expect(result).toStrictEqual({ + success: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); expect(mockDeps.logger.error).not.toHaveBeenCalled(); }); }); From 413991c61b5cecb598dcb0d52c9af8fdd8a8e8a0 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 29 Sep 2026 12:31:03 +0800 Subject: [PATCH 21/33] fix(perps-controller): keep a signer that locks mid-signature retryable, and drop agents rejected on retraction - A host accountSigner that locks while signing and throws its own error now fails with KEYRING_LOCKED (the host error as its cause), so the referral and migration stay retryable (HyperLiquid and Lighter). - Retracting a stale TWAP or an abandoned chase order drops a rejected agent and reports it, whether the rejection is thrown or in a status entry. - The agent resolver no longer carries an unreachable supersede check. - prepareTradingWallet docs on the HyperLiquid provider and the read-only Lighter wording match the controller's. - Tests pin that a failing agent stays in use, two waiters at the referral lock, an unknown wallet without an address, and that every aggregated provider is prepared. --- packages/perps-controller/CHANGELOG.md | 2 +- .../src/providers/HyperLiquidProvider.ts | 45 ++-- .../src/providers/LighterProvider.ts | 8 +- .../src/services/HyperLiquidWalletService.ts | 10 +- .../src/services/LighterWalletService.ts | 10 +- packages/perps-controller/src/types/index.ts | 3 +- .../providers/AggregatedPerpsProvider.test.ts | 17 +- ...HyperLiquidProvider.account-signer.test.ts | 233 ++++++++++++++++-- .../LighterProvider.account-signer.test.ts | 40 +-- ...LiquidWalletService.account-signer.test.ts | 20 ++ ...ighterWalletService.account-signer.test.ts | 29 ++- .../tests/src/services/agentSigner.test.ts | 16 +- 12 files changed, 356 insertions(+), 77 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 580c15803b3..03477740f75 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -32,7 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 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; the aggregated provider prepares every provider in turn - - Implemented by the exported `HyperLiquidProvider` and by the Lighter provider, which resolves `ready: true` at once when it is read-only (no signer bridge) + - Implemented by the exported `HyperLiquidProvider` and by the Lighter provider, which resolves `ready: true` at once when it is read-only (no signer bridge) 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 diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 471be95a3a5..f5c79d619f9 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -2107,14 +2107,11 @@ export class HyperLiquidProvider implements PerpsProvider { } const pendingEntry = entry; - // A clear, or another answer stored while this one was pending, wins. - const isSuperseded = (): boolean => { - const latest = this.#agentSigners.get(key); - return ( - generation !== this.#agentSignersGeneration || - (latest !== undefined && latest !== pendingEntry) - ); - }; + // 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 { @@ -6541,10 +6538,14 @@ 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. + this.#classifyStatusSignerFailure([cancelStatus]); 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.#classifySignerFailure(error); this.#deps.debugLogger.log( 'Stale TWAP placement could not be retracted', { @@ -7901,6 +7902,8 @@ export class HyperLiquidProvider implements PerpsProvider { }); return outcome; } catch (error) { + // A rejected agent is dropped and reported; the order stays resting. + this.#classifySignerFailure(error); this.#deps.debugLogger.log('Could not retract abandoned chase order', { orderId: session.orderId, error: ensureError(error, 'HyperLiquidProvider.startChaseSession') @@ -14539,18 +14542,20 @@ export class HyperLiquidProvider implements PerpsProvider { } /** - * Run the deferred trading-readiness steps (account migration with user - * signing, builder fee and referral setup) ahead of the first order, so a - * hardware wallet signs them in one guided session instead of at order - * time. Results are cached, so an already-ready account signs nothing. + * Run the deferred trading-readiness steps (account migration, builder fee + * and referral setup) ahead of the first order, so their signatures happen + * in one guided session instead of at order time. The builder fee and the + * migration from `dexAbstraction` are signed by the main account; with an + * agent, the referral and the silent migration are L1 actions the agent + * signs. Results are cached, so an already-ready account signs nothing. * * @returns `ready: true` when the main-account signer is ready and none of - * these steps will ask it to sign again before the first order; a step the - * user declined counts, because the order path does not ask again either. - * `ready: false` carries `KEYRING_LOCKED` when the signer is not ready, the - * error when the steps could not run, and no error when a step will retry - * (a rejected builder fee, a transient failure, or a wallet with no - * HyperLiquid account yet). + * these steps will need a signature again before the first order; a step + * the user declined counts, because the order path does not ask again + * either. `ready: false` carries `KEYRING_LOCKED` when the signer is not + * ready, the error when the steps could not run, and no error when a step + * will retry (a rejected builder fee, a transient failure, a wallet with no + * HyperLiquid account yet, or an agent that could not sign). */ async prepareTradingWallet(): Promise { // Nothing can be signed, so run no setup (and log nothing) until it can. diff --git a/packages/perps-controller/src/providers/LighterProvider.ts b/packages/perps-controller/src/providers/LighterProvider.ts index c5d3d48b883..1b1c68afd02 100644 --- a/packages/perps-controller/src/providers/LighterProvider.ts +++ b/packages/perps-controller/src/providers/LighterProvider.ts @@ -1005,7 +1005,7 @@ class LighterSessionCancelledError extends Error { } // EIP-1193 `userRejectedRequest` error code. -export const USER_REJECTED_REQUEST_CODE = 4001; +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). @@ -1317,9 +1317,9 @@ export class LighterProvider implements PerpsProvider { * `personal_sign` surfaces in a guided session instead of at order time. * * @returns `ready: true` once the venue key is registered, or at once for a - * read-only provider (no signer bridge): it has nothing to prepare and - * never asks the signer, so it does not hold back an aggregated result, and - * `isReadyToTrade` still reports that it cannot trade. Otherwise + * read-only provider (no signer bridge) while the main-account signer is + * ready: it has nothing to prepare, so it does not hold back an aggregated + * result, and `isReadyToTrade` still reports that it cannot trade. Otherwise * `ready: false`: with `KEYRING_LOCKED` whenever the main-account signer is * not ready (even with a registered venue key), with `NO_ACCOUNT_SELECTED` * when no account is selected, without an error when the order path will diff --git a/packages/perps-controller/src/services/HyperLiquidWalletService.ts b/packages/perps-controller/src/services/HyperLiquidWalletService.ts index 10c8f5885a7..a58ad40bd63 100644 --- a/packages/perps-controller/src/services/HyperLiquidWalletService.ts +++ b/packages/perps-controller/src/services/HyperLiquidWalletService.ts @@ -186,7 +186,15 @@ export class HyperLiquidWalletService { if (!isAccountSignerReady(accountSigner)) { throw new Error(PERPS_ERROR_CODES.KEYRING_LOCKED); } - return await accountSigner.signTypedData(mainAddress, params); + 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 signature = await this.#signTypedMessage({ diff --git a/packages/perps-controller/src/services/LighterWalletService.ts b/packages/perps-controller/src/services/LighterWalletService.ts index 3197ae849b7..9f0dc8004e6 100644 --- a/packages/perps-controller/src/services/LighterWalletService.ts +++ b/packages/perps-controller/src/services/LighterWalletService.ts @@ -102,7 +102,15 @@ export class LighterWalletService { this.#deps.debugLogger.log('LighterWalletService: personal_sign', { address, }); - return await accountSigner.signPersonalMessage(address, message); + 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) { diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index d23d1bd8e07..c535451116d 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -2168,7 +2168,8 @@ export type PerpsProvider = { * `ready: true` when 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 (a read-only provider, which never signs, - * resolves `ready: true` at once). Providers without such setup omit it. + * resolves `ready: true` at once while the main-account signer is ready). + * Providers without such setup omit it. */ prepareTradingWallet?(): Promise; /** diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts index ea4ef6dc360..f3b39af8d41 100644 --- a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -1103,20 +1103,25 @@ describe('AggregatedPerpsProvider', () => { }); 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: jest - .fn() - .mockResolvedValue({ ready: false, error: 'first' }), + prepareTradingWallet: prepareHyperLiquid, }); Object.assign(mockLighterProvider, { - prepareTradingWallet: jest - .fn() - .mockResolvedValue({ ready: false, error: 'second' }), + 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 () => { 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 index 7506a70da1c..668f2e96259 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts @@ -62,9 +62,9 @@ import { // client service, so the module itself is never loaded. jest.mock('@nktkas/hyperliquid', () => ({})); -// Only the I/O boundaries are mocked: the REST/exchange/info clients and the -// WebSocket subscriptions. The wallet service, the signing caches and the -// validation run for real. +// 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'); @@ -576,11 +576,14 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () /** * Have another provider hold the real referral lock until released. * - * @returns Resolves once this provider finds the lock and waits on it, - * and the release. + * @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(): { + function holdReferralLock(waiters = 1): { waiting: Promise; + lookupsWhileHeld: () => number; release: () => void; } { const release = PerpsSigningCache.setInFlight( @@ -589,6 +592,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () ACCOUNT_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 @@ -596,11 +600,18 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () .mockImplementation((operationType, network, userAddress) => { const pending = isInFlight(operationType, network, userAddress); if (operationType === 'referral' && pending) { - waiting.resolve(); + lookupsWhileHeld += 1; + if (lookupsWhileHeld >= waiters) { + waiting.resolve(); + } } return pending; }); - return { waiting: waiting.promise, release }; + return { + waiting: waiting.promise, + lookupsWhileHeld: (): number => lookupsWhileHeld, + release, + }; } it('makes its own referral attempt when another provider ended without a result', async () => { @@ -667,23 +678,26 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () const second = createAccountSignerProvider({ abstraction: 'unifiedAccount', }); - const lock = holdReferralLock(); + 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; - // Let both providers reach the lock. - await new Promise((resolve) => setTimeout(resolve, 0)); + 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( @@ -882,20 +896,29 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () }); it('attempts the referral again when the signer locks while signing it', async () => { + let signerReady = true; const { accountSignerProvider, accountSigner, exchangeClient } = - createAccountSignerProvider({ abstraction: 'unifiedAccount' }); - // The signer still reports ready, but this signature fails as locked. - accountSigner.signTypedData.mockRejectedValueOnce( - new Error(PERPS_ERROR_CODES.KEYRING_LOCKED), - ); + 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 firstResult = await accountSignerProvider.prepareTradingWallet(); + const lockedResult = await accountSignerProvider.prepareTradingWallet(); const referralAfterLock = referralAttempted(); - const secondResult = await accountSignerProvider.prepareTradingWallet(); + signerReady = true; + const retriedResult = await accountSignerProvider.prepareTradingWallet(); - expect(firstResult).toStrictEqual({ ready: false }); + expect(lockedResult).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); expect(referralAfterLock).toBe(false); - expect(secondResult).toStrictEqual({ ready: true }); + expect(retriedResult).toStrictEqual({ ready: true }); expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ REFERRAL_WRITE, REFERRAL_WRITE, @@ -1418,7 +1441,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () it('leaves the referral to retry, unrecorded, when the agent fails to sign', async () => { const getAgentSigner = jest.fn(); - const { accountSignerProvider, agentSigner, exchangeClient } = + const { accountSignerProvider, agentSigner, exchangeClient, initialize } = createAccountSignerProvider({ abstraction: 'unifiedAccount', getAgentSigner, @@ -1429,12 +1452,23 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () getAgentSigner.mockResolvedValue(agentSigner); const result = await accountSignerProvider.prepareTradingWallet(); + const [[wallet]] = initialize.mock.calls; + 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(); }); @@ -2192,6 +2226,43 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () ]); }); + 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, + initialize, + } = createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + onAgentRejected, + }); + getAgentSigner.mockResolvedValue(agentSigner); + await accountSignerProvider.getMarketDataWithPrices(); + const [[wallet]] = initialize.mock.calls; + 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'); @@ -2471,6 +2542,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () cancel: jest.Mock; cancelByCloid: jest.Mock; twapCancel: jest.Mock; + twapOrder: jest.Mock; l2Book: jest.Mock; getAgentSigner: jest.Mock; onAgentRejected: jest.Mock; @@ -2481,6 +2553,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () 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(); @@ -2489,7 +2562,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () signer: { isReady: () => signerReady }, getAgentSigner, onAgentRejected, - exchange: { cancel, cancelByCloid, twapCancel }, + exchange: { cancel, cancelByCloid, twapCancel, twapOrder }, info: { twapHistory: jest.fn().mockResolvedValue(TWAP_HISTORY), userTwapSliceFills: jest.fn().mockResolvedValue([]), @@ -2547,6 +2620,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () cancel, cancelByCloid, twapCancel, + twapOrder, l2Book, getAgentSigner, onAgentRejected, @@ -2763,6 +2837,121 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () }, ); + it.each([ + [ + 'thrown', + (): Promise => Promise.reject(rejection(AGENT_ADDRESS)), + ], + [ + 'in its status entry', + async (): Promise> => ({ + status: 'ok', + response: { + type: 'twapCancel', + data: { status: { error: rejection(AGENT_ADDRESS).message } }, + }, + }), + ], + ])( + 'drops an agent the venue rejects while retracting a stale TWAP (%s)', + async (_how, answerCancel) => { + const { + provider, + twapOrder, + twapCancel, + 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; + + // 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], + ]); + }, + ); + + it.each([ + [ + 'thrown', + (): Promise => Promise.reject(rejection(AGENT_ADDRESS)), + ], + [ + 'in its status entry', + async (): Promise> => + withStatuses({ error: rejection(AGENT_ADDRESS).message }), + ], + ])( + 'drops an agent the venue rejects while retracting an abandoned chase order (%s)', + async (_how, answerCancel) => { + const { provider, order, cancel, 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: 123 } }); + }); + cancel.mockImplementation(async () => { + await signL1Action(); + return await answerCancel(); + }); + + const placed = await provider.placeOrder({ + ...ETH_ORDER, + orderType: 'chase', + }); + await disconnected; + + // 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: ['123'], + }); + expect(cancel.mock.calls).toStrictEqual([ + [{ cancels: [{ a: 1, o: 123 }] }], + ]); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + }, + ); + describe('during a chase re-price', () => { beforeEach(() => { jest.useFakeTimers(); 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 index 9406f7961e2..5e79fca88a4 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts @@ -3,10 +3,7 @@ import type { Hex } from '@metamask/utils'; 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, - USER_REJECTED_REQUEST_CODE, -} from '../../../src/providers/LighterProvider.js'; +import { LighterProvider } from '../../../src/providers/LighterProvider.js'; import { LighterApiError, LighterClientService, @@ -48,6 +45,8 @@ 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, @@ -274,7 +273,7 @@ describe('LighterProvider with accountSigner', () => { expect(loggerError).not.toHaveBeenCalled(); }); - it('reports KEYRING_LOCKED without logging a missing signer bridge while the account signer is locked', async () => { + 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, @@ -336,7 +335,7 @@ describe('LighterProvider with accountSigner', () => { [ 'an EIP-1193 rejection code', Object.assign(new Error('Rejected'), { - code: USER_REJECTED_REQUEST_CODE, + code: EIP1193_USER_REJECTED_CODE, }), ], ['a "User rejected" message', new Error('User rejected the request.')], @@ -353,7 +352,7 @@ describe('LighterProvider with accountSigner', () => { 'a rejection code wrapped in the cause chain', new Error('Signing failed', { cause: Object.assign(new Error('Rejected'), { - code: USER_REJECTED_REQUEST_CODE, + code: EIP1193_USER_REJECTED_CODE, }), }), ], @@ -520,19 +519,32 @@ describe('LighterProvider with accountSigner', () => { expect(loggerError).not.toHaveBeenCalled(); }); - it('reports KEYRING_LOCKED when the account signer locks during registration', async () => { - const { provider, accountSigner, deps } = buildProvider(); - accountSigner.signPersonalMessage.mockRejectedValue( - new Error(PERPS_ERROR_CODES.KEYRING_LOCKED), - ); + 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 result = await provider.prepareTradingWallet(); + const lockedResult = await provider.prepareTradingWallet(); + signerReady = true; + const retriedResult = await provider.prepareTradingWallet(); - expect(result).toStrictEqual({ + 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(); }); }); 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 index b41015c4a0a..9ba578be989 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts @@ -93,6 +93,26 @@ describe('HyperLiquidWalletService with accountSigner', () => { ).rejects.toThrow('User rejected the request.'); }); + 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)); + expect((error as Error).cause).toBe(hostError); + }); + it('reports ready when isReady is omitted', () => { const { service, call } = buildService(); 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 index 1613282bda3..dc45b48a004 100644 --- a/packages/perps-controller/tests/src/services/LighterWalletService.account-signer.test.ts +++ b/packages/perps-controller/tests/src/services/LighterWalletService.account-signer.test.ts @@ -33,13 +33,34 @@ describe('LighterWalletService with accountSigner', () => { const signature = await service.signPersonalMessage('hello'); expect(signature).toBe(MAIN_SIGNATURE); - expect(signer.signPersonalMessage).toHaveBeenCalledWith( - createMockEvmAccount().address, - 'hello', - ); + 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)); + expect((error as Error).cause).toBe(hostError); + }); + it('fails with KEYRING_LOCKED and does not sign when isReady returns false', async () => { const signer = createSigner(() => false); const { messenger, call } = createKeyringlessMessenger(); diff --git a/packages/perps-controller/tests/src/services/agentSigner.test.ts b/packages/perps-controller/tests/src/services/agentSigner.test.ts index 1b2428a322d..7ceb93dbd6e 100644 --- a/packages/perps-controller/tests/src/services/agentSigner.test.ts +++ b/packages/perps-controller/tests/src/services/agentSigner.test.ts @@ -61,14 +61,24 @@ describe('AgentBindings', () => { }); it('forgets bindings and pins once cleared', async () => { - const getAgentSigner = jest.fn().mockResolvedValue(AGENT); + const getAgentSigner = jest.fn().mockResolvedValue(null); const bindings = new AgentBindings(getAgentSigner); + const otherAccount: PerpsAgentAccount = { + ...ACCOUNT, + mainAddress: '0x00000000000000000000000000000000000b0b01', + }; bindings.set(ACCOUNT, null); + bindings.set(otherAccount, AGENT); bindings.clear(); - expect(await bindings.resolve(ACCOUNT)).toBe(AGENT); - expect(getAgentSigner.mock.calls).toStrictEqual([[ACCOUNT]]); + expect(await bindings.resolve(ACCOUNT)).toBeNull(); + expect(await bindings.resolve(otherAccount)).toBeNull(); + // Both now fall through to the host. + expect(getAgentSigner.mock.calls).toStrictEqual([ + [ACCOUNT], + [otherAccount], + ]); }); it('releases a binding to the rejected agent whatever the address casing', async () => { From 9c19c3f69c987861d35dc60945c0cf550c93dddc Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 29 Sep 2026 13:04:18 +0800 Subject: [PATCH 22/33] fix(perps-controller): skip unusable builder fee prompts and report locks during builder fee setup - prepareTradingWallet does not ask a wallet with no HyperLiquid account yet to approve the builder fee (the venue rejects its writes). - A builder fee approval skipped because the signer cannot sign fails the write with KEYRING_LOCKED instead of its approval failure code, so a locked TP/SL update is not reported as an error. - HyperLiquid prepareTradingWallet returns NO_ACCOUNT_SELECTED without logging, like Lighter. - The controller's prepareTradingWallet docs say which declined steps are asked again. - Test fakes wrap wallet errors as the SDK does; tests cover the retraction drops, a network switch with a resolved agent, the keyring signing phases and Lighter's recheck after a signed registration. --- .../PerpsController-method-action-types.ts | 8 +- .../perps-controller/src/PerpsController.ts | 8 +- .../src/providers/HyperLiquidProvider.ts | 32 ++++- ...ntroller.agent-signing.integration.test.ts | 11 +- ...HyperLiquidProvider.account-signer.test.ts | 128 ++++++++++++++++-- .../LighterProvider.account-signer.test.ts | 27 +++- 6 files changed, 190 insertions(+), 24 deletions(-) diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index 6abbe42e908..0a52094a09c 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -948,9 +948,11 @@ export type PerpsControllerClearAgentSignersAction = { * are L1 actions the agent signs. * * @returns `ready: true` when none of these steps will need a signature - * again before the first order; `ready: false` while one will be retried - * (it was declined, or its signer, the main account or the agent, could not - * sign). Providers without deferred setup are ready. + * again before the first order, including a step the user declined that is + * not asked again (the HyperLiquid migration); `ready: false` while one will + * be asked again: a declined builder fee or Lighter registration, or a step + * whose signer (the main account or the agent) could not sign. Providers + * without deferred setup are ready. * @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. diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 012d8c01d83..1d79a615583 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -5932,9 +5932,11 @@ export class PerpsController extends BaseController< * are L1 actions the agent signs. * * @returns `ready: true` when none of these steps will need a signature - * again before the first order; `ready: false` while one will be retried - * (it was declined, or its signer, the main account or the agent, could not - * sign). Providers without deferred setup are ready. + * again before the first order, including a step the user declined that is + * not asked again (the HyperLiquid migration); `ready: false` while one will + * be asked again: a declined builder fee or Lighter registration, or a step + * whose signer (the main account or the agent) could not sign. Providers + * without deferred setup are ready. * @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. diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index f5c79d619f9..71b156638be 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -2953,6 +2953,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) { @@ -2961,7 +2969,7 @@ export class HyperLiquidProvider implements PerpsProvider { error, ); if (approvalFailureCode) { - throw new Error(approvalFailureCode); + throw approvalFailure(approvalFailureCode); } } finally { if (this.#builderFeeSetupPromises.get(setupKey) === pendingApproval) { @@ -2970,7 +2978,7 @@ export class HyperLiquidProvider implements PerpsProvider { } if (approvalFailureCode && !this.#builderFeeCheckCache.has(cacheKey)) { - throw new Error(approvalFailureCode); + throw approvalFailure(approvalFailureCode); } return context; @@ -14563,9 +14571,17 @@ export class HyperLiquidProvider implements PerpsProvider { return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; } try { - const { network, userAddress } = await this.#ensureReadyForTrading({ - requiresBuilderFee: true, - }); + await this.#ensureReadyForTrading({ requiresBuilderFee: false }); + const network = this.#clientService.isTestnetMode() + ? 'testnet' + : 'mainnet'; + const userAddress = await this.#walletService.getUserAddressWithDefault(); + // 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 (!(await this.#isWalletOnHyperliquid(userAddress, network))) { + return { ready: false }; + } + await this.#ensureBuilderFeeSetup(); if (!this.#walletService.isMainAccountSignerReady()) { return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; } @@ -14587,6 +14603,12 @@ export class HyperLiquidProvider implements PerpsProvider { 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, diff --git a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts index ecf94ce6b2b..5fbe88cf352 100644 --- a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts @@ -144,7 +144,16 @@ class MockExchangeClient { params: unknown, payload: PerpsTypedDataPayload, ): Promise { - const signer = SIGNERS.get(await this.#wallet.signTypedData(payload)); + let signature: string; + try { + signature = await this.#wallet.signTypedData(payload); + } catch (error) { + // Like the SDK, which keeps the wallet error as the cause. + throw new Error('Failed to sign the typed data using the wallet', { + cause: error, + }); + } + const signer = SIGNERS.get(signature); mockVenue.writes.push({ write, params, signer }); if (signer && mockVenue.revokedAgents.has(signer)) { throw new Error(`User or API Wallet ${signer} does not exist.`); 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 index 668f2e96259..f29711c4619 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts @@ -264,7 +264,14 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () if (!sdkWallet) { throw new Error('SDK used before initialize'); } - await sdkWallet.signTypedData(payload); + try { + await sdkWallet.signTypedData(payload); + } catch (error) { + // Like the SDK, which keeps the wallet error as the cause. + throw new Error('Failed to sign the typed data using the wallet', { + cause: error, + }); + } return response; }; const exchangeClient = createMockExchangeClient({ @@ -399,6 +406,29 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () 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(); + }); + describe('when the signer locks before a user-signed write', () => { /** * A provider whose withdrawals and DEX transfers sign through the SDK @@ -861,11 +891,18 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(loggerError).not.toHaveBeenCalled(); }); - it('signs the deferred setup through the keyring without accountSigner', async () => { + it('signs the migration at connect and the referral in preparation through the keyring without accountSigner', async () => { const { accountSignerProvider, accountSigner, call, exchangeClient } = createAccountSignerProvider({ keyring: true }); - await accountSignerProvider.getMarketDataWithPrices(); + 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 }); @@ -876,17 +913,14 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () REFERRAL_WRITE, ]); expect(accountSigner.signTypedData).not.toHaveBeenCalled(); - // Migration, then referral. - expect( - call.mock.calls.filter( - ([action]) => action === 'KeyringController:signTypedMessage', - ), - ).toStrictEqual([ + expect(connectSignatures).toStrictEqual([ [ 'KeyringController:signTypedMessage', { from: ACCOUNT_ADDRESS, data: USER_SIGNED_PAYLOAD }, 'V4', ], + ]); + expect(typedDataSignatures()).toStrictEqual([ [ 'KeyringController:signTypedMessage', { from: ACCOUNT_ADDRESS, data: L1_PAYLOAD }, @@ -995,6 +1029,8 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () abstraction: 'default', info: { userNonFundingLedgerUpdates: jest.fn().mockResolvedValue([]), + // Not approved: the venue would reject the approval anyway. + maxBuilderFee: jest.fn().mockResolvedValue(0), }, }); @@ -1003,6 +1039,24 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(result).toStrictEqual({ ready: false }); 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('reports NO_ACCOUNT_SELECTED without logging when no account is selected', async () => { + const { accountSignerProvider, accountSigner, selectAccount } = + createAccountSignerProvider({ abstraction: 'unifiedAccount' }); + await accountSignerProvider.getMarketDataWithPrices(); + // An empty selection: the wallet service finds no account. + selectAccount('' as Hex); + + 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(); }); @@ -1365,6 +1419,37 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () ]); }); + it('does not reuse the mainnet agent after the provider switches to testnet', async () => { + const getAgentSigner = jest.fn(); + const { accountSignerProvider, accountSigner, agentSigner, initialize } = + 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]] = initialize.mock.calls; + await wallet.signTypedData(L1_PAYLOAD); + + mockClientService.isTestnetMode.mockReturnValue(true); + await wallet.signTypedData(L1_PAYLOAD); + + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [{ mainAddress: ACCOUNT_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([ + [ACCOUNT_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, initialize } = @@ -2859,6 +2944,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () provider, twapOrder, twapCancel, + getAgentSigner, onAgentRejected, signL1Action, } = createStrategyProvider('rejected'); @@ -2887,6 +2973,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () twapDuration: 30, }); await disconnected; + await signL1Action(); // The retraction was refused, so the TWAP is reported as live. expect(placed).toStrictEqual({ @@ -2899,6 +2986,12 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () 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(); }, ); @@ -2915,8 +3008,14 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () ])( 'drops an agent the venue rejects while retracting an abandoned chase order (%s)', async (_how, answerCancel) => { - const { provider, order, cancel, onAgentRejected, signL1Action } = - createStrategyProvider('rejected'); + 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. @@ -2935,6 +3034,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () orderType: 'chase', }); await disconnected; + await signL1Action(); // The retraction was refused, so the order is reported as resting. expect(placed).toStrictEqual({ @@ -2949,6 +3049,12 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () 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(); }, ); 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 index 5e79fca88a4..e213ded251a 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts @@ -481,13 +481,14 @@ describe('LighterProvider with accountSigner', () => { it('reports KEYRING_LOCKED when the signer locks once the venue key is registered', async () => { let signerReady = true; - const { provider, accountSigner, client } = buildProvider({ + 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(); @@ -496,6 +497,30 @@ describe('LighterProvider with accountSigner', () => { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); + 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('reports KEYRING_LOCKED without logging when the signer locks as registration fails', async () => { From b8fc59f91ad89dbda8f291edb119c2a662371f40 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 29 Sep 2026 13:31:13 +0800 Subject: [PATCH 23/33] fix(perps-controller): retry the referral after a first deposit, and report locked signers in every prepare path - A wallet that starts trading setup before its first deposit gets its referral set once it has deposited, instead of after a reconnect. - prepareTradingWallet reports KEYRING_LOCKED when the signer locked during an unfunded wallet's setup, and the controller reports it for a provider with nothing to prepare. - A builder fee signature rejected as locked fails the TP/SL update or order with KEYRING_LOCKED instead of its approval failure code. - The Lighter testnet rule the aggregated provider tags errors with lives in one internal helper. - README documents signing without a KeyringController; the CHANGELOG names the results that changed. - Tests run the KeyringController host shape through the controller, share the SDK error and signing fakes, and pin exact results. --- packages/perps-controller/CHANGELOG.md | 4 +- packages/perps-controller/README.md | 23 ++ .../PerpsController-method-action-types.ts | 2 +- .../perps-controller/src/PerpsController.ts | 14 +- .../src/providers/AggregatedPerpsProvider.ts | 14 +- .../src/providers/HyperLiquidProvider.ts | 20 +- .../src/services/providerNetwork.ts | 21 ++ .../tests/helpers/agentFixtures.ts | 46 ++++ .../tests/helpers/serviceMocks.ts | 27 ++- ...ntroller.agent-signing.integration.test.ts | 123 ++++++++-- .../PerpsController.providers-cache.test.ts | 83 +++++-- .../providers/AggregatedPerpsProvider.test.ts | 16 +- ...HyperLiquidProvider.account-signer.test.ts | 222 ++++++++++++------ .../LighterProvider.account-signer.test.ts | 49 +++- .../tests/src/services/TradingService.test.ts | 33 ++- .../tests/src/services/agentSigner.test.ts | 11 +- 16 files changed, 543 insertions(+), 165 deletions(-) create mode 100644 packages/perps-controller/src/services/providerNetwork.ts diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 03477740f75..8c32e45f375 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -43,8 +43,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- HyperLiquid writes that fail because the keyring is locked now fail with `KEYRING_LOCKED` instead of the SDK's "Failed to sign the typed data using the wallet" message, and are no longer reported as errors by the provider or `TradingService` ([#10559](https://github.com/MetaMask/core/pull/10559)) +- HyperLiquid writes that fail because the keyring is locked, or because the `accountSigner` is not ready, 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 +- A HyperLiquid wallet that starts trading setup before its first deposit gets its referral set as soon as it has deposited, instead of after the provider reconnects ([#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..7a50c589e4a 100644 --- a/packages/perps-controller/README.md +++ b/packages/perps-controller/README.md @@ -66,6 +66,29 @@ 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. + ## 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 0a52094a09c..748d3617872 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -952,7 +952,7 @@ export type PerpsControllerClearAgentSignersAction = { * not asked again (the HyperLiquid migration); `ready: false` while one will * be asked again: a declined builder fee or Lighter registration, or a step * whose signer (the main account or the agent) could not sign. Providers - * without deferred setup are ready. + * without deferred setup are ready while the main account can sign. * @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. diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 1d79a615583..d3d3dc549b5 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -52,6 +52,7 @@ 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'; @@ -5936,14 +5937,23 @@ export class PerpsController extends BaseController< * not asked again (the HyperLiquid migration); `ready: false` while one will * be asked again: a declined builder fee or Lighter registration, or a step * whose signer (the main account or the agent) could not sign. Providers - * without deferred setup are ready. + * without deferred setup are ready while the main account can sign. * @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(); - return (await provider.prepareTradingWallet?.()) ?? { ready: true }; + if (provider.prepareTradingWallet) { + return await provider.prepareTradingWallet(); + } + // Nothing to prepare, but ready still needs a main account that can sign. + return isMainAccountSignerReady( + this.#options.infrastructure.accountSigner, + () => this.messenger.call('KeyringController:getState').isUnlocked, + ) + ? { ready: true } + : { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; } /** diff --git a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts index 4da3bfe21c0..af62711fa00 100644 --- a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts +++ b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts @@ -17,9 +17,10 @@ import type { CaipAccountId } from '@metamask/utils'; import { SubscriptionMultiplexer } from '../aggregation/SubscriptionMultiplexer.js'; -import { PERPS_CONSTANTS, PROVIDER_CONFIG } from '../constants/perpsConfig.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, @@ -1072,12 +1073,11 @@ export class AggregatedPerpsProvider implements PerpsProvider { caughtError, 'AggregatedPerpsProvider.prepareTradingWallet', ); - // The provider's own network: Lighter can be pinned to testnet, as - // in buildProviderCacheKey. - const isProviderTestnet = - providerId === 'lighter' && PROVIDER_CONFIG.LIGHTER_TESTNET_ONLY - ? true - : this.#isTestnet; + // 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, diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 71b156638be..b6f671a5b97 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -2909,8 +2909,9 @@ export class HyperLiquidProvider implements PerpsProvider { #tradingSetupComplete = false; - // Set when the referral write failed because its signer could not sign, so - // trading setup is not marked complete and the referral is attempted again. + // Set when the referral could not be written yet (its signer could not + // sign, or the wallet has no HyperLiquid account yet), so trading setup is + // not marked complete and the referral is attempted again. #referralSetupNeedsRetry = false; readonly #builderFeeSetupPromises = new Map>(); @@ -2969,7 +2970,10 @@ export class HyperLiquidProvider implements PerpsProvider { error, ); if (approvalFailureCode) { - throw approvalFailure(approvalFailureCode); + throw ( + this.#classifySignerFailure(error) ?? + approvalFailure(approvalFailureCode) + ); } } finally { if (this.#builderFeeSetupPromises.get(setupKey) === pendingApproval) { @@ -4674,12 +4678,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 @@ -14579,7 +14584,9 @@ export class HyperLiquidProvider implements PerpsProvider { // 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 (!(await this.#isWalletOnHyperliquid(userAddress, network))) { - return { ready: false }; + return this.#walletService.isMainAccountSignerReady() + ? { ready: false } + : { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; } await this.#ensureBuilderFeeSetup(); if (!this.#walletService.isMainAccountSignerReady()) { @@ -15721,6 +15728,8 @@ export class HyperLiquidProvider implements PerpsProvider { '[ensureReferralSet] Wallet not yet on Hyperliquid, deferring referral setup', { network }, ); + // Attempt it again once the wallet has deposited. + this.#referralSetupNeedsRetry = true; return; } @@ -15847,6 +15856,7 @@ export class HyperLiquidProvider implements PerpsProvider { '[ensureReferralSet] Wallet not on Hyperliquid (race/stale-cache), deferring referral', { network, user: userAddress }, ); + this.#referralSetupNeedsRetry = true; completeInFlight(); return; } diff --git a/packages/perps-controller/src/services/providerNetwork.ts b/packages/perps-controller/src/services/providerNetwork.ts new file mode 100644 index 00000000000..aafcf6b3fea --- /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: boolean | undefined, +): boolean | undefined { + return providerId === 'lighter' && PROVIDER_CONFIG.LIGHTER_TESTNET_ONLY + ? true + : isTestnet; +} diff --git a/packages/perps-controller/tests/helpers/agentFixtures.ts b/packages/perps-controller/tests/helpers/agentFixtures.ts index ff615f222ea..63f4b573f93 100644 --- a/packages/perps-controller/tests/helpers/agentFixtures.ts +++ b/packages/perps-controller/tests/helpers/agentFixtures.ts @@ -1,3 +1,5 @@ +import type { Hex } from '@metamask/utils'; + import type { PerpsTypedDataPayload } from '../../src/types/index.js'; import { createMockEvmAccount } from './serviceMocks.js'; @@ -129,3 +131,47 @@ export function createFrontendOpenOrder( ...overrides, }; } + +/** + * 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/serviceMocks.ts b/packages/perps-controller/tests/helpers/serviceMocks.ts index 7322a9d4c25..de53727d189 100644 --- a/packages/perps-controller/tests/helpers/serviceMocks.ts +++ b/packages/perps-controller/tests/helpers/serviceMocks.ts @@ -291,10 +291,15 @@ export const createMockMessenger = ( } as unknown as jest.Mocked; }; +// The keyring type of a software (non-hardware) account. +const HD_KEYRING_TYPE = 'HD Key Tree'; + type AccountMessenger = { messenger: PerpsControllerMessenger; call: jest.SpyInstance; selectAccount: (address: `0x${string}`) => void; + // Leave no account selected. + deselectAccount: () => void; }; /** @@ -306,8 +311,8 @@ type AccountMessenger = { * @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, a spy on its `call`, and a way to switch the - * selected account. + * @returns The messenger, a spy on its `call`, and ways to switch or clear + * the selected account. */ const createAccountMessenger = ( keyringType: string, @@ -315,7 +320,8 @@ const createAccountMessenger = ( isUnlocked = true, ): AccountMessenger => { const account = createMockEvmAccount(); - let selectedAddress = account.address; + // Empty when no account is selected. + let selectedAddress: string = account.address; const root = new Messenger< MockAnyNamespace, MessengerActions, @@ -365,6 +371,9 @@ const createAccountMessenger = ( selectAccount: (address): void => { selectedAddress = address; }, + deselectAccount: (): void => { + selectedAddress = ''; + }, }; }; @@ -374,11 +383,11 @@ const createAccountMessenger = ( * delegated, so any `KeyringController:*` call throws. * * @param keyringType - Keyring type reported in the selected account metadata. - * @returns The messenger, a spy on its `call`, and a way to switch the - * selected account. + * @returns The messenger, a spy on its `call`, and ways to switch or clear + * the selected account. */ export const createKeyringlessMessenger = ( - keyringType = 'HD Key Tree', + keyringType = HD_KEYRING_TYPE, ): AccountMessenger => createAccountMessenger(keyringType); /** @@ -387,14 +396,14 @@ export const createKeyringlessMessenger = ( * * @param signature - Signature the keyring returns. * @param isUnlocked - Whether the keyring reports it is unlocked. - * @returns The messenger, a spy on its `call`, and a way to switch the - * selected account. + * @returns The 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 Key Tree', signature, isUnlocked); + createAccountMessenger(HD_KEYRING_TYPE, signature, isUnlocked); /** * Names of the `KeyringController:*` actions a messenger spy saw. diff --git a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts index 5fbe88cf352..82538108da7 100644 --- a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts @@ -27,6 +27,8 @@ import { MAIN_SIGNATURE, OTHER_AGENT_ADDRESS, OTHER_AGENT_SIGNATURE, + signThroughWallet, + unknownWalletError, USER_SIGNED_PAYLOAD, } from '../helpers/agentFixtures.js'; import { createMockInfoClient } from '../helpers/providerMocks.js'; @@ -144,19 +146,10 @@ class MockExchangeClient { params: unknown, payload: PerpsTypedDataPayload, ): Promise { - let signature: string; - try { - signature = await this.#wallet.signTypedData(payload); - } catch (error) { - // Like the SDK, which keeps the wallet error as the cause. - throw new Error('Failed to sign the typed data using the wallet', { - cause: error, - }); - } - const signer = SIGNERS.get(signature); + const signer = SIGNERS.get(await signThroughWallet(this.#wallet, payload)); mockVenue.writes.push({ write, params, signer }); if (signer && mockVenue.revokedAgents.has(signer)) { - throw new Error(`User or API Wallet ${signer} does not exist.`); + throw unknownWalletError(signer); } } } @@ -232,12 +225,25 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => /** * A messenger that answers the host actions these flows call: an empty * remote feature flag state, so the controller reads its defaults, the - * selected account, and the network the fee discount looks up. + * selected account, and the network the fee discount looks up. With + * `keyring`, it is also the host's KeyringController, which signs as the + * main account. * + * @param keyring - The KeyringController the host exposes, if any. + * @param keyring.isUnlocked - Whether the keyring is unlocked. * @returns The messenger. */ - function createMessenger(): ReturnType { + function createMessenger(keyring?: { + isUnlocked: boolean; + }): ReturnType { + const keyringAnswers: Record = keyring + ? { + 'KeyringController:getState': { isUnlocked: keyring.isUnlocked }, + 'KeyringController:signTypedMessage': Promise.resolve(MAIN_SIGNATURE), + } + : {}; const answers: Record = { + ...keyringAnswers, 'RemoteFeatureFlagController:getState': { remoteFeatureFlags: {}, cacheTimestamp: 0, @@ -259,25 +265,49 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => /** * Build a controller whose host signs with `accountSigner` and resolves - * agents with `getAgentSigner`. + * agents with `getAgentSigner`, unless told otherwise. * - * @param signer - The host's account signer. - * @param hyperliquid - The host's HyperLiquid credentials. + * @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.messenger - The host's messenger. * @returns The controller. */ - function createController( - signer: PerpsAccountSigner = accountSigner, - hyperliquid: HyperLiquidCredentials = { getAgentSigner, onAgentRejected }, - ): PerpsController { + function createController({ + signer = accountSigner, + hyperliquid = { getAgentSigner, onAgentRejected }, + messenger = createMessenger(), + }: { + signer?: PerpsAccountSigner | null; + hyperliquid?: HyperLiquidCredentials; + messenger?: ReturnType; + } = {}): PerpsController { return new PerpsController({ - messenger: createMessenger(), + messenger, state: getDefaultPerpsControllerState(), clientConfig: { providerCredentials: { hyperliquid } }, - infrastructure: { ...infrastructure, accountSigner: signer }, + infrastructure: signer + ? { ...infrastructure, accountSigner: signer } + : infrastructure, deferEligibilityCheck: true, }); } + /** + * The typed-data signatures the host's KeyringController was asked for. + * + * @param messenger - The host's messenger. + * @returns The `KeyringController:signTypedMessage` calls. + */ + function keyringSignatureRequests( + messenger: ReturnType, + ): unknown[][] { + return messenger.call.mock.calls.filter( + ([action]) => action === 'KeyringController:signTypedMessage', + ); + } + /** * An agent that signs with its own recognizable signature. * @@ -343,7 +373,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => }); it('signs L1 actions with the main account when the host has no getAgentSigner', async () => { - const controller = createController(accountSigner, {}); + const controller = createController({ hyperliquid: {} }); await controller.init(); const placed = await placeOrder(controller); @@ -356,6 +386,50 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => expect(loggerError).not.toHaveBeenCalled(); }); + 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 messenger = createMessenger({ isUnlocked: true }); + const controller = createController({ signer: null, messenger }); + await controller.init(); + + const placed = await placeOrder(controller); + + expect(placed).toStrictEqual(PLACED_ORDER); + expect(signedWrites()).toStrictEqual([ + ['approveBuilderFee', MAIN_ADDRESS], + ['order', AGENT_ADDRESS], + ]); + expect(keyringSignatureRequests(messenger)).toStrictEqual([ + [ + 'KeyringController:signTypedMessage', + { from: MAIN_ADDRESS, data: APPROVE_BUILDER_FEE_PAYLOAD }, + 'V4', + ], + ]); + expect(agentSigner.signTypedData.mock.calls).toStrictEqual([[L1_PAYLOAD]]); + expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); + expect(onAgentRejected).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('prepares nothing and reports KEYRING_LOCKED while the host keyring is locked', async () => { + const messenger = createMessenger({ isUnlocked: false }); + const controller = createController({ signer: null, messenger }); + await controller.init(); + + const result = await controller.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(mockVenue.writes).toStrictEqual([]); + expect(keyringSignatureRequests(messenger)).toStrictEqual([]); + expect(getAgentSigner).not.toHaveBeenCalled(); + expect(loggerError).not.toHaveBeenCalled(); + }); + it('pins L1 actions to the main account with setAgentSigner(null) until clearAgentSigners', async () => { const controller = createController(); await controller.init(); @@ -540,8 +614,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => it('prepares nothing and reports KEYRING_LOCKED while the account signer is not ready', async () => { const controller = createController({ - ...accountSigner, - isReady: (): boolean => false, + signer: { ...accountSigner, isReady: (): boolean => false }, }); await controller.init(); 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 4875bce25f9..114c165966f 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -934,24 +934,58 @@ describe('PerpsController', () => { signTypedData: jest.fn(), signPersonalMessage: jest.fn(), }; + const messenger = createMockMessenger(); + const infrastructure = { ...mockInfrastructure, accountSigner }; controller = new TestablePerpsController({ - messenger: createMockMessenger(), + messenger, state: getDefaultPerpsControllerState(), - infrastructure: { ...mockInfrastructure, accountSigner }, + infrastructure, }); await controller.init(); registerMockLighterProvider(controller); - const withAccountSigner = expect.objectContaining({ - platformDependencies: expect.objectContaining({ accountSigner }), - }); expect( - HyperLiquidProvider as jest.MockedClass, - ).toHaveBeenCalledWith(withAccountSigner); + (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, - ).toHaveBeenCalledWith(withAccountSigner); + (LighterProvider as jest.MockedClass).mock + .calls, + ).toStrictEqual([ + [ + { + isTestnet: false, + platformDependencies: infrastructure, + messenger, + signerBridge: undefined, + lighterAuthConfig: { + enabled: undefined, + accountIndex: undefined, + apiKeyIndex: undefined, + }, + }, + ], + ]); }); const agentSigner = { @@ -1100,13 +1134,34 @@ describe('PerpsController', () => { expect(constructedWith.providers).toBe(providers); }); - it('reports a trading wallet ready when the provider has no deferred setup', async () => { - await controller.init(); + it.each([ + { signerReady: true, expected: { ready: true } }, + { + signerReady: false, + expected: { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }, + }, + ])( + 'reports readiness from the main-account signer when the provider has no deferred setup (signer ready: $signerReady)', + async ({ signerReady, expected }) => { + controller = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + infrastructure: { + ...mockInfrastructure, + accountSigner: { + signTypedData: jest.fn(), + signPersonalMessage: jest.fn(), + isReady: (): boolean => signerReady, + }, + }, + }); + await controller.init(); - const result = await controller.prepareTradingWallet(); + const result = await controller.prepareTradingWallet(); - expect(result).toStrictEqual({ ready: true }); - }); + expect(result).toStrictEqual(expected); + }, + ); }); describe('getOpenOrders with standalone mode', () => { diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts index f3b39af8d41..e68f08dccaf 100644 --- a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -1086,8 +1086,8 @@ describe('AggregatedPerpsProvider', () => { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); - expect(prepareHyperLiquid).toHaveBeenCalledTimes(1); - expect(prepareLighter).toHaveBeenCalledTimes(1); + expect(prepareHyperLiquid.mock.calls).toStrictEqual([[]]); + expect(prepareLighter.mock.calls).toStrictEqual([[]]); }); it('reports ready when every provider is ready or has no deferred setup', async () => { @@ -1099,7 +1099,7 @@ describe('AggregatedPerpsProvider', () => { const result = await aggregatedProvider.prepareTradingWallet(); expect(result).toStrictEqual({ ready: true }); - expect(prepareHyperLiquid).toHaveBeenCalledTimes(1); + expect(prepareHyperLiquid.mock.calls).toStrictEqual([[]]); }); it('reports the first not-ready provider when several are not ready', async () => { @@ -1282,10 +1282,12 @@ describe('AggregatedPerpsProvider', () => { }); 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 () => await firstPreparation.promise, - ); + const prepareHyperLiquid = jest.fn(async () => { + hyperLiquidAsked.resolve(); + return await firstPreparation.promise; + }); const prepareLighter = jest.fn().mockResolvedValue({ ready: true }); Object.assign(mockHLProvider, { prepareTradingWallet: prepareHyperLiquid, @@ -1295,7 +1297,7 @@ describe('AggregatedPerpsProvider', () => { }); const preparing = aggregatedProvider.prepareTradingWallet(); - await Promise.resolve(); + await hyperLiquidAsked.promise; const hyperLiquidCallsBeforeSettling = [...prepareHyperLiquid.mock.calls]; const lighterCallsBeforeSettling = [...prepareLighter.mock.calls]; firstPreparation.resolve({ ready: true }); 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 index f29711c4619..e4e87bcb933 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts @@ -44,6 +44,8 @@ import { OTHER_AGENT_SIGNATURE, USER_SIGNED_PAYLOAD, createFrontendOpenOrder, + signThroughWallet, + unknownWalletError, } from '../../helpers/agentFixtures.js'; import { createMockExchangeClient, @@ -147,6 +149,28 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () // A fixed clock for cache timestamps. const NOW = 1_700_000_000_000; + const BTC_MARKET_ORDER = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + } as const; + + /** + * The order ID a placement returned. + * + * @param result - The placement result. + * @param result.orderId - Its order ID, if any. + * @returns The order ID. + */ + function orderIdOf(result: { orderId?: string }): string { + if (result.orderId === undefined) { + throw new Error('The placement returned no order ID'); + } + return result.orderId; + } + // The SDK writes the provider makes for the selected account on mainnet. const MIGRATION_WRITE = [ { user: ACCOUNT_ADDRESS, abstraction: HL_UNIFIED_ACCOUNT_MODE }, @@ -234,6 +258,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () infoClient: ReturnType; initialize: jest.Mock, [HyperLiquidWalletParams]>; selectAccount: (address: Hex) => void; + deselectAccount: () => void; }; function createAccountSignerProvider( @@ -248,7 +273,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () address: AGENT_ADDRESS, signTypedData: jest.fn().mockResolvedValue(AGENT_SIGNATURE), }; - const { messenger, call, selectAccount } = options.keyring + const { messenger, call, selectAccount, deselectAccount } = options.keyring ? createKeyringMessenger(MAIN_SIGNATURE) : createKeyringlessMessenger(); let sdkWallet: HyperLiquidWalletParams | undefined; @@ -264,14 +289,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () if (!sdkWallet) { throw new Error('SDK used before initialize'); } - try { - await sdkWallet.signTypedData(payload); - } catch (error) { - // Like the SDK, which keeps the wallet error as the cause. - throw new Error('Failed to sign the typed data using the wallet', { - cause: error, - }); - } + await signThroughWallet(sdkWallet, payload); return response; }; const exchangeClient = createMockExchangeClient({ @@ -325,6 +343,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () infoClient, initialize, selectAccount, + deselectAccount, }; } @@ -389,13 +408,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () signer: { isReady: () => false }, }); - const order = await accountSignerProvider.placeOrder({ - symbol: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'market', - currentPrice: 50000, - }); + const order = await accountSignerProvider.placeOrder(BTC_MARKET_ORDER); expect(order).toStrictEqual({ success: false, @@ -429,6 +442,39 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () 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(); + }); + describe('when the signer locks before a user-signed write', () => { /** * A provider whose withdrawals and DEX transfers sign through the SDK @@ -567,13 +613,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () const result = await accountSignerProvider.prepareTradingWallet(); const setupSignatures = accountSigner.signTypedData.mock.calls.slice(); accountSigner.signTypedData.mockClear(); - const order = await accountSignerProvider.placeOrder({ - symbol: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'market', - currentPrice: 50000, - }); + const order = await accountSignerProvider.placeOrder(BTC_MARKET_ORDER); expect(result).toStrictEqual({ ready: true }); // Migration, referral, builder fee approval. @@ -649,15 +689,18 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () createAccountSignerProvider({ abstraction: 'unifiedAccount' }); const lock = holdReferralLock(); - let referrerCallsWhileWaiting; + // 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; - // A provider that did not wait would reach its write by now. - await new Promise((resolve) => setTimeout(resolve, 0)); - referrerCallsWhileWaiting = - exchangeClient.setReferrer.mock.calls.length; + released = true; lock.release(); result = await preparing; } finally { @@ -665,7 +708,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () lock.release(); } - expect(referrerCallsWhileWaiting).toBe(0); + expect(releasedAtWrite).toStrictEqual([true]); expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ REFERRAL_WRITE, ]); @@ -1044,12 +1087,62 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(loggerError).not.toHaveBeenCalled(); }); + it('sets the referral once a wallet prepared before its first deposit has deposited', 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 }); + expect(afterDeposit).toStrictEqual({ ready: true }); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + REFERRAL_WRITE, + ]); + 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('reports NO_ACCOUNT_SELECTED without logging when no account is selected', async () => { - const { accountSignerProvider, accountSigner, selectAccount } = + const { accountSignerProvider, accountSigner, deselectAccount } = createAccountSignerProvider({ abstraction: 'unifiedAccount' }); await accountSignerProvider.getMarketDataWithPrices(); - // An empty selection: the wallet service finds no account. - selectAccount('' as Hex); + deselectAccount(); const result = await accountSignerProvider.prepareTradingWallet(); @@ -1216,7 +1309,10 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () const marketData = await accountSignerProvider.getMarketDataWithPrices(); const result = await accountSignerProvider.prepareTradingWallet(); - expect(marketData).toHaveLength(2); + 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. @@ -1515,8 +1611,9 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () getAgentSigner, }); - await accountSignerProvider.prepareTradingWallet(); + const result = await accountSignerProvider.prepareTradingWallet(); + expect(result).toStrictEqual({ ready: false }); expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ REFERRAL_WRITE, ]); @@ -1632,8 +1729,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () const CHECKSUMMED_AGENT_ADDRESS = '0x00000000000000000000000000000000000A9E17' as const; - const rejection = (address: string): Error => - new Error(`User or API Wallet ${address} does not exist.`); + const rejection = unknownWalletError; describe('when the venue rejects the agent', () => { // The position's take profit, resting on the venue. @@ -2124,13 +2220,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () throw rejection(agentSigner.address); }); - const ordering = accountSignerProvider.placeOrder({ - symbol: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'market', - currentPrice: 50000, - }); + 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. @@ -2181,13 +2271,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () throw rejection(ACCOUNT_ADDRESS); }); - const order = await accountSignerProvider.placeOrder({ - symbol: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'market', - currentPrice: 50000, - }); + const order = await accountSignerProvider.placeOrder(BTC_MARKET_ORDER); await wallet.signTypedData(L1_PAYLOAD); expect(order).toStrictEqual({ @@ -2440,13 +2524,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () } = createRejectingProvider('order'); await accountSignerProvider.getMarketDataWithPrices(); - const order = await accountSignerProvider.placeOrder({ - symbol: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'market', - currentPrice: 50000, - }); + const order = await accountSignerProvider.placeOrder(BTC_MARKET_ORDER); await initialize.mock.calls[0][0].signTypedData(L1_PAYLOAD); expect(order).toStrictEqual({ @@ -2533,13 +2611,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () }); await accountSignerProvider.getMarketDataWithPrices(); - const order = await accountSignerProvider.placeOrder({ - symbol: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'market', - currentPrice: 50000, - }); + const order = await accountSignerProvider.placeOrder(BTC_MARKET_ORDER); expect(order).toStrictEqual({ success: false, @@ -2786,13 +2858,13 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () failSigning(); const result = await provider.cancelOrder({ - orderId: placed.orderId as string, + orderId: orderIdOf(placed), symbol: 'ETH', orderType: 'scale', }); cancel.mockResolvedValue(withStatuses('success', 'success')); const retry = await provider.cancelOrder({ - orderId: placed.orderId as string, + orderId: orderIdOf(placed), symbol: 'ETH', orderType: 'scale', }); @@ -2855,16 +2927,16 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () failSigning(); const result = await provider.cancelOrder({ - orderId: placed.orderId as string, + orderId: orderIdOf(placed), symbol: 'ETH', orderType: 'scale', }); - expect(placed.orderId).toMatch(/^scale:/u); - expect(placed).toStrictEqual({ + const { orderId: groupId, ...placement } = placed; + expect(groupId).toMatch(/^scale:/u); + expect(placement).toStrictEqual({ success: false, error: PERPS_ERROR_CODES.ORDER_STRATEGY_CANCEL_INCOMPLETE, - orderId: placed.orderId, acceptedChildren: [ { state: 'waitingForFill' }, { state: 'waitingForFill' }, @@ -2904,7 +2976,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () failSigning(); const result = await provider.cancelOrder({ - orderId: placed.orderId as string, + orderId: orderIdOf(placed), symbol: 'ETH', orderType: 'chase', }); @@ -3114,13 +3186,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () await accountSignerProvider.getMarketDataWithPrices(); getAgentSigner.mockRejectedValue(new Error('agent store unavailable')); - const order = await accountSignerProvider.placeOrder({ - symbol: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'market', - currentPrice: 50000, - }); + const order = await accountSignerProvider.placeOrder(BTC_MARKET_ORDER); expect(order).toStrictEqual({ success: false, 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 index e213ded251a..9642a97a72e 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts @@ -1,5 +1,3 @@ -import type { Hex } from '@metamask/utils'; - 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'; @@ -39,6 +37,8 @@ const MockedClientService = LighterClientService as jest.MockedClass< const ACCOUNT_INDEX = 28; const API_KEY_INDEX = 7; const NEXT_NONCE = 42; +// A fixed clock, so the signed transaction is deterministic. +const NOW = 1_700_000_000_000; // 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 = @@ -50,9 +50,20 @@ const EIP1193_USER_REJECTED_CODE = 4001; // The registration transaction the mocked signer submits. const CHANGE_PUB_KEY_TX = [ LIGHTER_TX_TYPE_CHANGE_PUB_KEY, - expect.stringContaining('"changePubKey":true'), + 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[]; @@ -109,6 +120,7 @@ type BuiltProvider = { accountSigner: { signPersonalMessage: jest.Mock }; call: jest.SpyInstance; selectAccount: (address: `0x${string}`) => void; + deselectAccount: () => void; calls: LighterWasmCall[]; deps: ReturnType; }; @@ -162,7 +174,7 @@ function buildProvider({ signPersonalMessage: jest.fn().mockResolvedValue(MAIN_SIGNATURE), isReady, }; - const { messenger, call, selectAccount } = keyring + const { messenger, call, selectAccount, deselectAccount } = keyring ? createKeyringMessenger(MAIN_SIGNATURE) : createKeyringlessMessenger(); const { bridge, calls } = createBridge(); @@ -187,12 +199,15 @@ function buildProvider({ 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(); @@ -412,10 +427,9 @@ describe('LighterProvider with accountSigner', () => { }); it('reports NO_ACCOUNT_SELECTED without registering or logging when no account is selected', async () => { - const { provider, accountSigner, client, calls, deps, selectAccount } = + const { provider, accountSigner, client, calls, deps, deselectAccount } = buildProvider(); - // An empty selection: the wallet service finds no account. - selectAccount('' as Hex); + deselectAccount(); const loggerError = jest.spyOn(deps.logger, 'error'); const result = await provider.prepareTradingWallet(); @@ -500,6 +514,25 @@ describe('LighterProvider with accountSigner', () => { 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({ @@ -575,6 +608,8 @@ describe('LighterProvider with accountSigner', () => { }); describe('LighterProvider with a KeyringController', () => { + beforeEach(pinClock); + it('registers the venue key through prepareTradingWallet with a keyring signature', async () => { const { provider, address, client, call, calls } = buildProvider({ keyring: true, diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index c25894c60cd..2fcdaedc37d 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -1885,7 +1885,25 @@ describe('TradingService', () => { withStreamPause: mockWithStreamPause, }); - expect(result.success).toBe(false); + 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(); }); @@ -2644,7 +2662,18 @@ describe('TradingService', () => { context: { ...mockContext, getPositions: mockGetPositions }, }); - expect(result.success).toBe(false); + 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(); }); diff --git a/packages/perps-controller/tests/src/services/agentSigner.test.ts b/packages/perps-controller/tests/src/services/agentSigner.test.ts index 7ceb93dbd6e..51b749dcbb7 100644 --- a/packages/perps-controller/tests/src/services/agentSigner.test.ts +++ b/packages/perps-controller/tests/src/services/agentSigner.test.ts @@ -7,6 +7,7 @@ import type { PerpsAgentAccount } from '../../../src/types/index.js'; import { AGENT_ADDRESS, OTHER_AGENT_ADDRESS, + sdkSigningError, } from '../../helpers/agentFixtures.js'; const ACCOUNT: PerpsAgentAccount = { @@ -128,14 +129,10 @@ describe('AgentBindings', () => { describe('isAgentSignerUnavailableError', () => { it('finds the error anywhere in the cause chain', () => { const unavailable = new AgentSignerUnavailableError(new Error('down')); - const wrapped = new Error( - 'Failed to sign the typed data using the wallet', - { - cause: unavailable, - }, - ); - expect(isAgentSignerUnavailableError(wrapped)).toBe(true); + expect(isAgentSignerUnavailableError(sdkSigningError(unavailable))).toBe( + true, + ); expect(isAgentSignerUnavailableError(new Error('other'))).toBe(false); }); }); From eb71122f3ae3668613334e81d76b3b0ed92e9b39 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 29 Sep 2026 14:01:59 +0800 Subject: [PATCH 24/33] fix(perps-controller): report an unfunded wallet in preparation, and retry a referral whose code was not ready - prepareTradingWallet returns EXCHANGE_ACCOUNT_NOT_FOUND for a wallet with no HyperLiquid or Lighter account yet, so the host can ask for a deposit instead of another attempt. - A referral skipped because the referral code is not ready, or because the venue rejected the wallet as unknown, is attempted again. - prepareTradingWallet reports a builder fee signature rejected as locked as KEYRING_LOCKED. - Tests run the controller on a real host messenger (a stray keyring call throws), trade on testnet after a network switch, cover a host without onAgentRejected and the keyring readiness fallback, and pin the approval-failure and superseded-rejection paths. --- packages/perps-controller/CHANGELOG.md | 4 +- .../PerpsController-method-action-types.ts | 6 +- .../perps-controller/src/PerpsController.ts | 6 +- .../src/providers/HyperLiquidProvider.ts | 54 ++-- .../src/providers/LighterProvider.ts | 31 ++- packages/perps-controller/src/types/index.ts | 3 +- .../tests/helpers/agentFixtures.ts | 4 + .../tests/helpers/serviceMocks.ts | 23 +- ...ntroller.agent-signing.integration.test.ts | 230 ++++++++++++------ .../PerpsController.providers-cache.test.ts | 52 +++- ...HyperLiquidProvider.account-signer.test.ts | 161 +++++++++++- .../HyperLiquidProvider.builder-fees.test.ts | 8 +- .../LighterProvider.account-signer.test.ts | 7 +- ...LiquidWalletService.account-signer.test.ts | 16 +- ...ighterWalletService.account-signer.test.ts | 2 +- .../tests/src/services/agentSigner.test.ts | 3 +- 16 files changed, 462 insertions(+), 148 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 8c32e45f375..4dee0af8b96 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -31,7 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 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; the aggregated provider prepares every provider in turn + - Resolves a `ReadyToTradeResult` that is `ready: true` once 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 and `EXCHANGE_ACCOUNT_NOT_FOUND` for a wallet with no account on the venue yet; the aggregated provider prepares every provider in turn - Implemented by the exported `HyperLiquidProvider` and by the Lighter provider, which resolves `ready: true` at once when it is read-only (no signer bridge) 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)) @@ -46,7 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - HyperLiquid writes that fail because the keyring is locked, or because the `accountSigner` is not ready, 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 -- A HyperLiquid wallet that starts trading setup before its first deposit gets its referral set as soon as it has deposited, instead of after the provider reconnects ([#10559](https://github.com/MetaMask/core/pull/10559)) +- A HyperLiquid referral skipped during trading setup, because the wallet has not deposited yet or the referral code is not ready, is attempted again as soon as it can be set, instead of after the provider reconnects ([#10559](https://github.com/MetaMask/core/pull/10559)) ## [18.0.1] diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index 748d3617872..c34e118315d 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -951,8 +951,10 @@ export type PerpsControllerClearAgentSignersAction = { * again before the first order, including a step the user declined that is * not asked again (the HyperLiquid migration); `ready: false` while one will * be asked again: a declined builder fee or Lighter registration, or a step - * whose signer (the main account or the agent) could not sign. Providers - * without deferred setup are ready while the main account can sign. + * whose signer (the main account or the agent) could not sign + * (`KEYRING_LOCKED`), or a wallet with no account on the venue yet + * (`EXCHANGE_ACCOUNT_NOT_FOUND`). Providers without deferred setup are + * ready while the main account can sign. * @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. diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index d3d3dc549b5..43633b5447a 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -5936,8 +5936,10 @@ export class PerpsController extends BaseController< * again before the first order, including a step the user declined that is * not asked again (the HyperLiquid migration); `ready: false` while one will * be asked again: a declined builder fee or Lighter registration, or a step - * whose signer (the main account or the agent) could not sign. Providers - * without deferred setup are ready while the main account can sign. + * whose signer (the main account or the agent) could not sign + * (`KEYRING_LOCKED`), or a wallet with no account on the venue yet + * (`EXCHANGE_ACCOUNT_NOT_FOUND`). Providers without deferred setup are + * ready while the main account can sign. * @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. diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index b6f671a5b97..04ab27c07ff 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -2910,8 +2910,9 @@ export class HyperLiquidProvider implements PerpsProvider { #tradingSetupComplete = false; // Set when the referral could not be written yet (its signer could not - // sign, or the wallet has no HyperLiquid account yet), so trading setup is - // not marked complete and the referral is attempted again. + // sign, the wallet has no HyperLiquid account yet, or the referral code is + // not ready), so trading setup is not marked complete and the referral is + // attempted again. #referralSetupNeedsRetry = false; readonly #builderFeeSetupPromises = new Map>(); @@ -2924,10 +2925,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'; @@ -2969,11 +2975,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 ( - this.#classifySignerFailure(error) ?? - approvalFailure(approvalFailureCode) - ); + throw approvalFailure(approvalFailureCode); } } finally { if (this.#builderFeeSetupPromises.get(setupKey) === pendingApproval) { @@ -14566,9 +14576,10 @@ export class HyperLiquidProvider implements PerpsProvider { * these steps will need a signature again before the first order; a step * the user declined counts, because the order path does not ask again * either. `ready: false` carries `KEYRING_LOCKED` when the signer is not - * ready, the error when the steps could not run, and no error when a step - * will retry (a rejected builder fee, a transient failure, a wallet with no - * HyperLiquid account yet, or an agent that could not sign). + * ready, `EXCHANGE_ACCOUNT_NOT_FOUND` for a wallet with no HyperLiquid + * account yet (fund it first), the error when the steps could not run, and + * no error when a step will retry (a rejected builder fee, a transient + * failure, or an agent that could not sign). */ async prepareTradingWallet(): Promise { // Nothing can be signed, so run no setup (and log nothing) until it can. @@ -14584,11 +14595,16 @@ export class HyperLiquidProvider implements PerpsProvider { // 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 (!(await this.#isWalletOnHyperliquid(userAddress, network))) { - return this.#walletService.isMainAccountSignerReady() - ? { ready: false } - : { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; + return { + ready: false, + error: this.#walletService.isMainAccountSignerReady() + ? PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND + : PERPS_ERROR_CODES.KEYRING_LOCKED, + }; } - await this.#ensureBuilderFeeSetup(); + await this.#ensureBuilderFeeSetup(undefined, { + reportSignerFailure: true, + }); if (!this.#walletService.isMainAccountSignerReady()) { return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; } @@ -14598,8 +14614,12 @@ export class HyperLiquidProvider implements PerpsProvider { this.#builderFeeCheckCache.has(this.#getCacheKey(network, userAddress)); return { ready }; } catch (error) { - // The signer locked while a step ran, so that step failed for it. - if (!this.#walletService.isMainAccountSignerReady()) { + // 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( @@ -15790,8 +15810,10 @@ export class HyperLiquidProvider implements PerpsProvider { '[ensureReferralSet] Builder referral not ready, skipping', { network }, ); + // Don't cache: attempt it again once the code is ready. + this.#referralSetupNeedsRetry = true; completeInFlight(); - return; // Don't cache - retry when ready + return; } // Check if user already has a referral on-chain diff --git a/packages/perps-controller/src/providers/LighterProvider.ts b/packages/perps-controller/src/providers/LighterProvider.ts index 1b1c68afd02..78da315c983 100644 --- a/packages/perps-controller/src/providers/LighterProvider.ts +++ b/packages/perps-controller/src/providers/LighterProvider.ts @@ -1012,18 +1012,16 @@ const USER_REJECTED_REQUEST_CODE = 4001; const USER_REJECTED_MESSAGE_PATTERN = /user (rejected|denied|cancell?ed)/iu; /** - * Whether preparing the wallet stopped in a way the order path retries: the - * user declined the venue-key signature, or the wallet has no Lighter account - * yet. + * Whether the user declined the venue-key signature; the order path asks + * again. * * @param error - The caught error. - * @returns True when registration will be asked again, not a failure. + * @returns True for a declined signature. */ -const isRetryablePreparationStop = (error: unknown): boolean => +const isDeclinedRegistration = (error: unknown): boolean => hasErrorInCauseChain( error, (current) => - current instanceof LighterAccountNotFoundError || (current as { code?: unknown }).code === USER_REJECTED_REQUEST_CODE || USER_REJECTED_MESSAGE_PATTERN.test(current.message), ); @@ -1322,9 +1320,10 @@ export class LighterProvider implements PerpsProvider { * result, and `isReadyToTrade` still reports that it cannot trade. Otherwise * `ready: false`: with `KEYRING_LOCKED` whenever the main-account signer is * not ready (even with a registered venue key), with `NO_ACCOUNT_SELECTED` - * when no account is selected, without an error when the order path will - * ask again (the user declined the signature, or the wallet has no Lighter - * account yet), with `PROVIDER_LIFECYCLE_STALE` (unlogged) when the + * when no account is selected, with `EXCHANGE_ACCOUNT_NOT_FOUND` when the + * wallet has no Lighter account yet (fund it first), without an error when + * the user declined the signature (the order path asks again), with + * `PROVIDER_LIFECYCLE_STALE` (unlogged) when the * provider disconnected or the wallet switched accounts meanwhile, and with * the logged error when registration failed. */ @@ -1355,9 +1354,21 @@ export class LighterProvider implements PerpsProvider { ) { return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; } - if (isRetryablePreparationStop(caughtError)) { + if (isDeclinedRegistration(caughtError)) { return { ready: false }; } + // Nothing can be registered before the wallet has a Lighter account. + if ( + hasErrorInCauseChain( + caughtError, + (current) => current 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) { diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index c535451116d..d819da4dc99 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -2167,7 +2167,8 @@ export type PerpsProvider = { * silent migration) are signed by an agent when one resolves. Resolves * `ready: true` when 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 (a read-only provider, which never signs, + * after an agent could not sign, with `EXCHANGE_ACCOUNT_NOT_FOUND` for a + * wallet with no account on the venue yet (a read-only provider, which never signs, * resolves `ready: true` at once while the main-account signer is ready). * Providers without such setup omit it. */ diff --git a/packages/perps-controller/tests/helpers/agentFixtures.ts b/packages/perps-controller/tests/helpers/agentFixtures.ts index 63f4b573f93..d0a1a223596 100644 --- a/packages/perps-controller/tests/helpers/agentFixtures.ts +++ b/packages/perps-controller/tests/helpers/agentFixtures.ts @@ -13,6 +13,10 @@ const EIP712_DOMAIN_TYPE = [ { name: 'verifyingContract', type: 'address' }, ]; +/** 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; diff --git a/packages/perps-controller/tests/helpers/serviceMocks.ts b/packages/perps-controller/tests/helpers/serviceMocks.ts index de53727d189..7ada69e2932 100644 --- a/packages/perps-controller/tests/helpers/serviceMocks.ts +++ b/packages/perps-controller/tests/helpers/serviceMocks.ts @@ -294,8 +294,16 @@ export const createMockMessenger = ( // 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. @@ -311,8 +319,8 @@ type AccountMessenger = { * @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, a spy on its `call`, and ways to switch or clear - * the selected account. + * @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, @@ -322,7 +330,7 @@ const createAccountMessenger = ( const account = createMockEvmAccount(); // Empty when no account is selected. let selectedAddress: string = account.address; - const root = new Messenger< + const root: RootMessenger = new Messenger< MockAnyNamespace, MessengerActions, MessengerEvents @@ -367,6 +375,7 @@ const createAccountMessenger = ( } return { messenger, + rootMessenger: root, call: jest.spyOn(messenger, 'call'), selectAccount: (address): void => { selectedAddress = address; @@ -383,8 +392,8 @@ const createAccountMessenger = ( * delegated, so any `KeyringController:*` call throws. * * @param keyringType - Keyring type reported in the selected account metadata. - * @returns The messenger, a spy on its `call`, and ways to switch or clear - * the selected account. + * @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, @@ -396,8 +405,8 @@ export const createKeyringlessMessenger = ( * * @param signature - Signature the keyring returns. * @param isUnlocked - Whether the keyring reports it is unlocked. - * @returns The messenger, a spy on its `call`, and ways to switch or clear - * the selected account. + * @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, diff --git a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts index 82538108da7..de59703543e 100644 --- a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts @@ -8,6 +8,7 @@ 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'; @@ -33,9 +34,11 @@ import { } from '../helpers/agentFixtures.js'; import { createMockInfoClient } from '../helpers/providerMocks.js'; import { + createKeyringlessMessenger, + createKeyringMessenger, createMockEvmAccount, createMockInfrastructure, - createMockMessenger, + keyringCalls, } from '../helpers/serviceMocks.js'; const MAIN_ADDRESS = createMockEvmAccount().address; @@ -200,13 +203,19 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => // 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({ - referral: jest.fn().mockResolvedValue({ + // 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: REFERRAL_CONFIG.MainnetCode }, + data: { + code: + user === BUILDER_FEE_CONFIG.TestnetBuilder + ? REFERRAL_CONFIG.TestnetCode + : REFERRAL_CONFIG.MainnetCode, + }, }, - }), + })), }); mockVenue.networks = []; mockVenue.writes = []; @@ -223,44 +232,60 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => }); /** - * A messenger that answers the host actions these flows call: an empty - * remote feature flag state, so the controller reads its defaults, the - * selected account, and the network the fee discount looks up. With - * `keyring`, it is also the host's KeyringController, which signs as the - * main account. + * 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. + * @returns The messenger and a spy on its `call`. */ - function createMessenger(keyring?: { - isUnlocked: boolean; - }): ReturnType { - const keyringAnswers: Record = keyring - ? { - 'KeyringController:getState': { isUnlocked: keyring.isUnlocked }, - 'KeyringController:signTypedMessage': Promise.resolve(MAIN_SIGNATURE), - } - : {}; - const answers: Record = { - ...keyringAnswers, - 'RemoteFeatureFlagController:getState': { - remoteFeatureFlags: {}, - cacheTimestamp: 0, - }, - 'AccountTreeController:getAccountsFromSelectedAccountGroup': [ - createMockEvmAccount(), + 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', ], - 'NetworkController:getState': { selectedNetworkClientId: 'mainnet' }, - 'NetworkController:getNetworkClientById': { - configuration: { chainId: '0x1' }, - }, - }; - return createMockMessenger({ - call: jest - .fn() - .mockImplementation((action: string): unknown => answers[action]), + events: [ + 'RemoteFeatureFlagController:stateChange', + 'AccountsController:selectedAccountChange', + 'AccountTreeController:selectedAccountGroupChange', + ], + messenger, }); + return { messenger, call }; } /** @@ -271,20 +296,20 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => * @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.messenger - The host's messenger. - * @returns The controller. + * @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 }, - messenger = createMessenger(), + host = createHost(), }: { signer?: PerpsAccountSigner | null; hyperliquid?: HyperLiquidCredentials; - messenger?: ReturnType; - } = {}): PerpsController { - return new PerpsController({ - messenger, + host?: ReturnType; + } = {}): { controller: PerpsController; call: jest.SpyInstance } { + const controller = new PerpsController({ + messenger: host.messenger, state: getDefaultPerpsControllerState(), clientConfig: { providerCredentials: { hyperliquid } }, infrastructure: signer @@ -292,20 +317,19 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => : infrastructure, deferEligibilityCheck: true, }); + return { controller, call: host.call }; } /** * The typed-data signatures the host's KeyringController was asked for. * - * @param messenger - The host's messenger. + * @param call - A spy on the host messenger's `call`. * @returns The `KeyringController:signTypedMessage` calls. */ - function keyringSignatureRequests( - messenger: ReturnType, - ): unknown[][] { - return messenger.call.mock.calls.filter( - ([action]) => action === 'KeyringController:signTypedMessage', - ); + function keyringSignatureRequests(call: jest.SpyInstance): unknown[][] { + return call.mock.calls + .map((args: unknown[]) => args) + .filter(([action]) => action === 'KeyringController:signTypedMessage'); } /** @@ -353,7 +377,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => 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 = createController(); + const { controller, call } = createController(); await controller.init(); const placed = await placeOrder(controller); @@ -369,11 +393,12 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => [MAIN_ADDRESS, APPROVE_BUILDER_FEE_PAYLOAD], ]); expect(onAgentRejected).not.toHaveBeenCalled(); + expect(keyringCalls(call)).toStrictEqual([]); expect(loggerError).not.toHaveBeenCalled(); }); it('signs L1 actions with the main account when the host has no getAgentSigner', async () => { - const controller = createController({ hyperliquid: {} }); + const { controller, call } = createController({ hyperliquid: {} }); await controller.init(); const placed = await placeOrder(controller); @@ -383,14 +408,17 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ [MAIN_ADDRESS, L1_PAYLOAD], ]); + expect(keyringCalls(call)).toStrictEqual([]); expect(loggerError).not.toHaveBeenCalled(); }); 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 messenger = createMessenger({ isUnlocked: true }); - const controller = createController({ signer: null, messenger }); + const { controller, call } = createController({ + signer: null, + host: createHost({ isUnlocked: true }), + }); await controller.init(); const placed = await placeOrder(controller); @@ -400,7 +428,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => ['approveBuilderFee', MAIN_ADDRESS], ['order', AGENT_ADDRESS], ]); - expect(keyringSignatureRequests(messenger)).toStrictEqual([ + expect(keyringSignatureRequests(call)).toStrictEqual([ [ 'KeyringController:signTypedMessage', { from: MAIN_ADDRESS, data: APPROVE_BUILDER_FEE_PAYLOAD }, @@ -414,8 +442,10 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => }); it('prepares nothing and reports KEYRING_LOCKED while the host keyring is locked', async () => { - const messenger = createMessenger({ isUnlocked: false }); - const controller = createController({ signer: null, messenger }); + const { controller, call } = createController({ + signer: null, + host: createHost({ isUnlocked: false }), + }); await controller.init(); const result = await controller.prepareTradingWallet(); @@ -425,13 +455,13 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); expect(mockVenue.writes).toStrictEqual([]); - expect(keyringSignatureRequests(messenger)).toStrictEqual([]); + expect(keyringSignatureRequests(call)).toStrictEqual([]); expect(getAgentSigner).not.toHaveBeenCalled(); expect(loggerError).not.toHaveBeenCalled(); }); it('pins L1 actions to the main account with setAgentSigner(null) until clearAgentSigners', async () => { - const controller = createController(); + const { controller, call } = createController(); await controller.init(); controller.setAgentSigner(MAINNET_ACCOUNT, null); @@ -449,42 +479,52 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => ]); expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); expect(onAgentRejected).not.toHaveBeenCalled(); + expect(keyringCalls(call)).toStrictEqual([]); expect(loggerError).not.toHaveBeenCalled(); }); 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 = createController(); + 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, placedAfter]).toStrictEqual([ + expect([placedBefore, placedOnTestnet, placedAfter]).toStrictEqual([ + PLACED_ORDER, PLACED_ORDER, PLACED_ORDER, ]); - // The original and the re-created mainnet provider each built SDK - // clients. - expect(mockVenue.networks).toStrictEqual(['mainnet', 'mainnet']); + // 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(getAgentSigner).not.toHaveBeenCalled(); - expect(accountSigner.signTypedData).not.toHaveBeenCalled(); + expect(getAgentSigner.mock.calls).toStrictEqual([ + [{ ...MAINNET_ACCOUNT, isTestnet: true }], + ]); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [MAIN_ADDRESS, L1_PAYLOAD], + ]); expect(onAgentRejected).not.toHaveBeenCalled(); + expect(keyringCalls(call)).toStrictEqual([]); expect(loggerError).not.toHaveBeenCalled(); }); it('honors a setAgentSigner binding made before init', async () => { getAgentSigner.mockResolvedValue(null); const boundAgent = createAgent(OTHER_AGENT_ADDRESS, OTHER_AGENT_SIGNATURE); - const controller = createController(); + const { controller, call } = createController(); controller.setAgentSigner(MAINNET_ACCOUNT, boundAgent); await controller.init(); @@ -494,12 +534,13 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => expect(signedWrites()).toStrictEqual([['order', OTHER_AGENT_ADDRESS]]); expect(getAgentSigner).not.toHaveBeenCalled(); expect(onAgentRejected).not.toHaveBeenCalled(); + expect(keyringCalls(call)).toStrictEqual([]); expect(loggerError).not.toHaveBeenCalled(); }); it('forgets a setAgentSigner binding cleared before init', async () => { const boundAgent = createAgent(OTHER_AGENT_ADDRESS, OTHER_AGENT_SIGNATURE); - const controller = createController(); + const { controller, call } = createController(); controller.setAgentSigner(MAINNET_ACCOUNT, boundAgent); controller.clearAgentSigners(); @@ -510,6 +551,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => expect(signedWrites()).toStrictEqual([['order', AGENT_ADDRESS]]); expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); expect(boundAgent.signTypedData).not.toHaveBeenCalled(); + expect(keyringCalls(call)).toStrictEqual([]); expect(loggerError).not.toHaveBeenCalled(); }); @@ -518,7 +560,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => OTHER_AGENT_ADDRESS, OTHER_AGENT_SIGNATURE, ); - const controller = createController(); + const { controller, call } = createController(); await controller.init(); const resolvedPlaced = await placeOrder(controller); @@ -539,6 +581,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => ]); expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); expect(onAgentRejected).not.toHaveBeenCalled(); + expect(keyringCalls(call)).toStrictEqual([]); expect(loggerError).not.toHaveBeenCalled(); }); @@ -551,7 +594,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => .mockResolvedValueOnce(agentSigner) .mockResolvedValueOnce(replacementAgent); mockVenue.revokedAgents.add(AGENT_ADDRESS); - const controller = createController(); + const { controller, call } = createController(); await controller.init(); const cancelled = await controller.cancelOrder({ @@ -578,13 +621,14 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => [MAINNET_ACCOUNT], ]); // Nothing reports the retryable signer failure. + expect(keyringCalls(call)).toStrictEqual([]); expect(loggerError).not.toHaveBeenCalled(); }); 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 = createController(); + const { controller, call } = createController(); await controller.init(); controller.setAgentSigner(MAINNET_ACCOUNT, boundAgent); @@ -609,11 +653,49 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => ['order', AGENT_ADDRESS], ]); expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); + expect(keyringCalls(call)).toStrictEqual([]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('releases a binding to an agent the venue rejects for a host without onAgentRejected', async () => { + const boundAgent = createAgent(OTHER_AGENT_ADDRESS, OTHER_AGENT_SIGNATURE); + mockVenue.revokedAgents.add(OTHER_AGENT_ADDRESS); + const { controller, call } = createController({ + hyperliquid: { getAgentSigner }, + }); + 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], + ]); + expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); + // Nothing threw while telling the host about the rejection. + expect( + (infrastructure.debugLogger.log as jest.Mock).mock.calls.filter( + ([message]) => message === 'HyperLiquidProvider: onAgentRejected threw', + ), + ).toStrictEqual([]); + expect(keyringCalls(call)).toStrictEqual([]); expect(loggerError).not.toHaveBeenCalled(); }); it('prepares nothing and reports KEYRING_LOCKED while the account signer is not ready', async () => { - const controller = createController({ + const { controller, call } = createController({ signer: { ...accountSigner, isReady: (): boolean => false }, }); await controller.init(); @@ -629,6 +711,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => expect(accountSigner.signTypedData).not.toHaveBeenCalled(); expect(getAgentSigner).not.toHaveBeenCalled(); expect(onAgentRejected).not.toHaveBeenCalled(); + expect(keyringCalls(call)).toStrictEqual([]); expect(loggerError).not.toHaveBeenCalled(); }); @@ -639,7 +722,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => userAbstraction: jest.fn().mockResolvedValue('dexAbstraction'), maxBuilderFee: jest.fn().mockResolvedValueOnce(0).mockResolvedValue(1), }); - const controller = createController(); + const { controller, call } = createController(); await controller.init(); const result = await controller.prepareTradingWallet(); @@ -674,6 +757,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => expect(agentSigner.signTypedData.mock.calls).toStrictEqual([[L1_PAYLOAD]]); expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); expect(onAgentRejected).not.toHaveBeenCalled(); + expect(keyringCalls(call)).toStrictEqual([]); expect(loggerError).not.toHaveBeenCalled(); }); }); 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 114c165966f..c7a6b5875f7 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -1135,31 +1135,57 @@ describe('PerpsController', () => { }); it.each([ - { signerReady: true, expected: { ready: true } }, { - signerReady: false, + 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 the main-account signer when the provider has no deferred setup (signer ready: $signerReady)', - async ({ signerReady, expected }) => { + '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 = jest + .fn() + .mockImplementation((action: string) => + action === 'KeyringController:getState' + ? { isUnlocked: canSign } + : undefined, + ); controller = new TestablePerpsController({ - messenger: createMockMessenger(), + messenger: createMockMessenger({ call }), state: getDefaultPerpsControllerState(), - infrastructure: { - ...mockInfrastructure, - accountSigner: { - signTypedData: jest.fn(), - signPersonalMessage: jest.fn(), - isReady: (): boolean => signerReady, - }, - }, + 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( + call.mock.calls.filter(([action]) => action.startsWith('Keyring')), + ).toStrictEqual(usesKeyring ? [['KeyringController:getState']] : []); }, ); }); 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 index e4e87bcb933..d410913f06d 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts @@ -42,6 +42,7 @@ import { MAIN_SIGNATURE, OTHER_AGENT_ADDRESS, OTHER_AGENT_SIGNATURE, + OTHER_MAIN_ADDRESS, USER_SIGNED_PAYLOAD, createFrontendOpenOrder, signThroughWallet, @@ -143,8 +144,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () // boundary: like the SDK, it signs through the wallet the provider // initialized it with. const ACCOUNT_ADDRESS = createMockEvmAccount().address; - const OTHER_ACCOUNT_ADDRESS = - '0x00000000000000000000000000000000000b0b01' as const; + const OTHER_ACCOUNT_ADDRESS = OTHER_MAIN_ADDRESS; // A fixed clock for cache timestamps. const NOW = 1_700_000_000_000; @@ -475,6 +475,50 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () 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', + ACCOUNT_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 @@ -935,7 +979,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () }); it('signs the migration at connect and the referral in preparation through the keyring without accountSigner', async () => { - const { accountSignerProvider, accountSigner, call, exchangeClient } = + const { accountSignerProvider, call, exchangeClient } = createAccountSignerProvider({ keyring: true }); const typedDataSignatures = (): unknown[] => call.mock.calls.filter( @@ -955,7 +999,6 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ REFERRAL_WRITE, ]); - expect(accountSigner.signTypedData).not.toHaveBeenCalled(); expect(connectSignatures).toStrictEqual([ [ 'KeyringController:signTypedMessage', @@ -1066,7 +1109,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(loggerError).not.toHaveBeenCalled(); }); - it('reports not ready, without an error, for a wallet with no HyperLiquid account yet', async () => { + it('reports EXCHANGE_ACCOUNT_NOT_FOUND without signing for a wallet with no HyperLiquid account yet', async () => { const { accountSignerProvider, accountSigner, exchangeClient } = createAccountSignerProvider({ abstraction: 'default', @@ -1079,7 +1122,10 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () const result = await accountSignerProvider.prepareTradingWallet(); - expect(result).toStrictEqual({ ready: false }); + 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(); @@ -1105,7 +1151,10 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () deposited = true; const afterDeposit = await accountSignerProvider.prepareTradingWallet(); - expect(beforeDeposit).toStrictEqual({ ready: false }); + expect(beforeDeposit).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, + }); expect(afterDeposit).toStrictEqual({ ready: true }); expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ REFERRAL_WRITE, @@ -1138,6 +1187,86 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(loggerError).not.toHaveBeenCalled(); }); + it('attempts the referral again when the venue rejects the wallet as unknown despite the probe', async () => { + const { accountSignerProvider, exchangeClient } = + createAccountSignerProvider({ abstraction: 'unifiedAccount' }); + // The probe sees a deposit, but the venue has not caught up yet. + exchangeClient.setReferrer.mockRejectedValueOnce( + unknownWalletError(ACCOUNT_ADDRESS), + ); + + const rejected = await accountSignerProvider.prepareTradingWallet(); + const referralAfterRejection = referralAttempted(); + const retried = await accountSignerProvider.prepareTradingWallet(); + + expect(rejected).toStrictEqual({ ready: false }); + expect(referralAfterRejection).toBe(false); + expect(retried).toStrictEqual({ ready: true }); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + REFERRAL_WRITE, + REFERRAL_WRITE, + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + it('sets the referral once the referral code becomes ready', 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: false }); + expect(afterReady).toStrictEqual({ ready: true }); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + REFERRAL_WRITE, + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + + 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' }); @@ -1480,7 +1609,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () 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 } = + const { accountSignerProvider, agentSigner, exchangeClient } = createAccountSignerProvider({ abstraction: 'default', getAgentSigner: bindings.resolve, @@ -1491,8 +1620,15 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () 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], @@ -1581,7 +1717,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () it('asks getAgentSigner again once the bindings are cleared', async () => { const getAgentSigner = jest.fn(); const bindings = new AgentBindings(getAgentSigner); - const { accountSignerProvider, agentSigner, initialize } = + const { accountSignerProvider, accountSigner, agentSigner, initialize } = createAccountSignerProvider({ abstraction: 'unifiedAccount', getAgentSigner: bindings.resolve, @@ -1590,15 +1726,22 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () bindings.set(MAINNET_ACCOUNT, null); await accountSignerProvider.getMarketDataWithPrices(); const [[wallet]] = initialize.mock.calls; + // 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([[ACCOUNT_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 getAgentSigner rejects', async () => { 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 f3519dcef05..d09e0c5bbab 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 @@ -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(); ( @@ -1665,8 +1665,10 @@ describe('HyperLiquidProvider', () => { }), ); - // Act - should resolve without throwing - await testableProvider.ensureBuilderFeeApproval(); + // Act - rethrows, so the caller reports a retryable failure + await expect(testableProvider.ensureBuilderFeeApproval()).rejects.toThrow( + 'KEYRING_LOCKED', + ); // Assert - cache should NOT be set (so it retries when unlocked) expect( 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 index 9642a97a72e..e2ae6a43ddc 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts @@ -393,7 +393,7 @@ describe('LighterProvider with accountSigner', () => { }, ); - it('reports a wallet with no Lighter account yet as a retry without logging', async () => { + 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, }); @@ -406,7 +406,10 @@ describe('LighterProvider with accountSigner', () => { // The account now exists (funded through the bridge). const retried = await provider.prepareTradingWallet(); - expect(missing).toStrictEqual({ ready: false }); + 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(); 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 index 9ba578be989..e1254b9e957 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts @@ -23,6 +23,7 @@ import { AGENT_SIGNATURE, L1_PAYLOAD, MAIN_SIGNATURE, + OTHER_MAIN_ADDRESS, USER_SIGNED_PAYLOAD, } from '../../helpers/agentFixtures.js'; import { @@ -110,7 +111,7 @@ describe('HyperLiquidWalletService with accountSigner', () => { .catch((caught: unknown) => caught); expect(error).toStrictEqual(new Error(PERPS_ERROR_CODES.KEYRING_LOCKED)); - expect((error as Error).cause).toBe(hostError); + expect(error).toHaveProperty('cause', hostError); }); it('reports ready when isReady is omitted', () => { @@ -164,7 +165,6 @@ describe('HyperLiquidWalletService with accountSigner', () => { describe('HyperLiquidWalletService wallet adapter with an agent', () => { const { address: mainAddress } = createMockEvmAccount(); - const OTHER_MAIN_ADDRESS = '0x00000000000000000000000000000000000b0b01'; function buildAdapter(agentAvailable = true): { adapter: ReturnType; resolveAgent: jest.Mock; @@ -225,7 +225,9 @@ describe('HyperLiquidWalletService wallet adapter with an agent', () => { const signature = await adapter.signTypedData(USER_SIGNED_PAYLOAD); expect(signature).toBe(MAIN_SIGNATURE); - expect(mainSign).toHaveBeenCalledWith(mainAddress, USER_SIGNED_PAYLOAD); + expect(mainSign.mock.calls).toStrictEqual([ + [mainAddress, USER_SIGNED_PAYLOAD], + ]); expect(resolveAgent).not.toHaveBeenCalled(); expect(agentSign).not.toHaveBeenCalled(); }); @@ -237,8 +239,9 @@ describe('HyperLiquidWalletService wallet adapter with an agent', () => { domain: { ...L1_PAYLOAD.domain, name: 'HyperliquidSignTransaction' }, }; - await adapter.signTypedData(lookalike); + 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(); @@ -251,8 +254,9 @@ describe('HyperLiquidWalletService wallet adapter with an agent', () => { domain: L1_PAYLOAD.domain, }; - await adapter.signTypedData(lookalike); + 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(); @@ -326,7 +330,7 @@ describe('HyperLiquidWalletService wallet adapter with an agent', () => { .catch((caught: unknown) => caught); expect(error).toBeInstanceOf(AgentSignerUnavailableError); - expect((error as Error).cause).toBe(failure); + expect(error).toHaveProperty('cause', failure); expect(mainSign).not.toHaveBeenCalled(); }); }); 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 index dc45b48a004..438e816dee4 100644 --- a/packages/perps-controller/tests/src/services/LighterWalletService.account-signer.test.ts +++ b/packages/perps-controller/tests/src/services/LighterWalletService.account-signer.test.ts @@ -58,7 +58,7 @@ describe('LighterWalletService with accountSigner', () => { .catch((caught: unknown) => caught); expect(error).toStrictEqual(new Error(PERPS_ERROR_CODES.KEYRING_LOCKED)); - expect((error as Error).cause).toBe(hostError); + expect(error).toHaveProperty('cause', hostError); }); it('fails with KEYRING_LOCKED and does not sign when isReady returns false', async () => { diff --git a/packages/perps-controller/tests/src/services/agentSigner.test.ts b/packages/perps-controller/tests/src/services/agentSigner.test.ts index 51b749dcbb7..0b4cfe1cf7a 100644 --- a/packages/perps-controller/tests/src/services/agentSigner.test.ts +++ b/packages/perps-controller/tests/src/services/agentSigner.test.ts @@ -7,6 +7,7 @@ import type { PerpsAgentAccount } from '../../../src/types/index.js'; import { AGENT_ADDRESS, OTHER_AGENT_ADDRESS, + OTHER_MAIN_ADDRESS, sdkSigningError, } from '../../helpers/agentFixtures.js'; @@ -66,7 +67,7 @@ describe('AgentBindings', () => { const bindings = new AgentBindings(getAgentSigner); const otherAccount: PerpsAgentAccount = { ...ACCOUNT, - mainAddress: '0x00000000000000000000000000000000000b0b01', + mainAddress: OTHER_MAIN_ADDRESS, }; bindings.set(ACCOUNT, null); bindings.set(otherAccount, AGENT); From b18368a84b285c1d84f652c6751413960ca832a6 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 29 Sep 2026 14:40:42 +0800 Subject: [PATCH 25/33] fix(perps-controller): check a pending referral code only in preparation, and report a locked signer for providers without setup - A HyperLiquid referral whose MetaMask referral code is not ready no longer holds trading setup back, so orders stop looking the code up again; the next prepareTradingWallet checks it. A failed lookup is logged once and waits for the next setup. - PerpsController.prepareTradingWallet returns KEYRING_LOCKED when a provider reports ready while the main account cannot sign (for example an aggregated provider whose providers have nothing to prepare). - Document every prepareTradingWallet result, use isProviderOnTestnet for the Lighter provider, and evict rejected agents directly where only eviction is needed. - Split the HyperLiquid account-signer tests into focused files over a shared fixture, and cover a mixed batch cancel, a deselection during Lighter registration and more host error shapes. --- packages/perps-controller/CHANGELOG.md | 5 +- .../PerpsController-method-action-types.ts | 23 +- .../perps-controller/src/PerpsController.ts | 56 +- .../src/providers/HyperLiquidProvider.ts | 72 +- .../src/providers/LighterProvider.ts | 13 +- .../src/services/providerNetwork.ts | 6 +- packages/perps-controller/src/types/index.ts | 13 +- .../tests/helpers/agentFixtures.ts | 35 +- .../hyperLiquidAccountSignerFixture.ts | 395 +++ .../tests/helpers/providerMocks.ts | 30 + ...ntroller.agent-signing.integration.test.ts | 138 +- .../PerpsController.providers-cache.test.ts | 153 +- .../providers/AggregatedPerpsProvider.test.ts | 43 + .../HyperLiquidProvider.account-mode.test.ts | 4 +- ...HyperLiquidProvider.account-signer.test.ts | 3099 +---------------- ...yperLiquidProvider.agent-rejection.test.ts | 1002 ++++++ .../HyperLiquidProvider.agent-signer.test.ts | 644 ++++ .../HyperLiquidProvider.builder-fees.test.ts | 4 +- ...uidProvider.prepare-trading-wallet.test.ts | 809 +++++ ...yperLiquidProvider.strategy-signer.test.ts | 601 ++++ .../LighterProvider.account-signer.test.ts | 34 +- ...LiquidWalletService.account-signer.test.ts | 45 +- .../services/HyperLiquidWalletService.test.ts | 4 +- ...ighterWalletService.account-signer.test.ts | 13 + .../tests/src/services/agentSigner.test.ts | 13 +- 25 files changed, 3915 insertions(+), 3339 deletions(-) create mode 100644 packages/perps-controller/tests/helpers/hyperLiquidAccountSignerFixture.ts create mode 100644 packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-rejection.test.ts create mode 100644 packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-signer.test.ts create mode 100644 packages/perps-controller/tests/src/providers/HyperLiquidProvider.prepare-trading-wallet.test.ts create mode 100644 packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-signer.test.ts diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 4dee0af8b96..0b66642da06 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -31,7 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 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 and `EXCHANGE_ACCOUNT_NOT_FOUND` for a wallet with no account on the venue yet; the aggregated provider prepares every provider in turn + - Resolves a `ReadyToTradeResult` that is `ready: true` once 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) 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)) @@ -46,7 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - HyperLiquid writes that fail because the keyring is locked, or because the `accountSigner` is not ready, 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 -- A HyperLiquid referral skipped during trading setup, because the wallet has not deposited yet or the referral code is not ready, is attempted again as soon as it can be set, instead of after the provider reconnects ([#10559](https://github.com/MetaMask/core/pull/10559)) +- A HyperLiquid referral skipped during trading setup because the wallet has not deposited yet is attempted again at the next trading setup once the wallet has deposited, instead of after the provider reconnects ([#10559](https://github.com/MetaMask/core/pull/10559)) ## [18.0.1] diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index c34e118315d..c066452cb28 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -948,13 +948,22 @@ export type PerpsControllerClearAgentSignersAction = { * are L1 actions the agent signs. * * @returns `ready: true` when none of these steps will need a signature - * again before the first order, including a step the user declined that is - * not asked again (the HyperLiquid migration); `ready: false` while one will - * be asked again: a declined builder fee or Lighter registration, or a step - * whose signer (the main account or the agent) could not sign - * (`KEYRING_LOCKED`), or a wallet with no account on the venue yet - * (`EXCHANGE_ACCOUNT_NOT_FOUND`). Providers without deferred setup are - * ready while the main account can sign. + * again before the first order, and only while 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. diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 43633b5447a..6b3ed618ab5 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, @@ -59,6 +58,7 @@ 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'; @@ -2427,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({ @@ -5933,29 +5935,43 @@ export class PerpsController extends BaseController< * are L1 actions the agent signs. * * @returns `ready: true` when none of these steps will need a signature - * again before the first order, including a step the user declined that is - * not asked again (the HyperLiquid migration); `ready: false` while one will - * be asked again: a declined builder fee or Lighter registration, or a step - * whose signer (the main account or the agent) could not sign - * (`KEYRING_LOCKED`), or a wallet with no account on the venue yet - * (`EXCHANGE_ACCOUNT_NOT_FOUND`). Providers without deferred setup are - * ready while the main account can sign. + * again before the first order, and only while 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(); - if (provider.prepareTradingWallet) { - return await provider.prepareTradingWallet(); - } - // Nothing to prepare, but ready still needs a main account that can sign. - return isMainAccountSignerReady( - this.#options.infrastructure.accountSigner, - () => this.messenger.call('KeyringController:getState').isUnlocked, - ) - ? { ready: true } - : { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; + const result = (await provider.prepareTradingWallet?.()) ?? { + ready: true, + }; + // A provider with nothing to prepare, alone or aggregated, does not check + // the signer. + if ( + result.ready && + !isMainAccountSignerReady( + this.#options.infrastructure.accountSigner, + () => this.messenger.call('KeyringController:getState').isUnlocked, + ) + ) { + return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; + } + return result; } /** diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 04ab27c07ff..68deab30a5f 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -2910,11 +2910,15 @@ export class HyperLiquidProvider implements PerpsProvider { #tradingSetupComplete = false; // Set when the referral could not be written yet (its signer could not - // sign, the wallet has no HyperLiquid account yet, or the referral code is - // not ready), so trading setup is not marked complete and the referral is - // attempted again. + // sign, or the wallet has no HyperLiquid account yet), 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>(); /** @@ -4358,8 +4362,8 @@ export class HyperLiquidProvider implements PerpsProvider { /** * The signer failure a venue reported in cancel status entries rather than - * threw. One signature covers the whole request, so a rejected signer fails - * every entry. + * 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. @@ -6563,12 +6567,17 @@ export class HyperLiquidProvider implements PerpsProvider { }); const cancelStatus: unknown = cancelResult.response?.data?.status; // A rejected agent is dropped and reported; the TWAP stays live. - this.#classifyStatusSignerFailure([cancelStatus]); + if ( + isStatusObject(cancelStatus) && + typeof cancelStatus.error === 'string' + ) { + this.#evictRejectedAgent(new Error(cancelStatus.error)); + } remainsLive = classifyCancelStatus(cancelStatus) === CancelChildOutcome.Refused; } catch (error) { // A rejected agent is dropped and reported; the TWAP stays live. - this.#classifySignerFailure(error); + this.#evictRejectedAgent(error); this.#deps.debugLogger.log( 'Stale TWAP placement could not be retracted', { @@ -7241,7 +7250,7 @@ export class HyperLiquidProvider implements PerpsProvider { .then(() => this.#runChaseTick(sessionId)) .catch((error: unknown) => { // A rejected agent is dropped so the next tick asks for another. - this.#classifySignerFailure(error); + 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', { @@ -7926,7 +7935,7 @@ export class HyperLiquidProvider implements PerpsProvider { return outcome; } catch (error) { // A rejected agent is dropped and reported; the order stays resting. - this.#classifySignerFailure(error); + this.#evictRejectedAgent(error); this.#deps.debugLogger.log('Could not retract abandoned chase order', { orderId: session.orderId, error: ensureError(error, 'HyperLiquidProvider.startChaseSession') @@ -9671,7 +9680,8 @@ export class HyperLiquidProvider implements PerpsProvider { statuses.length === ordinaryOrders.length ) { // One signature covers the batch, so a signer failure is classified - // (and reported to the host) once, and fails every entry. + // (and reported to the host) once, and is the error of every entry + // that reports one. Entries that succeeded keep their result. const signerFailure = this.#classifyStatusSignerFailure(statuses); ordinaryOrders.forEach(({ index, order }, statusIndex) => { const status: unknown = statuses[statusIndex]; @@ -14575,11 +14585,14 @@ export class HyperLiquidProvider implements PerpsProvider { * @returns `ready: true` when the main-account signer is ready and none of * these steps will need a signature again before the first order; a step * the user declined counts, because the order path does not ask again - * either. `ready: false` carries `KEYRING_LOCKED` when the signer is not - * ready, `EXCHANGE_ACCOUNT_NOT_FOUND` for a wallet with no HyperLiquid - * account yet (fund it first), the error when the steps could not run, and - * no error when a step will retry (a rejected builder fee, a transient - * failure, or an agent that could not sign). + * either, and so does a referral whose MetaMask referral code is not ready + * yet, which the next call checks again. `ready: false` carries + * `KEYRING_LOCKED` when the signer is not ready, `EXCHANGE_ACCOUNT_NOT_FOUND` + * for a wallet with no HyperLiquid account yet (fund it first), + * `NO_ACCOUNT_SELECTED` or `PROVIDER_LIFECYCLE_STALE` (neither logged), the + * logged error when the steps could not run, and no error when a step will + * retry (a rejected builder fee, a transient failure, or an agent that + * could not sign). */ async prepareTradingWallet(): Promise { // Nothing can be signed, so run no setup (and log nothing) until it can. @@ -14587,6 +14600,10 @@ export class HyperLiquidProvider implements PerpsProvider { return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; } try { + // Run the shared setup again to check the builder's referral code. + if (this.#referralAwaitsBuilderCode) { + this.#tradingSetupComplete = false; + } await this.#ensureReadyForTrading({ requiresBuilderFee: false }); const network = this.#clientService.isTestnetMode() ? 'testnet' @@ -15715,6 +15732,7 @@ export class HyperLiquidProvider implements PerpsProvider { */ async #ensureReferralSet(): Promise { this.#referralSetupNeedsRetry = false; + this.#referralAwaitsBuilderCode = false; const isTestnet = this.#clientService.isTestnetMode(); const network = isTestnet ? 'testnet' : 'mainnet'; const expectedReferralCode = this.#getReferralCode(isTestnet); @@ -15804,14 +15822,15 @@ 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: attempt it again once the code is ready. - this.#referralSetupNeedsRetry = true; + // 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; } @@ -15909,11 +15928,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(); @@ -15931,7 +15951,7 @@ 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 @@ -15943,7 +15963,7 @@ export class HyperLiquidProvider implements PerpsProvider { referrerAddr, }, ); - return false; + return 'pending'; } catch (error) { this.#deps.logger.error( ensureError(error, 'HyperLiquidProvider.isReferralCodeReady'), @@ -15954,7 +15974,7 @@ export class HyperLiquidProvider implements PerpsProvider { ), }), ); - return false; + return 'failed'; } } diff --git a/packages/perps-controller/src/providers/LighterProvider.ts b/packages/perps-controller/src/providers/LighterProvider.ts index 78da315c983..158af90332d 100644 --- a/packages/perps-controller/src/providers/LighterProvider.ts +++ b/packages/perps-controller/src/providers/LighterProvider.ts @@ -1323,9 +1323,9 @@ export class LighterProvider implements PerpsProvider { * when no account is selected, with `EXCHANGE_ACCOUNT_NOT_FOUND` when the * wallet has no Lighter account yet (fund it first), without an error when * the user declined the signature (the order path asks again), with - * `PROVIDER_LIFECYCLE_STALE` (unlogged) when the - * provider disconnected or the wallet switched accounts meanwhile, and with - * the logged error when registration failed. + * `PROVIDER_LIFECYCLE_STALE` (unlogged) when the provider disconnected or + * the wallet switched accounts meanwhile, and with the logged error when + * registration failed. */ async prepareTradingWallet(): Promise { if (!this.#walletService.isMainAccountSignerReady()) { @@ -1358,12 +1358,7 @@ export class LighterProvider implements PerpsProvider { return { ready: false }; } // Nothing can be registered before the wallet has a Lighter account. - if ( - hasErrorInCauseChain( - caughtError, - (current) => current instanceof LighterAccountNotFoundError, - ) - ) { + if (caughtError instanceof LighterAccountNotFoundError) { return { ready: false, error: PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, diff --git a/packages/perps-controller/src/services/providerNetwork.ts b/packages/perps-controller/src/services/providerNetwork.ts index aafcf6b3fea..bfc186c0f43 100644 --- a/packages/perps-controller/src/services/providerNetwork.ts +++ b/packages/perps-controller/src/services/providerNetwork.ts @@ -11,10 +11,10 @@ import type { PerpsProviderType } from '../types/index.js'; * @returns True on testnet, false on mainnet, and undefined when the * provider follows a network that is not known. */ -export function isProviderOnTestnet( +export function isProviderOnTestnet( providerId: PerpsProviderType, - isTestnet: boolean | undefined, -): boolean | undefined { + 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 d819da4dc99..1a4a76e4adb 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -2166,11 +2166,14 @@ export type PerpsProvider = { * steps need the main account; HyperLiquid L1 steps (the referral, the * silent migration) are signed by an agent when one resolves. Resolves * `ready: true` when 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, with `EXCHANGE_ACCOUNT_NOT_FOUND` for a - * wallet with no account on the venue yet (a read-only provider, which never signs, - * resolves `ready: true` at once while the main-account signer is ready). - * Providers without such setup omit it. + * the first order (a read-only provider, which never signs, resolves it at + * once while the main-account signer is ready). Otherwise `ready: false`, + * without an error while a step will be retried (including after an agent + * could not sign), or with `KEYRING_LOCKED` when the main-account signer + * cannot sign, `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. Providers without such setup omit it. */ prepareTradingWallet?(): Promise; /** diff --git a/packages/perps-controller/tests/helpers/agentFixtures.ts b/packages/perps-controller/tests/helpers/agentFixtures.ts index d0a1a223596..ff5a0dbff99 100644 --- a/packages/perps-controller/tests/helpers/agentFixtures.ts +++ b/packages/perps-controller/tests/helpers/agentFixtures.ts @@ -5,6 +5,11 @@ import { createMockEvmAccount } from './serviceMocks.js'; const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as const; +// The payloads below spell out the SDK's domain names, primary types and fee +// rate instead of reading HYPERLIQUID_L1_ACTION_DOMAIN_NAME, +// HYPERLIQUID_L1_ACTION_PRIMARY_TYPE or BUILDER_FEE_CONFIG, so the routing +// tests fail if one of those constants drifts from what the SDK signs. + // The SDK adds the domain type to every payload it signs. const EIP712_DOMAIN_TYPE = [ { name: 'name', type: 'string' }, @@ -106,36 +111,6 @@ export const L1_PAYLOAD: PerpsTypedDataPayload = { message: { source: 'a', connectionId: `0x${'22'.repeat(32)}` }, }; -/** - * 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, - }; -} - /** * The error the HyperLiquid SDK throws when the wallet fails to sign, with * the wallet's error as its cause. diff --git a/packages/perps-controller/tests/helpers/hyperLiquidAccountSignerFixture.ts b/packages/perps-controller/tests/helpers/hyperLiquidAccountSignerFixture.ts new file mode 100644 index 00000000000..f69190afc72 --- /dev/null +++ b/packages/perps-controller/tests/helpers/hyperLiquidAccountSignerFixture.ts @@ -0,0 +1,395 @@ +/** + * The shared fixture of the HyperLiquidProvider account-signer tests: a real + * provider, wallet service and signing caches over mocked client and + * subscription services. + * + * 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 { + 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_SIGNATURE, + USER_SIGNED_PAYLOAD, + signThroughWallet, +} from './agentFixtures.js'; +import { + createMockExchangeClient, + createMockInfoClient, +} from './providerMocks.js'; +import { + createDeferred, + createKeyringMessenger, + createKeyringlessMessenger, + createMockEvmAccount, + 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 + >; + +// The wallet service and the signing caches are real. 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. +export const ACCOUNT_ADDRESS = createMockEvmAccount().address; + +// The selected account on mainnet, as getAgentSigner is asked for it. +export const MAINNET_ACCOUNT = { + mainAddress: ACCOUNT_ADDRESS, + isTestnet: false, +} as const; + +// A fixed clock for cache timestamps. +export const NOW = 1_700_000_000_000; + +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: ACCOUNT_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 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', ACCOUNT_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', ACCOUNT_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(); +} + +export 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 }; +} + +export 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: 123 } }] } }, + }), + ), + ...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 a5099684fd2..1880bde4a16 100644 --- a/packages/perps-controller/tests/helpers/providerMocks.ts +++ b/packages/perps-controller/tests/helpers/providerMocks.ts @@ -122,6 +122,36 @@ export const createMockPosition = (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 = {}, ) => ({ diff --git a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts index de59703543e..9db9c85e18a 100644 --- a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts @@ -321,15 +321,36 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => } /** - * The typed-data signatures the host's KeyringController was asked for. + * Assert what the host 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`. - * @returns The `KeyringController:signTypedMessage` calls. + * @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 keyringSignatureRequests(call: jest.SpyInstance): unknown[][] { - return call.mock.calls - .map((args: unknown[]) => args) - .filter(([action]) => action === 'KeyringController:signTypedMessage'); + 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); + expect(keyringCalls(call)).toStrictEqual(keyringActions); + expect(loggerError).not.toHaveBeenCalled(); } /** @@ -387,14 +408,11 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => ['approveBuilderFee', MAIN_ADDRESS], ['order', AGENT_ADDRESS], ]); - expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); expect(agentSigner.signTypedData.mock.calls).toStrictEqual([[L1_PAYLOAD]]); expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ [MAIN_ADDRESS, APPROVE_BUILDER_FEE_PAYLOAD], ]); - expect(onAgentRejected).not.toHaveBeenCalled(); - expect(keyringCalls(call)).toStrictEqual([]); - expect(loggerError).not.toHaveBeenCalled(); + expectHostSaw(call, { agentRequests: [MAINNET_ACCOUNT] }); }); it('signs L1 actions with the main account when the host has no getAgentSigner', async () => { @@ -408,8 +426,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ [MAIN_ADDRESS, L1_PAYLOAD], ]); - expect(keyringCalls(call)).toStrictEqual([]); - expect(loggerError).not.toHaveBeenCalled(); + expectHostSaw(call, { agentRequests: [] }); }); it('signs L1 actions with the agent and user-signed actions through KeyringController for a host without accountSigner', async () => { @@ -428,7 +445,11 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => ['approveBuilderFee', MAIN_ADDRESS], ['order', AGENT_ADDRESS], ]); - expect(keyringSignatureRequests(call)).toStrictEqual([ + expect( + call.mock.calls.filter( + ([action]) => action === 'KeyringController:signTypedMessage', + ), + ).toStrictEqual([ [ 'KeyringController:signTypedMessage', { from: MAIN_ADDRESS, data: APPROVE_BUILDER_FEE_PAYLOAD }, @@ -436,9 +457,14 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => ], ]); expect(agentSigner.signTypedData.mock.calls).toStrictEqual([[L1_PAYLOAD]]); - expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); - expect(onAgentRejected).not.toHaveBeenCalled(); - expect(loggerError).not.toHaveBeenCalled(); + 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 () => { @@ -454,10 +480,12 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); + expect(mockVenue.networks).toStrictEqual([]); expect(mockVenue.writes).toStrictEqual([]); - expect(keyringSignatureRequests(call)).toStrictEqual([]); - expect(getAgentSigner).not.toHaveBeenCalled(); - expect(loggerError).not.toHaveBeenCalled(); + expectHostSaw(call, { + agentRequests: [], + keyringActions: ['KeyringController:getState'], + }); }); it('pins L1 actions to the main account with setAgentSigner(null) until clearAgentSigners', async () => { @@ -477,10 +505,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => ['order', MAIN_ADDRESS], ['order', AGENT_ADDRESS], ]); - expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); - expect(onAgentRejected).not.toHaveBeenCalled(); - expect(keyringCalls(call)).toStrictEqual([]); - expect(loggerError).not.toHaveBeenCalled(); + expectHostSaw(call, { agentRequests: [MAINNET_ACCOUNT] }); }); it('keeps a setAgentSigner binding when the HyperLiquid provider is re-created', async () => { @@ -510,15 +535,12 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => ['order', MAIN_ADDRESS], ['order', OTHER_AGENT_ADDRESS], ]); - expect(getAgentSigner.mock.calls).toStrictEqual([ - [{ ...MAINNET_ACCOUNT, isTestnet: true }], - ]); expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ [MAIN_ADDRESS, L1_PAYLOAD], ]); - expect(onAgentRejected).not.toHaveBeenCalled(); - expect(keyringCalls(call)).toStrictEqual([]); - expect(loggerError).not.toHaveBeenCalled(); + expectHostSaw(call, { + agentRequests: [{ ...MAINNET_ACCOUNT, isTestnet: true }], + }); }); it('honors a setAgentSigner binding made before init', async () => { @@ -532,10 +554,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => expect(placed).toStrictEqual(PLACED_ORDER); expect(signedWrites()).toStrictEqual([['order', OTHER_AGENT_ADDRESS]]); - expect(getAgentSigner).not.toHaveBeenCalled(); - expect(onAgentRejected).not.toHaveBeenCalled(); - expect(keyringCalls(call)).toStrictEqual([]); - expect(loggerError).not.toHaveBeenCalled(); + expectHostSaw(call, { agentRequests: [] }); }); it('forgets a setAgentSigner binding cleared before init', async () => { @@ -549,10 +568,8 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => expect(placed).toStrictEqual(PLACED_ORDER); expect(signedWrites()).toStrictEqual([['order', AGENT_ADDRESS]]); - expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); expect(boundAgent.signTypedData).not.toHaveBeenCalled(); - expect(keyringCalls(call)).toStrictEqual([]); - expect(loggerError).not.toHaveBeenCalled(); + expectHostSaw(call, { agentRequests: [MAINNET_ACCOUNT] }); }); it('signs with the agent bound through setAgentSigner after another was resolved', async () => { @@ -579,10 +596,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => ['order', OTHER_AGENT_ADDRESS], ['order', MAIN_ADDRESS], ]); - expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); - expect(onAgentRejected).not.toHaveBeenCalled(); - expect(keyringCalls(call)).toStrictEqual([]); - expect(loggerError).not.toHaveBeenCalled(); + expectHostSaw(call, { agentRequests: [MAINNET_ACCOUNT] }); }); it('tells the host about an agent the venue rejects and asks for another', async () => { @@ -608,21 +622,15 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => orderId: '1', error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); - expect(onAgentRejected.mock.calls).toStrictEqual([ - [MAINNET_ACCOUNT, AGENT_ADDRESS], - ]); expect(placed).toStrictEqual(PLACED_ORDER); expect(signedWrites()).toStrictEqual([ ['cancel', AGENT_ADDRESS], ['order', OTHER_AGENT_ADDRESS], ]); - expect(getAgentSigner.mock.calls).toStrictEqual([ - [MAINNET_ACCOUNT], - [MAINNET_ACCOUNT], - ]); - // Nothing reports the retryable signer failure. - expect(keyringCalls(call)).toStrictEqual([]); - expect(loggerError).not.toHaveBeenCalled(); + expectHostSaw(call, { + agentRequests: [MAINNET_ACCOUNT, MAINNET_ACCOUNT], + rejectedAgents: [[MAINNET_ACCOUNT, AGENT_ADDRESS]], + }); }); it('releases a setAgentSigner binding to an agent the venue rejects', async () => { @@ -643,18 +651,16 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => orderId: '1', error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); - expect(onAgentRejected.mock.calls).toStrictEqual([ - [MAINNET_ACCOUNT, OTHER_AGENT_ADDRESS], - ]); // 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], ]); - expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); - expect(keyringCalls(call)).toStrictEqual([]); - expect(loggerError).not.toHaveBeenCalled(); + expectHostSaw(call, { + agentRequests: [MAINNET_ACCOUNT], + rejectedAgents: [[MAINNET_ACCOUNT, OTHER_AGENT_ADDRESS]], + }); }); it('releases a binding to an agent the venue rejects for a host without onAgentRejected', async () => { @@ -683,15 +689,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => ['cancel', OTHER_AGENT_ADDRESS], ['order', AGENT_ADDRESS], ]); - expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); - // Nothing threw while telling the host about the rejection. - expect( - (infrastructure.debugLogger.log as jest.Mock).mock.calls.filter( - ([message]) => message === 'HyperLiquidProvider: onAgentRejected threw', - ), - ).toStrictEqual([]); - expect(keyringCalls(call)).toStrictEqual([]); - expect(loggerError).not.toHaveBeenCalled(); + expectHostSaw(call, { agentRequests: [MAINNET_ACCOUNT] }); }); it('prepares nothing and reports KEYRING_LOCKED while the account signer is not ready', async () => { @@ -709,10 +707,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => expect(mockVenue.networks).toStrictEqual([]); expect(mockVenue.writes).toStrictEqual([]); expect(accountSigner.signTypedData).not.toHaveBeenCalled(); - expect(getAgentSigner).not.toHaveBeenCalled(); - expect(onAgentRejected).not.toHaveBeenCalled(); - expect(keyringCalls(call)).toStrictEqual([]); - expect(loggerError).not.toHaveBeenCalled(); + expectHostSaw(call, { agentRequests: [] }); }); it('prepares the migration and builder fee on the main account and the referral on the agent', async () => { @@ -755,9 +750,6 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => [MAIN_ADDRESS, APPROVE_BUILDER_FEE_PAYLOAD], ]); expect(agentSigner.signTypedData.mock.calls).toStrictEqual([[L1_PAYLOAD]]); - expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); - expect(onAgentRejected).not.toHaveBeenCalled(); - expect(keyringCalls(call)).toStrictEqual([]); - expect(loggerError).not.toHaveBeenCalled(); + 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 c7a6b5875f7..e5b79fa69a7 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -6,19 +6,14 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; -import type { - MessengerActions, - MessengerEvents, - MockAnyNamespace, -} from '@metamask/messenger'; - -import { AGENT_ADDRESS } from '../helpers/agentFixtures.js'; +import { AGENT_ADDRESS, MAIN_SIGNATURE } from '../helpers/agentFixtures.js'; import { createMockHyperLiquidProvider, createMockPosition, } from '../helpers/providerMocks.js'; import { + createKeyringMessenger, + createMockEvmAccount, createMockInfrastructure, createMockMessenger, } from '../helpers/serviceMocks.js'; @@ -33,16 +28,14 @@ import { PERPS_CONSTANTS, PERPS_DISK_CACHE_MARKETS, PERPS_DISK_CACHE_USER_DATA, + PROVIDER_CONFIG, } from '../../src/constants/perpsConfig.js'; import { PerpsController, getDefaultPerpsControllerState, InitializationState, } from '../../src/PerpsController.js'; -import type { - PerpsControllerMessenger, - PerpsControllerState, -} 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'; @@ -892,6 +885,45 @@ 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(), + ); + controller = new TestablePerpsController({ + messenger: createMockMessenger(), + 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).toHaveBeenCalledWith( + expect.objectContaining({ + isTestnet: true, + 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'), @@ -925,7 +957,7 @@ describe('PerpsController', () => { describe('account and agent signers', () => { const account = { - mainAddress: '0x1234567890123456789012345678901234567890', + mainAddress: createMockEvmAccount().address, isTestnet: false, } as const; @@ -1036,19 +1068,13 @@ describe('PerpsController', () => { it('runs the agent and preparation actions called through the messenger after init', async () => { const getAgentSigner = jest.fn().mockResolvedValue(agentSigner); - const rootMessenger = new Messenger< - MockAnyNamespace, - MessengerActions, - MessengerEvents - >({ namespace: MOCK_ANY_NAMESPACE }); + // A keyring host, unlocked. + const { messenger, rootMessenger } = + createKeyringMessenger(MAIN_SIGNATURE); rootMessenger.registerActionHandler( 'RemoteFeatureFlagController:getState', () => ({ remoteFeatureFlags: {}, cacheTimestamp: 0 }), ); - const messenger: PerpsControllerMessenger = new Messenger({ - namespace: 'PerpsController', - parent: rootMessenger, - }); rootMessenger.delegate({ actions: ['RemoteFeatureFlagController:getState'], events: [ @@ -1088,6 +1114,28 @@ describe('PerpsController', () => { }); 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(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[] = []; @@ -1097,7 +1145,6 @@ describe('PerpsController', () => { providerIdsAtConstruction = [...config.providers.keys()]; return new RealAggregatedPerpsProvider(config); }); - mockProvider.clearAgentSigners = jest.fn(); controller = new TestablePerpsController({ messenger: createMockMessenger(), state: { @@ -1107,16 +1154,10 @@ describe('PerpsController', () => { }, infrastructure: mockInfrastructure, }); + await controller.init(); registerMockLighterProvider(controller); - const providers = controller.testGetProviders(); - - controller.setAgentSigner(account, agentSigner); - controller.clearAgentSigners(); - expect([...providers.keys()]).toStrictEqual(['hyperliquid', 'lighter']); - expect(providers.get('lighter')).not.toHaveProperty('clearAgentSigners'); - expect(mockProvider.clearAgentSigners.mock.calls).toStrictEqual([[], []]); expect(aggregatedConstructor.mock.calls).toStrictEqual([ [ { @@ -1131,7 +1172,11 @@ describe('PerpsController', () => { // map it shares with the controller. expect(providerIdsAtConstruction).toStrictEqual(['hyperliquid']); const [[constructedWith]] = aggregatedConstructor.mock.calls; - expect(constructedWith.providers).toBe(providers); + expect(constructedWith.providers).toBe(controller.testGetProviders()); + expect([...constructedWith.providers.keys()]).toStrictEqual([ + 'hyperliquid', + 'lighter', + ]); }); it.each([ @@ -1188,6 +1233,52 @@ describe('PerpsController', () => { ).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 }) => { + const call = jest + .fn() + .mockImplementation((action: string) => + action === 'KeyringController:getState' + ? { isUnlocked: false } + : undefined, + ); + // 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(); + + const result = await controller.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.KEYRING_LOCKED, + }); + expect(mockProvider.prepareTradingWallet.mock.calls).toStrictEqual([ + [], + ]); + }, + ); }); describe('getOpenOrders with standalone mode', () => { diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts index e68f08dccaf..78ae7b3607e 100644 --- a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -1160,6 +1160,49 @@ describe('AggregatedPerpsProvider', () => { ]); }); + 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'], 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 4beaab3a9f5..7e18814b313 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 @@ -1696,7 +1696,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'), @@ -1714,7 +1714,7 @@ 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.requiresSignatureConfirmation.mockReturnValue(true); 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 index d410913f06d..28426b59300 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts @@ -1,69 +1,35 @@ -import type { Hex } from '@metamask/utils'; - import { PERPS_EVENT_PROPERTY, PERPS_EVENT_VALUE, } from '../../../src/constants/eventNames.js'; -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 { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider.js'; -import { - AgentBindings, - AgentSignerUnavailableError, -} 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 { PerpsSigningCache } from '../../../src/services/TradingReadinessCache.js'; import { PerpsAnalyticsEvent } from '../../../src/types/index.js'; -import type { - HyperLiquidCredentials, - PerpsAgentAccount, - PerpsAgentSigner, - PerpsPlatformDependencies, - PerpsTypedDataPayload, -} from '../../../src/types/index.js'; +import type { PerpsTypedDataPayload } from '../../../src/types/index.js'; import { - AGENT_ADDRESS, - AGENT_SIGNATURE, APPROVE_BUILDER_FEE_PAYLOAD, - L1_PAYLOAD, MAIN_SIGNATURE, - OTHER_AGENT_ADDRESS, - OTHER_AGENT_SIGNATURE, - OTHER_MAIN_ADDRESS, USER_SIGNED_PAYLOAD, - createFrontendOpenOrder, - signThroughWallet, - unknownWalletError, } from '../../helpers/agentFixtures.js'; import { - createMockExchangeClient, - createMockInfoClient, -} from '../../helpers/providerMocks.js'; -import { - createDeferred, - createKeyringMessenger, - createKeyringlessMessenger, - createMockEvmAccount, - createMockInfrastructure, - keyringCalls, -} from '../../helpers/serviceMocks.js'; + ACCOUNT_ADDRESS, + 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. -jest.mock('@nktkas/hyperliquid', () => ({})); +// 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 @@ -71,282 +37,14 @@ jest.mock('@nktkas/hyperliquid', () => ({})); jest.mock('../../../src/services/HyperLiquidClientService'); jest.mock('../../../src/services/HyperLiquidSubscriptionService'); -const CACHED_PRICES: Record = { BTC: '50000', ETH: '3000' }; - -const MockedHyperLiquidClientService = - HyperLiquidClientService as jest.MockedClass; -const MockedHyperLiquidSubscriptionService = - HyperLiquidSubscriptionService as jest.MockedClass< - typeof HyperLiquidSubscriptionService - >; - describe('HyperLiquidProvider with a real wallet service and accountSigner', () => { - let mockClientService: jest.Mocked; - let mockPlatformDependencies: PerpsPlatformDependencies; let loggerError: jest.SpyInstance; let trackPerpsEvent: jest.SpyInstance; beforeEach(() => { - TradingReadinessCache.clearAll(); - mockPlatformDependencies = createMockInfrastructure(); - loggerError = jest.spyOn(mockPlatformDependencies.logger, 'error'); - trackPerpsEvent = jest.spyOn( - mockPlatformDependencies.metrics, - 'trackPerpsEvent', - ); - 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, - ); + ({ loggerError, trackPerpsEvent } = setUpAccountSignerSuite()); }); - // The wallet service and the signing caches are real. 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. - const ACCOUNT_ADDRESS = createMockEvmAccount().address; - const OTHER_ACCOUNT_ADDRESS = OTHER_MAIN_ADDRESS; - - // A fixed clock for cache timestamps. - const NOW = 1_700_000_000_000; - - const BTC_MARKET_ORDER = { - symbol: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'market', - currentPrice: 50000, - } as const; - - /** - * The order ID a placement returned. - * - * @param result - The placement result. - * @param result.orderId - Its order ID, if any. - * @returns The order ID. - */ - function orderIdOf(result: { orderId?: string }): string { - if (result.orderId === undefined) { - throw new Error('The placement returned no order ID'); - } - return result.orderId; - } - - // The SDK writes the provider makes for the selected account on mainnet. - const MIGRATION_WRITE = [ - { user: ACCOUNT_ADDRESS, abstraction: HL_UNIFIED_ACCOUNT_MODE }, - ]; - const SILENT_MIGRATION_WRITE = [ - { abstraction: HL_ABSTRACTION_WIRE.unifiedAccount }, - ]; - const REFERRAL_WRITE = [{ code: REFERRAL_CONFIG.MainnetCode }]; - const BUILDER_FEE_WRITE = [ - { - builder: BUILDER_FEE_CONFIG.MainnetBuilder, - maxFeeRate: BUILDER_FEE_CONFIG.MaxFeeRate, - }, - ]; - - /** - * Whether the unified-account migration is recorded as attempted for the - * selected account on mainnet. - * - * @returns True once the migration result is cached. - */ - function migrationAttempted(): boolean { - return ( - TradingReadinessCache.get('mainnet', ACCOUNT_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. - */ - function referralAttempted(): boolean { - return ( - PerpsSigningCache.getReferral('mainnet', ACCOUNT_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. - */ - 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 }; - } - - type Options = { - signer?: { - isReady?: () => boolean; - requiresSignatureConfirmation?: () => boolean; - }; - abstraction?: 'dexAbstraction' | 'default' | 'unifiedAccount'; - getAgentSigner?: HyperLiquidCredentials['getAgentSigner']; - onAgentRejected?: jest.Mock; - // Sign through a KeyringController instead of accountSigner. - keyring?: boolean; - // Extra SDK client methods, for the strategy order endpoints. - exchange?: Record; - info?: Record; - }; - - 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]>; - selectAccount: (address: Hex) => void; - deselectAccount: () => void; - }; - - function createAccountSignerProvider( - options: Options = {}, - ): AccountSignerFixture { - 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 sdkWallet: HyperLiquidWalletParams | undefined; - const initialize = jest.fn(async (wallet: HyperLiquidWalletParams) => { - sdkWallet = wallet; - }); - const signThroughSdkWallet = - ( - payload: PerpsTypedDataPayload, - response: Record = { status: 'ok' }, - ): (() => Promise>) => - async () => { - if (!sdkWallet) { - throw new Error('SDK used before initialize'); - } - 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: 123 } }] } }, - }), - ), - ...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, - initialAssetMapping: [ - ['BTC', 0], - ['ETH', 1], - ], - getAgentSigner: options.getAgentSigner, - onAgentRejected: options.onAgentRejected, - }); - return { - accountSignerProvider, - accountSigner, - agentSigner, - call, - exchangeClient, - infoClient, - initialize, - selectAccount, - deselectAccount, - }; - } - it('signs the init-time unified-account migration through accountSigner', async () => { const { accountSignerProvider, accountSigner, call, exchangeClient } = createAccountSignerProvider(); @@ -536,9 +234,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () signer: { isReady: () => signerReady }, }); const signUserAction = async (): Promise> => { - await fixture.initialize.mock.calls[0][0].signTypedData( - USER_SIGNED_PAYLOAD, - ); + await fixture.sdkWallet().signTypedData(USER_SIGNED_PAYLOAD); return { status: 'ok' }; }; const withdraw3 = jest.fn(signUserAction); @@ -608,2759 +304,4 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(loggerError).not.toHaveBeenCalled(); }); }); - - 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([ - [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], - [ACCOUNT_ADDRESS, L1_PAYLOAD], - ]); - // Already approved, so nothing is signed for it. - expect(infoClient.maxBuilderFee.mock.calls).toStrictEqual([ - [{ user: ACCOUNT_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([ - [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], - [ACCOUNT_ADDRESS, L1_PAYLOAD], - [ACCOUNT_ADDRESS, APPROVE_BUILDER_FEE_PAYLOAD], - ]); - expect(order).toStrictEqual({ - success: true, - orderId: '123', - submittedSize: '0.1', - averagePrice: undefined, - filledSize: undefined, - }); - expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ - [ACCOUNT_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', - ACCOUNT_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', ACCOUNT_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', ACCOUNT_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', ACCOUNT_ADDRESS), - ).toStrictEqual({ - attempted: true, - enabled: true, - reason: undefined, - timestamp: NOW, - }); - // Migration, then referral; nothing on the second call. - expect(firstSignatures).toStrictEqual([ - [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], - [ACCOUNT_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', ACCOUNT_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 when the builder fee approval is rejected', async () => { - const { accountSignerProvider, exchangeClient, infoClient } = - createAccountSignerProvider({ abstraction: 'unifiedAccount' }); - infoClient.maxBuilderFee.mockResolvedValue(0); - exchangeClient.approveBuilderFee.mockRejectedValue( - new Error('User rejected the request.'), - ); - - const result = await accountSignerProvider.prepareTradingWallet(); - - expect(result).toStrictEqual({ ready: false }); - expect(exchangeClient.approveBuilderFee.mock.calls).toStrictEqual([ - BUILDER_FEE_WRITE, - ]); - }); - - 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('does not log a provider replaced during preparation', async () => { - const { accountSignerProvider, initialize } = - createAccountSignerProvider(); - initialize.mockRejectedValue( - new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE), - ); - - const result = await 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: ACCOUNT_ADDRESS, data: USER_SIGNED_PAYLOAD }, - 'V4', - ], - ]); - expect(typedDataSignatures()).toStrictEqual([ - [ - 'KeyringController:signTypedMessage', - { from: ACCOUNT_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([ - [ACCOUNT_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('sets the referral once a wallet prepared before its first deposit has deposited', 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 }); - expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ - REFERRAL_WRITE, - ]); - 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('attempts the referral again when the venue rejects the wallet as unknown despite the probe', async () => { - const { accountSignerProvider, exchangeClient } = - createAccountSignerProvider({ abstraction: 'unifiedAccount' }); - // The probe sees a deposit, but the venue has not caught up yet. - exchangeClient.setReferrer.mockRejectedValueOnce( - unknownWalletError(ACCOUNT_ADDRESS), - ); - - const rejected = await accountSignerProvider.prepareTradingWallet(); - const referralAfterRejection = referralAttempted(); - const retried = await accountSignerProvider.prepareTradingWallet(); - - expect(rejected).toStrictEqual({ ready: false }); - expect(referralAfterRejection).toBe(false); - expect(retried).toStrictEqual({ ready: true }); - expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ - REFERRAL_WRITE, - REFERRAL_WRITE, - ]); - expect(loggerError).not.toHaveBeenCalled(); - }); - - it('sets the referral once the referral code becomes ready', 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: false }); - expect(afterReady).toStrictEqual({ ready: true }); - expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ - REFERRAL_WRITE, - ]); - expect(loggerError).not.toHaveBeenCalled(); - }); - - 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, - }); - }); - }); - - describe('with an agent', () => { - const MAINNET_ACCOUNT = { - mainAddress: ACCOUNT_ADDRESS, - isTestnet: false, - } as const; - - /** - * 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. - */ - function bind( - provider: HyperLiquidProvider, - bindings: AgentBindings, - account: PerpsAgentAccount, - agentSigner: PerpsAgentSigner | null, - ): void { - bindings.set(account, agentSigner); - provider.clearAgentSigners(); - } - - it('resolves the agent at the first L1 signature and signs with it', async () => { - const getAgentSigner = jest.fn(); - const { accountSignerProvider, accountSigner, agentSigner, initialize } = - createAccountSignerProvider({ - abstraction: 'default', - getAgentSigner, - }); - getAgentSigner.mockResolvedValue(agentSigner); - - await accountSignerProvider.getMarketDataWithPrices(); - - expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); - expect(initialize.mock.calls[0][0].address).toBe(ACCOUNT_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([ - [ACCOUNT_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 } = createAccountSignerProvider({ - abstraction: 'unifiedAccount', - getAgentSigner, - }); - - await accountSignerProvider.getMarketDataWithPrices(); - - 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([ - [ACCOUNT_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([ - [ACCOUNT_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_ACCOUNT_ADDRESS, isTestnet: false }, - agentSigner, - ); - await accountSignerProvider.getMarketDataWithPrices(); - selectAccount(OTHER_ACCOUNT_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([ - [ACCOUNT_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); - const { accountSignerProvider, accountSigner, agentSigner } = - createAccountSignerProvider({ - abstraction: 'default', - getAgentSigner: bindings.resolve, - }); - bindings.set(MAINNET_ACCOUNT, agentSigner); - - mockClientService.isTestnetMode.mockReturnValue(true); - await accountSignerProvider.getMarketDataWithPrices(); - - expect(agentSigner.signTypedData).not.toHaveBeenCalled(); - expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ - [ACCOUNT_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([ - [ACCOUNT_ADDRESS, L1_PAYLOAD], - [ACCOUNT_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([ - [ACCOUNT_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); - const { accountSignerProvider } = createAccountSignerProvider({ - abstraction: 'default', - getAgentSigner, - }); - mockClientService.isTestnetMode.mockReturnValue(true); - - await accountSignerProvider.getMarketDataWithPrices(); - - expect(getAgentSigner.mock.calls).toStrictEqual([ - [{ mainAddress: ACCOUNT_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, initialize } = - 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]] = initialize.mock.calls; - await wallet.signTypedData(L1_PAYLOAD); - - mockClientService.isTestnetMode.mockReturnValue(true); - await wallet.signTypedData(L1_PAYLOAD); - - expect(getAgentSigner.mock.calls).toStrictEqual([ - [MAINNET_ACCOUNT], - [{ mainAddress: ACCOUNT_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([ - [ACCOUNT_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, initialize } = - createAccountSignerProvider({ - abstraction: 'unifiedAccount', - getAgentSigner, - }); - getAgentSigner.mockResolvedValue(agentSigner); - await accountSignerProvider.getMarketDataWithPrices(); - const [[wallet]] = initialize.mock.calls; - - 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([ - [ACCOUNT_ADDRESS, L1_PAYLOAD], - ]); - expect(getAgentSigner.mock.calls).toStrictEqual([ - [MAINNET_ACCOUNT], - [MAINNET_ACCOUNT], - [MAINNET_ACCOUNT], - ]); - }); - - it('asks getAgentSigner again once the bindings are cleared', async () => { - const getAgentSigner = jest.fn(); - const bindings = new AgentBindings(getAgentSigner); - const { accountSignerProvider, accountSigner, agentSigner, initialize } = - createAccountSignerProvider({ - abstraction: 'unifiedAccount', - getAgentSigner: bindings.resolve, - }); - getAgentSigner.mockResolvedValue(agentSigner); - bindings.set(MAINNET_ACCOUNT, null); - await accountSignerProvider.getMarketDataWithPrices(); - const [[wallet]] = initialize.mock.calls; - // 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([[ACCOUNT_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 getAgentSigner rejects', async () => { - const getAgentSigner = jest - .fn() - .mockRejectedValue(new Error('agent store unavailable')); - const { accountSignerProvider, exchangeClient } = - createAccountSignerProvider({ - abstraction: 'unifiedAccount', - getAgentSigner, - }); - - const result = await accountSignerProvider.prepareTradingWallet(); - - expect(result).toStrictEqual({ ready: false }); - expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ - REFERRAL_WRITE, - ]); - expect(referralAttempted()).toBe(false); - expect(loggerError).not.toHaveBeenCalled(); - }); - - it('leaves the referral to retry, unrecorded, when the agent fails to sign', async () => { - const getAgentSigner = jest.fn(); - const { accountSignerProvider, agentSigner, exchangeClient, initialize } = - createAccountSignerProvider({ - abstraction: 'unifiedAccount', - getAgentSigner, - }); - agentSigner.signTypedData.mockRejectedValue( - new Error('agent key locked'), - ); - getAgentSigner.mockResolvedValue(agentSigner); - - const result = await accountSignerProvider.prepareTradingWallet(); - const [[wallet]] = initialize.mock.calls; - 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, initialize } = - createAccountSignerProvider({ - abstraction: 'unifiedAccount', - getAgentSigner, - }); - await accountSignerProvider.getMarketDataWithPrices(); - const [[wallet]] = initialize.mock.calls; - - 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([ - [ACCOUNT_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 secondResult = await accountSignerProvider.prepareTradingWallet(); - - expect(firstResult).toStrictEqual({ ready: false }); - expect(secondResult).toStrictEqual({ ready: true }); - expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ - REFERRAL_WRITE, - REFERRAL_WRITE, - ]); - }); - - const CHECKSUMMED_AGENT_ADDRESS = - '0x00000000000000000000000000000000000A9E17' as const; - - const rejection = unknownWalletError; - - 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, - }); - - /** - * 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.initialize.mock.calls[0][0].signTypedData(L1_PAYLOAD); - throw rejection(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, - initialize, - onAgentRejected, - } = createRejectingProvider('cancel'); - await accountSignerProvider.getMarketDataWithPrices(); - const [[wallet]] = initialize.mock.calls; - exchangeClient.cancel.mockImplementation(async () => { - await wallet.signTypedData(L1_PAYLOAD); - return { - status: 'ok', - response: { - data: { - statuses: [{ error: rejection(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, - initialize, - onAgentRejected, - } = createRejectingProvider('cancel'); - await accountSignerProvider.getMarketDataWithPrices(); - const [[wallet]] = initialize.mock.calls; - exchangeClient.cancel.mockImplementation(async () => { - await wallet.signTypedData(L1_PAYLOAD); - return { - status: 'ok', - response: { - data: { - statuses: [ - { error: rejection(AGENT_ADDRESS).message }, - { error: rejection(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('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: 456 }] }], - ]); - 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: 456 }] }], - ]); - expect(exchangeClient.order).not.toHaveBeenCalled(); - 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 in a status entry', async () => { - const { - accountSignerProvider, - exchangeClient, - infoClient, - initialize, - onAgentRejected, - } = createRejectingProvider('cancel'); - infoClient.frontendOpenOrders.mockResolvedValue([TAKE_PROFIT_ORDER]); - await accountSignerProvider.getMarketDataWithPrices(); - const [[wallet]] = initialize.mock.calls; - exchangeClient.cancel.mockImplementation(async () => { - await wallet.signTypedData(L1_PAYLOAD); - return { - status: 'ok', - response: { - data: { - statuses: [{ error: rejection(AGENT_ADDRESS).message }], - }, - }, - }; - }); - - 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: 456 }] }], - ]); - expect(exchangeClient.order).not.toHaveBeenCalled(); - expect(onAgentRejected.mock.calls).toStrictEqual([ - [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, - initialize, - onAgentRejected, - } = createRejectingProvider('cancel'); - onAgentRejected.mockImplementation(() => { - throw new Error('host callback failed'); - }); - await accountSignerProvider.getMarketDataWithPrices(); - - const result = await accountSignerProvider.cancelOrder({ - orderId: '123', - symbol: 'BTC', - }); - await initialize.mock.calls[0][0].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, - initialize, - selectAccount, - } = createAccountSignerProvider({ - abstraction: 'unifiedAccount', - getAgentSigner, - onAgentRejected, - }); - getAgentSigner.mockResolvedValue(agentSigner); - await accountSignerProvider.getMarketDataWithPrices(); - const [[wallet]] = initialize.mock.calls; - const signed = createDeferred(); - const venue = createDeferred(); - exchangeClient.cancel.mockImplementation(async () => { - await wallet.signTypedData(L1_PAYLOAD); - signed.resolve(); - await venue.promise; - throw rejection(agentSigner.address); - }); - - const cancelling = accountSignerProvider.cancelOrder({ - orderId: '123', - symbol: 'BTC', - }); - await signed.promise; - selectAccount(OTHER_ACCOUNT_ADDRESS); - venue.resolve(); - const result = await cancelling; - selectAccount(ACCOUNT_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, - initialize, - } = 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]] = initialize.mock.calls; - const signed = createDeferred(); - const venue = createDeferred(); - exchangeClient.order.mockImplementation(async () => { - await wallet.signTypedData(L1_PAYLOAD); - signed.resolve(); - await venue.promise; - throw rejection(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, - initialize, - } = createAccountSignerProvider({ - abstraction: 'unifiedAccount', - getAgentSigner, - onAgentRejected, - }); - getAgentSigner.mockResolvedValue(agentSigner); - await accountSignerProvider.getMarketDataWithPrices(); - const [[wallet]] = initialize.mock.calls; - exchangeClient.order.mockImplementation(async () => { - await wallet.signTypedData(L1_PAYLOAD); - throw rejection(ACCOUNT_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, - initialize, - } = createAccountSignerProvider({ - abstraction: 'unifiedAccount', - getAgentSigner, - onAgentRejected, - }); - getAgentSigner.mockResolvedValue(agentSigner); - await accountSignerProvider.getMarketDataWithPrices(); - const [[wallet]] = initialize.mock.calls; - 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 rejection(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, initialize } = - 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]] = initialize.mock.calls; - exchangeClient.cancel.mockImplementation(async () => { - await wallet.signTypedData(L1_PAYLOAD); - throw rejection(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, - initialize, - } = createAccountSignerProvider({ - abstraction: 'unifiedAccount', - getAgentSigner, - onAgentRejected, - }); - getAgentSigner.mockResolvedValue(agentSigner); - await accountSignerProvider.getMarketDataWithPrices(); - const [[wallet]] = initialize.mock.calls; - 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, - initialize, - selectAccount, - } = createAccountSignerProvider({ - abstraction: 'unifiedAccount', - getAgentSigner, - onAgentRejected, - }); - // The same agent is approved for both accounts. - getAgentSigner.mockResolvedValue(agentSigner); - await accountSignerProvider.getMarketDataWithPrices(); - const [[wallet]] = initialize.mock.calls; - selectAccount(OTHER_ACCOUNT_ADDRESS); - await wallet.signTypedData(L1_PAYLOAD); - selectAccount(ACCOUNT_ADDRESS); - exchangeClient.cancel.mockImplementation(async () => { - await wallet.signTypedData(L1_PAYLOAD); - throw rejection(agentSigner.address); - }); - - await accountSignerProvider.cancelOrder({ - orderId: '123', - symbol: 'BTC', - }); - selectAccount(OTHER_ACCOUNT_ADDRESS); - await wallet.signTypedData(L1_PAYLOAD); - selectAccount(ACCOUNT_ADDRESS); - await wallet.signTypedData(L1_PAYLOAD); - - 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_ACCOUNT_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, - initialize, - } = createRejectingProvider('order'); - await accountSignerProvider.getMarketDataWithPrices(); - - const order = await accountSignerProvider.placeOrder(BTC_MARKET_ORDER); - await initialize.mock.calls[0][0].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(); - }); - - 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, initialize } = - createAccountSignerProvider({ - abstraction: 'unifiedAccount', - getAgentSigner, - onAgentRejected, - }); - exchangeClient.order.mockImplementation(async () => { - await initialize.mock.calls[0][0].signTypedData(L1_PAYLOAD); - throw rejection(ACCOUNT_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(); - }); - }); - - 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: ACCOUNT_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` locks - * the keyring (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.initialize.mock.calls[0][0].signTypedData(L1_PAYLOAD); - const signedCancel = async (): Promise => { - await signL1Action(); - // Only a rejected agent gets this far. - throw rejection(fixture.agentSigner.address); - }; - // The venue answers with a rejection in every status entry. - const rejectedEntry = { - error: rejection(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: 'a locked keyring', 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(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: 123 }] }], - ]); - expect(onAgentRejected.mock.calls).toStrictEqual(rejectedAgents); - expect(loggerError).not.toHaveBeenCalled(); - }, - ); - - it.each([ - [ - 'thrown', - (): Promise => Promise.reject(rejection(AGENT_ADDRESS)), - ], - [ - 'in its status entry', - async (): Promise> => ({ - status: 'ok', - response: { - type: 'twapCancel', - data: { status: { error: rejection(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(rejection(AGENT_ADDRESS)), - ], - [ - 'in its status entry', - async (): Promise> => - withStatuses({ error: rejection(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: 123 } }); - }); - 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: ['123'], - }); - expect(cancel.mock.calls).toStrictEqual([ - [{ cancels: [{ a: 1, o: 123 }] }], - ]); - 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: 123 }] }], - ]); - 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(); - }); - }); - }); - - 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('reports a failed answer asked again after a clear as unavailable', async () => { - const { getAgentSigner, answer, asked } = createPendingResolver(); - const { accountSignerProvider, agentSigner, initialize } = - createAccountSignerProvider({ - abstraction: 'unifiedAccount', - getAgentSigner, - }); - await accountSignerProvider.getMarketDataWithPrices(); - const [[wallet]] = initialize.mock.calls; - - 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.agent-rejection.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-rejection.test.ts new file mode 100644 index 00000000000..2943b8320d0 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-rejection.test.ts @@ -0,0 +1,1002 @@ +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, + OTHER_AGENT_ADDRESS, + OTHER_AGENT_SIGNATURE, + OTHER_MAIN_ADDRESS, + unknownWalletError, +} from '../../helpers/agentFixtures.js'; +import { + ACCOUNT_ADDRESS, + BTC_MARKET_ORDER, + MAINNET_ACCOUNT, + REFERRAL_WRITE, + SILENT_MIGRATION_WRITE, + 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 a real wallet service and accountSigner', () => { + 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, + }); + + /** + * 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('keeps the entries of a batch cancel that succeeded when another reports a rejected agent', async () => { + const { + accountSignerProvider, + exchangeClient, + 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: [ + 'success', + { error: unknownWalletError(AGENT_ADDRESS).message }, + ], + }, + }, + }; + }); + + 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: 456 }] }], + ]); + 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: 456 }] }], + ]); + expect(exchangeClient.order).not.toHaveBeenCalled(); + 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 in a status entry', async () => { + 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 { + status: 'ok', + response: { + data: { + statuses: [ + { error: unknownWalletError(AGENT_ADDRESS).message }, + ], + }, + }, + }; + }); + + 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: 456 }] }], + ]); + expect(exchangeClient.order).not.toHaveBeenCalled(); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [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(ACCOUNT_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(ACCOUNT_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(ACCOUNT_ADDRESS); + exchangeClient.cancel.mockImplementation(async () => { + await wallet.signTypedData(L1_PAYLOAD); + throw unknownWalletError(agentSigner.address); + }); + + await accountSignerProvider.cancelOrder({ + orderId: '123', + symbol: 'BTC', + }); + selectAccount(OTHER_MAIN_ADDRESS); + await wallet.signTypedData(L1_PAYLOAD); + selectAccount(ACCOUNT_ADDRESS); + await wallet.signTypedData(L1_PAYLOAD); + + 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(); + }); + + 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(ACCOUNT_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(); + }); + }); + }); +}); 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..4b0cda97100 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-signer.test.ts @@ -0,0 +1,644 @@ +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, + OTHER_MAIN_ADDRESS, + USER_SIGNED_PAYLOAD, +} from '../../helpers/agentFixtures.js'; +import { + ACCOUNT_ADDRESS, + BTC_MARKET_ORDER, + BUILDER_FEE_WRITE, + MAINNET_ACCOUNT, + 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 a real wallet service and accountSigner', () => { + 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(ACCOUNT_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([ + [ACCOUNT_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 } = createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + }); + + await accountSignerProvider.getMarketDataWithPrices(); + + 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([ + [ACCOUNT_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([ + [ACCOUNT_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([ + [ACCOUNT_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([ + [ACCOUNT_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([ + [ACCOUNT_ADDRESS, L1_PAYLOAD], + [ACCOUNT_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([ + [ACCOUNT_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); + const { accountSignerProvider } = createAccountSignerProvider({ + abstraction: 'default', + getAgentSigner, + }); + mockClientService.isTestnetMode.mockReturnValue(true); + + await accountSignerProvider.getMarketDataWithPrices(); + + expect(getAgentSigner.mock.calls).toStrictEqual([ + [{ mainAddress: ACCOUNT_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: ACCOUNT_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([ + [ACCOUNT_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([ + [ACCOUNT_ADDRESS, L1_PAYLOAD], + ]); + expect(getAgentSigner.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + [MAINNET_ACCOUNT], + ]); + }); + + it('asks getAgentSigner again 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([[ACCOUNT_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 getAgentSigner rejects', async () => { + const getAgentSigner = jest + .fn() + .mockRejectedValue(new Error('agent store unavailable')); + const { accountSignerProvider, exchangeClient } = + createAccountSignerProvider({ + abstraction: 'unifiedAccount', + getAgentSigner, + }); + + const result = await accountSignerProvider.prepareTradingWallet(); + + expect(result).toStrictEqual({ ready: false }); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + REFERRAL_WRITE, + ]); + expect(referralAttempted()).toBe(false); + expect(loggerError).not.toHaveBeenCalled(); + }); + + 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([ + [ACCOUNT_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 secondResult = await accountSignerProvider.prepareTradingWallet(); + + expect(firstResult).toStrictEqual({ ready: false }); + expect(secondResult).toStrictEqual({ ready: true }); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + REFERRAL_WRITE, + REFERRAL_WRITE, + ]); + }); + + 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('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 d09e0c5bbab..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 @@ -1661,13 +1661,13 @@ describe('HyperLiquidProvider', () => { createMockExchangeClient({ approveBuilderFee: jest .fn() - .mockRejectedValue(new Error('KEYRING_LOCKED')), + .mockRejectedValue(new Error(PERPS_ERROR_CODES.KEYRING_LOCKED)), }), ); // Act - rethrows, so the caller reports a retryable failure await expect(testableProvider.ensureBuilderFeeApproval()).rejects.toThrow( - 'KEYRING_LOCKED', + PERPS_ERROR_CODES.KEYRING_LOCKED, ); // Assert - cache should NOT be set (so it retries when unlocked) 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..712ee2cb0bc --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.prepare-trading-wallet.test.ts @@ -0,0 +1,809 @@ +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_SIGNATURE, + USER_SIGNED_PAYLOAD, + unknownWalletError, +} from '../../helpers/agentFixtures.js'; +import { + ACCOUNT_ADDRESS, + BTC_MARKET_ORDER, + BUILDER_FEE_WRITE, + BUILDER_REFERRAL_LOOKUP, + MIGRATION_WRITE, + NOW, + REFERRAL_WRITE, + createAccountSignerProvider, + migrationAttempted, + referralAttempted, + setUpAccountSignerSuite, +} from '../../helpers/hyperLiquidAccountSignerFixture.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'); + +describe('HyperLiquidProvider with a real wallet service and accountSigner', () => { + 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([ + [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], + [ACCOUNT_ADDRESS, L1_PAYLOAD], + ]); + // Already approved, so nothing is signed for it. + expect(infoClient.maxBuilderFee.mock.calls).toStrictEqual([ + [{ user: ACCOUNT_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([ + [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], + [ACCOUNT_ADDRESS, L1_PAYLOAD], + [ACCOUNT_ADDRESS, APPROVE_BUILDER_FEE_PAYLOAD], + ]); + expect(order).toStrictEqual({ + success: true, + orderId: '123', + submittedSize: '0.1', + averagePrice: undefined, + filledSize: undefined, + }); + expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ + [ACCOUNT_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', + ACCOUNT_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', ACCOUNT_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', ACCOUNT_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', ACCOUNT_ADDRESS), + ).toStrictEqual({ + attempted: true, + enabled: true, + reason: undefined, + timestamp: NOW, + }); + // Migration, then referral; nothing on the second call. + expect(firstSignatures).toStrictEqual([ + [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], + [ACCOUNT_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', ACCOUNT_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('does not log a provider replaced during preparation', async () => { + const { accountSignerProvider, initialize } = + createAccountSignerProvider(); + initialize.mockRejectedValue( + new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE), + ); + + const result = await 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: ACCOUNT_ADDRESS, data: USER_SIGNED_PAYLOAD }, + 'V4', + ], + ]); + expect(typedDataSignatures()).toStrictEqual([ + [ + 'KeyringController:signTypedMessage', + { from: ACCOUNT_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([ + [ACCOUNT_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('sets the referral once a wallet prepared before its first deposit has deposited', 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 }); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + REFERRAL_WRITE, + ]); + 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('attempts the referral again when the venue rejects the wallet as unknown despite the probe', async () => { + const { accountSignerProvider, exchangeClient } = + createAccountSignerProvider({ abstraction: 'unifiedAccount' }); + // The probe sees a deposit, but the venue has not caught up yet. + exchangeClient.setReferrer.mockRejectedValueOnce( + unknownWalletError(ACCOUNT_ADDRESS), + ); + + const rejected = await accountSignerProvider.prepareTradingWallet(); + const referralAfterRejection = referralAttempted(); + const retried = await accountSignerProvider.prepareTradingWallet(); + + expect(rejected).toStrictEqual({ ready: false }); + expect(referralAfterRejection).toBe(false); + expect(retried).toStrictEqual({ ready: true }); + expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ + REFERRAL_WRITE, + REFERRAL_WRITE, + ]); + 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('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.strategy-signer.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-signer.test.ts new file mode 100644 index 00000000000..8ad4fc70653 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-signer.test.ts @@ -0,0 +1,601 @@ +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, + unknownWalletError, +} from '../../helpers/agentFixtures.js'; +import { + ACCOUNT_ADDRESS, + MAINNET_ACCOUNT, + NOW, + 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 a real wallet service and accountSigner', () => { + 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: ACCOUNT_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` locks + * the keyring (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: 'a locked keyring', 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(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: 123 }] }], + ]); + 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: 123 } }); + }); + 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: ['123'], + }); + expect(cancel.mock.calls).toStrictEqual([ + [{ cancels: [{ a: 1, o: 123 }] }], + ]); + 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: 123 }] }], + ]); + 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/LighterProvider.account-signer.test.ts b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts index e2ae6a43ddc..c18330dd328 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts @@ -12,7 +12,10 @@ import type { LighterSignerResult, LighterWasmCall, } from '../../../src/types/lighter-types.js'; -import { MAIN_SIGNATURE } from '../../helpers/agentFixtures.js'; +import { + MAIN_SIGNATURE, + OTHER_MAIN_ADDRESS, +} from '../../helpers/agentFixtures.js'; import { createKeyringMessenger, createKeyringlessMessenger, @@ -457,7 +460,13 @@ describe('LighterProvider with accountSigner', () => { [ 'the wallet switches accounts', async ({ selectAccount }: BuiltProvider): Promise => { - selectAccount('0x00000000000000000000000000000000000c0ffe'); + selectAccount(OTHER_MAIN_ADDRESS); + }, + ], + [ + 'the wallet deselects its account', + async ({ deselectAccount }: BuiltProvider): Promise => { + deselectAccount(); }, ], ])( @@ -559,27 +568,6 @@ describe('LighterProvider with accountSigner', () => { expect(loggerError).not.toHaveBeenCalled(); }); - it('reports KEYRING_LOCKED without logging when the signer locks as registration fails', async () => { - let signerReady = true; - const { provider, accountSigner, client, deps } = buildProvider({ - isReady: () => signerReady, - }); - accountSigner.signPersonalMessage.mockImplementation(async () => { - signerReady = false; - throw new Error('wallet disconnected'); - }); - 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('registers on the next preparation after the account signer locked during registration', async () => { let signerReady = true; const { provider, address, accountSigner, client, deps } = buildProvider({ 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 index e1254b9e957..3487115e544 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts @@ -1,8 +1,9 @@ +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 { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; +import { privateKeyToAccount } from 'viem/accounts'; import { ARBITRUM_SEPOLIA_CHAIN_ID, @@ -28,9 +29,9 @@ import { } from '../../helpers/agentFixtures.js'; import { createKeyringlessMessenger, + createKeyringMessenger, createMockEvmAccount, createMockInfrastructure, - createMockMessenger, keyringCalls, } from '../../helpers/serviceMocks.js'; @@ -135,7 +136,7 @@ describe('HyperLiquidWalletService with accountSigner', () => { it("requires signature confirmation when the account signer's requiresSignatureConfirmation says so, whatever the keyring type", () => { const { service } = buildService( { requiresSignatureConfirmation: () => true }, - 'HD Key Tree', + KeyringTypes.hd, ); expect(service.requiresSignatureConfirmation()).toBe(true); @@ -144,15 +145,15 @@ describe('HyperLiquidWalletService with accountSigner', () => { it("does not require signature confirmation when the account signer's requiresSignatureConfirmation says so, whatever the keyring type", () => { const { service } = buildService( { requiresSignatureConfirmation: () => false }, - 'Ledger Hardware', + KeyringTypes.ledger, ); expect(service.requiresSignatureConfirmation()).toBe(false); }); it.each([ - ['Ledger Hardware', true], - ['HD Key Tree', false], + [KeyringTypes.ledger, true], + [KeyringTypes.hd, false], ])( 'falls back to the %s keyring type when requiresSignatureConfirmation is omitted', (keyringType, expected) => { @@ -236,7 +237,7 @@ describe('HyperLiquidWalletService wallet adapter with an agent', () => { const { adapter, resolveAgent, agentSign, mainSign } = buildAdapter(); const lookalike = { ...L1_PAYLOAD, - domain: { ...L1_PAYLOAD.domain, name: 'HyperliquidSignTransaction' }, + domain: { ...L1_PAYLOAD.domain, name: USER_SIGNED_PAYLOAD.domain.name }, }; const signature = await adapter.signTypedData(lookalike); @@ -344,8 +345,7 @@ describe('HyperLiquidWalletService wallet adapter with an agent and a keyring', agentSign: jest.Mock; call: jest.SpyInstance; } { - const messenger = createMockMessenger(); - const call = jest.spyOn(messenger, 'call'); + const { messenger, call } = createKeyringMessenger(MAIN_SIGNATURE); const agentSign = jest.fn().mockResolvedValue(AGENT_SIGNATURE); const service = new HyperLiquidWalletService( createMockInfrastructure(), @@ -375,13 +375,19 @@ describe('HyperLiquidWalletService wallet adapter with an agent and a keyring', const signature = await adapter.signTypedData(USER_SIGNED_PAYLOAD); - expect(signature).toBe('0xSignatureResult'); + expect(signature).toBe(MAIN_SIGNATURE); expect(agentSign).not.toHaveBeenCalled(); - expect(call).toHaveBeenCalledWith( - 'KeyringController:signTypedMessage', - { from: mainAddress, data: USER_SIGNED_PAYLOAD }, - 'V4', - ); + expect( + call.mock.calls.filter( + ([action]) => action === 'KeyringController:signTypedMessage', + ), + ).toStrictEqual([ + [ + 'KeyringController:signTypedMessage', + { from: mainAddress, data: USER_SIGNED_PAYLOAD }, + 'V4', + ], + ]); }); }); @@ -399,8 +405,9 @@ describeWithSdk( // 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. - const mainAccount = privateKeyToAccount(generatePrivateKey()); - const agentAccount = privateKeyToAccount(generatePrivateKey()); + // 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; @@ -478,10 +485,6 @@ describeWithSdk( }); } - afterEach(() => { - jest.restoreAllMocks(); - }); - it('signs an SDK L1 action with the agent', async () => { const { adapter, signatures, agentSignatures } = buildSdkAdapter(); diff --git a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts index 46a19bc73fc..1658a499d4f 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts @@ -345,7 +345,7 @@ describe('HyperLiquidWalletService', () => { expect(address).toBe(mockEvmAccount.address); }); - it('returns false for software wallet', () => { + it('requires no signature confirmation for an HD keyring account', () => { expect(service.requiresSignatureConfirmation()).toBe(false); }); @@ -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' 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 index 438e816dee4..dc6286e2e7b 100644 --- a/packages/perps-controller/tests/src/services/LighterWalletService.account-signer.test.ts +++ b/packages/perps-controller/tests/src/services/LighterWalletService.account-signer.test.ts @@ -61,6 +61,19 @@ describe('LighterWalletService with accountSigner', () => { expect(error).toHaveProperty('cause', 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(); diff --git a/packages/perps-controller/tests/src/services/agentSigner.test.ts b/packages/perps-controller/tests/src/services/agentSigner.test.ts index 0b4cfe1cf7a..0689d669e86 100644 --- a/packages/perps-controller/tests/src/services/agentSigner.test.ts +++ b/packages/perps-controller/tests/src/services/agentSigner.test.ts @@ -10,11 +10,16 @@ import { OTHER_MAIN_ADDRESS, sdkSigningError, } from '../../helpers/agentFixtures.js'; +import { createMockEvmAccount } from '../../helpers/serviceMocks.js'; const ACCOUNT: PerpsAgentAccount = { - mainAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + mainAddress: createMockEvmAccount().address, isTestnet: false, }; +// The same main account, spelled in upper case. +const UPPER_CASE_MAIN_ADDRESS = `0x${ACCOUNT.mainAddress + .slice(2) + .toUpperCase()}` as const; const AGENT = { address: AGENT_ADDRESS, signTypedData: jest.fn(), @@ -32,7 +37,7 @@ describe('AgentBindings', () => { const bindings = new AgentBindings(getAgentSigner); const otherAccount: PerpsAgentAccount = { ...ACCOUNT, - mainAddress: '0x9999999999999999999999999999999999999999', + mainAddress: OTHER_MAIN_ADDRESS, }; const testnetAccount: PerpsAgentAccount = { ...ACCOUNT, isTestnet: true }; @@ -56,7 +61,7 @@ describe('AgentBindings', () => { expect( await bindings.resolve({ ...ACCOUNT, - mainAddress: '0xABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCD', + mainAddress: UPPER_CASE_MAIN_ADDRESS, }), ).toBe(AGENT); expect(getAgentSigner).not.toHaveBeenCalled(); @@ -89,7 +94,7 @@ describe('AgentBindings', () => { bindings.set(ACCOUNT, AGENT); bindings.release( - { ...ACCOUNT, mainAddress: '0xABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCD' }, + { ...ACCOUNT, mainAddress: UPPER_CASE_MAIN_ADDRESS }, AGENT.address.toUpperCase().replace('0X', '0x'), ); From 4461ea215a14ea00a6aa370db65f8c35793d2406 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 29 Sep 2026 15:10:44 +0800 Subject: [PATCH 26/33] fix(perps-controller): keep cancelled TP/SL legs next to a rejected agent, and report stale or unselected preparation - HyperLiquid cancel batches classify every status entry even when one reports a rejected agent, so orders the venue did cancel are no longer counted as resting. A TP/SL update that cancelled one leg restores it and fails with KEYRING_LOCKED; a scale ladder keeps only the rungs left. - HyperLiquid prepareTradingWallet reports PROVIDER_LIFECYCLE_STALE when the provider disconnects during its account check or builder fee setup. - PerpsController.prepareTradingWallet reports NO_ACCOUNT_SELECTED for a provider's ready result while no account is selected, and a read-only Lighter provider checks the selected account first. - Keep each resolved agent next to its getAgentSigner answer in one map, and name the referral code lookup's logs after its method. - Tests: TP/SL and scale mixed-status cancels, mid-setup disconnects, a host without onAgentRejected or whose onAgentRejected throws, distinct suite titles and shared main-account fixtures. --- packages/perps-controller/CHANGELOG.md | 4 +- .../PerpsController-method-action-types.ts | 6 +- .../perps-controller/src/PerpsController.ts | 17 ++- .../src/providers/HyperLiquidProvider.ts | 112 +++++++++++------- .../src/providers/LighterProvider.ts | 11 +- packages/perps-controller/src/types/index.ts | 15 +-- .../tests/helpers/agentFixtures.ts | 14 ++- .../hyperLiquidAccountSignerFixture.ts | 33 ++---- ...ntroller.agent-signing.integration.test.ts | 42 +++++-- .../PerpsController.providers-cache.test.ts | 72 ++++++++--- ...HyperLiquidProvider.account-signer.test.ts | 31 +++-- ...yperLiquidProvider.agent-rejection.test.ts | 109 +++++++++++++++-- .../HyperLiquidProvider.agent-signer.test.ts | 109 ++++++++++------- ...uidProvider.prepare-trading-wallet.test.ts | 86 +++++++++----- ...yperLiquidProvider.strategy-signer.test.ts | 68 ++++++++++- .../LighterProvider.account-signer.test.ts | 16 +++ ...LiquidWalletService.account-signer.test.ts | 7 +- ...ighterWalletService.account-signer.test.ts | 7 +- 18 files changed, 548 insertions(+), 211 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 0b66642da06..db64dc23b3f 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -31,9 +31,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 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 + - 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) and the main-account signer is ready + - 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 diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index c066452cb28..e3268083afe 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -948,9 +948,9 @@ export type PerpsControllerClearAgentSignersAction = { * 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 the main account can sign, - * whichever provider answered (including providers without deferred setup, - * for example in aggregated mode). A declined HyperLiquid migration is not + * 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 diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 6b3ed618ab5..eaada5922b0 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -5935,9 +5935,9 @@ export class PerpsController extends BaseController< * 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 the main account can sign, - * whichever provider answered (including providers without deferred setup, - * for example in aggregated mode). A declined HyperLiquid migration is not + * 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 @@ -5960,10 +5960,12 @@ export class PerpsController extends BaseController< const result = (await provider.prepareTradingWallet?.()) ?? { ready: true, }; - // A provider with nothing to prepare, alone or aggregated, does not check - // the signer. + // A provider with nothing to prepare, alone or aggregated, checks neither + // the signer nor the selected account. + if (!result.ready) { + return result; + } if ( - result.ready && !isMainAccountSignerReady( this.#options.infrastructure.accountSigner, () => this.messenger.call('KeyringController:getState').isUnlocked, @@ -5971,6 +5973,9 @@ export class PerpsController extends BaseController< ) { return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; } + if (!getSelectedEvmAccountFromMessenger(this.messenger)) { + return { ready: false, error: PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED }; + } return result; } diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 68deab30a5f..235ffd89e70 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -558,7 +558,8 @@ type CancelOrderBatchOutcome = { remainingOrderIds: number[]; cancelledOrderIds: number[]; responseComplete: boolean; - // KEYRING_LOCKED when the signer could not sign, so nothing was cancelled. + // 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; }; @@ -1554,16 +1555,18 @@ export class HyperLiquidProvider implements PerpsProvider { readonly #getAgentSigner: HyperLiquidCredentials['getAgentSigner']; - // Pending or non-null getAgentSigner answers per network and main account - // (see getAgentAccountKey), so an agent is only used for its account and network. // Incremented by clearAgentSigners so answers pending across a clear are // discarded and asked again. #agentSignersGeneration = 0; - readonly #agentSigners = new Map>(); - - // The agents those answers resolved to, dropped when the venue rejects one. - readonly #resolvedAgents = new Map(); + // 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 address was last resolved to sign an // L1 action for. One entry per address is enough: an L1 signature covers @@ -2098,11 +2101,13 @@ export class HyperLiquidProvider implements PerpsProvider { if (!this.#getAgentSigner) { return null; } + let answer: Promise; try { - entry = this.#getAgentSigner(account); + answer = this.#getAgentSigner(account); } catch (error) { - entry = Promise.reject(error); + answer = Promise.reject(error); } + entry = { answer }; this.#agentSigners.set(key, entry); } @@ -2115,7 +2120,7 @@ export class HyperLiquidProvider implements PerpsProvider { let agentSigner: PerpsAgentSigner | null; try { - agentSigner = await pendingEntry; + agentSigner = await pendingEntry.answer; } catch (error) { if (isSuperseded()) { return await this.#resolveAgentSigner(mainAddress); @@ -2132,7 +2137,7 @@ export class HyperLiquidProvider implements PerpsProvider { return await this.#resolveAgentSigner(mainAddress); } if (agentSigner) { - this.#resolvedAgents.set(key, 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(), { @@ -2142,7 +2147,6 @@ export class HyperLiquidProvider implements PerpsProvider { }); } else { this.#agentSigners.delete(key); - this.#resolvedAgents.delete(key); } return agentSigner; } @@ -2217,9 +2221,8 @@ export class HyperLiquidProvider implements PerpsProvider { return false; } const { account, key, agentAddress } = rejected; - if (this.#resolvedAgents.get(key)?.address === agentAddress) { + if (this.#agentSigners.get(key)?.agent?.address === agentAddress) { this.#agentSigners.delete(key); - this.#resolvedAgents.delete(key); } this.#deps.debugLogger.log( 'HyperLiquidProvider: agent rejected by the venue, asking again', @@ -8722,7 +8725,7 @@ 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 resting, and the signer - * failure when nothing could be cancelled for that reason. + * failure when the signer could not sign the request. */ async #cancelOrderCloidRequestBatch( exchangeClient: ExchangeClient, @@ -8735,19 +8738,16 @@ export class HyperLiquidProvider implements PerpsProvider { 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); - if (signerFailure) { - return { - remainingClientOrderIds: requests.map((request) => request.cloid), - signerFailure, - }; - } return { remainingClientOrderIds: requests.flatMap((request, index) => classifyCancelStatus(statuses[index]) === CancelChildOutcome.Refused ? [request.cloid] : [], ), + ...(signerFailure && { signerFailure }), }; }; @@ -8810,15 +8810,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); - if (signerFailure) { - return { - remainingOrderIds: requests.map((request) => request.o), - cancelledOrderIds: [], - responseComplete: false, - signerFailure, - }; - } const remainingOrderIds: number[] = []; const cancelledOrderIds: number[] = []; requests.forEach((request, index) => { @@ -8829,7 +8823,12 @@ export class HyperLiquidProvider implements PerpsProvider { cancelledOrderIds.push(request.o); } }); - return { remainingOrderIds, cancelledOrderIds, responseComplete: true }; + return { + remainingOrderIds, + cancelledOrderIds, + responseComplete: true, + ...(signerFailure && { signerFailure }), + }; }; try { @@ -10692,8 +10691,14 @@ export class HyperLiquidProvider implements PerpsProvider { exchangeClient, cancelRequests, ); - if (oldCancellation.signerFailure) { - // Nothing was cancelled, so the old protection is still in place. + // 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, @@ -10753,6 +10758,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, @@ -10764,12 +10782,7 @@ export class HyperLiquidProvider implements PerpsProvider { ); return createErrorResult(updateError, { success: false, - childOrderIds: [ - ...new Set([ - ...oldCancellation.remainingOrderIds.map(String), - ...restoration.restoredOrderIds, - ]), - ], + childOrderIds: survivingOrderIds, }); } @@ -14571,7 +14584,6 @@ export class HyperLiquidProvider implements PerpsProvider { clearAgentSigners(): void { this.#agentSignersGeneration += 1; this.#agentSigners.clear(); - this.#resolvedAgents.clear(); } /** @@ -14600,6 +14612,7 @@ export class HyperLiquidProvider implements PerpsProvider { return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; } try { + const lifecycleGeneration = this.#lifecycleGeneration; // Run the shared setup again to check the builder's referral code. if (this.#referralAwaitsBuilderCode) { this.#tradingSetupComplete = false; @@ -14609,9 +14622,17 @@ export class HyperLiquidProvider implements PerpsProvider { ? 'testnet' : 'mainnet'; const userAddress = await this.#walletService.getUserAddressWithDefault(); + const isRegistered = await this.#isWalletOnHyperliquid( + userAddress, + network, + ); + this.#assertProviderLifecycleCurrent( + lifecycleGeneration, + 'Trading wallet preparation', + ); // 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 (!(await this.#isWalletOnHyperliquid(userAddress, network))) { + if (!isRegistered) { return { ready: false, error: this.#walletService.isMainAccountSignerReady() @@ -14622,6 +14643,11 @@ export class HyperLiquidProvider implements PerpsProvider { await this.#ensureBuilderFeeSetup(undefined, { reportSignerFailure: true, }); + // The builder fee setup ends quietly when the provider disconnects. + this.#assertProviderLifecycleCurrent( + lifecycleGeneration, + 'Trading wallet preparation', + ); if (!this.#walletService.isMainAccountSignerReady()) { return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; } @@ -15956,7 +15982,7 @@ export class HyperLiquidProvider implements PerpsProvider { // 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, @@ -15966,8 +15992,8 @@ export class HyperLiquidProvider implements PerpsProvider { 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(), diff --git a/packages/perps-controller/src/providers/LighterProvider.ts b/packages/perps-controller/src/providers/LighterProvider.ts index 158af90332d..d6cb3b5cf4e 100644 --- a/packages/perps-controller/src/providers/LighterProvider.ts +++ b/packages/perps-controller/src/providers/LighterProvider.ts @@ -1316,8 +1316,9 @@ export class LighterProvider implements PerpsProvider { * * @returns `ready: true` once the venue key is registered, or at once for a * read-only provider (no signer bridge) while the main-account signer is - * ready: it has nothing to prepare, so it does not hold back an aggregated - * result, and `isReadyToTrade` still reports that it cannot trade. Otherwise + * ready and an account is selected: it has nothing to prepare, so it does + * not hold back an aggregated result, and `isReadyToTrade` still reports + * that it cannot trade. Otherwise * `ready: false`: with `KEYRING_LOCKED` whenever the main-account signer is * not ready (even with a registered venue key), with `NO_ACCOUNT_SELECTED` * when no account is selected, with `EXCHANGE_ACCOUNT_NOT_FOUND` when the @@ -1331,14 +1332,14 @@ export class LighterProvider implements PerpsProvider { if (!this.#walletService.isMainAccountSignerReady()) { return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; } - if (!this.#signerBridge) { - return { ready: true }; - } 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. diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 1a4a76e4adb..85e94acc6ea 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -2167,13 +2167,14 @@ export type PerpsProvider = { * silent migration) are signed by an agent when one resolves. Resolves * `ready: true` when none of these steps will need a signature again before * the first order (a read-only provider, which never signs, resolves it at - * once while the main-account signer is ready). Otherwise `ready: false`, - * without an error while a step will be retried (including after an agent - * could not sign), or with `KEYRING_LOCKED` when the main-account signer - * cannot sign, `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. Providers without such setup omit it. + * once while an account is selected and the main-account signer is ready). + * Otherwise `ready: false`, without an error while a step will be retried + * (including after an agent could not sign), or with `KEYRING_LOCKED` when + * the main-account signer cannot sign, `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. Providers + * without such setup omit it. */ prepareTradingWallet?(): Promise; /** diff --git a/packages/perps-controller/tests/helpers/agentFixtures.ts b/packages/perps-controller/tests/helpers/agentFixtures.ts index ff5a0dbff99..9111e873cdf 100644 --- a/packages/perps-controller/tests/helpers/agentFixtures.ts +++ b/packages/perps-controller/tests/helpers/agentFixtures.ts @@ -1,6 +1,9 @@ import type { Hex } from '@metamask/utils'; -import type { PerpsTypedDataPayload } from '../../src/types/index.js'; +import type { + PerpsAgentAccount, + PerpsTypedDataPayload, +} from '../../src/types/index.js'; import { createMockEvmAccount } from './serviceMocks.js'; const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as const; @@ -18,6 +21,15 @@ const EIP712_DOMAIN_TYPE = [ { 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, +}; + /** A second main account, for account-switch and scoping cases. */ export const OTHER_MAIN_ADDRESS = '0x00000000000000000000000000000000000b0b01' as const; diff --git a/packages/perps-controller/tests/helpers/hyperLiquidAccountSignerFixture.ts b/packages/perps-controller/tests/helpers/hyperLiquidAccountSignerFixture.ts index f69190afc72..83aa706f6f4 100644 --- a/packages/perps-controller/tests/helpers/hyperLiquidAccountSignerFixture.ts +++ b/packages/perps-controller/tests/helpers/hyperLiquidAccountSignerFixture.ts @@ -3,6 +3,11 @@ * 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: * @@ -40,6 +45,7 @@ import { AGENT_SIGNATURE, APPROVE_BUILDER_FEE_PAYLOAD, L1_PAYLOAD, + MAIN_ADDRESS, MAIN_SIGNATURE, USER_SIGNED_PAYLOAD, signThroughWallet, @@ -52,7 +58,6 @@ import { createDeferred, createKeyringMessenger, createKeyringlessMessenger, - createMockEvmAccount, createMockInfrastructure, } from './serviceMocks.js'; @@ -65,19 +70,6 @@ const MockedHyperLiquidSubscriptionService = typeof HyperLiquidSubscriptionService >; -// The wallet service and the signing caches are real. 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. -export const ACCOUNT_ADDRESS = createMockEvmAccount().address; - -// The selected account on mainnet, as getAgentSigner is asked for it. -export const MAINNET_ACCOUNT = { - mainAddress: ACCOUNT_ADDRESS, - isTestnet: false, -} as const; - // A fixed clock for cache timestamps. export const NOW = 1_700_000_000_000; @@ -91,7 +83,7 @@ export const BTC_MARKET_ORDER = { // The SDK writes the provider makes for the selected account on mainnet. export const MIGRATION_WRITE = [ - { user: ACCOUNT_ADDRESS, abstraction: HL_UNIFIED_ACCOUNT_MODE }, + { user: MAIN_ADDRESS, abstraction: HL_UNIFIED_ACCOUNT_MODE }, ]; export const SILENT_MIGRATION_WRITE = [ { abstraction: HL_ABSTRACTION_WIRE.unifiedAccount }, @@ -128,9 +120,7 @@ export function orderIdOf(result: { orderId?: string }): string { * @returns True once the migration result is cached. */ export function migrationAttempted(): boolean { - return ( - TradingReadinessCache.get('mainnet', ACCOUNT_ADDRESS)?.attempted ?? false - ); + return TradingReadinessCache.get('mainnet', MAIN_ADDRESS)?.attempted ?? false; } /** @@ -141,8 +131,7 @@ export function migrationAttempted(): boolean { */ export function referralAttempted(): boolean { return ( - PerpsSigningCache.getReferral('mainnet', ACCOUNT_ADDRESS)?.attempted ?? - false + PerpsSigningCache.getReferral('mainnet', MAIN_ADDRESS)?.attempted ?? false ); } @@ -185,7 +174,7 @@ export function bind( provider.clearAgentSigners(); } -export type AccountSignerSuite = { +type AccountSignerSuite = { mockClientService: jest.Mocked; loggerError: jest.SpyInstance; trackPerpsEvent: jest.SpyInstance; @@ -258,7 +247,7 @@ export function setUpAccountSignerSuite(): AccountSignerSuite { return { mockClientService, loggerError, trackPerpsEvent }; } -export type AccountSignerOptions = { +type AccountSignerOptions = { signer?: { isReady?: () => boolean; requiresSignatureConfirmation?: () => boolean; diff --git a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts index 9db9c85e18a..d7189ad5e6d 100644 --- a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts @@ -25,7 +25,9 @@ import { AGENT_SIGNATURE, APPROVE_BUILDER_FEE_PAYLOAD, L1_PAYLOAD, + MAIN_ADDRESS, MAIN_SIGNATURE, + MAINNET_ACCOUNT, OTHER_AGENT_ADDRESS, OTHER_AGENT_SIGNATURE, signThroughWallet, @@ -36,17 +38,10 @@ import { createMockInfoClient } from '../helpers/providerMocks.js'; import { createKeyringlessMessenger, createKeyringMessenger, - createMockEvmAccount, createMockInfrastructure, keyringCalls, } from '../helpers/serviceMocks.js'; -const MAIN_ADDRESS = createMockEvmAccount().address; -// The controller starts on mainnet (default state). -const MAINNET_ACCOUNT: PerpsAgentAccount = { - mainAddress: MAIN_ADDRESS, - isTestnet: false, -}; // The venue recovers each signer from its signature. const SIGNERS = new Map([ [MAIN_SIGNATURE, MAIN_ADDRESS], @@ -663,6 +658,39 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => }); }); + it('releases a binding to an agent the venue rejects even when the host onAgentRejected throws', async () => { + onAgentRejected.mockImplementation(() => { + throw new Error('host callback failed'); + }); + 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]], + }); + }); + it('releases a binding to an agent the venue rejects for a host without onAgentRejected', async () => { const boundAgent = createAgent(OTHER_AGENT_ADDRESS, OTHER_AGENT_SIGNATURE); mockVenue.revokedAgents.add(OTHER_AGENT_ADDRESS); 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 e5b79fa69a7..de87461fe04 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -956,6 +956,33 @@ 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; + selectedAccount?: ReturnType | null; + }): jest.Mock { + return jest.fn().mockImplementation((action: string) => { + if (action === 'KeyringController:getState') { + return { isUnlocked }; + } + if (action === 'AccountsController:getSelectedAccount') { + return selectedAccount ?? undefined; + } + return undefined; + }); + } + const account = { mainAddress: createMockEvmAccount().address, isTestnet: false, @@ -1200,13 +1227,7 @@ describe('PerpsController', () => { '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 = jest - .fn() - .mockImplementation((action: string) => - action === 'KeyringController:getState' - ? { isUnlocked: canSign } - : undefined, - ); + const call = createHostCall({ isUnlocked: canSign }); controller = new TestablePerpsController({ messenger: createMockMessenger({ call }), state: getDefaultPerpsControllerState(), @@ -1240,13 +1261,9 @@ describe('PerpsController', () => { ])( 'reports KEYRING_LOCKED when the provider reports ready while $signer cannot sign', async ({ usesKeyring }) => { - const call = jest - .fn() - .mockImplementation((action: string) => - action === 'KeyringController:getState' - ? { isUnlocked: false } - : undefined, - ); + // 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 @@ -1267,6 +1284,7 @@ describe('PerpsController', () => { }, }); await controller.init(); + call.mockClear(); const result = await controller.prepareTradingWallet(); @@ -1277,8 +1295,34 @@ describe('PerpsController', () => { expect(mockProvider.prepareTradingWallet.mock.calls).toStrictEqual([ [], ]); + // Only a host without an account signer is asked for its keyring. + expect( + call.mock.calls.filter(([action]) => action.startsWith('Keyring')), + ).toStrictEqual(usesKeyring ? [['KeyringController:getState']] : []); }, ); + + it('reports NO_ACCOUNT_SELECTED when the provider reports ready while no account is selected', async () => { + // 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: null }), + }), + state: getDefaultPerpsControllerState(), + infrastructure: mockInfrastructure, + }); + await controller.init(); + + const result = await controller.prepareTradingWallet(); + + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED, + }); + }); }); describe('getOpenOrders with standalone mode', () => { 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 index 28426b59300..4442ff9798b 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts @@ -8,11 +8,11 @@ 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 { - ACCOUNT_ADDRESS, BTC_MARKET_ORDER, BUILDER_FEE_WRITE, MIGRATION_WRITE, @@ -37,7 +37,7 @@ jest.mock('@nktkas/hyperliquid', () => ({ jest.mock('../../../src/services/HyperLiquidClientService'); jest.mock('../../../src/services/HyperLiquidSubscriptionService'); -describe('HyperLiquidProvider with a real wallet service and accountSigner', () => { +describe('HyperLiquidProvider with accountSigner: main-account signing', () => { let loggerError: jest.SpyInstance; let trackPerpsEvent: jest.SpyInstance; @@ -55,19 +55,28 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () MIGRATION_WRITE, ]); expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ - [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], + [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 } = - createAccountSignerProvider({ - signer: { requiresSignatureConfirmation: () => true }, - }); + 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([]); @@ -185,7 +194,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () const release = PerpsSigningCache.setInFlight( 'builderFee', 'mainnet', - ACCOUNT_ADDRESS, + MAIN_ADDRESS, ); const isInFlight = PerpsSigningCache.isInFlight.bind(PerpsSigningCache); jest @@ -258,7 +267,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () const result = await accountSignerProvider.withdraw({ amount: '10', - destination: ACCOUNT_ADDRESS, + destination: MAIN_ADDRESS, assetId, }); @@ -267,7 +276,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); expect(withdraw3.mock.calls).toStrictEqual([ - [{ destination: ACCOUNT_ADDRESS, amount: '10' }], + [{ destination: MAIN_ADDRESS, amount: '10' }], ]); expect(accountSigner.signTypedData).not.toHaveBeenCalled(); expect(loggerError).not.toHaveBeenCalled(); @@ -292,7 +301,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(exchangeClient.sendAsset.mock.calls).toStrictEqual([ [ { - destination: ACCOUNT_ADDRESS, + destination: MAIN_ADDRESS, sourceDex: '', destinationDex: 'xyz', token: 'USDC:0xdef456', 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 index 2943b8320d0..d6c8bdc9697 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-rejection.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-rejection.test.ts @@ -10,15 +10,15 @@ 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 { - ACCOUNT_ADDRESS, BTC_MARKET_ORDER, - MAINNET_ACCOUNT, REFERRAL_WRITE, SILENT_MIGRATION_WRITE, createAccountSignerProvider, @@ -47,7 +47,7 @@ jest.mock('../../../src/services/HyperLiquidSubscriptionService'); // mixed case. const CHECKSUMMED_AGENT_ADDRESS = getChecksumAddress(AGENT_ADDRESS); -describe('HyperLiquidProvider with a real wallet service and accountSigner', () => { +describe('HyperLiquidProvider with accountSigner: agent rejection', () => { let loggerError: jest.SpyInstance; let trackPerpsEvent: jest.SpyInstance; @@ -382,7 +382,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); expect(exchangeClient.cancel.mock.calls).toStrictEqual([ - [{ cancels: [{ a: 0, o: 456 }] }], + [{ cancels: [{ a: 0, o: TAKE_PROFIT_ORDER.oid }] }], ]); expect(exchangeClient.order).not.toHaveBeenCalled(); expect(onAgentRejected.mock.calls).toStrictEqual([ @@ -410,7 +410,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); expect(exchangeClient.cancel.mock.calls).toStrictEqual([ - [{ cancels: [{ a: 0, o: 456 }] }], + [{ cancels: [{ a: 0, o: TAKE_PROFIT_ORDER.oid }] }], ]); expect(exchangeClient.order).not.toHaveBeenCalled(); expect(onAgentRejected.mock.calls).toStrictEqual([ @@ -454,7 +454,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); expect(exchangeClient.cancel.mock.calls).toStrictEqual([ - [{ cancels: [{ a: 0, o: 456 }] }], + [{ cancels: [{ a: 0, o: TAKE_PROFIT_ORDER.oid }] }], ]); expect(exchangeClient.order).not.toHaveBeenCalled(); expect(onAgentRejected.mock.calls).toStrictEqual([ @@ -463,6 +463,93 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(loggerError).not.toHaveBeenCalled(); }); + it('restores the leg it cancelled and fails with KEYRING_LOCKED when the venue cancels one leg and rejects the agent on the other', async () => { + 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, + }); + const { + accountSignerProvider, + exchangeClient, + infoClient, + sdkWallet, + onAgentRejected, + } = createRejectingProvider('cancel'); + 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 { + status: 'ok', + response: { + data: { + statuses: [ + 'success', + { error: unknownWalletError(AGENT_ADDRESS).message }, + ], + }, + }, + }; + }); + + 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), '123'], + }); + 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', + }, + ]); + expect(onAgentRejected.mock.calls).toStrictEqual([ + [MAINNET_ACCOUNT, AGENT_ADDRESS], + ]); + expect(loggerError).not.toHaveBeenCalled(); + }); + it('still drops the agent and fails with KEYRING_LOCKED when onAgentRejected throws', async () => { const { accountSignerProvider, @@ -551,7 +638,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () selectAccount(OTHER_MAIN_ADDRESS); venue.resolve(); const result = await cancelling; - selectAccount(ACCOUNT_ADDRESS); + selectAccount(MAIN_ADDRESS); await wallet.signTypedData(L1_PAYLOAD); expect(result).toStrictEqual({ @@ -646,7 +733,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () const wallet = sdkWallet(); exchangeClient.order.mockImplementation(async () => { await wallet.signTypedData(L1_PAYLOAD); - throw unknownWalletError(ACCOUNT_ADDRESS); + throw unknownWalletError(MAIN_ADDRESS); }); const order = await accountSignerProvider.placeOrder(BTC_MARKET_ORDER); @@ -866,7 +953,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () const wallet = sdkWallet(); selectAccount(OTHER_MAIN_ADDRESS); await wallet.signTypedData(L1_PAYLOAD); - selectAccount(ACCOUNT_ADDRESS); + selectAccount(MAIN_ADDRESS); exchangeClient.cancel.mockImplementation(async () => { await wallet.signTypedData(L1_PAYLOAD); throw unknownWalletError(agentSigner.address); @@ -878,7 +965,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () }); selectAccount(OTHER_MAIN_ADDRESS); await wallet.signTypedData(L1_PAYLOAD); - selectAccount(ACCOUNT_ADDRESS); + selectAccount(MAIN_ADDRESS); await wallet.signTypedData(L1_PAYLOAD); expect(onAgentRejected.mock.calls).toStrictEqual([ @@ -985,7 +1072,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () }); exchangeClient.order.mockImplementation(async () => { await sdkWallet().signTypedData(L1_PAYLOAD); - throw unknownWalletError(ACCOUNT_ADDRESS); + throw unknownWalletError(MAIN_ADDRESS); }); await accountSignerProvider.getMarketDataWithPrices(); 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 index 4b0cda97100..9e356648b76 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-signer.test.ts @@ -13,14 +13,15 @@ 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 { - ACCOUNT_ADDRESS, BTC_MARKET_ORDER, BUILDER_FEE_WRITE, - MAINNET_ACCOUNT, REFERRAL_WRITE, SILENT_MIGRATION_WRITE, bind, @@ -43,7 +44,7 @@ jest.mock('@nktkas/hyperliquid', () => ({ jest.mock('../../../src/services/HyperLiquidClientService'); jest.mock('../../../src/services/HyperLiquidSubscriptionService'); -describe('HyperLiquidProvider with a real wallet service and accountSigner', () => { +describe('HyperLiquidProvider with accountSigner: agents', () => { let mockClientService: jest.Mocked; let loggerError: jest.SpyInstance; let trackPerpsEvent: jest.SpyInstance; @@ -66,7 +67,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () await accountSignerProvider.getMarketDataWithPrices(); expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); - expect(sdkWallet().address).toBe(ACCOUNT_ADDRESS); + expect(sdkWallet().address).toBe(MAIN_ADDRESS); expect(agentSigner.signTypedData.mock.calls).toStrictEqual([ [L1_PAYLOAD], ]); @@ -112,7 +113,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () [MAINNET_ACCOUNT], ]); expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ - [ACCOUNT_ADDRESS, L1_PAYLOAD], + [MAIN_ADDRESS, L1_PAYLOAD], ]); expect(agentSigner.signTypedData.mock.calls).toStrictEqual([ [L1_PAYLOAD], @@ -121,13 +122,19 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () it('does not ask for an agent when nothing is signed', async () => { const getAgentSigner = jest.fn(); - const { accountSignerProvider } = createAccountSignerProvider({ - abstraction: 'unifiedAccount', - getAgentSigner, - }); + 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(); }); @@ -140,7 +147,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () await accountSignerProvider.getMarketDataWithPrices(); expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ - [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], + [MAIN_ADDRESS, USER_SIGNED_PAYLOAD], ]); expect(agentSigner.signTypedData).not.toHaveBeenCalled(); expect(getAgentSigner).not.toHaveBeenCalled(); @@ -191,7 +198,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () BUILDER_FEE_WRITE, ]); expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ - [ACCOUNT_ADDRESS, APPROVE_BUILDER_FEE_PAYLOAD], + [MAIN_ADDRESS, APPROVE_BUILDER_FEE_PAYLOAD], ]); expect(result).toStrictEqual({ ready: false }); // Retryable like a locked keyring: no failure metric, nothing logged. @@ -263,7 +270,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () // 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([ - [ACCOUNT_ADDRESS, L1_PAYLOAD], + [MAIN_ADDRESS, L1_PAYLOAD], ]); expect(agentSigner.signTypedData.mock.calls).toStrictEqual([ [L1_PAYLOAD], @@ -287,7 +294,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(agentSigner.signTypedData).not.toHaveBeenCalled(); expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ - [ACCOUNT_ADDRESS, L1_PAYLOAD], + [MAIN_ADDRESS, L1_PAYLOAD], ]); }); @@ -309,8 +316,8 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(agentSigner.signTypedData).not.toHaveBeenCalled(); // Migration, then referral. expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ - [ACCOUNT_ADDRESS, L1_PAYLOAD], - [ACCOUNT_ADDRESS, L1_PAYLOAD], + [MAIN_ADDRESS, L1_PAYLOAD], + [MAIN_ADDRESS, L1_PAYLOAD], ]); }); @@ -331,7 +338,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(agentSigner.signTypedData).not.toHaveBeenCalled(); expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ - [ACCOUNT_ADDRESS, L1_PAYLOAD], + [MAIN_ADDRESS, L1_PAYLOAD], ]); }); @@ -376,7 +383,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () await accountSignerProvider.getMarketDataWithPrices(); expect(getAgentSigner.mock.calls).toStrictEqual([ - [{ mainAddress: ACCOUNT_ADDRESS, isTestnet: true }], + [{ mainAddress: MAIN_ADDRESS, isTestnet: true }], ]); }); @@ -400,14 +407,14 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(getAgentSigner.mock.calls).toStrictEqual([ [MAINNET_ACCOUNT], - [{ mainAddress: ACCOUNT_ADDRESS, isTestnet: true }], + [{ 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([ - [ACCOUNT_ADDRESS, L1_PAYLOAD], + [MAIN_ADDRESS, L1_PAYLOAD], ]); }); @@ -434,7 +441,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () [L1_PAYLOAD], ]); expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ - [ACCOUNT_ADDRESS, L1_PAYLOAD], + [MAIN_ADDRESS, L1_PAYLOAD], ]); expect(getAgentSigner.mock.calls).toStrictEqual([ [MAINNET_ACCOUNT], @@ -463,7 +470,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () accountSignerProvider.clearAgentSigners(); await wallet.signTypedData(L1_PAYLOAD); - expect(pinnedSignatures).toStrictEqual([[ACCOUNT_ADDRESS, L1_PAYLOAD]]); + expect(pinnedSignatures).toStrictEqual([[MAIN_ADDRESS, L1_PAYLOAD]]); expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); expect(agentSigner.signTypedData.mock.calls).toStrictEqual([ [L1_PAYLOAD], @@ -473,26 +480,6 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () ); }); - it('leaves the referral to retry, unrecorded, when getAgentSigner rejects', async () => { - const getAgentSigner = jest - .fn() - .mockRejectedValue(new Error('agent store unavailable')); - const { accountSignerProvider, exchangeClient } = - createAccountSignerProvider({ - abstraction: 'unifiedAccount', - getAgentSigner, - }); - - const result = await accountSignerProvider.prepareTradingWallet(); - - expect(result).toStrictEqual({ ready: false }); - expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ - REFERRAL_WRITE, - ]); - expect(referralAttempted()).toBe(false); - expect(loggerError).not.toHaveBeenCalled(); - }); - it('leaves the referral to retry, unrecorded, when the agent fails to sign', async () => { const getAgentSigner = jest.fn(); const { accountSignerProvider, agentSigner, exchangeClient, sdkWallet } = @@ -572,7 +559,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () ]); expect(agentSigner.signTypedData).not.toHaveBeenCalled(); expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ - [ACCOUNT_ADDRESS, L1_PAYLOAD], + [MAIN_ADDRESS, L1_PAYLOAD], ]); }); @@ -588,14 +575,19 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () }); 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 () => { @@ -616,6 +608,39 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () 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 } = 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 index 712ee2cb0bc..c2f556e77b0 100644 --- 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 @@ -12,12 +12,12 @@ import type { PerpsTypedDataPayload } from '../../../src/types/index.js'; import { APPROVE_BUILDER_FEE_PAYLOAD, L1_PAYLOAD, + MAIN_ADDRESS, MAIN_SIGNATURE, USER_SIGNED_PAYLOAD, unknownWalletError, } from '../../helpers/agentFixtures.js'; import { - ACCOUNT_ADDRESS, BTC_MARKET_ORDER, BUILDER_FEE_WRITE, BUILDER_REFERRAL_LOOKUP, @@ -44,7 +44,7 @@ jest.mock('@nktkas/hyperliquid', () => ({ jest.mock('../../../src/services/HyperLiquidClientService'); jest.mock('../../../src/services/HyperLiquidSubscriptionService'); -describe('HyperLiquidProvider with a real wallet service and accountSigner', () => { +describe('HyperLiquidProvider with accountSigner: prepareTradingWallet', () => { let loggerError: jest.SpyInstance; beforeEach(() => { @@ -73,12 +73,12 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () REFERRAL_WRITE, ]); expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ - [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], - [ACCOUNT_ADDRESS, L1_PAYLOAD], + [MAIN_ADDRESS, USER_SIGNED_PAYLOAD], + [MAIN_ADDRESS, L1_PAYLOAD], ]); // Already approved, so nothing is signed for it. expect(infoClient.maxBuilderFee.mock.calls).toStrictEqual([ - [{ user: ACCOUNT_ADDRESS, builder: BUILDER_FEE_CONFIG.MainnetBuilder }], + [{ user: MAIN_ADDRESS, builder: BUILDER_FEE_CONFIG.MainnetBuilder }], ]); expect(exchangeClient.approveBuilderFee).not.toHaveBeenCalled(); }); @@ -104,9 +104,9 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(result).toStrictEqual({ ready: true }); // Migration, referral, builder fee approval. expect(setupSignatures).toStrictEqual([ - [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], - [ACCOUNT_ADDRESS, L1_PAYLOAD], - [ACCOUNT_ADDRESS, APPROVE_BUILDER_FEE_PAYLOAD], + [MAIN_ADDRESS, USER_SIGNED_PAYLOAD], + [MAIN_ADDRESS, L1_PAYLOAD], + [MAIN_ADDRESS, APPROVE_BUILDER_FEE_PAYLOAD], ]); expect(order).toStrictEqual({ success: true, @@ -116,7 +116,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () filledSize: undefined, }); expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ - [ACCOUNT_ADDRESS, L1_PAYLOAD], + [MAIN_ADDRESS, L1_PAYLOAD], ]); expect(exchangeClient.userSetAbstraction.mock.calls).toStrictEqual([ MIGRATION_WRITE, @@ -145,7 +145,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () const release = PerpsSigningCache.setInFlight( 'referral', 'mainnet', - ACCOUNT_ADDRESS, + MAIN_ADDRESS, ); const waiting = createDeferred(); let lookupsWhileHeld = 0; @@ -199,7 +199,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () REFERRAL_WRITE, ]); expect( - PerpsSigningCache.getReferral('mainnet', ACCOUNT_ADDRESS), + PerpsSigningCache.getReferral('mainnet', MAIN_ADDRESS), ).toStrictEqual({ attempted: true, success: true, @@ -216,7 +216,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () try { const preparing = accountSignerProvider.prepareTradingWallet(); await lock.waiting; - PerpsSigningCache.setReferral('mainnet', ACCOUNT_ADDRESS, { + PerpsSigningCache.setReferral('mainnet', MAIN_ADDRESS, { attempted: true, success: true, }); @@ -279,9 +279,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () const result = await accountSignerProvider.prepareTradingWallet(); expect(result).toStrictEqual({ ready: true }); - expect( - TradingReadinessCache.get('mainnet', ACCOUNT_ADDRESS), - ).toStrictEqual({ + expect(TradingReadinessCache.get('mainnet', MAIN_ADDRESS)).toStrictEqual({ attempted: true, enabled: true, reason: undefined, @@ -289,8 +287,8 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () }); // Migration, then referral; nothing on the second call. expect(firstSignatures).toStrictEqual([ - [ACCOUNT_ADDRESS, USER_SIGNED_PAYLOAD], - [ACCOUNT_ADDRESS, L1_PAYLOAD], + [MAIN_ADDRESS, USER_SIGNED_PAYLOAD], + [MAIN_ADDRESS, L1_PAYLOAD], ]); expect(accountSigner.signTypedData.mock.calls).toStrictEqual( firstSignatures, @@ -317,9 +315,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(result).toStrictEqual({ ready: true }); expect(secondResult).toStrictEqual({ ready: true }); - expect( - TradingReadinessCache.get('mainnet', ACCOUNT_ADDRESS), - ).toStrictEqual({ + expect(TradingReadinessCache.get('mainnet', MAIN_ADDRESS)).toStrictEqual({ attempted: true, enabled: false, reason: undefined, @@ -408,19 +404,51 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () ]); }); - it('does not log a provider replaced during preparation', async () => { - const { accountSignerProvider, initialize } = - createAccountSignerProvider(); - initialize.mockRejectedValue( - new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE), + 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(); }); @@ -448,14 +476,14 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () expect(connectSignatures).toStrictEqual([ [ 'KeyringController:signTypedMessage', - { from: ACCOUNT_ADDRESS, data: USER_SIGNED_PAYLOAD }, + { from: MAIN_ADDRESS, data: USER_SIGNED_PAYLOAD }, 'V4', ], ]); expect(typedDataSignatures()).toStrictEqual([ [ 'KeyringController:signTypedMessage', - { from: ACCOUNT_ADDRESS, data: L1_PAYLOAD }, + { from: MAIN_ADDRESS, data: L1_PAYLOAD }, 'V4', ], ]); @@ -532,7 +560,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ - [ACCOUNT_ADDRESS, L1_PAYLOAD], + [MAIN_ADDRESS, L1_PAYLOAD], ]); expect(loggerError).not.toHaveBeenCalled(); }); @@ -638,7 +666,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () createAccountSignerProvider({ abstraction: 'unifiedAccount' }); // The probe sees a deposit, but the venue has not caught up yet. exchangeClient.setReferrer.mockRejectedValueOnce( - unknownWalletError(ACCOUNT_ADDRESS), + unknownWalletError(MAIN_ADDRESS), ); const rejected = await accountSignerProvider.prepareTradingWallet(); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-signer.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-signer.test.ts index 8ad4fc70653..f0b1f70f9aa 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-signer.test.ts @@ -5,11 +5,11 @@ import type { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProv import { AGENT_ADDRESS, L1_PAYLOAD, + MAIN_ADDRESS, + MAINNET_ACCOUNT, unknownWalletError, } from '../../helpers/agentFixtures.js'; import { - ACCOUNT_ADDRESS, - MAINNET_ACCOUNT, NOW, createAccountSignerProvider, orderIdOf, @@ -30,7 +30,7 @@ jest.mock('@nktkas/hyperliquid', () => ({ jest.mock('../../../src/services/HyperLiquidClientService'); jest.mock('../../../src/services/HyperLiquidSubscriptionService'); -describe('HyperLiquidProvider with a real wallet service and accountSigner', () => { +describe('HyperLiquidProvider with accountSigner: strategy cancels', () => { let loggerError: jest.SpyInstance; beforeEach(() => { @@ -66,7 +66,7 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () side: 'B', sz: '1', timestamp: NOW, - user: ACCOUNT_ADDRESS, + user: MAIN_ADDRESS, }, status: { status: 'activated' }, }, @@ -318,6 +318,66 @@ describe('HyperLiquidProvider with a real wallet service and accountSigner', () }, ); + it('keeps only the rungs a cancel by client order ID left resting when the venue cancels one and rejects the agent on the other', async () => { + 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 withStatuses('success', { + error: unknownWalletError(AGENT_ADDRESS).message, + }); + }); + + 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 }) => { 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 index c18330dd328..ba44a32a0c4 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts @@ -505,6 +505,22 @@ describe('LighterProvider with accountSigner', () => { 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({ 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 index 3487115e544..9131f826437 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts @@ -111,8 +111,11 @@ describe('HyperLiquidWalletService with accountSigner', () => { .signTypedData(L1_PAYLOAD) .catch((caught: unknown) => caught); - expect(error).toStrictEqual(new Error(PERPS_ERROR_CODES.KEYRING_LOCKED)); - expect(error).toHaveProperty('cause', hostError); + // Jest's Error equality ignores `cause`, so match both fields. + expect(error).toMatchObject({ + message: PERPS_ERROR_CODES.KEYRING_LOCKED, + cause: hostError, + }); }); it('reports ready when isReady is omitted', () => { 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 index dc6286e2e7b..bbdd62e4706 100644 --- a/packages/perps-controller/tests/src/services/LighterWalletService.account-signer.test.ts +++ b/packages/perps-controller/tests/src/services/LighterWalletService.account-signer.test.ts @@ -57,8 +57,11 @@ describe('LighterWalletService with accountSigner', () => { .signPersonalMessage('hello') .catch((caught: unknown) => caught); - expect(error).toStrictEqual(new Error(PERPS_ERROR_CODES.KEYRING_LOCKED)); - expect(error).toHaveProperty('cause', hostError); + // Jest's Error equality ignores `cause`, so match both fields. + expect(error).toMatchObject({ + message: PERPS_ERROR_CODES.KEYRING_LOCKED, + cause: hostError, + }); }); it('rethrows an account signer rejection unchanged while the signer stays ready', async () => { From 0b9da517fb11d847537759ee4533a77f07052b6f Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 29 Sep 2026 15:37:50 +0800 Subject: [PATCH 27/33] fix(perps-controller): read thrown cancel statuses per entry, restore TP/SL with a new agent, and check the prepared account - HyperLiquid cancel batches read the per-entry statuses of the error the SDK throws when an entry fails before classifying the error, so orders the venue cancelled are no longer reported as resting or failed. cancelOrders gives KEYRING_LOCKED only to the entries that name the rejected agent. - A TP/SL replacement the signer could not sign drops a rejected agent before the old protection is restored; a restoration the signer could not sign is not logged, and the protection is reported lost. - HyperLiquid prepareTradingWallet reports PROVIDER_LIFECYCLE_STALE when the selected account changes during setup; the controller treats the empty account the AccountsController answers with nothing selected as NO_ACCOUNT_SELECTED. - Tests: thrown and returned mixed cancel responses, TP/SL restoration with a new agent, the silent agent migration through the controller, a locked keyring host on Lighter, exact rejection identity, and fixture cleanups. --- packages/perps-controller/CHANGELOG.md | 1 + .../PerpsController-method-action-types.ts | 13 +- .../perps-controller/src/PerpsController.ts | 20 +- .../src/providers/HyperLiquidProvider.ts | 134 ++++--- .../src/providers/LighterProvider.ts | 17 +- .../tests/helpers/agentFixtures.ts | 21 +- .../hyperLiquidAccountSignerFixture.ts | 40 +- ...ntroller.agent-signing.integration.test.ts | 55 ++- .../PerpsController.providers-cache.test.ts | 55 ++- ...HyperLiquidProvider.account-signer.test.ts | 31 ++ ...yperLiquidProvider.agent-rejection.test.ts | 361 +++++++++++++----- .../HyperLiquidProvider.agent-signer.test.ts | 4 +- ...uidProvider.prepare-trading-wallet.test.ts | 21 +- ...yperLiquidProvider.strategy-signer.test.ts | 146 +++---- .../HyperLiquidProvider.trading.test.ts | 47 ++- .../LighterProvider.account-signer.test.ts | 29 +- ...LiquidWalletService.account-signer.test.ts | 14 +- 17 files changed, 726 insertions(+), 283 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index db64dc23b3f..a83ebf07314 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -48,6 +48,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 - A HyperLiquid referral skipped during trading setup because the wallet has not deposited yet is attempted again at the next trading setup once the wallet has deposited, instead of after the provider reconnects ([#10559](https://github.com/MetaMask/core/pull/10559)) +- 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/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index e3268083afe..da5658fc638 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -950,12 +950,13 @@ export type PerpsControllerClearAgentSignersAction = { * @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: + * 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 diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index eaada5922b0..3cf294e6ba0 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -5937,12 +5937,13 @@ export class PerpsController extends BaseController< * @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: + * 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 @@ -5960,11 +5961,11 @@ export class PerpsController extends BaseController< const result = (await provider.prepareTradingWallet?.()) ?? { ready: true, }; - // A provider with nothing to prepare, alone or aggregated, checks neither - // the signer nor the selected account. 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, @@ -5973,7 +5974,8 @@ export class PerpsController extends BaseController< ) { return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; } - if (!getSelectedEvmAccountFromMessenger(this.messenger)) { + // With nothing selected, the AccountsController answers an empty account. + if (!getSelectedEvmAccountFromMessenger(this.messenger)?.address) { return { ready: false, error: PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED }; } return result; diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 235ffd89e70..080f0a4f8ee 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -8764,6 +8764,11 @@ 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) { @@ -8772,10 +8777,6 @@ export class HyperLiquidProvider implements PerpsProvider { signerFailure, }; } - const statuses = getCancelStatusesFromError(error, requests.length); - if (statuses) { - return classifyStatuses(statuses); - } this.#deps.debugLogger.log('Order cancellation by CLOID failed', { error: ensureError( error, @@ -8844,6 +8845,11 @@ 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) { @@ -8854,10 +8860,6 @@ export class HyperLiquidProvider implements PerpsProvider { signerFailure, }; } - const statuses = getCancelStatusesFromError(error, requests.length); - if (statuses) { - return classifyStatuses(statuses); - } this.#deps.debugLogger.log('Order cancellation batch failed', { error: ensureError(error, 'HyperLiquidProvider.cancelOrderRequests') .message, @@ -9669,41 +9671,48 @@ 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, and is the error of every entry - // that reports one. Entries that succeeded keep their result. + // (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 - : ( - signerFailure ?? - this.#mapError(new Error(statusError)) - ).message, - }), + ...(success ? {} : { error }), }; }); } @@ -10661,16 +10670,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 }; @@ -10795,12 +10813,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; } @@ -14613,6 +14643,19 @@ export class HyperLiquidProvider implements PerpsProvider { } 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', + ); + const currentAddress = + await this.#walletService.getUserAddressWithDefault(); + if (currentAddress.toLowerCase() !== userAddress.toLowerCase()) { + throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); + } + }; // Run the shared setup again to check the builder's referral code. if (this.#referralAwaitsBuilderCode) { this.#tradingSetupComplete = false; @@ -14621,15 +14664,11 @@ export class HyperLiquidProvider implements PerpsProvider { const network = this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet'; - const userAddress = await this.#walletService.getUserAddressWithDefault(); const isRegistered = await this.#isWalletOnHyperliquid( userAddress, network, ); - this.#assertProviderLifecycleCurrent( - lifecycleGeneration, - 'Trading wallet preparation', - ); + 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) { @@ -14644,10 +14683,7 @@ export class HyperLiquidProvider implements PerpsProvider { reportSignerFailure: true, }); // The builder fee setup ends quietly when the provider disconnects. - this.#assertProviderLifecycleCurrent( - lifecycleGeneration, - 'Trading wallet preparation', - ); + await assertPreparationCurrent(); if (!this.#walletService.isMainAccountSignerReady()) { return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; } diff --git a/packages/perps-controller/src/providers/LighterProvider.ts b/packages/perps-controller/src/providers/LighterProvider.ts index d6cb3b5cf4e..1db7eeda4d2 100644 --- a/packages/perps-controller/src/providers/LighterProvider.ts +++ b/packages/perps-controller/src/providers/LighterProvider.ts @@ -1318,15 +1318,14 @@ export class LighterProvider implements PerpsProvider { * read-only provider (no signer bridge) while the main-account signer is * ready and an account is selected: it has nothing to prepare, so it does * not hold back an aggregated result, and `isReadyToTrade` still reports - * that it cannot trade. Otherwise - * `ready: false`: with `KEYRING_LOCKED` whenever the main-account signer is - * not ready (even with a registered venue key), with `NO_ACCOUNT_SELECTED` - * when no account is selected, with `EXCHANGE_ACCOUNT_NOT_FOUND` when the - * wallet has no Lighter account yet (fund it first), without an error when - * the user declined the signature (the order path asks again), with - * `PROVIDER_LIFECYCLE_STALE` (unlogged) when the provider disconnected or - * the wallet switched accounts meanwhile, and with the logged error when - * registration failed. + * that it cannot trade. Otherwise `ready: false`: with `KEYRING_LOCKED` + * whenever the main-account signer is not ready (even with a registered + * venue key), with `NO_ACCOUNT_SELECTED` when no account is selected, with + * `EXCHANGE_ACCOUNT_NOT_FOUND` when the wallet has no Lighter account yet + * (fund it first), without an error when the user declined the signature + * (the order path asks again), with `PROVIDER_LIFECYCLE_STALE` (unlogged) + * when the provider disconnected or the wallet switched accounts meanwhile, + * and with the logged error when registration failed. */ async prepareTradingWallet(): Promise { if (!this.#walletService.isMainAccountSignerReady()) { diff --git a/packages/perps-controller/tests/helpers/agentFixtures.ts b/packages/perps-controller/tests/helpers/agentFixtures.ts index 9111e873cdf..594ec7074e2 100644 --- a/packages/perps-controller/tests/helpers/agentFixtures.ts +++ b/packages/perps-controller/tests/helpers/agentFixtures.ts @@ -1,5 +1,6 @@ import type { Hex } from '@metamask/utils'; +import { BUILDER_FEE_CONFIG } from '../../src/constants/hyperLiquidConfig.js'; import type { PerpsAgentAccount, PerpsTypedDataPayload, @@ -8,10 +9,11 @@ import { createMockEvmAccount } from './serviceMocks.js'; const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as const; -// The payloads below spell out the SDK's domain names, primary types and fee -// rate instead of reading HYPERLIQUID_L1_ACTION_DOMAIN_NAME, -// HYPERLIQUID_L1_ACTION_PRIMARY_TYPE or BUILDER_FEE_CONFIG, so the routing -// tests fail if one of those constants drifts from what the SDK signs. +// 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 = [ @@ -74,15 +76,16 @@ export const USER_SIGNED_PAYLOAD: PerpsTypedDataPayload = { primaryType: 'HyperliquidTransaction:UserSetAbstraction', message: { hyperliquidChain: 'Mainnet', - user: createMockEvmAccount().address, + user: MAIN_ADDRESS, abstraction: 'unifiedAccount', nonce: 1, }, }; /** - * A builder fee approval as the HyperLiquid SDK builds it: user-signed, like - * the migration, but a different action. + * 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, @@ -98,8 +101,8 @@ export const APPROVE_BUILDER_FEE_PAYLOAD: PerpsTypedDataPayload = { primaryType: 'HyperliquidTransaction:ApproveBuilderFee', message: { hyperliquidChain: 'Mainnet', - maxFeeRate: '0.1%', - builder: ZERO_ADDRESS, + maxFeeRate: BUILDER_FEE_CONFIG.MaxFeeRate, + builder: BUILDER_FEE_CONFIG.MainnetBuilder, nonce: 1, }, }; diff --git a/packages/perps-controller/tests/helpers/hyperLiquidAccountSignerFixture.ts b/packages/perps-controller/tests/helpers/hyperLiquidAccountSignerFixture.ts index 83aa706f6f4..e9a70602463 100644 --- a/packages/perps-controller/tests/helpers/hyperLiquidAccountSignerFixture.ts +++ b/packages/perps-controller/tests/helpers/hyperLiquidAccountSignerFixture.ts @@ -15,6 +15,7 @@ * jest.mock('../../../src/services/HyperLiquidSubscriptionService'); */ import type { Hex } from '@metamask/utils'; +import { HyperliquidError } from '@nktkas/hyperliquid'; import { BUILDER_FEE_CONFIG, @@ -73,6 +74,9 @@ const MockedHyperLiquidSubscriptionService = // A fixed clock for cache timestamps. export const NOW = 1_700_000_000_000; +// The ID of every order the mocked exchange places. +export const RESTING_ORDER_ID = 123; + export const BTC_MARKET_ORDER = { symbol: 'BTC', isBuy: true, @@ -99,6 +103,38 @@ export const BUILDER_FEE_WRITE = [ }, ]; +/** + * A cancel the venue answered with one status per entry, handed over the way + * the SDK does: returned, or thrown as the `ApiRequestError` it raises when an + * entry failed, whose message names the failed entries. + * + * @param statuses - The entries' statuses. + * @param delivery - Whether the SDK returns the response or throws 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 + ? [`Order ${index}: ${String(status.error)}`] + : [], + ); + const error = new HyperliquidError( + `Cannot process API request: ${failures.join(', ')}`, + ); + error.name = 'ApiRequestError'; + throw Object.assign(error, { response }); +} + /** * The order ID a placement returned. * @@ -336,7 +372,9 @@ export function createAccountSignerProvider( order: jest.fn( signThroughSdkWallet(L1_PAYLOAD, { status: 'ok', - response: { data: { statuses: [{ resting: { oid: 123 } }] } }, + response: { + data: { statuses: [{ resting: { oid: RESTING_ORDER_ID } }] }, + }, }), ), ...options.exchange, diff --git a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts index d7189ad5e6d..fb7ab55fe77 100644 --- a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts @@ -12,6 +12,7 @@ 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, @@ -316,9 +317,26 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => } /** - * Assert what the host saw during a flow: the accounts `getAgentSigner` was - * asked for, the agents reported to `onAgentRejected`, the - * KeyringController actions called, and no reported error. + * 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. @@ -344,8 +362,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => agentRequests.map((account) => [account]), ); expect(onAgentRejected.mock.calls).toStrictEqual(rejectedAgents); - expect(keyringCalls(call)).toStrictEqual(keyringActions); - expect(loggerError).not.toHaveBeenCalled(); + expectQuietHost(call, keyringActions); } /** @@ -410,6 +427,27 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => 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(); @@ -421,7 +459,8 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => expect(accountSigner.signTypedData.mock.calls).toStrictEqual([ [MAIN_ADDRESS, L1_PAYLOAD], ]); - expectHostSaw(call, { agentRequests: [] }); + // 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 () => { @@ -717,7 +756,9 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => ['cancel', OTHER_AGENT_ADDRESS], ['order', AGENT_ADDRESS], ]); - expectHostSaw(call, { agentRequests: [MAINNET_ACCOUNT] }); + // The host has no onAgentRejected to observe. + expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); + expectQuietHost(call); }); it('prepares nothing and reports KEYRING_LOCKED while the account signer is not ready', async () => { 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 de87461fe04..a9983ae306e 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -970,7 +970,8 @@ describe('PerpsController', () => { selectedAccount = createMockEvmAccount(), }: { isUnlocked: boolean; - selectedAccount?: ReturnType | null; + // An account with an empty address stands for no selection. + selectedAccount?: { address: string } | null; }): jest.Mock { return jest.fn().mockImplementation((action: string) => { if (action === 'KeyringController:getState') { @@ -1302,27 +1303,41 @@ describe('PerpsController', () => { }, ); - it('reports NO_ACCOUNT_SELECTED when the provider reports ready while no account is selected', async () => { - // 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: null }), - }), - state: getDefaultPerpsControllerState(), - infrastructure: mockInfrastructure, - }); - await controller.init(); + 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(); + const result = await controller.prepareTradingWallet(); - expect(result).toStrictEqual({ - ready: false, - error: PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED, - }); - }); + expect(result).toStrictEqual({ + ready: false, + error: PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED, + }); + expect(mockProvider.prepareTradingWallet.mock.calls).toStrictEqual([ + [], + ]); + }, + ); }); describe('getOpenOrders with standalone mode', () => { 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 index 4442ff9798b..e4d560c9117 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-signer.test.ts @@ -182,6 +182,37 @@ describe('HyperLiquidProvider with accountSigner: main-account signing', () => { 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({ 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 index d6c8bdc9697..f8c8a85f8ae 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-rejection.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-rejection.test.ts @@ -20,7 +20,9 @@ import { import { BTC_MARKET_ORDER, REFERRAL_WRITE, + RESTING_ORDER_ID, SILENT_MIGRATION_WRITE, + cancelStatusesResponse, createAccountSignerProvider, migrationAttempted, referralAttempted, @@ -71,6 +73,18 @@ describe('HyperLiquidProvider with accountSigner: agent rejection', () => { 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. @@ -236,54 +250,50 @@ describe('HyperLiquidProvider with accountSigner: agent rejection', () => { expect(loggerError).not.toHaveBeenCalled(); }); - it('keeps the entries of a batch cancel that succeeded when another reports a rejected agent', async () => { - const { - accountSignerProvider, - exchangeClient, - 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: [ - 'success', - { error: unknownWalletError(AGENT_ADDRESS).message }, - ], - }, - }, - }; - }); - - const result = await accountSignerProvider.cancelOrders([ - { orderId: '123', symbol: 'BTC' }, - { orderId: '124', symbol: 'BTC' }, - ]); + it.each(['returned', 'thrown'] as const)( + 'keeps the entries of a batch cancel that succeeded when another reports a rejected agent (%s by the SDK)', + 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, + ); + }); - 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(); - }); + 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 } = @@ -463,44 +473,178 @@ describe('HyperLiquidProvider with accountSigner: agent rejection', () => { expect(loggerError).not.toHaveBeenCalled(); }); - it('restores the leg it cancelled and fails with KEYRING_LOCKED when the venue cancels one leg and rejects the agent on the other', async () => { - 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, + it('fails only the batch cancel entries that name the rejected agent with KEYRING_LOCKED, and maps the others', async () => { + const { accountSignerProvider, exchangeClient, sdkWallet } = + 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' }, + ], + 'thrown', + ); }); + + const result = await accountSignerProvider.cancelOrders([ + { orderId: '123', symbol: 'BTC' }, + { orderId: '124', symbol: 'BTC' }, + ]); + + expect(result.results).toStrictEqual([ + { + 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(loggerError).not.toHaveBeenCalled(); + }); + + it.each(['returned', 'thrown'] as const)( + 'restores the leg it cancelled and fails with KEYRING_LOCKED when the venue cancels one leg and rejects the agent on the other (%s by the SDK)', + async (delivery) => { + 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, + }); + 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('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('cancel'); - infoClient.frontendOpenOrders.mockResolvedValue([ - TAKE_PROFIT_ORDER, - STOP_LOSS_ORDER, - ]); + } = createRejectingProvider('order'); + getAgentSigner + .mockResolvedValueOnce(agentSigner) + .mockResolvedValue(REPLACEMENT_AGENT); + infoClient.frontendOpenOrders.mockResolvedValue([TAKE_PROFIT_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'], '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: [ - 'success', - { error: unknownWalletError(AGENT_ADDRESS).message }, - ], - }, + data: { statuses: [{ resting: { oid: RESTING_ORDER_ID } }] }, }, }; }); @@ -508,41 +652,31 @@ describe('HyperLiquidProvider with accountSigner: agent rejection', () => { 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), '123'], + childOrderIds: [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, - }), + ([request]: [{ orders: { t: unknown }[] }]) => + request.orders.map((order) => order.t), ), ).toStrictEqual([ - { - triggers: [ - { - trigger: { isMarket: true, triggerPx: '58000', tpsl: 'tp' }, - }, - ], - grouping: 'positionTpsl', - }, + [{ 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], @@ -550,6 +684,41 @@ describe('HyperLiquidProvider with accountSigner: agent rejection', () => { expect(loggerError).not.toHaveBeenCalled(); }); + it('reports the protection lost, without logging, when the rejected agent cannot restore it either', async () => { + const { + accountSignerProvider, + exchangeClient, + 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. + expect(exchangeClient.order).toHaveBeenCalledTimes(2); + 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, 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 index 9e356648b76..176acd099b1 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-signer.test.ts @@ -374,11 +374,13 @@ describe('HyperLiquidProvider with accountSigner: agents', () => { 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, }); - mockClientService.isTestnetMode.mockReturnValue(true); await accountSignerProvider.getMarketDataWithPrices(); 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 index c2f556e77b0..631a292948d 100644 --- 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 @@ -14,6 +14,7 @@ import { L1_PAYLOAD, MAIN_ADDRESS, MAIN_SIGNATURE, + OTHER_MAIN_ADDRESS, USER_SIGNED_PAYLOAD, unknownWalletError, } from '../../helpers/agentFixtures.js'; @@ -24,6 +25,7 @@ import { MIGRATION_WRITE, NOW, REFERRAL_WRITE, + RESTING_ORDER_ID, createAccountSignerProvider, migrationAttempted, referralAttempted, @@ -110,7 +112,7 @@ describe('HyperLiquidProvider with accountSigner: prepareTradingWallet', () => { ]); expect(order).toStrictEqual({ success: true, - orderId: '123', + orderId: String(RESTING_ORDER_ID), submittedSize: '0.1', averagePrice: undefined, filledSize: undefined, @@ -452,6 +454,23 @@ describe('HyperLiquidProvider with accountSigner: prepareTradingWallet', () => { expect(loggerError).not.toHaveBeenCalled(); }); + it('reports a preparation whose account was switched meanwhile as stale, without logging', async () => { + const { accountSignerProvider, infoClient, selectAccount } = + createAccountSignerProvider({ abstraction: 'unifiedAccount' }); + infoClient.maxBuilderFee.mockImplementation(async () => { + selectAccount(OTHER_MAIN_ADDRESS); + return 1; + }); + + const result = await 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 }); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-signer.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-signer.test.ts index f0b1f70f9aa..da8db551071 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-signer.test.ts @@ -11,6 +11,8 @@ import { } from '../../helpers/agentFixtures.js'; import { NOW, + RESTING_ORDER_ID, + cancelStatusesResponse, createAccountSignerProvider, orderIdOf, setUpAccountSignerSuite, @@ -103,9 +105,10 @@ describe('HyperLiquidProvider with accountSigner: strategy cancels', () => { /** * A provider whose strategy orders are placed while signing works, and - * whose later cancels sign through the SDK wallet: `failSigning` locks - * the keyring (no agent), makes the agent fail to sign, or has the venue - * reject the agent, by throwing or in the cancel status entries. + * 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. @@ -220,7 +223,11 @@ describe('HyperLiquidProvider with accountSigner: strategy cancels', () => { } const SIGNER_FAILURES = [ - { name: 'a locked keyring', failure: 'locked', rejectedAgents: [] }, + { + name: 'an account signer that is not ready', + failure: 'locked', + rejectedAgents: [], + }, { name: 'an agent that cannot sign', failure: 'unavailable', @@ -318,65 +325,74 @@ describe('HyperLiquidProvider with accountSigner: strategy cancels', () => { }, ); - it('keeps only the rungs a cancel by client order ID left resting when the venue cancels one and rejects the agent on the other', async () => { - 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 withStatuses('success', { - error: unknownWalletError(AGENT_ADDRESS).message, + it.each(['returned', 'thrown'] as const)( + 'keeps only the rungs a cancel by client order ID left resting when the venue cancels one and rejects the agent on the other (%s by the SDK)', + 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', - }); + 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(); - }); + 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', @@ -464,7 +480,7 @@ describe('HyperLiquidProvider with accountSigner: strategy cancels', () => { error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); expect(cancel.mock.calls).toStrictEqual([ - [{ cancels: [{ a: 1, o: 123 }] }], + [{ cancels: [{ a: 1, o: RESTING_ORDER_ID }] }], ]); expect(onAgentRejected.mock.calls).toStrictEqual(rejectedAgents); expect(loggerError).not.toHaveBeenCalled(); @@ -575,7 +591,7 @@ describe('HyperLiquidProvider with accountSigner: strategy cancels', () => { order.mockImplementation(async () => { await signL1Action(); disconnected = provider.disconnect(); - return withStatuses({ resting: { oid: 123 } }); + return withStatuses({ resting: { oid: RESTING_ORDER_ID } }); }); cancel.mockImplementation(async () => { await signL1Action(); @@ -594,10 +610,10 @@ describe('HyperLiquidProvider with accountSigner: strategy cancels', () => { success: false, error: PERPS_ERROR_CODES.ORDER_CHASE_ABANDONED, submittedSize: '1', - childOrderIds: ['123'], + childOrderIds: [String(RESTING_ORDER_ID)], }); expect(cancel.mock.calls).toStrictEqual([ - [{ cancels: [{ a: 1, o: 123 }] }], + [{ cancels: [{ a: 1, o: RESTING_ORDER_ID }] }], ]); expect(onAgentRejected.mock.calls).toStrictEqual([ [MAINNET_ACCOUNT, AGENT_ADDRESS], @@ -643,7 +659,7 @@ describe('HyperLiquidProvider with accountSigner: strategy cancels', () => { await signL1Action(); expect(cancel.mock.calls).toStrictEqual([ - [{ cancels: [{ a: 1, o: 123 }] }], + [{ cancels: [{ a: 1, o: RESTING_ORDER_ID }] }], ]); expect(onAgentRejected.mock.calls).toStrictEqual([ [MAINNET_ACCOUNT, AGENT_ADDRESS], 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 636f00639d2..bac7d3298c7 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 { @@ -4299,6 +4303,47 @@ 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( + 'Cannot process API request: Order 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/LighterProvider.account-signer.test.ts b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts index ba44a32a0c4..43d5dbcde8a 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts @@ -132,6 +132,8 @@ 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; @@ -140,6 +142,7 @@ type BuildOptions = { function buildProvider({ isReady, keyring = false, + keyringUnlocked = true, findAccountByAddress = false, withoutBridge = false, }: BuildOptions = {}): BuiltProvider { @@ -178,7 +181,7 @@ function buildProvider({ isReady, }; const { messenger, call, selectAccount, deselectAccount } = keyring - ? createKeyringMessenger(MAIN_SIGNATURE) + ? createKeyringMessenger(MAIN_SIGNATURE, keyringUnlocked) : createKeyringlessMessenger(); const { bridge, calls } = createBridge(); const deps = keyring @@ -617,6 +620,30 @@ describe('LighterProvider with accountSigner', () => { 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( + call.mock.calls.filter(([action]: [string]) => + action.startsWith('KeyringController:'), + ), + ).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, 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 index 9131f826437..44d27ad2167 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts @@ -84,15 +84,14 @@ describe('HyperLiquidWalletService with accountSigner', () => { }); it('propagates account signer rejections', async () => { + const rejection = new Error('User rejected the request.'); const { service } = buildService({ - signTypedData: jest - .fn() - .mockRejectedValue(new Error('User rejected the request.')), + signTypedData: jest.fn().mockRejectedValue(rejection), }); await expect( service.createWalletAdapter().signTypedData(L1_PAYLOAD), - ).rejects.toThrow('User rejected the request.'); + ).rejects.toBe(rejection); }); it('fails with KEYRING_LOCKED, keeping the host error as its cause, when the signer locks while signing', async () => { @@ -287,11 +286,10 @@ describe('HyperLiquidWalletService wallet adapter with an agent', () => { it('propagates agent resolution failures', async () => { const { adapter, resolveAgent, mainSign } = buildAdapter(); - resolveAgent.mockRejectedValue(new Error('agent store unavailable')); + const failure = new Error('agent store unavailable'); + resolveAgent.mockRejectedValue(failure); - await expect(adapter.signTypedData(L1_PAYLOAD)).rejects.toThrow( - 'agent store unavailable', - ); + await expect(adapter.signTypedData(L1_PAYLOAD)).rejects.toBe(failure); expect(mainSign).not.toHaveBeenCalled(); }); From eb12dd76e4ff2a33140be24e93f7805955b8cee5 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 29 Sep 2026 16:10:26 +0800 Subject: [PATCH 28/33] fix(perps-controller): report a preparation whose account changed as stale, and treat unsigned HIP-3 transfers as retryable - PerpsController.prepareTradingWallet compares the selected account before and after the provider's preparation (in aggregated mode, every provider in turn) and returns PROVIDER_LIFECYCLE_STALE when it changed. HyperLiquid's own check treats a deselection during setup as stale too. - A HIP-3 rollback or auto-rebalance transfer that fails with KEYRING_LOCKED is debug-logged with the amount left on the HIP-3 DEX instead of reported as an error. - #classifySignerFailure looks a rejected agent up once. - Docs: the Fixed entry covers the HIP-3 transfers and no longer lists the new accountSigner; getAgentSigner's rejection wording. - Tests: HIP-3 rollback and rebalance with KEYRING_LOCKED and failed transfers, a partial TP/SL cancel whose restoration fails, account changes during preparation, SDK-shaped thrown cancel errors (`cancel N: ...`), exact batch results and host callbacks, shared account and clock fixtures. --- packages/perps-controller/CHANGELOG.md | 4 +- .../perps-controller/src/PerpsController.ts | 20 +- .../src/providers/HyperLiquidProvider.ts | 36 ++- packages/perps-controller/src/types/index.ts | 8 +- .../tests/helpers/agentFixtures.ts | 6 + .../hyperLiquidAccountSignerFixture.ts | 23 +- .../tests/helpers/serviceMocks.ts | 3 + ...ntroller.agent-signing.integration.test.ts | 3 +- .../PerpsController.providers-cache.test.ts | 97 +++++++- ...yperLiquidProvider.agent-rejection.test.ts | 229 +++++++++++++----- .../HyperLiquidProvider.agent-signer.test.ts | 2 +- ...uidProvider.prepare-trading-wallet.test.ts | 48 ++-- ...yperLiquidProvider.strategy-orders.test.ts | 156 ++++++++++++ ...yperLiquidProvider.strategy-signer.test.ts | 7 +- .../HyperLiquidProvider.trading.test.ts | 4 +- .../LighterProvider.account-signer.test.ts | 5 +- ...LiquidWalletService.account-signer.test.ts | 8 +- ...ighterWalletService.account-signer.test.ts | 8 +- .../tests/src/services/agentSigner.test.ts | 68 +++--- 19 files changed, 565 insertions(+), 170 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index a83ebf07314..97809a5349a 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -44,9 +44,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- HyperLiquid writes that fail because the keyring is locked, or because the `accountSigner` is not ready, 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)) +- 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 + - 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 - A HyperLiquid referral skipped during trading setup because the wallet has not deposited yet is attempted again at the next trading setup once the wallet has deposited, instead of after the provider reconnects ([#10559](https://github.com/MetaMask/core/pull/10559)) - 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)) diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 3cf294e6ba0..8a5b38d888c 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -5958,6 +5958,14 @@ export class PerpsController extends BaseController< */ 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, }; @@ -5974,10 +5982,18 @@ export class PerpsController extends BaseController< ) { return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; } - // With nothing selected, the AccountsController answers an empty account. - if (!getSelectedEvmAccountFromMessenger(this.messenger)?.address) { + const address = readSelectedAddress(); + if (!address && !addressAtStart) { return { ready: false, error: PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED }; } + // The steps ran for the account selected when they started (in aggregated + // mode, one provider after another). + if (address !== addressAtStart) { + return { + ready: false, + error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, + }; + } return result; } diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 080f0a4f8ee..90e6b598ecf 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -2265,11 +2265,12 @@ export class HyperLiquidProvider implements PerpsProvider { * @returns `KEYRING_LOCKED` for a signer failure, else undefined. */ #classifySignerFailure(error: unknown): Error | undefined { - if (!this.#isSignerFailure(error)) { - return undefined; - } - this.#evictRejectedAgent(error); - return new Error(PERPS_ERROR_CODES.KEYRING_LOCKED); + const rejectedAgent = this.#evictRejectedAgent(error); + return rejectedAgent || + isKeyringLockedError(error) || + isAgentSignerUnavailableError(error) + ? new Error(PERPS_ERROR_CODES.KEYRING_LOCKED) + : undefined; } /** @@ -5306,6 +5307,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: excessAmount.toFixed(2) }, + ); + return false; + } throw new Error( transferResult.error ?? PERPS_ERROR_CODES.TRANSFER_FAILED, ); @@ -5402,6 +5411,15 @@ 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.toFixed(USDC_DECIMALS), + }, + ); } else { this.#deps.logger.error( new Error(rollbackResult.error ?? 'Rollback transfer failed'), @@ -14650,9 +14668,11 @@ export class HyperLiquidProvider implements PerpsProvider { lifecycleGeneration, 'Trading wallet preparation', ); - const currentAddress = - await this.#walletService.getUserAddressWithDefault(); - if (currentAddress.toLowerCase() !== userAddress.toLowerCase()) { + // 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); } }; diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 85e94acc6ea..a43d799b4a3 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1126,10 +1126,10 @@ export type HyperLiquidCredentials = { * `setAgentSigner`/`clearAgentSigners`; null is not kept, so it is asked * again at the next L1 action. With an agent, L1 actions are signed by the * agent key and user-signed actions (builder fee, withdraw, ...) by the main - * account. A rejection fails that action and is asked again at the next - * one. An agent whose `signTypedData` rejects fails that action and stays - * in use, so call `PerpsController:clearAgentSigners` when the agent key - * locks. + * account. If `getAgentSigner` rejects or throws, that action fails and it + * is asked again at the next one. An agent whose `signTypedData` rejects + * fails that action and stays in use, so call + * `PerpsController:clearAgentSigners` when the agent key locks. */ getAgentSigner?: ( account: PerpsAgentAccount, diff --git a/packages/perps-controller/tests/helpers/agentFixtures.ts b/packages/perps-controller/tests/helpers/agentFixtures.ts index 594ec7074e2..0a03e2aeb29 100644 --- a/packages/perps-controller/tests/helpers/agentFixtures.ts +++ b/packages/perps-controller/tests/helpers/agentFixtures.ts @@ -32,6 +32,12 @@ export const MAINNET_ACCOUNT: PerpsAgentAccount = { 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; diff --git a/packages/perps-controller/tests/helpers/hyperLiquidAccountSignerFixture.ts b/packages/perps-controller/tests/helpers/hyperLiquidAccountSignerFixture.ts index e9a70602463..4b9493970d6 100644 --- a/packages/perps-controller/tests/helpers/hyperLiquidAccountSignerFixture.ts +++ b/packages/perps-controller/tests/helpers/hyperLiquidAccountSignerFixture.ts @@ -72,7 +72,7 @@ const MockedHyperLiquidSubscriptionService = >; // A fixed clock for cache timestamps. -export const NOW = 1_700_000_000_000; +export { NOW } from './serviceMocks.js'; // The ID of every order the mocked exchange places. export const RESTING_ORDER_ID = 123; @@ -103,13 +103,20 @@ export const BUILDER_FEE_WRITE = [ }, ]; +// 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, handed over the way - * the SDK does: returned, or thrown as the `ApiRequestError` it raises when an - * entry failed, whose message names the failed entries. + * 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 returns the response or throws it. + * @param delivery - Whether the SDK throws the response or returns it. * @returns The response, when it is returned. */ export function cancelStatusesResponse( @@ -125,12 +132,10 @@ export function cancelStatusesResponse( } const failures = statuses.flatMap((status, index) => typeof status === 'object' && status !== null && 'error' in status - ? [`Order ${index}: ${String(status.error)}`] + ? [`cancel ${index}: ${String(status.error)}`] : [], ); - const error = new HyperliquidError( - `Cannot process API request: ${failures.join(', ')}`, - ); + const error = new HyperliquidError(failures.join(', ')); error.name = 'ApiRequestError'; throw Object.assign(error, { response }); } diff --git a/packages/perps-controller/tests/helpers/serviceMocks.ts b/packages/perps-controller/tests/helpers/serviceMocks.ts index 7ada69e2932..7328ceefa5e 100644 --- a/packages/perps-controller/tests/helpers/serviceMocks.ts +++ b/packages/perps-controller/tests/helpers/serviceMocks.ts @@ -18,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; diff --git a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts index fb7ab55fe77..2a2896aac9b 100644 --- a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts @@ -32,6 +32,7 @@ import { OTHER_AGENT_ADDRESS, OTHER_AGENT_SIGNATURE, signThroughWallet, + TESTNET_ACCOUNT, unknownWalletError, USER_SIGNED_PAYLOAD, } from '../helpers/agentFixtures.js'; @@ -573,7 +574,7 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => [MAIN_ADDRESS, L1_PAYLOAD], ]); expectHostSaw(call, { - agentRequests: [{ ...MAINNET_ACCOUNT, isTestnet: true }], + agentRequests: [TESTNET_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 a9983ae306e..8a05287be1e 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -6,7 +6,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { AGENT_ADDRESS, MAIN_SIGNATURE } from '../helpers/agentFixtures.js'; +import { + AGENT_ADDRESS, + MAIN_SIGNATURE, + MAINNET_ACCOUNT, + OTHER_MAIN_ADDRESS, +} from '../helpers/agentFixtures.js'; import { createMockHyperLiquidProvider, createMockPosition, @@ -984,11 +989,6 @@ describe('PerpsController', () => { }); } - const account = { - mainAddress: createMockEvmAccount().address, - isTestnet: false, - } as const; - it('hands infrastructure.accountSigner to the HyperLiquid and Lighter providers', async () => { const accountSigner = { signTypedData: jest.fn(), @@ -1126,17 +1126,21 @@ describe('PerpsController', () => { await controller.init(); const resolve = getProviderAgentResolver(); - rootMessenger.call('PerpsController:setAgentSigner', account, null); - const pinned = await resolve(account); + rootMessenger.call( + 'PerpsController:setAgentSigner', + MAINNET_ACCOUNT, + null, + ); + const pinned = await resolve(MAINNET_ACCOUNT); rootMessenger.call('PerpsController:clearAgentSigners'); - const cleared = await resolve(account); + 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([[account]]); + expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); expect(readiness).toStrictEqual({ ready: true }); expect(mockProvider.prepareTradingWallet.mock.calls).toStrictEqual([[]]); }); @@ -1155,7 +1159,7 @@ describe('PerpsController', () => { registerMockLighterProvider(controller); const providers = controller.testGetProviders(); - controller.setAgentSigner(account, agentSigner); + controller.setAgentSigner(MAINNET_ACCOUNT, agentSigner); controller.clearAgentSigners(); expect([...providers.keys()]).toStrictEqual(['hyperliquid', 'lighter']); @@ -1338,6 +1342,77 @@ describe('PerpsController', () => { ]); }, ); + it.each([ + { + change: 'switched', + nextAccount: { ...createMockEvmAccount(), address: OTHER_MAIN_ADDRESS }, + }, + { + change: 'deselected', + nextAccount: { ...createMockEvmAccount(), address: '' }, + }, + ])( + 'reports PROVIDER_LIFECYCLE_STALE when the account is $change while the provider prepares', + async ({ nextAccount }) => { + let selectedAccount: { address: string } = createMockEvmAccount(); + const call = jest.fn().mockImplementation((action: string) => { + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } + return action === 'AccountsController:getSelectedAccount' + ? selectedAccount + : undefined; + }); + // For example an aggregated provider preparing one provider after + // another. + mockProvider.prepareTradingWallet = jest.fn(async () => { + selectedAccount = nextAccount; + return { ready: true }; + }); + 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 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( + call.mock.calls.filter(([action]) => action.startsWith('Keyring')), + ).toStrictEqual([]); + }); }); describe('getOpenOrders with standalone mode', () => { 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 index f8c8a85f8ae..46244e72ab5 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-rejection.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-rejection.test.ts @@ -19,6 +19,7 @@ import { } from '../../helpers/agentFixtures.js'; import { BTC_MARKET_ORDER, + CANCEL_DELIVERIES, REFERRAL_WRITE, RESTING_ORDER_ID, SILENT_MIGRATION_WRITE, @@ -250,9 +251,9 @@ describe('HyperLiquidProvider with accountSigner: agent rejection', () => { expect(loggerError).not.toHaveBeenCalled(); }); - it.each(['returned', 'thrown'] as const)( - 'keeps the entries of a batch cancel that succeeded when another reports a rejected agent (%s by the SDK)', - async (delivery) => { + 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, @@ -473,47 +474,62 @@ describe('HyperLiquidProvider with accountSigner: agent rejection', () => { expect(loggerError).not.toHaveBeenCalled(); }); - it('fails only the batch cancel entries that name the rejected agent with KEYRING_LOCKED, and maps the others', async () => { - const { accountSignerProvider, exchangeClient, sdkWallet } = - 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' }, - ], - 'thrown', - ); - }); + 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' }, - ]); + const result = await accountSignerProvider.cancelOrders([ + { orderId: '123', symbol: 'BTC' }, + { orderId: '124', symbol: 'BTC' }, + ]); - expect(result.results).toStrictEqual([ - { - orderId: '123', - symbol: 'BTC', - success: false, - error: PERPS_ERROR_CODES.KEYRING_LOCKED, - }, - { - orderId: '124', - symbol: 'BTC', + expect(result).toStrictEqual({ success: false, - error: PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, - }, - ]); - expect(loggerError).not.toHaveBeenCalled(); - }); + 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(['returned', 'thrown'] as const)( - 'restores the leg it cancelled and fails with KEYRING_LOCKED when the venue cancels one leg and rejects the agent on the other (%s by the SDK)', - async (delivery) => { + 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 STOP_LOSS_ORDER = createFrontendOpenOrder({ side: 'A', limitPx: '42000', @@ -614,6 +630,72 @@ describe('HyperLiquidProvider with accountSigner: agent rejection', () => { }, ); + 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 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, + }); + const { + accountSignerProvider, + exchangeClient, + 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)], + }); + // 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], + ]); + 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, @@ -688,6 +770,7 @@ describe('HyperLiquidProvider with accountSigner: agent rejection', () => { const { accountSignerProvider, exchangeClient, + getAgentSigner, infoClient, sdkWallet, onAgentRejected, @@ -710,8 +793,21 @@ describe('HyperLiquidProvider with accountSigner: agent rejection', () => { error: PERPS_ERROR_CODES.TPSL_PROTECTION_LOST, childOrderIds: [], }); - // The replacement and the restoration are both rejected. - expect(exchangeClient.order).toHaveBeenCalledTimes(2); + // 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], @@ -1128,7 +1224,7 @@ describe('HyperLiquidProvider with accountSigner: agent rejection', () => { throw unknownWalletError(agentSigner.address); }); - await accountSignerProvider.cancelOrder({ + const result = await accountSignerProvider.cancelOrder({ orderId: '123', symbol: 'BTC', }); @@ -1137,6 +1233,12 @@ describe('HyperLiquidProvider with accountSigner: agent rejection', () => { 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], ]); @@ -1229,30 +1331,33 @@ describe('HyperLiquidProvider with accountSigner: agent rejection', () => { expect(referralAttempted()).toBe(false); expect(loggerError).not.toHaveBeenCalled(); }); + }); + }); - 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); + 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, }); - await accountSignerProvider.getMarketDataWithPrices(); + exchangeClient.order.mockImplementation(async () => { + await sdkWallet().signTypedData(L1_PAYLOAD); + throw unknownWalletError(MAIN_ADDRESS); + }); + await accountSignerProvider.getMarketDataWithPrices(); - const order = await accountSignerProvider.placeOrder(BTC_MARKET_ORDER); + 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(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 index 176acd099b1..b8f82e5b096 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-signer.test.ts @@ -452,7 +452,7 @@ describe('HyperLiquidProvider with accountSigner: agents', () => { ]); }); - it('asks getAgentSigner again once the bindings are cleared', async () => { + 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 } = 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 index 631a292948d..828ae474e65 100644 --- 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 @@ -454,22 +454,42 @@ describe('HyperLiquidProvider with accountSigner: prepareTradingWallet', () => { expect(loggerError).not.toHaveBeenCalled(); }); - it('reports a preparation whose account was switched meanwhile as stale, without logging', async () => { - const { accountSignerProvider, infoClient, selectAccount } = - createAccountSignerProvider({ abstraction: 'unifiedAccount' }); - infoClient.maxBuilderFee.mockImplementation(async () => { - selectAccount(OTHER_MAIN_ADDRESS); - return 1; - }); + 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 accountSignerProvider.prepareTradingWallet(); + const result = + await fixture.accountSignerProvider.prepareTradingWallet(); - expect(result).toStrictEqual({ - ready: false, - error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, - }); - expect(loggerError).not.toHaveBeenCalled(); - }); + 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 } = 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 010fc2a43ef..48e22fb03cb 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 @@ -5015,6 +5015,162 @@ 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, + ): jest.SpyInstance => { + let ordered = false; + useStrategyClients({ + exchange: { + order: jest.fn(async () => { + ordered = true; + return orderResponse; + }), + }, + 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 jest.spyOn(provider, 'transferBetweenDexs'); + }; + + 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)}`; + + /** + * 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.each([ + { + transferError: PERPS_ERROR_CODES.KEYRING_LOCKED, + reported: [[ORDER_FAILURE, 'placeOrder']], + }, + { + transferError: 'transfer failed', + reported: [ + ['transfer failed', 'placeOrder:rollback'], + [ORDER_FAILURE, 'placeOrder'], + ], + }, + ])( + 'reports the rollback of a failed HIP-3 order only when it fails for a reason other than the signer ($transferError)', + async ({ transferError, reported }) => { + 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); + }, + ); + + it.each([ + { transferError: PERPS_ERROR_CODES.KEYRING_LOCKED, reported: [] }, + { + transferError: 'transfer failed', + reported: [['transfer failed', 'placeOrder:autoRebalance']], + }, + ])( + 'reports the rebalance after a HIP-3 order only when it fails for a reason other than the signer ($transferError)', + async ({ transferError, reported }) => { + 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); + }, + ); + }); + 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 index da8db551071..210e06acaef 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-signer.test.ts @@ -10,6 +10,7 @@ import { unknownWalletError, } from '../../helpers/agentFixtures.js'; import { + CANCEL_DELIVERIES, NOW, RESTING_ORDER_ID, cancelStatusesResponse, @@ -325,9 +326,9 @@ describe('HyperLiquidProvider with accountSigner: strategy cancels', () => { }, ); - it.each(['returned', 'thrown'] as const)( - 'keeps only the rungs a cancel by client order ID left resting when the venue cancels one and rejects the agent on the other (%s by the SDK)', - async (delivery) => { + 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, 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 bac7d3298c7..542341ed72b 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts @@ -4306,9 +4306,7 @@ 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( - 'Cannot process API request: Order 1: multi-sig required', - ), + new HyperliquidError('cancel 1: multi-sig required'), { name: 'ApiRequestError', response: { 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 index 43d5dbcde8a..6b1528a1204 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts @@ -17,11 +17,12 @@ import { OTHER_MAIN_ADDRESS, } from '../../helpers/agentFixtures.js'; import { - createKeyringMessenger, createKeyringlessMessenger, + createKeyringMessenger, createMockEvmAccount, createMockInfrastructure, keyringCalls, + NOW, } from '../../helpers/serviceMocks.js'; // The wallet service stays real. The venue REST client and the WASM signer @@ -40,8 +41,6 @@ const MockedClientService = LighterClientService as jest.MockedClass< const ACCOUNT_INDEX = 28; const API_KEY_INDEX = 7; const NEXT_NONCE = 42; -// A fixed clock, so the signed transaction is deterministic. -const NOW = 1_700_000_000_000; // 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 = 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 index 44d27ad2167..cee084b03c8 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts @@ -110,11 +110,9 @@ describe('HyperLiquidWalletService with accountSigner', () => { .signTypedData(L1_PAYLOAD) .catch((caught: unknown) => caught); - // Jest's Error equality ignores `cause`, so match both fields. - expect(error).toMatchObject({ - message: PERPS_ERROR_CODES.KEYRING_LOCKED, - cause: hostError, - }); + 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', () => { 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 index bbdd62e4706..5607cfa5afa 100644 --- a/packages/perps-controller/tests/src/services/LighterWalletService.account-signer.test.ts +++ b/packages/perps-controller/tests/src/services/LighterWalletService.account-signer.test.ts @@ -57,11 +57,9 @@ describe('LighterWalletService with accountSigner', () => { .signPersonalMessage('hello') .catch((caught: unknown) => caught); - // Jest's Error equality ignores `cause`, so match both fields. - expect(error).toMatchObject({ - message: PERPS_ERROR_CODES.KEYRING_LOCKED, - cause: hostError, - }); + 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 () => { diff --git a/packages/perps-controller/tests/src/services/agentSigner.test.ts b/packages/perps-controller/tests/src/services/agentSigner.test.ts index 0689d669e86..2f88736defb 100644 --- a/packages/perps-controller/tests/src/services/agentSigner.test.ts +++ b/packages/perps-controller/tests/src/services/agentSigner.test.ts @@ -6,18 +6,15 @@ import { 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'; -import { createMockEvmAccount } from '../../helpers/serviceMocks.js'; -const ACCOUNT: PerpsAgentAccount = { - mainAddress: createMockEvmAccount().address, - isTestnet: false, -}; // The same main account, spelled in upper case. -const UPPER_CASE_MAIN_ADDRESS = `0x${ACCOUNT.mainAddress +const UPPER_CASE_MAIN_ADDRESS = `0x${MAINNET_ACCOUNT.mainAddress .slice(2) .toUpperCase()}` as const; const AGENT = { @@ -29,26 +26,25 @@ describe('AgentBindings', () => { it('resolves no agent without getAgentSigner or a binding', async () => { const bindings = new AgentBindings(undefined); - expect(await bindings.resolve(ACCOUNT)).toBeNull(); + 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 = { - ...ACCOUNT, + ...MAINNET_ACCOUNT, mainAddress: OTHER_MAIN_ADDRESS, }; - const testnetAccount: PerpsAgentAccount = { ...ACCOUNT, isTestnet: true }; - bindings.set(ACCOUNT, AGENT); + bindings.set(MAINNET_ACCOUNT, AGENT); - expect(await bindings.resolve(ACCOUNT)).toBe(AGENT); + expect(await bindings.resolve(MAINNET_ACCOUNT)).toBe(AGENT); expect(await bindings.resolve(otherAccount)).toBeNull(); - expect(await bindings.resolve(testnetAccount)).toBeNull(); + expect(await bindings.resolve(TESTNET_ACCOUNT)).toBeNull(); expect(getAgentSigner.mock.calls).toStrictEqual([ [otherAccount], - [testnetAccount], + [TESTNET_ACCOUNT], ]); }); @@ -56,11 +52,11 @@ describe('AgentBindings', () => { const getAgentSigner = jest.fn().mockResolvedValue(null); const bindings = new AgentBindings(getAgentSigner); - bindings.set(ACCOUNT, AGENT); + bindings.set(MAINNET_ACCOUNT, AGENT); expect( await bindings.resolve({ - ...ACCOUNT, + ...MAINNET_ACCOUNT, mainAddress: UPPER_CASE_MAIN_ADDRESS, }), ).toBe(AGENT); @@ -71,19 +67,19 @@ describe('AgentBindings', () => { const getAgentSigner = jest.fn().mockResolvedValue(null); const bindings = new AgentBindings(getAgentSigner); const otherAccount: PerpsAgentAccount = { - ...ACCOUNT, + ...MAINNET_ACCOUNT, mainAddress: OTHER_MAIN_ADDRESS, }; - bindings.set(ACCOUNT, null); + bindings.set(MAINNET_ACCOUNT, null); bindings.set(otherAccount, AGENT); bindings.clear(); - expect(await bindings.resolve(ACCOUNT)).toBeNull(); + 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([ - [ACCOUNT], + [MAINNET_ACCOUNT], [otherAccount], ]); }); @@ -91,43 +87,41 @@ describe('AgentBindings', () => { 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(ACCOUNT, AGENT); + bindings.set(MAINNET_ACCOUNT, AGENT); bindings.release( - { ...ACCOUNT, mainAddress: UPPER_CASE_MAIN_ADDRESS }, + { ...MAINNET_ACCOUNT, mainAddress: UPPER_CASE_MAIN_ADDRESS }, AGENT.address.toUpperCase().replace('0X', '0x'), ); - expect(await bindings.resolve(ACCOUNT)).toBeNull(); - expect(getAgentSigner.mock.calls).toStrictEqual([[ACCOUNT]]); + 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); - const testnetAccount: PerpsAgentAccount = { ...ACCOUNT, isTestnet: true }; - bindings.set(ACCOUNT, AGENT); - bindings.set(testnetAccount, AGENT); + bindings.set(MAINNET_ACCOUNT, AGENT); + bindings.set(TESTNET_ACCOUNT, AGENT); - bindings.release(testnetAccount, AGENT.address); + bindings.release(TESTNET_ACCOUNT, AGENT.address); - expect(await bindings.resolve(ACCOUNT)).toBe(AGENT); - expect(await bindings.resolve(testnetAccount)).toBeNull(); - expect(getAgentSigner.mock.calls).toStrictEqual([[testnetAccount]]); + 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); - const otherAccount: PerpsAgentAccount = { ...ACCOUNT, isTestnet: true }; - bindings.set(ACCOUNT, AGENT); - bindings.set(otherAccount, null); + bindings.set(MAINNET_ACCOUNT, AGENT); + bindings.set(TESTNET_ACCOUNT, null); - bindings.release(ACCOUNT, OTHER_AGENT_ADDRESS); - bindings.release(otherAccount, AGENT.address); + bindings.release(MAINNET_ACCOUNT, OTHER_AGENT_ADDRESS); + bindings.release(TESTNET_ACCOUNT, AGENT.address); - expect(await bindings.resolve(ACCOUNT)).toBe(AGENT); - expect(await bindings.resolve(otherAccount)).toBeNull(); + expect(await bindings.resolve(MAINNET_ACCOUNT)).toBe(AGENT); + expect(await bindings.resolve(TESTNET_ACCOUNT)).toBeNull(); expect(getAgentSigner).not.toHaveBeenCalled(); }); }); From f18b9ff05bf9d0ae69f425ce34584929ee16a245 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 29 Sep 2026 16:49:15 +0800 Subject: [PATCH 29/33] fix(perps-controller): check the prepared account before any result, keep the referral retry scoped to preparation, and keep main's unfunded-wallet referral behavior - PerpsController.prepareTradingWallet compares the selected account before returning any provider result, ready or not. - A pending builder referral code is checked again only by prepareTradingWallet: the setup flag is reset inside #ensureReadyForTrading right before the setup check, so a failure before it no longer leaves the referral for an order to sign. - The referral no longer marks itself for retry when the wallet has no HyperLiquid account yet (main's behavior: the next provider attempts it). - A HIP-3 pre-order collateral transfer the signer could not sign fails the order with KEYRING_LOCKED instead of a wrapped message that was logged. - Debug logs record raw amounts; the setAgentSigner changelog entry says the messenger action is available once init has run. - Tests: stale results for not-ready providers and selections, a failed preparation followed by an order, HIP-3 pre-order transfers, exact debug notes, SDK-shaped single-leg cancels, and shared helpers. --- packages/perps-controller/CHANGELOG.md | 1 - .../perps-controller/src/PerpsController.ts | 21 +-- .../src/providers/HyperLiquidProvider.ts | 39 ++--- ...ntroller.agent-signing.integration.test.ts | 123 ++++++++-------- .../PerpsController.providers-cache.test.ts | 124 +++++++++++----- .../HyperLiquidProvider.account-mode.test.ts | 8 +- ...yperLiquidProvider.agent-rejection.test.ts | 133 +++++++++--------- ...uidProvider.prepare-trading-wallet.test.ts | 73 ++++++++-- ...yperLiquidProvider.strategy-orders.test.ts | 69 +++++++-- .../LighterProvider.account-signer.test.ts | 6 +- ...LiquidWalletService.account-signer.test.ts | 5 +- 11 files changed, 377 insertions(+), 225 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 97809a5349a..f021b0a5698 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -47,7 +47,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 -- A HyperLiquid referral skipped during trading setup because the wallet has not deposited yet is attempted again at the next trading setup once the wallet has deposited, instead of after the provider reconnects ([#10559](https://github.com/MetaMask/core/pull/10559)) - 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/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 8a5b38d888c..7e538d99f0e 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -5969,6 +5969,16 @@ export class PerpsController extends BaseController< 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; } @@ -5982,18 +5992,9 @@ export class PerpsController extends BaseController< ) { return { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED }; } - const address = readSelectedAddress(); - if (!address && !addressAtStart) { + if (!address) { return { ready: false, error: PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED }; } - // The steps ran for the account selected when they started (in aggregated - // mode, one provider after another). - if (address !== addressAtStart) { - return { - ready: false, - error: PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE, - }; - } return result; } diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 90e6b598ecf..4acf199ef7a 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -2913,9 +2913,8 @@ export class HyperLiquidProvider implements PerpsProvider { #tradingSetupComplete = false; - // Set when the referral could not be written yet (its signer could not - // sign, or the wallet has no HyperLiquid account yet), so trading setup is - // not marked complete and the referral is attempted again. + // 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 @@ -3009,21 +3008,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(); @@ -3033,6 +3038,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( @@ -5008,6 +5018,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'}`, ); @@ -5311,7 +5325,7 @@ export class HyperLiquidProvider implements PerpsProvider { 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: excessAmount.toFixed(2) }, + { dex: dexName, excessAmount }, ); return false; } @@ -5415,10 +5429,7 @@ export class HyperLiquidProvider implements PerpsProvider { // 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.toFixed(USDC_DECIMALS), - }, + { dex: dexName, amount: transferInfo.amount }, ); } else { this.#deps.logger.error( @@ -14676,11 +14687,10 @@ export class HyperLiquidProvider implements PerpsProvider { throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE); } }; - // Run the shared setup again to check the builder's referral code. - if (this.#referralAwaitsBuilderCode) { - this.#tradingSetupComplete = false; - } - await this.#ensureReadyForTrading({ requiresBuilderFee: false }); + await this.#ensureReadyForTrading({ + requiresBuilderFee: false, + recheckPendingReferral: true, + }); const network = this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet'; @@ -15848,8 +15858,6 @@ export class HyperLiquidProvider implements PerpsProvider { '[ensureReferralSet] Wallet not yet on Hyperliquid, deferring referral setup', { network }, ); - // Attempt it again once the wallet has deposited. - this.#referralSetupNeedsRetry = true; return; } @@ -15979,7 +15987,6 @@ export class HyperLiquidProvider implements PerpsProvider { '[ensureReferralSet] Wallet not on Hyperliquid (race/stale-cache), deferring referral', { network, user: userAddress }, ); - this.#referralSetupNeedsRetry = true; completeInFlight(); return; } diff --git a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts index 2a2896aac9b..087d91f05fb 100644 --- a/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.agent-signing.integration.test.ts @@ -698,69 +698,68 @@ describe('PerpsController agent signing with a real HyperLiquid provider', () => }); }); - it('releases a binding to an agent the venue rejects even when the host onAgentRejected throws', async () => { - onAgentRejected.mockImplementation(() => { - throw new Error('host callback failed'); - }); - 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], + // 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]], - }); - }); - - it('releases a binding to an agent the venue rejects for a host without onAgentRejected', async () => { - const boundAgent = createAgent(OTHER_AGENT_ADDRESS, OTHER_AGENT_SIGNATURE); - mockVenue.revokedAgents.add(OTHER_AGENT_ADDRESS); - const { controller, call } = createController({ - hyperliquid: { getAgentSigner }, - }); - 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], - ]); - // The host has no onAgentRejected to observe. - expect(getAgentSigner.mock.calls).toStrictEqual([[MAINNET_ACCOUNT]]); - expectQuietHost(call); - }); + }, + { + 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({ 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 8a05287be1e..28539123e4d 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -21,6 +21,7 @@ import { createMockEvmAccount, createMockInfrastructure, createMockMessenger, + keyringCalls, } from '../helpers/serviceMocks.js'; jest.mock('@nktkas/hyperliquid', () => ({})); @@ -899,8 +900,9 @@ describe('PerpsController', () => { const MockLighterConstructor = jest.fn(() => createMockHyperLiquidProvider(), ); + const messenger = createMockMessenger(); controller = new TestablePerpsController({ - messenger: createMockMessenger(), + messenger, state: getDefaultPerpsControllerState(), clientConfig: { providerCredentials: { @@ -917,16 +919,21 @@ describe('PerpsController', () => { ); expect(controller.state.isTestnet).toBe(false); - expect(MockLighterConstructor).toHaveBeenCalledWith( - expect.objectContaining({ - isTestnet: true, - lighterAuthConfig: { - enabled: undefined, - accountIndex: 2, - apiKeyIndex: undefined, + 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', () => { @@ -975,15 +982,23 @@ describe('PerpsController', () => { selectedAccount = createMockEvmAccount(), }: { isUnlocked: boolean; - // An account with an empty address stands for no selection. - selectedAccount?: { address: string } | null; + // 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') { - return selectedAccount ?? undefined; + const account = + typeof selectedAccount === 'function' + ? selectedAccount() + : selectedAccount; + return account ?? undefined; } return undefined; }); @@ -1254,9 +1269,9 @@ describe('PerpsController', () => { expect(result).toStrictEqual(expected); // Only a host without an account signer is asked for its keyring. - expect( - call.mock.calls.filter(([action]) => action.startsWith('Keyring')), - ).toStrictEqual(usesKeyring ? [['KeyringController:getState']] : []); + expect(keyringCalls(call)).toStrictEqual( + usesKeyring ? ['KeyringController:getState'] : [], + ); }, ); @@ -1301,9 +1316,9 @@ describe('PerpsController', () => { [], ]); // Only a host without an account signer is asked for its keyring. - expect( - call.mock.calls.filter(([action]) => action.startsWith('Keyring')), - ).toStrictEqual(usesKeyring ? [['KeyringController:getState']] : []); + expect(keyringCalls(call)).toStrictEqual( + usesKeyring ? ['KeyringController:getState'] : [], + ); }, ); @@ -1342,32 +1357,50 @@ describe('PerpsController', () => { ]); }, ); + 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', - async ({ nextAccount }) => { - let selectedAccount: { address: string } = createMockEvmAccount(); - const call = jest.fn().mockImplementation((action: string) => { - if (action === 'KeyringController:getState') { - return { isUnlocked: true }; - } - return action === 'AccountsController:getSelectedAccount' - ? selectedAccount - : undefined; + '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 { ready: true }; + return providerResult; }); controller = new TestablePerpsController({ messenger: createMockMessenger({ call }), @@ -1385,6 +1418,33 @@ describe('PerpsController', () => { }, ); + 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, @@ -1409,9 +1469,7 @@ describe('PerpsController', () => { ready: false, error: PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, }); - expect( - call.mock.calls.filter(([action]) => action.startsWith('Keyring')), - ).toStrictEqual([]); + expect(keyringCalls(call)).toStrictEqual([]); }); }); 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 7e18814b313..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 @@ -743,12 +743,8 @@ describe('HyperLiquidProvider', () => { attempted: true, success: true, }); - // Keyring is locked - ( - mockWalletService as unknown as { - isMainAccountSignerReady: jest.Mock; - } - ).isMainAccountSignerReady.mockReturnValue(false); + // The main account cannot sign. + mockWalletService.isMainAccountSignerReady.mockReturnValue(false); // Act await testableProvider.ensureReadyForTrading(); 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 index 46244e72ab5..d768f770ef1 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-rejection.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.agent-rejection.test.ts @@ -74,6 +74,20 @@ describe('HyperLiquidProvider with accountSigner: agent rejection', () => { 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, @@ -430,49 +444,46 @@ describe('HyperLiquidProvider with accountSigner: agent rejection', () => { expect(loggerError).not.toHaveBeenCalled(); }); - it('keeps the old protection and fails with KEYRING_LOCKED when its cancel is rejected in a status entry', async () => { - 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 { - status: 'ok', - response: { - data: { - statuses: [ - { error: unknownWalletError(AGENT_ADDRESS).message }, - ], - }, - }, - }; - }); + 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', - }); + 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(); - }); + 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)', @@ -530,18 +541,6 @@ describe('HyperLiquidProvider with accountSigner: agent rejection', () => { 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 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, - }); const { accountSignerProvider, agentSigner, @@ -631,21 +630,10 @@ describe('HyperLiquidProvider with accountSigner: agent rejection', () => { ); 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 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, - }); const { accountSignerProvider, exchangeClient, + getAgentSigner, infoClient, sdkWallet, onAgentRejected, @@ -680,6 +668,16 @@ describe('HyperLiquidProvider with accountSigner: agent rejection', () => { 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( @@ -693,6 +691,11 @@ describe('HyperLiquidProvider with accountSigner: agent rejection', () => { [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(); }); 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 index 828ae474e65..1e19cab9714 100644 --- 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 @@ -31,7 +31,10 @@ import { referralAttempted, setUpAccountSignerSuite, } from '../../helpers/hyperLiquidAccountSignerFixture.js'; -import { createDeferred } from '../../helpers/serviceMocks.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 @@ -646,7 +649,7 @@ describe('HyperLiquidProvider with accountSigner: prepareTradingWallet', () => { expect(loggerError).not.toHaveBeenCalled(); }); - it('sets the referral once a wallet prepared before its first deposit has deposited', async () => { + 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({ @@ -669,9 +672,9 @@ describe('HyperLiquidProvider with accountSigner: prepareTradingWallet', () => { error: PERPS_ERROR_CODES.EXCHANGE_ACCOUNT_NOT_FOUND, }); expect(afterDeposit).toStrictEqual({ ready: true }); - expect(exchangeClient.setReferrer.mock.calls).toStrictEqual([ - REFERRAL_WRITE, - ]); + // Not attempted and not recorded, so the next provider attempts it. + expect(exchangeClient.setReferrer).not.toHaveBeenCalled(); + expect(referralAttempted()).toBe(false); expect(loggerError).not.toHaveBeenCalled(); }); @@ -700,7 +703,7 @@ describe('HyperLiquidProvider with accountSigner: prepareTradingWallet', () => { expect(loggerError).not.toHaveBeenCalled(); }); - it('attempts the referral again when the venue rejects the wallet as unknown despite the probe', async () => { + 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. @@ -709,16 +712,15 @@ describe('HyperLiquidProvider with accountSigner: prepareTradingWallet', () => { ); const rejected = await accountSignerProvider.prepareTradingWallet(); - const referralAfterRejection = referralAttempted(); - const retried = await accountSignerProvider.prepareTradingWallet(); + const preparedAgain = await accountSignerProvider.prepareTradingWallet(); - expect(rejected).toStrictEqual({ ready: false }); - expect(referralAfterRejection).toBe(false); - expect(retried).toStrictEqual({ ready: true }); + // 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, - REFERRAL_WRITE, ]); + expect(referralAttempted()).toBe(false); expect(loggerError).not.toHaveBeenCalled(); }); @@ -751,6 +753,53 @@ describe('HyperLiquidProvider with accountSigner: prepareTradingWallet', () => { 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 }, 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 48e22fb03cb..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 @@ -5025,15 +5025,14 @@ describe('HyperLiquidProvider - strategy order types', () => { */ const useHip3MarketOrder = ( orderResponse: Record, - ): jest.SpyInstance => { + ): { transfer: jest.SpyInstance; order: jest.Mock } => { let ordered = false; + const order = jest.fn(async () => { + ordered = true; + return orderResponse; + }); useStrategyClients({ - exchange: { - order: jest.fn(async () => { - ordered = true; - return orderResponse; - }), - }, + exchange: { order }, info: { clearinghouseState: jest .fn() @@ -5059,7 +5058,7 @@ describe('HyperLiquidProvider - strategy order types', () => { useUnifiedAccount: false, initialAssetMapping: [['xyz:TSLA', 110000]], }); - return jest.spyOn(provider, 'transferBetweenDexs'); + return { transfer: jest.spyOn(provider, 'transferBetweenDexs'), order }; }; const HIP3_MARKET_ORDER = { @@ -5086,6 +5085,23 @@ describe('HyperLiquidProvider - strategy order types', () => { }; 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. @@ -5100,10 +5116,29 @@ describe('HyperLiquidProvider - strategy order types', () => { ]) => [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', @@ -5111,11 +5146,12 @@ describe('HyperLiquidProvider - strategy order types', () => { ['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 }) => { - const transfer = useHip3MarketOrder(REFUSED_ORDER); + async ({ transferError, reported, notes }) => { + const { transfer } = useHip3MarketOrder(REFUSED_ORDER); transfer .mockResolvedValueOnce({ success: true }) .mockResolvedValueOnce({ success: false, error: transferError }); @@ -5128,19 +5164,25 @@ describe('HyperLiquidProvider - strategy order types', () => { [ROLLBACK_TRANSFER], ]); expect(reportedErrors()).toStrictEqual(reported); + expect(unsignedTransferNotes()).toStrictEqual(notes); }, ); it.each([ - { transferError: PERPS_ERROR_CODES.KEYRING_LOCKED, reported: [] }, + { + 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 }) => { - const transfer = useHip3MarketOrder({ + async ({ transferError, reported, notes }) => { + const { transfer } = useHip3MarketOrder({ status: 'ok', response: { data: { @@ -5167,6 +5209,7 @@ describe('HyperLiquidProvider - strategy order types', () => { [REBALANCE_TRANSFER], ]); expect(reportedErrors()).toStrictEqual(reported); + expect(unsignedTransferNotes()).toStrictEqual(notes); }, ); }); 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 index 6b1528a1204..2a595ca94d4 100644 --- a/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts +++ b/packages/perps-controller/tests/src/providers/LighterProvider.account-signer.test.ts @@ -632,11 +632,7 @@ describe('LighterProvider with a KeyringController', () => { ready: false, error: PERPS_ERROR_CODES.KEYRING_LOCKED, }); - expect( - call.mock.calls.filter(([action]: [string]) => - action.startsWith('KeyringController:'), - ), - ).toStrictEqual([['KeyringController:getState']]); + expect(keyringCalls(call)).toStrictEqual(['KeyringController:getState']); expect(calls).toStrictEqual([]); expect(client.getNextNonce).not.toHaveBeenCalled(); expect(client.sendTx).not.toHaveBeenCalled(); 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 index cee084b03c8..5f2aa7642ac 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.account-signer.test.ts @@ -78,8 +78,9 @@ describe('HyperLiquidWalletService with accountSigner', () => { .signTypedData(L1_PAYLOAD); expect(signature).toBe(MAIN_SIGNATURE); - expect(signer.signTypedData).toHaveBeenCalledTimes(1); - expect(signer.signTypedData).toHaveBeenCalledWith(address, L1_PAYLOAD); + expect(signer.signTypedData.mock.calls).toStrictEqual([ + [address, L1_PAYLOAD], + ]); expect(keyringCalls(call)).toStrictEqual([]); }); From 459592184cd43fcc6a32b0be7d16e4ae5c264f20 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 29 Sep 2026 16:49:33 +0800 Subject: [PATCH 30/33] docs(perps-controller): say when the setAgentSigner messenger action is available --- packages/perps-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index f021b0a5698..001d4d4b4f5 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -21,7 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 and they can be set before `init` + - 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` From 110be95a9c33125688edd37861c859990603a6f2 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 29 Sep 2026 17:29:03 +0800 Subject: [PATCH 31/33] docs(perps-controller): explain Lighter API key slots and the different-key error --- packages/perps-controller/README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/perps-controller/README.md b/packages/perps-controller/README.md index 7a50c589e4a..1bf4e25ee94 100644 --- a/packages/perps-controller/README.md +++ b/packages/perps-controller/README.md @@ -89,6 +89,24 @@ write fails with `KEYRING_LOCKED`, the agent is dropped and 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). From 795d09bd23216c6fec66433bd28173ff15b4d5b9 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 29 Sep 2026 21:33:08 +0800 Subject: [PATCH 32/33] test(perps-controller): check the signer surface through the package entrypoint, and document prepareTradingWallet once --- .../src/providers/HyperLiquidProvider.ts | 41 ++++------- .../src/providers/LighterProvider.ts | 18 ++--- packages/perps-controller/src/types/index.ts | 42 ++++------- .../perps-controller/tests/public-api.test.ts | 73 +++++++++++++++++++ 4 files changed, 105 insertions(+), 69 deletions(-) create mode 100644 packages/perps-controller/tests/public-api.test.ts diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 4acf199ef7a..9618267ba05 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -1568,15 +1568,12 @@ export class HyperLiquidProvider implements PerpsProvider { { answer: Promise; agent?: PerpsAgentSigner } >(); - // The account and network each agent address was last resolved to sign an - // L1 action for. One entry per address is enough: an L1 signature covers - // no user, so the venue resolves the account from the agent address, and an - // agent acts for a single account. A rejection is attributed from this - // record rather than from the selected account, which may have changed - // while the write was in flight. Kept across clearAgentSigners, so the - // rejection of an agent that was replaced while its action was in flight is - // still recognized. Keyed by the lowercased address the venue reports; the - // value keeps the agent's own address, as the host supplied it. + // 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 } @@ -14646,24 +14643,14 @@ export class HyperLiquidProvider implements PerpsProvider { } /** - * Run the deferred trading-readiness steps (account migration, builder fee - * and referral setup) ahead of the first order, so their signatures happen - * in one guided session instead of at order time. The builder fee and the - * migration from `dexAbstraction` are signed by the main account; with an - * agent, the referral and the silent migration are L1 actions the agent - * signs. Results are cached, so an already-ready account signs nothing. - * - * @returns `ready: true` when the main-account signer is ready and none of - * these steps will need a signature again before the first order; a step - * the user declined counts, because the order path does not ask again - * either, and so does a referral whose MetaMask referral code is not ready - * yet, which the next call checks again. `ready: false` carries - * `KEYRING_LOCKED` when the signer is not ready, `EXCHANGE_ACCOUNT_NOT_FOUND` - * for a wallet with no HyperLiquid account yet (fund it first), - * `NO_ACCOUNT_SELECTED` or `PROVIDER_LIFECYCLE_STALE` (neither logged), the - * logged error when the steps could not run, and no error when a step will - * retry (a rejected builder fee, a transient failure, or an agent that - * could not sign). + * 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. diff --git a/packages/perps-controller/src/providers/LighterProvider.ts b/packages/perps-controller/src/providers/LighterProvider.ts index 1db7eeda4d2..8dd9b84da62 100644 --- a/packages/perps-controller/src/providers/LighterProvider.ts +++ b/packages/perps-controller/src/providers/LighterProvider.ts @@ -1312,20 +1312,12 @@ export class LighterProvider implements PerpsProvider { /** * Register the venue key ahead of the first order, so its main-account - * `personal_sign` surfaces in a guided session instead of at order time. + * `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 `ready: true` once the venue key is registered, or at once for a - * read-only provider (no signer bridge) while the main-account signer is - * ready and an account is selected: it has nothing to prepare, so it does - * not hold back an aggregated result, and `isReadyToTrade` still reports - * that it cannot trade. Otherwise `ready: false`: with `KEYRING_LOCKED` - * whenever the main-account signer is not ready (even with a registered - * venue key), with `NO_ACCOUNT_SELECTED` when no account is selected, with - * `EXCHANGE_ACCOUNT_NOT_FOUND` when the wallet has no Lighter account yet - * (fund it first), without an error when the user declined the signature - * (the order path asks again), with `PROVIDER_LIFECYCLE_STALE` (unlogged) - * when the provider disconnected or the wallet switched accounts meanwhile, - * and with the logged error when registration failed. + * @returns The readiness result described on + * `PerpsController.prepareTradingWallet`. */ async prepareTradingWallet(): Promise { if (!this.#walletService.isMainAccountSignerReady()) { diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index a43d799b4a3..868c66a8b30 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -1116,20 +1116,15 @@ export type HyperLiquidCredentials = { /** Dedicated subscription waiver builder for mainnet. */ subscriptionBuilderAddressMainnet?: string; /** - * Resolves the agent approved for a main account on a network, or null - * when there is none (for example while the wallet is locked). Called when - * an L1 action (order, cancel, leverage, ...) is signed for that account and - * network, including the unified-account migration the provider may sign - * while connecting. Not called for an account and network bound through - * `PerpsController:setAgentSigner`. An agent it returns is kept for the - * lifetime of the HyperLiquid provider instance, or until - * `setAgentSigner`/`clearAgentSigners`; null is not kept, so it is asked - * again at the next L1 action. With an agent, L1 actions are signed by the - * agent key and user-signed actions (builder fee, withdraw, ...) by the main - * account. If `getAgentSigner` rejects or throws, that action fails and it - * is asked again at the next one. An agent whose `signTypedData` rejects - * fails that action and stays in use, so call - * `PerpsController:clearAgentSigners` when the agent key locks. + * 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, @@ -2160,21 +2155,10 @@ export type PerpsProvider = { initialize(): Promise; isReadyToTrade(): Promise; /** - * Run the deferred trading setup (for example account migration, builder - * fee, referral or venue-key registration) ahead of the first order, so its - * signatures happen in a guided session instead of at order time. User-signed - * steps need the main account; HyperLiquid L1 steps (the referral, the - * silent migration) are signed by an agent when one resolves. Resolves - * `ready: true` when none of these steps will need a signature again before - * the first order (a read-only provider, which never signs, resolves it at - * once while an account is selected and the main-account signer is ready). - * Otherwise `ready: false`, without an error while a step will be retried - * (including after an agent could not sign), or with `KEYRING_LOCKED` when - * the main-account signer cannot sign, `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. Providers - * without such setup omit it. + * 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; /** 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..19504424571 --- /dev/null +++ b/packages/perps-controller/tests/public-api.test.ts @@ -0,0 +1,73 @@ +// 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'; + +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', + ]); + }); +}); From 3e028d469f1423a9ae97cc1bafca29507c3bee88 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 29 Sep 2026 21:36:18 +0800 Subject: [PATCH 33/33] test(perps-controller): mock the SDK in the public API test so it runs on Node 22 --- packages/perps-controller/tests/public-api.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/perps-controller/tests/public-api.test.ts b/packages/perps-controller/tests/public-api.test.ts index 19504424571..944219f21b9 100644 --- a/packages/perps-controller/tests/public-api.test.ts +++ b/packages/perps-controller/tests/public-api.test.ts @@ -15,6 +15,12 @@ import type { 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');