From ca157fb67961e8f0566720b79bd422bb86e405a7 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 18 May 2026 20:38:50 +0800 Subject: [PATCH 1/5] test(perps): migrate controller unit tests to core --- packages/perps-controller/jest.config.js | 16 +- .../tests/helpers/providerMocks.ts | 121 + .../tests/helpers/serviceMocks.ts | 246 ++ .../src/PerpsController.configuration.test.ts | 1594 ++++++++++ .../src/PerpsController.lifecycle.test.ts | 1143 ++++++++ .../src/PerpsController.operations.test.ts | 2143 ++++++++++++++ .../PerpsController.providers-cache.test.ts | 2535 ++++++++++++++++ .../tests/src/PerpsController.state.test.ts | 1138 ++++++++ .../src/PerpsController.subscriptions.test.ts | 851 ++++++ .../tests/src/PerpsController.trading.test.ts | 1114 +++++++ .../SubscriptionMultiplexer.test.ts | 821 ++++++ .../src/constants/hyperLiquidConfig.test.ts | 28 + .../tests/src/constants/myxConfig.test.ts | 142 + .../providers/AggregatedPerpsProvider.test.ts | 869 ++++++ .../HyperLiquidProvider.account-mode.test.ts | 2057 +++++++++++++ .../HyperLiquidProvider.builder-fees.test.ts | 1592 ++++++++++ .../HyperLiquidProvider.data.test.ts | 1220 ++++++++ ...HyperLiquidProvider.error-handling.test.ts | 2477 ++++++++++++++++ .../HyperLiquidProvider.history.test.ts | 1927 +++++++++++++ .../HyperLiquidProvider.lifecycle.test.ts | 694 +++++ .../HyperLiquidProvider.misc.test.ts | 726 +++++ .../HyperLiquidProvider.standalone.test.ts | 1510 ++++++++++ .../HyperLiquidProvider.trading.test.ts | 2034 +++++++++++++ .../HyperLiquidProvider.validation.test.ts | 1295 +++++++++ .../tests/src/providers/MYXProvider.test.ts | 1256 ++++++++ .../tests/src/routing/ProviderRouter.test.ts | 180 ++ .../tests/src/selectors.test.ts | 540 ++++ .../tests/src/services/AccountService.test.ts | 664 +++++ .../src/services/DataLakeService.test.ts | 503 ++++ .../tests/src/services/DepositService.test.ts | 350 +++ .../src/services/EligibilityService.test.ts | 103 + .../FeatureFlagConfigurationService.test.ts | 698 +++++ .../services/HyperLiquidClientService.test.ts | 1918 ++++++++++++ ...perLiquidSubscriptionService.cache.test.ts | 2491 ++++++++++++++++ ...iquidSubscriptionService.lifecycle.test.ts | 852 ++++++ ...uidSubscriptionService.market-data.test.ts | 2562 +++++++++++++++++ ...rLiquidSubscriptionService.streams.test.ts | 1217 ++++++++ .../services/HyperLiquidWalletService.test.ts | 570 ++++ .../src/services/MYXClientService.test.ts | 1079 +++++++ .../src/services/MYXWalletService.test.ts | 484 ++++ .../src/services/MarketDataService.test.ts | 1128 ++++++++ .../RewardsIntegrationService.test.ts | 283 ++ .../services/TradingReadinessCache.test.ts | 776 +++++ .../tests/src/services/TradingService.test.ts | 2413 ++++++++++++++++ .../tests/src/types/hyperliquid-types.test.ts | 34 + .../tests/src/utils/accountUtils.test.ts | 439 +++ .../utils/coalescePerpsRestRequest.test.ts | 180 ++ .../tests/src/utils/errorUtils.test.ts | 102 + .../src/utils/hyperLiquidAbstraction.test.ts | 24 + .../hyperLiquidOrderBookProcessor.test.ts | 648 +++++ .../tests/src/utils/myxAdapter.test.ts | 905 ++++++ .../perpsConnectionAttemptContext.test.ts | 64 + .../tests/src/utils/perpsFormatters.test.ts | 297 ++ .../tests/src/utils/rewardsUtils.test.ts | 103 + .../src/utils/significantFigures.test.ts | 48 + .../tests/src/utils/sortMarkets.test.ts | 87 + .../src/utils/standaloneInfoClient.test.ts | 326 +++ .../tests/src/utils/stringParseUtils.test.ts | 79 + .../tests/src/utils/transferData.test.ts | 26 + 59 files changed, 51713 insertions(+), 9 deletions(-) create mode 100644 packages/perps-controller/tests/helpers/providerMocks.ts create mode 100644 packages/perps-controller/tests/helpers/serviceMocks.ts create mode 100644 packages/perps-controller/tests/src/PerpsController.configuration.test.ts create mode 100644 packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts create mode 100644 packages/perps-controller/tests/src/PerpsController.operations.test.ts create mode 100644 packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts create mode 100644 packages/perps-controller/tests/src/PerpsController.state.test.ts create mode 100644 packages/perps-controller/tests/src/PerpsController.subscriptions.test.ts create mode 100644 packages/perps-controller/tests/src/PerpsController.trading.test.ts create mode 100644 packages/perps-controller/tests/src/aggregation/SubscriptionMultiplexer.test.ts create mode 100644 packages/perps-controller/tests/src/constants/hyperLiquidConfig.test.ts create mode 100644 packages/perps-controller/tests/src/constants/myxConfig.test.ts create mode 100644 packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts create mode 100644 packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts create mode 100644 packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts create mode 100644 packages/perps-controller/tests/src/providers/HyperLiquidProvider.data.test.ts create mode 100644 packages/perps-controller/tests/src/providers/HyperLiquidProvider.error-handling.test.ts create mode 100644 packages/perps-controller/tests/src/providers/HyperLiquidProvider.history.test.ts create mode 100644 packages/perps-controller/tests/src/providers/HyperLiquidProvider.lifecycle.test.ts create mode 100644 packages/perps-controller/tests/src/providers/HyperLiquidProvider.misc.test.ts create mode 100644 packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts create mode 100644 packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts create mode 100644 packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts create mode 100644 packages/perps-controller/tests/src/providers/MYXProvider.test.ts create mode 100644 packages/perps-controller/tests/src/routing/ProviderRouter.test.ts create mode 100644 packages/perps-controller/tests/src/selectors.test.ts create mode 100644 packages/perps-controller/tests/src/services/AccountService.test.ts create mode 100644 packages/perps-controller/tests/src/services/DataLakeService.test.ts create mode 100644 packages/perps-controller/tests/src/services/DepositService.test.ts create mode 100644 packages/perps-controller/tests/src/services/EligibilityService.test.ts create mode 100644 packages/perps-controller/tests/src/services/FeatureFlagConfigurationService.test.ts create mode 100644 packages/perps-controller/tests/src/services/HyperLiquidClientService.test.ts create mode 100644 packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts create mode 100644 packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.lifecycle.test.ts create mode 100644 packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.market-data.test.ts create mode 100644 packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.streams.test.ts create mode 100644 packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts create mode 100644 packages/perps-controller/tests/src/services/MYXClientService.test.ts create mode 100644 packages/perps-controller/tests/src/services/MYXWalletService.test.ts create mode 100644 packages/perps-controller/tests/src/services/MarketDataService.test.ts create mode 100644 packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts create mode 100644 packages/perps-controller/tests/src/services/TradingReadinessCache.test.ts create mode 100644 packages/perps-controller/tests/src/services/TradingService.test.ts create mode 100644 packages/perps-controller/tests/src/types/hyperliquid-types.test.ts create mode 100644 packages/perps-controller/tests/src/utils/accountUtils.test.ts create mode 100644 packages/perps-controller/tests/src/utils/coalescePerpsRestRequest.test.ts create mode 100644 packages/perps-controller/tests/src/utils/errorUtils.test.ts create mode 100644 packages/perps-controller/tests/src/utils/hyperLiquidAbstraction.test.ts create mode 100644 packages/perps-controller/tests/src/utils/hyperLiquidOrderBookProcessor.test.ts create mode 100644 packages/perps-controller/tests/src/utils/myxAdapter.test.ts create mode 100644 packages/perps-controller/tests/src/utils/perpsConnectionAttemptContext.test.ts create mode 100644 packages/perps-controller/tests/src/utils/perpsFormatters.test.ts create mode 100644 packages/perps-controller/tests/src/utils/rewardsUtils.test.ts create mode 100644 packages/perps-controller/tests/src/utils/significantFigures.test.ts create mode 100644 packages/perps-controller/tests/src/utils/sortMarkets.test.ts create mode 100644 packages/perps-controller/tests/src/utils/standaloneInfoClient.test.ts create mode 100644 packages/perps-controller/tests/src/utils/stringParseUtils.test.ts create mode 100644 packages/perps-controller/tests/src/utils/transferData.test.ts diff --git a/packages/perps-controller/jest.config.js b/packages/perps-controller/jest.config.js index cb4e00c4b9..c71f307c09 100644 --- a/packages/perps-controller/jest.config.js +++ b/packages/perps-controller/jest.config.js @@ -18,18 +18,16 @@ module.exports = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 100, - functions: 100, - lines: 100, - statements: 100, + branches: 70, + functions: 78, + lines: 80, + statements: 80, }, }, }), - // Coverage is scoped to the placeholder test file only (not synced source). - // The real source files are synced from Mobile and tested there. - // When tests are migrated from Mobile to Core, restore this to - // the default ('./src/**/*.ts') and raise thresholds accordingly. + // Coverage is collected from real source files. Barrel files are excluded + // because they only re-export the tested modules. // Applied after merge to fully replace (not concat) the base array. - collectCoverageFrom: ['./tests/placeholder.test.ts'], + collectCoverageFrom: ['./src/**/*.ts', '!./src/**/index.ts'], }; diff --git a/packages/perps-controller/tests/helpers/providerMocks.ts b/packages/perps-controller/tests/helpers/providerMocks.ts new file mode 100644 index 0000000000..fddf627059 --- /dev/null +++ b/packages/perps-controller/tests/helpers/providerMocks.ts @@ -0,0 +1,121 @@ +/* eslint-disable */ +/** + * Shared provider mocks for Perps tests + * Provides reusable mock implementations for HyperLiquidProvider and related interfaces + */ +import { type HyperLiquidProvider } from '@metamask/perps-controller'; + +export const createMockHyperLiquidProvider = + (): jest.Mocked => + ({ + protocolId: 'hyperliquid', + initialize: jest.fn(), + isReadyToTrade: jest.fn(), + toggleTestnet: jest.fn(), + getPositions: jest.fn(), + getAccountState: jest.fn(), + getHistoricalPortfolio: jest.fn().mockResolvedValue({ + totalBalance24hAgo: '10000', + totalBalance7dAgo: '9500', + totalBalance30dAgo: '9000', + }), + getMarkets: jest.fn(), + placeOrder: jest.fn(), + editOrder: jest.fn(), + cancelOrder: jest.fn(), + cancelOrders: jest.fn(), + closePosition: jest.fn(), + closePositions: jest.fn(), + withdraw: jest.fn(), + getDepositRoutes: jest.fn(), + getWithdrawalRoutes: jest.fn(), + validateDeposit: jest.fn().mockResolvedValue({ isValid: true }), + validateOrder: jest.fn().mockResolvedValue({ isValid: true }), + validateClosePosition: jest.fn().mockResolvedValue({ isValid: true }), + validateWithdrawal: jest.fn().mockResolvedValue({ isValid: true }), + subscribeToPrices: jest.fn(), + subscribeToPositions: jest.fn(), + subscribeToOrderFills: jest.fn(), + setLiveDataConfig: jest.fn(), + disconnect: jest.fn(), + updatePositionTPSL: jest.fn(), + calculateLiquidationPrice: jest.fn(), + calculateMaintenanceMargin: jest.fn(), + getMaxLeverage: jest.fn(), + calculateFees: jest.fn(), + getMarketDataWithPrices: jest.fn(), + getBlockExplorerUrl: jest.fn(), + getOrderFills: jest.fn(), + getOrders: jest.fn(), + getFunding: jest.fn(), + getCurrentAccountId: jest + .fn() + .mockResolvedValue( + 'eip155:1:0x0000000000000000000000000000000000000001', + ), + getIsFirstTimeUser: jest.fn(), + getOpenOrders: jest.fn(), + subscribeToOrders: jest.fn(), + subscribeToAccount: jest.fn(), + setUserFeeDiscount: jest.fn(), + // WebSocket connection state methods + getWebSocketConnectionState: jest.fn(), + subscribeToConnectionState: jest.fn().mockReturnValue(() => undefined), + reconnect: jest.fn().mockResolvedValue(undefined), + }) as unknown as jest.Mocked; + +export const createMockOrderResult = () => ({ + success: true, + orderId: 'order-123', + filledSize: '0.1', + averagePrice: '50000', +}); + +export const createMockOrderParams = () => ({ + symbol: 'BTC', + side: 'buy', + orderType: 'market', + amount: '0.1', + price: '50000', +}); + +export const createMockOrder = (overrides = {}) => ({ + orderId: 'order-1', + symbol: 'BTC', + side: 'buy' as const, + orderType: 'limit' as const, + size: '0.1', + originalSize: '0.1', + price: '50000', + filledSize: '0', + remainingSize: '0.1', + status: 'open' as const, + timestamp: Date.now(), + ...overrides, +}); + +export const createMockPosition = (overrides = {}) => ({ + symbol: 'BTC', + size: '0.5', + entryPrice: '50000', + positionValue: '25000', + unrealizedPnl: '100', + marginUsed: '1000', + leverage: { type: 'cross' as const, value: 25 }, + liquidationPrice: '48000', + maxLeverage: 50, + returnOnEquity: '10', + cumulativeFunding: { + allTime: '0', + sinceOpen: '0', + sinceChange: '0', + }, + roi: '10', + takeProfitPrice: undefined, + stopLossPrice: undefined, + takeProfitCount: 0, + stopLossCount: 0, + marketPrice: '50200', + timestamp: Date.now(), + ...overrides, +}); diff --git a/packages/perps-controller/tests/helpers/serviceMocks.ts b/packages/perps-controller/tests/helpers/serviceMocks.ts new file mode 100644 index 0000000000..f1b6c7bdf3 --- /dev/null +++ b/packages/perps-controller/tests/helpers/serviceMocks.ts @@ -0,0 +1,246 @@ +/* eslint-disable */ +/** + * Shared service mocks for Perps service tests + * Provides reusable mock implementations for ServiceContext and related types + */ + +import { + type ServiceContext, + type PerpsControllerState, + type InitializationState, + type PerpsControllerMessenger, + type PerpsPlatformDependencies, +} from '@metamask/perps-controller'; + +/** + * Create a mock EVM account (KeyringAccount) + */ +export const createMockEvmAccount = () => ({ + id: '00000000-0000-0000-0000-000000000000', + address: '0x1234567890abcdef1234567890abcdef12345678' as `0x${string}`, + type: 'eip155:eoa' as const, + options: {}, + scopes: ['eip155:1'], + methods: ['eth_signTransaction', 'eth_sign'], + metadata: { + name: 'Test Account', + importTime: Date.now(), + keyring: { type: 'HD Key Tree' }, + }, +}); + +/** + * Create a mock PerpsPlatformDependencies instance. + * Returns a type-safe mock with jest.Mock functions for all methods. + * Uses `as unknown as jest.Mocked` pattern + * to ensure compatibility with both the interface contract and Jest mock APIs. + * + * Architecture: + * - Observability: logger, debugLogger, metrics, performance, tracer (stateless utilities) + * - Platform: streamManager (mobile/extension specific capabilities) + * - Controllers: consolidated access to all external controllers + */ +export const createMockInfrastructure = + (): jest.Mocked => + ({ + // === Observability (stateless utilities) === + logger: { + error: jest.fn(), + }, + debugLogger: { + log: jest.fn(), + }, + metrics: { + trackEvent: jest.fn(), + isEnabled: jest.fn(() => true), + trackPerpsEvent: jest.fn(), + }, + performance: { + now: jest.fn(() => Date.now()), + }, + tracer: { + trace: jest.fn(() => undefined), + endTrace: jest.fn(), + setMeasurement: jest.fn(), + addBreadcrumb: jest.fn(), + }, + + // === Platform Services === + streamManager: { + pauseChannel: jest.fn(), + resumeChannel: jest.fn(), + clearAllChannels: jest.fn(), + }, + + // === Feature Flags (platform-specific version gating) === + featureFlags: { + validateVersionGated: jest.fn().mockReturnValue(undefined), + }, + + // === Market Data Formatting === + marketDataFormatters: { + formatVolume: jest.fn((v: number) => `$${v.toFixed(0)}`), + formatPerpsFiat: jest.fn((v: number) => `$${v.toFixed(2)}`), + formatPercentage: jest.fn((p: number) => `${p.toFixed(2)}%`), + priceRangesUniversal: [], + }, + + // === Cache Invalidation === + cacheInvalidator: { + invalidate: jest.fn(), + invalidateAll: jest.fn(), + }, + + // === Rewards (DI — no RewardsController in Core yet) === + rewards: { + getPerpsDiscountForAccount: jest.fn().mockResolvedValue(0), + }, + + // === Disk Cache (cold-start persistence) === + diskCache: { + getItem: jest.fn().mockResolvedValue(null), + getItemSync: jest.fn().mockReturnValue(null), + setItem: jest.fn().mockResolvedValue(undefined), + removeItem: jest.fn().mockResolvedValue(undefined), + }, + }) as unknown as jest.Mocked; + +/** + * Create a mock PerpsControllerState + */ +export const createMockPerpsControllerState = ( + overrides: Partial = {}, +): PerpsControllerState => ({ + activeProvider: 'hyperliquid', + isTestnet: false, + initializationState: 'initialized' as InitializationState, + initializationError: null, + initializationAttempts: 0, + accountState: null, + perpsBalances: {}, + depositInProgress: false, + lastDepositTransactionId: null, + lastDepositResult: null, + withdrawInProgress: false, + lastWithdrawResult: null, + lastCompletedWithdrawalTimestamp: null, + lastCompletedWithdrawalTxHashes: [], + withdrawalRequests: [], + withdrawalProgress: { + progress: 0, + lastUpdated: 0, + activeWithdrawalId: null, + }, + depositRequests: [], + isEligible: true, + isFirstTimeUser: { + testnet: true, + mainnet: true, + }, + hasPlacedFirstOrder: { + testnet: false, + mainnet: false, + }, + watchlistMarkets: { + testnet: [], + mainnet: [], + }, + tradeConfigurations: { + testnet: {}, + mainnet: {}, + }, + marketFilterPreferences: { + optionId: 'volume', + direction: 'desc', + }, + lastError: null, + lastUpdateTimestamp: Date.now(), + hip3ConfigVersion: 0, + selectedPaymentToken: null, + cachedMarketDataByProvider: {}, + cachedUserDataByProvider: {}, + ...overrides, +}); + +/** + * Create a mock ServiceContext with optional overrides + * Note: infrastructure is no longer part of ServiceContext - it's now injected + * into service instances via constructor. + */ +export const createMockServiceContext = ( + overrides: Partial = {}, +): ServiceContext => ({ + tracingContext: { + provider: 'hyperliquid', + isTestnet: false, + }, + errorContext: { + controller: 'TestService', + method: 'testMethod', + }, + stateManager: { + update: jest.fn(), + getState: jest.fn(() => createMockPerpsControllerState()), + }, + ...overrides, +}); + +/** + * Create a mock PerpsControllerMessenger for testing inter-controller communication. + * The messenger.call() method should be configured in each test to return appropriate values. + * + * Common messenger actions used: + * - 'AccountTreeController:getAccountsFromSelectedAccountGroup' - returns array of accounts + * - 'KeyringController:signTypedMessage' - returns signature string + * - 'NetworkController:getState' - returns { selectedNetworkClientId: string } + * - 'NetworkController:getNetworkClientById' - returns { configuration: { chainId: string } } + * - 'AuthenticationController:getBearerToken' - returns bearer token string + * + * @param overrides - Optional partial messenger to override default behavior + */ +export const createMockMessenger = ( + overrides?: Partial, +): jest.Mocked => { + const mockEvmAccount = createMockEvmAccount(); + const base = { + call: jest.fn().mockImplementation((action: string) => { + // Default implementations for common actions + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [mockEvmAccount]; + } + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } + if (action === 'KeyringController:signTypedMessage') { + return Promise.resolve('0xSignatureResult'); + } + if (action === 'NetworkController:getState') { + return { selectedNetworkClientId: 'mainnet' }; + } + if (action === 'NetworkController:getNetworkClientById') { + return { configuration: { chainId: '0x1' } }; + } + if (action === 'AuthenticationController:getBearerToken') { + return Promise.resolve('mock-bearer-token'); + } + return undefined; + }), + publish: jest.fn(), + subscribe: jest.fn(), + unsubscribe: jest.fn(), + registerActionHandler: jest.fn(), + registerMethodActionHandlers: jest.fn(), + unregisterActionHandler: jest.fn(), + // Additional methods used by PerpsController + registerEventHandler: jest.fn(), + registerInitialEventPayload: jest.fn(), + unregisterEventHandler: jest.fn(), + clearEventSubscriptions: jest.fn(), + }; + return { + ...base, + ...overrides, + } as unknown as jest.Mocked; +}; diff --git a/packages/perps-controller/tests/src/PerpsController.configuration.test.ts b/packages/perps-controller/tests/src/PerpsController.configuration.test.ts new file mode 100644 index 0000000000..347590c7f9 --- /dev/null +++ b/packages/perps-controller/tests/src/PerpsController.configuration.test.ts @@ -0,0 +1,1594 @@ +/* eslint-disable */ +/** + * PerpsController Tests + * Clean, focused test suite for PerpsController + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { + GasFeeEstimateLevel, + GasFeeEstimateType, +} from '@metamask/transaction-controller'; + +import Engine from '../../../core/Engine'; +import { + createMockHyperLiquidProvider, + createMockPosition, +} from '../helpers/providerMocks'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../helpers/serviceMocks'; + +jest.mock('@nktkas/hyperliquid', () => ({})); +jest.mock('@myx-trade/sdk', () => ({ + MyxClient: jest.fn(), + OrderStatusEnum: { Successful: 9 }, +})); + +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '../../src/constants/eventNames'; +import { + PERPS_CONSTANTS, + PERPS_DISK_CACHE_MARKETS, + PERPS_DISK_CACHE_USER_DATA, +} from '../../src/constants/perpsConfig'; +import { + PerpsController, + getDefaultPerpsControllerState, + InitializationState, + firstNonEmpty, + resolveMyxAuthConfig, +} from '../../src/PerpsController'; +import type { PerpsControllerState } from '../../src/PerpsController'; +import { PERPS_ERROR_CODES } from '../../src/perpsErrorCodes'; +import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider'; +import type { + AccountState, + GetAvailableDexsParams, + PerpsProvider, + PerpsPlatformDependencies, + PerpsProviderType, + SubscribeAccountParams, +} from '../../src/types'; +import { PerpsAnalyticsEvent } from '../../src/types'; + +jest.mock('../../src/providers/HyperLiquidProvider'); +jest.mock('../../src/providers/MYXProvider'); + +// Mock transaction controller utility +const mockAddTransaction = jest.fn(); +jest.mock( + '../../../util/transaction-controller', + () => ({ + addTransaction: (...args: unknown[]) => mockAddTransaction(...args), + }), + { virtual: true }, +); + +// Mock wait utility to speed up retry tests +jest.mock('../../src/utils/wait', () => ({ + wait: jest.fn().mockResolvedValue(undefined), +})); + +// Mock stream manager +const mockStreamManager = { + positions: { pause: jest.fn(), resume: jest.fn() }, + account: { pause: jest.fn(), resume: jest.fn() }, + orders: { pause: jest.fn(), resume: jest.fn() }, + prices: { pause: jest.fn(), resume: jest.fn() }, + orderFills: { pause: jest.fn(), resume: jest.fn() }, +}; + +jest.mock( + '../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: jest.fn(() => mockStreamManager), + }), + { virtual: true }, +); + +// Create persistent mock controllers INSIDE jest.mock factory +jest.mock( + '../../../core/Engine', + () => { + const mockRewardsController = { + getPerpsDiscountForAccount: jest.fn(), + }; + + const mockNetworkController = { + getNetworkClientById: jest.fn().mockReturnValue({ + configuration: { chainId: '0x1' }, + }), + }; + + const mockAccountTreeController = { + getAccountsFromSelectedAccountGroup: jest.fn().mockReturnValue([ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]), + }; + + const mockTransactionController = { + estimateGasFee: jest.fn(), + estimateGas: jest.fn(), + }; + + const mockAccountTrackerController = { + state: { + accountsByChainId: {}, + }, + }; + + const mockEngineContext = { + RewardsController: mockRewardsController, + NetworkController: mockNetworkController, + AccountTreeController: mockAccountTreeController, + TransactionController: mockTransactionController, + AccountTrackerController: mockAccountTrackerController, + }; + + // Return as default export to match the actual Engine import + return { + __esModule: true, + default: { + context: mockEngineContext, + }, + }; + }, + { virtual: true }, +); + +jest.mock('@metamask/utils', () => ({ + ...jest.requireActual('@metamask/utils'), + formatAccountToCaipAccountId: jest + .fn() + .mockReturnValue('eip155:1:0x1234567890123456789012345678901234567890'), +})); + +// Mock EligibilityService as a class with instance methods +const mockEligibilityServiceInstance = { + checkEligibility: jest.fn().mockResolvedValue(true), +}; +jest.mock('../../src/services/EligibilityService', () => ({ + EligibilityService: jest + .fn() + .mockImplementation(() => mockEligibilityServiceInstance), +})); + +// Mock DepositService as a class with instance methods +const mockDepositServiceInstance = { + prepareTransaction: jest.fn(), +}; +jest.mock('../../src/services/DepositService', () => ({ + DepositService: jest + .fn() + .mockImplementation(() => mockDepositServiceInstance), +})); + +// Mock MarketDataService as a class with instance methods +const mockMarketDataServiceInstance = { + getPositions: jest.fn(), + getAccountState: jest.fn(), + getMarkets: jest.fn(), + getWithdrawalRoutes: jest.fn().mockReturnValue([]), + validateClosePosition: jest.fn().mockResolvedValue({ isValid: true }), + validateOrder: jest.fn(), + calculateMaintenanceMargin: jest.fn().mockResolvedValue(0), + calculateLiquidationPrice: jest.fn(), + getMaxLeverage: jest.fn(), + calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + getAvailableDexs: jest.fn().mockResolvedValue([]), + getBlockExplorerUrl: jest.fn(), + getOrderFills: jest.fn(), + getOrders: jest.fn(), + getFunding: jest.fn(), +}; +jest.mock('../../src/services/MarketDataService', () => ({ + MarketDataService: jest + .fn() + .mockImplementation(() => mockMarketDataServiceInstance), +})); + +// Mock TradingService as a class with instance methods +const mockTradingServiceInstance = { + placeOrder: jest.fn(), + editOrder: jest.fn(), + cancelOrder: jest.fn(), + cancelOrders: jest.fn(), + closePosition: jest.fn(), + closePositions: jest.fn(), + updatePositionTPSL: jest.fn(), + updateMargin: jest.fn(), + flipPosition: jest.fn(), + setControllerDependencies: jest.fn(), +}; +jest.mock('../../src/services/TradingService', () => ({ + TradingService: jest + .fn() + .mockImplementation(() => mockTradingServiceInstance), +})); + +// Mock AccountService as a class with instance methods +const mockAccountServiceInstance = { + withdraw: jest.fn(), + validateWithdrawal: jest.fn(), +}; +jest.mock('../../src/services/AccountService', () => ({ + AccountService: jest + .fn() + .mockImplementation(() => mockAccountServiceInstance), +})); + +// Mock DataLakeService as a class with instance methods +const mockDataLakeServiceInstance = { + reportOrder: jest.fn(), +}; +jest.mock('../../src/services/DataLakeService', () => ({ + DataLakeService: jest + .fn() + .mockImplementation(() => mockDataLakeServiceInstance), +})); + +// Mock FeatureFlagConfigurationService as a class with instance methods +const mockFeatureFlagConfigurationServiceInstance = { + refreshEligibility: jest.fn((options: any) => { + // Simulate the service's behavior: extract blocked regions from remote flags + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + // Never downgrade from remote to fallback + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList(remoteBlockedRegions, 'remote'); + } + } + + // Call refreshEligibility callback if available + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + // Also call refreshHip3Config if available + if (remoteFlags) { + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config(options); + } + }), + refreshHip3Config: jest.fn(), + setBlockedRegions: jest.fn((options: any) => { + // Simulate setBlockedRegions behavior + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + // Never downgrade from remote to fallback + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + // Call refreshEligibility callback if available + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }), +}; +jest.mock('../../src/services/FeatureFlagConfigurationService', () => ({ + FeatureFlagConfigurationService: jest + .fn() + .mockImplementation(() => mockFeatureFlagConfigurationServiceInstance), +})); + +/** + * Testable version of PerpsController that exposes protected methods for testing. + * This follows the pattern used in RewardsController.test.ts + */ +class TestablePerpsController extends PerpsController { + /** + * Test-only method to update state directly. + * Exposed for scenarios where state needs to be manipulated + * outside the normal public API (e.g., testing error conditions). + * @param callback + */ + public testUpdate(callback: (state: PerpsControllerState) => void) { + this.update(callback); + } + + /** + * Test-only method to mark controller as initialized. + * Common test scenario that requires internal state changes. + */ + public testMarkInitialized() { + this.isInitialized = true; + this.update((state) => { + state.initializationState = InitializationState.Initialized; + }); + } + + /** + * Test-only method to set the providers map with complete providers. + * Used in most tests to inject mock providers. + * Also sets activeProviderInstance to the first provider (default provider). + * @param providers + */ + public testSetProviders(providers: Map) { + this.providers = providers; + // Set activeProviderInstance to the first provider (typically 'hyperliquid') + const firstProvider = providers.values().next().value; + if (firstProvider) { + this.activeProviderInstance = firstProvider; + } + } + + /** + * Test-only method to set the providers map with partial providers. + * Used explicitly in tests that verify error handling with incomplete providers. + * Type cast is intentional and necessary for testing graceful degradation. + * @param providers + */ + public testSetPartialProviders( + providers: Map>, + ) { + this.providers = providers as Map; + } + + /** + * Test-only method to get the providers map. + * Used to verify provider state in tests. + */ + public testGetProviders(): Map { + return this.providers; + } + + /** + * Test-only method to set initialization state. + * Allows tests to simulate both initialized and uninitialized states. + * @param value + */ + public testSetInitialized(value: boolean) { + this.isInitialized = value; + } + + /** + * Test-only method to get initialization state. + * Used to verify initialization status in tests. + */ + public testGetInitialized(): boolean { + return this.isInitialized; + } + + /** + * Test-only method to get blocked region list. + * Used to verify geo-blocking configuration in tests. + */ + public testGetBlockedRegionList(): { source: string; list: string[] } { + return this.blockedRegionList; + } + + /** + * Test-only method to set blocked region list. + * Used to test priority logic (remote vs fallback). + * @param list + * @param source + */ + public testSetBlockedRegionList( + list: string[], + source: 'remote' | 'fallback', + ) { + this.setBlockedRegionList(list, source); + } + + /** + * Test accessor for protected method refreshEligibilityOnFeatureFlagChange. + * Wrapper is necessary because protected methods can't be called from test code. + * @param remoteFlags + */ + public testRefreshEligibilityOnFeatureFlagChange(remoteFlags: any) { + this.refreshEligibilityOnFeatureFlagChange(remoteFlags); + } + + /** + * Test accessor for protected method reportOrderToDataLake. + * Wrapper is necessary because protected methods can't be called from test code. + * @param data + */ + public testReportOrderToDataLake(data: any): Promise { + return this.reportOrderToDataLake(data); + } + + public testHasStandaloneProvider(): boolean { + return this.hasStandaloneProvider(); + } + + public testRegisterMYXProvider( + MYXProvider: new (opts: Record) => PerpsProvider, + ) { + this.registerMYXProvider(MYXProvider as never); + } + + public testHandleMYXImportError(error: unknown) { + this.handleMYXImportError(error); + } +} + +describe('PerpsController', () => { + let controller: TestablePerpsController; + let mockProvider: jest.Mocked; + let mockInfrastructure: jest.Mocked; + + // Helper to mark controller as initialized for tests + const markControllerAsInitialized = () => { + controller.testMarkInitialized(); + }; + + beforeEach(() => { + jest.clearAllMocks(); + + ( + jest.requireMock('../../src/services/EligibilityService') + .EligibilityService as jest.Mock + ).mockImplementation(() => mockEligibilityServiceInstance); + ( + jest.requireMock('../../src/services/DepositService') + .DepositService as jest.Mock + ).mockImplementation(() => mockDepositServiceInstance); + ( + jest.requireMock('../../src/services/MarketDataService') + .MarketDataService as jest.Mock + ).mockImplementation(() => mockMarketDataServiceInstance); + ( + jest.requireMock('../../src/services/TradingService') + .TradingService as jest.Mock + ).mockImplementation(() => mockTradingServiceInstance); + ( + jest.requireMock('../../src/services/AccountService') + .AccountService as jest.Mock + ).mockImplementation(() => mockAccountServiceInstance); + ( + jest.requireMock('../../src/services/DataLakeService') + .DataLakeService as jest.Mock + ).mockImplementation(() => mockDataLakeServiceInstance); + ( + jest.requireMock('../../src/services/FeatureFlagConfigurationService') + .FeatureFlagConfigurationService as jest.Mock + ).mockImplementation(() => mockFeatureFlagConfigurationServiceInstance); + + mockEligibilityServiceInstance.checkEligibility.mockResolvedValue(true); + mockMarketDataServiceInstance.getPositions.mockResolvedValue([]); + mockMarketDataServiceInstance.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockMarketDataServiceInstance.getMarkets.mockResolvedValue([]); + mockMarketDataServiceInstance.getWithdrawalRoutes.mockReturnValue([]); + mockMarketDataServiceInstance.validateClosePosition.mockResolvedValue({ + isValid: true, + }); + mockMarketDataServiceInstance.calculateMaintenanceMargin.mockResolvedValue( + 0, + ); + mockMarketDataServiceInstance.calculateFees.mockResolvedValue({ + totalFee: 0, + }); + mockMarketDataServiceInstance.getAvailableDexs.mockResolvedValue([]); + + mockFeatureFlagConfigurationServiceInstance.refreshEligibility.mockImplementation( + (options: any) => { + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList( + remoteBlockedRegions, + 'remote', + ); + } + } + + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + if (remoteFlags) { + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config( + options, + ); + } + }, + ); + mockFeatureFlagConfigurationServiceInstance.setBlockedRegions.mockImplementation( + (options: any) => { + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }, + ); + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config.mockImplementation( + () => undefined, + ); + + // Reset Engine.context mocks to default state to prevent test interdependence + ( + Engine.context.RewardsController.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(null); + ( + Engine.context.NetworkController.getNetworkClientById as jest.Mock + ).mockReturnValue({ configuration: { chainId: '0x1' } }); + + // Create a fresh mock provider for each test + mockProvider = createMockHyperLiquidProvider(); + + // Add default mock return values for all provider methods + mockProvider.getPositions.mockResolvedValue([]); + mockProvider.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockProvider.getMarkets.mockResolvedValue([]); + mockProvider.getOpenOrders.mockResolvedValue([]); + mockProvider.getFunding.mockResolvedValue([]); + mockProvider.getOrderFills.mockResolvedValue([]); + mockProvider.getOrders.mockResolvedValue([]); + mockProvider.calculateLiquidationPrice.mockResolvedValue('0'); + mockProvider.getMaxLeverage.mockResolvedValue(50); + mockProvider.calculateMaintenanceMargin.mockResolvedValue(0); + mockProvider.calculateFees.mockResolvedValue({ feeAmount: 0 }); + mockProvider.getBlockExplorerUrl.mockReturnValue( + 'https://explorer.example.com', + ); + mockProvider.getWithdrawalRoutes.mockReturnValue([]); + + ( + HyperLiquidProvider as jest.MockedClass + ).mockImplementation(() => mockProvider); + + const mockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: [], + }, + }, + }; + } + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + id: 'account-1', + options: {}, + scopes: ['eip155:1'], + methods: [], + metadata: { + name: 'Test', + importTime: 0, + keyring: { type: 'HD Key Tree' }, + }, + }, + ]; + } + return undefined; + }); + + mockInfrastructure = createMockInfrastructure(); + controller = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: mockInfrastructure, + }); + }); + + afterEach(() => { + // Clear only provider mocks, not Engine.context mocks + // This prevents breaking Engine.context.RewardsController/NetworkController references + if (mockProvider) { + Object.values(mockProvider).forEach((value) => { + if ( + typeof value === 'object' && + value !== null && + 'mockClear' in value + ) { + (value as jest.Mock).mockClear(); + } + }); + } + (mockInfrastructure.metrics.trackPerpsEvent as jest.Mock).mockClear(); + (mockInfrastructure.logger.error as jest.Mock).mockClear(); + (mockInfrastructure.debugLogger.log as jest.Mock).mockClear(); + }); + describe('toggleTestnet', () => { + it('returns error when already reinitializing', async () => { + await controller.init(); + jest.spyOn(controller, 'isCurrentlyReinitializing').mockReturnValue(true); + + const result = await controller.toggleTestnet(); + + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.CLIENT_REINITIALIZING); + expect(result.isTestnet).toBe(false); + }); + + it('toggles to testnet network', async () => { + await controller.init(); + const initialTestnetState = controller.state.isTestnet; + + const result = await controller.toggleTestnet(); + + expect(result.success).toBe(true); + expect(result.isTestnet).toBe(!initialTestnetState); + expect(controller.state.isTestnet).toBe(!initialTestnetState); + }); + + it('returns failure and rolls back isTestnet when init sets InitializationState.Failed', async () => { + await controller.init(); + const initialTestnetState = controller.state.isTestnet; + + // Make init set state to Failed (mimics performInitialization catching an error) + jest.spyOn(controller, 'init').mockImplementationOnce(async () => { + controller.testUpdate((state) => { + state.initializationState = InitializationState.Failed; + state.initializationError = 'Network toggle init failed'; + }); + }); + + const result = await controller.toggleTestnet(); + + expect(result.success).toBe(false); + expect(result.error).toBe('Network toggle init failed'); + // isTestnet should be rolled back to its original value + expect(result.isTestnet).toBe(initialTestnetState); + expect(controller.state.isTestnet).toBe(initialTestnetState); + + jest.restoreAllMocks(); + }); + + it('clears isReinitializing flag after init failure', async () => { + await controller.init(); + + jest.spyOn(controller, 'init').mockImplementationOnce(async () => { + controller.testUpdate((state) => { + state.initializationState = InitializationState.Failed; + }); + }); + + await controller.toggleTestnet(); + + expect(controller.isCurrentlyReinitializing()).toBe(false); + + jest.restoreAllMocks(); + }); + }); + + describe('market filter preferences', () => { + it('saves and retrieves filter preference', () => { + controller.saveMarketFilterPreferences('openInterest', 'desc'); + + const result = controller.getMarketFilterPreferences(); + + expect(result).toEqual({ + optionId: 'openInterest', + direction: 'desc', + }); + }); + + it('saves and retrieves price change with ascending direction', () => { + controller.saveMarketFilterPreferences('priceChange', 'asc'); + + const result = controller.getMarketFilterPreferences(); + + expect(result).toEqual({ + optionId: 'priceChange', + direction: 'asc', + }); + }); + }); + + describe('watchlist management', () => { + it('adds and removes market from watchlist', async () => { + await controller.init(); + + controller.toggleWatchlistMarket('BTC'); + + expect(controller.isWatchlistMarket('BTC')).toBe(true); + expect(controller.getWatchlistMarkets()).toContain('BTC'); + + controller.toggleWatchlistMarket('BTC'); + + expect(controller.isWatchlistMarket('BTC')).toBe(false); + }); + }); + + describe('resetFirstTimeUserState', () => { + it('resets tutorial and order state for both networks', () => { + controller.markTutorialCompleted(); + controller.markFirstOrderCompleted(); + + controller.resetFirstTimeUserState(); + + expect(controller.state.isFirstTimeUser.testnet).toBe(true); + expect(controller.state.isFirstTimeUser.mainnet).toBe(true); + expect(controller.state.hasPlacedFirstOrder.testnet).toBe(false); + expect(controller.state.hasPlacedFirstOrder.mainnet).toBe(false); + }); + }); + + describe('clearPendingTransactionRequests', () => { + it('removes pending and bridging withdrawal requests', () => { + // Arrange: Add withdrawal requests with different statuses + controller.testUpdate((state) => { + state.withdrawalRequests = [ + { + id: 'withdrawal-1', + amount: '100', + asset: 'USDC', + accountAddress: '0x123', + timestamp: Date.now(), + success: false, + status: 'pending', + }, + { + id: 'withdrawal-2', + amount: '200', + asset: 'USDC', + accountAddress: '0x123', + timestamp: Date.now(), + success: false, + status: 'bridging', + }, + { + id: 'withdrawal-3', + amount: '300', + asset: 'USDC', + accountAddress: '0x123', + timestamp: Date.now(), + success: true, + status: 'completed', + txHash: '0xabc', + }, + { + id: 'withdrawal-4', + amount: '50', + asset: 'USDC', + accountAddress: '0x123', + timestamp: Date.now(), + success: false, + status: 'failed', + }, + ]; + }); + + controller.clearPendingTransactionRequests(); + + expect(controller.state.withdrawalRequests).toHaveLength(2); + expect(controller.state.withdrawalRequests.map((w) => w.id)).toEqual([ + 'withdrawal-3', + 'withdrawal-4', + ]); + }); + + it('removes pending and bridging deposit requests', () => { + // Arrange: Add deposit requests with different statuses + controller.testUpdate((state) => { + state.depositRequests = [ + { + id: 'deposit-1', + amount: '100', + asset: 'USDC', + accountAddress: '0x123', + timestamp: Date.now(), + success: false, + status: 'pending', + }, + { + id: 'deposit-2', + amount: '200', + asset: 'USDC', + accountAddress: '0x123', + timestamp: Date.now(), + success: false, + status: 'bridging', + }, + { + id: 'deposit-3', + amount: '300', + asset: 'USDC', + accountAddress: '0x123', + timestamp: Date.now(), + success: true, + status: 'completed', + txHash: '0xdef', + }, + ]; + }); + + controller.clearPendingTransactionRequests(); + + expect(controller.state.depositRequests).toHaveLength(1); + expect(controller.state.depositRequests[0].id).toBe('deposit-3'); + }); + + it('resets withdrawal progress', () => { + // Arrange: Set some withdrawal progress + controller.testUpdate((state) => { + state.withdrawalProgress = { + progress: 50, + lastUpdated: Date.now() - 10000, + activeWithdrawalId: 'withdrawal-1', + }; + }); + + controller.clearPendingTransactionRequests(); + + expect(controller.state.withdrawalProgress.progress).toBe(0); + expect(controller.state.withdrawalProgress.activeWithdrawalId).toBeNull(); + }); + + it('handles empty arrays gracefully', () => { + // Arrange: Ensure arrays are empty + controller.testUpdate((state) => { + state.withdrawalRequests = []; + state.depositRequests = []; + }); + + controller.clearPendingTransactionRequests(); + + expect(controller.state.withdrawalRequests).toHaveLength(0); + expect(controller.state.depositRequests).toHaveLength(0); + }); + }); + + describe('trade configuration', () => { + it('returns undefined for unsaved configuration', () => { + const result = controller.getTradeConfiguration('ETH'); + + expect(result).toBeUndefined(); + }); + + it('retrieves saved configuration', () => { + controller.saveTradeConfiguration('BTC', 10); + + const result = controller.getTradeConfiguration('BTC'); + + expect(result?.leverage).toBe(10); + }); + }); + + describe('pending trade configuration', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('saves pending trade configuration', () => { + const config = { + amount: '100', + leverage: 5, + takeProfitPrice: '50000', + stopLossPrice: '40000', + limitPrice: '45000', + orderType: 'limit' as const, + }; + + controller.savePendingTradeConfiguration('BTC', config); + + const result = controller.getPendingTradeConfiguration('BTC'); + expect(result).toEqual(config); + }); + + it('returns undefined for non-existent pending configuration', () => { + const result = controller.getPendingTradeConfiguration('ETH'); + + expect(result).toBeUndefined(); + }); + + it('returns undefined for expired pending configuration (more than 5 minutes)', () => { + const config = { + amount: '100', + leverage: 5, + }; + + controller.savePendingTradeConfiguration('BTC', config); + + // Fast-forward 6 minutes (more than 5 minutes) + jest.advanceTimersByTime(6 * 60 * 1000); + + const result = controller.getPendingTradeConfiguration('BTC'); + + expect(result).toBeUndefined(); + }); + + it('returns configuration for valid pending configuration (less than 5 minutes)', () => { + const config = { + amount: '100', + leverage: 5, + takeProfitPrice: '50000', + orderType: 'market' as const, + }; + + controller.savePendingTradeConfiguration('BTC', config); + + // Fast-forward 4 minutes (less than 5 minutes) + jest.advanceTimersByTime(4 * 60 * 1000); + + const result = controller.getPendingTradeConfiguration('BTC'); + + expect(result).toEqual(config); + }); + + it('clears expired pending configuration automatically', () => { + const config = { + amount: '100', + leverage: 5, + }; + + controller.savePendingTradeConfiguration('BTC', config); + + // Fast-forward 6 minutes + jest.advanceTimersByTime(6 * 60 * 1000); + + // First call should clear expired config + controller.getPendingTradeConfiguration('BTC'); + + // Second call should return undefined + const result = controller.getPendingTradeConfiguration('BTC'); + expect(result).toBeUndefined(); + + // Verify state was cleaned up + const network = controller.state.isTestnet ? 'testnet' : 'mainnet'; + expect( + controller.state.tradeConfigurations[network]?.BTC?.pendingConfig, + ).toBeUndefined(); + }); + + it('clears pending trade configuration explicitly', () => { + const config = { + amount: '100', + leverage: 5, + }; + + controller.savePendingTradeConfiguration('BTC', config); + expect(controller.getPendingTradeConfiguration('BTC')).toEqual(config); + + controller.clearPendingTradeConfiguration('BTC'); + + const result = controller.getPendingTradeConfiguration('BTC'); + expect(result).toBeUndefined(); + }); + + it('saves pending config per network (testnet vs mainnet)', () => { + const configMainnet = { + amount: '100', + leverage: 5, + }; + const configTestnet = { + amount: '200', + leverage: 10, + }; + + // Save on mainnet (default is mainnet) + controller.savePendingTradeConfiguration('BTC', configMainnet); + expect(controller.getPendingTradeConfiguration('BTC')).toEqual( + configMainnet, + ); + + // Switch to testnet using update method + controller.testUpdate((state) => { + state.isTestnet = true; + }); + controller.savePendingTradeConfiguration('BTC', configTestnet); + expect(controller.getPendingTradeConfiguration('BTC')).toEqual( + configTestnet, + ); + + // Switch back to mainnet + controller.testUpdate((state) => { + state.isTestnet = false; + }); + expect(controller.getPendingTradeConfiguration('BTC')).toEqual( + configMainnet, + ); + }); + + it('preserves existing leverage when saving pending config', () => { + // First save leverage + controller.saveTradeConfiguration('BTC', 10); + + // Then save pending config + const pendingConfig = { + amount: '100', + leverage: 5, + }; + controller.savePendingTradeConfiguration('BTC', pendingConfig); + + // Leverage should still be saved + const savedConfig = controller.getTradeConfiguration('BTC'); + expect(savedConfig?.leverage).toBe(10); + + // Pending config should also be available + const pending = controller.getPendingTradeConfiguration('BTC'); + expect(pending).toEqual(pendingConfig); + }); + }); + + describe('WebSocket connection state', () => { + // Import actual enum to ensure type compatibility + const { WebSocketConnectionState } = jest.requireActual('../../src/types'); + + it('getWebSocketConnectionState returns state from active provider', () => { + // Arrange + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + markControllerAsInitialized(); + mockProvider.getWebSocketConnectionState.mockReturnValue( + WebSocketConnectionState.Connected, + ); + + // Act + const result = controller.getWebSocketConnectionState(); + + // Assert + expect(result).toBe(WebSocketConnectionState.Connected); + expect(mockProvider.getWebSocketConnectionState).toHaveBeenCalled(); + }); + + it('getWebSocketConnectionState returns DISCONNECTED when provider does not support method', () => { + // Arrange + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + markControllerAsInitialized(); + // Remove the method to simulate provider without support + mockProvider.getWebSocketConnectionState = undefined as never; + + // Act + const result = controller.getWebSocketConnectionState(); + + // Assert + expect(result).toBe(WebSocketConnectionState.Disconnected); + }); + + it('getWebSocketConnectionState returns DISCONNECTED when no provider is active', () => { + // Arrange - don't set up any provider + + // Act + const result = controller.getWebSocketConnectionState(); + + // Assert + expect(result).toBe(WebSocketConnectionState.Disconnected); + }); + + it('subscribeToConnectionState delegates to active provider', () => { + // Arrange + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + markControllerAsInitialized(); + const mockUnsubscribe = jest.fn(); + mockProvider.subscribeToConnectionState.mockReturnValue(mockUnsubscribe); + const listener = jest.fn(); + + // Act + const unsubscribe = controller.subscribeToConnectionState(listener); + + // Assert + expect(mockProvider.subscribeToConnectionState).toHaveBeenCalledWith( + listener, + ); + expect(unsubscribe).toBe(mockUnsubscribe); + }); + + it('subscribeToConnectionState calls listener immediately when provider does not support method', () => { + // Arrange + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + markControllerAsInitialized(); + // Keep getWebSocketConnectionState but remove subscribeToConnectionState + mockProvider.getWebSocketConnectionState.mockReturnValue( + WebSocketConnectionState.Disconnected, + ); + mockProvider.subscribeToConnectionState = undefined as never; + const listener = jest.fn(); + + // Act + const unsubscribe = controller.subscribeToConnectionState(listener); + + // Assert - listener is called with result of getWebSocketConnectionState() + expect(listener).toHaveBeenCalledWith( + WebSocketConnectionState.Disconnected, + 0, + ); + expect(typeof unsubscribe).toBe('function'); + }); + + it('subscribeToConnectionState returns no-op when no provider is active', () => { + // Arrange - don't set up any provider + const listener = jest.fn(); + + // Act + const unsubscribe = controller.subscribeToConnectionState(listener); + + // Assert + expect(listener).toHaveBeenCalledWith( + WebSocketConnectionState.Disconnected, + 0, + ); + expect(typeof unsubscribe).toBe('function'); + // Verify unsubscribe doesn't throw + expect(() => unsubscribe()).not.toThrow(); + }); + + it('reconnect delegates to active provider', async () => { + // Arrange + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + markControllerAsInitialized(); + mockProvider.reconnect.mockResolvedValue(undefined); + + // Act + await controller.reconnect(); + + // Assert + expect(mockProvider.reconnect).toHaveBeenCalled(); + }); + + it('reconnect does nothing when provider does not support method', async () => { + // Arrange + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + markControllerAsInitialized(); + // Remove the method to simulate provider without support + mockProvider.reconnect = undefined as never; + + // Act & Assert - should not throw + await expect(controller.reconnect()).resolves.toBeUndefined(); + }); + + it('reconnect does nothing when no provider is active', async () => { + // Arrange - don't set up any provider + + // Act & Assert - should not throw + await expect(controller.reconnect()).resolves.toBeUndefined(); + }); + }); + + describe('order book grouping', () => { + it('saves order book grouping for mainnet', () => { + controller.testUpdate((state) => { + state.isTestnet = false; + }); + + controller.saveOrderBookGrouping('BTC', 10); + + const result = controller.getOrderBookGrouping('BTC'); + expect(result).toBe(10); + }); + + it('saves order book grouping for testnet', () => { + controller.testUpdate((state) => { + state.isTestnet = true; + }); + + controller.saveOrderBookGrouping('ETH', 0.01); + + const result = controller.getOrderBookGrouping('ETH'); + expect(result).toBe(0.01); + }); + + it('returns undefined when no grouping is saved', () => { + const result = controller.getOrderBookGrouping('SOL'); + expect(result).toBeUndefined(); + }); + + it('preserves existing config when saving grouping', () => { + controller.testUpdate((state) => { + state.isTestnet = false; + }); + + // First save leverage + controller.saveTradeConfiguration('BTC', 5); + + // Then save grouping + controller.saveOrderBookGrouping('BTC', 100); + + // Both should be preserved + const savedConfig = controller.getTradeConfiguration('BTC'); + expect(savedConfig?.leverage).toBe(5); + + const savedGrouping = controller.getOrderBookGrouping('BTC'); + expect(savedGrouping).toBe(100); + }); + }); + + describe('standalone mode', () => { + const mockUserAddress = '0xabcdef1234567890abcdef1234567890abcdef12'; + const MockedHyperLiquidProvider = HyperLiquidProvider as jest.MockedClass< + typeof HyperLiquidProvider + >; + + beforeEach(() => { + // Reset mocks before each test + MockedHyperLiquidProvider.mockClear(); + }); + + describe('getPositions with standalone mode', () => { + it('uses existing provider for standalone queries when available', async () => { + // Arrange - set up mock provider with properly typed positions + const mockPositions = [ + createMockPosition({ symbol: 'BTC', size: '0.5' }), + ]; + const existingMockProvider = createMockHyperLiquidProvider(); + existingMockProvider.getPositions.mockResolvedValue(mockPositions); + controller.testSetProviders( + new Map([['hyperliquid', existingMockProvider]]), + ); + controller.testMarkInitialized(); + controller.testUpdate((state) => { + state.activeProvider = 'hyperliquid'; + }); + + // Act + const positions = await controller.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert - should use existing provider + expect(existingMockProvider.getPositions).toHaveBeenCalledWith({ + standalone: true, + userAddress: mockUserAddress, + }); + expect(positions).toEqual(mockPositions); + // Should NOT create a new HyperLiquidProvider instance + expect(MockedHyperLiquidProvider).not.toHaveBeenCalled(); + }); + + it('creates temporary provider for standalone queries when no activeProviderInstance', async () => { + // Arrange - no activeProviderInstance set (pre-initialization) + const mockPositions = [ + createMockPosition({ symbol: 'ETH', size: '2.0' }), + ]; + const tempMockProvider = createMockHyperLiquidProvider(); + tempMockProvider.getPositions.mockResolvedValue(mockPositions); + MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); + + controller.testUpdate((state) => { + state.activeProvider = 'aggregated'; + state.isTestnet = false; + }); + + // Act + const positions = await controller.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert - should create a temporary provider for pre-init discovery + expect(MockedHyperLiquidProvider).toHaveBeenCalledWith( + expect.objectContaining({ + isTestnet: false, + }), + ); + expect(positions).toEqual(mockPositions); + }); + + it('bypasses getActiveProvider check for standalone queries', async () => { + // Arrange - controller not initialized (no provider available via normal path) + const mockPositions = [ + createMockPosition({ symbol: 'BTC', size: '1.0' }), + ]; + const tempMockProvider = createMockHyperLiquidProvider(); + tempMockProvider.getPositions.mockResolvedValue(mockPositions); + MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); + + controller.testUpdate((state) => { + state.initializationState = InitializationState.Initializing; + state.activeProvider = 'aggregated'; + }); + + // Act - should NOT throw despite controller not being initialized + const positions = await controller.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert + expect(positions).toEqual(mockPositions); + }); + }); + + describe('getAccountState with standalone mode', () => { + // Complete AccountState mock with all required fields + const createMockAccountState = (overrides = {}) => ({ + totalBalance: '50000', + spendableBalance: '45000', + withdrawableBalance: '45000', + marginUsed: '5000', + unrealizedPnl: '1000', + returnOnEquity: '20', + ...overrides, + }); + + it('uses existing provider for standalone queries when available', async () => { + // Arrange + const mockAccountState = createMockAccountState(); + const existingMockProvider = createMockHyperLiquidProvider(); + existingMockProvider.getAccountState.mockResolvedValue( + mockAccountState, + ); + controller.testSetProviders( + new Map([['hyperliquid', existingMockProvider]]), + ); + controller.testMarkInitialized(); + controller.testUpdate((state) => { + state.activeProvider = 'hyperliquid'; + }); + + // Act + const accountState = await controller.getAccountState({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert - should use existing provider + expect(existingMockProvider.getAccountState).toHaveBeenCalledWith({ + standalone: true, + userAddress: mockUserAddress, + }); + expect(accountState).toEqual(mockAccountState); + expect(MockedHyperLiquidProvider).not.toHaveBeenCalled(); + }); + + it('creates temporary provider for standalone queries when no activeProviderInstance', async () => { + // Arrange - no activeProviderInstance set (pre-initialization) + const mockAccountState = createMockAccountState({ + totalBalance: '25000', + spendableBalance: '20000', + withdrawableBalance: '20000', + }); + const tempMockProvider = createMockHyperLiquidProvider(); + tempMockProvider.getAccountState.mockResolvedValue(mockAccountState); + MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); + + controller.testUpdate((state) => { + state.activeProvider = 'aggregated'; + state.isTestnet = true; + }); + + // Act + const accountState = await controller.getAccountState({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert - should create a temporary provider for pre-init discovery + expect(MockedHyperLiquidProvider).toHaveBeenCalledWith( + expect.objectContaining({ + isTestnet: true, + }), + ); + expect(accountState).toEqual(mockAccountState); + }); + + it('bypasses getActiveProvider check for standalone queries', async () => { + // Arrange - controller not initialized (no provider available via normal path) + const mockAccountState = createMockAccountState({ + totalBalance: '10000', + }); + const tempMockProvider = createMockHyperLiquidProvider(); + tempMockProvider.getAccountState.mockResolvedValue(mockAccountState); + MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); + + controller.testUpdate((state) => { + state.initializationState = InitializationState.Initializing; + state.activeProvider = 'aggregated'; + }); + + // Act - should NOT throw despite controller not being initialized + const accountState = await controller.getAccountState({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert + expect(accountState).toEqual(mockAccountState); + }); + }); + + describe('standalone provider caching', () => { + it('reuses the same standalone provider across multiple calls', async () => { + const tempMockProvider = createMockHyperLiquidProvider(); + tempMockProvider.getPositions.mockResolvedValue([]); + tempMockProvider.getOpenOrders.mockResolvedValue([]); + MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); + + // Two standalone calls — should only create one provider + await controller.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + await controller.getOpenOrders({ + standalone: true, + userAddress: mockUserAddress, + }); + + expect(MockedHyperLiquidProvider).toHaveBeenCalledTimes(1); + expect(controller.testHasStandaloneProvider()).toBe(true); + }); + + it('cleans up standalone provider on init()', async () => { + const tempMockProvider = createMockHyperLiquidProvider(); + tempMockProvider.getPositions.mockResolvedValue([]); + MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); + + // Create a cached standalone provider + await controller.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + expect(controller.testHasStandaloneProvider()).toBe(true); + + // init() should clean it up + await controller.init(); + + expect(controller.testHasStandaloneProvider()).toBe(false); + expect(tempMockProvider.disconnect).toHaveBeenCalled(); + }); + + it('invalidates cached provider when isTestnet changes', async () => { + const firstProvider = createMockHyperLiquidProvider(); + firstProvider.getPositions.mockResolvedValue([]); + const secondProvider = createMockHyperLiquidProvider(); + secondProvider.getPositions.mockResolvedValue([]); + MockedHyperLiquidProvider.mockImplementationOnce( + () => firstProvider, + ).mockImplementationOnce(() => secondProvider); + + // First standalone call on mainnet + await controller.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + expect(MockedHyperLiquidProvider).toHaveBeenCalledTimes(1); + + // Toggle testnet flag (simulates config change) + controller.testUpdate((state) => { + state.isTestnet = true; + }); + + // Second standalone call — should create a new provider + await controller.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + expect(MockedHyperLiquidProvider).toHaveBeenCalledTimes(2); + // Old provider should have been disconnected + expect(firstProvider.disconnect).toHaveBeenCalled(); + }); + + it('cleans up standalone provider on disconnect()', async () => { + const tempMockProvider = createMockHyperLiquidProvider(); + tempMockProvider.getMarketDataWithPrices.mockResolvedValue([]); + MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); + + await controller.getMarketDataWithPrices({ standalone: true }); + expect(controller.testHasStandaloneProvider()).toBe(true); + + await controller.disconnect(); + + expect(controller.testHasStandaloneProvider()).toBe(false); + expect(tempMockProvider.disconnect).toHaveBeenCalled(); + }); + + it('cleans up standalone provider on stopMarketDataPreload()', async () => { + const tempMockProvider = createMockHyperLiquidProvider(); + tempMockProvider.getMarkets.mockResolvedValue([]); + MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); + + await controller.getMarkets({ standalone: true }); + expect(controller.testHasStandaloneProvider()).toBe(true); + + controller.stopMarketDataPreload(); + + // Fire-and-forget — give microtask a tick to resolve + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(controller.testHasStandaloneProvider()).toBe(false); + expect(tempMockProvider.disconnect).toHaveBeenCalled(); + }); + }); + }); + + describe('setSelectedPaymentToken', () => { + it('sets selectedPaymentToken to null when passed null', () => { + controller.testUpdate((state) => { + state.selectedPaymentToken = { + description: 'USDC', + address: '0xa0b8', + chainId: '0x1', + } as PerpsControllerState['selectedPaymentToken']; + }); + + controller.setSelectedPaymentToken(null); + + expect(controller.state.selectedPaymentToken).toBeNull(); + }); + + it('sets selectedPaymentToken to null when token has PerpsBalanceTokenDescription', () => { + controller.setSelectedPaymentToken({ + description: 'perps-balance', + address: '0x0', + chainId: '0x1', + } as Parameters[0]); + + expect(controller.state.selectedPaymentToken).toBeNull(); + }); + + it('stores description, address and chainId when passed a normal token', () => { + const token = { + description: 'USDC', + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as const, + chainId: '0x1' as const, + }; + + controller.setSelectedPaymentToken( + token as Parameters[0], + ); + + expect(controller.state.selectedPaymentToken).toMatchObject({ + description: 'USDC', + address: token.address, + chainId: token.chainId, + }); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts b/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts new file mode 100644 index 0000000000..eaf03f6860 --- /dev/null +++ b/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts @@ -0,0 +1,1143 @@ +/* eslint-disable */ +/** + * PerpsController Tests + * Clean, focused test suite for PerpsController + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { + GasFeeEstimateLevel, + GasFeeEstimateType, +} from '@metamask/transaction-controller'; + +import Engine from '../../../core/Engine'; +import { + createMockHyperLiquidProvider, + createMockPosition, +} from '../helpers/providerMocks'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../helpers/serviceMocks'; + +jest.mock('@nktkas/hyperliquid', () => ({})); +jest.mock('@myx-trade/sdk', () => ({ + MyxClient: jest.fn(), + OrderStatusEnum: { Successful: 9 }, +})); + +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '../../src/constants/eventNames'; +import { + PERPS_CONSTANTS, + PERPS_DISK_CACHE_MARKETS, + PERPS_DISK_CACHE_USER_DATA, +} from '../../src/constants/perpsConfig'; +import { + PerpsController, + getDefaultPerpsControllerState, + InitializationState, + firstNonEmpty, + resolveMyxAuthConfig, +} from '../../src/PerpsController'; +import type { PerpsControllerState } from '../../src/PerpsController'; +import { PERPS_ERROR_CODES } from '../../src/perpsErrorCodes'; +import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider'; +import type { + AccountState, + GetAvailableDexsParams, + PerpsProvider, + PerpsPlatformDependencies, + PerpsProviderType, + SubscribeAccountParams, +} from '../../src/types'; +import { PerpsAnalyticsEvent } from '../../src/types'; + +jest.mock('../../src/providers/HyperLiquidProvider'); +jest.mock('../../src/providers/MYXProvider'); + +// Mock transaction controller utility +const mockAddTransaction = jest.fn(); +jest.mock( + '../../../util/transaction-controller', + () => ({ + addTransaction: (...args: unknown[]) => mockAddTransaction(...args), + }), + { virtual: true }, +); + +// Mock wait utility to speed up retry tests +jest.mock('../../src/utils/wait', () => ({ + wait: jest.fn().mockResolvedValue(undefined), +})); + +// Mock stream manager +const mockStreamManager = { + positions: { pause: jest.fn(), resume: jest.fn() }, + account: { pause: jest.fn(), resume: jest.fn() }, + orders: { pause: jest.fn(), resume: jest.fn() }, + prices: { pause: jest.fn(), resume: jest.fn() }, + orderFills: { pause: jest.fn(), resume: jest.fn() }, +}; + +jest.mock( + '../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: jest.fn(() => mockStreamManager), + }), + { virtual: true }, +); + +// Create persistent mock controllers INSIDE jest.mock factory +jest.mock( + '../../../core/Engine', + () => { + const mockRewardsController = { + getPerpsDiscountForAccount: jest.fn(), + }; + + const mockNetworkController = { + getNetworkClientById: jest.fn().mockReturnValue({ + configuration: { chainId: '0x1' }, + }), + }; + + const mockAccountTreeController = { + getAccountsFromSelectedAccountGroup: jest.fn().mockReturnValue([ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]), + }; + + const mockTransactionController = { + estimateGasFee: jest.fn(), + estimateGas: jest.fn(), + }; + + const mockAccountTrackerController = { + state: { + accountsByChainId: {}, + }, + }; + + const mockEngineContext = { + RewardsController: mockRewardsController, + NetworkController: mockNetworkController, + AccountTreeController: mockAccountTreeController, + TransactionController: mockTransactionController, + AccountTrackerController: mockAccountTrackerController, + }; + + // Return as default export to match the actual Engine import + return { + __esModule: true, + default: { + context: mockEngineContext, + }, + }; + }, + { virtual: true }, +); + +jest.mock('@metamask/utils', () => ({ + ...jest.requireActual('@metamask/utils'), + formatAccountToCaipAccountId: jest + .fn() + .mockReturnValue('eip155:1:0x1234567890123456789012345678901234567890'), +})); + +// Mock EligibilityService as a class with instance methods +const mockEligibilityServiceInstance = { + checkEligibility: jest.fn().mockResolvedValue(true), +}; +jest.mock('../../src/services/EligibilityService', () => ({ + EligibilityService: jest + .fn() + .mockImplementation(() => mockEligibilityServiceInstance), +})); + +// Mock DepositService as a class with instance methods +const mockDepositServiceInstance = { + prepareTransaction: jest.fn(), +}; +jest.mock('../../src/services/DepositService', () => ({ + DepositService: jest + .fn() + .mockImplementation(() => mockDepositServiceInstance), +})); + +// Mock MarketDataService as a class with instance methods +const mockMarketDataServiceInstance = { + getPositions: jest.fn(), + getAccountState: jest.fn(), + getMarkets: jest.fn(), + getWithdrawalRoutes: jest.fn().mockReturnValue([]), + validateClosePosition: jest.fn().mockResolvedValue({ isValid: true }), + validateOrder: jest.fn(), + calculateMaintenanceMargin: jest.fn().mockResolvedValue(0), + calculateLiquidationPrice: jest.fn(), + getMaxLeverage: jest.fn(), + calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + getAvailableDexs: jest.fn().mockResolvedValue([]), + getBlockExplorerUrl: jest.fn(), + getOrderFills: jest.fn(), + getOrders: jest.fn(), + getFunding: jest.fn(), +}; +jest.mock('../../src/services/MarketDataService', () => ({ + MarketDataService: jest + .fn() + .mockImplementation(() => mockMarketDataServiceInstance), +})); + +// Mock TradingService as a class with instance methods +const mockTradingServiceInstance = { + placeOrder: jest.fn(), + editOrder: jest.fn(), + cancelOrder: jest.fn(), + cancelOrders: jest.fn(), + closePosition: jest.fn(), + closePositions: jest.fn(), + updatePositionTPSL: jest.fn(), + updateMargin: jest.fn(), + flipPosition: jest.fn(), + setControllerDependencies: jest.fn(), +}; +jest.mock('../../src/services/TradingService', () => ({ + TradingService: jest + .fn() + .mockImplementation(() => mockTradingServiceInstance), +})); + +// Mock AccountService as a class with instance methods +const mockAccountServiceInstance = { + withdraw: jest.fn(), + validateWithdrawal: jest.fn(), +}; +jest.mock('../../src/services/AccountService', () => ({ + AccountService: jest + .fn() + .mockImplementation(() => mockAccountServiceInstance), +})); + +// Mock DataLakeService as a class with instance methods +const mockDataLakeServiceInstance = { + reportOrder: jest.fn(), +}; +jest.mock('../../src/services/DataLakeService', () => ({ + DataLakeService: jest + .fn() + .mockImplementation(() => mockDataLakeServiceInstance), +})); + +// Mock FeatureFlagConfigurationService as a class with instance methods +const mockFeatureFlagConfigurationServiceInstance = { + refreshEligibility: jest.fn((options: any) => { + // Simulate the service's behavior: extract blocked regions from remote flags + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + // Never downgrade from remote to fallback + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList(remoteBlockedRegions, 'remote'); + } + } + + // Call refreshEligibility callback if available + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + // Also call refreshHip3Config if available + if (remoteFlags) { + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config(options); + } + }), + refreshHip3Config: jest.fn(), + setBlockedRegions: jest.fn((options: any) => { + // Simulate setBlockedRegions behavior + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + // Never downgrade from remote to fallback + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + // Call refreshEligibility callback if available + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }), +}; +jest.mock('../../src/services/FeatureFlagConfigurationService', () => ({ + FeatureFlagConfigurationService: jest + .fn() + .mockImplementation(() => mockFeatureFlagConfigurationServiceInstance), +})); + +/** + * Testable version of PerpsController that exposes protected methods for testing. + * This follows the pattern used in RewardsController.test.ts + */ +class TestablePerpsController extends PerpsController { + /** + * Test-only method to update state directly. + * Exposed for scenarios where state needs to be manipulated + * outside the normal public API (e.g., testing error conditions). + * @param callback + */ + public testUpdate(callback: (state: PerpsControllerState) => void) { + this.update(callback); + } + + /** + * Test-only method to mark controller as initialized. + * Common test scenario that requires internal state changes. + */ + public testMarkInitialized() { + this.isInitialized = true; + this.update((state) => { + state.initializationState = InitializationState.Initialized; + }); + } + + /** + * Test-only method to set the providers map with complete providers. + * Used in most tests to inject mock providers. + * Also sets activeProviderInstance to the first provider (default provider). + * @param providers + */ + public testSetProviders(providers: Map) { + this.providers = providers; + // Set activeProviderInstance to the first provider (typically 'hyperliquid') + const firstProvider = providers.values().next().value; + if (firstProvider) { + this.activeProviderInstance = firstProvider; + } + } + + /** + * Test-only method to set the providers map with partial providers. + * Used explicitly in tests that verify error handling with incomplete providers. + * Type cast is intentional and necessary for testing graceful degradation. + * @param providers + */ + public testSetPartialProviders( + providers: Map>, + ) { + this.providers = providers as Map; + } + + /** + * Test-only method to get the providers map. + * Used to verify provider state in tests. + */ + public testGetProviders(): Map { + return this.providers; + } + + /** + * Test-only method to set initialization state. + * Allows tests to simulate both initialized and uninitialized states. + * @param value + */ + public testSetInitialized(value: boolean) { + this.isInitialized = value; + } + + /** + * Test-only method to get initialization state. + * Used to verify initialization status in tests. + */ + public testGetInitialized(): boolean { + return this.isInitialized; + } + + /** + * Test-only method to get blocked region list. + * Used to verify geo-blocking configuration in tests. + */ + public testGetBlockedRegionList(): { source: string; list: string[] } { + return this.blockedRegionList; + } + + /** + * Test-only method to set blocked region list. + * Used to test priority logic (remote vs fallback). + * @param list + * @param source + */ + public testSetBlockedRegionList( + list: string[], + source: 'remote' | 'fallback', + ) { + this.setBlockedRegionList(list, source); + } + + /** + * Test accessor for protected method refreshEligibilityOnFeatureFlagChange. + * Wrapper is necessary because protected methods can't be called from test code. + * @param remoteFlags + */ + public testRefreshEligibilityOnFeatureFlagChange(remoteFlags: any) { + this.refreshEligibilityOnFeatureFlagChange(remoteFlags); + } + + /** + * Test accessor for protected method reportOrderToDataLake. + * Wrapper is necessary because protected methods can't be called from test code. + * @param data + */ + public testReportOrderToDataLake(data: any): Promise { + return this.reportOrderToDataLake(data); + } + + public testHasStandaloneProvider(): boolean { + return this.hasStandaloneProvider(); + } + + public testRegisterMYXProvider( + MYXProvider: new (opts: Record) => PerpsProvider, + ) { + this.registerMYXProvider(MYXProvider as never); + } + + public testHandleMYXImportError(error: unknown) { + this.handleMYXImportError(error); + } +} + +describe('PerpsController', () => { + let controller: TestablePerpsController; + let mockProvider: jest.Mocked; + let mockInfrastructure: jest.Mocked; + + // Helper to mark controller as initialized for tests + const markControllerAsInitialized = () => { + controller.testMarkInitialized(); + }; + + beforeEach(() => { + jest.clearAllMocks(); + + ( + jest.requireMock('../../src/services/EligibilityService') + .EligibilityService as jest.Mock + ).mockImplementation(() => mockEligibilityServiceInstance); + ( + jest.requireMock('../../src/services/DepositService') + .DepositService as jest.Mock + ).mockImplementation(() => mockDepositServiceInstance); + ( + jest.requireMock('../../src/services/MarketDataService') + .MarketDataService as jest.Mock + ).mockImplementation(() => mockMarketDataServiceInstance); + ( + jest.requireMock('../../src/services/TradingService') + .TradingService as jest.Mock + ).mockImplementation(() => mockTradingServiceInstance); + ( + jest.requireMock('../../src/services/AccountService') + .AccountService as jest.Mock + ).mockImplementation(() => mockAccountServiceInstance); + ( + jest.requireMock('../../src/services/DataLakeService') + .DataLakeService as jest.Mock + ).mockImplementation(() => mockDataLakeServiceInstance); + ( + jest.requireMock('../../src/services/FeatureFlagConfigurationService') + .FeatureFlagConfigurationService as jest.Mock + ).mockImplementation(() => mockFeatureFlagConfigurationServiceInstance); + + mockEligibilityServiceInstance.checkEligibility.mockResolvedValue(true); + mockMarketDataServiceInstance.getPositions.mockResolvedValue([]); + mockMarketDataServiceInstance.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockMarketDataServiceInstance.getMarkets.mockResolvedValue([]); + mockMarketDataServiceInstance.getWithdrawalRoutes.mockReturnValue([]); + mockMarketDataServiceInstance.validateClosePosition.mockResolvedValue({ + isValid: true, + }); + mockMarketDataServiceInstance.calculateMaintenanceMargin.mockResolvedValue( + 0, + ); + mockMarketDataServiceInstance.calculateFees.mockResolvedValue({ + totalFee: 0, + }); + mockMarketDataServiceInstance.getAvailableDexs.mockResolvedValue([]); + + mockFeatureFlagConfigurationServiceInstance.refreshEligibility.mockImplementation( + (options: any) => { + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList( + remoteBlockedRegions, + 'remote', + ); + } + } + + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + if (remoteFlags) { + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config( + options, + ); + } + }, + ); + mockFeatureFlagConfigurationServiceInstance.setBlockedRegions.mockImplementation( + (options: any) => { + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }, + ); + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config.mockImplementation( + () => undefined, + ); + + // Reset Engine.context mocks to default state to prevent test interdependence + ( + Engine.context.RewardsController.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(null); + ( + Engine.context.NetworkController.getNetworkClientById as jest.Mock + ).mockReturnValue({ configuration: { chainId: '0x1' } }); + + // Create a fresh mock provider for each test + mockProvider = createMockHyperLiquidProvider(); + + // Add default mock return values for all provider methods + mockProvider.getPositions.mockResolvedValue([]); + mockProvider.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockProvider.getMarkets.mockResolvedValue([]); + mockProvider.getOpenOrders.mockResolvedValue([]); + mockProvider.getFunding.mockResolvedValue([]); + mockProvider.getOrderFills.mockResolvedValue([]); + mockProvider.getOrders.mockResolvedValue([]); + mockProvider.calculateLiquidationPrice.mockResolvedValue('0'); + mockProvider.getMaxLeverage.mockResolvedValue(50); + mockProvider.calculateMaintenanceMargin.mockResolvedValue(0); + mockProvider.calculateFees.mockResolvedValue({ feeAmount: 0 }); + mockProvider.getBlockExplorerUrl.mockReturnValue( + 'https://explorer.example.com', + ); + mockProvider.getWithdrawalRoutes.mockReturnValue([]); + + ( + HyperLiquidProvider as jest.MockedClass + ).mockImplementation(() => mockProvider); + + const mockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: [], + }, + }, + }; + } + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + id: 'account-1', + options: {}, + scopes: ['eip155:1'], + methods: [], + metadata: { + name: 'Test', + importTime: 0, + keyring: { type: 'HD Key Tree' }, + }, + }, + ]; + } + return undefined; + }); + + mockInfrastructure = createMockInfrastructure(); + controller = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: mockInfrastructure, + }); + }); + + afterEach(() => { + // Clear only provider mocks, not Engine.context mocks + // This prevents breaking Engine.context.RewardsController/NetworkController references + if (mockProvider) { + Object.values(mockProvider).forEach((value) => { + if ( + typeof value === 'object' && + value !== null && + 'mockClear' in value + ) { + (value as jest.Mock).mockClear(); + } + }); + } + (mockInfrastructure.metrics.trackPerpsEvent as jest.Mock).mockClear(); + (mockInfrastructure.logger.error as jest.Mock).mockClear(); + (mockInfrastructure.debugLogger.log as jest.Mock).mockClear(); + }); + describe('constructor', () => { + it('initializes with default state', () => { + // Constructor no longer auto-starts initialization (moved to Engine.ts) + expect(controller.state.activeProvider).toBe('hyperliquid'); + expect(controller.state.accountState).toBeNull(); + expect(controller.state.initializationState).toBe('uninitialized'); // Waits for explicit initialization + expect(controller.state.initializationError).toBeNull(); + expect(controller.state.initializationAttempts).toBe(0); // Not started yet + // isEligible is initially false, but refreshEligibility is called during construction + // which updates it to true (defaulting to eligible when geo-location is unknown) + expect(controller.state.isEligible).toBe(true); + expect(controller.state.isTestnet).toBe(false); // Default to mainnet + }); + + it('reads current RemoteFeatureFlagController state during construction', () => { + // Given: A messenger that returns remote feature flags state + const testMockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: ['US', 'CA'], + }, + }, + }; + } + return undefined; + }); + const testMessenger = createMockMessenger({ call: testMockCall }); + + // When: Controller is constructed + const testController = new TestablePerpsController({ + messenger: testMessenger, + state: getDefaultPerpsControllerState(), + infrastructure: createMockInfrastructure(), + }); + + // Then: Should have called to get RemoteFeatureFlagController state via messenger + expect(testController).toBeDefined(); + expect(testMockCall).toHaveBeenCalledWith( + 'RemoteFeatureFlagController:getState', + ); + }); + + it('applies remote blocked regions when available during construction', () => { + // Given: Messenger that returns remote feature flags with blocked regions + const testMockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: ['US-NY', 'CA-ON'], + }, + }, + }; + } + return undefined; + }); + + // When: Controller is constructed + const testController = new TestablePerpsController({ + messenger: createMockMessenger({ call: testMockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: createMockInfrastructure(), + clientConfig: { + fallbackBlockedRegions: ['FALLBACK-REGION'], + }, + }); + + // Then: Should have used remote regions (not fallback) + // Verify by checking the internal blockedRegionList + const blockedRegionList = testController.testGetBlockedRegionList(); + expect(blockedRegionList.source).toBe('remote'); + expect(blockedRegionList.list).toEqual(['US-NY', 'CA-ON']); + }); + + it('uses fallback regions when remote flags are not available', () => { + // Given: Remote feature flags without blocked regions + const mockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: {}, + }; + } + return undefined; + }); + + // When: Controller is constructed with fallback regions + const testController = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: createMockInfrastructure(), + clientConfig: { + fallbackBlockedRegions: ['FALLBACK-US', 'FALLBACK-CA'], + }, + }); + + // Then: Should have used fallback regions + const blockedRegionList = testController.testGetBlockedRegionList(); + expect(blockedRegionList.source).toBe('fallback'); + expect(blockedRegionList.list).toEqual(['FALLBACK-US', 'FALLBACK-CA']); + }); + + it('never downgrade from remote to fallback regions', () => { + // Given: Messenger that returns remote feature flags with blocked regions + const testMockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: ['REMOTE-US'], + }, + }, + }; + } + return undefined; + }); + + // When: Controller is constructed with both remote and fallback + const testController = new TestablePerpsController({ + messenger: createMockMessenger({ call: testMockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: createMockInfrastructure(), + clientConfig: { + fallbackBlockedRegions: ['FALLBACK-US'], + }, + }); + + // Then: Should use remote (set after fallback) + let blockedRegionList = testController.testGetBlockedRegionList(); + expect(blockedRegionList.source).toBe('remote'); + expect(blockedRegionList.list).toEqual(['REMOTE-US']); + + // When: Attempt to set fallback again (simulating what setBlockedRegionList does) + testController.testSetBlockedRegionList(['NEW-FALLBACK'], 'fallback'); + + // Then: Should still use remote (no downgrade) + blockedRegionList = testController.testGetBlockedRegionList(); + expect(blockedRegionList.source).toBe('remote'); + expect(blockedRegionList.list).toEqual(['REMOTE-US']); + }); + + it('continues initialization when RemoteFeatureFlagController state call throws error', () => { + const testInfrastructure = createMockInfrastructure(); + const testMockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + throw new Error('RemoteFeatureFlagController not ready'); + } + return undefined; + }); + + const testController = new TestablePerpsController({ + messenger: createMockMessenger({ call: testMockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: testInfrastructure, + clientConfig: { + fallbackBlockedRegions: ['FALLBACK-US', 'FALLBACK-CA'], + }, + }); + + expect(testController).toBeDefined(); + const blockedRegionList = testController.testGetBlockedRegionList(); + expect(blockedRegionList.source).toBe('fallback'); + expect(blockedRegionList.list).toEqual(['FALLBACK-US', 'FALLBACK-CA']); + expect(testInfrastructure.logger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: expect.objectContaining({ + feature: 'perps', + }), + context: expect.objectContaining({ + name: 'PerpsController', + data: expect.objectContaining({ + method: 'constructor', + operation: 'readRemoteFeatureFlags', + }), + }), + }), + ); + }); + }); + + describe('deferEligibilityCheck', () => { + it('skips refreshEligibility when eligibility check is deferred', async () => { + // Arrange + const testMockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + if (action === 'GeolocationController:getGeolocation') { + return 'US'; + } + return undefined; + }); + + const deferredController = new TestablePerpsController({ + messenger: createMockMessenger({ call: testMockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: createMockInfrastructure(), + deferEligibilityCheck: true, + }); + + // Act + await deferredController.refreshEligibility(); + + // Assert — geolocation was never called because refreshEligibility returned early + expect(testMockCall).not.toHaveBeenCalledWith( + 'GeolocationController:getGeolocation', + ); + }); + + it('resumes eligibility checks after startEligibilityMonitoring is called', () => { + // Arrange + const testMockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: ['US'], + }, + }, + }; + } + return undefined; + }); + + const deferredController = new TestablePerpsController({ + messenger: createMockMessenger({ call: testMockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: createMockInfrastructure(), + deferEligibilityCheck: true, + }); + + // Reset mocks after construction to isolate startEligibilityMonitoring behavior + testMockCall.mockClear(); + mockFeatureFlagConfigurationServiceInstance.refreshEligibility.mockClear(); + + // Re-wire the mock so it still returns flags when called again + testMockCall.mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: ['US'], + }, + }, + }; + } + return undefined; + }); + + // Act + deferredController.startEligibilityMonitoring(); + + // Assert — startEligibilityMonitoring itself reads remote flags and triggers eligibility + expect(testMockCall).toHaveBeenCalledWith( + 'RemoteFeatureFlagController:getState', + ); + expect( + mockFeatureFlagConfigurationServiceInstance.refreshEligibility, + ).toHaveBeenCalled(); + }); + + it('logs error when RemoteFeatureFlagController throws during startEligibilityMonitoring', () => { + // Arrange + const testInfrastructure = createMockInfrastructure(); + const testMockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + throw new Error('Controller not ready'); + } + return undefined; + }); + + const deferredController = new TestablePerpsController({ + messenger: createMockMessenger({ call: testMockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: testInfrastructure, + deferEligibilityCheck: true, + }); + + // Reset mock to isolate startEligibilityMonitoring errors from constructor errors + (testInfrastructure.logger.error as jest.Mock).mockClear(); + + // Act — should not throw + expect(() => + deferredController.startEligibilityMonitoring(), + ).not.toThrow(); + + // Assert — error was logged + expect(testInfrastructure.logger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + context: expect.objectContaining({ + data: expect.objectContaining({ + method: 'startEligibilityMonitoring', + operation: 'readRemoteFeatureFlags', + }), + }), + }), + ); + }); + + it('stopEligibilityMonitoring defers subsequent refreshEligibility calls', async () => { + // Arrange — controller without deferral + const testMockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + if (action === 'GeolocationController:getGeolocation') { + return 'US'; + } + return undefined; + }); + + const testController = new TestablePerpsController({ + messenger: createMockMessenger({ call: testMockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: createMockInfrastructure(), + }); + testMockCall.mockClear(); + + // Act + testController.stopEligibilityMonitoring(); + await testController.refreshEligibility(); + + // Assert — geolocation was never called + expect(testMockCall).not.toHaveBeenCalledWith( + 'GeolocationController:getGeolocation', + ); + }); + }); + + describe('HIP-3 Configuration Integration', () => { + it('delegates HIP-3 config updates to FeatureFlagConfigurationService', () => { + const remoteFlags = { + remoteFeatureFlags: { + perpsHip3AllowlistMarkets: 'BTC-USD,ETH-USD', + perpsHip3BlocklistMarkets: 'SCAM-USD', + }, + }; + + controller.testRefreshEligibilityOnFeatureFlagChange(remoteFlags); + + expect( + mockFeatureFlagConfigurationServiceInstance.refreshEligibility, + ).toHaveBeenCalledWith({ + remoteFeatureFlagControllerState: remoteFlags, + context: expect.objectContaining({ + getHip3Config: expect.any(Function), + setHip3Config: expect.any(Function), + incrementHip3ConfigVersion: expect.any(Function), + }), + }); + }); + + it('does not crash on malformed remote flags', () => { + const malformedFlags = { + remoteFeatureFlags: { + perpsHip3AllowlistMarkets: 123, + }, + }; + + expect(() => + controller.testRefreshEligibilityOnFeatureFlagChange(malformedFlags), + ).not.toThrow(); + }); + }); + + describe('getActiveProvider', () => { + it('throws error when not initialized', () => { + controller.testSetInitialized(false); + + expect(() => controller.getActiveProvider()).toThrow( + 'CLIENT_NOT_INITIALIZED', + ); + }); + + it('returns provider when initialized', () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + const provider = controller.getActiveProvider(); + expect(provider).toBe(mockProvider); + }); + }); + + describe('getActiveProviderOrNull', () => { + it('returns null during reinitialization', () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest.spyOn(controller, 'isCurrentlyReinitializing').mockReturnValue(true); + + const result = controller.getActiveProviderOrNull(); + + expect(result).toBeNull(); + }); + + it('returns null when not initialized', () => { + controller.testSetInitialized(false); + + const result = controller.getActiveProviderOrNull(); + + expect(result).toBeNull(); + }); + + it('returns provider when initialized and not reinitializing', () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + const result = controller.getActiveProviderOrNull(); + + expect(result).toBe(mockProvider); + }); + }); + + describe('init', () => { + it('initializes providers successfully', async () => { + await controller.init(); + + expect(controller.testGetInitialized()).toBe(true); + expect(controller.testGetProviders().has('hyperliquid')).toBe(true); + }); + + it('handles initialization when already initialized', async () => { + // First initialization + await controller.init(); + expect(controller.testGetInitialized()).toBe(true); + + // Second initialization should not throw + await controller.init(); + expect(controller.testGetInitialized()).toBe(true); + }); + + it('allows retry after all initialization attempts fail', async () => { + // Set up mock to throw errors BEFORE creating controller + const networkError = new Error('Network error'); + ( + HyperLiquidProvider as jest.MockedClass + ).mockImplementation(() => { + throw networkError; + }); + + const mockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: [], + }, + }, + }; + } + return undefined; + }); + + const testController = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: createMockInfrastructure(), + }); + + // Explicitly start initialization (no longer auto-starts in constructor) + testController.init().catch(() => { + // Expected to fail - error is stored in state + }); + + // Wait for initialization to complete (retries happen instantly due to mocked wait()) + // Small delay allows async promise chain to resolve + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Verify failure state + expect(testController.state.initializationState).toBe('failed'); + expect(testController.state.initializationError).toBe('Network error'); + expect(testController.testGetInitialized()).toBe(false); + + // Network recovers - provider succeeds on next attempt + ( + HyperLiquidProvider as jest.MockedClass + ).mockImplementation(() => mockProvider); + + // User retries initialization (e.g., via network switch) + await testController.init(); + + // Verify initialization succeeds (not cached failure) + expect(testController.state.initializationState).toBe('initialized'); + expect(testController.state.initializationError).toBeNull(); + expect(testController.testGetInitialized()).toBe(true); + }); // Fast execution with mocked wait() + }); +}); diff --git a/packages/perps-controller/tests/src/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts new file mode 100644 index 0000000000..830676e39c --- /dev/null +++ b/packages/perps-controller/tests/src/PerpsController.operations.test.ts @@ -0,0 +1,2143 @@ +/* eslint-disable */ +/** + * PerpsController Tests + * Clean, focused test suite for PerpsController + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { + GasFeeEstimateLevel, + GasFeeEstimateType, +} from '@metamask/transaction-controller'; + +import Engine from '../../../core/Engine'; +import { + createMockHyperLiquidProvider, + createMockPosition, +} from '../helpers/providerMocks'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../helpers/serviceMocks'; + +jest.mock('@nktkas/hyperliquid', () => ({})); +jest.mock('@myx-trade/sdk', () => ({ + MyxClient: jest.fn(), + OrderStatusEnum: { Successful: 9 }, +})); + +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '../../src/constants/eventNames'; +import { + PERPS_CONSTANTS, + PERPS_DISK_CACHE_MARKETS, + PERPS_DISK_CACHE_USER_DATA, +} from '../../src/constants/perpsConfig'; +import { + PerpsController, + getDefaultPerpsControllerState, + InitializationState, + firstNonEmpty, + resolveMyxAuthConfig, +} from '../../src/PerpsController'; +import type { PerpsControllerState } from '../../src/PerpsController'; +import { PERPS_ERROR_CODES } from '../../src/perpsErrorCodes'; +import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider'; +import type { + AccountState, + GetAvailableDexsParams, + PerpsProvider, + PerpsPlatformDependencies, + PerpsProviderType, + SubscribeAccountParams, +} from '../../src/types'; +import { PerpsAnalyticsEvent } from '../../src/types'; + +jest.mock('../../src/providers/HyperLiquidProvider'); +jest.mock('../../src/providers/MYXProvider'); + +// Mock transaction controller utility +const mockAddTransaction = jest.fn(); +jest.mock( + '../../../util/transaction-controller', + () => ({ + addTransaction: (...args: unknown[]) => mockAddTransaction(...args), + }), + { virtual: true }, +); + +// Mock wait utility to speed up retry tests +jest.mock('../../src/utils/wait', () => ({ + wait: jest.fn().mockResolvedValue(undefined), +})); + +// Mock stream manager +const mockStreamManager = { + positions: { pause: jest.fn(), resume: jest.fn() }, + account: { pause: jest.fn(), resume: jest.fn() }, + orders: { pause: jest.fn(), resume: jest.fn() }, + prices: { pause: jest.fn(), resume: jest.fn() }, + orderFills: { pause: jest.fn(), resume: jest.fn() }, +}; + +jest.mock( + '../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: jest.fn(() => mockStreamManager), + }), + { virtual: true }, +); + +// Create persistent mock controllers INSIDE jest.mock factory +jest.mock( + '../../../core/Engine', + () => { + const mockRewardsController = { + getPerpsDiscountForAccount: jest.fn(), + }; + + const mockNetworkController = { + getNetworkClientById: jest.fn().mockReturnValue({ + configuration: { chainId: '0x1' }, + }), + }; + + const mockAccountTreeController = { + getAccountsFromSelectedAccountGroup: jest.fn().mockReturnValue([ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]), + }; + + const mockTransactionController = { + estimateGasFee: jest.fn(), + estimateGas: jest.fn(), + }; + + const mockAccountTrackerController = { + state: { + accountsByChainId: {}, + }, + }; + + const mockEngineContext = { + RewardsController: mockRewardsController, + NetworkController: mockNetworkController, + AccountTreeController: mockAccountTreeController, + TransactionController: mockTransactionController, + AccountTrackerController: mockAccountTrackerController, + }; + + // Return as default export to match the actual Engine import + return { + __esModule: true, + default: { + context: mockEngineContext, + }, + }; + }, + { virtual: true }, +); + +jest.mock('@metamask/utils', () => ({ + ...jest.requireActual('@metamask/utils'), + formatAccountToCaipAccountId: jest + .fn() + .mockReturnValue('eip155:1:0x1234567890123456789012345678901234567890'), +})); + +// Mock EligibilityService as a class with instance methods +const mockEligibilityServiceInstance = { + checkEligibility: jest.fn().mockResolvedValue(true), +}; +jest.mock('../../src/services/EligibilityService', () => ({ + EligibilityService: jest + .fn() + .mockImplementation(() => mockEligibilityServiceInstance), +})); + +// Mock DepositService as a class with instance methods +const mockDepositServiceInstance = { + prepareTransaction: jest.fn(), +}; +jest.mock('../../src/services/DepositService', () => ({ + DepositService: jest + .fn() + .mockImplementation(() => mockDepositServiceInstance), +})); + +// Mock MarketDataService as a class with instance methods +const mockMarketDataServiceInstance = { + getPositions: jest.fn(), + getAccountState: jest.fn(), + getMarkets: jest.fn(), + getWithdrawalRoutes: jest.fn().mockReturnValue([]), + validateClosePosition: jest.fn().mockResolvedValue({ isValid: true }), + validateOrder: jest.fn(), + calculateMaintenanceMargin: jest.fn().mockResolvedValue(0), + calculateLiquidationPrice: jest.fn(), + getMaxLeverage: jest.fn(), + calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + getAvailableDexs: jest.fn().mockResolvedValue([]), + getBlockExplorerUrl: jest.fn(), + getOrderFills: jest.fn(), + getOrders: jest.fn(), + getFunding: jest.fn(), +}; +jest.mock('../../src/services/MarketDataService', () => ({ + MarketDataService: jest + .fn() + .mockImplementation(() => mockMarketDataServiceInstance), +})); + +// Mock TradingService as a class with instance methods +const mockTradingServiceInstance = { + placeOrder: jest.fn(), + editOrder: jest.fn(), + cancelOrder: jest.fn(), + cancelOrders: jest.fn(), + closePosition: jest.fn(), + closePositions: jest.fn(), + updatePositionTPSL: jest.fn(), + updateMargin: jest.fn(), + flipPosition: jest.fn(), + setControllerDependencies: jest.fn(), +}; +jest.mock('../../src/services/TradingService', () => ({ + TradingService: jest + .fn() + .mockImplementation(() => mockTradingServiceInstance), +})); + +// Mock AccountService as a class with instance methods +const mockAccountServiceInstance = { + withdraw: jest.fn(), + validateWithdrawal: jest.fn(), +}; +jest.mock('../../src/services/AccountService', () => ({ + AccountService: jest + .fn() + .mockImplementation(() => mockAccountServiceInstance), +})); + +// Mock DataLakeService as a class with instance methods +const mockDataLakeServiceInstance = { + reportOrder: jest.fn(), +}; +jest.mock('../../src/services/DataLakeService', () => ({ + DataLakeService: jest + .fn() + .mockImplementation(() => mockDataLakeServiceInstance), +})); + +// Mock FeatureFlagConfigurationService as a class with instance methods +const mockFeatureFlagConfigurationServiceInstance = { + refreshEligibility: jest.fn((options: any) => { + // Simulate the service's behavior: extract blocked regions from remote flags + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + // Never downgrade from remote to fallback + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList(remoteBlockedRegions, 'remote'); + } + } + + // Call refreshEligibility callback if available + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + // Also call refreshHip3Config if available + if (remoteFlags) { + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config(options); + } + }), + refreshHip3Config: jest.fn(), + setBlockedRegions: jest.fn((options: any) => { + // Simulate setBlockedRegions behavior + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + // Never downgrade from remote to fallback + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + // Call refreshEligibility callback if available + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }), +}; +jest.mock('../../src/services/FeatureFlagConfigurationService', () => ({ + FeatureFlagConfigurationService: jest + .fn() + .mockImplementation(() => mockFeatureFlagConfigurationServiceInstance), +})); + +/** + * Testable version of PerpsController that exposes protected methods for testing. + * This follows the pattern used in RewardsController.test.ts + */ +class TestablePerpsController extends PerpsController { + /** + * Test-only method to update state directly. + * Exposed for scenarios where state needs to be manipulated + * outside the normal public API (e.g., testing error conditions). + * @param callback + */ + public testUpdate(callback: (state: PerpsControllerState) => void) { + this.update(callback); + } + + /** + * Test-only method to mark controller as initialized. + * Common test scenario that requires internal state changes. + */ + public testMarkInitialized() { + this.isInitialized = true; + this.update((state) => { + state.initializationState = InitializationState.Initialized; + }); + } + + /** + * Test-only method to set the providers map with complete providers. + * Used in most tests to inject mock providers. + * Also sets activeProviderInstance to the first provider (default provider). + * @param providers + */ + public testSetProviders(providers: Map) { + this.providers = providers; + // Set activeProviderInstance to the first provider (typically 'hyperliquid') + const firstProvider = providers.values().next().value; + if (firstProvider) { + this.activeProviderInstance = firstProvider; + } + } + + /** + * Test-only method to set the providers map with partial providers. + * Used explicitly in tests that verify error handling with incomplete providers. + * Type cast is intentional and necessary for testing graceful degradation. + * @param providers + */ + public testSetPartialProviders( + providers: Map>, + ) { + this.providers = providers as Map; + } + + /** + * Test-only method to get the providers map. + * Used to verify provider state in tests. + */ + public testGetProviders(): Map { + return this.providers; + } + + /** + * Test-only method to set initialization state. + * Allows tests to simulate both initialized and uninitialized states. + * @param value + */ + public testSetInitialized(value: boolean) { + this.isInitialized = value; + } + + /** + * Test-only method to get initialization state. + * Used to verify initialization status in tests. + */ + public testGetInitialized(): boolean { + return this.isInitialized; + } + + /** + * Test-only method to get blocked region list. + * Used to verify geo-blocking configuration in tests. + */ + public testGetBlockedRegionList(): { source: string; list: string[] } { + return this.blockedRegionList; + } + + /** + * Test-only method to set blocked region list. + * Used to test priority logic (remote vs fallback). + * @param list + * @param source + */ + public testSetBlockedRegionList( + list: string[], + source: 'remote' | 'fallback', + ) { + this.setBlockedRegionList(list, source); + } + + /** + * Test accessor for protected method refreshEligibilityOnFeatureFlagChange. + * Wrapper is necessary because protected methods can't be called from test code. + * @param remoteFlags + */ + public testRefreshEligibilityOnFeatureFlagChange(remoteFlags: any) { + this.refreshEligibilityOnFeatureFlagChange(remoteFlags); + } + + /** + * Test accessor for protected method reportOrderToDataLake. + * Wrapper is necessary because protected methods can't be called from test code. + * @param data + */ + public testReportOrderToDataLake(data: any): Promise { + return this.reportOrderToDataLake(data); + } + + public testHasStandaloneProvider(): boolean { + return this.hasStandaloneProvider(); + } + + public testRegisterMYXProvider( + MYXProvider: new (opts: Record) => PerpsProvider, + ) { + this.registerMYXProvider(MYXProvider as never); + } + + public testHandleMYXImportError(error: unknown) { + this.handleMYXImportError(error); + } +} + +describe('PerpsController', () => { + let controller: TestablePerpsController; + let mockProvider: jest.Mocked; + let mockInfrastructure: jest.Mocked; + + // Helper to mark controller as initialized for tests + const markControllerAsInitialized = () => { + controller.testMarkInitialized(); + }; + + beforeEach(() => { + jest.clearAllMocks(); + + ( + jest.requireMock('../../src/services/EligibilityService') + .EligibilityService as jest.Mock + ).mockImplementation(() => mockEligibilityServiceInstance); + ( + jest.requireMock('../../src/services/DepositService') + .DepositService as jest.Mock + ).mockImplementation(() => mockDepositServiceInstance); + ( + jest.requireMock('../../src/services/MarketDataService') + .MarketDataService as jest.Mock + ).mockImplementation(() => mockMarketDataServiceInstance); + ( + jest.requireMock('../../src/services/TradingService') + .TradingService as jest.Mock + ).mockImplementation(() => mockTradingServiceInstance); + ( + jest.requireMock('../../src/services/AccountService') + .AccountService as jest.Mock + ).mockImplementation(() => mockAccountServiceInstance); + ( + jest.requireMock('../../src/services/DataLakeService') + .DataLakeService as jest.Mock + ).mockImplementation(() => mockDataLakeServiceInstance); + ( + jest.requireMock('../../src/services/FeatureFlagConfigurationService') + .FeatureFlagConfigurationService as jest.Mock + ).mockImplementation(() => mockFeatureFlagConfigurationServiceInstance); + + mockEligibilityServiceInstance.checkEligibility.mockResolvedValue(true); + mockMarketDataServiceInstance.getPositions.mockResolvedValue([]); + mockMarketDataServiceInstance.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockMarketDataServiceInstance.getMarkets.mockResolvedValue([]); + mockMarketDataServiceInstance.getWithdrawalRoutes.mockReturnValue([]); + mockMarketDataServiceInstance.validateClosePosition.mockResolvedValue({ + isValid: true, + }); + mockMarketDataServiceInstance.calculateMaintenanceMargin.mockResolvedValue( + 0, + ); + mockMarketDataServiceInstance.calculateFees.mockResolvedValue({ + totalFee: 0, + }); + mockMarketDataServiceInstance.getAvailableDexs.mockResolvedValue([]); + + mockFeatureFlagConfigurationServiceInstance.refreshEligibility.mockImplementation( + (options: any) => { + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList( + remoteBlockedRegions, + 'remote', + ); + } + } + + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + if (remoteFlags) { + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config( + options, + ); + } + }, + ); + mockFeatureFlagConfigurationServiceInstance.setBlockedRegions.mockImplementation( + (options: any) => { + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }, + ); + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config.mockImplementation( + () => undefined, + ); + + // Reset Engine.context mocks to default state to prevent test interdependence + ( + Engine.context.RewardsController.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(null); + ( + Engine.context.NetworkController.getNetworkClientById as jest.Mock + ).mockReturnValue({ configuration: { chainId: '0x1' } }); + + // Create a fresh mock provider for each test + mockProvider = createMockHyperLiquidProvider(); + + // Add default mock return values for all provider methods + mockProvider.getPositions.mockResolvedValue([]); + mockProvider.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockProvider.getMarkets.mockResolvedValue([]); + mockProvider.getOpenOrders.mockResolvedValue([]); + mockProvider.getFunding.mockResolvedValue([]); + mockProvider.getOrderFills.mockResolvedValue([]); + mockProvider.getOrders.mockResolvedValue([]); + mockProvider.calculateLiquidationPrice.mockResolvedValue('0'); + mockProvider.getMaxLeverage.mockResolvedValue(50); + mockProvider.calculateMaintenanceMargin.mockResolvedValue(0); + mockProvider.calculateFees.mockResolvedValue({ feeAmount: 0 }); + mockProvider.getBlockExplorerUrl.mockReturnValue( + 'https://explorer.example.com', + ); + mockProvider.getWithdrawalRoutes.mockReturnValue([]); + + ( + HyperLiquidProvider as jest.MockedClass + ).mockImplementation(() => mockProvider); + + const mockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: [], + }, + }, + }; + } + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + id: 'account-1', + options: {}, + scopes: ['eip155:1'], + methods: [], + metadata: { + name: 'Test', + importTime: 0, + keyring: { type: 'HD Key Tree' }, + }, + }, + ]; + } + return undefined; + }); + + mockInfrastructure = createMockInfrastructure(); + controller = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: mockInfrastructure, + }); + }); + + afterEach(() => { + // Clear only provider mocks, not Engine.context mocks + // This prevents breaking Engine.context.RewardsController/NetworkController references + if (mockProvider) { + Object.values(mockProvider).forEach((value) => { + if ( + typeof value === 'object' && + value !== null && + 'mockClear' in value + ) { + (value as jest.Mock).mockClear(); + } + }); + } + (mockInfrastructure.metrics.trackPerpsEvent as jest.Mock).mockClear(); + (mockInfrastructure.logger.error as jest.Mock).mockClear(); + (mockInfrastructure.debugLogger.log as jest.Mock).mockClear(); + }); + describe('validation methods', () => { + it('validates close position', async () => { + const closeParams = { + symbol: 'BTC', + orderType: 'market' as const, + size: '0.5', + }; + + const mockValidationResult = { + isValid: true, + errors: [], + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'validateClosePosition') + .mockResolvedValue(mockValidationResult); + + const result = await controller.validateClosePosition(closeParams); + + expect(result).toEqual(mockValidationResult); + expect( + mockMarketDataServiceInstance.validateClosePosition, + ).toHaveBeenCalledWith({ + provider: mockProvider, + params: closeParams, + context: expect.any(Object), + }); + }); + + it('validates withdrawal', async () => { + const withdrawParams = { + amount: '100', + destination: + '0x1234567890123456789012345678901234567890' as `0x${string}`, + }; + + const mockValidationResult = { + isValid: true, + errors: [], + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockAccountServiceInstance, 'validateWithdrawal') + .mockResolvedValue(mockValidationResult); + + const result = await controller.validateWithdrawal(withdrawParams); + + expect(result).toEqual(mockValidationResult); + expect( + mockAccountServiceInstance.validateWithdrawal, + ).toHaveBeenCalledWith({ + provider: mockProvider, + params: withdrawParams, + }); + }); + }); + + describe('position management', () => { + it('updates position TP/SL', async () => { + const updateParams = { + symbol: 'BTC', + takeProfitPrice: '55000', + stopLossPrice: '45000', + }; + + const mockUpdateResult = { + success: true, + positionId: 'pos-123', + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockTradingServiceInstance, 'updatePositionTPSL') + .mockResolvedValue(mockUpdateResult); + + const result = await controller.updatePositionTPSL(updateParams); + + expect(result).toEqual(mockUpdateResult); + expect( + mockTradingServiceInstance.updatePositionTPSL, + ).toHaveBeenCalledWith( + expect.objectContaining({ + provider: mockProvider, + params: updateParams, + context: expect.any(Object), + }), + ); + }); + + it('calculates maintenance margin', async () => { + const marginParams = { + symbol: 'BTC', + size: '1.0', + entryPrice: '50000', + asset: 'BTC', + }; + + const mockMargin = 2500; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'calculateMaintenanceMargin') + .mockResolvedValue(mockMargin); + + const result = await controller.calculateMaintenanceMargin(marginParams); + + expect(result).toBe(mockMargin); + expect( + mockMarketDataServiceInstance.calculateMaintenanceMargin, + ).toHaveBeenCalledWith({ + provider: mockProvider, + params: marginParams, + context: expect.any(Object), + }); + }); + + it('updates margin successfully', async () => { + const updateMarginParams = { + symbol: 'BTC', + amount: '100', + }; + + const mockUpdateResult = { + success: true, + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockTradingServiceInstance, 'updateMargin') + .mockResolvedValue(mockUpdateResult); + + const result = await controller.updateMargin(updateMarginParams); + + expect(result).toEqual(mockUpdateResult); + expect(mockTradingServiceInstance.updateMargin).toHaveBeenCalledWith( + expect.objectContaining({ + provider: mockProvider, + symbol: updateMarginParams.symbol, + amount: '100', + context: expect.any(Object), + }), + ); + }); + + it('handles updateMargin error', async () => { + const updateMarginParams = { + symbol: 'BTC', + amount: '100', + }; + + const errorMessage = 'Insufficient balance'; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockTradingServiceInstance, 'updateMargin') + .mockRejectedValue(new Error(errorMessage)); + + await expect(controller.updateMargin(updateMarginParams)).rejects.toThrow( + errorMessage, + ); + expect(mockTradingServiceInstance.updateMargin).toHaveBeenCalled(); + }); + + it('flips position successfully', async () => { + const mockPosition = { + symbol: 'BTC', + size: '0.5', + entryPrice: '50000', + positionValue: '25000', + unrealizedPnl: '1000', + returnOnEquity: '0.04', + leverage: { type: 'cross' as const, value: 10 }, + liquidationPrice: '45000', + marginUsed: '2500', + maxLeverage: 100, + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }; + + const flipPositionParams = { + symbol: 'BTC', + position: mockPosition, + }; + + const mockFlipResult = { + success: true, + orderId: 'flip-123', + filledSize: '1.0', + averagePrice: '50000', + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockTradingServiceInstance, 'flipPosition') + .mockResolvedValue(mockFlipResult); + + const result = await controller.flipPosition(flipPositionParams); + + expect(result).toEqual(mockFlipResult); + expect(mockTradingServiceInstance.flipPosition).toHaveBeenCalledWith( + expect.objectContaining({ + provider: mockProvider, + position: mockPosition, + context: expect.any(Object), + }), + ); + }); + + it('handles flipPosition error', async () => { + const mockPosition = { + symbol: 'BTC', + size: '0.5', + entryPrice: '50000', + positionValue: '25000', + unrealizedPnl: '1000', + returnOnEquity: '0.04', + leverage: { type: 'cross' as const, value: 10 }, + liquidationPrice: '45000', + marginUsed: '2500', + maxLeverage: 100, + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }; + + const flipPositionParams = { + symbol: 'BTC', + position: mockPosition, + }; + + const errorMessage = 'Insufficient balance for flip fees'; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockTradingServiceInstance, 'flipPosition') + .mockRejectedValue(new Error(errorMessage)); + + await expect(controller.flipPosition(flipPositionParams)).rejects.toThrow( + errorMessage, + ); + expect(mockTradingServiceInstance.flipPosition).toHaveBeenCalled(); + }); + }); + + describe('fee calculations', () => { + it('calculates fees', async () => { + const feeParams = { + orderType: 'market' as const, + isMaker: false, + amount: '100000', + symbol: 'BTC', + }; + + const mockFees = { + makerFee: '0.0001', + takerFee: '0.0005', + totalFee: '0.05', + feeToken: 'USDC', + feeAmount: 0.05, + feeRate: 0.0005, + protocolFeeRate: 0.0003, + metamaskFeeRate: 0.0002, + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'calculateFees') + .mockResolvedValue(mockFees); + + const result = await controller.calculateFees(feeParams); + + expect(result).toEqual(mockFees); + expect(mockMarketDataServiceInstance.calculateFees).toHaveBeenCalledWith({ + provider: mockProvider, + params: feeParams, + context: expect.any(Object), + }); + }); + }); + + describe('reportOrderToDataLake', () => { + beforeEach(() => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + }); + + it('delegates to DataLakeService.reportOrder', async () => { + const mockReportResult = { + success: true, + error: undefined, + }; + + jest + .spyOn(mockDataLakeServiceInstance, 'reportOrder') + .mockResolvedValue(mockReportResult); + + const orderParams = { + action: 'open' as const, + symbol: 'BTC', + slPrice: 45000, + tpPrice: 55000, + }; + + const result = await controller.testReportOrderToDataLake(orderParams); + + expect(result).toEqual(mockReportResult); + expect(mockDataLakeServiceInstance.reportOrder).toHaveBeenCalledWith({ + action: orderParams.action, + symbol: orderParams.symbol, + slPrice: orderParams.slPrice, + tpPrice: orderParams.tpPrice, + isTestnet: controller.state.isTestnet, + context: expect.objectContaining({ + tracingContext: expect.any(Object), + errorContext: expect.objectContaining({ + method: 'reportOrderToDataLake', + }), + stateManager: expect.any(Object), + }), + retryCount: undefined, + _traceId: undefined, + }); + }); + }); + + describe('getAvailableDexs', () => { + beforeEach(() => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + }); + + it('returns available HIP-3 DEXs from provider', async () => { + const mockDexs = ['dex1', 'dex2', 'dex3']; + jest + .spyOn(mockMarketDataServiceInstance, 'getAvailableDexs') + .mockResolvedValue(mockDexs); + + const result = await controller.getAvailableDexs(); + + expect(result).toEqual(mockDexs); + expect( + mockMarketDataServiceInstance.getAvailableDexs, + ).toHaveBeenCalledWith({ + provider: mockProvider, + params: undefined, + context: expect.any(Object), + }); + }); + + it('passes filter parameters to provider', async () => { + const mockDexs = ['dex1']; + const filterParams = {} as GetAvailableDexsParams; + jest + .spyOn(mockMarketDataServiceInstance, 'getAvailableDexs') + .mockResolvedValue(mockDexs); + + const result = await controller.getAvailableDexs(filterParams); + + expect(result).toEqual(mockDexs); + expect( + mockMarketDataServiceInstance.getAvailableDexs, + ).toHaveBeenCalledWith({ + provider: mockProvider, + params: filterParams, + context: expect.any(Object), + }); + }); + + it('throws error when provider does not support HIP-3', async () => { + jest + .spyOn(mockMarketDataServiceInstance, 'getAvailableDexs') + .mockRejectedValue(new Error('Provider does not support HIP-3 DEXs')); + + await expect(controller.getAvailableDexs()).rejects.toThrow( + 'Provider does not support HIP-3 DEXs', + ); + }); + }); + + describe('depositWithConfirmation', () => { + const mockTransaction = { + from: '0x1234567890123456789012345678901234567890', + to: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + value: '0x0', + data: '0x', + gas: '0x186a0', + }; + + const mockDepositId = 'deposit-123'; + const mockAssetChainId = '0x1'; + const mockNetworkClientId = 'mainnet'; + const mockTransactionMeta = { id: 'tx-meta-123' }; + const mockTxHash = '0xhash123'; + + let depositInfrastructure: jest.Mocked; + let depositController: TestablePerpsController; + let depositMockCall: jest.Mock; + + beforeEach(() => { + // Mock DepositService + jest + .spyOn(mockDepositServiceInstance, 'prepareTransaction') + .mockResolvedValue({ + transaction: mockTransaction, + assetChainId: mockAssetChainId, + currentDepositId: mockDepositId, + }); + + // Create infrastructure mock (controllers no longer on infra) + depositInfrastructure = createMockInfrastructure(); + + // Create messenger mock that handles network + transaction + account controller calls + depositMockCall = jest + .fn() + .mockImplementation((action: string, ..._args: unknown[]) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { blockedRegions: [] }, + }, + }; + } + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]; + } + if (action === 'NetworkController:findNetworkClientIdByChainId') { + return mockNetworkClientId; + } + if (action === 'TransactionController:addTransaction') { + return Promise.resolve({ + result: Promise.resolve(mockTxHash), + transactionMeta: mockTransactionMeta, + }); + } + return undefined; + }); + + Engine.context.TransactionController.estimateGasFee = jest + .fn() + .mockResolvedValue({ + estimates: { + type: GasFeeEstimateType.FeeMarket, + [GasFeeEstimateLevel.Low]: { + maxFeePerGas: '0x3b9aca00', + maxPriorityFeePerGas: '0x1', + }, + [GasFeeEstimateLevel.Medium]: { + maxFeePerGas: '0x3b9aca00', + maxPriorityFeePerGas: '0x1', + }, + [GasFeeEstimateLevel.High]: { + maxFeePerGas: '0x3b9aca00', + maxPriorityFeePerGas: '0x1', + }, + }, + }); + + Engine.context.AccountTrackerController.state.accountsByChainId = { + [mockAssetChainId]: { + [mockTransaction.from.toLowerCase()]: { + balance: '0xde0b6b3a7640000', + }, + }, + }; + + // Create a controller with the custom infrastructure for this test suite + depositController = new TestablePerpsController({ + messenger: createMockMessenger({ call: depositMockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: depositInfrastructure, + }); + }); + + afterEach(() => { + delete (Engine.context.TransactionController as any).estimateGasFee; + jest.clearAllMocks(); + mockAddTransaction.mockClear(); + }); + + it('returns promise result', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + const result = await depositController.depositWithConfirmation({ + amount: '100', + }); + + expect(result).toEqual({ + result: expect.any(Promise), + }); + }); + + it('delegates to DepositService.prepareTransaction', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + await depositController.depositWithConfirmation({ amount: '100' }); + + expect( + mockDepositServiceInstance.prepareTransaction, + ).toHaveBeenCalledWith({ + provider: mockProvider, + }); + }); + + it('calls NetworkController:findNetworkClientIdByChainId with correct chainId', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + await depositController.depositWithConfirmation({ amount: '100' }); + + expect(depositMockCall).toHaveBeenCalledWith( + 'NetworkController:findNetworkClientIdByChainId', + mockAssetChainId, + ); + }); + + it('calls TransactionController:addTransaction with prepared transaction', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + await depositController.depositWithConfirmation({ amount: '100' }); + + expect(depositMockCall).toHaveBeenCalledWith( + 'TransactionController:addTransaction', + mockTransaction, + { + networkClientId: mockNetworkClientId, + origin: 'metamask', + type: 'perpsDeposit', + skipInitialGasEstimate: true, + }, + ); + }); + + it('throws error when controller not initialized', async () => { + depositController.testSetInitialized(false); + + await expect( + depositController.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow('CLIENT_NOT_INITIALIZED'); + }); + + it('throws error when no active provider', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders(new Map()); + + await expect( + depositController.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow(); + }); + + it('propagates DepositService errors', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + const mockError = new Error('Deposit service failed'); + jest + .spyOn(mockDepositServiceInstance, 'prepareTransaction') + .mockRejectedValue(mockError); + + await expect( + depositController.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow('Deposit service failed'); + }); + + it('propagates NetworkController:findNetworkClientIdByChainId errors', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + const mockError = new Error('Network client not found'); + depositMockCall.mockImplementation( + (action: string, ..._args: unknown[]) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]; + } + if (action === 'NetworkController:findNetworkClientIdByChainId') { + throw mockError; + } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + return undefined; + }, + ); + + await expect( + depositController.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow('Network client not found'); + }); + + it('marks deposit request as failed when networkClientId is not found', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + depositMockCall.mockImplementation( + (action: string, ..._args: unknown[]) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]; + } + if (action === 'NetworkController:findNetworkClientIdByChainId') { + return undefined; + } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + return undefined; + }, + ); + + await expect( + depositController.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow('No network client found for chain'); + + // Verify the deposit request was marked as failed, not left as pending + const depositRequest = depositController.state.depositRequests.find( + (req) => req.id === mockDepositId, + ); + expect(depositRequest).toBeDefined(); + expect(depositRequest?.status).toBe('failed'); + expect(depositRequest?.success).toBe(false); + }); + + it('propagates TransactionController:addTransaction errors', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + const mockError = new Error('Transaction failed'); + depositMockCall.mockImplementation( + (action: string, ..._args: unknown[]) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]; + } + if (action === 'TransactionController:addTransaction') { + return Promise.reject(mockError); + } + if (action === 'NetworkController:findNetworkClientIdByChainId') { + return mockNetworkClientId; + } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + return undefined; + }, + ); + + await expect( + depositController.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow('Transaction failed'); + }); + + it('clears transaction ID when error occurs and not user cancellation', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + depositController.testUpdate((state) => { + state.lastDepositTransactionId = 'old-tx-id'; + }); + const mockError = new Error('Network error'); + depositMockCall.mockImplementation( + (action: string, ..._args: unknown[]) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]; + } + if (action === 'TransactionController:addTransaction') { + return Promise.reject(mockError); + } + if (action === 'NetworkController:findNetworkClientIdByChainId') { + return mockNetworkClientId; + } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + return undefined; + }, + ); + + await expect( + depositController.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow('Network error'); + + expect(depositController.state.lastDepositTransactionId).toBeNull(); + }); + + it('preserves state when user cancels transaction', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + depositController.testUpdate((state) => { + state.lastDepositTransactionId = 'old-tx-id'; + }); + const mockError = new Error('User denied transaction signature'); + depositMockCall.mockImplementation( + (action: string, ..._args: unknown[]) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]; + } + if (action === 'TransactionController:addTransaction') { + return Promise.reject(mockError); + } + if (action === 'NetworkController:findNetworkClientIdByChainId') { + return mockNetworkClientId; + } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + return undefined; + }, + ); + + await expect( + depositController.depositWithConfirmation({ amount: '100' }), + ).rejects.toThrow('User denied'); + + // When user cancels, transaction ID is not cleared + expect(depositController.state.lastDepositTransactionId).toBe( + 'old-tx-id', + ); + }); + + it('clears stale deposit results before transaction', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + depositController.testUpdate((state) => { + state.lastDepositResult = { + success: true, + txHash: '0xold', + amount: '50', + asset: 'USDC', + timestamp: Date.now() - 1000, + error: '', + }; + }); + + const { result } = await depositController.depositWithConfirmation({ + amount: '100', + }); + + await result; + + // After promise resolves, lastDepositResult is set with new result + expect(depositController.state.lastDepositResult).toBeTruthy(); + expect(depositController.state.lastDepositResult?.success).toBe(true); + }); + + it('updates state with transaction details', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + await depositController.depositWithConfirmation({ amount: '100' }); + + expect(depositController.state.lastDepositTransactionId).toBe( + 'tx-meta-123', + ); + }); + + it('stores depositId from service immediately', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + await depositController.depositWithConfirmation({ amount: '100' }); + + expect(depositController.state.depositRequests[0].id).toBe(mockDepositId); + }); + + it('delegates to DepositService with provider', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + await depositController.depositWithConfirmation({ amount: '100' }); + + expect( + mockDepositServiceInstance.prepareTransaction, + ).toHaveBeenCalledWith({ + provider: mockProvider, + }); + }); + + it('adds deposit request to tracking initially as pending', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + await depositController.depositWithConfirmation({ amount: '100' }); + + expect(depositController.state.depositRequests).toHaveLength(1); + expect(depositController.state.depositRequests[0].id).toBe(mockDepositId); + expect(depositController.state.depositRequests[0].amount).toBe('100'); + expect(depositController.state.depositRequests[0].asset).toBe('USDC'); + }); + + it('uses default amount when not provided', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + await depositController.depositWithConfirmation(); + + expect(depositController.state.depositRequests[0].amount).toBe('0'); + }); + + it('updates deposit request to completed when transaction succeeds', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + const { result } = await depositController.depositWithConfirmation({ + amount: '100', + }); + + await result; + + // After promise resolves, deposit request is marked as completed + expect(depositController.state.depositRequests[0].status).toBe( + 'completed', + ); + expect(depositController.state.depositRequests[0].success).toBe(true); + expect(depositController.state.depositRequests[0].txHash).toBe( + mockTxHash, + ); + }); + + it('handles concurrent deposit operations without data corruption', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + const deposit1 = depositController.depositWithConfirmation({ + amount: '100', + }); + const deposit2 = depositController.depositWithConfirmation({ + amount: '200', + }); + + await Promise.all([deposit1, deposit2]); + + expect(depositController.state.depositRequests).toHaveLength(2); + const amounts = depositController.state.depositRequests.map( + (req) => req.amount, + ); + expect(amounts).toContain('100'); + expect(amounts).toContain('200'); + }); + + it('uses addTransaction when placeOrder is true', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + await depositController.depositWithConfirmation({ + amount: '100', + placeOrder: true, + }); + + // placeOrder uses messenger-based addTransaction with perpsDepositAndOrder type + expect(depositMockCall).toHaveBeenCalledWith( + 'TransactionController:addTransaction', + mockTransaction, + { + networkClientId: mockNetworkClientId, + origin: 'metamask', + type: 'perpsDepositAndOrder', + skipInitialGasEstimate: true, + }, + ); + // Should NOT also call with perpsDeposit type + expect(depositMockCall).not.toHaveBeenCalledWith( + 'TransactionController:addTransaction', + expect.anything(), + expect.objectContaining({ type: 'perpsDeposit' }), + ); + expect(depositController.state.lastDepositTransactionId).toBe( + 'tx-meta-123', + ); + }); + + it('returns resolved promise with transaction ID when placeOrder is true', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + const { result } = await depositController.depositWithConfirmation({ + amount: '100', + placeOrder: true, + }); + + // This would hang indefinitely with the old never-resolving promise + const txId = await result; + expect(typeof txId).toBe('string'); + expect(txId).toBe('tx-meta-123'); + }); + + it('clears depositInProgress after successful transaction', async () => { + jest.useFakeTimers(); + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + const { result } = await depositController.depositWithConfirmation({ + amount: '100', + }); + + // Transaction succeeds + await result; + + // Initially depositInProgress should be true + expect(depositController.state.depositInProgress).toBe(true); + + // Fast-forward the setTimeout + jest.advanceTimersByTime(100); + + // After timeout, depositInProgress should be cleared + expect(depositController.state.depositInProgress).toBe(false); + expect(depositController.state.lastDepositTransactionId).toBeNull(); + + jest.useRealTimers(); + }); + + it('handles non-user-cancelled transaction errors after confirmation', async () => { + jest.useFakeTimers(); + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + // Mock messenger to succeed initially, but result promise rejects + const mockError = new Error('Network error occurred'); + depositMockCall.mockImplementation( + (action: string, ..._args: unknown[]) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]; + } + if (action === 'TransactionController:addTransaction') { + return Promise.resolve({ + result: Promise.reject(mockError), + transactionMeta: mockTransactionMeta, + }); + } + if (action === 'NetworkController:findNetworkClientIdByChainId') { + return mockNetworkClientId; + } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + return undefined; + }, + ); + + const { result } = await depositController.depositWithConfirmation({ + amount: '100', + }); + + // Wait for the result promise to reject + await expect(result).rejects.toThrow('Network error occurred'); + + // Should set error state + expect(depositController.state.depositInProgress).toBe(false); + expect(depositController.state.lastDepositTransactionId).toBeNull(); + expect(depositController.state.lastDepositResult).toEqual({ + success: false, + error: 'Network error occurred', + amount: '100', + asset: 'USDC', + timestamp: expect.any(Number), + txHash: '', + }); + + // Should update deposit request status + expect(depositController.state.depositRequests[0].status).toBe('failed'); + expect(depositController.state.depositRequests[0].success).toBe(false); + + jest.useRealTimers(); + }); + + it('handles user cancelled transaction with different error messages', async () => { + depositController.testMarkInitialized(); + depositController.testSetProviders( + new Map([['hyperliquid', mockProvider]]), + ); + + const cancellationMessages = [ + 'User rejected transaction signature', + 'User cancelled transaction', + 'User canceled transaction', + ]; + + for (const message of cancellationMessages) { + // Reset deposit controller state for each iteration + depositController.testUpdate((state) => { + state.depositRequests = []; + state.lastDepositResult = null; + state.depositInProgress = false; + }); + jest.clearAllMocks(); + const mockError = new Error(message); + // Mock messenger to succeed initially, but result promise rejects with user cancellation + depositMockCall.mockImplementation( + (action: string, ..._args: unknown[]) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]; + } + if (action === 'TransactionController:addTransaction') { + return Promise.resolve({ + result: Promise.reject(mockError), + transactionMeta: mockTransactionMeta, + }); + } + if (action === 'NetworkController:findNetworkClientIdByChainId') { + return mockNetworkClientId; + } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + return undefined; + }, + ); + + const { result } = await depositController.depositWithConfirmation({ + amount: '100', + }); + + await expect(result).rejects.toThrow(message); + + // Should clear state but not set error result + expect(depositController.state.depositInProgress).toBe(false); + expect(depositController.state.lastDepositTransactionId).toBeNull(); + expect(depositController.state.lastDepositResult).toBeNull(); + } + }); + }); + + describe('updateWithdrawalStatus', () => { + const mockWithdrawalId = 'withdrawal-123'; + const mockTxHash = '0xhash456'; + + beforeEach(() => { + markControllerAsInitialized(); + controller.testUpdate((state) => { + state.withdrawalRequests = [ + { + id: mockWithdrawalId, + timestamp: Date.now(), + amount: '50', + asset: 'USDC', + accountAddress: '0x1234567890123456789012345678901234567890', + success: false, + status: 'pending', + source: 'hyperliquid', + }, + ]; + }); + }); + + it('updates withdrawal status to completed with txHash', () => { + controller.updateWithdrawalStatus( + mockWithdrawalId, + 'completed', + mockTxHash, + ); + + const withdrawal = controller.state.withdrawalRequests[0]; + expect(withdrawal.status).toBe('completed'); + expect(withdrawal.txHash).toBe(mockTxHash); + expect(withdrawal.success).toBe(true); + }); + + it('removes withdrawal request when status is failed', () => { + controller.updateWithdrawalStatus(mockWithdrawalId, 'failed'); + + expect( + controller.state.withdrawalRequests.some( + (w) => w.id === mockWithdrawalId, + ), + ).toBe(false); + }); + + it('clears withdrawal progress when status completed', () => { + controller.testUpdate((state) => { + state.withdrawalProgress = { + progress: 50, + lastUpdated: Date.now() - 1000, + activeWithdrawalId: mockWithdrawalId, + }; + }); + + controller.updateWithdrawalStatus( + mockWithdrawalId, + 'completed', + mockTxHash, + ); + + expect(controller.state.withdrawalProgress.progress).toBe(0); + expect(controller.state.withdrawalProgress.activeWithdrawalId).toBeNull(); + }); + + it('clears withdrawal progress when status failed', () => { + controller.testUpdate((state) => { + state.withdrawalProgress = { + progress: 75, + lastUpdated: Date.now() - 1000, + activeWithdrawalId: mockWithdrawalId, + }; + }); + + controller.updateWithdrawalStatus(mockWithdrawalId, 'failed'); + + expect(controller.state.withdrawalProgress.progress).toBe(0); + expect(controller.state.withdrawalProgress.activeWithdrawalId).toBeNull(); + expect( + controller.state.withdrawalRequests.some( + (w) => w.id === mockWithdrawalId, + ), + ).toBe(false); + }); + + it('finds withdrawal by ID', () => { + controller.testUpdate((state) => { + state.withdrawalRequests.push({ + id: 'withdrawal-456', + timestamp: Date.now(), + amount: '75', + asset: 'USDC', + accountAddress: '0x1234567890123456789012345678901234567890', + success: false, + status: 'pending', + source: 'hyperliquid', + }); + }); + + controller.updateWithdrawalStatus( + 'withdrawal-456', + 'completed', + mockTxHash, + ); + + expect(controller.state.withdrawalRequests[1].status).toBe('completed'); + expect(controller.state.withdrawalRequests[0].status).toBe('pending'); + }); + + it('does nothing when withdrawal ID not found', () => { + const initialRequests = [...controller.state.withdrawalRequests]; + + controller.updateWithdrawalStatus( + 'non-existent-id', + 'completed', + mockTxHash, + ); + + expect(controller.state.withdrawalRequests).toEqual(initialRequests); + }); + + it('updates state correctly for multiple withdrawals', () => { + controller.testUpdate((state) => { + state.withdrawalRequests.push({ + id: 'withdrawal-789', + timestamp: Date.now(), + amount: '100', + asset: 'USDC', + accountAddress: '0x1234567890123456789012345678901234567890', + success: false, + status: 'pending', + source: 'hyperliquid', + }); + }); + + controller.updateWithdrawalStatus( + mockWithdrawalId, + 'completed', + mockTxHash, + ); + + expect(controller.state.withdrawalRequests[0].status).toBe('completed'); + expect(controller.state.withdrawalRequests[1].status).toBe('pending'); + }); + + it('handles undefined txHash gracefully', () => { + controller.updateWithdrawalStatus(mockWithdrawalId, 'completed'); + + const withdrawal = controller.state.withdrawalRequests[0]; + expect(withdrawal.status).toBe('completed'); + expect(withdrawal.txHash).toBeUndefined(); + expect(withdrawal.success).toBe(true); + }); + }); + + describe('completeWithdrawalFromHistory', () => { + const pendingId = 'withdrawal-fifo-1'; + const txHash = '0xfifoabc'; + const completedPayload = { + txHash, + amount: '25', + timestamp: 1_700_000_000_000, + asset: 'USDC', + }; + + beforeEach(() => { + markControllerAsInitialized(); + controller.testUpdate((state) => { + state.withdrawalRequests = [ + { + id: pendingId, + timestamp: Date.now(), + amount: '25', + asset: 'USDC', + accountAddress: '0x1234567890123456789012345678901234567890', + success: false, + status: 'pending', + source: 'hyperliquid', + }, + ]; + state.withdrawInProgress = true; + state.lastCompletedWithdrawalTimestamp = 1_699_000_000_000; + state.lastCompletedWithdrawalTxHashes = ['0xexisting']; + state.lastUpdateTimestamp = 99_999; + }); + }); + + it('does not mutate FIFO guards or emit analytics when withdrawal id is unknown', () => { + const snapshot = { + withdrawalRequests: [...controller.state.withdrawalRequests], + lastCompletedWithdrawalTimestamp: + controller.state.lastCompletedWithdrawalTimestamp, + lastCompletedWithdrawalTxHashes: [ + ...controller.state.lastCompletedWithdrawalTxHashes, + ], + withdrawInProgress: controller.state.withdrawInProgress, + lastUpdateTimestamp: controller.state.lastUpdateTimestamp, + }; + + controller.completeWithdrawalFromHistory('unknown-withdrawal-id', { + ...completedPayload, + txHash: '0xstale', + }); + + expect(controller.state.withdrawalRequests).toEqual( + snapshot.withdrawalRequests, + ); + expect(controller.state.lastCompletedWithdrawalTimestamp).toBe( + snapshot.lastCompletedWithdrawalTimestamp, + ); + expect(controller.state.lastCompletedWithdrawalTxHashes).toEqual( + snapshot.lastCompletedWithdrawalTxHashes, + ); + expect(controller.state.withdrawInProgress).toBe( + snapshot.withdrawInProgress, + ); + expect(controller.state.lastUpdateTimestamp).toBe( + snapshot.lastUpdateTimestamp, + ); + expect(mockInfrastructure.metrics.trackPerpsEvent).not.toHaveBeenCalled(); + }); + + it('removes the request, updates FIFO guards, and tracks completion when id matches', () => { + controller.completeWithdrawalFromHistory(pendingId, completedPayload); + + expect(controller.state.withdrawalRequests).toHaveLength(0); + expect(controller.state.lastCompletedWithdrawalTimestamp).toBe( + completedPayload.timestamp, + ); + expect(controller.state.lastCompletedWithdrawalTxHashes).toEqual([ + '0xexisting', + txHash, + ]); + expect(controller.state.withdrawInProgress).toBe(false); + expect(mockInfrastructure.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.WithdrawalTransaction, + expect.objectContaining({ + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.COMPLETED, + [PERPS_EVENT_PROPERTY.WITHDRAWAL_AMOUNT]: 25, + }), + ); + }); + }); + + describe('markFirstOrderCompleted', () => { + beforeEach(() => { + markControllerAsInitialized(); + }); + + it('marks first order completed for mainnet', () => { + controller.testUpdate((state) => { + state.isTestnet = false; + }); + + controller.markFirstOrderCompleted(); + + expect(controller.state.hasPlacedFirstOrder.mainnet).toBe(true); + }); + + it('marks first order completed for testnet', () => { + controller.testUpdate((state) => { + state.isTestnet = true; + }); + + controller.markFirstOrderCompleted(); + + expect(controller.state.hasPlacedFirstOrder.testnet).toBe(true); + }); + + it('only updates status for current network', () => { + controller.testUpdate((state) => { + state.isTestnet = false; + state.hasPlacedFirstOrder = { + mainnet: false, + testnet: false, + }; + }); + + controller.markFirstOrderCompleted(); + + expect(controller.state.hasPlacedFirstOrder.mainnet).toBe(true); + expect(controller.state.hasPlacedFirstOrder.testnet).toBe(false); + }); + + it('does not crash when called multiple times', () => { + controller.testUpdate((state) => { + state.isTestnet = false; + }); + + controller.markFirstOrderCompleted(); + expect(controller.state.hasPlacedFirstOrder.mainnet).toBe(true); + + controller.markFirstOrderCompleted(); + expect(controller.state.hasPlacedFirstOrder.mainnet).toBe(true); + }); + + it('logs completion without throwing', () => { + controller.testUpdate((state) => { + state.isTestnet = false; + }); + + expect(() => controller.markFirstOrderCompleted()).not.toThrow(); + }); + }); + + describe('getWithdrawalRoutes error handling', () => { + beforeEach(() => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + }); + + it('logs error in getWithdrawalRoutes when provider throws', () => { + const mockError = new Error('Provider error'); + jest + .spyOn(mockMarketDataServiceInstance, 'getWithdrawalRoutes') + .mockImplementation(() => { + throw mockError; + }); + + const result = controller.getWithdrawalRoutes(); + + expect(result).toEqual([]); + expect(mockInfrastructure.logger.error).toHaveBeenCalledWith( + mockError, + expect.objectContaining({ + context: expect.objectContaining({ + name: 'PerpsController', + data: expect.objectContaining({ + method: 'getWithdrawalRoutes', + }), + }), + }), + ); + }); + + it('returns empty array from getWithdrawalRoutes on error', () => { + jest + .spyOn(mockMarketDataServiceInstance, 'getWithdrawalRoutes') + .mockImplementation(() => { + throw new Error('Service failure'); + }); + + const result = controller.getWithdrawalRoutes(); + + expect(result).toEqual([]); + }); + + it('handles edge case with null provider gracefully', () => { + controller.testSetProviders(new Map()); + + expect(() => controller.getWithdrawalRoutes()).not.toThrow(); + expect(controller.getWithdrawalRoutes()).toEqual([]); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts new file mode 100644 index 0000000000..76fc7a3137 --- /dev/null +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -0,0 +1,2535 @@ +/* eslint-disable */ +/** + * PerpsController Tests + * Clean, focused test suite for PerpsController + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { + GasFeeEstimateLevel, + GasFeeEstimateType, +} from '@metamask/transaction-controller'; + +import Engine from '../../../core/Engine'; +import { + createMockHyperLiquidProvider, + createMockPosition, +} from '../helpers/providerMocks'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../helpers/serviceMocks'; + +jest.mock('@nktkas/hyperliquid', () => ({})); +jest.mock('@myx-trade/sdk', () => ({ + MyxClient: jest.fn(), + OrderStatusEnum: { Successful: 9 }, +})); + +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '../../src/constants/eventNames'; +import { + PERPS_CONSTANTS, + PERPS_DISK_CACHE_MARKETS, + PERPS_DISK_CACHE_USER_DATA, +} from '../../src/constants/perpsConfig'; +import { + PerpsController, + getDefaultPerpsControllerState, + InitializationState, + firstNonEmpty, + resolveMyxAuthConfig, +} from '../../src/PerpsController'; +import type { PerpsControllerState } from '../../src/PerpsController'; +import { PERPS_ERROR_CODES } from '../../src/perpsErrorCodes'; +import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider'; +import type { + AccountState, + GetAvailableDexsParams, + PerpsProvider, + PerpsPlatformDependencies, + PerpsProviderType, + SubscribeAccountParams, +} from '../../src/types'; +import { PerpsAnalyticsEvent } from '../../src/types'; + +jest.mock('../../src/providers/HyperLiquidProvider'); +jest.mock('../../src/providers/MYXProvider'); + +// Mock transaction controller utility +const mockAddTransaction = jest.fn(); +jest.mock( + '../../../util/transaction-controller', + () => ({ + addTransaction: (...args: unknown[]) => mockAddTransaction(...args), + }), + { virtual: true }, +); + +// Mock wait utility to speed up retry tests +jest.mock('../../src/utils/wait', () => ({ + wait: jest.fn().mockResolvedValue(undefined), +})); + +// Mock stream manager +const mockStreamManager = { + positions: { pause: jest.fn(), resume: jest.fn() }, + account: { pause: jest.fn(), resume: jest.fn() }, + orders: { pause: jest.fn(), resume: jest.fn() }, + prices: { pause: jest.fn(), resume: jest.fn() }, + orderFills: { pause: jest.fn(), resume: jest.fn() }, +}; + +jest.mock( + '../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: jest.fn(() => mockStreamManager), + }), + { virtual: true }, +); + +// Create persistent mock controllers INSIDE jest.mock factory +jest.mock( + '../../../core/Engine', + () => { + const mockRewardsController = { + getPerpsDiscountForAccount: jest.fn(), + }; + + const mockNetworkController = { + getNetworkClientById: jest.fn().mockReturnValue({ + configuration: { chainId: '0x1' }, + }), + }; + + const mockAccountTreeController = { + getAccountsFromSelectedAccountGroup: jest.fn().mockReturnValue([ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]), + }; + + const mockTransactionController = { + estimateGasFee: jest.fn(), + estimateGas: jest.fn(), + }; + + const mockAccountTrackerController = { + state: { + accountsByChainId: {}, + }, + }; + + const mockEngineContext = { + RewardsController: mockRewardsController, + NetworkController: mockNetworkController, + AccountTreeController: mockAccountTreeController, + TransactionController: mockTransactionController, + AccountTrackerController: mockAccountTrackerController, + }; + + // Return as default export to match the actual Engine import + return { + __esModule: true, + default: { + context: mockEngineContext, + }, + }; + }, + { virtual: true }, +); + +jest.mock('@metamask/utils', () => ({ + ...jest.requireActual('@metamask/utils'), + formatAccountToCaipAccountId: jest + .fn() + .mockReturnValue('eip155:1:0x1234567890123456789012345678901234567890'), +})); + +// Mock EligibilityService as a class with instance methods +const mockEligibilityServiceInstance = { + checkEligibility: jest.fn().mockResolvedValue(true), +}; +jest.mock('../../src/services/EligibilityService', () => ({ + EligibilityService: jest + .fn() + .mockImplementation(() => mockEligibilityServiceInstance), +})); + +// Mock DepositService as a class with instance methods +const mockDepositServiceInstance = { + prepareTransaction: jest.fn(), +}; +jest.mock('../../src/services/DepositService', () => ({ + DepositService: jest + .fn() + .mockImplementation(() => mockDepositServiceInstance), +})); + +// Mock MarketDataService as a class with instance methods +const mockMarketDataServiceInstance = { + getPositions: jest.fn(), + getAccountState: jest.fn(), + getMarkets: jest.fn(), + getWithdrawalRoutes: jest.fn().mockReturnValue([]), + validateClosePosition: jest.fn().mockResolvedValue({ isValid: true }), + validateOrder: jest.fn(), + calculateMaintenanceMargin: jest.fn().mockResolvedValue(0), + calculateLiquidationPrice: jest.fn(), + getMaxLeverage: jest.fn(), + calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + getAvailableDexs: jest.fn().mockResolvedValue([]), + getBlockExplorerUrl: jest.fn(), + getOrderFills: jest.fn(), + getOrders: jest.fn(), + getFunding: jest.fn(), +}; +jest.mock('../../src/services/MarketDataService', () => ({ + MarketDataService: jest + .fn() + .mockImplementation(() => mockMarketDataServiceInstance), +})); + +// Mock TradingService as a class with instance methods +const mockTradingServiceInstance = { + placeOrder: jest.fn(), + editOrder: jest.fn(), + cancelOrder: jest.fn(), + cancelOrders: jest.fn(), + closePosition: jest.fn(), + closePositions: jest.fn(), + updatePositionTPSL: jest.fn(), + updateMargin: jest.fn(), + flipPosition: jest.fn(), + setControllerDependencies: jest.fn(), +}; +jest.mock('../../src/services/TradingService', () => ({ + TradingService: jest + .fn() + .mockImplementation(() => mockTradingServiceInstance), +})); + +// Mock AccountService as a class with instance methods +const mockAccountServiceInstance = { + withdraw: jest.fn(), + validateWithdrawal: jest.fn(), +}; +jest.mock('../../src/services/AccountService', () => ({ + AccountService: jest + .fn() + .mockImplementation(() => mockAccountServiceInstance), +})); + +// Mock DataLakeService as a class with instance methods +const mockDataLakeServiceInstance = { + reportOrder: jest.fn(), +}; +jest.mock('../../src/services/DataLakeService', () => ({ + DataLakeService: jest + .fn() + .mockImplementation(() => mockDataLakeServiceInstance), +})); + +// Mock FeatureFlagConfigurationService as a class with instance methods +const mockFeatureFlagConfigurationServiceInstance = { + refreshEligibility: jest.fn((options: any) => { + // Simulate the service's behavior: extract blocked regions from remote flags + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + // Never downgrade from remote to fallback + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList(remoteBlockedRegions, 'remote'); + } + } + + // Call refreshEligibility callback if available + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + // Also call refreshHip3Config if available + if (remoteFlags) { + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config(options); + } + }), + refreshHip3Config: jest.fn(), + setBlockedRegions: jest.fn((options: any) => { + // Simulate setBlockedRegions behavior + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + // Never downgrade from remote to fallback + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + // Call refreshEligibility callback if available + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }), +}; +jest.mock('../../src/services/FeatureFlagConfigurationService', () => ({ + FeatureFlagConfigurationService: jest + .fn() + .mockImplementation(() => mockFeatureFlagConfigurationServiceInstance), +})); + +/** + * Testable version of PerpsController that exposes protected methods for testing. + * This follows the pattern used in RewardsController.test.ts + */ +class TestablePerpsController extends PerpsController { + /** + * Test-only method to update state directly. + * Exposed for scenarios where state needs to be manipulated + * outside the normal public API (e.g., testing error conditions). + * @param callback + */ + public testUpdate(callback: (state: PerpsControllerState) => void) { + this.update(callback); + } + + /** + * Test-only method to mark controller as initialized. + * Common test scenario that requires internal state changes. + */ + public testMarkInitialized() { + this.isInitialized = true; + this.update((state) => { + state.initializationState = InitializationState.Initialized; + }); + } + + /** + * Test-only method to set the providers map with complete providers. + * Used in most tests to inject mock providers. + * Also sets activeProviderInstance to the first provider (default provider). + * @param providers + */ + public testSetProviders(providers: Map) { + this.providers = providers; + // Set activeProviderInstance to the first provider (typically 'hyperliquid') + const firstProvider = providers.values().next().value; + if (firstProvider) { + this.activeProviderInstance = firstProvider; + } + } + + /** + * Test-only method to set the providers map with partial providers. + * Used explicitly in tests that verify error handling with incomplete providers. + * Type cast is intentional and necessary for testing graceful degradation. + * @param providers + */ + public testSetPartialProviders( + providers: Map>, + ) { + this.providers = providers as Map; + } + + /** + * Test-only method to get the providers map. + * Used to verify provider state in tests. + */ + public testGetProviders(): Map { + return this.providers; + } + + /** + * Test-only method to set initialization state. + * Allows tests to simulate both initialized and uninitialized states. + * @param value + */ + public testSetInitialized(value: boolean) { + this.isInitialized = value; + } + + /** + * Test-only method to get initialization state. + * Used to verify initialization status in tests. + */ + public testGetInitialized(): boolean { + return this.isInitialized; + } + + /** + * Test-only method to get blocked region list. + * Used to verify geo-blocking configuration in tests. + */ + public testGetBlockedRegionList(): { source: string; list: string[] } { + return this.blockedRegionList; + } + + /** + * Test-only method to set blocked region list. + * Used to test priority logic (remote vs fallback). + * @param list + * @param source + */ + public testSetBlockedRegionList( + list: string[], + source: 'remote' | 'fallback', + ) { + this.setBlockedRegionList(list, source); + } + + /** + * Test accessor for protected method refreshEligibilityOnFeatureFlagChange. + * Wrapper is necessary because protected methods can't be called from test code. + * @param remoteFlags + */ + public testRefreshEligibilityOnFeatureFlagChange(remoteFlags: any) { + this.refreshEligibilityOnFeatureFlagChange(remoteFlags); + } + + /** + * Test accessor for protected method reportOrderToDataLake. + * Wrapper is necessary because protected methods can't be called from test code. + * @param data + */ + public testReportOrderToDataLake(data: any): Promise { + return this.reportOrderToDataLake(data); + } + + public testHasStandaloneProvider(): boolean { + return this.hasStandaloneProvider(); + } + + public testRegisterMYXProvider( + MYXProvider: new (opts: Record) => PerpsProvider, + ) { + this.registerMYXProvider(MYXProvider as never); + } + + public testHandleMYXImportError(error: unknown) { + this.handleMYXImportError(error); + } +} + +describe('PerpsController', () => { + let controller: TestablePerpsController; + let mockProvider: jest.Mocked; + let mockInfrastructure: jest.Mocked; + + // Helper to mark controller as initialized for tests + const markControllerAsInitialized = () => { + controller.testMarkInitialized(); + }; + + beforeEach(() => { + jest.clearAllMocks(); + + ( + jest.requireMock('../../src/services/EligibilityService') + .EligibilityService as jest.Mock + ).mockImplementation(() => mockEligibilityServiceInstance); + ( + jest.requireMock('../../src/services/DepositService') + .DepositService as jest.Mock + ).mockImplementation(() => mockDepositServiceInstance); + ( + jest.requireMock('../../src/services/MarketDataService') + .MarketDataService as jest.Mock + ).mockImplementation(() => mockMarketDataServiceInstance); + ( + jest.requireMock('../../src/services/TradingService') + .TradingService as jest.Mock + ).mockImplementation(() => mockTradingServiceInstance); + ( + jest.requireMock('../../src/services/AccountService') + .AccountService as jest.Mock + ).mockImplementation(() => mockAccountServiceInstance); + ( + jest.requireMock('../../src/services/DataLakeService') + .DataLakeService as jest.Mock + ).mockImplementation(() => mockDataLakeServiceInstance); + ( + jest.requireMock('../../src/services/FeatureFlagConfigurationService') + .FeatureFlagConfigurationService as jest.Mock + ).mockImplementation(() => mockFeatureFlagConfigurationServiceInstance); + + mockEligibilityServiceInstance.checkEligibility.mockResolvedValue(true); + mockMarketDataServiceInstance.getPositions.mockResolvedValue([]); + mockMarketDataServiceInstance.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockMarketDataServiceInstance.getMarkets.mockResolvedValue([]); + mockMarketDataServiceInstance.getWithdrawalRoutes.mockReturnValue([]); + mockMarketDataServiceInstance.validateClosePosition.mockResolvedValue({ + isValid: true, + }); + mockMarketDataServiceInstance.calculateMaintenanceMargin.mockResolvedValue( + 0, + ); + mockMarketDataServiceInstance.calculateFees.mockResolvedValue({ + totalFee: 0, + }); + mockMarketDataServiceInstance.getAvailableDexs.mockResolvedValue([]); + + mockFeatureFlagConfigurationServiceInstance.refreshEligibility.mockImplementation( + (options: any) => { + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList( + remoteBlockedRegions, + 'remote', + ); + } + } + + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + if (remoteFlags) { + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config( + options, + ); + } + }, + ); + mockFeatureFlagConfigurationServiceInstance.setBlockedRegions.mockImplementation( + (options: any) => { + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }, + ); + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config.mockImplementation( + () => undefined, + ); + + // Reset Engine.context mocks to default state to prevent test interdependence + ( + Engine.context.RewardsController.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(null); + ( + Engine.context.NetworkController.getNetworkClientById as jest.Mock + ).mockReturnValue({ configuration: { chainId: '0x1' } }); + + // Create a fresh mock provider for each test + mockProvider = createMockHyperLiquidProvider(); + + // Add default mock return values for all provider methods + mockProvider.getPositions.mockResolvedValue([]); + mockProvider.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockProvider.getMarkets.mockResolvedValue([]); + mockProvider.getOpenOrders.mockResolvedValue([]); + mockProvider.getFunding.mockResolvedValue([]); + mockProvider.getOrderFills.mockResolvedValue([]); + mockProvider.getOrders.mockResolvedValue([]); + mockProvider.calculateLiquidationPrice.mockResolvedValue('0'); + mockProvider.getMaxLeverage.mockResolvedValue(50); + mockProvider.calculateMaintenanceMargin.mockResolvedValue(0); + mockProvider.calculateFees.mockResolvedValue({ feeAmount: 0 }); + mockProvider.getBlockExplorerUrl.mockReturnValue( + 'https://explorer.example.com', + ); + mockProvider.getWithdrawalRoutes.mockReturnValue([]); + + ( + HyperLiquidProvider as jest.MockedClass + ).mockImplementation(() => mockProvider); + + const mockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: [], + }, + }, + }; + } + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + id: 'account-1', + options: {}, + scopes: ['eip155:1'], + methods: [], + metadata: { + name: 'Test', + importTime: 0, + keyring: { type: 'HD Key Tree' }, + }, + }, + ]; + } + return undefined; + }); + + mockInfrastructure = createMockInfrastructure(); + controller = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: mockInfrastructure, + }); + }); + + afterEach(() => { + // Clear only provider mocks, not Engine.context mocks + // This prevents breaking Engine.context.RewardsController/NetworkController references + if (mockProvider) { + Object.values(mockProvider).forEach((value) => { + if ( + typeof value === 'object' && + value !== null && + 'mockClear' in value + ) { + (value as jest.Mock).mockClear(); + } + }); + } + (mockInfrastructure.metrics.trackPerpsEvent as jest.Mock).mockClear(); + (mockInfrastructure.logger.error as jest.Mock).mockClear(); + (mockInfrastructure.debugLogger.log as jest.Mock).mockClear(); + }); + describe('resetSelectedPaymentToken', () => { + it('sets selectedPaymentToken to null', () => { + controller.testUpdate((state) => { + state.selectedPaymentToken = { + description: 'USDC', + address: '0xa0b8', + chainId: '0x1', + } as PerpsControllerState['selectedPaymentToken']; + }); + + controller.resetSelectedPaymentToken(); + + expect(controller.state.selectedPaymentToken).toBeNull(); + }); + }); + + describe('switchProvider', () => { + it('returns success as no-op before init() when already on requested provider', async () => { + // Before init(), providers map is empty. + // switchProvider should still succeed as a no-op because activeProvider already matches. + const result = await controller.switchProvider('hyperliquid'); + + expect(result.success).toBe(true); + expect(result.providerId).toBe('hyperliquid'); + }); + + it('returns success without re-init when switching to same provider', async () => { + await controller.init(); + + const result = await controller.switchProvider('hyperliquid'); + + expect(result.success).toBe(true); + expect(result.providerId).toBe('hyperliquid'); + }); + + it('returns error when already reinitializing', async () => { + await controller.init(); + + // Register myx in providers map so it passes the isValidProvider check + const mockMYXProvider = { + ...createMockHyperLiquidProvider(), + protocolId: 'myx', + }; + const providers = controller.testGetProviders(); + providers.set('myx', mockMYXProvider as any); + controller.testSetProviders(providers); + + jest.spyOn(controller, 'isCurrentlyReinitializing').mockReturnValue(true); + + const result = await controller.switchProvider('myx'); + + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.CLIENT_REINITIALIZING); + }); + + it('returns error for invalid provider not in providers map', async () => { + await controller.init(); + + const result = await controller.switchProvider('myx'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Provider myx not available'); + }); + + it('allows aggregated even without explicit map entry', async () => { + await controller.init(); + + // 'aggregated' is always valid according to the validation logic + const result = await controller.switchProvider('aggregated'); + + // The key assertion is that it didn't return "not available" error + // aggregated proceeds to the init path and succeeds + expect(result.success).toBe(true); + }); + + it('switches to myx provider successfully', async () => { + // Create controller with MYX-enabled mocks + const myxInfrastructure = createMockInfrastructure(); + ( + myxInfrastructure.featureFlags.validateVersionGated as jest.Mock + ).mockReturnValue(true); + // Enable MYX feature flag via messenger + const myxMockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { blockedRegions: [] }, + perpsMyxProviderEnabled: { + enabled: true, + minimumVersion: '0.0.0', + }, + }, + }; + } + return undefined; + }); + + const myxController = new TestablePerpsController({ + messenger: createMockMessenger({ call: myxMockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: myxInfrastructure, + }); + + await myxController.init(); + + // Register a mock MYX provider + const mockMYXProvider = { + ...createMockHyperLiquidProvider(), + protocolId: 'myx', + }; + const providers = myxController.testGetProviders(); + providers.set('myx', mockMYXProvider as any); + myxController.testSetProviders(providers); + + // Mock init on the reinit call inside switchProvider. + // Dynamic import() rejects in Jest (no --experimental-vm-modules), + // so MYX can't register via #createProviders. Mock init to + // simulate successful reinitialization while preserving our + // manually-injected MYX provider in the map. + jest.spyOn(myxController, 'init').mockImplementationOnce(async () => { + myxController.testUpdate((state) => { + state.initializationState = InitializationState.Initialized; + }); + }); + + const result = await myxController.switchProvider('myx'); + + expect(result.success).toBe(true); + expect(result.providerId).toBe('myx'); + expect(myxController.state.activeProvider).toBe('myx'); + }); + + it('rolls back to previous provider on init failure', async () => { + await controller.init(); + + // Register a mock MYX provider + const mockMYXProvider = { + ...createMockHyperLiquidProvider(), + protocolId: 'myx', + }; + const providers = controller.testGetProviders(); + providers.set('myx', mockMYXProvider as any); + controller.testSetProviders(providers); + + // Make init set state to Failed so switchProvider detects failure + jest.spyOn(controller, 'init').mockImplementationOnce(async () => { + controller.testUpdate((state) => { + state.initializationState = InitializationState.Failed; + state.initializationError = 'MYX init failed'; + }); + }); + + const result = await controller.switchProvider('myx'); + + expect(result.success).toBe(false); + // Should roll back to previous provider + expect(controller.state.activeProvider).toBe('hyperliquid'); + + // Restore init for further tests + jest.restoreAllMocks(); + }); + + it('clears isReinitializing flag after success', async () => { + await controller.init(); + + const mockMYXProvider = { + ...createMockHyperLiquidProvider(), + protocolId: 'myx', + }; + const providers = controller.testGetProviders(); + providers.set('myx', mockMYXProvider as any); + controller.testSetProviders(providers); + + await controller.switchProvider('myx'); + + expect(controller.isCurrentlyReinitializing()).toBe(false); + }); + + it('clears isReinitializing flag after failure', async () => { + await controller.init(); + + const mockMYXProvider = { + ...createMockHyperLiquidProvider(), + protocolId: 'myx', + }; + const providers = controller.testGetProviders(); + providers.set('myx', mockMYXProvider as any); + controller.testSetProviders(providers); + + jest.spyOn(controller, 'init').mockImplementationOnce(async () => { + controller.testUpdate((state) => { + state.initializationState = InitializationState.Failed; + state.initializationError = 'fail'; + }); + }); + + await controller.switchProvider('myx'); + + expect(controller.isCurrentlyReinitializing()).toBe(false); + + jest.restoreAllMocks(); + }); + }); + + describe('init - MYX fallback', () => { + it('falls back to hyperliquid when activeProvider is myx but MYX feature flag is disabled', async () => { + // Set state to myx before init + controller.testUpdate((state) => { + state.activeProvider = 'myx'; + }); + + // isMYXProviderEnabled() returns false by default (no perpsMyxProviderEnabled in remote flags) + await controller.init(); + + // The init path should detect MYX is not available and fall back + expect(controller.state.activeProvider).toBe('hyperliquid'); + }); + + it('registerMYXProvider creates and registers the MYX provider', () => { + // Arrange + const mockMYXInstance = createMockHyperLiquidProvider(); + const MockMYXConstructor = jest.fn(() => mockMYXInstance); + + // Act + controller.testRegisterMYXProvider( + MockMYXConstructor as unknown as new ( + opts: Record, + ) => PerpsProvider, + ); + + // Assert + const providers = controller.testGetProviders(); + expect(providers.get('myx')).toBe(mockMYXInstance); + expect(MockMYXConstructor).toHaveBeenCalledWith( + expect.objectContaining({ isTestnet: false }), + ); + }); + + it('handleMYXImportError logs debug for MODULE_NOT_FOUND errors', () => { + // Arrange — Node sets code: 'MODULE_NOT_FOUND' on missing modules + const moduleError = Object.assign( + new Error('Cannot find module ./providers/MYXProvider'), + { code: 'MODULE_NOT_FOUND' }, + ); + + // Act + controller.testHandleMYXImportError(moduleError); + + // Assert + expect(mockInfrastructure.debugLogger.log).toHaveBeenCalledWith( + 'PerpsController: MYX provider module not available, skipping registration', + ); + }); + + it('handleMYXImportError routes runtime errors to logError', () => { + // Act — error without MODULE_NOT_FOUND code goes to Sentry + controller.testHandleMYXImportError(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.myx', + }), + }), + }), + ); + }); + }); + + describe('getOpenOrders with standalone mode', () => { + const mockUserAddress = '0xabcdef1234567890abcdef1234567890abcdef12'; + const MockedHyperLiquidProvider = HyperLiquidProvider as jest.MockedClass< + typeof HyperLiquidProvider + >; + + beforeEach(() => { + MockedHyperLiquidProvider.mockClear(); + }); + + it('uses existing provider for standalone queries when available', async () => { + const mockOrders = [ + { + orderId: 'o1', + symbol: 'BTC', + side: 'buy' as const, + orderType: 'limit' as const, + size: '0.1', + originalSize: '0.1', + filledSize: '0', + remainingSize: '0.1', + price: '50000', + status: 'open' as const, + timestamp: Date.now(), + }, + ]; + const existingMockProvider = createMockHyperLiquidProvider(); + existingMockProvider.getOpenOrders.mockResolvedValue(mockOrders); + controller.testSetProviders( + new Map([['hyperliquid', existingMockProvider]]), + ); + controller.testMarkInitialized(); + controller.testUpdate((state) => { + state.activeProvider = 'hyperliquid'; + }); + + const result = await controller.getOpenOrders({ + standalone: true, + userAddress: mockUserAddress, + }); + + expect(existingMockProvider.getOpenOrders).toHaveBeenCalledWith({ + standalone: true, + userAddress: mockUserAddress, + }); + expect(result).toEqual(mockOrders); + expect(MockedHyperLiquidProvider).not.toHaveBeenCalled(); + }); + + it('creates temporary provider for standalone queries when no activeProviderInstance', async () => { + const mockOrders = [ + { + orderId: 'o2', + symbol: 'ETH', + side: 'sell' as const, + orderType: 'market' as const, + size: '1', + originalSize: '1', + filledSize: '0', + remainingSize: '1', + price: '3000', + status: 'open' as const, + timestamp: Date.now(), + }, + ]; + const tempMockProvider = createMockHyperLiquidProvider(); + tempMockProvider.getOpenOrders.mockResolvedValue(mockOrders); + MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); + + controller.testUpdate((state) => { + state.activeProvider = 'aggregated'; + state.isTestnet = true; + }); + + const result = await controller.getOpenOrders({ + standalone: true, + userAddress: mockUserAddress, + }); + + expect(MockedHyperLiquidProvider).toHaveBeenCalledWith( + expect.objectContaining({ isTestnet: true }), + ); + expect(result).toEqual(mockOrders); + }); + + it('bypasses getActiveProvider check for standalone queries', async () => { + const tempMockProvider = createMockHyperLiquidProvider(); + tempMockProvider.getOpenOrders.mockResolvedValue([]); + MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); + + controller.testUpdate((state) => { + state.initializationState = InitializationState.Initializing; + state.activeProvider = 'aggregated'; + }); + + const result = await controller.getOpenOrders({ + standalone: true, + userAddress: mockUserAddress, + }); + + expect(result).toEqual([]); + }); + }); + + describe('getMarketDataWithPrices with standalone mode', () => { + const MockedHyperLiquidProvider = HyperLiquidProvider as jest.MockedClass< + typeof HyperLiquidProvider + >; + + beforeEach(() => { + MockedHyperLiquidProvider.mockClear(); + }); + + it('uses existing provider for standalone queries when available', async () => { + const mockMarketData = [ + { + symbol: 'BTC', + name: 'BTC', + price: '50000', + maxLeverage: '50x', + change24h: '+100', + change24hPercent: '+0.2%', + volume: '$1B', + }, + ]; + const existingMockProvider = createMockHyperLiquidProvider(); + existingMockProvider.getMarketDataWithPrices.mockResolvedValue( + mockMarketData, + ); + controller.testSetProviders( + new Map([['hyperliquid', existingMockProvider]]), + ); + controller.testMarkInitialized(); + controller.testUpdate((state) => { + state.activeProvider = 'hyperliquid'; + }); + + const result = await controller.getMarketDataWithPrices({ + standalone: true, + }); + + expect(existingMockProvider.getMarketDataWithPrices).toHaveBeenCalled(); + expect(result).toEqual(mockMarketData); + expect(MockedHyperLiquidProvider).not.toHaveBeenCalled(); + }); + + it('creates temporary provider for standalone queries when no activeProviderInstance', async () => { + const mockMarketData = [ + { + symbol: 'ETH', + name: 'ETH', + price: '3000', + maxLeverage: '50x', + change24h: '+50', + change24hPercent: '+1.7%', + volume: '$500M', + }, + ]; + const tempMockProvider = createMockHyperLiquidProvider(); + tempMockProvider.getMarketDataWithPrices.mockResolvedValue( + mockMarketData, + ); + MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); + + controller.testUpdate((state) => { + state.activeProvider = 'aggregated'; + state.isTestnet = false; + }); + + const result = await controller.getMarketDataWithPrices({ + standalone: true, + }); + + expect(MockedHyperLiquidProvider).toHaveBeenCalledWith( + expect.objectContaining({ isTestnet: false }), + ); + expect(result).toEqual(mockMarketData); + }); + + it('uses getActiveProvider for non-standalone queries', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.getMarketDataWithPrices.mockResolvedValue([]); + + const result = await controller.getMarketDataWithPrices(); + + expect(mockProvider.getMarketDataWithPrices).toHaveBeenCalled(); + expect(result).toEqual([]); + }); + }); + + describe('startMarketDataPreload and stopMarketDataPreload', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + controller.stopMarketDataPreload(); + jest.useRealTimers(); + }); + + it('is idempotent - calling start twice does not create duplicate timers', () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.getMarketDataWithPrices.mockResolvedValue([]); + + controller.startMarketDataPreload(); + controller.startMarketDataPreload(); + + // Advance timers past the preload interval (5 min) to verify no double calls + jest.advanceTimersByTime(5 * 60 * 1000 + 100); + + // performMarketDataPreload calls getMarketDataWithPrices({ standalone: true }) + // The first immediate call happens, then only 1 interval call (not 2) + // With isPreloading guard, second immediate call is skipped + expect(mockInfrastructure.debugLogger.log).toHaveBeenCalledWith( + 'PerpsController: Preload already started, skipping', + ); + }); + + it('calls performMarketDataPreload immediately on start', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.getMarketDataWithPrices.mockResolvedValue([ + { + symbol: 'BTC', + name: 'BTC', + price: '50000', + maxLeverage: '50x', + change24h: '+100', + change24hPercent: '+0.2%', + volume: '$1B', + }, + ]); + + controller.startMarketDataPreload(); + + // Wait for the async performMarketDataPreload to complete + await jest.advanceTimersByTimeAsync(100); + + expect(mockInfrastructure.debugLogger.log).toHaveBeenCalledWith( + 'PerpsController: Fetching market data in background', + ); + }); + + it('stopMarketDataPreload clears interval', () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.getMarketDataWithPrices.mockResolvedValue([]); + + controller.startMarketDataPreload(); + controller.stopMarketDataPreload(); + + // After stop, advancing timers should not trigger more calls + const callCountBefore = + mockProvider.getMarketDataWithPrices.mock.calls.length; + jest.advanceTimersByTime(10 * 60 * 1000); + const callCountAfter = + mockProvider.getMarketDataWithPrices.mock.calls.length; + + // No new calls should have been made after stop + expect(callCountAfter).toBe(callCountBefore); + }); + + it('stopMarketDataPreload is safe to call when not started', () => { + expect(() => controller.stopMarketDataPreload()).not.toThrow(); + }); + + it('hydrates market data from disk at construction time', () => { + const diskMarkets = { + providerNetworkKey: 'hyperliquid:mainnet', + data: [ + { + symbol: 'BTC', + name: 'Bitcoin', + price: '50000', + change24h: '+100', + change24hPercent: '+0.2%', + maxLeverage: '50x', + volume: '$1B', + }, + ], + timestamp: Date.now(), + }; + const infra = createMockInfrastructure(); + (infra.diskCache.getItemSync as jest.Mock).mockImplementation( + (key: string) => { + if (key === PERPS_DISK_CACHE_MARKETS) { + return JSON.stringify(diskMarkets); + } + return null; + }, + ); + + const ctrl = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + infrastructure: infra, + }); + + const cached = + ctrl.state.cachedMarketDataByProvider['hyperliquid:mainnet']; + expect(cached).not.toBeNull(); + expect(cached?.data).toHaveLength(1); + expect(cached?.data[0].symbol).toBe('BTC'); + // Prices are stripped to placeholder values + expect(cached?.data[0].price).toBe(PERPS_CONSTANTS.FallbackPriceDisplay); + expect(cached?.data[0].change24h).toBe( + PERPS_CONSTANTS.FallbackDataDisplay, + ); + expect(cached?.data[0].change24hPercent).toBe( + PERPS_CONSTANTS.FallbackPercentageDisplay, + ); + expect(ctrl.getCachedMarketDataForActiveProvider()).toBeNull(); + expect( + ctrl.getCachedMarketDataForActiveProvider({ skipTTL: true }), + ).toHaveLength(1); + }); + + it('hydrates multi-provider market data from disk before providers register', () => { + const timestamp = Date.now(); + const diskMarkets = { + entries: [ + { + providerNetworkKey: 'hyperliquid:mainnet', + data: [ + { + symbol: 'BTC', + name: 'Bitcoin', + price: '50000', + change24h: '+100', + change24hPercent: '+0.2%', + maxLeverage: '50x', + volume: '$1B', + }, + ], + timestamp, + }, + { + providerNetworkKey: 'myx:mainnet', + data: [ + { + symbol: 'ETH', + name: 'Ethereum', + price: '3000', + change24h: '+50', + change24hPercent: '+1.2%', + maxLeverage: '25x', + volume: '$500M', + }, + ], + timestamp, + }, + ], + }; + const infra = createMockInfrastructure(); + (infra.diskCache.getItemSync as jest.Mock).mockImplementation( + (key: string) => { + if (key === PERPS_DISK_CACHE_MARKETS) { + return JSON.stringify(diskMarkets); + } + return null; + }, + ); + + const ctrl = new TestablePerpsController({ + messenger: createMockMessenger(), + state: { + ...getDefaultPerpsControllerState(), + activeProvider: 'aggregated', + }, + clientConfig: { + providerCredentials: { + myx: { + enabled: true, + }, + }, + } as never, + infrastructure: infra, + }); + + const aggregated = ctrl.getCachedMarketDataForActiveProvider({ + skipTTL: true, + }); + expect(aggregated).toHaveLength(2); + expect(aggregated?.map((market) => market.symbol)).toEqual([ + 'BTC', + 'ETH', + ]); + }); + + it('hydrates aggregated user data from disk before providers register', () => { + const timestamp = Date.now(); + const diskUserData = { + entries: [ + { + providerNetworkKey: 'hyperliquid:mainnet', + address: '0x1234567890abcdef1234567890abcdef12345678', + positions: [createMockPosition({ symbol: 'BTC', size: '1.0' })], + orders: [], + accountState: { + totalBalance: '5000', + spendableBalance: '4000', + withdrawableBalance: '4000', + marginUsed: '1000', + unrealizedPnl: '0', + returnOnEquity: '0', + providerId: 'hyperliquid', + }, + timestamp, + }, + { + providerNetworkKey: 'myx:mainnet', + address: '0x1234567890abcdef1234567890abcdef12345678', + positions: [createMockPosition({ symbol: 'MYX', size: '2.0' })], + orders: [], + accountState: null, + timestamp, + }, + ], + }; + const infra = createMockInfrastructure(); + (infra.diskCache.getItemSync as jest.Mock).mockImplementation( + (key: string) => { + if (key === PERPS_DISK_CACHE_USER_DATA) { + return JSON.stringify(diskUserData); + } + return null; + }, + ); + + const ctrl = new TestablePerpsController({ + messenger: createMockMessenger(), + state: { + ...getDefaultPerpsControllerState(), + activeProvider: 'aggregated', + }, + clientConfig: { + providerCredentials: { + myx: { + enabled: true, + }, + }, + } as never, + infrastructure: infra, + }); + + const aggregated = ctrl.getCachedUserDataForActiveProvider({ + skipTTL: true, + }); + expect(aggregated?.positions).toHaveLength(2); + expect(aggregated?.accountState?.providerId).toBe('hyperliquid'); + }); + + it('hydrates user data from disk at construction time', () => { + const diskUserData = { + providerNetworkKey: 'hyperliquid:mainnet', + address: '0x1234567890123456789012345678901234567890', + positions: [{ symbol: 'ETH', size: '2.0', entryPrice: '3000' }], + orders: [], + accountState: { + totalBalance: '5000', + spendableBalance: '4000', + withdrawableBalance: '4000', + marginUsed: '1000', + unrealizedPnl: '0', + returnOnEquity: '0', + }, + timestamp: Date.now(), + }; + const infra = createMockInfrastructure(); + (infra.diskCache.getItemSync as jest.Mock).mockImplementation( + (key: string) => { + if (key === PERPS_DISK_CACHE_USER_DATA) { + return JSON.stringify(diskUserData); + } + return null; + }, + ); + + const ctrl = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + infrastructure: infra, + }); + + const cached = ctrl.state.cachedUserDataByProvider['hyperliquid:mainnet']; + expect(cached).not.toBeNull(); + expect(cached?.positions).toHaveLength(1); + expect(cached?.positions[0].symbol).toBe('ETH'); + expect(cached?.address).toBe( + '0x1234567890123456789012345678901234567890', + ); + }); + + it('hydrates user data from disk even when address differs (filtered at read time)', () => { + const diskUserData = { + providerNetworkKey: 'hyperliquid:mainnet', + address: '0xDEADBEEF00000000000000000000000000000000', + positions: [{ symbol: 'ETH', size: '2.0' }], + orders: [], + accountState: null, + timestamp: Date.now(), + }; + const infra = createMockInfrastructure(); + (infra.diskCache.getItemSync as jest.Mock).mockImplementation( + (key: string) => { + if (key === PERPS_DISK_CACHE_USER_DATA) { + return JSON.stringify(diskUserData); + } + return null; + }, + ); + + const ctrl = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + infrastructure: infra, + }); + + // Sync hydration populates cache unconditionally — address + // validation happens in getCachedUserDataForActiveProvider at read time + const cached = ctrl.state.cachedUserDataByProvider['hyperliquid:mainnet']; + expect(cached).not.toBeNull(); + expect(cached?.address).toBe( + '0xDEADBEEF00000000000000000000000000000000', + ); + + // But getCachedUserDataForActiveProvider filters it out (address mismatch) + const read = ctrl.getCachedUserDataForActiveProvider({ + skipTTL: true, + }); + expect(read).toBeNull(); + }); + + it('does not overwrite fresher in-memory state from older disk data', () => { + const freshTimestamp = Date.now(); + const diskMarkets = { + providerNetworkKey: 'hyperliquid:mainnet', + data: [{ symbol: 'BTC', name: 'Bitcoin', price: '50000' }], + timestamp: freshTimestamp - 60_000, // older than initial state + }; + const infra = createMockInfrastructure(); + (infra.diskCache.getItemSync as jest.Mock).mockImplementation( + (key: string) => { + if (key === PERPS_DISK_CACHE_MARKETS) { + return JSON.stringify(diskMarkets); + } + return null; + }, + ); + + // Construct with fresher in-memory data already present + const ctrl = new TestablePerpsController({ + messenger: createMockMessenger(), + state: { + ...getDefaultPerpsControllerState(), + cachedMarketDataByProvider: { + 'hyperliquid:mainnet': { + data: [{ symbol: 'ETH', name: 'Ethereum', price: '3000' } as any], + timestamp: freshTimestamp, + }, + }, + }, + infrastructure: infra, + }); + + const cached = + ctrl.state.cachedMarketDataByProvider['hyperliquid:mainnet']; + expect(cached?.data[0].symbol).toBe('ETH'); + expect(cached?.timestamp).toBe(freshTimestamp); + }); + + it('handles corrupt disk JSON gracefully at construction', () => { + const infra = createMockInfrastructure(); + (infra.diskCache.getItemSync as jest.Mock).mockReturnValue( + 'not valid json{{{', + ); + + const ctrl = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + infrastructure: infra, + }); + + expect( + ctrl.state.cachedMarketDataByProvider['hyperliquid:mainnet'], + ).toBeUndefined(); + }); + + it('falls back gracefully when getItemSync is undefined', () => { + const infra = createMockInfrastructure(); + (infra.diskCache as any).getItemSync = undefined; + + const ctrl = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + infrastructure: infra, + }); + + expect( + ctrl.state.cachedMarketDataByProvider['hyperliquid:mainnet'], + ).toBeUndefined(); + }); + }); + + describe('performMarketDataPreload', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + controller.stopMarketDataPreload(); + jest.useRealTimers(); + }); + + it('updates cachedMarketData in state', async () => { + const mockData = [ + { + symbol: 'BTC', + name: 'BTC', + price: '50000', + maxLeverage: '50x', + change24h: '+100', + change24hPercent: '+0.2%', + volume: '$1B', + }, + ]; + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.getMarketDataWithPrices.mockResolvedValue(mockData); + + controller.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + + const entry = + controller.state.cachedMarketDataByProvider['hyperliquid:mainnet']; + expect(entry?.data).toEqual(mockData); + expect(entry?.timestamp).toBeGreaterThan(0); + }); + + it('persists preloaded market data to disk', async () => { + const mockData = [ + { + symbol: 'BTC', + name: 'BTC', + price: '50000', + maxLeverage: '50x', + change24h: '+100', + change24hPercent: '+0.2%', + volume: '$1B', + }, + ]; + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.getMarketDataWithPrices.mockResolvedValue(mockData); + + controller.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + + expect(mockInfrastructure.diskCache.setItem).toHaveBeenCalledWith( + PERPS_DISK_CACHE_MARKETS, + expect.any(String), + ); + + const persistedPayload = JSON.parse( + (mockInfrastructure.diskCache.setItem as jest.Mock).mock.calls.find( + ([key]) => key === PERPS_DISK_CACHE_MARKETS, + )?.[1] as string, + ); + + expect(persistedPayload.providerNetworkKey).toBe('hyperliquid:mainnet'); + expect(persistedPayload.data).toEqual(mockData); + expect(persistedPayload.timestamp).toBeGreaterThan(0); + }); + + it('respects 30s debounce guard', async () => { + const mockData = [ + { + symbol: 'BTC', + name: 'BTC', + price: '50000', + maxLeverage: '50x', + change24h: '+100', + change24hPercent: '+0.2%', + volume: '$1B', + }, + ]; + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.getMarketDataWithPrices.mockResolvedValue(mockData); + + // First preload + controller.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + + const callCount = mockProvider.getMarketDataWithPrices.mock.calls.length; + + // Advance by less than 30s and trigger interval + controller.stopMarketDataPreload(); + // Set timestamp to recent to trigger debounce guard + controller.testUpdate((state) => { + state.cachedMarketDataByProvider['hyperliquid:mainnet'] = { + data: mockData, + timestamp: Date.now(), + }; + }); + controller.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + + // Should not have called again due to debounce + // The second immediate call is debounced + expect(mockProvider.getMarketDataWithPrices.mock.calls.length).toBe( + callCount, + ); + }); + + it('handles errors without throwing', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.getMarketDataWithPrices.mockRejectedValue( + new Error('API failed'), + ); + + controller.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + + // Should log error but not throw + expect(mockInfrastructure.logger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + context: expect.objectContaining({ + data: expect.objectContaining({ + method: 'performMarketDataPreload', + }), + }), + }), + ); + }); + + it('traces performance via tracer', async () => { + const mockData = [ + { + symbol: 'BTC', + name: 'BTC', + price: '50000', + maxLeverage: '50x', + change24h: '+100', + change24hPercent: '+0.2%', + volume: '$1B', + }, + ]; + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.getMarketDataWithPrices.mockResolvedValue(mockData); + + controller.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + + expect(mockInfrastructure.tracer.trace).toHaveBeenCalled(); + expect(mockInfrastructure.tracer.endTrace).toHaveBeenCalled(); + expect(mockInfrastructure.tracer.setMeasurement).toHaveBeenCalled(); + }); + }); + + describe('performUserDataPreload', () => { + // Import actual enum for type compatibility + const { WebSocketConnectionState: WSState } = + jest.requireActual('../../src/types'); + const mockEvmAccount = { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + id: 'account-1', + options: {}, + scopes: ['eip155:1'], + methods: [], + metadata: { + name: 'Test', + importTime: 0, + keyring: { type: 'HD Key Tree' }, + }, + }; + + let preloadController: TestablePerpsController; + let preloadMockProvider: jest.Mocked; + let preloadInfrastructure: jest.Mocked; + + beforeEach(() => { + jest.useFakeTimers(); + // Create controller with messenger that handles account queries + const mockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { blockedRegions: [] }, + }, + }; + } + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [mockEvmAccount]; + } + return undefined; + }); + preloadInfrastructure = createMockInfrastructure(); + preloadMockProvider = createMockHyperLiquidProvider(); + preloadMockProvider.getPositions.mockResolvedValue([]); + preloadMockProvider.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + preloadMockProvider.getMarkets.mockResolvedValue([]); + preloadMockProvider.getOpenOrders.mockResolvedValue([]); + ( + HyperLiquidProvider as jest.MockedClass + ).mockImplementation(() => preloadMockProvider); + preloadController = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: preloadInfrastructure, + }); + }); + + afterEach(() => { + preloadController.stopMarketDataPreload(); + jest.useRealTimers(); + }); + + it('fetches positions, orders, and account state', async () => { + const mockPositions = [createMockPosition()]; + const mockOrders = [ + { + orderId: 'o1', + symbol: 'BTC', + side: 'buy' as const, + orderType: 'limit' as const, + size: '0.1', + originalSize: '0.1', + filledSize: '0', + remainingSize: '0.1', + price: '50000', + status: 'open' as const, + timestamp: Date.now(), + }, + ]; + const mockAccountState: AccountState = { + totalBalance: '50000', + spendableBalance: '45000', + withdrawableBalance: '45000', + marginUsed: '5000', + unrealizedPnl: '1000', + returnOnEquity: '20', + }; + + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + preloadMockProvider.getPositions.mockResolvedValue(mockPositions); + preloadMockProvider.getOpenOrders.mockResolvedValue(mockOrders); + preloadMockProvider.getAccountState.mockResolvedValue(mockAccountState); + preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([ + { + symbol: 'BTC', + name: 'BTC', + price: '50000', + maxLeverage: '50x', + change24h: '+100', + change24hPercent: '+0.2%', + volume: '$1B', + }, + ]); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Disconnected, + ); + + preloadController.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(500); + + const userCache = preloadController.state.cachedUserDataByProvider; + const cacheKey = Object.keys(userCache)[0]; + expect(cacheKey).toBeDefined(); + const entry = userCache[cacheKey]; + expect(entry.positions).toEqual(mockPositions); + expect(entry.orders).toEqual(mockOrders); + expect(entry.accountState).toEqual(mockAccountState); + expect(entry.timestamp).toBeGreaterThan(0); + }); + + it('persists preloaded user data to disk', async () => { + const mockPositions = [createMockPosition()]; + const mockOrders = [ + { + orderId: 'o1', + symbol: 'BTC', + side: 'buy' as const, + orderType: 'limit' as const, + size: '0.1', + originalSize: '0.1', + filledSize: '0', + remainingSize: '0.1', + price: '50000', + status: 'open' as const, + timestamp: Date.now(), + }, + ]; + const mockAccountState: AccountState = { + totalBalance: '50000', + spendableBalance: '45000', + withdrawableBalance: '45000', + marginUsed: '5000', + unrealizedPnl: '1000', + returnOnEquity: '20', + }; + + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + preloadMockProvider.getPositions.mockResolvedValue(mockPositions); + preloadMockProvider.getOpenOrders.mockResolvedValue(mockOrders); + preloadMockProvider.getAccountState.mockResolvedValue(mockAccountState); + preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([ + { + symbol: 'BTC', + name: 'BTC', + price: '50000', + maxLeverage: '50x', + change24h: '+100', + change24hPercent: '+0.2%', + volume: '$1B', + }, + ]); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Disconnected, + ); + + preloadController.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(500); + + expect(preloadInfrastructure.diskCache.setItem).toHaveBeenCalledWith( + PERPS_DISK_CACHE_USER_DATA, + expect.any(String), + ); + + const persistedPayload = JSON.parse( + (preloadInfrastructure.diskCache.setItem as jest.Mock).mock.calls.find( + ([key]) => key === PERPS_DISK_CACHE_USER_DATA, + )?.[1] as string, + ); + + expect(persistedPayload.providerNetworkKey).toBe('hyperliquid:mainnet'); + expect(persistedPayload.address).toBe(mockEvmAccount.address); + expect(persistedPayload.positions).toEqual(mockPositions); + expect(persistedPayload.orders).toEqual(mockOrders); + expect(persistedPayload.accountState).toEqual(mockAccountState); + expect(persistedPayload.timestamp).toBeGreaterThan(0); + }); + + it('skips when WebSocket is connected', async () => { + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Connected, + ); + + preloadController.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(500); + + expect(preloadInfrastructure.debugLogger.log).toHaveBeenCalledWith( + 'PerpsController: Skipping user data preload \u2014 WebSocket connected', + ); + expect( + Object.keys(preloadController.state.cachedUserDataByProvider), + ).toHaveLength(0); + }); + + it('handles errors without throwing', async () => { + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); + preloadMockProvider.getPositions.mockRejectedValue( + new Error('positions error'), + ); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Disconnected, + ); + + preloadController.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(500); + + // Should not crash + expect( + Object.keys(preloadController.state.cachedUserDataByProvider), + ).toHaveLength(0); + }); + + it('skips when cache is fresh for same account', async () => { + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Disconnected, + ); + preloadMockProvider.getPositions.mockResolvedValue([]); + preloadMockProvider.getOpenOrders.mockResolvedValue([]); + preloadMockProvider.getAccountState.mockResolvedValue({ + spendableBalance: '100', + withdrawableBalance: '100', + totalBalance: '100', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + + // First preload — populates the cache + preloadController.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(500); + + const freshCache = preloadController.state.cachedUserDataByProvider; + const freshKey = Object.keys(freshCache)[0]; + expect(freshKey).toBeDefined(); + expect(freshCache[freshKey].address).toBe(mockEvmAccount.address); + expect(freshCache[freshKey].timestamp).toBeGreaterThan(0); + + // Reset call counts + preloadMockProvider.getPositions.mockClear(); + preloadMockProvider.getOpenOrders.mockClear(); + preloadMockProvider.getAccountState.mockClear(); + + // Trigger another preload cycle — should skip (cache is fresh, same account) + await jest.advanceTimersByTimeAsync(60_000); + + expect(preloadMockProvider.getPositions).not.toHaveBeenCalled(); + }); + }); + + describe('subscribe method hardening', () => { + it('subscribeToPrices returns no-op when provider is null', () => { + controller.testSetInitialized(false); + + const unsub = controller.subscribeToPrices({ + symbols: ['BTC'], + callback: jest.fn(), + }); + + expect(typeof unsub).toBe('function'); + // Should not throw + unsub(); + expect(mockProvider.subscribeToPrices).not.toHaveBeenCalled(); + }); + + it('subscribeToOrders returns no-op when provider is null', () => { + controller.testSetInitialized(false); + + const unsub = controller.subscribeToOrders({ callback: jest.fn() }); + + expect(typeof unsub).toBe('function'); + unsub(); + expect(mockProvider.subscribeToOrders).not.toHaveBeenCalled(); + }); + + it('subscribeToPositions returns no-op when provider is null', () => { + controller.testSetInitialized(false); + + const unsub = controller.subscribeToPositions({ + callback: jest.fn(), + }); + + expect(typeof unsub).toBe('function'); + unsub(); + expect(mockProvider.subscribeToPositions).not.toHaveBeenCalled(); + }); + + it('subscribeToOrderFills returns no-op when provider is null', () => { + controller.testSetInitialized(false); + + const unsub = controller.subscribeToOrderFills({ + callback: jest.fn(), + }); + + expect(typeof unsub).toBe('function'); + unsub(); + expect(mockProvider.subscribeToOrderFills).not.toHaveBeenCalled(); + }); + + it('subscribeToOrderBook returns no-op when provider is null', () => { + controller.testSetInitialized(false); + + const unsub = controller.subscribeToOrderBook({ + symbol: 'BTC', + callback: jest.fn(), + }); + + expect(typeof unsub).toBe('function'); + unsub(); + }); + + it('subscribeToCandles returns no-op when provider is null', () => { + controller.testSetInitialized(false); + + const unsub = controller.subscribeToCandles({ + symbol: 'BTC', + interval: '1h' as never, + callback: jest.fn(), + }); + + expect(typeof unsub).toBe('function'); + unsub(); + }); + + it('subscribeToOICaps returns no-op when provider is null', () => { + controller.testSetInitialized(false); + + const unsub = controller.subscribeToOICaps({ + callback: jest.fn(), + }); + + expect(typeof unsub).toBe('function'); + unsub(); + }); + }); + + describe('getCachedMarketDataForActiveProvider', () => { + it('returns null when no cache exists', () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testUpdate((state) => { + state.activeProvider = 'hyperliquid'; + }); + + const result = controller.getCachedMarketDataForActiveProvider(); + + expect(result).toBeNull(); + }); + + it('returns cached data for single provider', () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testUpdate((state) => { + state.activeProvider = 'hyperliquid'; + state.cachedMarketDataByProvider['hyperliquid:mainnet'] = { + data: [{ symbol: 'BTC', name: 'BTC', price: '50000' } as any], + timestamp: Date.now(), + }; + }); + + const result = controller.getCachedMarketDataForActiveProvider(); + + expect(result).toHaveLength(1); + expect(result?.[0].symbol).toBe('BTC'); + }); + + it('returns null when single provider cache is expired', () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testUpdate((state) => { + state.activeProvider = 'hyperliquid'; + state.cachedMarketDataByProvider['hyperliquid:mainnet'] = { + data: [{ symbol: 'BTC', name: 'BTC', price: '50000' } as any], + timestamp: Date.now() - 999_999_999, // very old + }; + }); + + const result = controller.getCachedMarketDataForActiveProvider(); + + expect(result).toBeNull(); + }); + + it('returns expired data when skipTTL is true', () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testUpdate((state) => { + state.activeProvider = 'hyperliquid'; + state.cachedMarketDataByProvider['hyperliquid:mainnet'] = { + data: [{ symbol: 'BTC', name: 'BTC', price: '50000' } as any], + timestamp: Date.now() - 999_999_999, // very old + }; + }); + + const result = controller.getCachedMarketDataForActiveProvider({ + skipTTL: true, + }); + + expect(result).toHaveLength(1); + expect(result?.[0].symbol).toBe('BTC'); + }); + + it('assembles data from multiple providers in aggregated mode', () => { + const mockMYXProvider = createMockHyperLiquidProvider(); + markControllerAsInitialized(); + controller.testSetProviders( + new Map([ + ['hyperliquid', mockProvider], + ['myx', mockMYXProvider], + ] as any), + ); + controller.testUpdate((state) => { + state.activeProvider = 'aggregated'; + state.cachedMarketDataByProvider['hyperliquid:mainnet'] = { + data: [ + { + symbol: 'BTC', + name: 'BTC', + price: '50000', + providerId: 'hyperliquid', + } as any, + ], + timestamp: Date.now(), + }; + state.cachedMarketDataByProvider['myx:mainnet'] = { + data: [ + { + symbol: 'MYX', + name: 'MYX', + price: '1', + providerId: 'myx', + } as any, + ], + timestamp: Date.now(), + }; + }); + + const result = controller.getCachedMarketDataForActiveProvider(); + + expect(result).toHaveLength(2); + const symbols = (result ?? []).map((m: any) => m.symbol); + expect(symbols).toEqual(expect.arrayContaining(['BTC', 'MYX'])); + }); + + it('returns null in aggregated mode when all provider caches are empty', () => { + const mockMYXProvider = createMockHyperLiquidProvider(); + markControllerAsInitialized(); + controller.testSetProviders( + new Map([ + ['hyperliquid', mockProvider], + ['myx', mockMYXProvider], + ] as any), + ); + controller.testUpdate((state) => { + state.activeProvider = 'aggregated'; + state.cachedMarketDataByProvider['hyperliquid:mainnet'] = { + data: [], + timestamp: Date.now(), + }; + }); + + const result = controller.getCachedMarketDataForActiveProvider(); + + expect(result).toBeNull(); + }); + + it('returns null in aggregated mode when oldest entry exceeds TTL', () => { + const mockMYXProvider = createMockHyperLiquidProvider(); + markControllerAsInitialized(); + controller.testSetProviders( + new Map([ + ['hyperliquid', mockProvider], + ['myx', mockMYXProvider], + ] as any), + ); + controller.testUpdate((state) => { + state.activeProvider = 'aggregated'; + state.cachedMarketDataByProvider['hyperliquid:mainnet'] = { + data: [{ symbol: 'BTC', name: 'BTC', price: '50000' } as any], + timestamp: Date.now() - 999_999_999, // very old + }; + state.cachedMarketDataByProvider['myx:mainnet'] = { + data: [{ symbol: 'MYX', name: 'MYX', price: '1' } as any], + timestamp: Date.now(), // fresh + }; + }); + + const result = controller.getCachedMarketDataForActiveProvider(); + + expect(result).toBeNull(); + }); + }); + + describe('getCachedUserDataForActiveProvider', () => { + const mockAddress = '0x1234567890123456789012345678901234567890'; + + it('returns null when no cache exists', () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testUpdate((state) => { + state.activeProvider = 'hyperliquid'; + }); + + const result = controller.getCachedUserDataForActiveProvider(); + + expect(result).toBeNull(); + }); + + it('returns cached user data for single provider', () => { + const mockPosition = createMockPosition({ symbol: 'BTC', size: '1.0' }); + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testUpdate((state) => { + state.activeProvider = 'hyperliquid'; + state.cachedUserDataByProvider['hyperliquid:mainnet'] = { + positions: [mockPosition], + orders: [], + accountState: { + totalBalance: '50000', + spendableBalance: '45000', + withdrawableBalance: '45000', + marginUsed: '5000', + unrealizedPnl: '1000', + returnOnEquity: '20', + }, + timestamp: Date.now(), + address: mockAddress, + }; + }); + + const result = controller.getCachedUserDataForActiveProvider(); + + expect(result).not.toBeNull(); + expect(result?.positions).toHaveLength(1); + expect(result?.positions[0].symbol).toBe('BTC'); + expect(result?.accountState?.totalBalance).toBe('50000'); + }); + + it('assembles user data from multiple providers in aggregated mode', () => { + const hlPosition = createMockPosition({ symbol: 'BTC', size: '1.0' }); + const myxPosition = createMockPosition({ symbol: 'MYX', size: '5.0' }); + const mockMYXProvider = createMockHyperLiquidProvider(); + markControllerAsInitialized(); + controller.testSetProviders( + new Map([ + ['hyperliquid', mockProvider], + ['myx', mockMYXProvider], + ] as any), + ); + controller.testUpdate((state) => { + state.activeProvider = 'aggregated'; + state.cachedUserDataByProvider['hyperliquid:mainnet'] = { + positions: [hlPosition], + orders: [], + accountState: { + totalBalance: '50000', + spendableBalance: '45000', + withdrawableBalance: '45000', + marginUsed: '5000', + unrealizedPnl: '1000', + returnOnEquity: '20', + }, + timestamp: Date.now(), + address: mockAddress, + }; + state.cachedUserDataByProvider['myx:mainnet'] = { + positions: [myxPosition], + orders: [], + accountState: null, + timestamp: Date.now(), + address: mockAddress, + }; + }); + + const result = controller.getCachedUserDataForActiveProvider(); + + expect(result).not.toBeNull(); + expect(result?.positions).toHaveLength(2); + expect(result?.accountState?.totalBalance).toBe('50000'); + }); + + it('returns null in aggregated mode when no valid entries exist', () => { + const mockMYXProvider = createMockHyperLiquidProvider(); + markControllerAsInitialized(); + controller.testSetProviders( + new Map([ + ['hyperliquid', mockProvider], + ['myx', mockMYXProvider], + ] as any), + ); + controller.testUpdate((state) => { + state.activeProvider = 'aggregated'; + }); + + const result = controller.getCachedUserDataForActiveProvider(); + + expect(result).toBeNull(); + }); + + it('returns stale data when skipTTL is true', () => { + const mockPosition = createMockPosition({ symbol: 'BTC', size: '1.0' }); + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testUpdate((state) => { + state.activeProvider = 'hyperliquid'; + state.cachedUserDataByProvider['hyperliquid:mainnet'] = { + positions: [mockPosition], + orders: [], + accountState: null, + timestamp: Date.now() - 999_999_999, // very old + address: mockAddress, + }; + }); + + const withoutSkip = controller.getCachedUserDataForActiveProvider(); + const withSkip = controller.getCachedUserDataForActiveProvider({ + skipTTL: true, + }); + + expect(withoutSkip).toBeNull(); + expect(withSkip).not.toBeNull(); + expect(withSkip?.positions).toHaveLength(1); + }); + }); + + describe('performMarketDataPreload aggregated mode', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + controller.stopMarketDataPreload(); + jest.useRealTimers(); + }); + + it('splits market data by providerId into per-provider cache entries', async () => { + const base = { + maxLeverage: '50x', + change24h: '+1', + change24hPercent: '+0.1%', + volume: '$1M', + }; + const mockData = [ + { + ...base, + symbol: 'BTC', + name: 'BTC', + price: '50000', + providerId: 'hyperliquid' as const, + }, + { + ...base, + symbol: 'ETH', + name: 'ETH', + price: '3000', + providerId: 'hyperliquid' as const, + }, + { + ...base, + symbol: 'MYX', + name: 'MYX', + price: '1', + providerId: 'myx' as const, + }, + ]; + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testUpdate((state) => { + state.activeProvider = 'aggregated'; + }); + mockProvider.getMarketDataWithPrices.mockResolvedValue(mockData); + + controller.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + + // Per-provider entries should be written + const hlEntry = + controller.state.cachedMarketDataByProvider['hyperliquid:mainnet']; + expect(hlEntry?.data).toHaveLength(2); + expect(hlEntry?.data[0].symbol).toBe('BTC'); + + const myxEntry = + controller.state.cachedMarketDataByProvider['myx:mainnet']; + expect(myxEntry?.data).toHaveLength(1); + expect(myxEntry?.data[0].symbol).toBe('MYX'); + + // Aggregated sentinel should be empty + const sentinel = + controller.state.cachedMarketDataByProvider['aggregated:mainnet']; + expect(sentinel?.data).toHaveLength(0); + expect(sentinel?.timestamp).toBeGreaterThan(0); + }); + + it('assigns items without providerId to hyperliquid fallback', async () => { + const mockData = [ + { + symbol: 'BTC', + name: 'BTC', + price: '50000', + maxLeverage: '50x', + change24h: '+1', + change24hPercent: '+0.1%', + volume: '$1M', + }, // no providerId + ]; + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testUpdate((state) => { + state.activeProvider = 'aggregated'; + }); + mockProvider.getMarketDataWithPrices.mockResolvedValue(mockData); + + controller.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + + const hlEntry = + controller.state.cachedMarketDataByProvider['hyperliquid:mainnet']; + expect(hlEntry?.data).toHaveLength(1); + expect(hlEntry?.data[0].symbol).toBe('BTC'); + }); + }); + + describe('firstNonEmpty', () => { + it('returns the first non-empty string', () => { + expect(firstNonEmpty('', undefined, 'hello', 'world')).toBe('hello'); + }); + + it('returns empty string when all values are empty or undefined', () => { + expect(firstNonEmpty('', undefined, '')).toBe(''); + }); + + it('returns the first value if it is non-empty', () => { + expect(firstNonEmpty('first', 'second')).toBe('first'); + }); + + it('skips empty strings and returns the fallback', () => { + expect(firstNonEmpty('', 'fallback')).toBe('fallback'); + }); + }); + + describe('resolveMyxAuthConfig', () => { + it('uses testnet credentials on testnet', () => { + // Arrange + const myx = { + appIdTestnet: 'test-app', + apiSecretTestnet: 'test-secret', + brokerAddressTestnet: '0xTestBroker', + appIdMainnet: 'main-app', + apiSecretMainnet: 'main-secret', + brokerAddressMainnet: '0xMainBroker', + }; + + // Act + const result = resolveMyxAuthConfig(myx, true); + + // Assert + expect(result.appId).toBe('test-app'); + expect(result.apiSecret).toBe('test-secret'); + expect(result.brokerAddress).toBe('0xTestBroker'); + }); + + it('uses mainnet credentials on mainnet', () => { + // Arrange + const myx = { + appIdTestnet: 'test-app', + apiSecretTestnet: 'test-secret', + brokerAddressTestnet: '0xTestBroker', + appIdMainnet: 'main-app', + apiSecretMainnet: 'main-secret', + brokerAddressMainnet: '0xMainBroker', + }; + + // Act + const result = resolveMyxAuthConfig(myx, false); + + // Assert + expect(result.appId).toBe('main-app'); + expect(result.apiSecret).toBe('main-secret'); + expect(result.brokerAddress).toBe('0xMainBroker'); + }); + + it('falls back to testnet credentials when mainnet are empty', () => { + // Arrange + const myx = { + appIdTestnet: 'test-app', + apiSecretTestnet: 'test-secret', + brokerAddressTestnet: '0xTestBroker', + appIdMainnet: '', + apiSecretMainnet: '', + brokerAddressMainnet: '', + }; + + // Act + const result = resolveMyxAuthConfig(myx, false); + + // Assert + expect(result.appId).toBe('test-app'); + expect(result.apiSecret).toBe('test-secret'); + expect(result.brokerAddress).toBe('0xTestBroker'); + }); + + it('returns empty strings when no credentials are set', () => { + // Act + const result = resolveMyxAuthConfig({}, true); + + // Assert + expect(result.appId).toBe(''); + expect(result.apiSecret).toBe(''); + expect(result.brokerAddress).toBe(''); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/PerpsController.state.test.ts b/packages/perps-controller/tests/src/PerpsController.state.test.ts new file mode 100644 index 0000000000..99871b8c9f --- /dev/null +++ b/packages/perps-controller/tests/src/PerpsController.state.test.ts @@ -0,0 +1,1138 @@ +/* eslint-disable */ +/** + * PerpsController Tests + * Clean, focused test suite for PerpsController + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { + GasFeeEstimateLevel, + GasFeeEstimateType, +} from '@metamask/transaction-controller'; + +import Engine from '../../../core/Engine'; +import { + createMockHyperLiquidProvider, + createMockPosition, +} from '../helpers/providerMocks'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../helpers/serviceMocks'; + +jest.mock('@nktkas/hyperliquid', () => ({})); +jest.mock('@myx-trade/sdk', () => ({ + MyxClient: jest.fn(), + OrderStatusEnum: { Successful: 9 }, +})); + +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '../../src/constants/eventNames'; +import { + PERPS_CONSTANTS, + PERPS_DISK_CACHE_MARKETS, + PERPS_DISK_CACHE_USER_DATA, +} from '../../src/constants/perpsConfig'; +import { + PerpsController, + getDefaultPerpsControllerState, + InitializationState, + firstNonEmpty, + resolveMyxAuthConfig, +} from '../../src/PerpsController'; +import type { PerpsControllerState } from '../../src/PerpsController'; +import { PERPS_ERROR_CODES } from '../../src/perpsErrorCodes'; +import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider'; +import type { + AccountState, + GetAvailableDexsParams, + PerpsProvider, + PerpsPlatformDependencies, + PerpsProviderType, + SubscribeAccountParams, +} from '../../src/types'; +import { PerpsAnalyticsEvent } from '../../src/types'; + +jest.mock('../../src/providers/HyperLiquidProvider'); +jest.mock('../../src/providers/MYXProvider'); + +// Mock transaction controller utility +const mockAddTransaction = jest.fn(); +jest.mock( + '../../../util/transaction-controller', + () => ({ + addTransaction: (...args: unknown[]) => mockAddTransaction(...args), + }), + { virtual: true }, +); + +// Mock wait utility to speed up retry tests +jest.mock('../../src/utils/wait', () => ({ + wait: jest.fn().mockResolvedValue(undefined), +})); + +// Mock stream manager +const mockStreamManager = { + positions: { pause: jest.fn(), resume: jest.fn() }, + account: { pause: jest.fn(), resume: jest.fn() }, + orders: { pause: jest.fn(), resume: jest.fn() }, + prices: { pause: jest.fn(), resume: jest.fn() }, + orderFills: { pause: jest.fn(), resume: jest.fn() }, +}; + +jest.mock( + '../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: jest.fn(() => mockStreamManager), + }), + { virtual: true }, +); + +// Create persistent mock controllers INSIDE jest.mock factory +jest.mock( + '../../../core/Engine', + () => { + const mockRewardsController = { + getPerpsDiscountForAccount: jest.fn(), + }; + + const mockNetworkController = { + getNetworkClientById: jest.fn().mockReturnValue({ + configuration: { chainId: '0x1' }, + }), + }; + + const mockAccountTreeController = { + getAccountsFromSelectedAccountGroup: jest.fn().mockReturnValue([ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]), + }; + + const mockTransactionController = { + estimateGasFee: jest.fn(), + estimateGas: jest.fn(), + }; + + const mockAccountTrackerController = { + state: { + accountsByChainId: {}, + }, + }; + + const mockEngineContext = { + RewardsController: mockRewardsController, + NetworkController: mockNetworkController, + AccountTreeController: mockAccountTreeController, + TransactionController: mockTransactionController, + AccountTrackerController: mockAccountTrackerController, + }; + + // Return as default export to match the actual Engine import + return { + __esModule: true, + default: { + context: mockEngineContext, + }, + }; + }, + { virtual: true }, +); + +jest.mock('@metamask/utils', () => ({ + ...jest.requireActual('@metamask/utils'), + formatAccountToCaipAccountId: jest + .fn() + .mockReturnValue('eip155:1:0x1234567890123456789012345678901234567890'), +})); + +// Mock EligibilityService as a class with instance methods +const mockEligibilityServiceInstance = { + checkEligibility: jest.fn().mockResolvedValue(true), +}; +jest.mock('../../src/services/EligibilityService', () => ({ + EligibilityService: jest + .fn() + .mockImplementation(() => mockEligibilityServiceInstance), +})); + +// Mock DepositService as a class with instance methods +const mockDepositServiceInstance = { + prepareTransaction: jest.fn(), +}; +jest.mock('../../src/services/DepositService', () => ({ + DepositService: jest + .fn() + .mockImplementation(() => mockDepositServiceInstance), +})); + +// Mock MarketDataService as a class with instance methods +const mockMarketDataServiceInstance = { + getPositions: jest.fn(), + getAccountState: jest.fn(), + getMarkets: jest.fn(), + getWithdrawalRoutes: jest.fn().mockReturnValue([]), + validateClosePosition: jest.fn().mockResolvedValue({ isValid: true }), + validateOrder: jest.fn(), + calculateMaintenanceMargin: jest.fn().mockResolvedValue(0), + calculateLiquidationPrice: jest.fn(), + getMaxLeverage: jest.fn(), + calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + getAvailableDexs: jest.fn().mockResolvedValue([]), + getBlockExplorerUrl: jest.fn(), + getOrderFills: jest.fn(), + getOrders: jest.fn(), + getFunding: jest.fn(), +}; +jest.mock('../../src/services/MarketDataService', () => ({ + MarketDataService: jest + .fn() + .mockImplementation(() => mockMarketDataServiceInstance), +})); + +// Mock TradingService as a class with instance methods +const mockTradingServiceInstance = { + placeOrder: jest.fn(), + editOrder: jest.fn(), + cancelOrder: jest.fn(), + cancelOrders: jest.fn(), + closePosition: jest.fn(), + closePositions: jest.fn(), + updatePositionTPSL: jest.fn(), + updateMargin: jest.fn(), + flipPosition: jest.fn(), + setControllerDependencies: jest.fn(), +}; +jest.mock('../../src/services/TradingService', () => ({ + TradingService: jest + .fn() + .mockImplementation(() => mockTradingServiceInstance), +})); + +// Mock AccountService as a class with instance methods +const mockAccountServiceInstance = { + withdraw: jest.fn(), + validateWithdrawal: jest.fn(), +}; +jest.mock('../../src/services/AccountService', () => ({ + AccountService: jest + .fn() + .mockImplementation(() => mockAccountServiceInstance), +})); + +// Mock DataLakeService as a class with instance methods +const mockDataLakeServiceInstance = { + reportOrder: jest.fn(), +}; +jest.mock('../../src/services/DataLakeService', () => ({ + DataLakeService: jest + .fn() + .mockImplementation(() => mockDataLakeServiceInstance), +})); + +// Mock FeatureFlagConfigurationService as a class with instance methods +const mockFeatureFlagConfigurationServiceInstance = { + refreshEligibility: jest.fn((options: any) => { + // Simulate the service's behavior: extract blocked regions from remote flags + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + // Never downgrade from remote to fallback + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList(remoteBlockedRegions, 'remote'); + } + } + + // Call refreshEligibility callback if available + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + // Also call refreshHip3Config if available + if (remoteFlags) { + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config(options); + } + }), + refreshHip3Config: jest.fn(), + setBlockedRegions: jest.fn((options: any) => { + // Simulate setBlockedRegions behavior + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + // Never downgrade from remote to fallback + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + // Call refreshEligibility callback if available + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }), +}; +jest.mock('../../src/services/FeatureFlagConfigurationService', () => ({ + FeatureFlagConfigurationService: jest + .fn() + .mockImplementation(() => mockFeatureFlagConfigurationServiceInstance), +})); + +/** + * Testable version of PerpsController that exposes protected methods for testing. + * This follows the pattern used in RewardsController.test.ts + */ +class TestablePerpsController extends PerpsController { + /** + * Test-only method to update state directly. + * Exposed for scenarios where state needs to be manipulated + * outside the normal public API (e.g., testing error conditions). + * @param callback + */ + public testUpdate(callback: (state: PerpsControllerState) => void) { + this.update(callback); + } + + /** + * Test-only method to mark controller as initialized. + * Common test scenario that requires internal state changes. + */ + public testMarkInitialized() { + this.isInitialized = true; + this.update((state) => { + state.initializationState = InitializationState.Initialized; + }); + } + + /** + * Test-only method to set the providers map with complete providers. + * Used in most tests to inject mock providers. + * Also sets activeProviderInstance to the first provider (default provider). + * @param providers + */ + public testSetProviders(providers: Map) { + this.providers = providers; + // Set activeProviderInstance to the first provider (typically 'hyperliquid') + const firstProvider = providers.values().next().value; + if (firstProvider) { + this.activeProviderInstance = firstProvider; + } + } + + /** + * Test-only method to set the providers map with partial providers. + * Used explicitly in tests that verify error handling with incomplete providers. + * Type cast is intentional and necessary for testing graceful degradation. + * @param providers + */ + public testSetPartialProviders( + providers: Map>, + ) { + this.providers = providers as Map; + } + + /** + * Test-only method to get the providers map. + * Used to verify provider state in tests. + */ + public testGetProviders(): Map { + return this.providers; + } + + /** + * Test-only method to set initialization state. + * Allows tests to simulate both initialized and uninitialized states. + * @param value + */ + public testSetInitialized(value: boolean) { + this.isInitialized = value; + } + + /** + * Test-only method to get initialization state. + * Used to verify initialization status in tests. + */ + public testGetInitialized(): boolean { + return this.isInitialized; + } + + /** + * Test-only method to get blocked region list. + * Used to verify geo-blocking configuration in tests. + */ + public testGetBlockedRegionList(): { source: string; list: string[] } { + return this.blockedRegionList; + } + + /** + * Test-only method to set blocked region list. + * Used to test priority logic (remote vs fallback). + * @param list + * @param source + */ + public testSetBlockedRegionList( + list: string[], + source: 'remote' | 'fallback', + ) { + this.setBlockedRegionList(list, source); + } + + /** + * Test accessor for protected method refreshEligibilityOnFeatureFlagChange. + * Wrapper is necessary because protected methods can't be called from test code. + * @param remoteFlags + */ + public testRefreshEligibilityOnFeatureFlagChange(remoteFlags: any) { + this.refreshEligibilityOnFeatureFlagChange(remoteFlags); + } + + /** + * Test accessor for protected method reportOrderToDataLake. + * Wrapper is necessary because protected methods can't be called from test code. + * @param data + */ + public testReportOrderToDataLake(data: any): Promise { + return this.reportOrderToDataLake(data); + } + + public testHasStandaloneProvider(): boolean { + return this.hasStandaloneProvider(); + } + + public testRegisterMYXProvider( + MYXProvider: new (opts: Record) => PerpsProvider, + ) { + this.registerMYXProvider(MYXProvider as never); + } + + public testHandleMYXImportError(error: unknown) { + this.handleMYXImportError(error); + } +} + +describe('PerpsController', () => { + let controller: TestablePerpsController; + let mockProvider: jest.Mocked; + let mockInfrastructure: jest.Mocked; + + // Helper to mark controller as initialized for tests + const markControllerAsInitialized = () => { + controller.testMarkInitialized(); + }; + + beforeEach(() => { + jest.clearAllMocks(); + + ( + jest.requireMock('../../src/services/EligibilityService') + .EligibilityService as jest.Mock + ).mockImplementation(() => mockEligibilityServiceInstance); + ( + jest.requireMock('../../src/services/DepositService') + .DepositService as jest.Mock + ).mockImplementation(() => mockDepositServiceInstance); + ( + jest.requireMock('../../src/services/MarketDataService') + .MarketDataService as jest.Mock + ).mockImplementation(() => mockMarketDataServiceInstance); + ( + jest.requireMock('../../src/services/TradingService') + .TradingService as jest.Mock + ).mockImplementation(() => mockTradingServiceInstance); + ( + jest.requireMock('../../src/services/AccountService') + .AccountService as jest.Mock + ).mockImplementation(() => mockAccountServiceInstance); + ( + jest.requireMock('../../src/services/DataLakeService') + .DataLakeService as jest.Mock + ).mockImplementation(() => mockDataLakeServiceInstance); + ( + jest.requireMock('../../src/services/FeatureFlagConfigurationService') + .FeatureFlagConfigurationService as jest.Mock + ).mockImplementation(() => mockFeatureFlagConfigurationServiceInstance); + + mockEligibilityServiceInstance.checkEligibility.mockResolvedValue(true); + mockMarketDataServiceInstance.getPositions.mockResolvedValue([]); + mockMarketDataServiceInstance.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockMarketDataServiceInstance.getMarkets.mockResolvedValue([]); + mockMarketDataServiceInstance.getWithdrawalRoutes.mockReturnValue([]); + mockMarketDataServiceInstance.validateClosePosition.mockResolvedValue({ + isValid: true, + }); + mockMarketDataServiceInstance.calculateMaintenanceMargin.mockResolvedValue( + 0, + ); + mockMarketDataServiceInstance.calculateFees.mockResolvedValue({ + totalFee: 0, + }); + mockMarketDataServiceInstance.getAvailableDexs.mockResolvedValue([]); + + mockFeatureFlagConfigurationServiceInstance.refreshEligibility.mockImplementation( + (options: any) => { + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList( + remoteBlockedRegions, + 'remote', + ); + } + } + + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + if (remoteFlags) { + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config( + options, + ); + } + }, + ); + mockFeatureFlagConfigurationServiceInstance.setBlockedRegions.mockImplementation( + (options: any) => { + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }, + ); + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config.mockImplementation( + () => undefined, + ); + + // Reset Engine.context mocks to default state to prevent test interdependence + ( + Engine.context.RewardsController.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(null); + ( + Engine.context.NetworkController.getNetworkClientById as jest.Mock + ).mockReturnValue({ configuration: { chainId: '0x1' } }); + + // Create a fresh mock provider for each test + mockProvider = createMockHyperLiquidProvider(); + + // Add default mock return values for all provider methods + mockProvider.getPositions.mockResolvedValue([]); + mockProvider.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockProvider.getMarkets.mockResolvedValue([]); + mockProvider.getOpenOrders.mockResolvedValue([]); + mockProvider.getFunding.mockResolvedValue([]); + mockProvider.getOrderFills.mockResolvedValue([]); + mockProvider.getOrders.mockResolvedValue([]); + mockProvider.calculateLiquidationPrice.mockResolvedValue('0'); + mockProvider.getMaxLeverage.mockResolvedValue(50); + mockProvider.calculateMaintenanceMargin.mockResolvedValue(0); + mockProvider.calculateFees.mockResolvedValue({ feeAmount: 0 }); + mockProvider.getBlockExplorerUrl.mockReturnValue( + 'https://explorer.example.com', + ); + mockProvider.getWithdrawalRoutes.mockReturnValue([]); + + ( + HyperLiquidProvider as jest.MockedClass + ).mockImplementation(() => mockProvider); + + const mockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: [], + }, + }, + }; + } + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + id: 'account-1', + options: {}, + scopes: ['eip155:1'], + methods: [], + metadata: { + name: 'Test', + importTime: 0, + keyring: { type: 'HD Key Tree' }, + }, + }, + ]; + } + return undefined; + }); + + mockInfrastructure = createMockInfrastructure(); + controller = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: mockInfrastructure, + }); + }); + + afterEach(() => { + // Clear only provider mocks, not Engine.context mocks + // This prevents breaking Engine.context.RewardsController/NetworkController references + if (mockProvider) { + Object.values(mockProvider).forEach((value) => { + if ( + typeof value === 'object' && + value !== null && + 'mockClear' in value + ) { + (value as jest.Mock).mockClear(); + } + }); + } + (mockInfrastructure.metrics.trackPerpsEvent as jest.Mock).mockClear(); + (mockInfrastructure.logger.error as jest.Mock).mockClear(); + (mockInfrastructure.debugLogger.log as jest.Mock).mockClear(); + }); + describe('state management', () => { + it('returns positions without updating state', async () => { + const mockPositions = [ + { + symbol: 'ETH', + size: '2.5', + entryPrice: '2000', + positionValue: '5000', + unrealizedPnl: '500', + marginUsed: '2500', + leverage: { type: 'cross' as const, value: 2 }, + liquidationPrice: '1500', + maxLeverage: 100, + returnOnEquity: '10.0', + cumulativeFunding: { + allTime: '10', + sinceOpen: '5', + sinceChange: '2', + }, + takeProfitCount: 0, + stopLossCount: 0, + }, + ]; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getPositions') + .mockResolvedValue(mockPositions); + + const result = await controller.getPositions(); + + expect(result).toEqual(mockPositions); + expect(mockMarketDataServiceInstance.getPositions).toHaveBeenCalled(); + }); + + it('handles errors without updating state', async () => { + const errorMessage = 'Failed to fetch positions'; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getPositions') + .mockRejectedValue(new Error(errorMessage)); + + await expect(controller.getPositions()).rejects.toThrow(errorMessage); + expect(mockMarketDataServiceInstance.getPositions).toHaveBeenCalled(); + }); + }); + + describe('connection management', () => { + it('handles disconnection', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.disconnect.mockResolvedValue({ success: true }); + + await controller.disconnect(); + + expect(mockProvider.disconnect).toHaveBeenCalled(); + }); + + it('cleans up preload subscriptions on disconnect', async () => { + jest.useFakeTimers(); + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.disconnect.mockResolvedValue({ success: true }); + mockProvider.getMarketDataWithPrices.mockResolvedValue([]); + + // Arrange: start preloading to set up timer + subscriptions + controller.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + + // Act: disconnect should tear down all preload state + await controller.disconnect(); + + // Assert: provider disconnected and no interval fires after disconnect + expect(mockProvider.disconnect).toHaveBeenCalled(); + const callsBefore = + mockProvider.getMarketDataWithPrices.mock.calls.length; + jest.advanceTimersByTime(10 * 60 * 1000); + expect(mockProvider.getMarketDataWithPrices.mock.calls.length).toBe( + callsBefore, + ); + + jest.useRealTimers(); + }); + }); + + describe('utility methods', () => { + it('gets funding information', async () => { + const mockFunding = [ + { + symbol: 'BTC', + fundingRate: '0.0001', + timestamp: 1640995200000, + amountUsd: '100', + rate: '0.0001', + }, + ]; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getFunding') + .mockResolvedValue(mockFunding); + + const result = await controller.getFunding(); + + expect(result).toEqual(mockFunding); + expect(mockMarketDataServiceInstance.getFunding).toHaveBeenCalledWith({ + provider: mockProvider, + params: undefined, + context: expect.any(Object), + }); + }); + + it('gets order fills with parameters', async () => { + const params = { limit: 10, user: '0x123' as `0x${string}` }; + const mockOrderFills = [ + { + orderId: 'order-123', + symbol: 'BTC', + side: 'buy', + size: '0.1', + price: '50000', + pnl: '100', + direction: 'long', + fee: '5', + feeToken: 'USDC', + timestamp: 1640995200000, + }, + ]; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getOrderFills') + .mockResolvedValue(mockOrderFills); + + const result = await controller.getOrderFills(params); + + expect(result).toEqual(mockOrderFills); + expect(mockMarketDataServiceInstance.getOrderFills).toHaveBeenCalledWith({ + provider: mockProvider, + params, + context: expect.any(Object), + }); + }); + }); + + describe('order management', () => { + it('edits order successfully', async () => { + const editParams = { + orderId: 'order-123', + newOrder: { + symbol: 'BTC', + isBuy: true, + orderType: 'limit' as const, + price: '51000', + size: '0.2', + }, + }; + + const mockEditResult = { + success: true, + orderId: 'order-123', + updatedOrder: editParams.newOrder, + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockTradingServiceInstance, 'editOrder') + .mockResolvedValue(mockEditResult); + + const result = await controller.editOrder(editParams); + + expect(result).toEqual(mockEditResult); + expect(mockTradingServiceInstance.editOrder).toHaveBeenCalledWith( + expect.objectContaining({ + provider: mockProvider, + params: editParams, + context: expect.any(Object), + }), + ); + }); + + it('handles edit order error', async () => { + const editParams = { + orderId: 'order-123', + newOrder: { + symbol: 'BTC', + isBuy: true, + orderType: 'limit' as const, + price: '51000', + size: '0.2', + }, + }; + + const errorMessage = 'Order edit failed'; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockTradingServiceInstance, 'editOrder') + .mockRejectedValue(new Error(errorMessage)); + + await expect(controller.editOrder(editParams)).rejects.toThrow( + errorMessage, + ); + expect(mockTradingServiceInstance.editOrder).toHaveBeenCalled(); + }); + }); + + describe('subscription management', () => { + it('subscribes to order fills', () => { + const mockUnsubscribe = jest.fn(); + const params = { + callback: jest.fn(), + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.subscribeToOrderFills.mockReturnValue(mockUnsubscribe); + + const unsubscribe = controller.subscribeToOrderFills(params); + + expect(unsubscribe).toBe(mockUnsubscribe); + expect(mockProvider.subscribeToOrderFills).toHaveBeenCalledWith(params); + }); + + it('sets live data configuration', () => { + const config = { + priceThrottleMs: 1000, + positionThrottleMs: 2000, + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.setLiveDataConfig.mockReturnValue(undefined); + + controller.setLiveDataConfig(config); + + expect(mockProvider.setLiveDataConfig).toHaveBeenCalledWith(config); + }); + + it('handles subscription cleanup', () => { + const mockUnsubscribe = jest.fn(); + const params = { + symbols: ['BTC', 'ETH'], + callback: jest.fn(), + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.subscribeToPrices.mockReturnValue(mockUnsubscribe); + + const unsubscribe = controller.subscribeToPrices(params); + + // Test that unsubscribe function works + unsubscribe(); + expect(mockUnsubscribe).toHaveBeenCalled(); + }); + }); + + describe('deposit operations', () => { + it('clears deposit result', () => { + // Test that clearDepositResult method exists and can be called + expect(() => controller.clearDepositResult()).not.toThrow(); + + // Verify the method was called (it's a void method) + expect(typeof controller.clearDepositResult).toBe('function'); + }); + }); + + describe('withdrawal operations', () => { + it('clears withdraw result', () => { + // Test that clearWithdrawResult method exists and can be called + expect(() => controller.clearWithdrawResult()).not.toThrow(); + + // Verify the method was called (it's a void method) + expect(typeof controller.clearWithdrawResult).toBe('function'); + }); + }); + + describe('network management', () => { + it('gets current network', () => { + const network = controller.getCurrentNetwork(); + + expect(['mainnet', 'testnet']).toContain(network); + expect(typeof network).toBe('string'); + }); + + it('gets withdrawal routes', () => { + const mockRoutes = [ + { + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cc2239327c5edb3a432268e5831' as `${string}:${string}/${string}:${string}/${string}`, + chainId: 'eip155:42161' as `${string}:${string}`, + contractAddress: + '0x1234567890123456789012345678901234567890' as `0x${string}`, + constraints: { + minAmount: '10', + maxAmount: '1000000', + }, + }, + ]; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getWithdrawalRoutes') + .mockReturnValue(mockRoutes); + + const result = controller.getWithdrawalRoutes(); + + expect(result).toEqual(mockRoutes); + expect( + mockMarketDataServiceInstance.getWithdrawalRoutes, + ).toHaveBeenCalledWith({ + provider: mockProvider, + }); + }); + }); + + describe('user management', () => { + it('checks if first time user on current network', () => { + const isFirstTime = controller.isFirstTimeUserOnCurrentNetwork(); + + expect(typeof isFirstTime).toBe('boolean'); + }); + + it('marks tutorial as completed', () => { + // Test that markTutorialCompleted method exists and can be called + expect(() => controller.markTutorialCompleted()).not.toThrow(); + + // Verify the method was called (it's a void method) + expect(typeof controller.markTutorialCompleted).toBe('function'); + }); + }); + + describe('watchlist markets', () => { + it('returns empty array by default', () => { + const watchlist = controller.getWatchlistMarkets(); + expect(watchlist).toEqual([]); + }); + + it('toggles watchlist market (add)', () => { + controller.toggleWatchlistMarket('BTC'); + + const watchlist = controller.getWatchlistMarkets(); + expect(watchlist).toContain('BTC'); + expect(controller.isWatchlistMarket('BTC')).toBe(true); + }); + + it('toggles watchlist market (remove)', () => { + controller.toggleWatchlistMarket('BTC'); + controller.toggleWatchlistMarket('BTC'); + + const watchlist = controller.getWatchlistMarkets(); + expect(watchlist).not.toContain('BTC'); + expect(controller.isWatchlistMarket('BTC')).toBe(false); + }); + + it('handles multiple watchlist markets', () => { + controller.toggleWatchlistMarket('BTC'); + controller.toggleWatchlistMarket('ETH'); + controller.toggleWatchlistMarket('SOL'); + + const watchlist = controller.getWatchlistMarkets(); + expect(watchlist).toHaveLength(3); + expect(watchlist).toContain('BTC'); + expect(watchlist).toContain('ETH'); + expect(watchlist).toContain('SOL'); + }); + + it('persist watchlist per network', () => { + // Add to watchlist on mainnet (default is testnet in dev, so set to false) + controller.testUpdate((state) => { + state.isTestnet = false; + }); + controller.toggleWatchlistMarket('BTC'); + + const mainnetWatchlist = controller.getWatchlistMarkets(); + expect(mainnetWatchlist).toContain('BTC'); + + // Switch to testnet + controller.testUpdate((state) => { + state.isTestnet = true; + }); + const testnetWatchlist = controller.getWatchlistMarkets(); + expect(testnetWatchlist).toEqual([]); + + // Add to watchlist on testnet + controller.toggleWatchlistMarket('ETH'); + expect(controller.getWatchlistMarkets()).toContain('ETH'); + expect(controller.isWatchlistMarket('ETH')).toBe(true); + + // Switch back to mainnet + controller.testUpdate((state) => { + state.isTestnet = false; + }); + expect(controller.getWatchlistMarkets()).toContain('BTC'); + expect(controller.getWatchlistMarkets()).not.toContain('ETH'); + }); + }); + + describe('additional subscriptions', () => { + it('subscribes to orders', () => { + const mockUnsubscribe = jest.fn(); + const params = { + callback: jest.fn(), + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.subscribeToOrders.mockReturnValue(mockUnsubscribe); + + const unsubscribe = controller.subscribeToOrders(params); + + expect(unsubscribe).toBe(mockUnsubscribe); + expect(mockProvider.subscribeToOrders).toHaveBeenCalledWith(params); + }); + + it('subscribes to account updates', () => { + const mockUnsubscribe = jest.fn(); + const params = { + callback: jest.fn(), + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.subscribeToAccount.mockReturnValue(mockUnsubscribe); + + const unsubscribe = controller.subscribeToAccount(params); + + expect(unsubscribe).toBe(mockUnsubscribe); + // Controller wraps callback to update state, so expect a function rather than exact params + expect(mockProvider.subscribeToAccount).toHaveBeenCalledWith( + expect.objectContaining({ callback: expect.any(Function) }), + ); + }); + + it('updates accountState when subscribeToAccount callback receives non-null account', () => { + const originalCallback = jest.fn(); + let wrappedCallback: (account: AccountState | null) => void = () => { + /* assigned by mock */ + }; + mockProvider.subscribeToAccount.mockImplementation( + (p: SubscribeAccountParams) => { + wrappedCallback = p.callback; + return jest.fn(); + }, + ); + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + controller.subscribeToAccount({ callback: originalCallback }); + + const accountState = { + spendableBalance: '5000', + withdrawableBalance: '5000', + totalBalance: '5000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + wrappedCallback(accountState); + + expect(controller.state.accountState).toMatchObject(accountState); + expect(originalCallback).toHaveBeenCalledWith(accountState); + }); + + it('returns no-op unsub and does not throw when subscribeToAccount called before init', () => { + const params = { callback: jest.fn() }; + + const unsubscribe = controller.subscribeToAccount(params); + + expect(typeof unsubscribe).toBe('function'); + expect(() => unsubscribe()).not.toThrow(); + expect(mockProvider.subscribeToAccount).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/PerpsController.subscriptions.test.ts b/packages/perps-controller/tests/src/PerpsController.subscriptions.test.ts new file mode 100644 index 0000000000..281d4d0881 --- /dev/null +++ b/packages/perps-controller/tests/src/PerpsController.subscriptions.test.ts @@ -0,0 +1,851 @@ +/* eslint-disable */ +/** + * PerpsController Tests + * Clean, focused test suite for PerpsController + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { + GasFeeEstimateLevel, + GasFeeEstimateType, +} from '@metamask/transaction-controller'; + +import Engine from '../../../core/Engine'; +import { + createMockHyperLiquidProvider, + createMockPosition, +} from '../helpers/providerMocks'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../helpers/serviceMocks'; + +jest.mock('@nktkas/hyperliquid', () => ({})); +jest.mock('@myx-trade/sdk', () => ({ + MyxClient: jest.fn(), + OrderStatusEnum: { Successful: 9 }, +})); + +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '../../src/constants/eventNames'; +import { + PERPS_CONSTANTS, + PERPS_DISK_CACHE_MARKETS, + PERPS_DISK_CACHE_USER_DATA, +} from '../../src/constants/perpsConfig'; +import { + PerpsController, + getDefaultPerpsControllerState, + InitializationState, + firstNonEmpty, + resolveMyxAuthConfig, +} from '../../src/PerpsController'; +import type { PerpsControllerState } from '../../src/PerpsController'; +import { PERPS_ERROR_CODES } from '../../src/perpsErrorCodes'; +import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider'; +import type { + AccountState, + GetAvailableDexsParams, + PerpsProvider, + PerpsPlatformDependencies, + PerpsProviderType, + SubscribeAccountParams, +} from '../../src/types'; +import { PerpsAnalyticsEvent } from '../../src/types'; + +jest.mock('../../src/providers/HyperLiquidProvider'); +jest.mock('../../src/providers/MYXProvider'); + +// Mock transaction controller utility +const mockAddTransaction = jest.fn(); +jest.mock( + '../../../util/transaction-controller', + () => ({ + addTransaction: (...args: unknown[]) => mockAddTransaction(...args), + }), + { virtual: true }, +); + +// Mock wait utility to speed up retry tests +jest.mock('../../src/utils/wait', () => ({ + wait: jest.fn().mockResolvedValue(undefined), +})); + +// Mock stream manager +const mockStreamManager = { + positions: { pause: jest.fn(), resume: jest.fn() }, + account: { pause: jest.fn(), resume: jest.fn() }, + orders: { pause: jest.fn(), resume: jest.fn() }, + prices: { pause: jest.fn(), resume: jest.fn() }, + orderFills: { pause: jest.fn(), resume: jest.fn() }, +}; + +jest.mock( + '../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: jest.fn(() => mockStreamManager), + }), + { virtual: true }, +); + +// Create persistent mock controllers INSIDE jest.mock factory +jest.mock( + '../../../core/Engine', + () => { + const mockRewardsController = { + getPerpsDiscountForAccount: jest.fn(), + }; + + const mockNetworkController = { + getNetworkClientById: jest.fn().mockReturnValue({ + configuration: { chainId: '0x1' }, + }), + }; + + const mockAccountTreeController = { + getAccountsFromSelectedAccountGroup: jest.fn().mockReturnValue([ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]), + }; + + const mockTransactionController = { + estimateGasFee: jest.fn(), + estimateGas: jest.fn(), + }; + + const mockAccountTrackerController = { + state: { + accountsByChainId: {}, + }, + }; + + const mockEngineContext = { + RewardsController: mockRewardsController, + NetworkController: mockNetworkController, + AccountTreeController: mockAccountTreeController, + TransactionController: mockTransactionController, + AccountTrackerController: mockAccountTrackerController, + }; + + // Return as default export to match the actual Engine import + return { + __esModule: true, + default: { + context: mockEngineContext, + }, + }; + }, + { virtual: true }, +); + +jest.mock('@metamask/utils', () => ({ + ...jest.requireActual('@metamask/utils'), + formatAccountToCaipAccountId: jest + .fn() + .mockReturnValue('eip155:1:0x1234567890123456789012345678901234567890'), +})); + +// Mock EligibilityService as a class with instance methods +const mockEligibilityServiceInstance = { + checkEligibility: jest.fn().mockResolvedValue(true), +}; +jest.mock('../../src/services/EligibilityService', () => ({ + EligibilityService: jest + .fn() + .mockImplementation(() => mockEligibilityServiceInstance), +})); + +// Mock DepositService as a class with instance methods +const mockDepositServiceInstance = { + prepareTransaction: jest.fn(), +}; +jest.mock('../../src/services/DepositService', () => ({ + DepositService: jest + .fn() + .mockImplementation(() => mockDepositServiceInstance), +})); + +// Mock MarketDataService as a class with instance methods +const mockMarketDataServiceInstance = { + getPositions: jest.fn(), + getAccountState: jest.fn(), + getMarkets: jest.fn(), + getWithdrawalRoutes: jest.fn().mockReturnValue([]), + validateClosePosition: jest.fn().mockResolvedValue({ isValid: true }), + validateOrder: jest.fn(), + calculateMaintenanceMargin: jest.fn().mockResolvedValue(0), + calculateLiquidationPrice: jest.fn(), + getMaxLeverage: jest.fn(), + calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + getAvailableDexs: jest.fn().mockResolvedValue([]), + getBlockExplorerUrl: jest.fn(), + getOrderFills: jest.fn(), + getOrders: jest.fn(), + getFunding: jest.fn(), +}; +jest.mock('../../src/services/MarketDataService', () => ({ + MarketDataService: jest + .fn() + .mockImplementation(() => mockMarketDataServiceInstance), +})); + +// Mock TradingService as a class with instance methods +const mockTradingServiceInstance = { + placeOrder: jest.fn(), + editOrder: jest.fn(), + cancelOrder: jest.fn(), + cancelOrders: jest.fn(), + closePosition: jest.fn(), + closePositions: jest.fn(), + updatePositionTPSL: jest.fn(), + updateMargin: jest.fn(), + flipPosition: jest.fn(), + setControllerDependencies: jest.fn(), +}; +jest.mock('../../src/services/TradingService', () => ({ + TradingService: jest + .fn() + .mockImplementation(() => mockTradingServiceInstance), +})); + +// Mock AccountService as a class with instance methods +const mockAccountServiceInstance = { + withdraw: jest.fn(), + validateWithdrawal: jest.fn(), +}; +jest.mock('../../src/services/AccountService', () => ({ + AccountService: jest + .fn() + .mockImplementation(() => mockAccountServiceInstance), +})); + +// Mock DataLakeService as a class with instance methods +const mockDataLakeServiceInstance = { + reportOrder: jest.fn(), +}; +jest.mock('../../src/services/DataLakeService', () => ({ + DataLakeService: jest + .fn() + .mockImplementation(() => mockDataLakeServiceInstance), +})); + +// Mock FeatureFlagConfigurationService as a class with instance methods +const mockFeatureFlagConfigurationServiceInstance = { + refreshEligibility: jest.fn((options: any) => { + // Simulate the service's behavior: extract blocked regions from remote flags + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + // Never downgrade from remote to fallback + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList(remoteBlockedRegions, 'remote'); + } + } + + // Call refreshEligibility callback if available + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + // Also call refreshHip3Config if available + if (remoteFlags) { + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config(options); + } + }), + refreshHip3Config: jest.fn(), + setBlockedRegions: jest.fn((options: any) => { + // Simulate setBlockedRegions behavior + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + // Never downgrade from remote to fallback + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + // Call refreshEligibility callback if available + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }), +}; +jest.mock('../../src/services/FeatureFlagConfigurationService', () => ({ + FeatureFlagConfigurationService: jest + .fn() + .mockImplementation(() => mockFeatureFlagConfigurationServiceInstance), +})); + +/** + * Testable version of PerpsController that exposes protected methods for testing. + * This follows the pattern used in RewardsController.test.ts + */ +class TestablePerpsController extends PerpsController { + /** + * Test-only method to update state directly. + * Exposed for scenarios where state needs to be manipulated + * outside the normal public API (e.g., testing error conditions). + * @param callback + */ + public testUpdate(callback: (state: PerpsControllerState) => void) { + this.update(callback); + } + + /** + * Test-only method to mark controller as initialized. + * Common test scenario that requires internal state changes. + */ + public testMarkInitialized() { + this.isInitialized = true; + this.update((state) => { + state.initializationState = InitializationState.Initialized; + }); + } + + /** + * Test-only method to set the providers map with complete providers. + * Used in most tests to inject mock providers. + * Also sets activeProviderInstance to the first provider (default provider). + * @param providers + */ + public testSetProviders(providers: Map) { + this.providers = providers; + // Set activeProviderInstance to the first provider (typically 'hyperliquid') + const firstProvider = providers.values().next().value; + if (firstProvider) { + this.activeProviderInstance = firstProvider; + } + } + + /** + * Test-only method to set the providers map with partial providers. + * Used explicitly in tests that verify error handling with incomplete providers. + * Type cast is intentional and necessary for testing graceful degradation. + * @param providers + */ + public testSetPartialProviders( + providers: Map>, + ) { + this.providers = providers as Map; + } + + /** + * Test-only method to get the providers map. + * Used to verify provider state in tests. + */ + public testGetProviders(): Map { + return this.providers; + } + + /** + * Test-only method to set initialization state. + * Allows tests to simulate both initialized and uninitialized states. + * @param value + */ + public testSetInitialized(value: boolean) { + this.isInitialized = value; + } + + /** + * Test-only method to get initialization state. + * Used to verify initialization status in tests. + */ + public testGetInitialized(): boolean { + return this.isInitialized; + } + + /** + * Test-only method to get blocked region list. + * Used to verify geo-blocking configuration in tests. + */ + public testGetBlockedRegionList(): { source: string; list: string[] } { + return this.blockedRegionList; + } + + /** + * Test-only method to set blocked region list. + * Used to test priority logic (remote vs fallback). + * @param list + * @param source + */ + public testSetBlockedRegionList( + list: string[], + source: 'remote' | 'fallback', + ) { + this.setBlockedRegionList(list, source); + } + + /** + * Test accessor for protected method refreshEligibilityOnFeatureFlagChange. + * Wrapper is necessary because protected methods can't be called from test code. + * @param remoteFlags + */ + public testRefreshEligibilityOnFeatureFlagChange(remoteFlags: any) { + this.refreshEligibilityOnFeatureFlagChange(remoteFlags); + } + + /** + * Test accessor for protected method reportOrderToDataLake. + * Wrapper is necessary because protected methods can't be called from test code. + * @param data + */ + public testReportOrderToDataLake(data: any): Promise { + return this.reportOrderToDataLake(data); + } + + public testHasStandaloneProvider(): boolean { + return this.hasStandaloneProvider(); + } + + public testRegisterMYXProvider( + MYXProvider: new (opts: Record) => PerpsProvider, + ) { + this.registerMYXProvider(MYXProvider as never); + } + + public testHandleMYXImportError(error: unknown) { + this.handleMYXImportError(error); + } +} + +describe('PerpsController', () => { + let controller: TestablePerpsController; + let mockProvider: jest.Mocked; + let mockInfrastructure: jest.Mocked; + + // Helper to mark controller as initialized for tests + const markControllerAsInitialized = () => { + controller.testMarkInitialized(); + }; + + beforeEach(() => { + jest.clearAllMocks(); + + ( + jest.requireMock('../../src/services/EligibilityService') + .EligibilityService as jest.Mock + ).mockImplementation(() => mockEligibilityServiceInstance); + ( + jest.requireMock('../../src/services/DepositService') + .DepositService as jest.Mock + ).mockImplementation(() => mockDepositServiceInstance); + ( + jest.requireMock('../../src/services/MarketDataService') + .MarketDataService as jest.Mock + ).mockImplementation(() => mockMarketDataServiceInstance); + ( + jest.requireMock('../../src/services/TradingService') + .TradingService as jest.Mock + ).mockImplementation(() => mockTradingServiceInstance); + ( + jest.requireMock('../../src/services/AccountService') + .AccountService as jest.Mock + ).mockImplementation(() => mockAccountServiceInstance); + ( + jest.requireMock('../../src/services/DataLakeService') + .DataLakeService as jest.Mock + ).mockImplementation(() => mockDataLakeServiceInstance); + ( + jest.requireMock('../../src/services/FeatureFlagConfigurationService') + .FeatureFlagConfigurationService as jest.Mock + ).mockImplementation(() => mockFeatureFlagConfigurationServiceInstance); + + mockEligibilityServiceInstance.checkEligibility.mockResolvedValue(true); + mockMarketDataServiceInstance.getPositions.mockResolvedValue([]); + mockMarketDataServiceInstance.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockMarketDataServiceInstance.getMarkets.mockResolvedValue([]); + mockMarketDataServiceInstance.getWithdrawalRoutes.mockReturnValue([]); + mockMarketDataServiceInstance.validateClosePosition.mockResolvedValue({ + isValid: true, + }); + mockMarketDataServiceInstance.calculateMaintenanceMargin.mockResolvedValue( + 0, + ); + mockMarketDataServiceInstance.calculateFees.mockResolvedValue({ + totalFee: 0, + }); + mockMarketDataServiceInstance.getAvailableDexs.mockResolvedValue([]); + + mockFeatureFlagConfigurationServiceInstance.refreshEligibility.mockImplementation( + (options: any) => { + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList( + remoteBlockedRegions, + 'remote', + ); + } + } + + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + if (remoteFlags) { + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config( + options, + ); + } + }, + ); + mockFeatureFlagConfigurationServiceInstance.setBlockedRegions.mockImplementation( + (options: any) => { + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }, + ); + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config.mockImplementation( + () => undefined, + ); + + // Reset Engine.context mocks to default state to prevent test interdependence + ( + Engine.context.RewardsController.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(null); + ( + Engine.context.NetworkController.getNetworkClientById as jest.Mock + ).mockReturnValue({ configuration: { chainId: '0x1' } }); + + // Create a fresh mock provider for each test + mockProvider = createMockHyperLiquidProvider(); + + // Add default mock return values for all provider methods + mockProvider.getPositions.mockResolvedValue([]); + mockProvider.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockProvider.getMarkets.mockResolvedValue([]); + mockProvider.getOpenOrders.mockResolvedValue([]); + mockProvider.getFunding.mockResolvedValue([]); + mockProvider.getOrderFills.mockResolvedValue([]); + mockProvider.getOrders.mockResolvedValue([]); + mockProvider.calculateLiquidationPrice.mockResolvedValue('0'); + mockProvider.getMaxLeverage.mockResolvedValue(50); + mockProvider.calculateMaintenanceMargin.mockResolvedValue(0); + mockProvider.calculateFees.mockResolvedValue({ feeAmount: 0 }); + mockProvider.getBlockExplorerUrl.mockReturnValue( + 'https://explorer.example.com', + ); + mockProvider.getWithdrawalRoutes.mockReturnValue([]); + + ( + HyperLiquidProvider as jest.MockedClass + ).mockImplementation(() => mockProvider); + + const mockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: [], + }, + }, + }; + } + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + id: 'account-1', + options: {}, + scopes: ['eip155:1'], + methods: [], + metadata: { + name: 'Test', + importTime: 0, + keyring: { type: 'HD Key Tree' }, + }, + }, + ]; + } + return undefined; + }); + + mockInfrastructure = createMockInfrastructure(); + controller = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: mockInfrastructure, + }); + }); + + afterEach(() => { + // Clear only provider mocks, not Engine.context mocks + // This prevents breaking Engine.context.RewardsController/NetworkController references + if (mockProvider) { + Object.values(mockProvider).forEach((value) => { + if ( + typeof value === 'object' && + value !== null && + 'mockClear' in value + ) { + (value as jest.Mock).mockClear(); + } + }); + } + (mockInfrastructure.metrics.trackPerpsEvent as jest.Mock).mockClear(); + (mockInfrastructure.logger.error as jest.Mock).mockClear(); + (mockInfrastructure.debugLogger.log as jest.Mock).mockClear(); + }); + describe('subscribeToPositions', () => { + it('subscribes to position updates', () => { + const mockUnsubscribe = jest.fn(); + const params = { + callback: jest.fn(), + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.subscribeToPositions.mockReturnValue(mockUnsubscribe); + + const unsubscribe = controller.subscribeToPositions(params); + + expect(unsubscribe).toBe(mockUnsubscribe); + expect(mockProvider.subscribeToPositions).toHaveBeenCalledWith(params); + }); + }); + + describe('withdraw', () => { + it('withdraws successfully', async () => { + const withdrawParams = { + amount: '100', + destination: + '0x1234567890123456789012345678901234567890' as `0x${string}`, + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cc2239327c5edb3a432268e5831' as `${string}:${string}/${string}:${string}/${string}`, + }; + + const mockWithdrawResult = { + success: true, + txHash: '0xabcdef1234567890', + withdrawalId: 'withdrawal-123', + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockAccountServiceInstance, 'withdraw') + .mockResolvedValue(mockWithdrawResult); + + const result = await controller.withdraw(withdrawParams); + + expect(result).toEqual(mockWithdrawResult); + expect(mockAccountServiceInstance.withdraw).toHaveBeenCalledWith({ + provider: mockProvider, + params: withdrawParams, + context: expect.objectContaining({ + tracingContext: expect.any(Object), + errorContext: expect.objectContaining({ method: 'withdraw' }), + stateManager: expect.any(Object), + }), + refreshAccountState: expect.any(Function), + }); + }); + }); + + describe('calculateLiquidationPrice', () => { + it('calculates liquidation price successfully', async () => { + const liquidationParams = { + entryPrice: 50000, + leverage: 10, + direction: 'long' as const, + positionSize: 1, + marginType: 'isolated' as const, + asset: 'BTC', + }; + + const mockLiquidationPrice = '45000'; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'calculateLiquidationPrice') + .mockResolvedValue(mockLiquidationPrice); + + const result = + await controller.calculateLiquidationPrice(liquidationParams); + + expect(result).toBe(mockLiquidationPrice); + expect( + mockMarketDataServiceInstance.calculateLiquidationPrice, + ).toHaveBeenCalledWith({ + provider: mockProvider, + params: liquidationParams, + context: expect.any(Object), + }); + }); + }); + + describe('getMaxLeverage', () => { + it('gets max leverage successfully', async () => { + const asset = 'BTC'; + const mockMaxLeverage = 50; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getMaxLeverage') + .mockResolvedValue(mockMaxLeverage); + + const result = await controller.getMaxLeverage(asset); + + expect(result).toBe(mockMaxLeverage); + expect(mockMarketDataServiceInstance.getMaxLeverage).toHaveBeenCalledWith( + { + provider: mockProvider, + asset, + context: expect.any(Object), + }, + ); + }); + }); + + describe('getWithdrawalRoutes', () => { + it('gets withdrawal routes successfully', () => { + const mockRoutes = [ + { + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cc2239327c5edb3a432268e5831' as `${string}:${string}/${string}:${string}/${string}`, + chainId: 'eip155:42161' as `${string}:${string}`, + contractAddress: + '0x1234567890123456789012345678901234567890' as `0x${string}`, + constraints: { + minAmount: '10', + maxAmount: '1000000', + }, + }, + ]; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getWithdrawalRoutes') + .mockReturnValue(mockRoutes); + + const result = controller.getWithdrawalRoutes(); + + expect(result).toEqual(mockRoutes); + expect( + mockMarketDataServiceInstance.getWithdrawalRoutes, + ).toHaveBeenCalledWith({ + provider: mockProvider, + }); + }); + }); + + describe('getBlockExplorerUrl', () => { + it('gets block explorer URL successfully', () => { + const address = '0x1234567890123456789012345678901234567890'; + const mockUrl = + 'https://app.hyperliquid.xyz/explorer/address/0x1234567890123456789012345678901234567890'; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getBlockExplorerUrl') + .mockReturnValue(mockUrl); + + const result = controller.getBlockExplorerUrl(address); + + expect(result).toBe(mockUrl); + expect( + mockMarketDataServiceInstance.getBlockExplorerUrl, + ).toHaveBeenCalledWith({ + provider: mockProvider, + address, + }); + }); + }); + + describe('error handling', () => { + it('handles provider errors gracefully', async () => { + const errorMessage = 'Provider connection failed'; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getPositions') + .mockRejectedValue(new Error(errorMessage)); + + await expect(controller.getPositions()).rejects.toThrow(errorMessage); + expect(mockMarketDataServiceInstance.getPositions).toHaveBeenCalled(); + }); + + it('handles network errors', async () => { + const errorMessage = 'Network timeout'; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getAccountState') + .mockRejectedValue(new Error(errorMessage)); + + await expect(controller.getAccountState()).rejects.toThrow(errorMessage); + expect(mockMarketDataServiceInstance.getAccountState).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/PerpsController.trading.test.ts b/packages/perps-controller/tests/src/PerpsController.trading.test.ts new file mode 100644 index 0000000000..7e297b2843 --- /dev/null +++ b/packages/perps-controller/tests/src/PerpsController.trading.test.ts @@ -0,0 +1,1114 @@ +/* eslint-disable */ +/** + * PerpsController Tests + * Clean, focused test suite for PerpsController + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { + GasFeeEstimateLevel, + GasFeeEstimateType, +} from '@metamask/transaction-controller'; + +import Engine from '../../../core/Engine'; +import { + createMockHyperLiquidProvider, + createMockPosition, +} from '../helpers/providerMocks'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../helpers/serviceMocks'; + +jest.mock('@nktkas/hyperliquid', () => ({})); +jest.mock('@myx-trade/sdk', () => ({ + MyxClient: jest.fn(), + OrderStatusEnum: { Successful: 9 }, +})); + +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '../../src/constants/eventNames'; +import { + PERPS_CONSTANTS, + PERPS_DISK_CACHE_MARKETS, + PERPS_DISK_CACHE_USER_DATA, +} from '../../src/constants/perpsConfig'; +import { + PerpsController, + getDefaultPerpsControllerState, + InitializationState, + firstNonEmpty, + resolveMyxAuthConfig, +} from '../../src/PerpsController'; +import type { PerpsControllerState } from '../../src/PerpsController'; +import { PERPS_ERROR_CODES } from '../../src/perpsErrorCodes'; +import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider'; +import type { + AccountState, + GetAvailableDexsParams, + PerpsProvider, + PerpsPlatformDependencies, + PerpsProviderType, + SubscribeAccountParams, +} from '../../src/types'; +import { PerpsAnalyticsEvent } from '../../src/types'; + +jest.mock('../../src/providers/HyperLiquidProvider'); +jest.mock('../../src/providers/MYXProvider'); + +// Mock transaction controller utility +const mockAddTransaction = jest.fn(); +jest.mock( + '../../../util/transaction-controller', + () => ({ + addTransaction: (...args: unknown[]) => mockAddTransaction(...args), + }), + { virtual: true }, +); + +// Mock wait utility to speed up retry tests +jest.mock('../../src/utils/wait', () => ({ + wait: jest.fn().mockResolvedValue(undefined), +})); + +// Mock stream manager +const mockStreamManager = { + positions: { pause: jest.fn(), resume: jest.fn() }, + account: { pause: jest.fn(), resume: jest.fn() }, + orders: { pause: jest.fn(), resume: jest.fn() }, + prices: { pause: jest.fn(), resume: jest.fn() }, + orderFills: { pause: jest.fn(), resume: jest.fn() }, +}; + +jest.mock( + '../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: jest.fn(() => mockStreamManager), + }), + { virtual: true }, +); + +// Create persistent mock controllers INSIDE jest.mock factory +jest.mock( + '../../../core/Engine', + () => { + const mockRewardsController = { + getPerpsDiscountForAccount: jest.fn(), + }; + + const mockNetworkController = { + getNetworkClientById: jest.fn().mockReturnValue({ + configuration: { chainId: '0x1' }, + }), + }; + + const mockAccountTreeController = { + getAccountsFromSelectedAccountGroup: jest.fn().mockReturnValue([ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]), + }; + + const mockTransactionController = { + estimateGasFee: jest.fn(), + estimateGas: jest.fn(), + }; + + const mockAccountTrackerController = { + state: { + accountsByChainId: {}, + }, + }; + + const mockEngineContext = { + RewardsController: mockRewardsController, + NetworkController: mockNetworkController, + AccountTreeController: mockAccountTreeController, + TransactionController: mockTransactionController, + AccountTrackerController: mockAccountTrackerController, + }; + + // Return as default export to match the actual Engine import + return { + __esModule: true, + default: { + context: mockEngineContext, + }, + }; + }, + { virtual: true }, +); + +jest.mock('@metamask/utils', () => ({ + ...jest.requireActual('@metamask/utils'), + formatAccountToCaipAccountId: jest + .fn() + .mockReturnValue('eip155:1:0x1234567890123456789012345678901234567890'), +})); + +// Mock EligibilityService as a class with instance methods +const mockEligibilityServiceInstance = { + checkEligibility: jest.fn().mockResolvedValue(true), +}; +jest.mock('../../src/services/EligibilityService', () => ({ + EligibilityService: jest + .fn() + .mockImplementation(() => mockEligibilityServiceInstance), +})); + +// Mock DepositService as a class with instance methods +const mockDepositServiceInstance = { + prepareTransaction: jest.fn(), +}; +jest.mock('../../src/services/DepositService', () => ({ + DepositService: jest + .fn() + .mockImplementation(() => mockDepositServiceInstance), +})); + +// Mock MarketDataService as a class with instance methods +const mockMarketDataServiceInstance = { + getPositions: jest.fn(), + getAccountState: jest.fn(), + getMarkets: jest.fn(), + getWithdrawalRoutes: jest.fn().mockReturnValue([]), + validateClosePosition: jest.fn().mockResolvedValue({ isValid: true }), + validateOrder: jest.fn(), + calculateMaintenanceMargin: jest.fn().mockResolvedValue(0), + calculateLiquidationPrice: jest.fn(), + getMaxLeverage: jest.fn(), + calculateFees: jest.fn().mockResolvedValue({ totalFee: 0 }), + getAvailableDexs: jest.fn().mockResolvedValue([]), + getBlockExplorerUrl: jest.fn(), + getOrderFills: jest.fn(), + getOrders: jest.fn(), + getFunding: jest.fn(), +}; +jest.mock('../../src/services/MarketDataService', () => ({ + MarketDataService: jest + .fn() + .mockImplementation(() => mockMarketDataServiceInstance), +})); + +// Mock TradingService as a class with instance methods +const mockTradingServiceInstance = { + placeOrder: jest.fn(), + editOrder: jest.fn(), + cancelOrder: jest.fn(), + cancelOrders: jest.fn(), + closePosition: jest.fn(), + closePositions: jest.fn(), + updatePositionTPSL: jest.fn(), + updateMargin: jest.fn(), + flipPosition: jest.fn(), + setControllerDependencies: jest.fn(), +}; +jest.mock('../../src/services/TradingService', () => ({ + TradingService: jest + .fn() + .mockImplementation(() => mockTradingServiceInstance), +})); + +// Mock AccountService as a class with instance methods +const mockAccountServiceInstance = { + withdraw: jest.fn(), + validateWithdrawal: jest.fn(), +}; +jest.mock('../../src/services/AccountService', () => ({ + AccountService: jest + .fn() + .mockImplementation(() => mockAccountServiceInstance), +})); + +// Mock DataLakeService as a class with instance methods +const mockDataLakeServiceInstance = { + reportOrder: jest.fn(), +}; +jest.mock('../../src/services/DataLakeService', () => ({ + DataLakeService: jest + .fn() + .mockImplementation(() => mockDataLakeServiceInstance), +})); + +// Mock FeatureFlagConfigurationService as a class with instance methods +const mockFeatureFlagConfigurationServiceInstance = { + refreshEligibility: jest.fn((options: any) => { + // Simulate the service's behavior: extract blocked regions from remote flags + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + // Never downgrade from remote to fallback + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList(remoteBlockedRegions, 'remote'); + } + } + + // Call refreshEligibility callback if available + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + // Also call refreshHip3Config if available + if (remoteFlags) { + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config(options); + } + }), + refreshHip3Config: jest.fn(), + setBlockedRegions: jest.fn((options: any) => { + // Simulate setBlockedRegions behavior + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + // Never downgrade from remote to fallback + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + // Call refreshEligibility callback if available + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }), +}; +jest.mock('../../src/services/FeatureFlagConfigurationService', () => ({ + FeatureFlagConfigurationService: jest + .fn() + .mockImplementation(() => mockFeatureFlagConfigurationServiceInstance), +})); + +/** + * Testable version of PerpsController that exposes protected methods for testing. + * This follows the pattern used in RewardsController.test.ts + */ +class TestablePerpsController extends PerpsController { + /** + * Test-only method to update state directly. + * Exposed for scenarios where state needs to be manipulated + * outside the normal public API (e.g., testing error conditions). + * @param callback + */ + public testUpdate(callback: (state: PerpsControllerState) => void) { + this.update(callback); + } + + /** + * Test-only method to mark controller as initialized. + * Common test scenario that requires internal state changes. + */ + public testMarkInitialized() { + this.isInitialized = true; + this.update((state) => { + state.initializationState = InitializationState.Initialized; + }); + } + + /** + * Test-only method to set the providers map with complete providers. + * Used in most tests to inject mock providers. + * Also sets activeProviderInstance to the first provider (default provider). + * @param providers + */ + public testSetProviders(providers: Map) { + this.providers = providers; + // Set activeProviderInstance to the first provider (typically 'hyperliquid') + const firstProvider = providers.values().next().value; + if (firstProvider) { + this.activeProviderInstance = firstProvider; + } + } + + /** + * Test-only method to set the providers map with partial providers. + * Used explicitly in tests that verify error handling with incomplete providers. + * Type cast is intentional and necessary for testing graceful degradation. + * @param providers + */ + public testSetPartialProviders( + providers: Map>, + ) { + this.providers = providers as Map; + } + + /** + * Test-only method to get the providers map. + * Used to verify provider state in tests. + */ + public testGetProviders(): Map { + return this.providers; + } + + /** + * Test-only method to set initialization state. + * Allows tests to simulate both initialized and uninitialized states. + * @param value + */ + public testSetInitialized(value: boolean) { + this.isInitialized = value; + } + + /** + * Test-only method to get initialization state. + * Used to verify initialization status in tests. + */ + public testGetInitialized(): boolean { + return this.isInitialized; + } + + /** + * Test-only method to get blocked region list. + * Used to verify geo-blocking configuration in tests. + */ + public testGetBlockedRegionList(): { source: string; list: string[] } { + return this.blockedRegionList; + } + + /** + * Test-only method to set blocked region list. + * Used to test priority logic (remote vs fallback). + * @param list + * @param source + */ + public testSetBlockedRegionList( + list: string[], + source: 'remote' | 'fallback', + ) { + this.setBlockedRegionList(list, source); + } + + /** + * Test accessor for protected method refreshEligibilityOnFeatureFlagChange. + * Wrapper is necessary because protected methods can't be called from test code. + * @param remoteFlags + */ + public testRefreshEligibilityOnFeatureFlagChange(remoteFlags: any) { + this.refreshEligibilityOnFeatureFlagChange(remoteFlags); + } + + /** + * Test accessor for protected method reportOrderToDataLake. + * Wrapper is necessary because protected methods can't be called from test code. + * @param data + */ + public testReportOrderToDataLake(data: any): Promise { + return this.reportOrderToDataLake(data); + } + + public testHasStandaloneProvider(): boolean { + return this.hasStandaloneProvider(); + } + + public testRegisterMYXProvider( + MYXProvider: new (opts: Record) => PerpsProvider, + ) { + this.registerMYXProvider(MYXProvider as never); + } + + public testHandleMYXImportError(error: unknown) { + this.handleMYXImportError(error); + } +} + +describe('PerpsController', () => { + let controller: TestablePerpsController; + let mockProvider: jest.Mocked; + let mockInfrastructure: jest.Mocked; + + // Helper to mark controller as initialized for tests + const markControllerAsInitialized = () => { + controller.testMarkInitialized(); + }; + + beforeEach(() => { + jest.clearAllMocks(); + + ( + jest.requireMock('../../src/services/EligibilityService') + .EligibilityService as jest.Mock + ).mockImplementation(() => mockEligibilityServiceInstance); + ( + jest.requireMock('../../src/services/DepositService') + .DepositService as jest.Mock + ).mockImplementation(() => mockDepositServiceInstance); + ( + jest.requireMock('../../src/services/MarketDataService') + .MarketDataService as jest.Mock + ).mockImplementation(() => mockMarketDataServiceInstance); + ( + jest.requireMock('../../src/services/TradingService') + .TradingService as jest.Mock + ).mockImplementation(() => mockTradingServiceInstance); + ( + jest.requireMock('../../src/services/AccountService') + .AccountService as jest.Mock + ).mockImplementation(() => mockAccountServiceInstance); + ( + jest.requireMock('../../src/services/DataLakeService') + .DataLakeService as jest.Mock + ).mockImplementation(() => mockDataLakeServiceInstance); + ( + jest.requireMock('../../src/services/FeatureFlagConfigurationService') + .FeatureFlagConfigurationService as jest.Mock + ).mockImplementation(() => mockFeatureFlagConfigurationServiceInstance); + + mockEligibilityServiceInstance.checkEligibility.mockResolvedValue(true); + mockMarketDataServiceInstance.getPositions.mockResolvedValue([]); + mockMarketDataServiceInstance.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockMarketDataServiceInstance.getMarkets.mockResolvedValue([]); + mockMarketDataServiceInstance.getWithdrawalRoutes.mockReturnValue([]); + mockMarketDataServiceInstance.validateClosePosition.mockResolvedValue({ + isValid: true, + }); + mockMarketDataServiceInstance.calculateMaintenanceMargin.mockResolvedValue( + 0, + ); + mockMarketDataServiceInstance.calculateFees.mockResolvedValue({ + totalFee: 0, + }); + mockMarketDataServiceInstance.getAvailableDexs.mockResolvedValue([]); + + mockFeatureFlagConfigurationServiceInstance.refreshEligibility.mockImplementation( + (options: any) => { + const remoteFlags = + options.remoteFeatureFlagControllerState.remoteFeatureFlags; + const perpsGeoBlockedRegionsFeatureFlag = + remoteFlags?.perpsPerpTradingGeoBlockedCountriesV2; + const remoteBlockedRegions = + perpsGeoBlockedRegionsFeatureFlag?.blockedRegions; + + if ( + Array.isArray(remoteBlockedRegions) && + options.context.setBlockedRegionList + ) { + const currentList = options.context.getBlockedRegionList?.(); + if (!currentList || currentList.source !== 'remote') { + options.context.setBlockedRegionList( + remoteBlockedRegions, + 'remote', + ); + } + } + + if (options.context.refreshEligibility) { + options.context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + + if (remoteFlags) { + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config( + options, + ); + } + }, + ); + mockFeatureFlagConfigurationServiceInstance.setBlockedRegions.mockImplementation( + (options: any) => { + const { list, source, context } = options; + if (context.setBlockedRegionList && context.getBlockedRegionList) { + const currentList = context.getBlockedRegionList(); + if (source === 'fallback' && currentList.source === 'remote') { + return; + } + if (Array.isArray(list)) { + context.setBlockedRegionList(list, source); + } + } + + if (context.refreshEligibility) { + context.refreshEligibility().catch(() => { + // Ignore errors in mock + }); + } + }, + ); + mockFeatureFlagConfigurationServiceInstance.refreshHip3Config.mockImplementation( + () => undefined, + ); + + // Reset Engine.context mocks to default state to prevent test interdependence + ( + Engine.context.RewardsController.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(null); + ( + Engine.context.NetworkController.getNetworkClientById as jest.Mock + ).mockReturnValue({ configuration: { chainId: '0x1' } }); + + // Create a fresh mock provider for each test + mockProvider = createMockHyperLiquidProvider(); + + // Add default mock return values for all provider methods + mockProvider.getPositions.mockResolvedValue([]); + mockProvider.getAccountState.mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + mockProvider.getMarkets.mockResolvedValue([]); + mockProvider.getOpenOrders.mockResolvedValue([]); + mockProvider.getFunding.mockResolvedValue([]); + mockProvider.getOrderFills.mockResolvedValue([]); + mockProvider.getOrders.mockResolvedValue([]); + mockProvider.calculateLiquidationPrice.mockResolvedValue('0'); + mockProvider.getMaxLeverage.mockResolvedValue(50); + mockProvider.calculateMaintenanceMargin.mockResolvedValue(0); + mockProvider.calculateFees.mockResolvedValue({ feeAmount: 0 }); + mockProvider.getBlockExplorerUrl.mockReturnValue( + 'https://explorer.example.com', + ); + mockProvider.getWithdrawalRoutes.mockReturnValue([]); + + ( + HyperLiquidProvider as jest.MockedClass + ).mockImplementation(() => mockProvider); + + const mockCall = jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: [], + }, + }, + }; + } + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + id: 'account-1', + options: {}, + scopes: ['eip155:1'], + methods: [], + metadata: { + name: 'Test', + importTime: 0, + keyring: { type: 'HD Key Tree' }, + }, + }, + ]; + } + return undefined; + }); + + mockInfrastructure = createMockInfrastructure(); + controller = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: mockInfrastructure, + }); + }); + + afterEach(() => { + // Clear only provider mocks, not Engine.context mocks + // This prevents breaking Engine.context.RewardsController/NetworkController references + if (mockProvider) { + Object.values(mockProvider).forEach((value) => { + if ( + typeof value === 'object' && + value !== null && + 'mockClear' in value + ) { + (value as jest.Mock).mockClear(); + } + }); + } + (mockInfrastructure.metrics.trackPerpsEvent as jest.Mock).mockClear(); + (mockInfrastructure.logger.error as jest.Mock).mockClear(); + (mockInfrastructure.debugLogger.log as jest.Mock).mockClear(); + }); + describe('getPositions', () => { + it('gets positions successfully', async () => { + const mockPositions = [ + { + symbol: 'ETH', + size: '2.5', + entryPrice: '2000', + positionValue: '5000', + unrealizedPnl: '500', + marginUsed: '2500', + leverage: { type: 'cross' as const, value: 2 }, + liquidationPrice: '1500', + maxLeverage: 100, + returnOnEquity: '10.0', + cumulativeFunding: { + allTime: '10', + sinceOpen: '5', + sinceChange: '2', + }, + takeProfitCount: 0, + stopLossCount: 0, + }, + ]; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getPositions') + .mockResolvedValue(mockPositions); + + const result = await controller.getPositions(); + + expect(result).toEqual(mockPositions); + expect(mockMarketDataServiceInstance.getPositions).toHaveBeenCalledWith({ + provider: mockProvider, + params: undefined, + context: expect.any(Object), + }); + }); + + it('handles getPositions error', async () => { + const errorMessage = 'Network error'; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getPositions') + .mockRejectedValue(new Error(errorMessage)); + + await expect(controller.getPositions()).rejects.toThrow(errorMessage); + expect(mockMarketDataServiceInstance.getPositions).toHaveBeenCalled(); + }); + }); + + describe('getAccountState', () => { + it('gets account state successfully', async () => { + const mockAccountState = { + spendableBalance: '1000', + withdrawableBalance: '1000', + marginUsed: '500', + unrealizedPnl: '100', + returnOnEquity: '20.0', + totalBalance: '1600', + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getAccountState') + .mockResolvedValue(mockAccountState); + + const result = await controller.getAccountState(); + + expect(result).toEqual(mockAccountState); + expect( + mockMarketDataServiceInstance.getAccountState, + ).toHaveBeenCalledWith({ + provider: mockProvider, + params: undefined, + context: expect.any(Object), + }); + }); + }); + + describe('placeOrder', () => { + it('places order successfully', async () => { + const orderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market' as const, + }; + + const mockOrderResult = { + success: true, + orderId: 'order-123', + filledSize: '0.1', + averagePrice: '50000', + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockTradingServiceInstance, 'placeOrder') + .mockResolvedValue(mockOrderResult); + + const result = await controller.placeOrder(orderParams); + + expect(result).toEqual(mockOrderResult); + expect(mockTradingServiceInstance.placeOrder).toHaveBeenCalledWith( + expect.objectContaining({ + provider: mockProvider, + params: orderParams, + context: expect.any(Object), + }), + ); + }); + + it('handles placeOrder error', async () => { + const orderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market' as const, + }; + + const errorMessage = 'Order placement failed'; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockTradingServiceInstance, 'placeOrder') + .mockRejectedValue(new Error(errorMessage)); + + await expect(controller.placeOrder(orderParams)).rejects.toThrow( + errorMessage, + ); + expect(mockTradingServiceInstance.placeOrder).toHaveBeenCalled(); + }); + }); + + describe('getMarkets', () => { + it('gets markets successfully', async () => { + const mockMarkets = [ + { + name: 'BTC', + szDecimals: 3, + maxLeverage: 50, + marginTableId: 1, + }, + { + name: 'ETH', + szDecimals: 2, + maxLeverage: 25, + marginTableId: 2, + }, + ]; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getMarkets') + .mockResolvedValue(mockMarkets); + + const result = await controller.getMarkets(); + + expect(result).toEqual(mockMarkets); + expect(mockMarketDataServiceInstance.getMarkets).toHaveBeenCalledWith({ + provider: mockProvider, + params: undefined, + context: expect.any(Object), + }); + }); + }); + + describe('cancelOrder', () => { + it('cancels order successfully', async () => { + const cancelParams = { + orderId: 'order-123', + symbol: 'BTC', + }; + + const mockCancelResult = { + success: true, + orderId: 'order-123', + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockTradingServiceInstance, 'cancelOrder') + .mockResolvedValue(mockCancelResult); + + const result = await controller.cancelOrder(cancelParams); + + expect(result).toEqual(mockCancelResult); + expect(mockTradingServiceInstance.cancelOrder).toHaveBeenCalledWith( + expect.objectContaining({ + provider: mockProvider, + params: cancelParams, + context: expect.any(Object), + }), + ); + }); + }); + + describe('cancelOrders', () => { + it('delegates to TradingService with withStreamPause callback', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + const mockImplementation = jest.fn(async (options: any) => { + // Simulate TradingService calling the withStreamPause callback + await options.withStreamPause( + async () => ({ + success: true, + successCount: 1, + failureCount: 0, + results: [{ symbol: 'BTC', orderId: 'order-1', success: true }], + }), + ['orders'], + ); + + return { + success: true, + successCount: 1, + failureCount: 0, + results: [{ symbol: 'BTC', orderId: 'order-1', success: true }], + }; + }); + + jest + .spyOn(mockTradingServiceInstance, 'cancelOrders') + .mockImplementation(mockImplementation); + + await controller.cancelOrders({ cancelAll: true }); + + expect( + mockInfrastructure.streamManager.pauseChannel, + ).toHaveBeenCalledWith('orders'); + expect( + mockInfrastructure.streamManager.resumeChannel, + ).toHaveBeenCalledWith('orders'); + expect(mockTradingServiceInstance.cancelOrders).toHaveBeenCalledWith( + expect.objectContaining({ + provider: mockProvider, + params: { cancelAll: true }, + context: expect.any(Object), + withStreamPause: expect.any(Function), + }), + ); + }); + + it('resumes streams even when operation throws error', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + const mockImplementation = jest.fn(async (options: any) => + // Simulate TradingService calling the withStreamPause callback with an error + options.withStreamPause(async () => { + throw new Error('Network error'); + }, ['orders']), + ); + + jest + .spyOn(mockTradingServiceInstance, 'cancelOrders') + .mockImplementation(mockImplementation); + + await expect( + controller.cancelOrders({ cancelAll: true }), + ).rejects.toThrow('Network error'); + + expect( + mockInfrastructure.streamManager.pauseChannel, + ).toHaveBeenCalledWith('orders'); + expect( + mockInfrastructure.streamManager.resumeChannel, + ).toHaveBeenCalledWith('orders'); + }); + }); + + describe('closePosition', () => { + it('closes position successfully', async () => { + const closeParams = { + symbol: 'BTC', + orderType: 'market' as const, + size: '0.5', + }; + + const mockCloseResult = { + success: true, + orderId: 'close-order-123', + filledSize: '0.5', + averagePrice: '50000', + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockTradingServiceInstance, 'closePosition') + .mockResolvedValue(mockCloseResult); + + const result = await controller.closePosition(closeParams); + + expect(result).toEqual(mockCloseResult); + expect(mockTradingServiceInstance.closePosition).toHaveBeenCalledWith( + expect.objectContaining({ + provider: mockProvider, + params: closeParams, + context: expect.any(Object), + }), + ); + }); + }); + + describe('closePositions', () => { + it('delegates to TradingService.closePositions', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + jest + .spyOn(mockTradingServiceInstance, 'closePositions') + .mockResolvedValue({ + success: true, + successCount: 1, + failureCount: 0, + results: [{ symbol: 'BTC', success: true }], + }); + + const result = await controller.closePositions({ closeAll: true }); + + expect(result.success).toBe(true); + expect(result.successCount).toBe(1); + expect(mockTradingServiceInstance.closePositions).toHaveBeenCalledWith( + expect.objectContaining({ + provider: mockProvider, + params: { closeAll: true }, + context: expect.any(Object), + }), + ); + }); + }); + + describe('validateOrder', () => { + it('validates order successfully', async () => { + const orderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market' as const, + }; + + const mockValidationResult = { + isValid: true, + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'validateOrder') + .mockResolvedValue(mockValidationResult); + + const result = await controller.validateOrder(orderParams); + + expect(result).toEqual(mockValidationResult); + expect(mockMarketDataServiceInstance.validateOrder).toHaveBeenCalledWith({ + provider: mockProvider, + params: orderParams, + context: expect.any(Object), + }); + }); + }); + + describe('getOrderFills', () => { + it('gets order fills successfully', async () => { + const mockOrderFills = [ + { + orderId: 'order-123', + symbol: 'BTC', + side: 'buy', + size: '0.1', + price: '50000', + pnl: '100', + direction: 'long', + fee: '5', + feeToken: 'USDC', + timestamp: 1640995200000, + }, + ]; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getOrderFills') + .mockResolvedValue(mockOrderFills); + + const result = await controller.getOrderFills(); + + expect(result).toEqual(mockOrderFills); + expect(mockMarketDataServiceInstance.getOrderFills).toHaveBeenCalledWith({ + provider: mockProvider, + params: undefined, + context: expect.any(Object), + }); + }); + }); + + describe('getOrders', () => { + it('gets orders successfully', async () => { + const mockOrders = [ + { + orderId: 'order-123', + symbol: 'BTC', + side: 'buy' as const, + orderType: 'market' as const, + size: '0.1', + originalSize: '0.1', + price: '50000', + filledSize: '0.1', + remainingSize: '0', + status: 'filled' as const, + timestamp: 1640995200000, + }, + ]; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + jest + .spyOn(mockMarketDataServiceInstance, 'getOrders') + .mockResolvedValue(mockOrders); + + const result = await controller.getOrders(); + + expect(result).toEqual(mockOrders); + expect(mockMarketDataServiceInstance.getOrders).toHaveBeenCalledWith({ + provider: mockProvider, + params: undefined, + context: expect.any(Object), + }); + }); + }); + + describe('subscribeToPrices', () => { + it('subscribes to price updates', () => { + const mockUnsubscribe = jest.fn(); + const params = { + symbols: ['BTC', 'ETH'], + callback: jest.fn(), + }; + + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + mockProvider.subscribeToPrices.mockReturnValue(mockUnsubscribe); + + const unsubscribe = controller.subscribeToPrices(params); + + expect(unsubscribe).toBe(mockUnsubscribe); + expect(mockProvider.subscribeToPrices).toHaveBeenCalledWith(params); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/aggregation/SubscriptionMultiplexer.test.ts b/packages/perps-controller/tests/src/aggregation/SubscriptionMultiplexer.test.ts new file mode 100644 index 0000000000..08ad4a2f6d --- /dev/null +++ b/packages/perps-controller/tests/src/aggregation/SubscriptionMultiplexer.test.ts @@ -0,0 +1,821 @@ +import { SubscriptionMultiplexer } from '../../../src/aggregation/SubscriptionMultiplexer'; +/* eslint-disable */ +import type { + PerpsProvider, + PerpsLogger, + PriceUpdate, + Position, + Order, + OrderFill, + AccountState, +} from '../../../src/types'; + +// Mock logger factory +const createMockLogger = (): jest.Mocked => ({ + error: jest.fn(), +}); + +// Mock provider with test helper methods +type MockProviderWithEmit = jest.Mocked> & { + _emitPrices: (prices: PriceUpdate[]) => void; + _emitPositions: (positions: Position[]) => void; + _emitOrders: (orders: Order[]) => void; + _emitFills: (fills: OrderFill[], isSnapshot?: boolean) => void; + _emitAccount: (account: AccountState | null) => void; +}; + +// Mock provider factory +const createMockProvider = (providerId: string): MockProviderWithEmit => { + const priceCallbacks: ((prices: PriceUpdate[]) => void)[] = []; + const positionCallbacks: ((positions: Position[]) => void)[] = []; + const orderCallbacks: ((orders: Order[]) => void)[] = []; + const fillCallbacks: ((fills: OrderFill[], isSnapshot?: boolean) => void)[] = + []; + const accountCallbacks: ((account: AccountState | null) => void)[] = []; + + return { + protocolId: providerId, + subscribeToPrices: jest.fn((params) => { + priceCallbacks.push(params.callback); + return () => { + const idx = priceCallbacks.indexOf(params.callback); + if (idx > -1) { + priceCallbacks.splice(idx, 1); + } + }; + }), + subscribeToPositions: jest.fn((params) => { + positionCallbacks.push(params.callback); + return () => { + const idx = positionCallbacks.indexOf(params.callback); + if (idx > -1) { + positionCallbacks.splice(idx, 1); + } + }; + }), + subscribeToOrders: jest.fn((params) => { + orderCallbacks.push(params.callback); + return () => { + const idx = orderCallbacks.indexOf(params.callback); + if (idx > -1) { + orderCallbacks.splice(idx, 1); + } + }; + }), + subscribeToOrderFills: jest.fn((params) => { + fillCallbacks.push(params.callback); + return () => { + const idx = fillCallbacks.indexOf(params.callback); + if (idx > -1) { + fillCallbacks.splice(idx, 1); + } + }; + }), + subscribeToAccount: jest.fn((params) => { + accountCallbacks.push(params.callback); + return () => { + const idx = accountCallbacks.indexOf(params.callback); + if (idx > -1) { + accountCallbacks.splice(idx, 1); + } + }; + }), + // Helper to emit updates in tests + _emitPrices: (prices: PriceUpdate[]) => { + priceCallbacks.forEach((cb) => cb(prices)); + }, + _emitPositions: (positions: Position[]) => { + positionCallbacks.forEach((cb) => cb(positions)); + }, + _emitOrders: (orders: Order[]) => { + orderCallbacks.forEach((cb) => cb(orders)); + }, + _emitFills: (fills: OrderFill[], isSnapshot?: boolean) => { + fillCallbacks.forEach((cb) => cb(fills, isSnapshot)); + }, + _emitAccount: (account: AccountState | null) => { + accountCallbacks.forEach((cb) => cb(account)); + }, + } as MockProviderWithEmit; +}; + +describe('SubscriptionMultiplexer', () => { + let mux: SubscriptionMultiplexer; + let mockHLProvider: ReturnType; + let mockMYXProvider: ReturnType; + + beforeEach(() => { + mux = new SubscriptionMultiplexer(); + mockHLProvider = createMockProvider('hyperliquid'); + mockMYXProvider = createMockProvider('myx'); + }); + + describe('subscribeToPrices', () => { + it('subscribes to multiple providers', () => { + const callback = jest.fn(); + + mux.subscribeToPrices({ + symbols: ['BTC', 'ETH'], + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ['myx', mockMYXProvider as unknown as PerpsProvider], + ], + callback, + }); + + expect(mockHLProvider.subscribeToPrices).toHaveBeenCalledTimes(1); + expect(mockMYXProvider.subscribeToPrices).toHaveBeenCalledTimes(1); + }); + + it('injects providerId into price updates', () => { + const callback = jest.fn(); + + mux.subscribeToPrices({ + symbols: ['BTC'], + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ], + callback, + }); + + // Emit price from provider + mockHLProvider._emitPrices([ + { symbol: 'BTC', price: '50000', timestamp: Date.now() }, + ]); + + expect(callback).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'BTC', + price: '50000', + providerId: 'hyperliquid', + }), + ]), + ); + }); + + it('aggregates prices from multiple providers in merge mode', () => { + const callback = jest.fn(); + + mux.subscribeToPrices({ + symbols: ['BTC'], + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ['myx', mockMYXProvider as unknown as PerpsProvider], + ], + callback, + aggregationMode: 'merge', + }); + + // Emit from both providers + mockHLProvider._emitPrices([ + { symbol: 'BTC', price: '50000', timestamp: Date.now() }, + ]); + mockMYXProvider._emitPrices([ + { symbol: 'BTC', price: '50100', timestamp: Date.now() }, + ]); + + // After second emission, should have both prices + const lastCall = callback.mock.calls.at(-1)?.[0]; + expect(lastCall).toHaveLength(2); + expect(lastCall).toContainEqual( + expect.objectContaining({ providerId: 'hyperliquid', price: '50000' }), + ); + expect(lastCall).toContainEqual( + expect.objectContaining({ providerId: 'myx', price: '50100' }), + ); + }); + + it('selects best price in best_price mode', () => { + const callback = jest.fn(); + + mux.subscribeToPrices({ + symbols: ['BTC'], + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ['myx', mockMYXProvider as unknown as PerpsProvider], + ], + callback, + aggregationMode: 'best_price', + }); + + // Emit from both providers with different spreads + mockHLProvider._emitPrices([ + { symbol: 'BTC', price: '50000', timestamp: Date.now(), spread: '10' }, + ]); + mockMYXProvider._emitPrices([ + { symbol: 'BTC', price: '50100', timestamp: Date.now(), spread: '5' }, + ]); + + // Should return MYX price (smaller spread) + const lastCall = callback.mock.calls.at(-1)?.[0]; + expect(lastCall).toHaveLength(1); + expect(lastCall[0]).toMatchObject({ + providerId: 'myx', + spread: '5', + }); + }); + + it('unsubscribes from all providers', () => { + const callback = jest.fn(); + + const unsubscribe = mux.subscribeToPrices({ + symbols: ['BTC'], + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ['myx', mockMYXProvider as unknown as PerpsProvider], + ], + callback, + }); + + unsubscribe(); + + // Emit after unsubscribe - callback should not be called + callback.mockClear(); + mockHLProvider._emitPrices([ + { symbol: 'BTC', price: '50000', timestamp: Date.now() }, + ]); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe('subscribeToPositions', () => { + const createMockPosition = (symbol: string, size: string): Position => + ({ + symbol, + size, + entryPrice: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPrice: '45000', + maxLeverage: 50, + returnOnEquity: '2%', + cumulativeFunding: { + allTime: '10', + sinceOpen: '5', + sinceChange: '2', + }, + takeProfitCount: 0, + stopLossCount: 0, + }) as Position; + + it('injects providerId into position updates', () => { + const callback = jest.fn(); + + mux.subscribeToPositions({ + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ], + callback, + }); + + mockHLProvider._emitPositions([createMockPosition('BTC', '0.1')]); + + expect(callback).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'BTC', + providerId: 'hyperliquid', + }), + ]), + ); + }); + + it('aggregates positions from multiple providers', () => { + const callback = jest.fn(); + + mux.subscribeToPositions({ + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ['myx', mockMYXProvider as unknown as PerpsProvider], + ], + callback, + }); + + mockHLProvider._emitPositions([createMockPosition('BTC', '0.1')]); + mockMYXProvider._emitPositions([createMockPosition('ETH', '1.0')]); + + const lastCall = callback.mock.calls.at(-1)?.[0]; + expect(lastCall).toHaveLength(2); + expect(lastCall).toContainEqual( + expect.objectContaining({ symbol: 'BTC', providerId: 'hyperliquid' }), + ); + expect(lastCall).toContainEqual( + expect.objectContaining({ symbol: 'ETH', providerId: 'myx' }), + ); + }); + }); + + describe('subscribeToOrders', () => { + const createMockOrder = (orderId: string, symbol: string): Order => + ({ + orderId, + symbol, + side: 'buy', + orderType: 'limit', + size: '0.1', + originalSize: '0.1', + price: '50000', + filledSize: '0', + remainingSize: '0.1', + status: 'open', + timestamp: Date.now(), + }) as Order; + + it('injects providerId into order updates', () => { + const callback = jest.fn(); + + mux.subscribeToOrders({ + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ], + callback, + }); + + mockHLProvider._emitOrders([createMockOrder('order-1', 'BTC')]); + + expect(callback).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + orderId: 'order-1', + providerId: 'hyperliquid', + }), + ]), + ); + }); + + it('aggregates orders from multiple providers', () => { + const callback = jest.fn(); + + mux.subscribeToOrders({ + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ['myx', mockMYXProvider as unknown as PerpsProvider], + ], + callback, + }); + + mockHLProvider._emitOrders([createMockOrder('hl-order', 'BTC')]); + mockMYXProvider._emitOrders([createMockOrder('myx-order', 'ETH')]); + + const lastCall = callback.mock.calls.at(-1)?.[0]; + expect(lastCall).toHaveLength(2); + expect(lastCall).toContainEqual( + expect.objectContaining({ + orderId: 'hl-order', + providerId: 'hyperliquid', + }), + ); + expect(lastCall).toContainEqual( + expect.objectContaining({ orderId: 'myx-order', providerId: 'myx' }), + ); + }); + }); + + describe('subscribeToOrderFills', () => { + const createMockFill = (orderId: string, symbol: string): OrderFill => + ({ + orderId, + symbol, + side: 'buy', + size: '0.1', + price: '50000', + pnl: '100', + direction: 'long', + fee: '0.5', + feeToken: 'USDC', + timestamp: Date.now(), + }) as OrderFill; + + it('injects providerId into fill updates', () => { + const callback = jest.fn(); + + mux.subscribeToOrderFills({ + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ], + callback, + }); + + mockHLProvider._emitFills([createMockFill('fill-1', 'BTC')], false); + + expect(callback).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + orderId: 'fill-1', + providerId: 'hyperliquid', + }), + ]), + false, + ); + }); + + it('passes through isSnapshot flag', () => { + const callback = jest.fn(); + + mux.subscribeToOrderFills({ + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ], + callback, + }); + + mockHLProvider._emitFills([createMockFill('fill-1', 'BTC')], true); + + expect(callback).toHaveBeenCalledWith(expect.any(Array), true); + }); + }); + + describe('subscribeToAccount', () => { + const createMockAccount = (balance: string): AccountState => + ({ + spendableBalance: balance, + withdrawableBalance: balance, + totalBalance: balance, + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }) as AccountState; + + it('injects providerId into account updates', () => { + const callback = jest.fn(); + + mux.subscribeToAccount({ + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ], + callback, + }); + + mockHLProvider._emitAccount(createMockAccount('10000')); + + expect(callback).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + spendableBalance: '10000', + withdrawableBalance: '10000', + providerId: 'hyperliquid', + }), + ]), + ); + }); + + it('aggregates accounts from multiple providers', () => { + const callback = jest.fn(); + + mux.subscribeToAccount({ + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ['myx', mockMYXProvider as unknown as PerpsProvider], + ], + callback, + }); + + mockHLProvider._emitAccount(createMockAccount('10000')); + mockMYXProvider._emitAccount(createMockAccount('5000')); + + const lastCall = callback.mock.calls.at(-1)?.[0]; + expect(lastCall).toHaveLength(2); + expect(lastCall).toContainEqual( + expect.objectContaining({ + spendableBalance: '10000', + withdrawableBalance: '10000', + providerId: 'hyperliquid', + }), + ); + expect(lastCall).toContainEqual( + expect.objectContaining({ + spendableBalance: '5000', + withdrawableBalance: '5000', + providerId: 'myx', + }), + ); + }); + + it('removes provider from cache and invokes callback when provider emits null', () => { + const callback = jest.fn(); + + mux.subscribeToAccount({ + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ], + callback, + }); + + mockHLProvider._emitAccount(createMockAccount('10000')); + expect(callback).toHaveBeenLastCalledWith( + expect.arrayContaining([ + expect.objectContaining({ providerId: 'hyperliquid' }), + ]), + ); + + mockHLProvider._emitAccount(null); + + expect(callback).toHaveBeenLastCalledWith([]); + }); + }); + + describe('cache operations', () => { + it('caches prices', () => { + const callback = jest.fn(); + + mux.subscribeToPrices({ + symbols: ['BTC'], + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ], + callback, + }); + + mockHLProvider._emitPrices([ + { symbol: 'BTC', price: '50000', timestamp: Date.now() }, + ]); + + const cached = mux.getCachedPrice('BTC', 'hyperliquid'); + expect(cached).toMatchObject({ + symbol: 'BTC', + price: '50000', + providerId: 'hyperliquid', + }); + }); + + it('returns all cached prices for a symbol', () => { + const callback = jest.fn(); + + mux.subscribeToPrices({ + symbols: ['BTC'], + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ['myx', mockMYXProvider as unknown as PerpsProvider], + ], + callback, + }); + + mockHLProvider._emitPrices([ + { symbol: 'BTC', price: '50000', timestamp: Date.now() }, + ]); + mockMYXProvider._emitPrices([ + { symbol: 'BTC', price: '50100', timestamp: Date.now() }, + ]); + + const allPrices = mux.getAllCachedPricesForSymbol('BTC'); + expect(allPrices?.size).toBe(2); + expect(allPrices?.get('hyperliquid')?.price).toBe('50000'); + expect(allPrices?.get('myx')?.price).toBe('50100'); + }); + + it('clears cache', () => { + const callback = jest.fn(); + + mux.subscribeToPrices({ + symbols: ['BTC'], + providers: [ + ['hyperliquid', mockHLProvider as unknown as PerpsProvider], + ], + callback, + }); + + mockHLProvider._emitPrices([ + { symbol: 'BTC', price: '50000', timestamp: Date.now() }, + ]); + + mux.clearCache(); + + expect(mux.getCachedPrice('BTC', 'hyperliquid')).toBeUndefined(); + }); + }); + + describe('subscription cleanup on partial failure', () => { + let mockLogger: jest.Mocked; + let muxWithLogger: SubscriptionMultiplexer; + let successfulProvider: ReturnType; + let failingProvider: MockProviderWithEmit; + + beforeEach(() => { + mockLogger = createMockLogger(); + muxWithLogger = new SubscriptionMultiplexer({ logger: mockLogger }); + successfulProvider = createMockProvider('hyperliquid'); + + // Create a provider that throws on subscription + failingProvider = { + ...createMockProvider('myx'), + subscribeToPrices: jest.fn(() => { + throw new Error('Provider 2 failed'); + }), + subscribeToPositions: jest.fn(() => { + throw new Error('Provider 2 failed'); + }), + subscribeToOrders: jest.fn(() => { + throw new Error('Provider 2 failed'); + }), + subscribeToOrderFills: jest.fn(() => { + throw new Error('Provider 2 failed'); + }), + subscribeToAccount: jest.fn(() => { + throw new Error('Provider 2 failed'); + }), + } as MockProviderWithEmit; + }); + + it('cleans up successful subscriptions when subscribeToPrices fails for a later provider', () => { + // Track if the first provider's unsubscribe was called + const unsubMock = jest.fn(); + successfulProvider.subscribeToPrices = jest.fn(() => unsubMock); + + expect(() => { + muxWithLogger.subscribeToPrices({ + symbols: ['BTC'], + providers: [ + ['hyperliquid', successfulProvider as unknown as PerpsProvider], + ['myx', failingProvider as unknown as PerpsProvider], + ], + callback: jest.fn(), + }); + }).toThrow('Provider 2 failed'); + + // Verify cleanup was called for the successful subscription + expect(unsubMock).toHaveBeenCalled(); + // Verify error was logged with feature tag + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: { + feature: 'perps', + provider: 'myx', + method: 'subscribeToPrices', + }, + context: expect.objectContaining({ + name: 'SubscriptionMultiplexer', + data: { subscribedCount: 1 }, + }), + }), + ); + }); + + it('cleans up successful subscriptions when subscribeToPositions fails for a later provider', () => { + const unsubMock = jest.fn(); + successfulProvider.subscribeToPositions = jest.fn(() => unsubMock); + + expect(() => { + muxWithLogger.subscribeToPositions({ + providers: [ + ['hyperliquid', successfulProvider as unknown as PerpsProvider], + ['myx', failingProvider as unknown as PerpsProvider], + ], + callback: jest.fn(), + }); + }).toThrow('Provider 2 failed'); + + expect(unsubMock).toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: { + feature: 'perps', + provider: 'myx', + method: 'subscribeToPositions', + }, + }), + ); + }); + + it('cleans up successful subscriptions when subscribeToOrders fails for a later provider', () => { + const unsubMock = jest.fn(); + successfulProvider.subscribeToOrders = jest.fn(() => unsubMock); + + expect(() => { + muxWithLogger.subscribeToOrders({ + providers: [ + ['hyperliquid', successfulProvider as unknown as PerpsProvider], + ['myx', failingProvider as unknown as PerpsProvider], + ], + callback: jest.fn(), + }); + }).toThrow('Provider 2 failed'); + + expect(unsubMock).toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: { + feature: 'perps', + provider: 'myx', + method: 'subscribeToOrders', + }, + }), + ); + }); + + it('cleans up successful subscriptions when subscribeToOrderFills fails for a later provider', () => { + const unsubMock = jest.fn(); + successfulProvider.subscribeToOrderFills = jest.fn(() => unsubMock); + + expect(() => { + muxWithLogger.subscribeToOrderFills({ + providers: [ + ['hyperliquid', successfulProvider as unknown as PerpsProvider], + ['myx', failingProvider as unknown as PerpsProvider], + ], + callback: jest.fn(), + }); + }).toThrow('Provider 2 failed'); + + expect(unsubMock).toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: { + feature: 'perps', + provider: 'myx', + method: 'subscribeToOrderFills', + }, + }), + ); + }); + + it('cleans up successful subscriptions when subscribeToAccount fails for a later provider', () => { + const unsubMock = jest.fn(); + successfulProvider.subscribeToAccount = jest.fn(() => unsubMock); + + expect(() => { + muxWithLogger.subscribeToAccount({ + providers: [ + ['hyperliquid', successfulProvider as unknown as PerpsProvider], + ['myx', failingProvider as unknown as PerpsProvider], + ], + callback: jest.fn(), + }); + }).toThrow('Provider 2 failed'); + + expect(unsubMock).toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: { + feature: 'perps', + provider: 'myx', + method: 'subscribeToAccount', + }, + }), + ); + }); + + it('works without a logger (no crash when logger is undefined)', () => { + const muxNoLogger = new SubscriptionMultiplexer(); + const unsubMock = jest.fn(); + successfulProvider.subscribeToPrices = jest.fn(() => unsubMock); + + expect(() => { + muxNoLogger.subscribeToPrices({ + symbols: ['BTC'], + providers: [ + ['hyperliquid', successfulProvider as unknown as PerpsProvider], + ['myx', failingProvider as unknown as PerpsProvider], + ], + callback: jest.fn(), + }); + }).toThrow('Provider 2 failed'); + + // Cleanup still happens even without logger + expect(unsubMock).toHaveBeenCalled(); + }); + + it('cleans up multiple successful subscriptions when a later provider fails', () => { + const unsubMock1 = jest.fn(); + const unsubMock2 = jest.fn(); + const provider1 = createMockProvider('provider1'); + const provider2 = createMockProvider('provider2'); + + provider1.subscribeToPrices = jest.fn(() => unsubMock1); + provider2.subscribeToPrices = jest.fn(() => unsubMock2); + + expect(() => { + muxWithLogger.subscribeToPrices({ + symbols: ['BTC'], + providers: [ + ['hyperliquid', provider1 as unknown as PerpsProvider], + ['myx', provider2 as unknown as PerpsProvider], + ['myx', failingProvider as unknown as PerpsProvider], + ], + callback: jest.fn(), + }); + }).toThrow('Provider 2 failed'); + + // Both successful subscriptions should be cleaned up + expect(unsubMock1).toHaveBeenCalled(); + expect(unsubMock2).toHaveBeenCalled(); + // subscribedCount should be 2 since two providers succeeded before the failure + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + context: expect.objectContaining({ + data: { subscribedCount: 2 }, + }), + }), + ); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/constants/hyperLiquidConfig.test.ts b/packages/perps-controller/tests/src/constants/hyperLiquidConfig.test.ts new file mode 100644 index 0000000000..84ee8e319e --- /dev/null +++ b/packages/perps-controller/tests/src/constants/hyperLiquidConfig.test.ts @@ -0,0 +1,28 @@ +import { HIP3_ASSET_MARKET_TYPES } from '../../../src/constants/hyperLiquidConfig'; + +describe('HIP3_ASSET_MARKET_TYPES', () => { + it('classifies URNM as commodity (Sprott Uranium Miners ETF)', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:URNM']).toBe('commodity'); + }); + + it('classifies USAR as equity (US equity fund)', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:USAR']).toBe('equity'); + }); + + it('classifies known equities correctly', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:TSLA']).toBe('equity'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:NVDA']).toBe('equity'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:AAPL']).toBe('equity'); + }); + + it('classifies known commodities correctly', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:GOLD']).toBe('commodity'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:SILVER']).toBe('commodity'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:URANIUM']).toBe('commodity'); + }); + + it('classifies known forex pairs correctly', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:EUR']).toBe('forex'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:JPY']).toBe('forex'); + }); +}); diff --git a/packages/perps-controller/tests/src/constants/myxConfig.test.ts b/packages/perps-controller/tests/src/constants/myxConfig.test.ts new file mode 100644 index 0000000000..92f39a3049 --- /dev/null +++ b/packages/perps-controller/tests/src/constants/myxConfig.test.ts @@ -0,0 +1,142 @@ +/* eslint-disable */ +import BigNumber from 'bignumber.js'; + +import { + fromMYXPrice, + toMYXPrice, + fromMYXSize, + toMYXSize, + fromMYXCollateral, + getMYXChainId, + getMYXHttpEndpoint, + MYX_SIZE_DECIMALS, +} from '../../../src/constants/myxConfig'; + +describe('myxConfig', () => { + describe('fromMYXPrice', () => { + it('parses a normal float price string', () => { + expect(fromMYXPrice('1000')).toBe(1000); + }); + + it('returns 0 for "0"', () => { + expect(fromMYXPrice('0')).toBe(0); + }); + + it('returns 0 for empty string', () => { + expect(fromMYXPrice('')).toBe(0); + }); + + it('parses a realistic BTC price from MYX API', () => { + // MYX API returns normal float strings like "64854.760266796727" + expect(fromMYXPrice('64854.760266796727')).toBeCloseTo(64854.76, 2); + }); + + it('parses a sub-dollar price', () => { + // MYX token price ≈ $0.39 + expect(fromMYXPrice('0.390062307787905')).toBeCloseTo(0.39, 2); + }); + + it('returns 0 for invalid string', () => { + expect(fromMYXPrice('not-a-number')).toBe(0); + }); + }); + + describe('toMYXPrice', () => { + it('converts a number to string', () => { + expect(toMYXPrice(1000)).toBe('1000'); + }); + + it('converts a string input', () => { + expect(toMYXPrice('2500.5')).toBe('2500.5'); + }); + + it('returns "0" for invalid string', () => { + expect(toMYXPrice('invalid')).toBe('0'); + }); + }); + + describe('fromMYXSize', () => { + it('converts an 18-decimal size string to a number', () => { + const myxSize = new BigNumber(5) + .times(new BigNumber(10).pow(MYX_SIZE_DECIMALS)) + .toFixed(0); + expect(fromMYXSize(myxSize)).toBe(5); + }); + + it('returns 0 for "0"', () => { + expect(fromMYXSize('0')).toBe(0); + }); + + it('returns 0 for empty string', () => { + expect(fromMYXSize('')).toBe(0); + }); + + it('returns 0 for invalid string', () => { + expect(fromMYXSize('xyz')).toBe(0); + }); + }); + + describe('toMYXSize', () => { + it('converts a number to 18-decimal size string', () => { + const result = toMYXSize(3); + const expected = new BigNumber(3) + .times(new BigNumber(10).pow(MYX_SIZE_DECIMALS)) + .toFixed(0); + expect(result).toBe(expected); + }); + + it('converts a string input', () => { + const result = toMYXSize('0.5'); + const expected = new BigNumber('0.5') + .times(new BigNumber(10).pow(MYX_SIZE_DECIMALS)) + .toFixed(0); + expect(result).toBe(expected); + }); + + it('returns "0" for invalid string', () => { + expect(toMYXSize('bad')).toBe('0'); + }); + }); + + describe('fromMYXCollateral', () => { + it('converts an 18-decimal collateral string to a number', () => { + // 18 decimals (same as size) + const myxCollateral = new BigNumber(100) + .times(new BigNumber(10).pow(18)) + .toFixed(0); + expect(fromMYXCollateral(myxCollateral)).toBe(100); + }); + + it('returns 0 for "0"', () => { + expect(fromMYXCollateral('0')).toBe(0); + }); + + it('returns 0 for empty string', () => { + expect(fromMYXCollateral('')).toBe(0); + }); + + it('returns 0 for invalid string', () => { + expect(fromMYXCollateral('garbage')).toBe(0); + }); + }); + + describe('getMYXChainId', () => { + it('returns 59141 (Linea Sepolia) for testnet', () => { + expect(getMYXChainId('testnet')).toBe(59141); + }); + + it('returns 56 (BNB) for mainnet', () => { + expect(getMYXChainId('mainnet')).toBe(56); + }); + }); + + describe('getMYXHttpEndpoint', () => { + it('returns testnet URL for testnet', () => { + expect(getMYXHttpEndpoint('testnet')).toBe('https://api-test.myx.cash'); + }); + + it('returns prod URL for mainnet', () => { + expect(getMYXHttpEndpoint('mainnet')).toBe('https://api.myx.finance'); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts new file mode 100644 index 0000000000..e3ef218644 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -0,0 +1,869 @@ +import { CandlePeriod } from '../../../src/constants/chartConfig'; +import { AggregatedPerpsProvider } from '../../../src/providers/AggregatedPerpsProvider'; +import type { + PerpsProvider, + PerpsProviderType, + Position, + MarketInfo, + Order, +} from '../../../src/types'; +import { WebSocketConnectionState } from '../../../src/types'; +/* eslint-disable */ +import { createMockInfrastructure } from '../../helpers/serviceMocks'; + +// Create a comprehensive mock provider +const createMockProvider = (providerId: string): jest.Mocked => { + const mockProvider: jest.Mocked = { + protocolId: providerId, + + // Asset routes + getDepositRoutes: jest.fn().mockReturnValue([]), + getWithdrawalRoutes: jest.fn().mockReturnValue([]), + + // Read operations + getPositions: jest.fn().mockResolvedValue([]), + getAccountState: jest.fn().mockResolvedValue({ + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }), + getMarkets: jest.fn().mockResolvedValue([]), + getMarketDataWithPrices: jest.fn().mockResolvedValue([]), + getOrderFills: jest.fn().mockResolvedValue([]), + getOrFetchFills: jest.fn().mockResolvedValue([]), + getOrders: jest.fn().mockResolvedValue([]), + getOpenOrders: jest.fn().mockResolvedValue([]), + getFunding: jest.fn().mockResolvedValue([]), + getHistoricalPortfolio: jest.fn().mockResolvedValue({ + accountValue1dAgo: '10000', + timestamp: Date.now(), + }), + getUserNonFundingLedgerUpdates: jest.fn().mockResolvedValue([]), + getUserHistory: jest.fn().mockResolvedValue([]), + getCurrentAccountId: jest + .fn() + .mockResolvedValue( + `eip155:1:0x${providerId.padStart(40, '0').slice(0, 40)}`, + ), + + // Write operations + placeOrder: jest + .fn() + .mockResolvedValue({ success: true, orderId: 'order-123' }), + editOrder: jest + .fn() + .mockResolvedValue({ success: true, orderId: 'order-123' }), + cancelOrder: jest.fn().mockResolvedValue({ success: true }), + cancelOrders: jest.fn().mockResolvedValue({ + success: true, + successCount: 1, + failureCount: 0, + results: [], + }), + closePosition: jest.fn().mockResolvedValue({ success: true }), + closePositions: jest.fn().mockResolvedValue({ + success: true, + successCount: 1, + failureCount: 0, + results: [], + }), + updatePositionTPSL: jest.fn().mockResolvedValue({ success: true }), + updateMargin: jest.fn().mockResolvedValue({ success: true }), + withdraw: jest.fn().mockResolvedValue({ success: true }), + + // Validation + validateDeposit: jest.fn().mockResolvedValue({ isValid: true }), + validateOrder: jest.fn().mockResolvedValue({ isValid: true }), + validateClosePosition: jest.fn().mockResolvedValue({ isValid: true }), + validateWithdrawal: jest.fn().mockResolvedValue({ isValid: true }), + + // Calculations + calculateLiquidationPrice: jest.fn().mockResolvedValue('45000'), + calculateMaintenanceMargin: jest.fn().mockResolvedValue(0.05), + getMaxLeverage: jest.fn().mockResolvedValue(50), + calculateFees: jest.fn().mockResolvedValue({ feeRate: 0.001 }), + + // Subscriptions + subscribeToPrices: jest.fn().mockReturnValue(() => undefined), + subscribeToPositions: jest.fn().mockReturnValue(() => undefined), + subscribeToOrderFills: jest.fn().mockReturnValue(() => undefined), + subscribeToOrders: jest.fn().mockReturnValue(() => undefined), + subscribeToAccount: jest.fn().mockReturnValue(() => undefined), + subscribeToOICaps: jest.fn().mockReturnValue(() => undefined), + subscribeToCandles: jest.fn().mockReturnValue(() => undefined), + subscribeToOrderBook: jest.fn().mockReturnValue(() => undefined), + + // Configuration + setLiveDataConfig: jest.fn(), + setUserFeeDiscount: jest.fn(), + + // Lifecycle + toggleTestnet: jest + .fn() + .mockResolvedValue({ success: true, isTestnet: false }), + initialize: jest.fn().mockResolvedValue({ success: true }), + isReadyToTrade: jest.fn().mockResolvedValue({ ready: true }), + disconnect: jest.fn().mockResolvedValue({ success: true }), + ping: jest.fn().mockResolvedValue(undefined), + + // Block explorer + getBlockExplorerUrl: jest.fn().mockReturnValue('https://explorer.example'), + + // HIP-3 + getAvailableDexs: jest.fn().mockResolvedValue([]), + }; + + return mockProvider; +}; + +// Helper to create mock position +const createMockPosition = (symbol: string, size: string): Position => + ({ + symbol, + size, + entryPrice: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPrice: '45000', + maxLeverage: 50, + returnOnEquity: '2%', + cumulativeFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + takeProfitCount: 0, + stopLossCount: 0, + }) as Position; + +// Helper to create mock market +const createMockMarket = (name: string): MarketInfo => + ({ + name, + szDecimals: 3, + maxLeverage: 50, + marginTableId: 0, + }) as MarketInfo; + +// Helper to create mock order +const createMockOrder = (orderId: string, symbol: string): Order => + ({ + orderId, + symbol, + side: 'buy', + orderType: 'limit', + size: '0.1', + originalSize: '0.1', + price: '50000', + filledSize: '0', + remainingSize: '0.1', + status: 'open', + timestamp: Date.now(), + }) as Order; + +describe('AggregatedPerpsProvider', () => { + let aggregatedProvider: AggregatedPerpsProvider; + let mockHLProvider: jest.Mocked; + let mockMYXProvider: jest.Mocked; + let mockInfrastructure: ReturnType; + + beforeEach(() => { + mockHLProvider = createMockProvider('hyperliquid'); + mockMYXProvider = createMockProvider('myx'); + mockInfrastructure = createMockInfrastructure(); + + aggregatedProvider = new AggregatedPerpsProvider({ + providers: new Map([ + ['hyperliquid', mockHLProvider], + ['myx', mockMYXProvider], + ]), + defaultProvider: 'hyperliquid', + infrastructure: mockInfrastructure, + }); + }); + + describe('constructor', () => { + it('initializes with provided providers', () => { + expect(aggregatedProvider.getProviderIds()).toContain('hyperliquid'); + expect(aggregatedProvider.getProviderIds()).toContain('myx'); + }); + + it('has protocolId set to "aggregated"', () => { + expect(aggregatedProvider.protocolId).toBe('aggregated'); + }); + }); + + describe('Read Operations - getPositions', () => { + it('aggregates positions from all providers', async () => { + mockHLProvider.getPositions.mockResolvedValue([ + createMockPosition('BTC', '0.1'), + ]); + mockMYXProvider.getPositions.mockResolvedValue([ + createMockPosition('ETH', '1.0'), + ]); + + const positions = await aggregatedProvider.getPositions(); + + expect(positions).toHaveLength(2); + expect(positions).toContainEqual( + expect.objectContaining({ symbol: 'BTC', providerId: 'hyperliquid' }), + ); + expect(positions).toContainEqual( + expect.objectContaining({ symbol: 'ETH', providerId: 'myx' }), + ); + }); + + it('injects providerId into each position', async () => { + mockHLProvider.getPositions.mockResolvedValue([ + createMockPosition('BTC', '0.1'), + ]); + + const positions = await aggregatedProvider.getPositions(); + + expect(positions[0].providerId).toBe('hyperliquid'); + }); + + it('handles partial failures gracefully', async () => { + mockHLProvider.getPositions.mockResolvedValue([ + createMockPosition('BTC', '0.1'), + ]); + mockMYXProvider.getPositions.mockRejectedValue( + new Error('Provider unavailable'), + ); + + const positions = await aggregatedProvider.getPositions(); + + // Should still return positions from successful provider + expect(positions).toHaveLength(1); + expect(positions[0].providerId).toBe('hyperliquid'); + }); + + it('returns empty array when all providers fail', async () => { + mockHLProvider.getPositions.mockRejectedValue(new Error('Error 1')); + mockMYXProvider.getPositions.mockRejectedValue(new Error('Error 2')); + + const positions = await aggregatedProvider.getPositions(); + + expect(positions).toEqual([]); + }); + }); + + describe('Read Operations - getMarkets', () => { + it('aggregates markets from all providers', async () => { + mockHLProvider.getMarkets.mockResolvedValue([createMockMarket('BTC')]); + mockMYXProvider.getMarkets.mockResolvedValue([createMockMarket('ETH')]); + + const markets = await aggregatedProvider.getMarkets(); + + expect(markets).toHaveLength(2); + expect(markets).toContainEqual( + expect.objectContaining({ name: 'BTC', providerId: 'hyperliquid' }), + ); + expect(markets).toContainEqual( + expect.objectContaining({ name: 'ETH', providerId: 'myx' }), + ); + }); + + it('keeps same market from different providers', async () => { + mockHLProvider.getMarkets.mockResolvedValue([createMockMarket('BTC')]); + mockMYXProvider.getMarkets.mockResolvedValue([createMockMarket('BTC')]); + + const markets = await aggregatedProvider.getMarkets(); + + // Both should be kept since they have different providerIds + expect(markets).toHaveLength(2); + }); + }); + + describe('Read Operations - getOrders', () => { + it('aggregates orders from all providers', async () => { + mockHLProvider.getOrders.mockResolvedValue([ + createMockOrder('hl-order', 'BTC'), + ]); + mockMYXProvider.getOrders.mockResolvedValue([ + createMockOrder('myx-order', 'ETH'), + ]); + + const orders = await aggregatedProvider.getOrders(); + + expect(orders).toHaveLength(2); + expect(orders).toContainEqual( + expect.objectContaining({ + orderId: 'hl-order', + providerId: 'hyperliquid', + }), + ); + expect(orders).toContainEqual( + expect.objectContaining({ orderId: 'myx-order', providerId: 'myx' }), + ); + }); + }); + + describe('Read Operations - getAccountState', () => { + it('returns account state from default provider with providerId injected', async () => { + const mockState = { + spendableBalance: '1000', + withdrawableBalance: '1000', + totalBalance: '1000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + mockHLProvider.getAccountState.mockResolvedValue(mockState); + + const result = await aggregatedProvider.getAccountState(); + + expect(result).toEqual({ ...mockState, providerId: 'hyperliquid' }); + }); + }); + + describe('Read Operations - getMarketDataWithPrices', () => { + it('aggregates market data from all providers', async () => { + mockHLProvider.getMarketDataWithPrices.mockResolvedValue([ + { + symbol: 'BTC', + name: 'Bitcoin', + maxLeverage: '50x', + price: '50000', + change24h: '100', + change24hPercent: '0.2', + volume: '1000000', + }, + ]); + mockMYXProvider.getMarketDataWithPrices.mockResolvedValue([ + { + symbol: 'ETH', + name: 'Ethereum', + maxLeverage: '25x', + price: '3000', + change24h: '50', + change24hPercent: '1.5', + volume: '500000', + }, + ]); + + const result = await aggregatedProvider.getMarketDataWithPrices(); + + expect(result).toHaveLength(2); + expect(result).toContainEqual( + expect.objectContaining({ symbol: 'BTC', providerId: 'hyperliquid' }), + ); + expect(result).toContainEqual( + expect.objectContaining({ symbol: 'ETH', providerId: 'myx' }), + ); + }); + }); + + describe('Read Operations - getOrderFills', () => { + it('aggregates order fills from all providers', async () => { + mockHLProvider.getOrderFills.mockResolvedValue([ + { + orderId: '1', + symbol: 'BTC', + side: 'buy', + size: '0.1', + price: '50000', + pnl: '0', + direction: 'long', + fee: '5', + feeToken: 'USDC', + timestamp: Date.now(), + }, + ]); + mockMYXProvider.getOrderFills.mockResolvedValue([ + { + orderId: '2', + symbol: 'ETH', + side: 'sell', + size: '1', + price: '3000', + pnl: '0', + direction: 'short', + fee: '3', + feeToken: 'USDC', + timestamp: Date.now(), + }, + ]); + + const result = await aggregatedProvider.getOrderFills(); + + expect(result).toHaveLength(2); + expect(result).toContainEqual( + expect.objectContaining({ orderId: '1', providerId: 'hyperliquid' }), + ); + }); + }); + + describe('Read Operations - getOpenOrders', () => { + it('aggregates open orders from all providers', async () => { + mockHLProvider.getOpenOrders.mockResolvedValue([ + createMockOrder('1', 'BTC'), + ]); + mockMYXProvider.getOpenOrders.mockResolvedValue([ + createMockOrder('2', 'ETH'), + ]); + + const result = await aggregatedProvider.getOpenOrders(); + + expect(result).toHaveLength(2); + expect(result).toContainEqual( + expect.objectContaining({ orderId: '1', providerId: 'hyperliquid' }), + ); + }); + }); + + describe('Read Operations - getFunding', () => { + it('aggregates funding data from all providers', async () => { + mockHLProvider.getFunding.mockResolvedValue([ + { symbol: 'BTC', amountUsd: '10', rate: '0.01', timestamp: Date.now() }, + ]); + mockMYXProvider.getFunding.mockResolvedValue([ + { symbol: 'ETH', amountUsd: '5', rate: '0.02', timestamp: Date.now() }, + ]); + + const result = await aggregatedProvider.getFunding(); + + expect(result).toHaveLength(2); + }); + }); + + describe('Write Operations - placeOrder', () => { + it('routes to default provider when no providerId specified', async () => { + await aggregatedProvider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }); + + expect(mockHLProvider.placeOrder).toHaveBeenCalled(); + expect(mockMYXProvider.placeOrder).not.toHaveBeenCalled(); + }); + + it('routes to specified provider when providerId is provided', async () => { + await aggregatedProvider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + providerId: 'myx', + }); + + expect(mockMYXProvider.placeOrder).toHaveBeenCalled(); + expect(mockHLProvider.placeOrder).not.toHaveBeenCalled(); + }); + + it('injects providerId into result', async () => { + mockMYXProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'myx-order-123', + }); + + const result = await aggregatedProvider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + providerId: 'myx', + }); + + expect(result.providerId).toBe('myx'); + expect(result.orderId).toBe('myx-order-123'); + }); + + it('falls back to default when specified provider not found', async () => { + await aggregatedProvider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + // @ts-expect-error Testing fallback behavior with invalid provider + providerId: 'unknown-provider', + }); + + // Should fall back to default (hyperliquid) + expect(mockHLProvider.placeOrder).toHaveBeenCalled(); + }); + }); + + describe('Write Operations - cancelOrder', () => { + it('routes to specified provider', async () => { + await aggregatedProvider.cancelOrder({ + orderId: 'order-123', + symbol: 'BTC', + providerId: 'myx', + }); + + expect(mockMYXProvider.cancelOrder).toHaveBeenCalledWith( + expect.objectContaining({ orderId: 'order-123' }), + ); + }); + + it('injects providerId into result', async () => { + mockMYXProvider.cancelOrder.mockResolvedValue({ + success: true, + orderId: 'order-123', + }); + + const result = await aggregatedProvider.cancelOrder({ + orderId: 'order-123', + symbol: 'BTC', + providerId: 'myx', + }); + + expect(result.providerId).toBe('myx'); + }); + }); + + describe('Write Operations - closePosition', () => { + it('routes to specified provider', async () => { + await aggregatedProvider.closePosition({ + symbol: 'BTC', + providerId: 'myx', + }); + + expect(mockMYXProvider.closePosition).toHaveBeenCalled(); + }); + }); + + describe('Validation', () => { + it('validates order with specified provider', async () => { + await aggregatedProvider.validateOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + providerId: 'myx', + }); + + expect(mockMYXProvider.validateOrder).toHaveBeenCalled(); + }); + + it('uses default provider for validateDeposit', async () => { + await aggregatedProvider.validateDeposit({ + amount: '100', + assetId: 'eip155:42161/erc20:0x1234/default', + }); + + expect(mockHLProvider.validateDeposit).toHaveBeenCalled(); + }); + + it('routes validateClosePosition to specified provider', async () => { + const params = { symbol: 'BTC', providerId: 'myx' as const }; + + await aggregatedProvider.validateClosePosition(params); + + expect(mockMYXProvider.validateClosePosition).toHaveBeenCalledWith( + params, + ); + }); + + it('routes validateWithdrawal to specified provider', async () => { + const params = { amount: '100', providerId: 'myx' as const }; + + await aggregatedProvider.validateWithdrawal(params); + + expect(mockMYXProvider.validateWithdrawal).toHaveBeenCalledWith(params); + }); + }); + + describe('Lifecycle', () => { + it('initializes default provider', async () => { + const result = await aggregatedProvider.initialize(); + + expect(mockHLProvider.initialize).toHaveBeenCalled(); + expect(result).toEqual({ success: true }); + }); + + it('disconnects all providers', async () => { + const result = await aggregatedProvider.disconnect(); + + expect(mockHLProvider.disconnect).toHaveBeenCalled(); + expect(mockMYXProvider.disconnect).toHaveBeenCalled(); + expect(result.success).toBe(true); + }); + + it('delegates isReadyToTrade to default provider', async () => { + mockHLProvider.isReadyToTrade.mockResolvedValue({ + ready: true, + walletConnected: true, + networkSupported: true, + }); + + const result = await aggregatedProvider.isReadyToTrade(); + + expect(result.ready).toBe(true); + expect(mockHLProvider.isReadyToTrade).toHaveBeenCalled(); + }); + + it('delegates toggleTestnet to default provider', async () => { + mockHLProvider.toggleTestnet.mockResolvedValue({ + success: true, + isTestnet: true, + }); + + const result = await aggregatedProvider.toggleTestnet(); + + expect(mockHLProvider.toggleTestnet).toHaveBeenCalled(); + expect(result).toEqual({ success: true, isTestnet: true }); + }); + }); + + describe('Configuration', () => { + it('applies setLiveDataConfig to all providers', () => { + aggregatedProvider.setLiveDataConfig({ priceThrottleMs: 1000 }); + + expect(mockHLProvider.setLiveDataConfig).toHaveBeenCalledWith({ + priceThrottleMs: 1000, + }); + expect(mockMYXProvider.setLiveDataConfig).toHaveBeenCalledWith({ + priceThrottleMs: 1000, + }); + }); + + it('applies setUserFeeDiscount to all providers', () => { + aggregatedProvider.setUserFeeDiscount(1000); + + expect(mockHLProvider.setUserFeeDiscount).toHaveBeenCalledWith(1000); + expect(mockMYXProvider.setUserFeeDiscount).toHaveBeenCalledWith(1000); + }); + }); + + describe('Provider Management', () => { + it('adds new provider', () => { + // Using 'myx' as an existing valid provider type for this test + // (simulating adding a duplicate or re-adding after removal) + const newProvider = createMockProvider('myx'); + aggregatedProvider.addProvider('myx', newProvider); + + expect(aggregatedProvider.hasProvider('myx')).toBe(true); + expect(aggregatedProvider.getProviderIds()).toContain('myx'); + }); + + it('removes provider', () => { + const removed = aggregatedProvider.removeProvider('myx'); + + expect(removed).toBe(true); + expect(aggregatedProvider.hasProvider('myx')).toBe(false); + }); + + it('returns false when removing non-existent provider', () => { + // @ts-expect-error Testing error handling with invalid provider type + const removed = aggregatedProvider.removeProvider('non-existent'); + + expect(removed).toBe(false); + }); + }); + + describe('Asset Routes', () => { + it('delegates getDepositRoutes to default provider', () => { + aggregatedProvider.getDepositRoutes(); + + expect(mockHLProvider.getDepositRoutes).toHaveBeenCalled(); + }); + + it('delegates getWithdrawalRoutes to default provider', () => { + aggregatedProvider.getWithdrawalRoutes(); + + expect(mockHLProvider.getWithdrawalRoutes).toHaveBeenCalled(); + }); + }); + + describe('Calculations', () => { + it('delegates calculateLiquidationPrice to default provider', async () => { + await aggregatedProvider.calculateLiquidationPrice({ + entryPrice: 50000, + leverage: 10, + direction: 'long', + }); + + expect(mockHLProvider.calculateLiquidationPrice).toHaveBeenCalled(); + }); + + it('delegates getMaxLeverage to default provider', async () => { + await aggregatedProvider.getMaxLeverage('BTC'); + + expect(mockHLProvider.getMaxLeverage).toHaveBeenCalledWith('BTC'); + }); + + it('delegates calculateMaintenanceMargin to default provider', async () => { + mockHLProvider.calculateMaintenanceMargin.mockResolvedValue(0.05); + + const result = await aggregatedProvider.calculateMaintenanceMargin({ + asset: 'BTC', + positionSize: 1, + }); + + expect(result).toBe(0.05); + expect(mockHLProvider.calculateMaintenanceMargin).toHaveBeenCalled(); + }); + + it('delegates calculateFees to default provider', async () => { + mockHLProvider.calculateFees.mockResolvedValue({ feeRate: 0.001 }); + + const result = await aggregatedProvider.calculateFees({ + orderType: 'market', + symbol: 'BTC', + }); + + expect(result).toEqual({ feeRate: 0.001 }); + expect(mockHLProvider.calculateFees).toHaveBeenCalled(); + }); + }); + + describe('Subscriptions', () => { + it('subscribes to prices via multiplexer', () => { + const callback = jest.fn(); + aggregatedProvider.subscribeToPrices({ + symbols: ['BTC'], + callback, + }); + + expect(mockHLProvider.subscribeToPrices).toHaveBeenCalled(); + expect(mockMYXProvider.subscribeToPrices).toHaveBeenCalled(); + }); + + it('delegates account subscription to default provider', () => { + const callback = jest.fn(); + aggregatedProvider.subscribeToAccount({ callback }); + + expect(mockHLProvider.subscribeToAccount).toHaveBeenCalled(); + }); + + it('delegates subscribeToOICaps to default provider', () => { + const callback = jest.fn(); + + aggregatedProvider.subscribeToOICaps({ callback }); + + expect(mockHLProvider.subscribeToOICaps).toHaveBeenCalled(); + }); + + it('delegates subscribeToCandles to default provider', () => { + const callback = jest.fn(); + + aggregatedProvider.subscribeToCandles({ + symbol: 'BTC', + interval: CandlePeriod.OneHour, + callback, + }); + + expect(mockHLProvider.subscribeToCandles).toHaveBeenCalled(); + }); + + it('delegates subscribeToOrderBook to default provider', () => { + const callback = jest.fn(); + + aggregatedProvider.subscribeToOrderBook({ symbol: 'BTC', callback }); + + expect(mockHLProvider.subscribeToOrderBook).toHaveBeenCalled(); + }); + }); + + describe('WebSocket', () => { + it('delegates getWebSocketConnectionState to default provider', () => { + // Arrange + ( + mockHLProvider as jest.Mocked & { + getWebSocketConnectionState: jest.Mock; + } + ).getWebSocketConnectionState = jest + .fn() + .mockReturnValue(WebSocketConnectionState.Connected); + + // Act + const result = aggregatedProvider.getWebSocketConnectionState(); + + // Assert + expect(result).toBe(WebSocketConnectionState.Connected); + }); + + it('returns Disconnected when provider lacks getWebSocketConnectionState', () => { + // Arrange — provider without the optional method + const noWsProvider = createMockProvider('no-ws'); + const testProvider = new AggregatedPerpsProvider({ + providers: new Map([['no-ws' as PerpsProviderType, noWsProvider]]), + defaultProvider: 'no-ws' as PerpsProviderType, + infrastructure: mockInfrastructure, + }); + + // Act + const result = testProvider.getWebSocketConnectionState(); + + // Assert + expect(result).toBe(WebSocketConnectionState.Disconnected); + }); + + it('delegates subscribeToConnectionState to default provider', () => { + // Arrange + const unsubscribe = jest.fn(); + ( + mockHLProvider as jest.Mocked & { + subscribeToConnectionState: jest.Mock; + } + ).subscribeToConnectionState = jest.fn().mockReturnValue(unsubscribe); + const listener = jest.fn(); + + // Act + const cleanup = aggregatedProvider.subscribeToConnectionState(listener); + + // Assert + expect(cleanup).toBe(unsubscribe); + }); + + it('calls listener with Disconnected when provider lacks subscribeToConnectionState', () => { + // Arrange + const noWsProvider = createMockProvider('no-ws'); + const testProvider = new AggregatedPerpsProvider({ + providers: new Map([['no-ws' as PerpsProviderType, noWsProvider]]), + defaultProvider: 'no-ws' as PerpsProviderType, + infrastructure: mockInfrastructure, + }); + const listener = jest.fn(); + + // Act + const cleanup = testProvider.subscribeToConnectionState(listener); + cleanup(); + + // Assert + expect(listener).toHaveBeenCalledWith( + WebSocketConnectionState.Disconnected, + 0, + ); + }); + + it('delegates reconnect to default provider', async () => { + // Arrange + ( + mockHLProvider as jest.Mocked & { + reconnect: jest.Mock; + } + ).reconnect = jest.fn().mockResolvedValue(undefined); + + // Act + await aggregatedProvider.reconnect(); + + // Assert + expect( + ( + mockHLProvider as jest.Mocked & { + reconnect: jest.Mock; + } + ).reconnect, + ).toHaveBeenCalled(); + }); + + it('does not throw when provider lacks reconnect', async () => { + // Arrange — provider without reconnect + const noWsProvider = createMockProvider('no-ws'); + const testProvider = new AggregatedPerpsProvider({ + providers: new Map([['no-ws' as PerpsProviderType, noWsProvider]]), + defaultProvider: 'no-ws' as PerpsProviderType, + infrastructure: mockInfrastructure, + }); + + // Act & Assert + await expect(testProvider.reconnect()).resolves.toBeUndefined(); + }); + }); +}); 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 new file mode 100644 index 0000000000..c80b526fb8 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.account-mode.test.ts @@ -0,0 +1,2057 @@ +/* eslint-disable */ +jest.mock('@nktkas/hyperliquid', () => ({})); + +import type { CaipAssetId, Hex } from '@metamask/utils'; + +import { CandlePeriod } from '../../../src/constants/chartConfig'; +import { + BUILDER_FEE_CONFIG, + REFERRAL_CONFIG, +} from '../../../src/constants/hyperLiquidConfig'; +import { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from '../../../src/constants/transactionsHistoryConfig'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes'; +import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider'; +import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService'; +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService'; +import { TradingReadinessCache } from '../../../src/services/TradingReadinessCache'; +import type { + ClosePositionParams, + DepositParams, + Order, + PerpsPlatformDependencies, + LiveDataConfig, + OrderParams, +} from '../../../src/types'; +import { + validateAssetSupport, + validateBalance, + validateCoinExists, + validateDepositParams, + validateOrderParams, + validateWithdrawalParams, +} from '../../../src/utils/hyperLiquidValidation'; +import { createStandaloneInfoClient } from '../../../src/utils/standaloneInfoClient'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks'; + +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidWalletService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); +// Mock stream manager - will be set up in test +let mockStreamManagerInstance: any; +const mockGetStreamManagerInstance = jest.fn(() => mockStreamManagerInstance); +jest.mock( + '../../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: mockGetStreamManagerInstance, + }), + { virtual: true }, +); + +// Mock standalone info client for standalone mode tests +let mockStandaloneInfoClient: any; +jest.mock('../../../src/utils/standaloneInfoClient', () => ({ + ...jest.requireActual('../../../src/utils/standaloneInfoClient'), + createStandaloneInfoClient: jest.fn(() => mockStandaloneInfoClient), +})); + +jest.mock('../../../src/utils/hyperLiquidValidation', () => ({ + validateOrderParams: jest.fn(), + validateWithdrawalParams: jest.fn(), + validateDepositParams: jest.fn(), + validateCoinExists: jest.fn(), + validateAssetSupport: jest.fn(), + validateBalance: jest.fn(), + getSupportedPaths: jest + .fn() + .mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]), + getBridgeInfo: jest.fn().mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }), + createErrorResult: jest.fn((error, defaultResponse) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + })), +})); + +// Mock adapter functions +jest.mock('../../../src/utils/hyperLiquidAdapter', () => { + const actual = jest.requireActual('../../../src/utils/hyperLiquidAdapter'); + return { + ...actual, + adaptHyperLiquidLedgerUpdateToUserHistoryItem: jest.fn((updates) => { + // Return mock history items based on input + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map((_update: unknown) => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }), + }; +}); + +// Mock TradingReadinessCache - global singleton for signing operation caching +// Use jest.createMockFromModule for proper mock creation +jest.mock('../../../src/services/TradingReadinessCache'); + +const MockedHyperLiquidClientService = + HyperLiquidClientService as jest.MockedClass; +const MockedHyperLiquidWalletService = + HyperLiquidWalletService as jest.MockedClass; +const MockedHyperLiquidSubscriptionService = + HyperLiquidSubscriptionService as jest.MockedClass< + typeof HyperLiquidSubscriptionService + >; +const mockValidateOrderParams = validateOrderParams as jest.MockedFunction< + typeof validateOrderParams +>; +const mockValidateWithdrawalParams = + validateWithdrawalParams as jest.MockedFunction< + typeof validateWithdrawalParams + >; +const mockValidateDepositParams = validateDepositParams as jest.MockedFunction< + typeof validateDepositParams +>; +const mockValidateCoinExists = validateCoinExists as jest.MockedFunction< + typeof validateCoinExists +>; +const mockValidateAssetSupport = validateAssetSupport as jest.MockedFunction< + typeof validateAssetSupport +>; +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'), + 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(); + +const mockMessenger = createMockMessenger(); + +/** + * Helper to create HyperLiquidProvider with mock platform dependencies + * @param options + * @param options.isTestnet + * @param options.hip3Enabled + * @param options.allowlistMarkets + * @param options.blocklistMarkets + * @param options.useUnifiedAccount + */ +const createTestProvider = ( + options: { + isTestnet?: boolean; + hip3Enabled?: boolean; + allowlistMarkets?: string[]; + blocklistMarkets?: string[]; + useUnifiedAccount?: boolean; + initialAssetMapping?: [string, number][]; + } = {}, +): HyperLiquidProvider => + new HyperLiquidProvider({ + ...options, + platformDependencies: mockPlatformDependencies, + messenger: mockMessenger, + }); + +describe('HyperLiquidProvider', () => { + let provider: HyperLiquidProvider; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionService: jest.Mocked; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + ( + mockPlatformDependencies.marketDataFormatters.formatVolume as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(0)); + ( + mockPlatformDependencies.marketDataFormatters.formatPerpsFiat as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(2)); + ( + mockPlatformDependencies.marketDataFormatters + .formatPercentage as jest.Mock + ).mockImplementation((value: number) => `${value.toFixed(2)}%`); + ( + mockPlatformDependencies.featureFlags.validateVersionGated as jest.Mock + ).mockReturnValue(undefined); + (mockPlatformDependencies.metrics.isEnabled as jest.Mock).mockReturnValue( + true, + ); + + // Reset TradingReadinessCache mock state (using imported mocked module) + const mockedCache = TradingReadinessCache as jest.Mocked< + typeof TradingReadinessCache + >; + mockedCache.get.mockReturnValue(undefined); + mockedCache.getBuilderFee.mockReturnValue(undefined); + mockedCache.getReferral.mockReturnValue(undefined); + mockedCache.isInFlight.mockReturnValue(undefined); + mockedCache.setInFlight.mockReturnValue(jest.fn()); + + // Initialize mock stream manager instance + mockStreamManagerInstance = { + clearAllChannels: jest.fn(), + }; + + // Create mocked service instances using factory functions + 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; + + mockWalletService = { + setTestnetMode: jest.fn(), + getCurrentAccountId: jest + .fn() + .mockReturnValue( + 'eip155:42161:0x1234567890123456789012345678901234567890', + ), + createWalletAdapter: jest.fn().mockReturnValue({ + request: jest + .fn() + .mockResolvedValue(['0x1234567890123456789012345678901234567890']), + }), + getUserAddress: jest + .fn() + .mockReturnValue('0x1234567890123456789012345678901234567890'), + getUserAddressWithDefault: jest + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890'), + isKeyringUnlocked: jest.fn().mockReturnValue(true), + isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + } as Partial as jest.Mocked; + + mockSubscriptionService = { + subscribeToPrices: jest.fn().mockResolvedValue(jest.fn()), // Returns Promise + subscribeToPositions: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + subscribeToOrderFills: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + clearAll: jest.fn(), + isPositionsCacheInitialized: jest.fn().mockReturnValue(false), + getCachedPositions: jest.fn().mockReturnValue([]), + updateFeatureFlags: jest.fn().mockResolvedValue(undefined), + // Cache methods used by buildAssetMapping optimization + setDexMetaCache: jest.fn(), + setDexAssetCtxsCache: jest.fn(), + getDexAssetCtxsCache: jest.fn().mockReturnValue(undefined), + // Price cache used by placeOrder, editOrder, closePosition optimizations + getCachedPrice: jest.fn().mockImplementation((symbol: string) => { + const prices: Record = { BTC: '50000', ETH: '3000' }; + return prices[symbol]; + }), + getLastAllMidsSnapshot: jest.fn().mockReturnValue(null), + // Orders cache used by updatePositionTPSL and getOpenOrders + isOrdersCacheInitialized: jest.fn().mockReturnValue(false), + getCachedOrders: jest.fn().mockReturnValue([]), + // Atomic getter - returns null when cache not initialized (prevents race condition) + getOrdersCacheIfInitialized: jest.fn().mockReturnValue(null), + // Abstraction-mode resolved-mode setter (unified account migration) + setUserAbstractionMode: jest.fn(), + } as Partial as jest.Mocked; + + // Mock constructors + MockedHyperLiquidClientService.mockImplementation(() => mockClientService); + MockedHyperLiquidWalletService.mockImplementation(() => mockWalletService); + MockedHyperLiquidSubscriptionService.mockImplementation( + () => mockSubscriptionService, + ); + + // Mock validation + mockValidateOrderParams.mockReturnValue({ isValid: true }); + mockValidateWithdrawalParams.mockReturnValue({ isValid: true }); + mockValidateDepositParams.mockReturnValue({ isValid: true }); + mockValidateCoinExists.mockReturnValue({ isValid: true }); + mockValidateAssetSupport.mockReturnValue({ isValid: true }); + mockValidateBalance.mockReturnValue({ isValid: true }); + const hyperLiquidValidation = jest.requireMock( + '../../../src/utils/hyperLiquidValidation', + ); + hyperLiquidValidation.getSupportedPaths.mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]); + hyperLiquidValidation.getBridgeInfo.mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }); + hyperLiquidValidation.createErrorResult.mockImplementation( + (error: unknown, defaultResponse: Record) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + }), + ); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptHyperLiquidLedgerUpdateToUserHistoryItem.mockImplementation( + (updates: unknown[]) => { + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map(() => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }, + ); + + provider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ], + }); + }); + describe('getUserNonFundingLedgerUpdates', () => { + it('returns non-funding ledger updates', async () => { + // Arrange + const mockUpdates = [ + { + delta: { type: 'deposit', usdc: '100' }, + time: Date.now(), + hash: '0x123', + }, + { + delta: { type: 'withdraw', usdc: '50' }, + time: Date.now() - 3600000, + hash: '0x456', + }, + ]; + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userNonFundingLedgerUpdates: jest.fn().mockResolvedValue(mockUpdates), + }), + ); + + // Act + const result = await provider.getUserNonFundingLedgerUpdates(); + + // Assert + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(2); + expect(mockClientService.getInfoClient).toHaveBeenCalled(); + }); + + it('returns empty array on error', async () => { + // Arrange + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userNonFundingLedgerUpdates: jest + .fn() + .mockRejectedValue(new Error('API Error')), + }), + ); + + // Act + const result = await provider.getUserNonFundingLedgerUpdates(); + + // Assert + expect(result).toEqual([]); + }); + }); + + // TODO: Refactor to test through public API — ES # private fields prevent direct access + describe.skip('HIP-3 Private Methods', () => { + interface ProviderWithPrivateMethods { + getUsdcTokenId(): Promise; + getBalanceForDex(params: { dex: string | null }): Promise; + findSourceDexWithBalance(params: { + targetDex: string; + requiredAmount: number; + }): Promise<{ sourceDex: string; available: number } | null>; + cachedUsdcTokenId?: string; + } + + let testableProvider: ProviderWithPrivateMethods; + + beforeEach(() => { + testableProvider = provider as unknown as ProviderWithPrivateMethods; + // Reset cache + testableProvider.cachedUsdcTokenId = undefined; + }); + + describe('getUsdcTokenId', () => { + it('returns cached token ID when available', async () => { + // Arrange + testableProvider.cachedUsdcTokenId = 'USDC:0xabc123'; + + // Act + const result = await testableProvider.getUsdcTokenId(); + + // Assert + expect(result).toBe('USDC:0xabc123'); + expect(mockClientService.getInfoClient).not.toHaveBeenCalled(); + }); + + it('fetches and caches token ID on first call', async () => { + // Arrange + const mockSpotMeta = { + tokens: [ + { name: 'USDC', tokenId: '0xdef456', index: 0 }, + { name: 'USDT', tokenId: '0x789abc', index: 1 }, + ], + universe: [], + }; + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + spotMeta: jest.fn().mockResolvedValue(mockSpotMeta), + }), + ); + + // Act + const result = await testableProvider.getUsdcTokenId(); + + // Assert + expect(result).toBe('USDC:0xdef456'); + expect(testableProvider.cachedUsdcTokenId).toBe('USDC:0xdef456'); + expect(mockClientService.getInfoClient).toHaveBeenCalledTimes(1); + }); + + it('throws error when USDC token not found in metadata', async () => { + // Arrange + const mockSpotMeta = { + tokens: [{ name: 'USDT', tokenId: '0x789abc', index: 0 }], + universe: [], + }; + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + spotMeta: jest.fn().mockResolvedValue(mockSpotMeta), + }), + ); + + // Act & Assert + await expect(testableProvider.getUsdcTokenId()).rejects.toThrow( + 'USDC token not found in spot metadata', + ); + }); + }); + + describe('findSourceDexWithBalance', () => { + it('finds main DEX with sufficient balance', async () => { + jest + .spyOn(testableProvider, 'getBalanceForDex') + .mockResolvedValue(1000); + const result = await testableProvider.findSourceDexWithBalance({ + targetDex: 'xyz', + requiredAmount: 500, + }); + expect(result).toEqual({ sourceDex: '', available: 1000 }); + }); + + it('returns null when insufficient balance', async () => { + jest.spyOn(testableProvider, 'getBalanceForDex').mockResolvedValue(100); + const result = await testableProvider.findSourceDexWithBalance({ + targetDex: 'xyz', + requiredAmount: 500, + }); + expect(result).toBeNull(); + }); + }); + + describe('getAllAvailableDexs', () => { + interface ProviderWithDexMethods { + getAllAvailableDexs(): Promise<(string | null)[]>; + dexDiscoveryCache: { + state: { + raw: ({ name: string; url: string } | null)[]; + validated: (string | null)[]; + timestamp: number; + } | null; + reset(): void; + }; + } + + let testableProvider: ProviderWithDexMethods; + + beforeEach(() => { + testableProvider = provider as unknown as ProviderWithDexMethods; + // Reset unified state + testableProvider.dexDiscoveryCache.reset(); + }); + + it('returns cached DEX list when cache is populated', async () => { + // Arrange + testableProvider.dexDiscoveryCache.state = { + raw: [ + null, + { name: 'dex1', url: 'https://dex1.example' }, + { name: 'dex2', url: 'https://dex2.example' }, + ], + validated: [null, 'dex1', 'dex2'], + timestamp: Date.now(), + }; + + // Act + const result = await testableProvider.getAllAvailableDexs(); + + // Assert + expect(result).toEqual([null, 'dex1', 'dex2']); + expect(mockClientService.getInfoClient).not.toHaveBeenCalled(); + }); + + it('fetches DEX list from API when cache is empty', async () => { + // Arrange + const mockDexs = [ + null, + { name: 'dex1', url: 'https://dex1.example' }, + { name: 'dex2', url: 'https://dex2.example' }, + ]; + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + perpDexs: jest.fn().mockResolvedValue(mockDexs), + }), + ); + + // Act + const result = await testableProvider.getAllAvailableDexs(); + + // Assert + expect(result).toEqual([null, 'dex1', 'dex2']); + expect(testableProvider.dexDiscoveryCache.state?.raw).toEqual(mockDexs); + expect(mockClientService.getInfoClient).toHaveBeenCalledTimes(1); + }); + + it('returns fallback when API returns null', async () => { + // Arrange + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + perpDexs: jest.fn().mockResolvedValue(null), + }), + ); + + // Act + const result = await testableProvider.getAllAvailableDexs(); + + // Assert + expect(result).toEqual([null]); + expect(testableProvider.dexDiscoveryCache.state).toBeNull(); + }); + + it('returns fallback when API returns non-array', async () => { + // Arrange + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + perpDexs: jest.fn().mockResolvedValue({ invalid: 'data' }), + }), + ); + + // Act + const result = await testableProvider.getAllAvailableDexs(); + + // Assert + expect(result).toEqual([null]); + expect(testableProvider.dexDiscoveryCache.state).toBeNull(); + }); + + it('returns fallback and logs error when API throws', async () => { + // Arrange + const mockError = new Error('Network error'); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + perpDexs: jest.fn().mockRejectedValue(mockError), + }), + ); + (mockPlatformDependencies.logger.error as jest.Mock).mockClear(); + + // Act + const result = await testableProvider.getAllAvailableDexs(); + + // Assert + expect(result).toEqual([null]); + expect(testableProvider.dexDiscoveryCache.state).toBeNull(); + expect(mockPlatformDependencies.logger.error).toHaveBeenCalledWith( + mockError, + expect.objectContaining({ + context: expect.objectContaining({ + name: 'HyperLiquidProvider', + data: expect.objectContaining({ + method: 'getAllAvailableDexs', + }), + }), + }), + ); + }); + + it('filters out null entries from cached DEX list', async () => { + // Arrange + testableProvider.dexDiscoveryCache.state = { + raw: [ + null, + { name: 'dex1', url: 'https://dex1.example' }, + null, + { name: 'dex2', url: 'https://dex2.example' }, + ], + validated: [null, 'dex1', 'dex2'], + timestamp: Date.now(), + }; + + // Act + const result = await testableProvider.getAllAvailableDexs(); + + // Assert + expect(result).toEqual([null, 'dex1', 'dex2']); + }); + + it('returns only main DEX when cached list contains only null', async () => { + // Arrange + testableProvider.dexDiscoveryCache.state = { + raw: [null], + validated: [null], + timestamp: Date.now(), + }; + + // Act + const result = await testableProvider.getAllAvailableDexs(); + + // Assert + expect(result).toEqual([null]); + }); + }); + + describe('ensureReadyForTrading', () => { + interface ProviderWithTradingSetup { + ensureReadyForTrading(): Promise; + ensureReady(): Promise; + tradingSetupComplete: boolean; + } + + let testableProvider: ProviderWithTradingSetup; + + beforeEach(() => { + testableProvider = provider as unknown as ProviderWithTradingSetup; + testableProvider.tradingSetupComplete = false; + }); + + it('calls ensureReady first before trading setup', async () => { + // Arrange - spy on ensureReady + const ensureReadySpy = jest + .spyOn(testableProvider, 'ensureReady') + .mockResolvedValue(); + + // Act + await testableProvider.ensureReadyForTrading(); + + // Assert + expect(ensureReadySpy).toHaveBeenCalled(); + }); + + it('returns immediately when tradingSetupComplete is true', async () => { + // Arrange + testableProvider.tradingSetupComplete = true; + const ensureReadySpy = jest + .spyOn(testableProvider, 'ensureReady') + .mockResolvedValue(); + + // Act + await testableProvider.ensureReadyForTrading(); + + // Assert - should call ensureReady but skip trading setup + expect(ensureReadySpy).toHaveBeenCalled(); + // No signing operations should be called + expect( + (TradingReadinessCache as jest.Mocked) + .setInFlight, + ).not.toHaveBeenCalled(); + }); + + it('sets tradingSetupComplete to true after successful setup', async () => { + // Arrange + jest.spyOn(testableProvider, 'ensureReady').mockResolvedValue(); + // Mock all caches as already attempted to skip signing + ( + TradingReadinessCache as jest.Mocked + ).get.mockReturnValue({ + attempted: true, + enabled: true, + timestamp: Date.now(), + }); + ( + TradingReadinessCache as jest.Mocked + ).getBuilderFee.mockReturnValue({ + attempted: true, + success: true, + }); + ( + TradingReadinessCache as jest.Mocked + ).getReferral.mockReturnValue({ + attempted: true, + success: true, + }); + + // Act + await testableProvider.ensureReadyForTrading(); + + // Assert + expect(testableProvider.tradingSetupComplete).toBe(true); + }); + + it('keeps tradingSetupComplete false when keyring is locked', async () => { + // Arrange + jest.spyOn(testableProvider, 'ensureReady').mockResolvedValue(); + // Mock all caches as already attempted to skip signing + ( + TradingReadinessCache as jest.Mocked + ).get.mockReturnValue({ + attempted: true, + enabled: true, + timestamp: Date.now(), + }); + ( + TradingReadinessCache as jest.Mocked + ).getBuilderFee.mockReturnValue({ + attempted: true, + success: true, + }); + ( + TradingReadinessCache as jest.Mocked + ).getReferral.mockReturnValue({ + attempted: true, + success: true, + }); + // Keyring is locked + ( + mockWalletService as unknown as { isKeyringUnlocked: jest.Mock } + ).isKeyringUnlocked.mockReturnValue(false); + + // Act + await testableProvider.ensureReadyForTrading(); + + // Assert - tradingSetupComplete should remain false + expect(testableProvider.tradingSetupComplete).toBe(false); + }); + }); + + describe('autoTransferForHip3Order', () => { + interface ProviderWithAutoTransfer { + autoTransferForHip3Order(params: { + targetDex: string; + requiredMargin: number; + }): Promise<{ amount: number; sourceDex: string } | null>; + getBalanceForDex(params: { dex: string | null }): Promise; + findSourceDexWithBalance(params: { + targetDex: string; + requiredAmount: number; + }): Promise<{ sourceDex: string; available: number } | null>; + transferBetweenDexs(params: { + sourceDex: string; + destinationDex: string; + amount: string; + }): Promise<{ success: boolean; error?: string }>; + } + + let testableProvider: ProviderWithAutoTransfer; + + beforeEach(() => { + testableProvider = provider as unknown as ProviderWithAutoTransfer; + }); + + it('returns null when target DEX has sufficient balance', async () => { + // Arrange + jest + .spyOn(testableProvider, 'getBalanceForDex') + .mockResolvedValue(1000); + + // Act + const result = await testableProvider.autoTransferForHip3Order({ + targetDex: 'xyz', + requiredMargin: 500, + }); + + // Assert + expect(result).toBeNull(); + }); + + it('transfers from main DEX when target has insufficient balance', async () => { + // Arrange + jest.spyOn(testableProvider, 'getBalanceForDex').mockResolvedValue(100); // Target has only 100 + jest + .spyOn(testableProvider, 'findSourceDexWithBalance') + .mockResolvedValue({ sourceDex: '', available: 1000 }); + jest + .spyOn(testableProvider, 'transferBetweenDexs') + .mockResolvedValue({ success: true }); + + // Act + const result = await testableProvider.autoTransferForHip3Order({ + targetDex: 'xyz', + requiredMargin: 500, + }); + + // Assert + expect(result).toEqual({ amount: expect.any(Number), sourceDex: '' }); + expect(testableProvider.transferBetweenDexs).toHaveBeenCalledWith({ + sourceDex: '', + destinationDex: 'xyz', + amount: expect.any(String), + }); + }); + + it('throws error when no source has sufficient balance', async () => { + // Arrange + jest.spyOn(testableProvider, 'getBalanceForDex').mockResolvedValue(100); // Target has only 100 + jest + .spyOn(testableProvider, 'findSourceDexWithBalance') + .mockResolvedValue(null); // No source found + + // Act & Assert + await expect( + testableProvider.autoTransferForHip3Order({ + targetDex: 'xyz', + requiredMargin: 500, + }), + ).rejects.toThrow('Insufficient balance for HIP-3 order'); + }); + + it('throws error when transfer fails', async () => { + // Arrange + jest.spyOn(testableProvider, 'getBalanceForDex').mockResolvedValue(100); + jest + .spyOn(testableProvider, 'findSourceDexWithBalance') + .mockResolvedValue({ sourceDex: '', available: 1000 }); + jest + .spyOn(testableProvider, 'transferBetweenDexs') + .mockResolvedValue({ success: false, error: 'Transfer failed' }); + + // Act & Assert + await expect( + testableProvider.autoTransferForHip3Order({ + targetDex: 'xyz', + requiredMargin: 500, + }), + ).rejects.toThrow('Auto-transfer failed: Transfer failed'); + }); + }); + + describe('calculateHip3RequiredMargin', () => { + interface ProviderWithMarginCalc { + calculateHip3RequiredMargin(params: { + symbol: string; + dexName: string; + positionSize: number; + orderPrice: number; + leverage: number; + isBuy: boolean; + }): Promise; + getPositions(): Promise< + { symbol: string; size: string; marginUsed: string }[] + >; + } + + let testableProvider: ProviderWithMarginCalc; + + beforeEach(() => { + testableProvider = provider as unknown as ProviderWithMarginCalc; + }); + + it('calculates total margin when increasing existing long position', async () => { + // Arrange + jest.spyOn(testableProvider, 'getPositions').mockResolvedValue([ + { + symbol: 'BTC', + size: '1.0', // Existing long position + marginUsed: '5000', + }, + ]); + + // Act + const result = await testableProvider.calculateHip3RequiredMargin({ + symbol: 'BTC', + dexName: 'xyz', + positionSize: 0.5, // Adding to position + orderPrice: 50000, + leverage: 10, + isBuy: true, // Long order - increasing position + }); + + // Assert + // Total size = 1.0 + 0.5 = 1.5 + // Total notional = 1.5 * 50000 = 75000 + // Total margin = 75000 / 10 = 7500 + // With buffer (1.003) = 7522.5 + expect(result).toBeCloseTo(7522.5, 1); + }); + + it('calculates incremental margin when reversing position', async () => { + // Arrange + jest.spyOn(testableProvider, 'getPositions').mockResolvedValue([ + { + symbol: 'BTC', + size: '1.0', // Existing long position + marginUsed: '5000', + }, + ]); + + // Act + const result = await testableProvider.calculateHip3RequiredMargin({ + symbol: 'BTC', + dexName: 'xyz', + positionSize: 0.5, + orderPrice: 50000, + leverage: 10, + isBuy: false, // Short order - opposite direction + }); + + // Assert + // Only new order margin (not total) + // Notional = 0.5 * 50000 = 25000 + // Margin = 25000 / 10 = 2500 + // With buffer (1.003) = 2507.5 + expect(result).toBeCloseTo(2507.5, 1); + }); + + it('calculates margin for new position when no existing position', async () => { + // Arrange + jest.spyOn(testableProvider, 'getPositions').mockResolvedValue([]); + + // Act + const result = await testableProvider.calculateHip3RequiredMargin({ + symbol: 'ETH', + dexName: 'xyz', + positionSize: 10, + orderPrice: 3000, + leverage: 5, + isBuy: true, + }); + + // Assert + // Notional = 10 * 3000 = 30000 + // Margin = 30000 / 5 = 6000 + // With buffer (1.003) = 6018 + expect(result).toBeCloseTo(6018, 1); + }); + + it('calculates total margin when increasing existing short position', async () => { + // Arrange + jest.spyOn(testableProvider, 'getPositions').mockResolvedValue([ + { + symbol: 'ETH', + size: '-5.0', // Existing short position + marginUsed: '3000', + }, + ]); + + // Act + const result = await testableProvider.calculateHip3RequiredMargin({ + symbol: 'ETH', + dexName: 'xyz', + positionSize: 2.0, // Adding to short + orderPrice: 3000, + leverage: 5, + isBuy: false, // Short order - increasing short position + }); + + // Assert + // Total size = 5.0 + 2.0 = 7.0 + // Total notional = 7.0 * 3000 = 21000 + // Total margin = 21000 / 5 = 4200 + // With buffer (1.003) = 4212.6 + expect(result).toBeCloseTo(4212.6, 1); + }); + }); + }); + + describe('ensureUnifiedAccountEnabled', () => { + // These tests verify the unified account migration behaviour that runs + // inside #ensureReady() → #ensureUnifiedAccountEnabled(). Because the + // method is native-private (#), we trigger it via the public + // getMarketDataWithPrices() entry point, which calls #ensureReady() on + // every fresh provider instance. + + // The user address used by mockWalletService.getUserAddressWithDefault + const USER_ADDRESS = '0x1234567890123456789012345678901234567890'; + + // ───────────────────────────────────────────────── + // Early-exit paths + // ───────────────────────────────────────────────── + + it('does not call userAbstraction when useUnifiedAccount is false', async () => { + // Arrange - provider created with the feature disabled + const disabledProvider = createTestProvider({ useUnifiedAccount: false }); + const mockInfoClient = createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + // Act + await disabledProvider.getMarketDataWithPrices(); + + // Assert - userAbstraction never queried (feature is off) + expect(mockInfoClient.userAbstraction).not.toHaveBeenCalled(); + }); + + it('does not call userAbstraction when global cache indicates already attempted', async () => { + // Arrange - cache says setup was already tried (success or failure) + ( + TradingReadinessCache as jest.Mocked + ).get.mockReturnValue({ + attempted: true, + enabled: true, + timestamp: Date.now(), + }); + const mockInfoClient = createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - skipped because of cache + expect(mockInfoClient.userAbstraction).not.toHaveBeenCalled(); + expect( + (TradingReadinessCache as jest.Mocked) + .get, + ).toHaveBeenCalledWith('mainnet', USER_ADDRESS); + }); + + it('waits for in-flight then returns when another provider already cached the result', async () => { + // Arrange — another provider instance is mid-setup AND will land a + // cache entry by the time we resume from the await. + let resolveInFlight: () => void = () => undefined; + const inFlightPromise = new Promise((resolve) => { + resolveInFlight = resolve; + }); + ( + TradingReadinessCache as jest.Mocked + ).isInFlight.mockReturnValue(inFlightPromise); + // Outer cache check returns undefined; post-await cache check reflects + // the other instance's recorded result. + (TradingReadinessCache as jest.Mocked).get + .mockReturnValueOnce(undefined) + .mockReturnValue({ + attempted: true, + enabled: true, + timestamp: Date.now(), + }); + + const mockInfoClient = createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + // Act + const marketDataPromise = provider.getMarketDataWithPrices(); + resolveInFlight(); + await marketDataPromise; + + // Assert — checked for in-flight, saw the cache landed, returned without + // acquiring a new lock or re-fetching userAbstraction. + expect( + (TradingReadinessCache as jest.Mocked) + .isInFlight, + ).toHaveBeenCalledWith('unifiedAccount', 'mainnet', USER_ADDRESS); + expect(mockInfoClient.userAbstraction).not.toHaveBeenCalled(); + expect( + (TradingReadinessCache as jest.Mocked) + .setInFlight, + ).not.toHaveBeenCalled(); + }); + + it('waits for in-flight then runs its own attempt when no cache was written (deferred dexAbstraction case)', async () => { + // Scenario: another provider's init-time call (allowUserSigning=false) + // hit the dexAbstraction defer branch and finished without writing the + // cache. Our caller is action-time (allowUserSigning=true via withdraw) + // and must not skip the migration just because another instance was + // mid-setup. + let resolveInFlight: () => void = () => undefined; + const inFlightPromise = new Promise((resolve) => { + resolveInFlight = resolve; + }); + ( + TradingReadinessCache as jest.Mocked + ).isInFlight.mockReturnValue(inFlightPromise); + // Cache stays empty across both checks (no entry was written by the + // other instance because it deferred). + ( + TradingReadinessCache as jest.Mocked + ).get.mockReturnValue(undefined); + + const exchangeClient = createMockExchangeClient(); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('dexAbstraction'), + }), + ); + + Object.defineProperty(provider, 'getAccountState', { + value: jest.fn().mockResolvedValue({ availableBalance: '5000' }), + writable: true, + }); + + // Act — withdraw is the action-time entry that requires migration. + const withdrawPromise = provider.withdraw({ + amount: '100', + destination: '0x1234567890123456789012345678901234567890' as Hex, + assetId: + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/usdc' as CaipAssetId, + }); + resolveInFlight(); + await withdrawPromise; + + // Assert — fell through, acquired our own lock, and migrated. + expect( + (TradingReadinessCache as jest.Mocked) + .setInFlight, + ).toHaveBeenCalledWith('unifiedAccount', 'mainnet', USER_ADDRESS); + expect(exchangeClient.userSetAbstraction).toHaveBeenCalledWith({ + user: USER_ADDRESS, + abstraction: 'unifiedAccount', + }); + }); + + it('returns early when re-check cache (inside lock) shows another provider completed', async () => { + // Arrange - first get() → undefined, second get() (inside try) → cached + (TradingReadinessCache as jest.Mocked).get + .mockReturnValueOnce(undefined) // outer check + .mockReturnValueOnce({ + attempted: true, + enabled: true, + timestamp: Date.now(), + }); // inner re-check after lock acquired + + const mockCompleteInFlight = jest.fn(); + ( + TradingReadinessCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + + const mockInfoClient = createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - lock was acquired and released, but no API call made + expect(mockInfoClient.userAbstraction).not.toHaveBeenCalled(); + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + + // ───────────────────────────────────────────────── + // Already on a compatible mode (unifiedAccount or portfolioMargin) + // ───────────────────────────────────────────────── + + it('tracks already_enabled and caches success when mode is already unifiedAccount', async () => { + // Arrange + const mockCompleteInFlight = jest.fn(); + ( + TradingReadinessCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + }), + ); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - tracks the already_enabled event + expect( + mockPlatformDependencies.metrics.trackPerpsEvent, + ).toHaveBeenCalledWith( + 'Perp Account Setup', + expect.objectContaining({ + abstraction_mode: 'unifiedAccount', + status: 'already_enabled', + }), + ); + // Caches success + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).toHaveBeenCalledWith('mainnet', USER_ADDRESS, { + attempted: true, + enabled: true, + }); + // Does NOT call exchange client for unified account transition + expect(mockClientService.getExchangeClient).not.toHaveBeenCalled(); + // Releases in-flight lock + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + + it('does NOT migrate portfolioMargin users — tracks already_enabled and skips exchange call', async () => { + // portfolioMargin is a superset of unifiedAccount: it already supports + // HIP-3 auto-collateral management and is more capital-efficient. + // Downgrading these users would be harmful. + // Arrange + const mockCompleteInFlight = jest.fn(); + ( + TradingReadinessCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('portfolioMargin'), + }), + ); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - tracked as already_enabled with the correct mode + expect( + mockPlatformDependencies.metrics.trackPerpsEvent, + ).toHaveBeenCalledWith( + 'Perp Account Setup', + expect.objectContaining({ + abstraction_mode: 'portfolioMargin', + status: 'already_enabled', + }), + ); + // Caches success — no retry needed + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).toHaveBeenCalledWith('mainnet', USER_ADDRESS, { + attempted: true, + enabled: true, + }); + // Does NOT call exchange client — user must NOT be downgraded + expect(mockClientService.getExchangeClient).not.toHaveBeenCalled(); + // Releases in-flight lock + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + + // ───────────────────────────────────────────────── + // Migration from default / disabled → unifiedAccount (silent agent path) + // ───────────────────────────────────────────────── + + it('calls agentSetAbstraction silently when mode is default', async () => { + // Arrange + const mockExchangeClient = createMockExchangeClient(); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - uses silent agent-key path (no user prompt) + expect(mockExchangeClient.agentSetAbstraction).toHaveBeenCalledWith({ + abstraction: 'u', + }); + expect(mockExchangeClient.userSetAbstraction).not.toHaveBeenCalled(); + }); + + it('calls agentSetAbstraction silently when mode is disabled', async () => { + // Arrange + const mockExchangeClient = createMockExchangeClient(); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('disabled'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert + expect(mockExchangeClient.agentSetAbstraction).toHaveBeenCalledWith({ + abstraction: 'u', + }); + expect(mockExchangeClient.userSetAbstraction).not.toHaveBeenCalled(); + }); + + it('tracks migration_required then success for default → unifiedAccount', async () => { + // Arrange + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(createMockExchangeClient()); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - two analytics events emitted in order + const trackCalls = ( + mockPlatformDependencies.metrics.trackPerpsEvent as jest.Mock + ).mock.calls.filter((call) => call[0] === 'Perp Account Setup'); + + // First event: migration_required with current mode + expect(trackCalls[0]).toEqual([ + 'Perp Account Setup', + expect.objectContaining({ + abstraction_mode: 'default', + status: 'migration_required', + }), + ]); + // Second event: success with before/after modes + expect(trackCalls[1]).toEqual([ + 'Perp Account Setup', + expect.objectContaining({ + previous_abstraction_mode: 'default', + abstraction_mode: 'unifiedAccount', + status: 'success', + }), + ]); + // Cache reflects success + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).toHaveBeenCalledWith('mainnet', USER_ADDRESS, { + attempted: true, + enabled: true, + }); + }); + + it('skips migration and does not cache success for unknown abstraction modes', async () => { + // Arrange + const mockExchangeClient = createMockExchangeClient(); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('futureMode'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - fail closed for unknown modes rather than silently forcing 'u' + expect(mockExchangeClient.agentSetAbstraction).not.toHaveBeenCalled(); + expect(mockExchangeClient.userSetAbstraction).not.toHaveBeenCalled(); + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).not.toHaveBeenCalledWith('mainnet', USER_ADDRESS, { + attempted: true, + enabled: true, + }); + }); + + // ───────────────────────────────────────────────── + // Signing-backed unifiedAccount migration on init + // + // Some transitions require an EIP-712 prompt, so software-wallet users + // migrate during initial setup to ensure the first trade sees unified + // collateral. Hardware wallets remain deferred to avoid repeated signing + // prompts while browsing. + // ───────────────────────────────────────────────── + + it('calls userSetAbstraction on init for software-wallet dexAbstraction users', async () => { + // Arrange + const mockExchangeClient = createMockExchangeClient(); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('dexAbstraction'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + // Act - init path + await provider.getMarketDataWithPrices(); + + // Assert - software wallets migrate during setup so first trade sees + // unified collateral folded into the size slider. + expect(mockExchangeClient.userSetAbstraction).toHaveBeenCalledWith({ + user: USER_ADDRESS, + abstraction: 'unifiedAccount', + }); + expect(mockExchangeClient.agentSetAbstraction).not.toHaveBeenCalled(); + }); + + it('tracks migration_required and writes cache for software-wallet dexAbstraction on init', async () => { + // Arrange + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('dexAbstraction'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(createMockExchangeClient()); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - analytics fire because software-wallet init performs the + // migration attempt. + const trackCalls = ( + mockPlatformDependencies.metrics.trackPerpsEvent as jest.Mock + ).mock.calls.filter((call) => call[0] === 'Perp Account Setup'); + expect(trackCalls[0]).toEqual([ + 'Perp Account Setup', + expect.objectContaining({ + abstraction_mode: 'dexAbstraction', + status: 'migration_required', + }), + ]); + expect(trackCalls[1]).toEqual([ + 'Perp Account Setup', + expect.objectContaining({ + previous_abstraction_mode: 'dexAbstraction', + abstraction_mode: 'unifiedAccount', + status: 'success', + }), + ]); + + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).toHaveBeenCalledWith('mainnet', USER_ADDRESS, { + attempted: true, + enabled: true, + }); + }); + + // ───────────────────────────────────────────────── + // setUserAbstractionMode is called on every success path with the + // resolved mode so the subscription service can fold spot correctly. + // ───────────────────────────────────────────────── + + it('records unifiedAccount mode when account is already unifiedAccount', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + }), + ); + + await provider.getMarketDataWithPrices(); + + expect( + mockSubscriptionService.setUserAbstractionMode, + ).toHaveBeenCalledWith(USER_ADDRESS, 'unifiedAccount'); + }); + + it('records portfolioMargin mode when account is already portfolioMargin', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('portfolioMargin'), + }), + ); + + await provider.getMarketDataWithPrices(); + + expect( + mockSubscriptionService.setUserAbstractionMode, + ).toHaveBeenCalledWith(USER_ADDRESS, 'portfolioMargin'); + }); + + it('records unifiedAccount mode after migrating from default → unifiedAccount', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(createMockExchangeClient()); + + await provider.getMarketDataWithPrices(); + + expect( + mockSubscriptionService.setUserAbstractionMode, + ).toHaveBeenCalledWith(USER_ADDRESS, 'unifiedAccount'); + }); + + it('records unifiedAccount mode after migrating software-wallet dexAbstraction on init', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('dexAbstraction'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(createMockExchangeClient()); + + await provider.getMarketDataWithPrices(); + + expect( + mockSubscriptionService.setUserAbstractionMode, + ).toHaveBeenCalledWith(USER_ADDRESS, 'unifiedAccount'); + }); + + it.each(['dexAbstraction', 'default', 'disabled'] as const)( + 'defers %s migration on init for hardware wallets', + async (currentMode) => { + // Arrange + mockWalletService.isSelectedHardwareWallet.mockReturnValue(true); + const mockExchangeClient = createMockExchangeClient(); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue(currentMode), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + // Act - init path + await provider.getMarketDataWithPrices(); + + // Assert - no browsing-time hardware prompt; action-time setup can still run. + expect(mockExchangeClient.userSetAbstraction).not.toHaveBeenCalled(); + expect(mockExchangeClient.agentSetAbstraction).not.toHaveBeenCalled(); + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).not.toHaveBeenCalled(); + expect( + mockSubscriptionService.setUserAbstractionMode, + ).not.toHaveBeenCalled(); + }, + ); + + it('does NOT call setUserAbstractionMode when migration fails', async () => { + const mockExchangeClient = createMockExchangeClient(); + mockExchangeClient.agentSetAbstraction = jest + .fn() + .mockRejectedValue(new Error('network error')); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + await provider.getMarketDataWithPrices(); + + expect( + mockSubscriptionService.setUserAbstractionMode, + ).not.toHaveBeenCalled(); + }); + + // ───────────────────────────────────────────────── + // Failure paths + // ───────────────────────────────────────────────── + + it('does NOT cache when silent agentSetAbstraction fails (default/disabled paths retry on next entry)', async () => { + // Silent agent-key migration (default/disabled) shows no UI prompt, so + // the "don't re-prompt rejected users" rationale doesn't apply. Caching + // a transient HL/network failure here would pin the user in the + // deprecated mode for the rest of the session — instead we leave the + // cache empty so the next #ensureReady or action-time call retries. + const mockError = new Error('Transient HL network blip'); + const mockExchangeClient = createMockExchangeClient(); + mockExchangeClient.agentSetAbstraction = jest + .fn() + .mockRejectedValue(mockError); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + await provider.getMarketDataWithPrices(); + + // No cache write — next entry can retry. + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).not.toHaveBeenCalled(); + // Failure analytics still emitted for observability. + expect( + mockPlatformDependencies.metrics.trackPerpsEvent, + ).toHaveBeenCalledWith( + 'Perp Account Setup', + expect.objectContaining({ + previous_abstraction_mode: 'default', + abstraction_mode: 'unifiedAccount', + status: 'failed', + error_message: expect.stringContaining('Transient HL network blip'), + }), + ); + // Sentry logger still records for debugging. + expect(mockPlatformDependencies.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Transient HL network blip'), + }), + expect.objectContaining({ + context: expect.objectContaining({ + name: 'HyperLiquidProvider', + data: expect.objectContaining({ + method: 'ensureUnifiedAccountEnabled', + }), + }), + }), + ); + }); + + it('retries migration on the next #ensureReady after a silent agent failure', async () => { + // Without resetting #ensureReadyPromise on the silent-failure path, + // a transient agentSetAbstraction blip during the first Perps section + // open would pin the user in the deprecated mode for the entire + // provider lifetime — every subsequent #ensureReady would just return + // the memoized resolved promise and skip the migration. + const userAbstractionMock = jest.fn().mockResolvedValue('default'); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: userAbstractionMock, + }), + ); + const agentSetAbstractionMock = jest + .fn() + .mockRejectedValueOnce(new Error('Transient HL network blip')) + .mockResolvedValueOnce({ status: 'ok' }); + const exchangeClient = createMockExchangeClient(); + exchangeClient.agentSetAbstraction = agentSetAbstractionMock; + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + + // First entry: migration fails silently, no cache write. + await provider.getMarketDataWithPrices(); + expect(userAbstractionMock).toHaveBeenCalledTimes(1); + expect(agentSetAbstractionMock).toHaveBeenCalledTimes(1); + + // Second entry: must re-run the migration because #ensureReadyPromise + // was reset on the silent-failure exit. agentSetAbstraction succeeds + // this time → cache attempted/enabled → no further retries. + await provider.getMarketDataWithPrices(); + expect(userAbstractionMock).toHaveBeenCalledTimes(2); + expect(agentSetAbstractionMock).toHaveBeenCalledTimes(2); + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).toHaveBeenCalledWith('mainnet', USER_ADDRESS, { + attempted: true, + enabled: true, + }); + }); + + it("caches failure when user-signed userSetAbstraction throws (don't re-prompt rejected users)", async () => { + // The dexAbstraction → unifiedAccount migration goes through + // userSetAbstraction which surfaces an EIP-712 signing dialog. Once + // the user has been prompted (and either rejected or signed but the + // call failed), we should not pop the dialog again this session. + const mockError = new Error('User rejected signing'); + const exchangeClient = createMockExchangeClient(); + exchangeClient.userSetAbstraction = jest + .fn() + .mockRejectedValue(mockError); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('dexAbstraction'), + }), + ); + + Object.defineProperty(provider, 'getAccountState', { + value: jest.fn().mockResolvedValue({ availableBalance: '5000' }), + writable: true, + }); + + // withdraw() is an action-time caller that passes allowUserSigning=true, + // so the dexAbstraction path actually attempts userSetAbstraction. + await provider.withdraw({ + amount: '100', + destination: '0x1234567890123456789012345678901234567890' as Hex, + assetId: + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/usdc' as CaipAssetId, + }); + + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).toHaveBeenCalledWith('mainnet', USER_ADDRESS, { + attempted: true, + enabled: false, + }); + }); + + it('does NOT cache or log to Sentry when KEYRING_LOCKED is thrown', async () => { + // Arrange + const mockExchangeClient = createMockExchangeClient(); + mockExchangeClient.agentSetAbstraction = jest + .fn() + .mockRejectedValue(new Error('KEYRING_LOCKED')); + const mockCompleteInFlight = jest.fn(); + ( + TradingReadinessCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + // Act - should resolve without throwing + await provider.getMarketDataWithPrices(); + + // Assert - cache NOT set (so it retries when keyring is unlocked) + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).not.toHaveBeenCalled(); + // Sentry NOT called + expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled(); + // In-flight lock still released + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + + it('does NOT cache or log to Sentry when a wrapped KEYRING_LOCKED error is thrown', async () => { + // Arrange + const wrappedKeyringLockedError = Object.assign( + new Error('Failed to sign typed data with viem wallet'), + { cause: new Error('KEYRING_LOCKED') }, + ); + const mockExchangeClient = createMockExchangeClient(); + mockExchangeClient.agentSetAbstraction = jest + .fn() + .mockRejectedValue(wrappedKeyringLockedError); + const mockCompleteInFlight = jest.fn(); + ( + TradingReadinessCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).not.toHaveBeenCalled(); + expect(mockPlatformDependencies.logger.error).not.toHaveBeenCalled(); + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + + it('does NOT cache failure when userAbstraction read itself rejects', async () => { + // Read-only userAbstraction lookup failures (transient HL outage / + // network) must not block all future migration attempts for the rest + // of the session — no signing prompt has happened yet, so the + // "don't re-prompt the user" rationale doesn't apply. + const lookupError = new Error('HL info endpoint timeout'); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockRejectedValue(lookupError), + }), + ); + const mockExchangeClient = createMockExchangeClient(); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(mockExchangeClient); + + // Act - should resolve without throwing + await provider.getMarketDataWithPrices(); + + // Assert - cache NOT written so the next call retries the lookup + expect( + (TradingReadinessCache as jest.Mocked) + .set, + ).not.toHaveBeenCalled(); + // No signing happened + expect(mockExchangeClient.userSetAbstraction).not.toHaveBeenCalled(); + expect(mockExchangeClient.agentSetAbstraction).not.toHaveBeenCalled(); + }); + + // ───────────────────────────────────────────────── + // Network key (mainnet vs testnet) + // ───────────────────────────────────────────────── + + it('uses testnet network key when client is in testnet mode', async () => { + // Arrange - testnet provider with cache already hit (so we only check the key) + mockClientService.isTestnetMode = jest.fn().mockReturnValue(true); + ( + TradingReadinessCache as jest.Mocked + ).get.mockReturnValue({ + attempted: true, + enabled: true, + timestamp: Date.now(), + }); + + const mockInfoClient = createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - cache keyed by 'testnet' + expect( + (TradingReadinessCache as jest.Mocked) + .get, + ).toHaveBeenCalledWith('testnet', USER_ADDRESS); + }); + + // ───────────────────────────────────────────────── + // In-flight lock management + // ───────────────────────────────────────────────── + + it('sets in-flight lock with unifiedAccount key and releases it on success', async () => { + // Arrange + const mockCompleteInFlight = jest.fn(); + ( + TradingReadinessCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('default'), + }), + ); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(createMockExchangeClient()); + + // Act + await provider.getMarketDataWithPrices(); + + // Assert - lock key uses 'unifiedAccount' + expect( + (TradingReadinessCache as jest.Mocked) + .setInFlight, + ).toHaveBeenCalledWith('unifiedAccount', 'mainnet', USER_ADDRESS); + expect(mockCompleteInFlight).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 new file mode 100644 index 0000000000..770a7372a3 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts @@ -0,0 +1,1592 @@ +/* eslint-disable */ +jest.mock('@nktkas/hyperliquid', () => ({})); + +import type { CaipAssetId, Hex } from '@metamask/utils'; + +import { CandlePeriod } from '../../../src/constants/chartConfig'; +import { + BUILDER_FEE_CONFIG, + REFERRAL_CONFIG, +} from '../../../src/constants/hyperLiquidConfig'; +import { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from '../../../src/constants/transactionsHistoryConfig'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes'; +import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider'; +import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService'; +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService'; +import { TradingReadinessCache } from '../../../src/services/TradingReadinessCache'; +import type { + ClosePositionParams, + DepositParams, + Order, + PerpsPlatformDependencies, + LiveDataConfig, + OrderParams, +} from '../../../src/types'; +import { + validateAssetSupport, + validateBalance, + validateCoinExists, + validateDepositParams, + validateOrderParams, + validateWithdrawalParams, +} from '../../../src/utils/hyperLiquidValidation'; +import { createStandaloneInfoClient } from '../../../src/utils/standaloneInfoClient'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks'; + +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidWalletService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); +// Mock stream manager - will be set up in test +let mockStreamManagerInstance: any; +const mockGetStreamManagerInstance = jest.fn(() => mockStreamManagerInstance); +jest.mock( + '../../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: mockGetStreamManagerInstance, + }), + { virtual: true }, +); + +// Mock standalone info client for standalone mode tests +let mockStandaloneInfoClient: any; +jest.mock('../../../src/utils/standaloneInfoClient', () => ({ + ...jest.requireActual('../../../src/utils/standaloneInfoClient'), + createStandaloneInfoClient: jest.fn(() => mockStandaloneInfoClient), +})); + +jest.mock('../../../src/utils/hyperLiquidValidation', () => ({ + validateOrderParams: jest.fn(), + validateWithdrawalParams: jest.fn(), + validateDepositParams: jest.fn(), + validateCoinExists: jest.fn(), + validateAssetSupport: jest.fn(), + validateBalance: jest.fn(), + getSupportedPaths: jest + .fn() + .mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]), + getBridgeInfo: jest.fn().mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }), + createErrorResult: jest.fn((error, defaultResponse) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + })), +})); + +// Mock adapter functions +jest.mock('../../../src/utils/hyperLiquidAdapter', () => { + const actual = jest.requireActual('../../../src/utils/hyperLiquidAdapter'); + return { + ...actual, + adaptHyperLiquidLedgerUpdateToUserHistoryItem: jest.fn((updates) => { + // Return mock history items based on input + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map((_update: unknown) => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }), + }; +}); + +// Mock TradingReadinessCache - global singleton for signing operation caching +// Use jest.createMockFromModule for proper mock creation +jest.mock('../../../src/services/TradingReadinessCache'); + +const MockedHyperLiquidClientService = + HyperLiquidClientService as jest.MockedClass; +const MockedHyperLiquidWalletService = + HyperLiquidWalletService as jest.MockedClass; +const MockedHyperLiquidSubscriptionService = + HyperLiquidSubscriptionService as jest.MockedClass< + typeof HyperLiquidSubscriptionService + >; +const mockValidateOrderParams = validateOrderParams as jest.MockedFunction< + typeof validateOrderParams +>; +const mockValidateWithdrawalParams = + validateWithdrawalParams as jest.MockedFunction< + typeof validateWithdrawalParams + >; +const mockValidateDepositParams = validateDepositParams as jest.MockedFunction< + typeof validateDepositParams +>; +const mockValidateCoinExists = validateCoinExists as jest.MockedFunction< + typeof validateCoinExists +>; +const mockValidateAssetSupport = validateAssetSupport as jest.MockedFunction< + typeof validateAssetSupport +>; +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'), + 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(); + +const mockMessenger = createMockMessenger(); + +/** + * Helper to create HyperLiquidProvider with mock platform dependencies + * @param options + * @param options.isTestnet + * @param options.hip3Enabled + * @param options.allowlistMarkets + * @param options.blocklistMarkets + * @param options.useUnifiedAccount + */ +const createTestProvider = ( + options: { + isTestnet?: boolean; + hip3Enabled?: boolean; + allowlistMarkets?: string[]; + blocklistMarkets?: string[]; + useUnifiedAccount?: boolean; + initialAssetMapping?: [string, number][]; + } = {}, +): HyperLiquidProvider => + new HyperLiquidProvider({ + ...options, + platformDependencies: mockPlatformDependencies, + messenger: mockMessenger, + }); + +describe('HyperLiquidProvider', () => { + let provider: HyperLiquidProvider; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionService: jest.Mocked; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + ( + mockPlatformDependencies.marketDataFormatters.formatVolume as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(0)); + ( + mockPlatformDependencies.marketDataFormatters.formatPerpsFiat as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(2)); + ( + mockPlatformDependencies.marketDataFormatters + .formatPercentage as jest.Mock + ).mockImplementation((value: number) => `${value.toFixed(2)}%`); + ( + mockPlatformDependencies.featureFlags.validateVersionGated as jest.Mock + ).mockReturnValue(undefined); + (mockPlatformDependencies.metrics.isEnabled as jest.Mock).mockReturnValue( + true, + ); + + // Reset TradingReadinessCache mock state (using imported mocked module) + const mockedCache = TradingReadinessCache as jest.Mocked< + typeof TradingReadinessCache + >; + mockedCache.get.mockReturnValue(undefined); + mockedCache.getBuilderFee.mockReturnValue(undefined); + mockedCache.getReferral.mockReturnValue(undefined); + mockedCache.isInFlight.mockReturnValue(undefined); + mockedCache.setInFlight.mockReturnValue(jest.fn()); + + // Initialize mock stream manager instance + mockStreamManagerInstance = { + clearAllChannels: jest.fn(), + }; + + // Create mocked service instances using factory functions + 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; + + mockWalletService = { + setTestnetMode: jest.fn(), + getCurrentAccountId: jest + .fn() + .mockReturnValue( + 'eip155:42161:0x1234567890123456789012345678901234567890', + ), + createWalletAdapter: jest.fn().mockReturnValue({ + request: jest + .fn() + .mockResolvedValue(['0x1234567890123456789012345678901234567890']), + }), + getUserAddress: jest + .fn() + .mockReturnValue('0x1234567890123456789012345678901234567890'), + getUserAddressWithDefault: jest + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890'), + isKeyringUnlocked: jest.fn().mockReturnValue(true), + isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + } as Partial as jest.Mocked; + + mockSubscriptionService = { + subscribeToPrices: jest.fn().mockResolvedValue(jest.fn()), // Returns Promise + subscribeToPositions: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + subscribeToOrderFills: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + clearAll: jest.fn(), + isPositionsCacheInitialized: jest.fn().mockReturnValue(false), + getCachedPositions: jest.fn().mockReturnValue([]), + updateFeatureFlags: jest.fn().mockResolvedValue(undefined), + // Cache methods used by buildAssetMapping optimization + setDexMetaCache: jest.fn(), + setDexAssetCtxsCache: jest.fn(), + getDexAssetCtxsCache: jest.fn().mockReturnValue(undefined), + // Price cache used by placeOrder, editOrder, closePosition optimizations + getCachedPrice: jest.fn().mockImplementation((symbol: string) => { + const prices: Record = { BTC: '50000', ETH: '3000' }; + return prices[symbol]; + }), + getLastAllMidsSnapshot: jest.fn().mockReturnValue(null), + // Orders cache used by updatePositionTPSL and getOpenOrders + isOrdersCacheInitialized: jest.fn().mockReturnValue(false), + getCachedOrders: jest.fn().mockReturnValue([]), + // Atomic getter - returns null when cache not initialized (prevents race condition) + getOrdersCacheIfInitialized: jest.fn().mockReturnValue(null), + // Abstraction-mode resolved-mode setter (unified account migration) + setUserAbstractionMode: jest.fn(), + } as Partial as jest.Mocked; + + // Mock constructors + MockedHyperLiquidClientService.mockImplementation(() => mockClientService); + MockedHyperLiquidWalletService.mockImplementation(() => mockWalletService); + MockedHyperLiquidSubscriptionService.mockImplementation( + () => mockSubscriptionService, + ); + + // Mock validation + mockValidateOrderParams.mockReturnValue({ isValid: true }); + mockValidateWithdrawalParams.mockReturnValue({ isValid: true }); + mockValidateDepositParams.mockReturnValue({ isValid: true }); + mockValidateCoinExists.mockReturnValue({ isValid: true }); + mockValidateAssetSupport.mockReturnValue({ isValid: true }); + mockValidateBalance.mockReturnValue({ isValid: true }); + const hyperLiquidValidation = jest.requireMock( + '../../../src/utils/hyperLiquidValidation', + ); + hyperLiquidValidation.getSupportedPaths.mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]); + hyperLiquidValidation.getBridgeInfo.mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }); + hyperLiquidValidation.createErrorResult.mockImplementation( + (error: unknown, defaultResponse: Record) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + }), + ); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptHyperLiquidLedgerUpdateToUserHistoryItem.mockImplementation( + (updates: unknown[]) => { + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map(() => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }, + ); + + provider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ], + }); + }); + describe('Builder Fee and Referral Integration', () => { + beforeEach(() => { + // Mock with maxBuilderFee: 0 to trigger approval calls + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee: jest.fn().mockResolvedValue(0), // Not approved yet + }), + ); + + // Mock user address to be different from builder address + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + '0x1234567890123456789012345678901234567890', // Different from builder + ); + + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ + 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', + }), + }); + }); + + it('includes builder fee and referral setup in order placement', async () => { + // Mock builder fee not approved to trigger approval call + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + maxBuilderFee: jest + .fn() + .mockResolvedValueOnce(0) // First call: not approved + .mockResolvedValueOnce(0.001), // Second call: approved after approval + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: REFERRAL_CONFIG.MainnetCode }, + }, + referredBy: null, // User has no referral set + }), + 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', + }, + ], + }), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', hold: '1000', total: '10000' }], + }), + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + 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([]), + }); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + + // Builder fee approval is set once during ensureReady() initialization + // With session caching, it should be called once (during first ensureReady) + expect( + mockClientService.getExchangeClient().approveBuilderFee, + ).toHaveBeenCalledWith({ + builder: expect.any(String), + maxFeeRate: expect.stringContaining('%'), + }); + + // Note: Referral setup is fire-and-forget (non-blocking), so we can't reliably + // test it synchronously. It's tested separately in dedicated referral tests. + + // Place a second order to verify caching (should NOT call builder fee approval again) + const mockExchangeClient = mockClientService.getExchangeClient(); + (mockExchangeClient.approveBuilderFee as jest.Mock).mockClear(); + + const result2 = await provider.placeOrder(orderParams); + + expect(result2.success).toBe(true); + // Session cache prevents redundant builder fee approval calls + expect(mockExchangeClient.approveBuilderFee).not.toHaveBeenCalled(); + + // Verify order was placed with builder fee + expect(mockClientService.getExchangeClient().order).toHaveBeenCalledWith( + expect.objectContaining({ + orders: expect.any(Array), + builder: { + b: expect.any(String), + f: expect.any(Number), + }, + }), + ); + }); + + it('includes builder fee and referral setup in TP/SL updates', async () => { + // Mock builder fee not approved to trigger approval call + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + maxBuilderFee: jest + .fn() + .mockResolvedValueOnce(0) // First call: not approved + .mockResolvedValueOnce(0.001), // Second call: approved after approval + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: REFERRAL_CONFIG.MainnetCode }, + }, + referredBy: null, // User has no referral set + }), + 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', + }, + ], + }), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', hold: '1000', total: '10000' }], + }), + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + 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([]), + }); + + const updateParams = { + symbol: 'BTC', + takeProfitPrice: '55000', + stopLossPrice: '45000', + }; + + const result = await provider.updatePositionTPSL(updateParams); + + expect(result.success).toBe(true); + + // Verify builder fee approval was called + expect( + mockClientService.getExchangeClient().approveBuilderFee, + ).toHaveBeenCalledWith({ + builder: expect.any(String), + maxFeeRate: expect.stringContaining('%'), + }); + + // Verify referral code was set + expect( + mockClientService.getExchangeClient().setReferrer, + ).toHaveBeenCalledWith({ + code: expect.any(String), + }); + + // Verify order was placed with builder fee + expect(mockClientService.getExchangeClient().order).toHaveBeenCalledWith( + expect.objectContaining({ + orders: expect.any(Array), + grouping: 'positionTpsl', + builder: { + b: expect.any(String), + f: expect.any(Number), + }, + }), + ); + }); + + it('skips referral setup when user is the builder', async () => { + // Mock user address to be the same as builder address + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + '0xe95a5e31904e005066614247d309e00d8ad753aa', // Builder address + ); + + // When user IS the builder, maxBuilderFee should already be approved + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee: jest.fn().mockResolvedValue(1), // Already approved + }), + ); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + + // Should not call setReferrer when user is the builder + expect( + mockClientService.getExchangeClient().setReferrer, + ).not.toHaveBeenCalled(); + }); + + it('handles builder fee approval failure (non-blocking)', async () => { + // Mock builder fee not approved to trigger approval call + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee: jest.fn().mockResolvedValue(0), // Not approved - triggers approval + }), + ); + + // Mock builder fee approval to fail + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + approveBuilderFee: jest + .fn() + .mockRejectedValue(new Error('Builder fee approval failed')), + }), + ); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + const result = await provider.placeOrder(orderParams); + + // PR #25334: Builder fee approval is now non-blocking (fire-and-forget) + // to prevent repeated signing prompts for hardware wallets. + // Order should proceed even if builder fee approval fails. + expect(result.success).toBe(true); + expect(result.orderId).toBeDefined(); + }); + + it('skips builder fee retry after cached approval failure', async () => { + const mockedCache = TradingReadinessCache as jest.Mocked< + typeof TradingReadinessCache + >; + + // First order: builder fee approval fails + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee: jest + .fn() + .mockResolvedValueOnce(0) // first order: check — not approved + .mockResolvedValueOnce(0) // second order: check — cached failure prevents retry + .mockResolvedValueOnce(0.001), // unused if cached failure is respected + }), + ); + + const mockApproveBuilderFee = jest + .fn() + .mockRejectedValueOnce(new Error('Network timeout')) + .mockResolvedValueOnce({ status: 'ok' }); + + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + approveBuilderFee: mockApproveBuilderFee, + }), + ); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + // First order — builder fee fails but order proceeds (non-blocking) + const result1 = await provider.placeOrder(orderParams); + expect(result1.success).toBe(true); + + // Simulate cached failure state — getBuilderFee returns { attempted: true, success: false } + mockedCache.getBuilderFee.mockReturnValue({ + attempted: true, + success: false, + }); + + // Second order — cached failure prevents another approval prompt + const result2 = await provider.placeOrder(orderParams); + expect(result2.success).toBe(true); + + // approveBuilderFee called once: cached failure avoids a repeated signing prompt. + expect(mockApproveBuilderFee).toHaveBeenCalledTimes(1); + }); + + it('skips builder fee retry when previous attempt succeeded', async () => { + const mockedCache = TradingReadinessCache as jest.Mocked< + typeof TradingReadinessCache + >; + + // Simulate successful cache + mockedCache.getBuilderFee.mockReturnValue({ + attempted: true, + success: true, + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(createMockInfoClient()); + + const mockApproveBuilderFee = jest.fn(); + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + approveBuilderFee: mockApproveBuilderFee, + }), + ); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + const result = await provider.placeOrder(orderParams); + expect(result.success).toBe(true); + + // Should not retry — cache shows success + expect(mockApproveBuilderFee).not.toHaveBeenCalled(); + }); + + it('leaves builder fee cache empty when wrapped KEYRING_LOCKED is thrown', async () => { + const wrappedKeyringLockedError = Object.assign( + new Error('Failed to sign typed data with viem wallet'), + { cause: new Error('KEYRING_LOCKED') }, + ); + const mockCompleteInFlight = jest.fn(); + ( + TradingReadinessCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee: jest.fn().mockResolvedValue(0), + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'not_ready', + data: null, + }, + }), + }), + ); + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + approveBuilderFee: jest + .fn() + .mockRejectedValue(wrappedKeyringLockedError), + }), + ); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + expect( + (TradingReadinessCache as jest.Mocked) + .setBuilderFee, + ).not.toHaveBeenCalled(); + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + + it('handles referral code setup failure (non-blocking)', async () => { + // Mock builder fee already approved + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(createMockInfoClient()); + + // Mock referral code setup to fail + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ + approveBuilderFee: jest.fn().mockResolvedValue({ + status: 'ok', + }), + setReferrer: jest + .fn() + .mockRejectedValue(new Error('Referral code setup failed')), + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: '123' } }] } }, + }), + updateLeverage: jest.fn().mockResolvedValue({ + status: 'ok', + }), + }); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + const result = await provider.placeOrder(orderParams); + + // Referral setup is now non-blocking (fire-and-forget), so order should succeed + expect(result.success).toBe(true); + expect(result.orderId).toBeDefined(); + }); + + it('leaves referral cache empty when wrapped KEYRING_LOCKED is thrown', async () => { + const wrappedKeyringLockedError = Object.assign( + new Error('Failed to sign typed data with viem wallet'), + { cause: new Error('KEYRING_LOCKED') }, + ); + const mockCompleteInFlight = jest.fn(); + ( + TradingReadinessCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee: jest.fn().mockResolvedValue(1), + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: REFERRAL_CONFIG.MainnetCode }, + }, + referredBy: null, + }), + }), + ); + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + setReferrer: jest.fn().mockRejectedValue(wrappedKeyringLockedError), + }), + ); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + const result = await provider.placeOrder(orderParams); + await Promise.resolve(); + await Promise.resolve(); + + expect(result.success).toBe(true); + expect( + (TradingReadinessCache as jest.Mocked) + .setReferral, + ).not.toHaveBeenCalled(); + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + + it('skips referral setup when referral code is not ready', async () => { + // Mock referral code not ready + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'not_ready', // Not ready + data: null, + }, + }), + }), + ); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + + // Should not call setReferrer when referral code is not ready + expect( + mockClientService.getExchangeClient().setReferrer, + ).not.toHaveBeenCalled(); + }); + + it('skips referral setup when user already has a referral', async () => { + // Mock user already has a referral by setting referredBy.code + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + referredBy: { + code: 'EXISTING_REFERRAL', + }, + }), + }), + ); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + + // Should not call setReferrer when user already has a referral + expect( + mockClientService.getExchangeClient().setReferrer, + ).not.toHaveBeenCalled(); + }); + + it('uses testnet builder address when in testnet mode', async () => { + // Arrange — flip to testnet mode + mockClientService.isTestnetMode.mockReturnValue(true); + + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee: jest.fn().mockResolvedValue(1), // Already approved + }), + ); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + // Act + const result = await provider.placeOrder(orderParams); + + // Assert — order placed with the testnet builder address + expect(result.success).toBe(true); + expect(mockClientService.getExchangeClient().order).toHaveBeenCalledWith( + expect.objectContaining({ + builder: { + b: BUILDER_FEE_CONFIG.TestnetBuilder, + f: expect.any(Number), + }, + }), + ); + }); + }); + + // TODO: Refactor to test through public API — ES # private fields prevent direct access + describe.skip('Builder Fee Global Cache (PR #25334)', () => { + interface ProviderWithBuilderFee { + ensureBuilderFeeApproval(): Promise; + } + + let testableProvider: ProviderWithBuilderFee; + + beforeEach(() => { + testableProvider = provider as unknown as ProviderWithBuilderFee; + mockWalletService.getUserAddressWithDefault = jest + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890'); + }); + + it('returns early when global cache indicates already attempted', async () => { + // Arrange - simulate cached state + ( + TradingReadinessCache as jest.Mocked + ).getBuilderFee.mockReturnValue({ + attempted: true, + success: true, + }); + + // Act + await testableProvider.ensureBuilderFeeApproval(); + + // Assert - should not call API when cached + expect(mockClientService.getInfoClient).not.toHaveBeenCalled(); + }); + + it('waits for in-flight operation instead of duplicating request', async () => { + // Arrange - ensure getBuilderFee returns undefined (not cached) + ( + TradingReadinessCache as jest.Mocked + ).getBuilderFee.mockReturnValue(undefined); + + // Simulate in-flight operation from another provider + let resolveInFlight: () => void = () => undefined; + const inFlightPromise = new Promise((resolve) => { + resolveInFlight = resolve; + }); + ( + TradingReadinessCache as jest.Mocked + ).isInFlight.mockReturnValue(inFlightPromise); + + // Act + const approvalPromise = testableProvider.ensureBuilderFeeApproval(); + + // Resolve the in-flight operation + resolveInFlight(); + await approvalPromise; + + // Verify it called isInFlight to check for concurrent operations + expect( + (TradingReadinessCache as jest.Mocked) + .isInFlight, + ).toHaveBeenCalledWith( + 'builderFee', + 'mainnet', + '0x1234567890123456789012345678901234567890', + ); + + // Assert - should not have set its own in-flight lock + expect( + (TradingReadinessCache as jest.Mocked) + .setInFlight, + ).not.toHaveBeenCalled(); + }); + + it('caches success after successful approval', async () => { + // Arrange + const mockCompleteInFlight = jest.fn(); + ( + TradingReadinessCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee: jest + .fn() + .mockResolvedValueOnce(0) // First call: not approved + .mockResolvedValueOnce(0.001), // Second call: approved after approval + }), + ); + + // Act + await testableProvider.ensureBuilderFeeApproval(); + + // Assert + expect( + (TradingReadinessCache as jest.Mocked) + .setBuilderFee, + ).toHaveBeenCalledWith( + 'mainnet', + '0x1234567890123456789012345678901234567890', + { attempted: true, success: true }, + ); + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + + it('caches failure to prevent repeated signing requests', async () => { + // Arrange + const mockCompleteInFlight = jest.fn(); + ( + TradingReadinessCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee: jest.fn().mockResolvedValue(0), + }), + ); + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + approveBuilderFee: jest + .fn() + .mockRejectedValue(new Error('User rejected')), + }), + ); + + // Act & Assert + await expect(testableProvider.ensureBuilderFeeApproval()).rejects.toThrow( + 'User rejected', + ); + + // Assert - failure should be cached + expect( + (TradingReadinessCache as jest.Mocked) + .setBuilderFee, + ).toHaveBeenCalledWith( + 'mainnet', + '0x1234567890123456789012345678901234567890', + { attempted: true, success: false }, + ); + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + + it('skips cache when KEYRING_LOCKED error is thrown', async () => { + // Arrange + const mockCompleteInFlight = jest.fn(); + ( + TradingReadinessCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + maxBuilderFee: jest.fn().mockResolvedValue(0), + }), + ); + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + approveBuilderFee: jest + .fn() + .mockRejectedValue(new Error('KEYRING_LOCKED')), + }), + ); + + // Act - should resolve without throwing + await testableProvider.ensureBuilderFeeApproval(); + + // Assert - cache should NOT be set (so it retries when unlocked) + expect( + (TradingReadinessCache as jest.Mocked) + .setBuilderFee, + ).not.toHaveBeenCalled(); + // Assert - in-flight lock should be released + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + }); + + // TODO: Refactor to test through public API — ES # private fields prevent direct access + describe.skip('Referral Global Cache (PR #25334)', () => { + interface ProviderWithReferral { + ensureReferralSet(): Promise; + } + + let testableProvider: ProviderWithReferral; + + beforeEach(() => { + testableProvider = provider as unknown as ProviderWithReferral; + mockWalletService.getUserAddressWithDefault = jest + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890'); + }); + + it('returns early when global cache indicates already attempted', async () => { + // Arrange - simulate cached state + ( + TradingReadinessCache as jest.Mocked + ).getReferral.mockReturnValue({ + attempted: true, + success: true, + }); + + // Act + await testableProvider.ensureReferralSet(); + + // Assert - should not call API when cached + expect(mockClientService.getInfoClient).not.toHaveBeenCalled(); + }); + + it('waits for in-flight operation instead of duplicating request', async () => { + // Arrange - ensure getReferral returns undefined (not cached) + ( + TradingReadinessCache as jest.Mocked + ).getReferral.mockReturnValue(undefined); + + // Simulate in-flight operation from another provider + let resolveInFlight: () => void = () => undefined; + const inFlightPromise = new Promise((resolve) => { + resolveInFlight = resolve; + }); + ( + TradingReadinessCache 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( + (TradingReadinessCache as jest.Mocked) + .isInFlight, + ).toHaveBeenCalledWith( + 'referral', + 'mainnet', + '0x1234567890123456789012345678901234567890', + ); + + // Assert - should not have set its own in-flight lock + expect( + (TradingReadinessCache as jest.Mocked) + .setInFlight, + ).not.toHaveBeenCalled(); + }); + + it('caches success after successful referral setup', async () => { + // Arrange + const mockCompleteInFlight = jest.fn(); + ( + TradingReadinessCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + referredBy: null, + }), + }), + ); + + // Act + await testableProvider.ensureReferralSet(); + + // Assert + expect( + (TradingReadinessCache as jest.Mocked) + .setReferral, + ).toHaveBeenCalledWith( + 'mainnet', + '0x1234567890123456789012345678901234567890', + { attempted: true, success: true }, + ); + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + + it('caches failure to prevent repeated signing requests', async () => { + // Arrange + const mockCompleteInFlight = jest.fn(); + ( + TradingReadinessCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + referredBy: null, + }), + }), + ); + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + setReferrer: jest.fn().mockRejectedValue(new Error('User rejected')), + }), + ); + + // Act - should not throw (referral is non-blocking) + await testableProvider.ensureReferralSet(); + + // Assert - failure should be cached + expect( + (TradingReadinessCache as jest.Mocked) + .setReferral, + ).toHaveBeenCalledWith( + 'mainnet', + '0x1234567890123456789012345678901234567890', + { attempted: true, success: false }, + ); + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + + it('caches success when user already has referral on-chain', async () => { + // Arrange + const mockCompleteInFlight = jest.fn(); + ( + TradingReadinessCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + referredBy: { code: 'EXISTING' }, // Already has referral + }), + }), + ); + + // Act + await testableProvider.ensureReferralSet(); + + // Assert - should cache success without calling setReferrer + expect( + (TradingReadinessCache as jest.Mocked) + .setReferral, + ).toHaveBeenCalledWith( + 'mainnet', + '0x1234567890123456789012345678901234567890', + { attempted: true, success: true }, + ); + expect( + mockClientService.getExchangeClient().setReferrer, + ).not.toHaveBeenCalled(); + }); + + it('skips cache and Sentry when KEYRING_LOCKED error is thrown', async () => { + // Arrange + const mockCompleteInFlight = jest.fn(); + ( + TradingReadinessCache as jest.Mocked + ).setInFlight.mockReturnValue(mockCompleteInFlight); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + referredBy: null, + }), + }), + ); + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + setReferrer: jest.fn().mockRejectedValue(new Error('KEYRING_LOCKED')), + }), + ); + + // Act - should resolve without throwing + await testableProvider.ensureReferralSet(); + + // Assert - cache should NOT be set (so it retries when unlocked) + expect( + (TradingReadinessCache as jest.Mocked) + .setReferral, + ).not.toHaveBeenCalled(); + // Assert - ensureReferralSet's catch does NOT call logger.error for KEYRING_LOCKED. + // Note: setReferralCode() internally logs to Sentry before rethrowing, so + // logger.error is called once (from setReferralCode), but NOT a second time + // from ensureReferralSet's catch block (which is the behavior under test). + expect(mockPlatformDependencies.logger.error).toHaveBeenCalledTimes(1); + expect(mockPlatformDependencies.logger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + context: expect.objectContaining({ + data: expect.objectContaining({ method: 'setReferralCode' }), + }), + }), + ); + // Assert - in-flight lock should be released + expect(mockCompleteInFlight).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.data.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.data.test.ts new file mode 100644 index 0000000000..abc073ebec --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.data.test.ts @@ -0,0 +1,1220 @@ +/* eslint-disable */ +jest.mock('@nktkas/hyperliquid', () => ({})); + +import type { CaipAssetId, Hex } from '@metamask/utils'; + +import { CandlePeriod } from '../../../src/constants/chartConfig'; +import { + BUILDER_FEE_CONFIG, + REFERRAL_CONFIG, +} from '../../../src/constants/hyperLiquidConfig'; +import { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from '../../../src/constants/transactionsHistoryConfig'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes'; +import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider'; +import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService'; +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService'; +import { TradingReadinessCache } from '../../../src/services/TradingReadinessCache'; +import type { + ClosePositionParams, + DepositParams, + Order, + PerpsPlatformDependencies, + LiveDataConfig, + OrderParams, +} from '../../../src/types'; +import { + validateAssetSupport, + validateBalance, + validateCoinExists, + validateDepositParams, + validateOrderParams, + validateWithdrawalParams, +} from '../../../src/utils/hyperLiquidValidation'; +import { createStandaloneInfoClient } from '../../../src/utils/standaloneInfoClient'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks'; + +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidWalletService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); +// Mock stream manager - will be set up in test +let mockStreamManagerInstance: any; +const mockGetStreamManagerInstance = jest.fn(() => mockStreamManagerInstance); +jest.mock( + '../../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: mockGetStreamManagerInstance, + }), + { virtual: true }, +); + +// Mock standalone info client for standalone mode tests +let mockStandaloneInfoClient: any; +jest.mock('../../../src/utils/standaloneInfoClient', () => ({ + ...jest.requireActual('../../../src/utils/standaloneInfoClient'), + createStandaloneInfoClient: jest.fn(() => mockStandaloneInfoClient), +})); + +jest.mock('../../../src/utils/hyperLiquidValidation', () => ({ + validateOrderParams: jest.fn(), + validateWithdrawalParams: jest.fn(), + validateDepositParams: jest.fn(), + validateCoinExists: jest.fn(), + validateAssetSupport: jest.fn(), + validateBalance: jest.fn(), + getSupportedPaths: jest + .fn() + .mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]), + getBridgeInfo: jest.fn().mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }), + createErrorResult: jest.fn((error, defaultResponse) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + })), +})); + +// Mock adapter functions +jest.mock('../../../src/utils/hyperLiquidAdapter', () => { + const actual = jest.requireActual('../../../src/utils/hyperLiquidAdapter'); + return { + ...actual, + adaptHyperLiquidLedgerUpdateToUserHistoryItem: jest.fn((updates) => { + // Return mock history items based on input + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map((_update: unknown) => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }), + }; +}); + +// Mock TradingReadinessCache - global singleton for signing operation caching +// Use jest.createMockFromModule for proper mock creation +jest.mock('../../../src/services/TradingReadinessCache'); + +const MockedHyperLiquidClientService = + HyperLiquidClientService as jest.MockedClass; +const MockedHyperLiquidWalletService = + HyperLiquidWalletService as jest.MockedClass; +const MockedHyperLiquidSubscriptionService = + HyperLiquidSubscriptionService as jest.MockedClass< + typeof HyperLiquidSubscriptionService + >; +const mockValidateOrderParams = validateOrderParams as jest.MockedFunction< + typeof validateOrderParams +>; +const mockValidateWithdrawalParams = + validateWithdrawalParams as jest.MockedFunction< + typeof validateWithdrawalParams + >; +const mockValidateDepositParams = validateDepositParams as jest.MockedFunction< + typeof validateDepositParams +>; +const mockValidateCoinExists = validateCoinExists as jest.MockedFunction< + typeof validateCoinExists +>; +const mockValidateAssetSupport = validateAssetSupport as jest.MockedFunction< + typeof validateAssetSupport +>; +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'), + 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(); + +const mockMessenger = createMockMessenger(); + +/** + * Helper to create HyperLiquidProvider with mock platform dependencies + * @param options + * @param options.isTestnet + * @param options.hip3Enabled + * @param options.allowlistMarkets + * @param options.blocklistMarkets + * @param options.useUnifiedAccount + */ +const createTestProvider = ( + options: { + isTestnet?: boolean; + hip3Enabled?: boolean; + allowlistMarkets?: string[]; + blocklistMarkets?: string[]; + useUnifiedAccount?: boolean; + initialAssetMapping?: [string, number][]; + } = {}, +): HyperLiquidProvider => + new HyperLiquidProvider({ + ...options, + platformDependencies: mockPlatformDependencies, + messenger: mockMessenger, + }); + +describe('HyperLiquidProvider', () => { + let provider: HyperLiquidProvider; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionService: jest.Mocked; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + ( + mockPlatformDependencies.marketDataFormatters.formatVolume as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(0)); + ( + mockPlatformDependencies.marketDataFormatters.formatPerpsFiat as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(2)); + ( + mockPlatformDependencies.marketDataFormatters + .formatPercentage as jest.Mock + ).mockImplementation((value: number) => `${value.toFixed(2)}%`); + ( + mockPlatformDependencies.featureFlags.validateVersionGated as jest.Mock + ).mockReturnValue(undefined); + (mockPlatformDependencies.metrics.isEnabled as jest.Mock).mockReturnValue( + true, + ); + + // Reset TradingReadinessCache mock state (using imported mocked module) + const mockedCache = TradingReadinessCache as jest.Mocked< + typeof TradingReadinessCache + >; + mockedCache.get.mockReturnValue(undefined); + mockedCache.getBuilderFee.mockReturnValue(undefined); + mockedCache.getReferral.mockReturnValue(undefined); + mockedCache.isInFlight.mockReturnValue(undefined); + mockedCache.setInFlight.mockReturnValue(jest.fn()); + + // Initialize mock stream manager instance + mockStreamManagerInstance = { + clearAllChannels: jest.fn(), + }; + + // Create mocked service instances using factory functions + 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; + + mockWalletService = { + setTestnetMode: jest.fn(), + getCurrentAccountId: jest + .fn() + .mockReturnValue( + 'eip155:42161:0x1234567890123456789012345678901234567890', + ), + createWalletAdapter: jest.fn().mockReturnValue({ + request: jest + .fn() + .mockResolvedValue(['0x1234567890123456789012345678901234567890']), + }), + getUserAddress: jest + .fn() + .mockReturnValue('0x1234567890123456789012345678901234567890'), + getUserAddressWithDefault: jest + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890'), + isKeyringUnlocked: jest.fn().mockReturnValue(true), + isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + } as Partial as jest.Mocked; + + mockSubscriptionService = { + subscribeToPrices: jest.fn().mockResolvedValue(jest.fn()), // Returns Promise + subscribeToPositions: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + subscribeToOrderFills: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + clearAll: jest.fn(), + isPositionsCacheInitialized: jest.fn().mockReturnValue(false), + getCachedPositions: jest.fn().mockReturnValue([]), + updateFeatureFlags: jest.fn().mockResolvedValue(undefined), + // Cache methods used by buildAssetMapping optimization + setDexMetaCache: jest.fn(), + setDexAssetCtxsCache: jest.fn(), + getDexAssetCtxsCache: jest.fn().mockReturnValue(undefined), + // Price cache used by placeOrder, editOrder, closePosition optimizations + getCachedPrice: jest.fn().mockImplementation((symbol: string) => { + const prices: Record = { BTC: '50000', ETH: '3000' }; + return prices[symbol]; + }), + getLastAllMidsSnapshot: jest.fn().mockReturnValue(null), + // Orders cache used by updatePositionTPSL and getOpenOrders + isOrdersCacheInitialized: jest.fn().mockReturnValue(false), + getCachedOrders: jest.fn().mockReturnValue([]), + // Atomic getter - returns null when cache not initialized (prevents race condition) + getOrdersCacheIfInitialized: jest.fn().mockReturnValue(null), + // Abstraction-mode resolved-mode setter (unified account migration) + setUserAbstractionMode: jest.fn(), + } as Partial as jest.Mocked; + + // Mock constructors + MockedHyperLiquidClientService.mockImplementation(() => mockClientService); + MockedHyperLiquidWalletService.mockImplementation(() => mockWalletService); + MockedHyperLiquidSubscriptionService.mockImplementation( + () => mockSubscriptionService, + ); + + // Mock validation + mockValidateOrderParams.mockReturnValue({ isValid: true }); + mockValidateWithdrawalParams.mockReturnValue({ isValid: true }); + mockValidateDepositParams.mockReturnValue({ isValid: true }); + mockValidateCoinExists.mockReturnValue({ isValid: true }); + mockValidateAssetSupport.mockReturnValue({ isValid: true }); + mockValidateBalance.mockReturnValue({ isValid: true }); + const hyperLiquidValidation = jest.requireMock( + '../../../src/utils/hyperLiquidValidation', + ); + hyperLiquidValidation.getSupportedPaths.mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]); + hyperLiquidValidation.getBridgeInfo.mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }); + hyperLiquidValidation.createErrorResult.mockImplementation( + (error: unknown, defaultResponse: Record) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + }), + ); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptHyperLiquidLedgerUpdateToUserHistoryItem.mockImplementation( + (updates: unknown[]) => { + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map(() => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }, + ); + + provider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ], + }); + }); + describe('Data Retrieval', () => { + it('gets positions successfully', async () => { + const positions = await provider.getPositions(); + + expect(Array.isArray(positions)).toBe(true); + expect(positions.length).toBeGreaterThan(0); + expect( + mockClientService.getInfoClient().clearinghouseState, + ).toHaveBeenCalled(); + }); + + it('gets account state successfully', async () => { + const accountState = await provider.getAccountState(); + + expect(accountState).toBeDefined(); + expect(accountState.totalBalance).toBe('19500'); // 10500 (perps) + 10000 (spot.total) - 1000 (spot.hold, double-counted in accountValue) + expect( + mockClientService.getInfoClient().clearinghouseState, + ).toHaveBeenCalled(); + expect( + mockClientService.getInfoClient().spotClearinghouseState, + ).toHaveBeenCalled(); + }); + + it('does not count USDH-only spot balance in funded-state totals', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDH', hold: '1000', total: '10000' }], + }), + }), + ); + + const accountState = await provider.getAccountState(); + + expect(accountState).toBeDefined(); + expect(accountState.totalBalance).toBe('10500'); + expect( + mockClientService.getInfoClient().spotClearinghouseState, + ).toHaveBeenCalled(); + }); + + it('does not fold non-USDC spot balance in Unified Account mode', async () => { + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + }); + const mockInfoClient = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '0', + accountValue: '0', + }, + withdrawable: '0', + assetPositions: [], + crossMarginSummary: { + accountValue: '0', + totalMarginUsed: '0', + }, + }), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [ + { coin: 'mUSD', hold: '10', total: '100' }, + { coin: 'HYPE', hold: '0', total: '999' }, + ], + }), + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + mockWalletService.getUserAddressWithDefault.mockResolvedValue('0x123'); + + const accountState = await hip3Provider.getAccountState(); + + expect(accountState.spendableBalance).toBe('0'); + expect(accountState.withdrawableBalance).toBe('0'); + expect(accountState.totalBalance).toBe('0'); + }); + + it('folds USDC spot balance into spendable/withdrawable in Unified Account mode', async () => { + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + }); + const mockInfoClient = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '0', + accountValue: '0', + }, + withdrawable: '0', + assetPositions: [], + crossMarginSummary: { + accountValue: '0', + totalMarginUsed: '0', + }, + }), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', hold: '10', total: '100' }], + }), + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + mockWalletService.getUserAddressWithDefault.mockResolvedValue('0x123'); + + const accountState = await hip3Provider.getAccountState(); + + expect(accountState.spendableBalance).toBe('90'); + expect(accountState.withdrawableBalance).toBe('90'); + expect(accountState.totalBalance).toBe('90'); + }); + + it.each(['default', 'disabled'] as const)( + 'does not fold USDC spot balance in %s account mode', + async (abstractionMode) => { + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + }); + const mockInfoClient = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '0', + accountValue: '0', + }, + withdrawable: '0', + assetPositions: [], + crossMarginSummary: { + accountValue: '0', + totalMarginUsed: '0', + }, + }), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', hold: '10', total: '100' }], + }), + userAbstraction: jest.fn().mockResolvedValue(abstractionMode), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + mockWalletService.getUserAddressWithDefault.mockResolvedValue('0x123'); + + const accountState = await hip3Provider.getAccountState(); + + expect(accountState.spendableBalance).toBe('0'); + expect(accountState.withdrawableBalance).toBe('0'); + expect(accountState.totalBalance).toBe('90'); + }, + ); + + it('gets markets successfully', async () => { + const markets = await provider.getMarkets(); + + expect(Array.isArray(markets)).toBe(true); + expect(markets.length).toBeGreaterThan(0); + // buildAssetMapping (via ensureReady) uses metaAndAssetCtxs to populate cache; getMarkets uses cached meta + expect( + mockClientService.getInfoClient().metaAndAssetCtxs, + ).toHaveBeenCalled(); + }); + + it('handles data retrieval errors gracefully', async () => { + ( + mockClientService.getInfoClient().clearinghouseState as jest.Mock + ).mockRejectedValueOnce(new Error('API Error')); + + const positions = await provider.getPositions(); + + expect(Array.isArray(positions)).toBe(true); + expect(positions.length).toBe(0); + }); + }); + + describe('Withdrawal Operations', () => { + it('processes withdrawal successfully', async () => { + const withdrawParams = { + amount: '1000', + destination: '0x1234567890123456789012345678901234567890' as Hex, + assetId: + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/usdc' as CaipAssetId, + }; + + const result = await provider.withdraw(withdrawParams); + + expect(result.success).toBe(true); + }); + + it('runs user-signed unified account migration before withdrawing for dexAbstraction users', async () => { + const exchangeClient = createMockExchangeClient(); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userAbstraction: jest.fn().mockResolvedValue('dexAbstraction'), + }), + ); + + Object.defineProperty(provider, 'getAccountState', { + value: jest.fn().mockResolvedValue({ + availableBalance: '5000', + }), + writable: true, + }); + + const withdrawParams = { + amount: '1000', + destination: '0x1234567890123456789012345678901234567890' as Hex, + assetId: + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/usdc' as CaipAssetId, + }; + + const result = await provider.withdraw(withdrawParams); + + expect(result.success).toBe(true); + expect(exchangeClient.userSetAbstraction).toHaveBeenCalledWith({ + user: '0x1234567890123456789012345678901234567890', + abstraction: 'unifiedAccount', + }); + expect(exchangeClient.withdraw3).toHaveBeenCalledWith({ + destination: '0x1234567890123456789012345678901234567890', + amount: '1000', + }); + expect(exchangeClient.approveBuilderFee).not.toHaveBeenCalled(); + expect(exchangeClient.setReferrer).not.toHaveBeenCalled(); + }); + + it('handles withdrawal errors', async () => { + mockValidateWithdrawalParams.mockReturnValueOnce({ + isValid: false, + error: 'Invalid withdrawal amount', + }); + + const withdrawParams = { + amount: '0', + destination: '0x1234567890123456789012345678901234567890' as Hex, + assetId: + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/usdc' as CaipAssetId, + }; + + const result = await provider.withdraw(withdrawParams); + + expect(result.success).toBe(false); + }); + }); + + describe('Subscription Management', () => { + it('subscribes to prices', () => { + const callback = jest.fn(); + const unsubscribe = provider.subscribeToPrices({ + symbols: ['BTC', 'ETH'], + callback, + }); + + expect(mockSubscriptionService.subscribeToPrices).toHaveBeenCalled(); + expect(typeof unsubscribe).toBe('function'); + }); + + it('subscribes to positions', () => { + const callback = jest.fn(); + const unsubscribe = provider.subscribeToPositions({ callback }); + + expect(mockSubscriptionService.subscribeToPositions).toHaveBeenCalled(); + expect(typeof unsubscribe).toBe('function'); + }); + + it('subscribes to order fills', () => { + const callback = jest.fn(); + const unsubscribe = provider.subscribeToOrderFills({ callback }); + + expect(mockSubscriptionService.subscribeToOrderFills).toHaveBeenCalled(); + expect(typeof unsubscribe).toBe('function'); + }); + + it('sets live data config', () => { + const config: Partial = { + priceThrottleMs: 1000, + positionThrottleMs: 3000, + }; + + provider.setLiveDataConfig(config); + + // Note: This test may need adjustment based on actual implementation + expect(mockSubscriptionService.clearAll).toBeDefined(); + }); + }); + + describe('Provider State Management', () => { + it('checks if ready to trade', async () => { + const result = await provider.isReadyToTrade(); + + expect(result.ready).toBe(true); + }); + + it('handles readiness check errors', async () => { + mockWalletService.getCurrentAccountId.mockImplementationOnce(() => { + throw new Error('No account selected'); + }); + + const result = await provider.isReadyToTrade(); + + expect(result.ready).toBe(false); + }); + + it('toggles testnet mode', async () => { + const result = await provider.toggleTestnet(); + + expect(result.success).toBe(true); + expect(mockClientService.setTestnetMode).toHaveBeenCalled(); + expect(mockWalletService.setTestnetMode).toHaveBeenCalled(); + }); + + it('toggleTestnet succeeds even when called concurrently with initialization', async () => { + const result = await provider.toggleTestnet(); + + expect(result.success).toBe(true); + }); + + it('disconnects successfully', async () => { + const result = await provider.disconnect(); + + expect(result.success).toBe(true); + expect(mockClientService.disconnect).toHaveBeenCalled(); + }); + + it('disconnects successfully even when initialization was pending', async () => { + const result = await provider.disconnect(); + + expect(result.success).toBe(true); + expect(mockClientService.disconnect).toHaveBeenCalled(); + }); + + it('disconnects successfully even when ensureReady was pending', async () => { + const result = await provider.disconnect(); + + expect(result.success).toBe(true); + }); + + it('handles disconnect errors', async () => { + mockClientService.disconnect.mockRejectedValueOnce( + new Error('Disconnect failed'), + ); + + const result = await provider.disconnect(); + + expect(result.success).toBe(false); + expect(result.error).toContain('Disconnect failed'); + }); + + describe('ping() health check', () => { + it('successfully ping WebSocket connection with default timeout', async () => { + const mockReady = jest.fn().mockResolvedValue(undefined); + const mockSubscriptionClient = { + config_: { + transport: { + ready: mockReady, + }, + }, + }; + mockClientService.getSubscriptionClient.mockReturnValue( + mockSubscriptionClient as any, + ); + + await provider.ping(); + + expect(mockReady).toHaveBeenCalled(); + // Verify the AbortSignal was passed + expect(mockReady.mock.calls[0][0]).toBeInstanceOf(AbortSignal); + }); + + it('successfully ping WebSocket connection with custom timeout', async () => { + const mockReady = jest.fn().mockResolvedValue(undefined); + const mockSubscriptionClient = { + config_: { + transport: { + ready: mockReady, + }, + }, + }; + mockClientService.getSubscriptionClient.mockReturnValue( + mockSubscriptionClient as any, + ); + + await provider.ping(10000); + + expect(mockReady).toHaveBeenCalled(); + expect(mockReady.mock.calls[0][0]).toBeInstanceOf(AbortSignal); + }); + + it('throws error when subscription client is not initialized', async () => { + mockClientService.getSubscriptionClient.mockReturnValue(undefined); + + await expect(provider.ping()).rejects.toThrow( + 'Subscription client not initialized', + ); + }); + + it('throws CONNECTION_TIMEOUT error when timeout occurs', async () => { + const mockReady = jest + .fn() + .mockImplementation( + () => + new Promise((_, reject) => + setTimeout(() => reject(new Error('Aborted')), 100), + ), + ); + const mockSubscriptionClient = { + config_: { + transport: { + ready: mockReady, + }, + }, + }; + mockClientService.getSubscriptionClient.mockReturnValue( + mockSubscriptionClient as any, + ); + + await expect(provider.ping(50)).rejects.toThrow('CONNECTION_TIMEOUT'); + }); + + it('throws error when WebSocket connection fails', async () => { + const mockReady = jest + .fn() + .mockRejectedValue(new Error('WebSocket closed')); + const mockSubscriptionClient = { + config_: { + transport: { + ready: mockReady, + }, + }, + }; + mockClientService.getSubscriptionClient.mockReturnValue( + mockSubscriptionClient as any, + ); + + await expect(provider.ping()).rejects.toThrow('WebSocket closed'); + }); + }); + }); + + describe('Asset Mapping', () => { + it('handles asset mapping errors', async () => { + ( + mockClientService.getInfoClient().meta as jest.Mock + ).mockRejectedValueOnce(new Error('Meta fetch failed')); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(false); + }); + }); + + describe('Error Handling', () => { + it('handles validation errors in orders', async () => { + mockValidateOrderParams.mockReturnValueOnce({ + isValid: false, + error: 'Invalid order parameters', + }); + + const orderParams: OrderParams = { + symbol: '', + isBuy: true, + size: '0', + orderType: 'market', + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid order parameters'); + }); + + it('handles validation errors in withdrawals', async () => { + mockValidateWithdrawalParams.mockReturnValueOnce({ + isValid: false, + error: 'Invalid withdrawal parameters', + }); + + const withdrawParams = { + amount: '', + destination: '0x1234567890123456789012345678901234567890' as Hex, + assetId: + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/usdc' as CaipAssetId, + }; + + const result = await provider.withdraw(withdrawParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid withdrawal parameters'); + }); + + it('handles unknown errors gracefully', async () => { + ( + mockClientService.getInfoClient().clearinghouseState as jest.Mock + ).mockRejectedValueOnce(new Error('Unknown error')); + + const result = await provider.getPositions(); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(0); + }); + + describe('error mapping integration', () => { + it('maps HyperLiquid leverage error in placeOrder to ORDER_LEVERAGE_REDUCTION_FAILED', async () => { + // Mock placeOrder to throw the specific HyperLiquid error + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ + order: jest + .fn() + .mockRejectedValue( + new Error( + 'isolated position does not have sufficient margin available to decrease leverage', + ), + ), + updateLeverage: jest.fn().mockResolvedValue({ status: 'ok' }), + approveBuilderFee: jest.fn().mockResolvedValue({ status: 'ok' }), + setReferrer: jest.fn().mockResolvedValue({ status: 'ok' }), + }); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + leverage: 10, + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(false); + expect(result.error).toBe('ORDER_LEVERAGE_REDUCTION_FAILED'); + }); + + it('maps case insensitive HyperLiquid error', async () => { + // Mock with uppercase version + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ + order: jest + .fn() + .mockRejectedValue( + new Error( + 'ISOLATED POSITION DOES NOT HAVE SUFFICIENT MARGIN AVAILABLE TO DECREASE LEVERAGE', + ), + ), + updateLeverage: jest.fn().mockResolvedValue({ status: 'ok' }), + approveBuilderFee: jest.fn().mockResolvedValue({ status: 'ok' }), + setReferrer: jest.fn().mockResolvedValue({ status: 'ok' }), + }); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + leverage: 10, + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(false); + expect(result.error).toBe('ORDER_LEVERAGE_REDUCTION_FAILED'); + }); + + it('maps partial error message containing the pattern', async () => { + // Mock with longer error message containing the pattern + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ + order: jest + .fn() + .mockRejectedValue( + new Error( + 'API Error: isolated position does not have sufficient margin available to decrease leverage. Please check your position.', + ), + ), + updateLeverage: jest.fn().mockResolvedValue({ status: 'ok' }), + approveBuilderFee: jest.fn().mockResolvedValue({ status: 'ok' }), + setReferrer: jest.fn().mockResolvedValue({ status: 'ok' }), + }); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + leverage: 10, + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(false); + expect(result.error).toBe('ORDER_LEVERAGE_REDUCTION_FAILED'); + }); + + it('preserves original error message for unmapped errors', async () => { + // Mock with an unmapped error + const originalError = new Error('Some other HyperLiquid API error'); + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ + order: jest.fn().mockRejectedValue(originalError), + updateLeverage: jest.fn().mockResolvedValue({ status: 'ok' }), + approveBuilderFee: jest.fn().mockResolvedValue({ status: 'ok' }), + setReferrer: jest.fn().mockResolvedValue({ status: 'ok' }), + }); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + leverage: 10, + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(false); + expect(result.error).toBe('Some other HyperLiquid API error'); + }); + }); + }); + + describe('Edge Cases', () => { + it('handles missing asset info in orders', async () => { + ( + mockClientService.getInfoClient().meta as jest.Mock + ).mockResolvedValueOnce({ + universe: [], // Empty universe + }); + + const orderParams: OrderParams = { + symbol: 'UNKNOWN', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, // Add price so validation passes, then fails on asset lookup + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('Asset UNKNOWN not found'); + }); + + it('handles missing price data', async () => { + mockSubscriptionService.getCachedPrice.mockReturnValueOnce(undefined); + ( + mockClientService.getInfoClient().allMids as jest.Mock + ).mockResolvedValueOnce({}); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + + const result = await provider.placeOrder(orderParams); + + // allMids returns {} so #getOrFetchPrice parses price as 0, which is + // invalid. The error surfaces from #getAssetInfo before validation runs. + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid price for BTC: 0'); + }); + + it('handles missing position in close operation', async () => { + ( + mockClientService.getInfoClient().clearinghouseState as jest.Mock + ).mockResolvedValueOnce({ + assetPositions: [], // No positions + }); + + const closeParams: ClosePositionParams = { + symbol: 'BTC', + orderType: 'market', + }; + + const result = await provider.closePosition(closeParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('No position found for BTC'); + }); + }); +}); 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 new file mode 100644 index 0000000000..84d107e137 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.error-handling.test.ts @@ -0,0 +1,2477 @@ +/* eslint-disable */ +jest.mock('@nktkas/hyperliquid', () => ({})); + +import type { CaipAssetId, Hex } from '@metamask/utils'; + +import { CandlePeriod } from '../../../src/constants/chartConfig'; +import { + BUILDER_FEE_CONFIG, + REFERRAL_CONFIG, +} from '../../../src/constants/hyperLiquidConfig'; +import { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from '../../../src/constants/transactionsHistoryConfig'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes'; +import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider'; +import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService'; +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService'; +import { TradingReadinessCache } from '../../../src/services/TradingReadinessCache'; +import type { + ClosePositionParams, + DepositParams, + Order, + PerpsPlatformDependencies, + LiveDataConfig, + OrderParams, +} from '../../../src/types'; +import { + validateAssetSupport, + validateBalance, + validateCoinExists, + validateDepositParams, + validateOrderParams, + validateWithdrawalParams, +} from '../../../src/utils/hyperLiquidValidation'; +import { createStandaloneInfoClient } from '../../../src/utils/standaloneInfoClient'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks'; + +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidWalletService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); +// Mock stream manager - will be set up in test +let mockStreamManagerInstance: any; +const mockGetStreamManagerInstance = jest.fn(() => mockStreamManagerInstance); +jest.mock( + '../../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: mockGetStreamManagerInstance, + }), + { virtual: true }, +); + +// Mock standalone info client for standalone mode tests +let mockStandaloneInfoClient: any; +jest.mock('../../../src/utils/standaloneInfoClient', () => ({ + ...jest.requireActual('../../../src/utils/standaloneInfoClient'), + createStandaloneInfoClient: jest.fn(() => mockStandaloneInfoClient), +})); + +jest.mock('../../../src/utils/hyperLiquidValidation', () => ({ + validateOrderParams: jest.fn(), + validateWithdrawalParams: jest.fn(), + validateDepositParams: jest.fn(), + validateCoinExists: jest.fn(), + validateAssetSupport: jest.fn(), + validateBalance: jest.fn(), + getSupportedPaths: jest + .fn() + .mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]), + getBridgeInfo: jest.fn().mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }), + createErrorResult: jest.fn((error, defaultResponse) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + })), +})); + +// Mock adapter functions +jest.mock('../../../src/utils/hyperLiquidAdapter', () => { + const actual = jest.requireActual('../../../src/utils/hyperLiquidAdapter'); + return { + ...actual, + adaptHyperLiquidLedgerUpdateToUserHistoryItem: jest.fn((updates) => { + // Return mock history items based on input + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map((_update: unknown) => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }), + }; +}); + +// Mock TradingReadinessCache - global singleton for signing operation caching +// Use jest.createMockFromModule for proper mock creation +jest.mock('../../../src/services/TradingReadinessCache'); + +const MockedHyperLiquidClientService = + HyperLiquidClientService as jest.MockedClass; +const MockedHyperLiquidWalletService = + HyperLiquidWalletService as jest.MockedClass; +const MockedHyperLiquidSubscriptionService = + HyperLiquidSubscriptionService as jest.MockedClass< + typeof HyperLiquidSubscriptionService + >; +const mockValidateOrderParams = validateOrderParams as jest.MockedFunction< + typeof validateOrderParams +>; +const mockValidateWithdrawalParams = + validateWithdrawalParams as jest.MockedFunction< + typeof validateWithdrawalParams + >; +const mockValidateDepositParams = validateDepositParams as jest.MockedFunction< + typeof validateDepositParams +>; +const mockValidateCoinExists = validateCoinExists as jest.MockedFunction< + typeof validateCoinExists +>; +const mockValidateAssetSupport = validateAssetSupport as jest.MockedFunction< + typeof validateAssetSupport +>; +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'), + 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(); + +const mockMessenger = createMockMessenger(); + +/** + * Helper to create HyperLiquidProvider with mock platform dependencies + * @param options + * @param options.isTestnet + * @param options.hip3Enabled + * @param options.allowlistMarkets + * @param options.blocklistMarkets + * @param options.useUnifiedAccount + */ +const createTestProvider = ( + options: { + isTestnet?: boolean; + hip3Enabled?: boolean; + allowlistMarkets?: string[]; + blocklistMarkets?: string[]; + useUnifiedAccount?: boolean; + initialAssetMapping?: [string, number][]; + } = {}, +): HyperLiquidProvider => + new HyperLiquidProvider({ + ...options, + platformDependencies: mockPlatformDependencies, + messenger: mockMessenger, + }); + +describe('HyperLiquidProvider', () => { + let provider: HyperLiquidProvider; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionService: jest.Mocked; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + ( + mockPlatformDependencies.marketDataFormatters.formatVolume as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(0)); + ( + mockPlatformDependencies.marketDataFormatters.formatPerpsFiat as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(2)); + ( + mockPlatformDependencies.marketDataFormatters + .formatPercentage as jest.Mock + ).mockImplementation((value: number) => `${value.toFixed(2)}%`); + ( + mockPlatformDependencies.featureFlags.validateVersionGated as jest.Mock + ).mockReturnValue(undefined); + (mockPlatformDependencies.metrics.isEnabled as jest.Mock).mockReturnValue( + true, + ); + + // Reset TradingReadinessCache mock state (using imported mocked module) + const mockedCache = TradingReadinessCache as jest.Mocked< + typeof TradingReadinessCache + >; + mockedCache.get.mockReturnValue(undefined); + mockedCache.getBuilderFee.mockReturnValue(undefined); + mockedCache.getReferral.mockReturnValue(undefined); + mockedCache.isInFlight.mockReturnValue(undefined); + mockedCache.setInFlight.mockReturnValue(jest.fn()); + + // Initialize mock stream manager instance + mockStreamManagerInstance = { + clearAllChannels: jest.fn(), + }; + + // Create mocked service instances using factory functions + 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; + + mockWalletService = { + setTestnetMode: jest.fn(), + getCurrentAccountId: jest + .fn() + .mockReturnValue( + 'eip155:42161:0x1234567890123456789012345678901234567890', + ), + createWalletAdapter: jest.fn().mockReturnValue({ + request: jest + .fn() + .mockResolvedValue(['0x1234567890123456789012345678901234567890']), + }), + getUserAddress: jest + .fn() + .mockReturnValue('0x1234567890123456789012345678901234567890'), + getUserAddressWithDefault: jest + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890'), + isKeyringUnlocked: jest.fn().mockReturnValue(true), + isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + } as Partial as jest.Mocked; + + mockSubscriptionService = { + subscribeToPrices: jest.fn().mockResolvedValue(jest.fn()), // Returns Promise + subscribeToPositions: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + subscribeToOrderFills: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + clearAll: jest.fn(), + isPositionsCacheInitialized: jest.fn().mockReturnValue(false), + getCachedPositions: jest.fn().mockReturnValue([]), + updateFeatureFlags: jest.fn().mockResolvedValue(undefined), + // Cache methods used by buildAssetMapping optimization + setDexMetaCache: jest.fn(), + setDexAssetCtxsCache: jest.fn(), + getDexAssetCtxsCache: jest.fn().mockReturnValue(undefined), + // Price cache used by placeOrder, editOrder, closePosition optimizations + getCachedPrice: jest.fn().mockImplementation((symbol: string) => { + const prices: Record = { BTC: '50000', ETH: '3000' }; + return prices[symbol]; + }), + getLastAllMidsSnapshot: jest.fn().mockReturnValue(null), + // Orders cache used by updatePositionTPSL and getOpenOrders + isOrdersCacheInitialized: jest.fn().mockReturnValue(false), + getCachedOrders: jest.fn().mockReturnValue([]), + // Atomic getter - returns null when cache not initialized (prevents race condition) + getOrdersCacheIfInitialized: jest.fn().mockReturnValue(null), + // Abstraction-mode resolved-mode setter (unified account migration) + setUserAbstractionMode: jest.fn(), + } as Partial as jest.Mocked; + + // Mock constructors + MockedHyperLiquidClientService.mockImplementation(() => mockClientService); + MockedHyperLiquidWalletService.mockImplementation(() => mockWalletService); + MockedHyperLiquidSubscriptionService.mockImplementation( + () => mockSubscriptionService, + ); + + // Mock validation + mockValidateOrderParams.mockReturnValue({ isValid: true }); + mockValidateWithdrawalParams.mockReturnValue({ isValid: true }); + mockValidateDepositParams.mockReturnValue({ isValid: true }); + mockValidateCoinExists.mockReturnValue({ isValid: true }); + mockValidateAssetSupport.mockReturnValue({ isValid: true }); + mockValidateBalance.mockReturnValue({ isValid: true }); + const hyperLiquidValidation = jest.requireMock( + '../../../src/utils/hyperLiquidValidation', + ); + hyperLiquidValidation.getSupportedPaths.mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]); + hyperLiquidValidation.getBridgeInfo.mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }); + hyperLiquidValidation.createErrorResult.mockImplementation( + (error: unknown, defaultResponse: Record) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + }), + ); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptHyperLiquidLedgerUpdateToUserHistoryItem.mockImplementation( + (updates: unknown[]) => { + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map(() => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }, + ); + + provider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ], + }); + }); + describe('Additional Error Handling and Edge Cases', () => { + describe('ensureReady and buildAssetMapping', () => { + it('handles meta fetch failure in buildAssetMapping', async () => { + // Create a fresh provider to test buildAssetMapping + const freshProvider = createTestProvider(); + + // Mock failed meta fetch but keep other methods working + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockRejectedValue(new Error('Network timeout')), + metaAndAssetCtxs: jest + .fn() + .mockRejectedValue(new Error('Network timeout')), + }), + ); + + MockedHyperLiquidClientService.mockImplementation( + () => mockClientService, + ); + + // Try to place an order which will trigger ensureReady -> buildAssetMapping + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, // Add price for validation + }; + + const result = await freshProvider.placeOrder(orderParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('Network timeout'); + }); + + it('handles string response from meta endpoint', async () => { + // metaAndAssetCtxs returns no valid meta so cache is not populated; buildAssetMapping leaves map empty + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue('invalid string response' as any), + metaAndAssetCtxs: jest.fn().mockResolvedValue([null, []]), // No valid meta -> no cache, no asset mapping + }), + ); + + mockWalletService.getUserAddressWithDefault.mockResolvedValue('0x123'); + + const updateParams = { + symbol: 'BTC', + takeProfitPrice: '55000', + }; + + const result = await provider.updatePositionTPSL(updateParams); + + expect(result.success).toBe(false); + // With no valid meta from metaAndAssetCtxs, asset mapping is empty so we fail with asset not found + expect( + result.error?.includes('Asset ID not found') || + result.error?.includes('Invalid meta response'), + ).toBe(true); + }); + + it('handles meta response without universe property', async () => { + // metaAndAssetCtxs returns no valid meta so cache is not populated; buildAssetMapping leaves map empty + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({}), // Empty object without universe + metaAndAssetCtxs: jest.fn().mockResolvedValue([null, []]), // No valid meta -> no cache, no asset mapping + }), + ); + + const updateParams = { + symbol: 'BTC', + takeProfitPrice: '55000', + }; + + const result = await provider.updatePositionTPSL(updateParams); + + expect(result.success).toBe(false); + // With no valid meta from metaAndAssetCtxs, asset mapping is empty so we fail with asset not found + expect( + result.error?.includes('Asset ID not found') || + result.error?.includes('Invalid meta response'), + ).toBe(true); + }); + }); + + describe('Order placement edge cases', () => { + it('handles leverage update failure', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + updateLeverage: jest.fn().mockResolvedValue({ + status: 'error', + response: { message: 'Leverage update failed' }, + }), + }), + ); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + leverage: 10, + currentPrice: 50000, + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('Failed to update leverage'); + }); + + it('succeeds with market order without current price or usdAmount (uses fetched price)', async () => { + // The provider now fetches the live price before validation so callers + // that intentionally omit currentPrice (e.g. flipPosition) work correctly. + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + // No currentPrice or usdAmount: provider fetches live price (50000) + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + expect(mockClientService.getExchangeClient().order).toHaveBeenCalled(); + }); + + it('placeOrder validates against fetched price when params omit currentPrice (flipPosition path)', async () => { + // Simulate the exact OrderParams shape that TradingService.flipPosition + // builds: symbol + isBuy + size + orderType + leverage, no price fields. + const flipOrderParams: OrderParams = { + symbol: 'BTC', + isBuy: false, // flipping long → short + size: '1', // 2× the 0.5 BTC position + orderType: 'market', + leverage: 10, + // currentPrice, usdAmount, price intentionally absent + }; + + const result = await provider.placeOrder(flipOrderParams); + + // Live price (50000) is fetched from allMids → validation passes + // (0.1 BTC × $50 000 = $5 000 >> $10 minimum) → order executes. + expect(result.success).toBe(true); + expect( + mockClientService.getExchangeClient().order, + ).toHaveBeenCalledWith( + expect.objectContaining({ orders: expect.any(Array) }), + ); + }); + + it('handles order with custom slippage', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + slippage: 0.02, // 2% slippage + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + // Should use 2% slippage instead of default 1% + }); + + it('handles filled order response', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + { + filled: { + oid: '456', + totalSz: '0.1', + avgPx: '50100', + }, + }, + ], + }, + }, + }), + }), + ); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + expect(result.orderId).toBe('456'); + expect(result.filledSize).toBe('0.1'); + expect(result.averagePrice).toBe('50100'); + }); + + it('handles order with clientOrderId', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + clientOrderId: '0x123abc', + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + }); + + it('handles order with TP/SL and custom grouping', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'limit', + price: '51000', + takeProfitPrice: '55000', + stopLossPrice: '48000', + grouping: 'positionTpsl', + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + }); + }); + + describe('updatePositionTPSL error scenarios', () => { + it('handles WebSocket error in getPositions', async () => { + // Set up mock BEFORE creating fresh provider (provider calls metaAndAssetCtxs on init) + MockedHyperLiquidClientService.mockImplementation( + () => mockClientService, + ); + + // Create a fresh provider to test WebSocket errors + const freshProvider = createTestProvider(); + + // Mock getPositions to simulate the WebSocket error being handled + jest + .spyOn(freshProvider, 'getPositions') + .mockImplementation(async () => { + throw new Error('WebSocket connection failed'); + }); + + const updateParams = { + symbol: 'BTC', + takeProfitPrice: '55000', + }; + + const result = await freshProvider.updatePositionTPSL(updateParams); + + expect(result.success).toBe(false); + expect(result.error).toBe('WebSocket connection failed'); + }); + + it('handles non-WebSocket error in getPositions', async () => { + // Set up mock BEFORE creating fresh provider (provider calls metaAndAssetCtxs on init) + MockedHyperLiquidClientService.mockImplementation( + () => mockClientService, + ); + + // Create a fresh provider to test non-WebSocket errors + const freshProvider = createTestProvider(); + + // Mock getPositions to simulate a generic API error + jest + .spyOn(freshProvider, 'getPositions') + .mockImplementation(async () => { + throw new Error('Generic API error'); + }); + + const updateParams = { + symbol: 'BTC', + takeProfitPrice: '55000', + }; + + const result = await freshProvider.updatePositionTPSL(updateParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('Generic API error'); + }); + + it('handles canceling existing TP/SL orders', async () => { + // Create provider with BTC in the asset mapping + provider = createTestProvider({ + initialAssetMapping: [['BTC', 0]], + }); + + // Mock position exists with existing TP/SL orders + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'BTC', + oid: 123, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + orderType: 'Take Profit', + }, + { + coin: 'BTC', + oid: 124, + reduceOnly: true, + isTrigger: true, + isPositionTpsl: true, + orderType: 'Stop Loss', + }, + ]), + }), + ); + + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success', 'success'] } }, + }), + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: '999' } }] } }, + }), + }), + ); + + mockWalletService.getUserAddressWithDefault.mockResolvedValue('0x123'); + + const updateParams = { + symbol: 'BTC', + takeProfitPrice: '55000', + }; + + const result = await provider.updatePositionTPSL(updateParams); + + expect(result.success).toBe(true); + expect( + mockClientService.getExchangeClient().cancel, + ).toHaveBeenCalledWith({ + cancels: [ + { a: 0, o: 123 }, + { a: 0, o: 124 }, + ], + }); + }); + + it('cache path: only cancels positionTpsl orders, not normalTpsl children of limit orders', async () => { + provider = createTestProvider({ + initialAssetMapping: [['BTC', 0]], + }); + + // Simulate WS cache with mixed orders: normalTpsl children (isPositionTpsl: false) + // from a pending limit order AND positionTpsl orders (isPositionTpsl: true) + const cachedOrders: Order[] = [ + { + orderId: '500', + symbol: 'BTC', + side: 'buy', + orderType: 'limit', + size: '0.01', + originalSize: '0.01', + price: '50000', + filledSize: '0', + remainingSize: '0.01', + status: 'open', + timestamp: 1000, + isTrigger: false, + reduceOnly: false, + isPositionTpsl: false, + }, + { + orderId: '501', + symbol: 'BTC', + side: 'sell', + orderType: 'limit', + size: '0.01', + originalSize: '0.01', + price: '60000', + filledSize: '0', + remainingSize: '0.01', + status: 'open', + timestamp: 1001, + isTrigger: true, + reduceOnly: true, + isPositionTpsl: false, + detailedOrderType: 'Take Profit Limit', + }, + { + orderId: '502', + symbol: 'BTC', + side: 'sell', + orderType: 'market', + size: '0.01', + originalSize: '0.01', + price: '40000', + filledSize: '0', + remainingSize: '0.01', + status: 'open', + timestamp: 1002, + isTrigger: true, + reduceOnly: true, + isPositionTpsl: false, + detailedOrderType: 'Stop Market', + }, + { + orderId: '503', + symbol: 'BTC', + side: 'sell', + orderType: 'limit', + size: '0', + originalSize: '0', + price: '58000', + filledSize: '0', + remainingSize: '0', + status: 'open', + timestamp: 1003, + isTrigger: true, + reduceOnly: true, + isPositionTpsl: true, + detailedOrderType: 'Take Profit Limit', + }, + { + orderId: '504', + symbol: 'BTC', + side: 'sell', + orderType: 'market', + size: '0', + originalSize: '0', + price: '42000', + filledSize: '0', + remainingSize: '0', + status: 'open', + timestamp: 1004, + isTrigger: true, + reduceOnly: true, + isPositionTpsl: true, + detailedOrderType: 'Stop Market', + }, + ]; + + mockSubscriptionService.getOrdersCacheIfInitialized = jest + .fn() + .mockReturnValue(cachedOrders); + + const mockCancel = jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['success', 'success'] } }, + }); + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + cancel: mockCancel, + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { statuses: [{ resting: { oid: '999' } }] }, + }, + }), + }), + ); + + mockWalletService.getUserAddressWithDefault.mockResolvedValue('0x123'); + + const result = await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '60000', + stopLossPrice: '40000', + }); + + expect(result.success).toBe(true); + // Must cancel positionTpsl orders (503, 504) only — not normalTpsl children (501, 502) + expect(mockCancel).toHaveBeenCalledWith({ + cancels: [ + { a: 0, o: 503 }, + { a: 0, o: 504 }, + ], + }); + }); + }); + + describe('getAccountState error handling', () => { + it('re-throws errors instead of returning zeros', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + clearinghouseState: jest + .fn() + .mockRejectedValue(new Error('Account state fetch failed')), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', hold: '1000', total: '10000' }], + }), + }), + ); + + mockWalletService.getUserAddressWithDefault.mockResolvedValue('0x123'); + + await expect(provider.getAccountState()).rejects.toThrow( + 'Failed to fetch account state (failedDexs=[main], spotError=none)', + ); + }); + + it('returns partial account state when one HIP-3 DEX fails', async () => { + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + }); + const mockInfoClient = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + clearinghouseState: jest + .fn() + .mockImplementation((params?: { dex?: string }) => { + if (params?.dex === 'xyz') { + return Promise.reject(new Error('xyz DEX unavailable')); + } + + return Promise.resolve({ + marginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [], + crossMarginSummary: { + accountValue: '10500', + totalMarginUsed: '500', + }, + }); + }), + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', hold: '1000', total: '10000' }], + }), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + mockWalletService.getUserAddressWithDefault.mockResolvedValue('0x123'); + + const accountState = await hip3Provider.getAccountState(); + + expect(parseFloat(accountState.totalBalance)).toBe(19500); // perps 10500 + spot.total 10000 - spot.hold 1000 + expect(parseFloat(accountState.marginUsed)).toBe(500); + expect(mockInfoClient.clearinghouseState).toHaveBeenCalledWith({ + user: '0x123', + dex: 'xyz', + }); + }); + }); + + describe('getMarketDataWithPrices error scenarios', () => { + it('handles missing perpsMeta', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue(null), + allMids: jest.fn().mockResolvedValue({ BTC: '50000' }), + predictedFundings: jest.fn().mockResolvedValue([]), + metaAndAssetCtxs: jest.fn().mockResolvedValue([null, []]), + }), + ); + + await expect(provider.getMarketDataWithPrices()).rejects.toThrow( + /Failed to fetch market data - no markets available/, + ); + }); + + it('uses HTTP InfoClient for market data fetches', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + allMids: jest.fn().mockResolvedValue({ BTC: '50000' }), + predictedFundings: jest.fn().mockResolvedValue([]), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }] }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + ], + ]), + }), + ); + + const freshProvider = createTestProvider(); + await freshProvider.getMarketDataWithPrices(); + + expect(mockClientService.getInfoClient).toHaveBeenCalledWith({ + useHttp: true, + }); + }); + + it('prefers the last WebSocket allMids snapshot over REST when available', async () => { + const mockInfoClient = createMockInfoClient({ + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }] }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + ], + ]), + allMids: jest.fn().mockResolvedValue({ BTC: '49999' }), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + mockSubscriptionService.getLastAllMidsSnapshot.mockReturnValue({ + BTC: '51000', + }); + + const freshProvider = createTestProvider(); + const result = await freshProvider.getMarketDataWithPrices(); + + expect(result[0].price).toBe('$51000.00'); + expect(mockInfoClient.allMids).not.toHaveBeenCalled(); + }); + + it('includes diagnostic context in error when all DEX fetches fail', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + metaAndAssetCtxs: jest + .fn() + .mockRejectedValue(new Error('WebSocket timeout')), + allMids: jest + .fn() + .mockRejectedValue(new Error('WebSocket timeout')), + }), + ); + + await expect(provider.getMarketDataWithPrices()).rejects.toThrow( + /enabledDexs=.*failed=.*wsState=/, + ); + }); + + it('handles missing allMids', async () => { + // Set up mock BEFORE creating fresh provider + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + allMids: jest.fn().mockResolvedValue(null), + predictedFundings: jest.fn().mockResolvedValue([]), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }] }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + ], + ]), + }), + ); + + // Create fresh provider to avoid cached state from other tests + const freshProvider = createTestProvider(); + + // Should gracefully handle missing price data with fallback + const result = await freshProvider.getMarketDataWithPrices(); + expect(Array.isArray(result)).toBe(true); + expect(result[0].price).toBe('$---'); // Fallback when allMids is null + }); + + it('handles meta and predictedFundings calls successfully', async () => { + // Set up mock BEFORE creating fresh provider + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + allMids: jest.fn().mockResolvedValue({ BTC: '50000' }), + predictedFundings: jest.fn().mockResolvedValue([]), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }] }, + [ + { + funding: '0.001', + openInterest: '1000000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + ], + ]), + }), + ); + + // Create fresh provider to avoid cached state from other tests + const freshProvider = createTestProvider(); + + const result = await freshProvider.getMarketDataWithPrices(); + + // Verify successful call with proper data structure + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + expect(result[0]).toHaveProperty('name'); + expect(result[0]).toHaveProperty('price'); + expect(result[0]).toHaveProperty('fundingRate'); + }); + + it('returns stale cached market data after retry failure', async () => { + jest.useFakeTimers(); + + try { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + allMids: jest.fn().mockResolvedValue({ BTC: '50000' }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }] }, + [ + { + funding: '0.001', + openInterest: '1000000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + ], + ]), + }), + ); + + const freshProvider = createTestProvider(); + const freshMarketData = await freshProvider.getMarketDataWithPrices(); + + expect(freshMarketData[0].isStale).toBe(false); + + await freshProvider.disconnect(); + + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + metaAndAssetCtxs: jest + .fn() + .mockRejectedValue(new Error('market data unavailable')), + allMids: jest + .fn() + .mockRejectedValue(new Error('market data unavailable')), + }), + ); + + const staleMarketDataPromise = + freshProvider.getMarketDataWithPrices(); + await jest.advanceTimersByTimeAsync(2000); + const staleMarketData = await staleMarketDataPromise; + + expect(staleMarketData[0].symbol).toBe(freshMarketData[0].symbol); + expect(staleMarketData[0].isStale).toBe(true); + } finally { + jest.useRealTimers(); + } + }); + + it('recovers on retry when cached meta refresh initially returns mismatched asset contexts', async () => { + jest.useFakeTimers(); + + try { + const mainMeta = { + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }; + const mainAssetCtx = { + funding: '0.001', + openInterest: '1000000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }; + let metaAndAssetCtxsCallCount = 0; + + const mockInfoClient = createMockInfoClient({ + metaAndAssetCtxs: jest.fn().mockImplementation(() => { + metaAndAssetCtxsCallCount += 1; + + if (metaAndAssetCtxsCallCount === 1) { + return Promise.resolve([mainMeta, [mainAssetCtx]]); + } + + if (metaAndAssetCtxsCallCount === 2) { + return Promise.resolve([mainMeta, []]); + } + + return Promise.resolve([mainMeta, [mainAssetCtx]]); + }), + allMids: jest.fn().mockResolvedValue({ BTC: '50000' }), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + mockSubscriptionService.getDexAssetCtxsCache.mockReturnValue([]); + + const freshProvider = createTestProvider(); + const resultPromise = freshProvider.getMarketDataWithPrices(); + + await jest.advanceTimersByTimeAsync(2000); + + const result = await resultPromise; + + expect(result).toEqual([ + expect.objectContaining({ + symbol: 'BTC', + price: '$50000.00', + isStale: false, + }), + ]); + expect(metaAndAssetCtxsCallCount).toBe(3); + expect(mockInfoClient.allMids).toHaveBeenCalledTimes(1); + expect(mockPlatformDependencies.debugLogger.log).toHaveBeenCalledWith( + '[getMarketDataWithPrices] Retry succeeded', + expect.objectContaining({ + marketCount: 1, + }), + ); + } finally { + jest.useRealTimers(); + } + }); + + it('excludes a cached-meta DEX when assetCtx refresh fails and no aligned ctx cache exists', async () => { + const xyzMeta = { + universe: [{ name: 'xyz:XYZ100', szDecimals: 2, maxLeverage: 20 }], + }; + const xyzAssetCtx = { + funding: '0.0002', + openInterest: '250', + prevDayPx: '40', + dayNtlVlm: '20000', + markPx: '42', + midPx: '42', + oraclePx: '42', + }; + const mainMeta = { + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }; + const mainAssetCtx = { + funding: '0.001', + openInterest: '1000000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }; + + const xyzMetaFetches = { count: 0 }; + const mockInfoClient = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + metaAndAssetCtxs: jest + .fn() + .mockImplementation((params?: { dex?: string }) => { + if (params?.dex === 'xyz') { + xyzMetaFetches.count += 1; + if (xyzMetaFetches.count === 1) { + return Promise.resolve([xyzMeta, [xyzAssetCtx]]); + } + + return Promise.reject( + new Error('xyz assetCtxs refresh unavailable'), + ); + } + + return Promise.resolve([mainMeta, [mainAssetCtx]]); + }), + allMids: jest.fn().mockImplementation((params?: { dex?: string }) => { + if (params?.dex === 'xyz') { + return Promise.resolve({ 'xyz:XYZ100': '42' }); + } + + return Promise.resolve({ BTC: '50000' }); + }), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + }); + + const result = await hip3Provider.getMarketDataWithPrices(); + + expect(result.map((market) => market.symbol)).toEqual(['BTC']); + expect(mockInfoClient.metaAndAssetCtxs).toHaveBeenCalledWith({ + dex: 'xyz', + }); + }); + }); + + describe('withdrawal edge cases', () => { + it('handles withdrawal without destination (use current user)', async () => { + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + '0xdefaultaddress', + ); + + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ + withdraw3: jest.fn().mockResolvedValue({ status: 'ok' }), + }); + + // Mock account state for balance validation + Object.defineProperty(provider, 'getAccountState', { + value: jest.fn().mockResolvedValue({ + spendableBalance: '5000', + withdrawableBalance: '5000', + }), + writable: true, + }); + + const withdrawParams = { + amount: '1000', + // No destination provided - should use current user address + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default' as CaipAssetId, + }; + + const result = await provider.withdraw(withdrawParams); + + expect(result.success).toBe(true); + expect( + mockClientService.getExchangeClient().withdraw3, + ).toHaveBeenCalledWith({ + destination: '0xdefaultaddress', + amount: '1000', + }); + }); + + it('validates withdrawal against withdrawableBalance populated by spot fold for Unified Account', async () => { + const exchangeClient = createMockExchangeClient(); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(exchangeClient); + + Object.defineProperty(provider, 'getAccountState', { + value: jest.fn().mockResolvedValue({ + spendableBalance: '2500', + withdrawableBalance: '2500', + totalBalance: '2500', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }), + writable: true, + }); + + const withdrawParams = { + amount: '1000', + destination: '0x1234567890123456789012345678901234567890' as Hex, + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default' as CaipAssetId, + }; + + const result = await provider.withdraw(withdrawParams); + + expect(result.success).toBe(true); + expect(mockValidateBalance).toHaveBeenCalledWith(1000, 2500); + expect(exchangeClient.withdraw3).toHaveBeenCalledWith({ + destination: '0x1234567890123456789012345678901234567890', + amount: '1000', + }); + }); + + it('handles withdrawal API error', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ + withdraw3: jest.fn().mockResolvedValue({ + status: 'insufficient_funds', + message: 'Not enough balance', + }), + }); + + // Mock account state for balance validation + Object.defineProperty(provider, 'getAccountState', { + value: jest.fn().mockResolvedValue({ + spendableBalance: '5000', + withdrawableBalance: '5000', + }), + writable: true, + }); + + const withdrawParams = { + amount: '1000', + destination: '0x1234567890123456789012345678901234567890' as Hex, + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default' as CaipAssetId, + }; + + const result = await provider.withdraw(withdrawParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('Withdrawal failed: insufficient_funds'); + }); + }); + + describe('liquidation price edge cases', () => { + it('handles denominator close to zero', async () => { + // Create scenario where denominator approaches zero + // For denominator = 1 - l * side to be close to 0 with long (side = 1): + // We need l very close to 1, so maintenanceLeverage very close to 1 + // With maxLeverage = 0.50005, maintenanceLeverage = 1.0001, l = 0.9999 + // denominator = 1 - 0.9999 * 1 = 0.0001 (right at the threshold) + // Need slightly larger to go below 0.0001: maxLeverage = 0.50001 → maintenanceLeverage = 1.00002 + // l = 0.99998, denominator = 0.00002 < 0.0001 ✓ triggers edge case + const params = { + entryPrice: 50000, + leverage: 1, // Use 1x leverage + direction: 'long' as const, + asset: 'BTC', + }; + + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 0.50001 }], // Very low to create denominator < 0.0001 + }), + }), + ); + + const result = await provider.calculateLiquidationPrice(params); + + // Should return entry price when denominator is too small (< 0.0001 threshold) + expect(parseFloat(result)).toBeCloseTo(50000, 0); + }); + + it('handles liquidation price calculation error', async () => { + // Mock getMaxLeverage to throw an error but still use default + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + meta: jest.fn().mockRejectedValue(new Error('Network error')), + }); + + const params = { + entryPrice: 50000, + leverage: 2, + direction: 'long' as const, + asset: 'UNKNOWN_ASSET', + }; + + const result = await provider.calculateLiquidationPrice(params); + + // Should use default leverage and still calculate + expect(parseFloat(result)).toBeGreaterThan(0); + + consoleSpy.mockRestore(); + }); + + it('handles negative liquidation price', async () => { + // Create scenario that might result in negative liquidation price + const params = { + entryPrice: 100, + leverage: 2, + direction: 'long' as const, + asset: 'BTC', + }; + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(createMockInfoClient()); + + const result = await provider.calculateLiquidationPrice(params); + + // Should never return negative price + expect(parseFloat(result)).toBeGreaterThanOrEqual(0); + }); + }); + + describe('isReadyToTrade edge cases', () => { + it('handles getCurrentAccountId throwing error', async () => { + mockWalletService.getCurrentAccountId.mockImplementation(() => { + throw new Error('No account found'); + }); + + const result = await provider.isReadyToTrade(); + + expect(result.ready).toBe(false); + expect(result.walletConnected).toBe(true); // Clients exist + expect(result.networkSupported).toBe(true); + }); + + it('handles missing exchange or info client', async () => { + mockClientService.getExchangeClient.mockReturnValue(null as any); + + const result = await provider.isReadyToTrade(); + + expect(result.ready).toBe(false); + expect(result.walletConnected).toBe(false); + }); + + it('handles general error in readiness check', async () => { + mockClientService.getExchangeClient.mockImplementation(() => { + throw new Error('Client error'); + }); + + const result = await provider.isReadyToTrade(); + + expect(result.ready).toBe(false); + expect(result.walletConnected).toBe(false); + expect(result.networkSupported).toBe(false); + expect(result.error).toContain('Client error'); + }); + }); + + describe('editOrder error scenarios', () => { + it('handles edit order API failure', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ + modify: jest.fn().mockResolvedValue({ + status: 'error', + response: { message: 'Order not found' }, + }), + }); + + const editParams = { + orderId: '999', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.2', + price: '52000', + orderType: 'limit', + } as OrderParams, + }; + + const result = await provider.editOrder(editParams); + + expect(result.success).toBe(false); + }); + }); + + describe('cancelOrder error scenarios', () => { + it('handles cancel order API returning non-success status', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ + cancel: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: ['failed'] } }, + }), + }); + + const cancelParams = { + orderId: '123', + symbol: 'BTC', + }; + + const result = await provider.cancelOrder(cancelParams); + + expect(result.success).toBe(false); + expect(result.error).toBe('Order cancellation failed'); + }); + }); + + describe('calculateFees', () => { + beforeEach(() => { + // Reset userFees mock for each test + (mockClientService.getInfoClient().userFees as jest.Mock).mockClear(); + // Default to throw error (will use base rates) + mockWalletService.getUserAddressWithDefault.mockRejectedValue( + new Error('No wallet connected'), + ); + }); + + it('calculates fees for market orders', async () => { + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + expect(result.feeRate).toBe(0.00145); // 0.045% taker + 0.1% MetaMask fee + expect(result.feeAmount).toBe(145); // 100000 * 0.00145 + }); + + it('calculates fees for limit orders as taker', async () => { + const result = await provider.calculateFees({ + orderType: 'limit', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + expect(result.feeRate).toBe(0.00145); // 0.045% taker + 0.1% MetaMask fee + expect(result.feeAmount).toBe(145); // Includes MetaMask fee + }); + + it('calculates fees for limit orders as maker', async () => { + const result = await provider.calculateFees({ + orderType: 'limit', + isMaker: true, + amount: '100000', + symbol: 'BTC', + }); + + expect(result.feeRate).toBe(0.00115); // 0.015% maker + 0.1% MetaMask fee + expect(result.feeAmount).toBeCloseTo(115, 10); // Includes MetaMask fee + }); + + it('handles zero amount', async () => { + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '0', + symbol: 'BTC', + }); + + expect(result.feeRate).toBe(0.00145); // Includes 0.1% MetaMask fee + expect(result.feeAmount).toBe(0); + }); + + it('handles undefined amount', async () => { + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + symbol: 'BTC', + }); + + expect(result.feeRate).toBe(0.00145); // Includes 0.1% MetaMask fee + expect(result.feeAmount).toBeUndefined(); + }); + + it('uses cached user-specific fee rates when available', async () => { + // Reset mock and set user address to trigger user fee fetching + (mockClientService.getInfoClient().userFees as jest.Mock).mockClear(); + ( + mockClientService.getInfoClient().userFees as jest.Mock + ).mockResolvedValue({ + userCrossRate: '0.00045', // 0.045% base taker rate + userAddRate: '0.00015', // 0.015% base maker rate + userSpotCrossRate: '0.00070', // 0.070% spot taker rate + userSpotAddRate: '0.00040', // 0.040% spot maker rate + activeReferralDiscount: '0.04', // 4% referral discount + activeStakingDiscount: { discount: '0.05' }, // 5% staking discount + dailyUserVlm: [], + }); + mockWalletService.getUserAddressWithDefault.mockResolvedValue('0x123'); + + // First call should fetch from API + const result1 = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + // Should use dynamically calculated rate: 0.045% * (1 - 0.04 - 0.05) = 0.045% * 0.91 = 0.04095% + expect(result1.feeRate).toBeCloseTo(0.0014095, 6); // Dynamic rate + 0.1% MetaMask + expect(result1.feeAmount).toBeCloseTo(140.95, 2); // 100000 * 0.0014095 + expect( + mockClientService.getInfoClient().userFees, + ).toHaveBeenCalledTimes(1); + + // Second call should use cache + const result2 = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + expect(result2.feeRate).toBeCloseTo(0.0014095, 6); // Includes MetaMask fee + expect(result2.feeAmount).toBeCloseTo(140.95, 2); // Includes MetaMask fee + // Should not call API again (cached) + expect( + mockClientService.getInfoClient().userFees, + ).toHaveBeenCalledTimes(1); + }); + + it('falls back to base rates on API failure', async () => { + // Reset and mock user address + (mockClientService.getInfoClient().userFees as jest.Mock).mockClear(); + mockWalletService.getUserAddressWithDefault.mockResolvedValue('0x123'); + + // Mock API failure + ( + mockClientService.getInfoClient().userFees as jest.Mock + ).mockRejectedValue(new Error('API Error')); + + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + // Should use base rates on failure + expect(result.feeRate).toBe(0.00145); // Includes 0.1% MetaMask fee // Base taker rate + expect(result.feeAmount).toBe(145); // Includes MetaMask fee + }); + + it('handles non-numeric amount gracefully', async () => { + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: 'invalid', + symbol: 'BTC', + }); + + expect(result.feeRate).toBe(0.00145); // Includes 0.1% MetaMask fee + expect(result.feeAmount).toBe(0); // parseFloat('invalid') returns NaN, which * 0.00045 = NaN, but we expect 0 + }); + + it('returns FeeCalculationResult with correct structure', async () => { + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + expect(result).toHaveProperty('feeRate'); + expect(result).toHaveProperty('feeAmount'); + expect(typeof result.feeRate).toBe('number'); + expect(typeof result.feeAmount).toBe('number'); + }); + + it('is async and return a Promise', () => { + const result = provider.calculateFees({ + orderType: 'market', + isMaker: false, + symbol: 'BTC', + }); + + expect(result).toBeInstanceOf(Promise); + }); + + it('fetches user-specific fee rates when wallet is connected', async () => { + const testAddress = '0xTestAddress123'; + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + testAddress, + ); + + // Mock user fees API response with base rates and discounts + ( + mockClientService.getInfoClient().userFees as jest.Mock + ).mockResolvedValue({ + userCrossRate: '0.00045', // 0.045% base taker rate + userAddRate: '0.00015', // 0.015% base maker rate + userSpotCrossRate: '0.00070', // 0.070% spot taker rate + userSpotAddRate: '0.00040', // 0.040% spot maker rate + activeReferralDiscount: '0.04', // 4% referral discount + activeStakingDiscount: null, // No staking discount + }); + + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + expect(result.feeRate).toBeCloseTo(0.001432, 6); // 0.045% * (1 - 0.04) + 0.1% MetaMask + expect(result.feeAmount).toBeCloseTo(143.2, 2); // Includes MetaMask fee + }); + + it('falls back to base rates when API returns invalid fee rates', async () => { + const testAddress = '0xTestAddress123'; + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + testAddress, + ); + + // Mock user fees API response with invalid rates that will produce NaN + ( + mockClientService.getInfoClient().userFees as jest.Mock + ).mockResolvedValue({ + userCrossRate: 'invalid', // Will cause parseFloat to return NaN + userAddRate: 'invalid', + activeReferralDiscount: 'invalid', + activeStakingDiscount: null, + }); + + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + // Should fall back to base rates due to validation failure + expect(result.feeRate).toBe(0.00145); // Includes 0.1% MetaMask fee // Base taker rate + expect(result.feeAmount).toBe(145); // Includes MetaMask fee + }); + + it('falls back to base rates when API returns negative fee rates', async () => { + const testAddress = '0xTestAddress123'; + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + testAddress, + ); + + // Mock user fees API response with negative rates + ( + mockClientService.getInfoClient().userFees as jest.Mock + ).mockResolvedValue({ + userCrossRate: '-0.0003', // Negative rate - invalid + userAddRate: '0.0001', + activeReferralDiscount: '0.00', + activeStakingDiscount: null, + }); + + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + // Should fall back to base rates due to validation failure + expect(result.feeRate).toBe(0.00145); // Includes 0.1% MetaMask fee // Base taker rate + expect(result.feeAmount).toBe(145); // Includes MetaMask fee + }); + + it('always uses taker rate for market orders regardless of isMaker', async () => { + const testAddress = '0xTestAddress123'; + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + testAddress, + ); + + ( + mockClientService.getInfoClient().userFees as jest.Mock + ).mockResolvedValue({ + userCrossRate: '0.00035', // Taker rate + userAddRate: '0.00008', // Maker rate (lower) + userSpotCrossRate: '0.00070', + userSpotAddRate: '0.00040', + activeReferralDiscount: '0.04', // 4% referral discount + activeStakingDiscount: null, + }); + + // Test market order with isMaker=true (should still use taker rate) + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: true, // This should be ignored for market orders + amount: '100000', + symbol: 'BTC', + }); + + // Should use taker rate even though isMaker is true + expect(result.feeRate).toBeCloseTo(0.001336, 6); // 0.035% * (1 - 0.04) + 0.1% MetaMask + expect(result.feeAmount).toBeCloseTo(133.6, 2); // Includes MetaMask fee + }); + + it('applies referral discount only when no staking discount', async () => { + const testAddress = '0xTestAddress123'; + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + testAddress, + ); + + ( + mockClientService.getInfoClient().userFees as jest.Mock + ).mockResolvedValue({ + userCrossRate: '0.00045', // 0.045% base taker rate + userAddRate: '0.00015', // 0.015% base maker rate + userSpotCrossRate: '0.00070', // 0.070% spot taker rate + userSpotAddRate: '0.00040', // 0.040% spot maker rate + activeReferralDiscount: '0.04', // 4% referral discount + activeStakingDiscount: null, + }); + + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + // Should apply only referral discount: 0.045% * (1 - 0.04) = 0.0432% + expect(result.feeRate).toBeCloseTo(0.001432, 6); // 0.0432% + 0.1% MetaMask + expect(result.feeAmount).toBeCloseTo(143.2, 2); + }); + + it('applies staking discount only when no referral discount', async () => { + const testAddress = '0xTestAddress123'; + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + testAddress, + ); + + ( + mockClientService.getInfoClient().userFees as jest.Mock + ).mockResolvedValue({ + userCrossRate: '0.00045', // 0.045% base taker rate + userAddRate: '0.00015', // 0.015% base maker rate + userSpotCrossRate: '0.00070', // 0.070% spot taker rate + userSpotAddRate: '0.00040', // 0.040% spot maker rate + activeReferralDiscount: null, + activeStakingDiscount: { discount: '0.10' }, // 10% staking discount + }); + + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + // Should apply only staking discount: 0.045% * (1 - 0.10) = 0.0405% + expect(result.feeRate).toBeCloseTo(0.001405, 6); // 0.0405% + 0.1% MetaMask + expect(result.feeAmount).toBeCloseTo(140.5, 2); + }); + + it('caps combined discounts at 40%', async () => { + const testAddress = '0xTestAddress123'; + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + testAddress, + ); + + ( + mockClientService.getInfoClient().userFees as jest.Mock + ).mockResolvedValue({ + userCrossRate: '0.00045', // 0.045% base taker rate + userAddRate: '0.00015', // 0.015% base maker rate + userSpotCrossRate: '0.00070', // 0.070% spot taker rate + userSpotAddRate: '0.00040', // 0.040% spot maker rate + activeReferralDiscount: '0.30', // 30% referral discount + activeStakingDiscount: { discount: '0.25' }, // 25% staking discount + }); + + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + // Combined discounts would be 55%, but capped at 40% + // 0.045% * (1 - 0.40) = 0.027% + expect(result.feeRate).toBeCloseTo(0.00127, 6); // 0.027% + 0.1% MetaMask + expect(result.feeAmount).toBeCloseTo(127.0, 2); + }); + + it('handles maker rates with discounts correctly', async () => { + const testAddress = '0xTestAddress123'; + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + testAddress, + ); + + ( + mockClientService.getInfoClient().userFees as jest.Mock + ).mockResolvedValue({ + userCrossRate: '0.00045', // 0.045% base taker rate + userAddRate: '0.00015', // 0.015% base maker rate + userSpotCrossRate: '0.00070', // 0.070% spot taker rate + userSpotAddRate: '0.00040', // 0.040% spot maker rate + activeReferralDiscount: '0.04', // 4% referral discount + activeStakingDiscount: { discount: '0.05' }, // 5% staking discount + }); + + const result = await provider.calculateFees({ + orderType: 'limit', + isMaker: true, + amount: '100000', + symbol: 'BTC', + }); + + // Should apply discounts to maker rate: 0.015% * (1 - 0.04 - 0.05) = 0.01365% + expect(result.feeRate).toBeCloseTo(0.0011365, 6); // 0.01365% + 0.1% MetaMask + expect(result.feeAmount).toBeCloseTo(113.65, 2); + }); + + it('handles zero discounts correctly', async () => { + const testAddress = '0xTestAddress123'; + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + testAddress, + ); + + ( + mockClientService.getInfoClient().userFees as jest.Mock + ).mockResolvedValue({ + userCrossRate: '0.00045', // 0.045% base taker rate + userAddRate: '0.00015', // 0.015% base maker rate + userSpotCrossRate: '0.00070', // 0.070% spot taker rate + userSpotAddRate: '0.00040', // 0.040% spot maker rate + activeReferralDiscount: '0.00', // No referral discount + activeStakingDiscount: { discount: '0.00' }, // No staking discount + }); + + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + // Should use base rates without discounts + expect(result.feeRate).toBe(0.00145); // 0.045% + 0.1% MetaMask + expect(result.feeAmount).toBe(145); + }); + + it('applies 2× fee multiplier for HIP-3 assets', async () => { + // HIP-3 asset (dex:SYMBOL format) + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'xyz:TSLA', // HIP-3 asset + }); + + // HIP-3 should have 2× base fees: 0.045% * 2 = 0.09% + 0.1% MetaMask = 0.19% + expect(result.feeRate).toBe(0.0019); // 0.09% taker + 0.1% MetaMask fee + expect(result.feeAmount).toBe(190); // 100000 * 0.0019 + }); + + it('applies 2× fee multiplier for HIP-3 maker orders', async () => { + // HIP-3 asset (dex:SYMBOL format) + const result = await provider.calculateFees({ + orderType: 'limit', + isMaker: true, + amount: '100000', + symbol: 'abc:SPX', // HIP-3 asset + }); + + // HIP-3 should have 2× base fees: 0.015% * 2 = 0.03% + 0.1% MetaMask = 0.13% + expect(result.feeRate).toBe(0.0013); // 0.03% maker + 0.1% MetaMask fee + expect(result.feeAmount).toBe(130); // 100000 * 0.0013 + }); + }); + + describe('fee discount functionality', () => { + describe('setUserFeeDiscount', () => { + it('logs discount context updates', () => { + // Arrange + const discountBips = 3000; // 30% in basis points + (mockPlatformDependencies.debugLogger.log as jest.Mock).mockClear(); + + // Act + provider.setUserFeeDiscount(discountBips); + + // Assert + expect(mockPlatformDependencies.debugLogger.log).toHaveBeenCalledWith( + 'HyperLiquid: Fee discount context updated', + { + discountBips, + discountPercentage: 30, + isActive: true, + }, + ); + }); + + it('logs when clearing discount context', () => { + // Arrange + (mockPlatformDependencies.debugLogger.log as jest.Mock).mockClear(); + + // Act + provider.setUserFeeDiscount(undefined); + + // Assert + expect(mockPlatformDependencies.debugLogger.log).toHaveBeenCalledWith( + 'HyperLiquid: Fee discount context updated', + { + discountBips: undefined, + discountPercentage: undefined, + isActive: false, + }, + ); + }); + }); + + describe('discount applied to orders', () => { + it('applies discount to builder fee in placeOrder', async () => { + // Arrange: Set 65% discount (6500 basis points) + provider.setUserFeeDiscount(6500); + + // Act + await provider.placeOrder({ + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'market', + currentPrice: 50000, // Add price for validation + }); + + // Assert: Verify exchangeClient.order called with discounted fee + // 100 * (1 - 0.65) = 35 + expect( + mockClientService.getExchangeClient().order, + ).toHaveBeenCalledWith( + expect.objectContaining({ + builder: expect.objectContaining({ + f: 35, + }), + }), + ); + }); + + it('applies discount to builder fee in updatePositionTPSL', async () => { + // Arrange: Set 65% discount + provider.setUserFeeDiscount(6500); + + // Act + await provider.updatePositionTPSL({ + symbol: 'BTC', + takeProfitPrice: '50000', + }); + + // Assert: Verify discounted fee (35 instead of 100) + expect( + mockClientService.getExchangeClient().order, + ).toHaveBeenCalledWith( + expect.objectContaining({ + builder: expect.objectContaining({ + f: 35, + }), + }), + ); + }); + }); + + describe('calculateFees with fee discount', () => { + beforeEach(() => { + // Reset mocks for fee discount tests + (mockClientService.getInfoClient().userFees as jest.Mock).mockClear(); + mockWalletService.getUserAddressWithDefault.mockRejectedValue( + new Error('No wallet connected'), + ); + }); + + it('applies discount to MetaMask fees when active', async () => { + // Arrange + const discountBips = 2000; // 20% discount in basis points + provider.setUserFeeDiscount(discountBips); + + // Act + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + // Assert + // Base: 0.045% protocol + 0.1% MetaMask = 0.145% + // With 20% discount on MetaMask fee: 0.045% + (0.1% * 0.8) = 0.045% + 0.08% = 0.125% + expect(result.feeRate).toBe(0.00125); + expect(result.feeAmount).toBe(125); + }); + + it('applies discount to maker fees correctly', async () => { + // Arrange + const discountBips = 5000; // 50% discount in basis points + provider.setUserFeeDiscount(discountBips); + + // Act + const result = await provider.calculateFees({ + orderType: 'limit', + isMaker: true, + amount: '100000', + symbol: 'BTC', + }); + + // Assert + // Base: 0.015% protocol + 0.1% MetaMask = 0.115% + // With 50% discount on MetaMask fee: 0.015% + (0.1% * 0.5) = 0.015% + 0.05% = 0.065% + expect(result.feeRate).toBe(0.00065); + expect(result.feeAmount).toBe(65); + }); + + it('preserves protocol fees unchanged', async () => { + // Arrange + const discountBips = 10000; // 100% discount on MetaMask fees (in basis points) + provider.setUserFeeDiscount(discountBips); + + // Act + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + // Assert + // Should only have protocol fees: 0.045% + // MetaMask fee should be 0 with 100% discount + expect(result.feeRate).toBe(0.00045); + expect(result.feeAmount).toBe(45); + }); + + it('works without discount - backward compatibility', async () => { + // Arrange - no discount set + // provider.setUserFeeDiscount() not called + + // Act + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + // Assert + // Should have full fees: 0.045% + 0.1% = 0.145% + expect(result.feeRate).toBe(0.00145); + expect(result.feeAmount).toBe(145); + }); + + it('handles 0% discount edge case', async () => { + // Arrange + provider.setUserFeeDiscount(0); + + // Act + const result = await provider.calculateFees({ + orderType: 'limit', + isMaker: true, + amount: '100000', + symbol: 'BTC', + }); + + // Assert + // 0% discount means full MetaMask fee: 0.015% + 0.1% = 0.115% + expect(result.feeRate).toBe(0.00115); + expect(result.feeAmount).toBeCloseTo(115, 10); + }); + + it('combines discount with user staking discount', async () => { + // Arrange + const rewardsDiscountBips = 2000; // 20% MetaMask rewards discount in basis points + provider.setUserFeeDiscount(rewardsDiscountBips); + + // Clear fee cache to ensure fresh API call + provider.clearFeeCache(); + + // Reset and mock staking discount (override beforeEach) + mockWalletService.getUserAddressWithDefault.mockClear(); + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + '0x123', + ); + ( + mockClientService.getInfoClient().userFees as jest.Mock + ).mockResolvedValue({ + feeSchedule: { + fee: '0.03', // 0.03% protocol fee (better than base) + }, + activeStakingDiscount: { discount: '0.10' }, // 10% staking discount + }); + + // Act + const result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + + // Assert + // Note: If staking discount is not applied properly in test, it falls back to base rates + // Base protocol fee: 0.045% + MetaMask fee with rewards discount: 0.08% = 0.125% + // This test validates that the rewards discount is properly applied even when staking API is mocked + expect(result.feeRate).toBeCloseTo(0.00125, 5); + expect(result.feeAmount).toBeCloseTo(125, 0); + }); + + it('clears discount context after undefined is set', async () => { + // Arrange - first set a discount + provider.setUserFeeDiscount(2500); // 25% discount in basis points + + // Verify discount is applied + let result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + expect(result.feeRate).toBeCloseTo(0.0012, 5); // 0.045% + (0.1% * 0.75) + + // Act - clear discount + provider.setUserFeeDiscount(undefined); + + // Assert - should return to full fees + result = await provider.calculateFees({ + orderType: 'market', + isMaker: false, + amount: '100000', + symbol: 'BTC', + }); + expect(result.feeRate).toBe(0.00145); // Back to full fees + }); + }); + }); + + describe('getBlockExplorerUrl', () => { + it('returns mainnet explorer URL with address', () => { + const address = '0x1234567890abcdef1234567890abcdef12345678'; + const result = provider.getBlockExplorerUrl(address); + + expect(result).toBe( + `https://app.hyperliquid.xyz/explorer/address/${address}`, + ); + }); + + it('returns mainnet base explorer URL without address', () => { + const result = provider.getBlockExplorerUrl(); + + expect(result).toBe('https://app.hyperliquid.xyz/explorer'); + }); + + it('returns testnet explorer URL with address when in testnet mode', () => { + // Mock testnet mode + (mockClientService.isTestnetMode as jest.Mock).mockReturnValue(true); + + const address = '0xabcdef1234567890abcdef1234567890abcdef12'; + const result = provider.getBlockExplorerUrl(address); + + expect(result).toBe( + `https://app.hyperliquid-testnet.xyz/explorer/address/${address}`, + ); + }); + + it('returns testnet base explorer URL without address when in testnet mode', () => { + // Mock testnet mode + (mockClientService.isTestnetMode as jest.Mock).mockReturnValue(true); + + const result = provider.getBlockExplorerUrl(); + + expect(result).toBe('https://app.hyperliquid-testnet.xyz/explorer'); + }); + + it('handles empty string address', () => { + const result = provider.getBlockExplorerUrl(''); + + expect(result).toBe('https://app.hyperliquid.xyz/explorer'); + }); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.history.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.history.test.ts new file mode 100644 index 0000000000..91d4fc1b76 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.history.test.ts @@ -0,0 +1,1927 @@ +/* eslint-disable */ +jest.mock('@nktkas/hyperliquid', () => ({})); + +import type { CaipAssetId, Hex } from '@metamask/utils'; + +import { CandlePeriod } from '../../../src/constants/chartConfig'; +import { + BUILDER_FEE_CONFIG, + REFERRAL_CONFIG, +} from '../../../src/constants/hyperLiquidConfig'; +import { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from '../../../src/constants/transactionsHistoryConfig'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes'; +import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider'; +import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService'; +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService'; +import { TradingReadinessCache } from '../../../src/services/TradingReadinessCache'; +import type { + ClosePositionParams, + DepositParams, + Order, + PerpsPlatformDependencies, + LiveDataConfig, + OrderParams, +} from '../../../src/types'; +import { + validateAssetSupport, + validateBalance, + validateCoinExists, + validateDepositParams, + validateOrderParams, + validateWithdrawalParams, +} from '../../../src/utils/hyperLiquidValidation'; +import { createStandaloneInfoClient } from '../../../src/utils/standaloneInfoClient'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks'; + +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidWalletService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); +// Mock stream manager - will be set up in test +let mockStreamManagerInstance: any; +const mockGetStreamManagerInstance = jest.fn(() => mockStreamManagerInstance); +jest.mock( + '../../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: mockGetStreamManagerInstance, + }), + { virtual: true }, +); + +// Mock standalone info client for standalone mode tests +let mockStandaloneInfoClient: any; +jest.mock('../../../src/utils/standaloneInfoClient', () => ({ + ...jest.requireActual('../../../src/utils/standaloneInfoClient'), + createStandaloneInfoClient: jest.fn(() => mockStandaloneInfoClient), +})); + +jest.mock('../../../src/utils/hyperLiquidValidation', () => ({ + validateOrderParams: jest.fn(), + validateWithdrawalParams: jest.fn(), + validateDepositParams: jest.fn(), + validateCoinExists: jest.fn(), + validateAssetSupport: jest.fn(), + validateBalance: jest.fn(), + getSupportedPaths: jest + .fn() + .mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]), + getBridgeInfo: jest.fn().mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }), + createErrorResult: jest.fn((error, defaultResponse) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + })), +})); + +// Mock adapter functions +jest.mock('../../../src/utils/hyperLiquidAdapter', () => { + const actual = jest.requireActual('../../../src/utils/hyperLiquidAdapter'); + return { + ...actual, + adaptHyperLiquidLedgerUpdateToUserHistoryItem: jest.fn((updates) => { + // Return mock history items based on input + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map((_update: unknown) => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }), + }; +}); + +// Mock TradingReadinessCache - global singleton for signing operation caching +// Use jest.createMockFromModule for proper mock creation +jest.mock('../../../src/services/TradingReadinessCache'); + +const MockedHyperLiquidClientService = + HyperLiquidClientService as jest.MockedClass; +const MockedHyperLiquidWalletService = + HyperLiquidWalletService as jest.MockedClass; +const MockedHyperLiquidSubscriptionService = + HyperLiquidSubscriptionService as jest.MockedClass< + typeof HyperLiquidSubscriptionService + >; +const mockValidateOrderParams = validateOrderParams as jest.MockedFunction< + typeof validateOrderParams +>; +const mockValidateWithdrawalParams = + validateWithdrawalParams as jest.MockedFunction< + typeof validateWithdrawalParams + >; +const mockValidateDepositParams = validateDepositParams as jest.MockedFunction< + typeof validateDepositParams +>; +const mockValidateCoinExists = validateCoinExists as jest.MockedFunction< + typeof validateCoinExists +>; +const mockValidateAssetSupport = validateAssetSupport as jest.MockedFunction< + typeof validateAssetSupport +>; +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'), + 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(); + +const mockMessenger = createMockMessenger(); + +/** + * Helper to create HyperLiquidProvider with mock platform dependencies + * @param options + * @param options.isTestnet + * @param options.hip3Enabled + * @param options.allowlistMarkets + * @param options.blocklistMarkets + * @param options.useUnifiedAccount + */ +const createTestProvider = ( + options: { + isTestnet?: boolean; + hip3Enabled?: boolean; + allowlistMarkets?: string[]; + blocklistMarkets?: string[]; + useUnifiedAccount?: boolean; + initialAssetMapping?: [string, number][]; + } = {}, +): HyperLiquidProvider => + new HyperLiquidProvider({ + ...options, + platformDependencies: mockPlatformDependencies, + messenger: mockMessenger, + }); + +describe('HyperLiquidProvider', () => { + let provider: HyperLiquidProvider; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionService: jest.Mocked; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + ( + mockPlatformDependencies.marketDataFormatters.formatVolume as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(0)); + ( + mockPlatformDependencies.marketDataFormatters.formatPerpsFiat as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(2)); + ( + mockPlatformDependencies.marketDataFormatters + .formatPercentage as jest.Mock + ).mockImplementation((value: number) => `${value.toFixed(2)}%`); + ( + mockPlatformDependencies.featureFlags.validateVersionGated as jest.Mock + ).mockReturnValue(undefined); + (mockPlatformDependencies.metrics.isEnabled as jest.Mock).mockReturnValue( + true, + ); + + // Reset TradingReadinessCache mock state (using imported mocked module) + const mockedCache = TradingReadinessCache as jest.Mocked< + typeof TradingReadinessCache + >; + mockedCache.get.mockReturnValue(undefined); + mockedCache.getBuilderFee.mockReturnValue(undefined); + mockedCache.getReferral.mockReturnValue(undefined); + mockedCache.isInFlight.mockReturnValue(undefined); + mockedCache.setInFlight.mockReturnValue(jest.fn()); + + // Initialize mock stream manager instance + mockStreamManagerInstance = { + clearAllChannels: jest.fn(), + }; + + // Create mocked service instances using factory functions + 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; + + mockWalletService = { + setTestnetMode: jest.fn(), + getCurrentAccountId: jest + .fn() + .mockReturnValue( + 'eip155:42161:0x1234567890123456789012345678901234567890', + ), + createWalletAdapter: jest.fn().mockReturnValue({ + request: jest + .fn() + .mockResolvedValue(['0x1234567890123456789012345678901234567890']), + }), + getUserAddress: jest + .fn() + .mockReturnValue('0x1234567890123456789012345678901234567890'), + getUserAddressWithDefault: jest + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890'), + isKeyringUnlocked: jest.fn().mockReturnValue(true), + isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + } as Partial as jest.Mocked; + + mockSubscriptionService = { + subscribeToPrices: jest.fn().mockResolvedValue(jest.fn()), // Returns Promise + subscribeToPositions: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + subscribeToOrderFills: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + clearAll: jest.fn(), + isPositionsCacheInitialized: jest.fn().mockReturnValue(false), + getCachedPositions: jest.fn().mockReturnValue([]), + updateFeatureFlags: jest.fn().mockResolvedValue(undefined), + // Cache methods used by buildAssetMapping optimization + setDexMetaCache: jest.fn(), + setDexAssetCtxsCache: jest.fn(), + getDexAssetCtxsCache: jest.fn().mockReturnValue(undefined), + // Price cache used by placeOrder, editOrder, closePosition optimizations + getCachedPrice: jest.fn().mockImplementation((symbol: string) => { + const prices: Record = { BTC: '50000', ETH: '3000' }; + return prices[symbol]; + }), + getLastAllMidsSnapshot: jest.fn().mockReturnValue(null), + // Orders cache used by updatePositionTPSL and getOpenOrders + isOrdersCacheInitialized: jest.fn().mockReturnValue(false), + getCachedOrders: jest.fn().mockReturnValue([]), + // Atomic getter - returns null when cache not initialized (prevents race condition) + getOrdersCacheIfInitialized: jest.fn().mockReturnValue(null), + // Abstraction-mode resolved-mode setter (unified account migration) + setUserAbstractionMode: jest.fn(), + } as Partial as jest.Mocked; + + // Mock constructors + MockedHyperLiquidClientService.mockImplementation(() => mockClientService); + MockedHyperLiquidWalletService.mockImplementation(() => mockWalletService); + MockedHyperLiquidSubscriptionService.mockImplementation( + () => mockSubscriptionService, + ); + + // Mock validation + mockValidateOrderParams.mockReturnValue({ isValid: true }); + mockValidateWithdrawalParams.mockReturnValue({ isValid: true }); + mockValidateDepositParams.mockReturnValue({ isValid: true }); + mockValidateCoinExists.mockReturnValue({ isValid: true }); + mockValidateAssetSupport.mockReturnValue({ isValid: true }); + mockValidateBalance.mockReturnValue({ isValid: true }); + const hyperLiquidValidation = jest.requireMock( + '../../../src/utils/hyperLiquidValidation', + ); + hyperLiquidValidation.getSupportedPaths.mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]); + hyperLiquidValidation.getBridgeInfo.mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }); + hyperLiquidValidation.createErrorResult.mockImplementation( + (error: unknown, defaultResponse: Record) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + }), + ); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptHyperLiquidLedgerUpdateToUserHistoryItem.mockImplementation( + (updates: unknown[]) => { + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map(() => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }, + ); + + provider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ], + }); + }); + describe('Additional Coverage Tests', () => { + it('handles getUserFills with empty response', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + userFills: jest.fn().mockResolvedValue(null), + }); + + const result = await provider.getOrderFills(); + expect(result).toEqual([]); + }); + + it('handles getOrders with empty response', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + historicalOrders: jest.fn().mockResolvedValue(null), + }); + mockClientService.fetchHistoricalOrders = jest.fn().mockResolvedValue([]); + + const result = await provider.getOrders(); + expect(result).toEqual([]); + }); + + it('properly transform getOrders with reduceOnly and isTrigger fields', async () => { + const historicalOrdersData = [ + { + order: { + oid: 123, + coin: 'BTC', + side: 'A', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + reduceOnly: false, + isTrigger: false, + }, + status: 'filled', + statusTimestamp: 1640995200000, + }, + { + order: { + oid: 124, + coin: 'ETH', + side: 'A', + sz: '0.0', + origSz: '2.0', + limitPx: '3500', + orderType: 'Take Profit Limit', + reduceOnly: true, + isTrigger: true, + }, + status: 'filled', + statusTimestamp: 1640995300000, + }, + { + order: { + oid: 125, + coin: 'BTC', + side: 'B', + sz: '0.1', + origSz: '0.1', + limitPx: '45000', + orderType: 'Stop Market', + reduceOnly: true, + isTrigger: true, + }, + status: 'triggered', + statusTimestamp: 1640995400000, + }, + ]; + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + maxBuilderFee: jest.fn().mockResolvedValue(1), + referral: jest.fn().mockResolvedValue({ + referrerState: { stage: 'ready', data: { code: 'MMCSI' } }, + referredBy: { code: 'MMCSI' }, + }), + historicalOrders: jest.fn().mockResolvedValue(historicalOrdersData), + }); + mockClientService.fetchHistoricalOrders = jest + .fn() + .mockResolvedValue(historicalOrdersData); + + const result = await provider.getOrders(); + + expect(result).toHaveLength(3); + + // Check first order - regular limit order (not closing) + expect(result[0]).toMatchObject({ + orderId: '123', + symbol: 'BTC', + side: 'sell', + orderType: 'limit', + size: '0.5', + originalSize: '1.0', + price: '50000', + status: 'filled', + detailedOrderType: 'Limit', + reduceOnly: false, + isTrigger: false, + }); + + // Check second order - Take Profit closing order + expect(result[1]).toMatchObject({ + orderId: '124', + symbol: 'ETH', + side: 'sell', + orderType: 'limit', + size: '0.0', + originalSize: '2.0', + price: '3500', + status: 'filled', + detailedOrderType: 'Take Profit Limit', + reduceOnly: true, + isTrigger: true, + }); + + // Check third order - Stop Market closing order + expect(result[2]).toMatchObject({ + orderId: '125', + symbol: 'BTC', + side: 'buy', + orderType: 'market', + size: '0.1', + originalSize: '0.1', + price: '45000', + status: 'triggered', + detailedOrderType: 'Stop Market', + reduceOnly: true, + isTrigger: true, + }); + }); + + it('properly transform getOpenOrders with reduceOnly and isTrigger fields', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + maxBuilderFee: jest.fn().mockResolvedValue(1), + referral: jest.fn().mockResolvedValue({ + referrerState: { stage: 'ready', data: { code: 'MMCSI' } }, + referredBy: { code: 'MMCSI' }, + }), + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '500', accountValue: '10500' }, + withdrawable: '9500', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '1.0', + entryPx: '50000', + positionValue: '50000', + unrealizedPnl: '1000', + marginUsed: '5000', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + maxLeverage: 50, + returnOnEquity: '20', + cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + }, + type: 'oneWay', + }, + ], + crossMarginSummary: { + accountValue: '10000', + totalMarginUsed: '5000', + }, + }), + frontendOpenOrders: jest.fn().mockResolvedValue([ + { + coin: 'BTC', + side: 'B', + limitPx: '49000', + sz: '0.5', + oid: 201, + timestamp: 1640995500000, + origSz: '0.5', + triggerCondition: '', + isTrigger: false, + triggerPx: '', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + tif: 'Gtc', + cloid: null, + }, + { + coin: 'BTC', + side: 'A', + limitPx: '55000', + sz: '1.0', + oid: 202, + timestamp: 1640995600000, + origSz: '1.0', + triggerCondition: '', + isTrigger: true, + triggerPx: '55000', + children: [], + isPositionTpsl: true, + reduceOnly: true, + orderType: 'Take Profit Limit', + tif: null, + cloid: null, + }, + { + coin: 'BTC', + side: 'A', + limitPx: '', + sz: '1.0', + oid: 203, + timestamp: 1640995700000, + origSz: '1.0', + triggerCondition: '', + isTrigger: true, + triggerPx: '45000', + children: [], + isPositionTpsl: true, + reduceOnly: true, + orderType: 'Stop Market', + tif: null, + cloid: null, + }, + ]), + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + perpDexs: jest.fn().mockResolvedValue([null]), + }); + + const result = await provider.getOpenOrders({ skipCache: true }); + + expect(result).toHaveLength(3); + + // Check first order - regular limit order (opening position) + expect(result[0]).toMatchObject({ + orderId: '201', + symbol: 'BTC', + side: 'buy', + orderType: 'limit', + size: '0.5', + originalSize: '0.5', + price: '49000', + status: 'open', + detailedOrderType: 'Limit', + reduceOnly: false, + isTrigger: false, + }); + + // Check second order - Take Profit closing order + expect(result[1]).toMatchObject({ + orderId: '202', + symbol: 'BTC', + side: 'sell', + orderType: 'limit', + size: '1.0', + originalSize: '1.0', + price: '55000', + status: 'open', + detailedOrderType: 'Take Profit Limit', + reduceOnly: true, + isTrigger: true, + }); + + // Check third order - Stop Market closing order + expect(result[2]).toMatchObject({ + orderId: '203', + symbol: 'BTC', + side: 'sell', + orderType: 'market', + size: '1.0', + originalSize: '1.0', + price: '45000', + status: 'open', + detailedOrderType: 'Stop Market', + reduceOnly: true, + isTrigger: true, + }); + }); + + it('handles getFunding with empty response', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + userFunding: jest.fn().mockResolvedValue(null), + }); + + const result = await provider.getFunding(); + expect(result).toEqual([]); + }); + + it('fetches funding across multiple page windows to include latest records', async () => { + const NOW = 1735689600000; // fixed timestamp for determinism + const DAY_MS = 24 * 60 * 60 * 1000; + + const oldRecord = { + time: NOW - 40 * DAY_MS, + hash: '0x' + 'a'.repeat(64), + delta: { + type: 'funding', + coin: 'BTC', + usdc: '-1.0', + szi: '0.1', + fundingRate: '0.0001', + nSamples: null, + }, + }; + const recentRecord = { + time: NOW - 5 * DAY_MS, + hash: '0x' + 'b'.repeat(64), + delta: { + type: 'funding', + coin: 'BTC', + usdc: '-2.0', + szi: '0.1', + fundingRate: '0.0001', + nSamples: null, + }, + }; + + const userFundingMock = jest + .fn() + .mockImplementation( + (params: { startTime: number; endTime: number }) => { + const records = [oldRecord, recentRecord].filter( + (r) => r.time >= params.startTime && r.time <= params.endTime, + ); + return Promise.resolve(records); + }, + ); + + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + userFunding: userFundingMock, + }); + + // Time range spans 60 days → 2 page windows of 30 days each + const result = await provider.getFunding({ + startTime: NOW - 60 * DAY_MS, + endTime: NOW, + }); + + expect(userFundingMock).toHaveBeenCalledTimes(2); + expect(result).toHaveLength(2); + // Results sorted ascending: older first, recent last + expect(result[0].amountUsd).toBe('-1.0'); + expect(result[1].amountUsd).toBe('-2.0'); + // Most recent record is present — this would fail with the old single-call approach + // when total records exceeded the 500-record API cap + expect(result[1].timestamp).toBe(recentRecord.time); + }); + + it('includes records from the most recent page window when history is long', async () => { + const NOW = 1735689600000; + const DAY_MS = 24 * 60 * 60 * 1000; + const recentTs = NOW - 2 * DAY_MS; + + const userFundingMock = jest + .fn() + .mockImplementation( + (params: { startTime: number; endTime: number }) => { + if (params.endTime >= recentTs && params.startTime <= recentTs) { + return Promise.resolve([ + { + time: recentTs, + hash: '0x' + 'f'.repeat(64), + delta: { + type: 'funding', + coin: 'ETH', + usdc: '-0.5', + szi: '1.0', + fundingRate: '0.00005', + nSamples: null, + }, + }, + ]); + } + return Promise.resolve([]); + }, + ); + + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + userFunding: userFundingMock, + }); + + // Pass explicit 365-day range to trigger multi-page behavior. + // The default is now 30 days (1 call); callers must pass startTime to paginate further. + const result = await provider.getFunding({ + startTime: NOW - 365 * DAY_MS, + endTime: NOW, + }); + + // Multiple page windows must be created for a 365-day explicit range + expect(userFundingMock.mock.calls.length).toBeGreaterThan(1); + // The most recent record is present — proves pagination reaches the latest window + expect(result.some((r) => r.timestamp === recentTs)).toBe(true); + }); + + it('handles null response from one page window without losing other pages', async () => { + const NOW = 1735689600000; + const DAY_MS = 24 * 60 * 60 * 1000; + const validRecord = { + time: NOW - 10 * DAY_MS, + hash: '0x' + 'c'.repeat(64), + delta: { + type: 'funding', + coin: 'BTC', + usdc: '-3.0', + szi: '0.2', + fundingRate: '0.0002', + nSamples: null, + }, + }; + + let callCount = 0; + const userFundingMock = jest.fn().mockImplementation(() => { + callCount += 1; + // First call returns null, subsequent calls return data + return Promise.resolve(callCount === 1 ? null : [validRecord]); + }); + + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + userFunding: userFundingMock, + }); + + const result = await provider.getFunding({ + startTime: NOW - 60 * DAY_MS, + endTime: NOW, + }); + + // Null page is gracefully skipped; valid records from other pages survive + expect(result.some((r) => r.amountUsd === '-3.0')).toBe(true); + }); + + it('handles validateWithdrawal returning true', async () => { + const params = { + amount: '100', + destination: '0x123' as Hex, + assetId: 'eip155:1/native' as CaipAssetId, + }; + + const result = await provider.validateWithdrawal(params); + expect(result.isValid).toBe(true); + }); + + it('handles clearFeeCache with specific user', () => { + const userAddress = '0x123'; + provider.clearFeeCache(userAddress); + // Method should complete without error + }); + + // TODO: Refactor — #isFeeCacheValid is an ES # private method, can't be accessed via type cast + it.skip('handles isFeeCacheValid with non-existent address', async () => { + // Access private method for edge case testing + interface ProviderWithPrivateMethods { + isFeeCacheValid(userAddress: string): boolean; + } + const testableProvider = + provider as unknown as ProviderWithPrivateMethods; + const result = testableProvider.isFeeCacheValid('0xnonexistent'); + expect(result).toBe(false); + }); + + it('transforms fill data with liquidation information', async () => { + // Mock fill with liquidation data + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userFills: jest.fn().mockResolvedValue([ + { + oid: 123, + coin: 'BTC', + side: 'B', + sz: '0.1', + px: '45000', + fee: '4.5', + feeToken: 'USDC', + time: Date.now(), + closedPnl: '-500', + dir: 'Close Long', + liquidation: { + liquidatedUser: '0x123', + markPx: '44900', + method: 'market', + }, + }, + ]), + }), + ); + + const fills = await provider.getOrderFills(); + + expect(fills[0].liquidation).toEqual({ + liquidatedUser: '0x123', + markPx: '44900', + method: 'market', + }); + }); + + it('handles fills without liquidation data', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userFills: jest.fn().mockResolvedValue([ + { + oid: 124, + coin: 'ETH', + side: 'B', + sz: '1.0', + px: '3000', + fee: '3', + feeToken: 'USDC', + time: Date.now(), + closedPnl: '100', + dir: 'Open Long', + }, + ]), + }), + ); + + const fills = await provider.getOrderFills(); + expect(fills[0].liquidation).toBeUndefined(); + }); + + it('uses userFillsByTime when startTime is provided', async () => { + const mockUserFillsByTime = jest.fn().mockResolvedValue([ + { + oid: 125, + coin: 'BTC', + side: 'B', + sz: '0.5', + px: '50000', + fee: '5', + feeToken: 'USDC', + time: Date.now(), + closedPnl: '200', + dir: 'Open Long', + }, + ]); + + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userFillsByTime: mockUserFillsByTime, + }), + ); + + const startTime = Date.now() - 90 * 24 * 60 * 60 * 1000; // 3 months ago + const fills = await provider.getOrderFills({ startTime }); + + expect(mockUserFillsByTime).toHaveBeenCalledWith({ + user: '0x1234567890123456789012345678901234567890', + startTime, + endTime: undefined, + aggregateByTime: false, + }); + expect(fills).toHaveLength(1); + expect(fills[0].symbol).toBe('BTC'); + }); + + it('uses userFills when startTime is not provided', async () => { + const mockUserFills = jest.fn().mockResolvedValue([ + { + oid: 126, + coin: 'ETH', + side: 'A', + sz: '2.0', + px: '3500', + fee: '7', + feeToken: 'USDC', + time: Date.now(), + closedPnl: '150', + dir: 'Close Short', + }, + ]); + + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userFills: mockUserFills, + }), + ); + + const fills = await provider.getOrderFills({ aggregateByTime: true }); + + expect(mockUserFills).toHaveBeenCalledWith({ + user: '0x1234567890123456789012345678901234567890', + aggregateByTime: true, + }); + expect(fills).toHaveLength(1); + expect(fills[0].symbol).toBe('ETH'); + }); + + it('passes endTime to userFillsByTime when provided', async () => { + const mockUserFillsByTime = jest.fn().mockResolvedValue([]); + + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userFillsByTime: mockUserFillsByTime, + }), + ); + + const startTime = Date.now() - 30 * 24 * 60 * 60 * 1000; // 30 days ago + const endTime = Date.now(); + await provider.getOrderFills({ + startTime, + endTime, + aggregateByTime: true, + }); + + expect(mockUserFillsByTime).toHaveBeenCalledWith({ + user: '0x1234567890123456789012345678901234567890', + startTime, + endTime, + aggregateByTime: true, + }); + }); + }); + + describe('getOrderFills enrichment with detailedOrderType', () => { + it('enriches fills with detailedOrderType from historical orders', async () => { + const mockUserFills = jest.fn().mockResolvedValue([ + { + oid: 100, + coin: 'BTC', + side: 'A', + sz: '0.5', + px: '50000', + fee: '5', + feeToken: 'USDC', + time: Date.now(), + closedPnl: '-200', + dir: 'Close Long', + startPosition: '0.5', + }, + { + oid: 101, + coin: 'ETH', + side: 'B', + sz: '1.0', + px: '3000', + fee: '3', + feeToken: 'USDC', + time: Date.now(), + closedPnl: '100', + dir: 'Close Short', + startPosition: '-1.0', + }, + ]); + + const historicalOrdersData = [ + { + order: { + oid: 100, + coin: 'BTC', + side: 'A', + sz: '0', + origSz: '0.5', + limitPx: '50000', + orderType: 'Stop Market', + reduceOnly: true, + isTrigger: true, + }, + status: 'filled', + statusTimestamp: Date.now(), + }, + { + order: { + oid: 101, + coin: 'ETH', + side: 'B', + sz: '0', + origSz: '1.0', + limitPx: '3000', + orderType: 'Take Profit Limit', + reduceOnly: true, + isTrigger: true, + }, + status: 'filled', + statusTimestamp: Date.now(), + }, + ]; + const mockHistoricalOrders = jest + .fn() + .mockResolvedValue(historicalOrdersData); + + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userFills: mockUserFills, + historicalOrders: mockHistoricalOrders, + }), + ); + mockClientService.fetchHistoricalOrders = jest + .fn() + .mockResolvedValue(historicalOrdersData); + + const fills = await provider.getOrderFills(); + + expect(fills).toHaveLength(2); + expect(fills[0].detailedOrderType).toBe('Stop Market'); + expect(fills[1].detailedOrderType).toBe('Take Profit Limit'); + }); + + it('gracefully handles historicalOrders failure', async () => { + const mockUserFills = jest.fn().mockResolvedValue([ + { + oid: 200, + coin: 'BTC', + side: 'B', + sz: '0.1', + px: '60000', + fee: '6', + feeToken: 'USDC', + time: Date.now(), + closedPnl: '0', + dir: 'Open Long', + startPosition: '0', + }, + ]); + + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userFills: mockUserFills, + historicalOrders: jest.fn().mockRejectedValue(new Error('API error')), + }), + ); + mockClientService.fetchHistoricalOrders = jest + .fn() + .mockRejectedValue(new Error('API error')); + + const fills = await provider.getOrderFills(); + + expect(fills).toHaveLength(1); + expect(fills[0].detailedOrderType).toBeUndefined(); + }); + }); + + describe('getOpenOrders additional coverage', () => { + it('returns empty array when frontendOpenOrders throws error', async () => { + // Arrange + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + frontendOpenOrders: jest.fn().mockRejectedValue(new Error('API Error')), + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + perpDexs: jest.fn().mockResolvedValue([null]), + }); + + // Act + const result = await provider.getOpenOrders({ skipCache: true }); + + // Assert + expect(result).toEqual([]); + }); + + it('returns cached orders when cache is initialized', async () => { + // Arrange + const cachedOrders = [ + { + orderId: '101', + symbol: 'ETH', + side: 'buy' as const, + orderType: 'limit' as const, + size: '1.0', + originalSize: '1.0', + filledSize: '0', + remainingSize: '1.0', + price: '2900', + status: 'open' as const, + timestamp: Date.now(), + detailedOrderType: 'Limit', + reduceOnly: false, + isTrigger: false, + }, + ]; + // Use the atomic getter mock + mockSubscriptionService.getOrdersCacheIfInitialized = jest + .fn() + .mockReturnValue(cachedOrders); + + // Act + const result = await provider.getOpenOrders(); + + // Assert + expect(result).toEqual(cachedOrders); + expect(mockClientService.getInfoClient).not.toHaveBeenCalled(); + }); + + it('falls back to REST when atomic cache getter returns null', async () => { + // Arrange - atomic getter returns null (cache not initialized or race condition) + mockSubscriptionService.getOrdersCacheIfInitialized = jest + .fn() + .mockReturnValue(null); + + const mockFrontendOpenOrders = jest.fn().mockResolvedValue([ + { + coin: 'ETH', + side: 'B', + limitPx: '3000', + sz: '1.0', + oid: 501, + timestamp: Date.now(), + origSz: '1.0', + triggerCondition: '', + isTrigger: false, + triggerPx: '', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + tif: 'Gtc', + cloid: null, + }, + ]); + + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + maxBuilderFee: jest.fn().mockResolvedValue(1), + referral: jest.fn().mockResolvedValue({ + referrerState: { stage: 'ready', data: { code: 'MMCSI' } }, + referredBy: { code: 'MMCSI' }, + }), + frontendOpenOrders: mockFrontendOpenOrders, + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '0', accountValue: '1000' }, + withdrawable: '1000', + assetPositions: [], + crossMarginSummary: { accountValue: '1000', totalMarginUsed: '0' }, + }), + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'ETH', szDecimals: 4, maxLeverage: 25 }], + }), + perpDexs: jest.fn().mockResolvedValue([null]), + }); + + // Act + const result = await provider.getOpenOrders(); + + // Assert - should fall back to REST API + expect(mockFrontendOpenOrders).toHaveBeenCalled(); + expect(result.length).toBe(1); + expect(result[0].orderId).toBe('501'); + }); + + it('returns defensive copy of cached orders (not original array)', async () => { + // Arrange - this tests that the atomic getter returns a copy + const cachedOrders = [ + { + orderId: '101', + symbol: 'ETH', + side: 'buy' as const, + orderType: 'limit' as const, + size: '1.0', + originalSize: '1.0', + filledSize: '0', + remainingSize: '1.0', + price: '2900', + status: 'open' as const, + timestamp: Date.now(), + detailedOrderType: 'Limit', + reduceOnly: false, + isTrigger: false, + }, + ]; + // Return a new array each time (simulating the defensive copy) + mockSubscriptionService.getOrdersCacheIfInitialized = jest + .fn() + .mockImplementation(() => [...cachedOrders]); + + // Act + const result1 = await provider.getOpenOrders(); + const result2 = await provider.getOpenOrders(); + + // Assert - should be equal but not the same reference + expect(result1).toEqual(result2); + expect(result1).not.toBe(result2); // Different array instances + }); + + it('queries only main DEX when no additional DEXs enabled', async () => { + // Arrange + const mockFrontendOpenOrders = jest.fn().mockResolvedValue([ + { + coin: 'ETH', + side: 'B', + limitPx: '3000', + sz: '1.0', + oid: 301, + timestamp: Date.now(), + origSz: '1.0', + triggerCondition: '', + isTrigger: false, + triggerPx: '', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + tif: 'Gtc', + cloid: null, + }, + ]); + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + maxBuilderFee: jest.fn().mockResolvedValue(1), + referral: jest.fn().mockResolvedValue({ + referrerState: { stage: 'ready', data: { code: 'MMCSI' } }, + referredBy: { code: 'MMCSI' }, + }), + frontendOpenOrders: mockFrontendOpenOrders, + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '0', accountValue: '1000' }, + withdrawable: '1000', + assetPositions: [], + crossMarginSummary: { accountValue: '1000', totalMarginUsed: '0' }, + }), + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'ETH', szDecimals: 4, maxLeverage: 25 }], + }), + perpDexs: jest.fn().mockResolvedValue([null]), + }); + + // Act + const result = await provider.getOpenOrders({ skipCache: true }); + + // Assert + expect(result).toHaveLength(1); + expect(result[0].symbol).toBe('ETH'); + // Note: frontendOpenOrders is called twice - once for getOpenOrders and once for getPositions + expect(mockFrontendOpenOrders).toHaveBeenCalled(); + }); + + it('queries multiple DEXs when HIP-3 enabled', async () => { + // Create provider with HIP-3 enabled and allowlist including 'xyz' DEX + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + initialAssetMapping: [ + ['BTC', 0], + ['xyz:STOCK1', 1], + ], + }); + + // Ensure cache is disabled for this test (atomic getter returns null) + mockSubscriptionService.getOrdersCacheIfInitialized = jest + .fn() + .mockReturnValue(null); + + const mockFrontendOpenOrders = jest + .fn() + .mockImplementation((params: { user: string; dex?: string }) => { + if (params.dex === 'xyz') { + return Promise.resolve([ + { + coin: 'xyz:STOCK1', + side: 'B', + limitPx: '100', + sz: '10', + oid: 401, + timestamp: Date.now(), + origSz: '10', + triggerCondition: '', + isTrigger: false, + triggerPx: '', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + tif: 'Gtc', + cloid: null, + }, + ]); + } + // Main DEX + return Promise.resolve([ + { + coin: 'BTC', + side: 'A', + limitPx: '51000', + sz: '0.5', + oid: 402, + timestamp: Date.now(), + origSz: '0.5', + triggerCondition: '', + isTrigger: false, + triggerPx: '', + children: [], + isPositionTpsl: false, + reduceOnly: false, + orderType: 'Limit', + tif: 'Gtc', + cloid: null, + }, + ]); + }); + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + maxBuilderFee: jest.fn().mockResolvedValue(1), + referral: jest.fn().mockResolvedValue({ + referrerState: { stage: 'ready', data: { code: 'MMCSI' } }, + referredBy: { code: 'MMCSI' }, + }), + frontendOpenOrders: mockFrontendOpenOrders, + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '0', accountValue: '1000' }, + withdrawable: '1000', + assetPositions: [], + crossMarginSummary: { accountValue: '1000', totalMarginUsed: '0' }, + }), + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'xyz:STOCK1', szDecimals: 2, maxLeverage: 20 }, + ], + }), + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + }); + + // Act + const result = await hip3Provider.getOpenOrders({ skipCache: true }); + + // Assert + expect(result).toHaveLength(2); + // Verify both orders are present (order may vary due to Promise.all) + const symbols = result.map((r) => r.symbol); + expect(symbols).toContain('xyz:STOCK1'); + expect(symbols).toContain('BTC'); + // Verify both DEXs were queried + expect(mockFrontendOpenOrders).toHaveBeenCalled(); + expect( + mockFrontendOpenOrders.mock.calls.some((call) => call[0].dex === 'xyz'), + ).toBe(true); + }); + }); + + describe('getUserHistory', () => { + it('returns user history items successfully', async () => { + // Arrange + const mockLedgerUpdates = [ + { + delta: { type: 'deposit', usdc: '100' }, + time: Date.now(), + hash: '0x123', + }, + { + delta: { type: 'withdraw', usdc: '50' }, + time: Date.now() - 3600000, + hash: '0x456', + }, + ]; + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userNonFundingLedgerUpdates: jest + .fn() + .mockResolvedValue(mockLedgerUpdates), + }), + ); + + // Act + const result = await provider.getUserHistory(); + + // Assert + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + expect(mockClientService.getInfoClient).toHaveBeenCalled(); + }); + + it('returns empty array on API error', async () => { + // Arrange + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + userNonFundingLedgerUpdates: jest + .fn() + .mockRejectedValue(new Error('API Error')), + }), + ); + + // Act + const result = await provider.getUserHistory(); + + // Assert + expect(result).toEqual([]); + }); + + it('handles custom time range parameters', async () => { + // Arrange + const startTime = Date.now() - 86400000; // 24h ago + const endTime = Date.now(); + const mockInfoClient = createMockInfoClient(); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + // Act + await provider.getUserHistory({ startTime, endTime }); + + // Assert + expect(mockInfoClient.userNonFundingLedgerUpdates).toHaveBeenCalledWith( + expect.objectContaining({ + startTime, + endTime, + }), + ); + }); + + it('uses default account when no accountId provided', async () => { + // Arrange + const mockInfoClient = createMockInfoClient(); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + // Act + await provider.getUserHistory(); + + // Assert + expect(mockWalletService.getUserAddressWithDefault).toHaveBeenCalledWith( + undefined, + ); + expect(mockInfoClient.userNonFundingLedgerUpdates).toHaveBeenCalled(); + }); + }); + + describe('getHistoricalPortfolio', () => { + it('returns historical portfolio value from 24h ago', async () => { + // Arrange + const yesterday = Date.now() - 86400000; + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + portfolio: jest.fn().mockResolvedValue([ + null, + [ + null, + { + accountValueHistory: [ + [yesterday, '10000'], + [yesterday - 86400000, '9500'], + ], + }, + ], + ]), + }), + ); + + // Act + const result = await provider.getHistoricalPortfolio(); + + // Assert + expect(result.accountValue1dAgo).toBeDefined(); + expect(result.timestamp).toBeDefined(); + }); + + it('finds closest entry before target timestamp', async () => { + // Arrange + const now = Date.now(); + const closestTime = now - 87000000; // Slightly older than 24h + + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + portfolio: jest.fn().mockResolvedValue([ + null, + [ + null, + { + accountValueHistory: [ + [closestTime, '10000'], // This should be selected + [now - 172800000, '9500'], // Too old + ], + }, + ], + ]), + }), + ); + + // Act + const result = await provider.getHistoricalPortfolio(); + + // Assert + expect(result.accountValue1dAgo).toBe('10000'); + expect(result.timestamp).toBe(closestTime); + }); + + it('returns fallback when no historical data exists', async () => { + // Arrange + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + portfolio: jest.fn().mockResolvedValue([ + null, + [ + null, + { + accountValueHistory: [], + }, + ], + ]), + }), + ); + + // Act + const result = await provider.getHistoricalPortfolio(); + + // Assert + expect(result.accountValue1dAgo).toBe('0'); + expect(result.timestamp).toBe(0); + }); + + it('handles empty portfolio data gracefully', async () => { + // Arrange + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + portfolio: jest.fn().mockResolvedValue(null), + }), + ); + + // Act + const result = await provider.getHistoricalPortfolio(); + + // Assert + expect(result.accountValue1dAgo).toBe('0'); + expect(result.timestamp).toBe(0); + }); + + it('returns zero values on error', async () => { + // Arrange + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + portfolio: jest + .fn() + .mockRejectedValue(new Error('Portfolio API error')), + }), + ); + + // Act + const result = await provider.getHistoricalPortfolio(); + + // Assert + expect(result.accountValue1dAgo).toBe('0'); + expect(result.timestamp).toBe(0); + }); + }); + + describe('getAvailableHip3Dexs', () => { + it('returns HIP-3 DEX names when equity enabled', async () => { + // Arrange - use existing provider with updated mock + const mockInfoClientWithDexs = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([ + null, + { name: 'dex1', url: 'https://dex1.com' }, + { name: 'dex2', url: 'https://dex2.com' }, + ]), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClientWithDexs); + + // Create a provider instance with equity enabled for this specific test + const testProvider = createTestProvider({ hip3Enabled: true }); + + // DEX discovery cache starts with null state on a fresh provider — no reset needed + + // Act + const result = await testProvider.getAvailableHip3Dexs(); + + // Assert + expect(Array.isArray(result)).toBe(true); + expect(mockInfoClientWithDexs.perpDexs).toHaveBeenCalled(); + }); + + it('returns empty array when equity disabled', async () => { + // Arrange + const disabledProvider = createTestProvider({ + hip3Enabled: false, + }); + + // Act + const result = await disabledProvider.getAvailableHip3Dexs(); + + // Assert + expect(result).toEqual([]); + }); + + it('returns empty array when perpDexs returns invalid data', async () => { + // Arrange + const hip3Provider = createTestProvider({ hip3Enabled: true }); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + perpDexs: jest.fn().mockResolvedValue(null), + }), + ); + + // Act + const result = await hip3Provider.getAvailableHip3Dexs(); + + // Assert + expect(result).toEqual([]); + }); + }); + + describe('transferBetweenDexs', () => { + beforeEach(() => { + // Add spotMeta to mock for getUsdcTokenId + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + spotMeta: jest.fn().mockResolvedValue({ + tokens: [{ name: 'USDC', tokenId: '0xabc123', index: 0 }], + universe: [], + }), + }), + ); + }); + + it('transfers USDC between DEXs successfully', async () => { + // Arrange + const transferParams = { + sourceDex: 'dex1', + destinationDex: 'dex2', + amount: '100', + }; + + // Act + const result = await provider.transferBetweenDexs(transferParams); + + // Assert + expect(result.success).toBe(true); + expect( + mockClientService.getExchangeClient().sendAsset, + ).toHaveBeenCalledWith( + expect.objectContaining({ + sourceDex: 'dex1', + destinationDex: 'dex2', + amount: '100', + token: expect.any(String), + }), + ); + }); + + it('rejects transfer with zero amount', async () => { + // Arrange + const transferParams = { + sourceDex: 'dex1', + destinationDex: 'dex2', + amount: '0', + }; + + // Act + const result = await provider.transferBetweenDexs(transferParams); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toContain('must be greater than 0'); + }); + + it('rejects transfer when source equals destination', async () => { + // Arrange + const transferParams = { + sourceDex: 'dex1', + destinationDex: 'dex1', + amount: '100', + }; + + // Act + const result = await provider.transferBetweenDexs(transferParams); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toContain('must be different'); + }); + + it('handles sendAsset failure gracefully', async () => { + // Arrange + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + sendAsset: jest.fn().mockResolvedValue({ + status: 'error', + message: 'Insufficient balance', + }), + }), + ); + const transferParams = { + sourceDex: 'dex1', + destinationDex: 'dex2', + amount: '100', + }; + + // Act + const result = await provider.transferBetweenDexs(transferParams); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + + it('calls getUsdcTokenId to get correct token', async () => { + // Arrange + const mockSpotMeta = jest.fn().mockResolvedValue({ + tokens: [{ name: 'USDC', tokenId: '0xspecific', index: 0 }], + universe: [], + }); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(createMockInfoClient({ spotMeta: mockSpotMeta })); + const transferParams = { + sourceDex: '', + destinationDex: 'dex1', + amount: '100', + }; + + // Act + await provider.transferBetweenDexs(transferParams); + + // Assert + expect(mockSpotMeta).toHaveBeenCalled(); + expect( + mockClientService.getExchangeClient().sendAsset, + ).toHaveBeenCalledWith( + expect.objectContaining({ + token: 'USDC:0xspecific', + }), + ); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.lifecycle.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.lifecycle.test.ts new file mode 100644 index 0000000000..f50f682f9e --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.lifecycle.test.ts @@ -0,0 +1,694 @@ +/* eslint-disable */ +jest.mock('@nktkas/hyperliquid', () => ({})); + +import type { CaipAssetId, Hex } from '@metamask/utils'; + +import { CandlePeriod } from '../../../src/constants/chartConfig'; +import { + BUILDER_FEE_CONFIG, + REFERRAL_CONFIG, +} from '../../../src/constants/hyperLiquidConfig'; +import { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from '../../../src/constants/transactionsHistoryConfig'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes'; +import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider'; +import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService'; +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService'; +import { TradingReadinessCache } from '../../../src/services/TradingReadinessCache'; +import type { + ClosePositionParams, + DepositParams, + Order, + PerpsPlatformDependencies, + LiveDataConfig, + OrderParams, +} from '../../../src/types'; +import { + validateAssetSupport, + validateBalance, + validateCoinExists, + validateDepositParams, + validateOrderParams, + validateWithdrawalParams, +} from '../../../src/utils/hyperLiquidValidation'; +import { createStandaloneInfoClient } from '../../../src/utils/standaloneInfoClient'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks'; + +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidWalletService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); +// Mock stream manager - will be set up in test +let mockStreamManagerInstance: any; +const mockGetStreamManagerInstance = jest.fn(() => mockStreamManagerInstance); +jest.mock( + '../../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: mockGetStreamManagerInstance, + }), + { virtual: true }, +); + +// Mock standalone info client for standalone mode tests +let mockStandaloneInfoClient: any; +jest.mock('../../../src/utils/standaloneInfoClient', () => ({ + ...jest.requireActual('../../../src/utils/standaloneInfoClient'), + createStandaloneInfoClient: jest.fn(() => mockStandaloneInfoClient), +})); + +jest.mock('../../../src/utils/hyperLiquidValidation', () => ({ + validateOrderParams: jest.fn(), + validateWithdrawalParams: jest.fn(), + validateDepositParams: jest.fn(), + validateCoinExists: jest.fn(), + validateAssetSupport: jest.fn(), + validateBalance: jest.fn(), + getSupportedPaths: jest + .fn() + .mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]), + getBridgeInfo: jest.fn().mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }), + createErrorResult: jest.fn((error, defaultResponse) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + })), +})); + +// Mock adapter functions +jest.mock('../../../src/utils/hyperLiquidAdapter', () => { + const actual = jest.requireActual('../../../src/utils/hyperLiquidAdapter'); + return { + ...actual, + adaptHyperLiquidLedgerUpdateToUserHistoryItem: jest.fn((updates) => { + // Return mock history items based on input + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map((_update: unknown) => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }), + }; +}); + +// Mock TradingReadinessCache - global singleton for signing operation caching +// Use jest.createMockFromModule for proper mock creation +jest.mock('../../../src/services/TradingReadinessCache'); + +const MockedHyperLiquidClientService = + HyperLiquidClientService as jest.MockedClass; +const MockedHyperLiquidWalletService = + HyperLiquidWalletService as jest.MockedClass; +const MockedHyperLiquidSubscriptionService = + HyperLiquidSubscriptionService as jest.MockedClass< + typeof HyperLiquidSubscriptionService + >; +const mockValidateOrderParams = validateOrderParams as jest.MockedFunction< + typeof validateOrderParams +>; +const mockValidateWithdrawalParams = + validateWithdrawalParams as jest.MockedFunction< + typeof validateWithdrawalParams + >; +const mockValidateDepositParams = validateDepositParams as jest.MockedFunction< + typeof validateDepositParams +>; +const mockValidateCoinExists = validateCoinExists as jest.MockedFunction< + typeof validateCoinExists +>; +const mockValidateAssetSupport = validateAssetSupport as jest.MockedFunction< + typeof validateAssetSupport +>; +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'), + 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(); + +const mockMessenger = createMockMessenger(); + +/** + * Helper to create HyperLiquidProvider with mock platform dependencies + * @param options + * @param options.isTestnet + * @param options.hip3Enabled + * @param options.allowlistMarkets + * @param options.blocklistMarkets + * @param options.useUnifiedAccount + */ +const createTestProvider = ( + options: { + isTestnet?: boolean; + hip3Enabled?: boolean; + allowlistMarkets?: string[]; + blocklistMarkets?: string[]; + useUnifiedAccount?: boolean; + initialAssetMapping?: [string, number][]; + } = {}, +): HyperLiquidProvider => + new HyperLiquidProvider({ + ...options, + platformDependencies: mockPlatformDependencies, + messenger: mockMessenger, + }); + +describe('HyperLiquidProvider', () => { + let provider: HyperLiquidProvider; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionService: jest.Mocked; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + ( + mockPlatformDependencies.marketDataFormatters.formatVolume as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(0)); + ( + mockPlatformDependencies.marketDataFormatters.formatPerpsFiat as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(2)); + ( + mockPlatformDependencies.marketDataFormatters + .formatPercentage as jest.Mock + ).mockImplementation((value: number) => `${value.toFixed(2)}%`); + ( + mockPlatformDependencies.featureFlags.validateVersionGated as jest.Mock + ).mockReturnValue(undefined); + (mockPlatformDependencies.metrics.isEnabled as jest.Mock).mockReturnValue( + true, + ); + + // Reset TradingReadinessCache mock state (using imported mocked module) + const mockedCache = TradingReadinessCache as jest.Mocked< + typeof TradingReadinessCache + >; + mockedCache.get.mockReturnValue(undefined); + mockedCache.getBuilderFee.mockReturnValue(undefined); + mockedCache.getReferral.mockReturnValue(undefined); + mockedCache.isInFlight.mockReturnValue(undefined); + mockedCache.setInFlight.mockReturnValue(jest.fn()); + + // Initialize mock stream manager instance + mockStreamManagerInstance = { + clearAllChannels: jest.fn(), + }; + + // Create mocked service instances using factory functions + 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; + + mockWalletService = { + setTestnetMode: jest.fn(), + getCurrentAccountId: jest + .fn() + .mockReturnValue( + 'eip155:42161:0x1234567890123456789012345678901234567890', + ), + createWalletAdapter: jest.fn().mockReturnValue({ + request: jest + .fn() + .mockResolvedValue(['0x1234567890123456789012345678901234567890']), + }), + getUserAddress: jest + .fn() + .mockReturnValue('0x1234567890123456789012345678901234567890'), + getUserAddressWithDefault: jest + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890'), + isKeyringUnlocked: jest.fn().mockReturnValue(true), + isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + } as Partial as jest.Mocked; + + mockSubscriptionService = { + subscribeToPrices: jest.fn().mockResolvedValue(jest.fn()), // Returns Promise + subscribeToPositions: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + subscribeToOrderFills: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + clearAll: jest.fn(), + isPositionsCacheInitialized: jest.fn().mockReturnValue(false), + getCachedPositions: jest.fn().mockReturnValue([]), + updateFeatureFlags: jest.fn().mockResolvedValue(undefined), + // Cache methods used by buildAssetMapping optimization + setDexMetaCache: jest.fn(), + setDexAssetCtxsCache: jest.fn(), + getDexAssetCtxsCache: jest.fn().mockReturnValue(undefined), + // Price cache used by placeOrder, editOrder, closePosition optimizations + getCachedPrice: jest.fn().mockImplementation((symbol: string) => { + const prices: Record = { BTC: '50000', ETH: '3000' }; + return prices[symbol]; + }), + getLastAllMidsSnapshot: jest.fn().mockReturnValue(null), + // Orders cache used by updatePositionTPSL and getOpenOrders + isOrdersCacheInitialized: jest.fn().mockReturnValue(false), + getCachedOrders: jest.fn().mockReturnValue([]), + // Atomic getter - returns null when cache not initialized (prevents race condition) + getOrdersCacheIfInitialized: jest.fn().mockReturnValue(null), + // Abstraction-mode resolved-mode setter (unified account migration) + setUserAbstractionMode: jest.fn(), + } as Partial as jest.Mocked; + + // Mock constructors + MockedHyperLiquidClientService.mockImplementation(() => mockClientService); + MockedHyperLiquidWalletService.mockImplementation(() => mockWalletService); + MockedHyperLiquidSubscriptionService.mockImplementation( + () => mockSubscriptionService, + ); + + // Mock validation + mockValidateOrderParams.mockReturnValue({ isValid: true }); + mockValidateWithdrawalParams.mockReturnValue({ isValid: true }); + mockValidateDepositParams.mockReturnValue({ isValid: true }); + mockValidateCoinExists.mockReturnValue({ isValid: true }); + mockValidateAssetSupport.mockReturnValue({ isValid: true }); + mockValidateBalance.mockReturnValue({ isValid: true }); + const hyperLiquidValidation = jest.requireMock( + '../../../src/utils/hyperLiquidValidation', + ); + hyperLiquidValidation.getSupportedPaths.mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]); + hyperLiquidValidation.getBridgeInfo.mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }); + hyperLiquidValidation.createErrorResult.mockImplementation( + (error: unknown, defaultResponse: Record) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + }), + ); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptHyperLiquidLedgerUpdateToUserHistoryItem.mockImplementation( + (updates: unknown[]) => { + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map(() => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }, + ); + + provider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ], + }); + }); + describe('Constructor and Initialization', () => { + it('initializes with default mainnet configuration', () => { + expect(provider).toBeDefined(); + expect(provider.protocolId).toBe('hyperliquid'); + }); + + it('initializes with testnet configuration', () => { + const testnetProvider = createTestProvider({ isTestnet: true }); + expect(testnetProvider).toBeDefined(); + expect(testnetProvider.protocolId).toBe('hyperliquid'); + }); + + it('initializes provider successfully', async () => { + const result = await provider.initialize(); + + expect(result.success).toBe(true); + expect(mockClientService.initialize).toHaveBeenCalled(); + }); + + it('handles initialization errors', async () => { + mockClientService.initialize.mockImplementationOnce(() => { + throw new Error('Init failed'); + }); + + const result = await provider.initialize(); + + expect(result.success).toBe(false); + expect(result.error).toContain('Init failed'); + }); + + it('initializes with HIP-3 disabled when hip3Enabled is false', async () => { + const disabledProvider = createTestProvider({ + hip3Enabled: false, + }); + + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([ + null, + { name: 'dex1', url: 'https://dex1.example' }, + ]), + }), + ); + + await disabledProvider.initialize(); + + const markets = await disabledProvider.getMarkets(); + expect(Array.isArray(markets)).toBe(true); + }); + + it('falls back to main DEX when perpDexs returns invalid response', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + perpDexs: jest.fn().mockResolvedValue(null), + }), + ); + + await provider.initialize(); + + const markets = await provider.getMarkets(); + expect(Array.isArray(markets)).toBe(true); + }); + + it('does not throw when allowlist contains invalid patterns', () => { + expect(() => { + createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:TSLA', '"bad"pattern"', 'valid:*'], + }); + }).not.toThrow(); + }); + + it('does not throw when blocklist contains invalid patterns', () => { + expect(() => { + createTestProvider({ + hip3Enabled: true, + blocklistMarkets: ['valid:BTC', '"invalid"', 'also:valid'], + }); + }).not.toThrow(); + }); + + it('logs warning for skipped invalid patterns', () => { + createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['"bad"pattern"'], + }); + + expect(mockPlatformDependencies.logger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: expect.objectContaining({ provider: 'hyperliquid' }), + context: expect.objectContaining({ + name: 'HyperLiquidProvider', + data: expect.objectContaining({ + method: 'compilePatternsSafely', + pattern: '"bad"pattern"', + }), + }), + }), + ); + }); + + it('compiles valid patterns even when some are invalid', () => { + const testProvider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:TSLA', '"bad"pattern"', 'valid:*'], + }); + + // Provider should be functional — valid patterns compiled, bad ones skipped + expect(testProvider).toBeDefined(); + expect(testProvider.protocolId).toBe('hyperliquid'); + }); + + it('handles perpDexs array with null entries', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([ + null, + { name: 'dex1', url: 'https://dex1.example' }, + null, + ]), + }), + ); + + await provider.initialize(); + + const markets = await provider.getMarkets(); + expect(Array.isArray(markets)).toBe(true); + }); + }); + + describe('Route Management', () => { + it('gets deposit routes with constraints', () => { + const routes = provider.getDepositRoutes(); + expect(Array.isArray(routes)).toBe(true); + + // Check that routes have constraints + if (routes.length > 0) { + const route = routes[0]; + expect(route.constraints).toBeDefined(); + expect(route.constraints?.minAmount).toBe('1.01'); + expect(route.constraints?.estimatedMinutes).toBe(5); + expect(route.constraints?.fees).toEqual({ + fixed: 1, + token: 'USDC', + }); + } + }); + + it('gets withdrawal routes with constraints', () => { + const routes = provider.getWithdrawalRoutes(); + expect(Array.isArray(routes)).toBe(true); + + // Check that routes have constraints (same as deposit routes) + if (routes.length > 0) { + const route = routes[0]; + expect(route.constraints).toBeDefined(); + expect(route.constraints?.minAmount).toBe('1.01'); + expect(route.constraints?.estimatedMinutes).toBe(5); + expect(route.constraints?.fees).toEqual({ + fixed: 1, + token: 'USDC', + }); + } + }); + + it('filters routes by parameters', () => { + const params = { isTestnet: true }; + const routes = provider.getDepositRoutes(params); + + expect(Array.isArray(routes)).toBe(true); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.misc.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.misc.test.ts new file mode 100644 index 0000000000..61b0711108 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.misc.test.ts @@ -0,0 +1,726 @@ +/* eslint-disable */ +jest.mock('@nktkas/hyperliquid', () => ({})); + +import type { CaipAssetId, Hex } from '@metamask/utils'; + +import { CandlePeriod } from '../../../src/constants/chartConfig'; +import { + BUILDER_FEE_CONFIG, + REFERRAL_CONFIG, +} from '../../../src/constants/hyperLiquidConfig'; +import { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from '../../../src/constants/transactionsHistoryConfig'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes'; +import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider'; +import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService'; +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService'; +import { TradingReadinessCache } from '../../../src/services/TradingReadinessCache'; +import type { + ClosePositionParams, + DepositParams, + Order, + PerpsPlatformDependencies, + LiveDataConfig, + OrderParams, +} from '../../../src/types'; +import { + validateAssetSupport, + validateBalance, + validateCoinExists, + validateDepositParams, + validateOrderParams, + validateWithdrawalParams, +} from '../../../src/utils/hyperLiquidValidation'; +import { createStandaloneInfoClient } from '../../../src/utils/standaloneInfoClient'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks'; + +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidWalletService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); +// Mock stream manager - will be set up in test +let mockStreamManagerInstance: any; +const mockGetStreamManagerInstance = jest.fn(() => mockStreamManagerInstance); +jest.mock( + '../../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: mockGetStreamManagerInstance, + }), + { virtual: true }, +); + +// Mock standalone info client for standalone mode tests +let mockStandaloneInfoClient: any; +jest.mock('../../../src/utils/standaloneInfoClient', () => ({ + ...jest.requireActual('../../../src/utils/standaloneInfoClient'), + createStandaloneInfoClient: jest.fn(() => mockStandaloneInfoClient), +})); + +jest.mock('../../../src/utils/hyperLiquidValidation', () => ({ + validateOrderParams: jest.fn(), + validateWithdrawalParams: jest.fn(), + validateDepositParams: jest.fn(), + validateCoinExists: jest.fn(), + validateAssetSupport: jest.fn(), + validateBalance: jest.fn(), + getSupportedPaths: jest + .fn() + .mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]), + getBridgeInfo: jest.fn().mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }), + createErrorResult: jest.fn((error, defaultResponse) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + })), +})); + +// Mock adapter functions +jest.mock('../../../src/utils/hyperLiquidAdapter', () => { + const actual = jest.requireActual('../../../src/utils/hyperLiquidAdapter'); + return { + ...actual, + adaptHyperLiquidLedgerUpdateToUserHistoryItem: jest.fn((updates) => { + // Return mock history items based on input + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map((_update: unknown) => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }), + }; +}); + +// Mock TradingReadinessCache - global singleton for signing operation caching +// Use jest.createMockFromModule for proper mock creation +jest.mock('../../../src/services/TradingReadinessCache'); + +const MockedHyperLiquidClientService = + HyperLiquidClientService as jest.MockedClass; +const MockedHyperLiquidWalletService = + HyperLiquidWalletService as jest.MockedClass; +const MockedHyperLiquidSubscriptionService = + HyperLiquidSubscriptionService as jest.MockedClass< + typeof HyperLiquidSubscriptionService + >; +const mockValidateOrderParams = validateOrderParams as jest.MockedFunction< + typeof validateOrderParams +>; +const mockValidateWithdrawalParams = + validateWithdrawalParams as jest.MockedFunction< + typeof validateWithdrawalParams + >; +const mockValidateDepositParams = validateDepositParams as jest.MockedFunction< + typeof validateDepositParams +>; +const mockValidateCoinExists = validateCoinExists as jest.MockedFunction< + typeof validateCoinExists +>; +const mockValidateAssetSupport = validateAssetSupport as jest.MockedFunction< + typeof validateAssetSupport +>; +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'), + 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(); + +const mockMessenger = createMockMessenger(); + +/** + * Helper to create HyperLiquidProvider with mock platform dependencies + * @param options + * @param options.isTestnet + * @param options.hip3Enabled + * @param options.allowlistMarkets + * @param options.blocklistMarkets + * @param options.useUnifiedAccount + */ +const createTestProvider = ( + options: { + isTestnet?: boolean; + hip3Enabled?: boolean; + allowlistMarkets?: string[]; + blocklistMarkets?: string[]; + useUnifiedAccount?: boolean; + initialAssetMapping?: [string, number][]; + } = {}, +): HyperLiquidProvider => + new HyperLiquidProvider({ + ...options, + platformDependencies: mockPlatformDependencies, + messenger: mockMessenger, + }); + +describe('HyperLiquidProvider', () => { + let provider: HyperLiquidProvider; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionService: jest.Mocked; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + ( + mockPlatformDependencies.marketDataFormatters.formatVolume as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(0)); + ( + mockPlatformDependencies.marketDataFormatters.formatPerpsFiat as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(2)); + ( + mockPlatformDependencies.marketDataFormatters + .formatPercentage as jest.Mock + ).mockImplementation((value: number) => `${value.toFixed(2)}%`); + ( + mockPlatformDependencies.featureFlags.validateVersionGated as jest.Mock + ).mockReturnValue(undefined); + (mockPlatformDependencies.metrics.isEnabled as jest.Mock).mockReturnValue( + true, + ); + + // Reset TradingReadinessCache mock state (using imported mocked module) + const mockedCache = TradingReadinessCache as jest.Mocked< + typeof TradingReadinessCache + >; + mockedCache.get.mockReturnValue(undefined); + mockedCache.getBuilderFee.mockReturnValue(undefined); + mockedCache.getReferral.mockReturnValue(undefined); + mockedCache.isInFlight.mockReturnValue(undefined); + mockedCache.setInFlight.mockReturnValue(jest.fn()); + + // Initialize mock stream manager instance + mockStreamManagerInstance = { + clearAllChannels: jest.fn(), + }; + + // Create mocked service instances using factory functions + 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; + + mockWalletService = { + setTestnetMode: jest.fn(), + getCurrentAccountId: jest + .fn() + .mockReturnValue( + 'eip155:42161:0x1234567890123456789012345678901234567890', + ), + createWalletAdapter: jest.fn().mockReturnValue({ + request: jest + .fn() + .mockResolvedValue(['0x1234567890123456789012345678901234567890']), + }), + getUserAddress: jest + .fn() + .mockReturnValue('0x1234567890123456789012345678901234567890'), + getUserAddressWithDefault: jest + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890'), + isKeyringUnlocked: jest.fn().mockReturnValue(true), + isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + } as Partial as jest.Mocked; + + mockSubscriptionService = { + subscribeToPrices: jest.fn().mockResolvedValue(jest.fn()), // Returns Promise + subscribeToPositions: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + subscribeToOrderFills: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + clearAll: jest.fn(), + isPositionsCacheInitialized: jest.fn().mockReturnValue(false), + getCachedPositions: jest.fn().mockReturnValue([]), + updateFeatureFlags: jest.fn().mockResolvedValue(undefined), + // Cache methods used by buildAssetMapping optimization + setDexMetaCache: jest.fn(), + setDexAssetCtxsCache: jest.fn(), + getDexAssetCtxsCache: jest.fn().mockReturnValue(undefined), + // Price cache used by placeOrder, editOrder, closePosition optimizations + getCachedPrice: jest.fn().mockImplementation((symbol: string) => { + const prices: Record = { BTC: '50000', ETH: '3000' }; + return prices[symbol]; + }), + getLastAllMidsSnapshot: jest.fn().mockReturnValue(null), + // Orders cache used by updatePositionTPSL and getOpenOrders + isOrdersCacheInitialized: jest.fn().mockReturnValue(false), + getCachedOrders: jest.fn().mockReturnValue([]), + // Atomic getter - returns null when cache not initialized (prevents race condition) + getOrdersCacheIfInitialized: jest.fn().mockReturnValue(null), + // Abstraction-mode resolved-mode setter (unified account migration) + setUserAbstractionMode: jest.fn(), + } as Partial as jest.Mocked; + + // Mock constructors + MockedHyperLiquidClientService.mockImplementation(() => mockClientService); + MockedHyperLiquidWalletService.mockImplementation(() => mockWalletService); + MockedHyperLiquidSubscriptionService.mockImplementation( + () => mockSubscriptionService, + ); + + // Mock validation + mockValidateOrderParams.mockReturnValue({ isValid: true }); + mockValidateWithdrawalParams.mockReturnValue({ isValid: true }); + mockValidateDepositParams.mockReturnValue({ isValid: true }); + mockValidateCoinExists.mockReturnValue({ isValid: true }); + mockValidateAssetSupport.mockReturnValue({ isValid: true }); + mockValidateBalance.mockReturnValue({ isValid: true }); + const hyperLiquidValidation = jest.requireMock( + '../../../src/utils/hyperLiquidValidation', + ); + hyperLiquidValidation.getSupportedPaths.mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]); + hyperLiquidValidation.getBridgeInfo.mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }); + hyperLiquidValidation.createErrorResult.mockImplementation( + (error: unknown, defaultResponse: Record) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + }), + ); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptHyperLiquidLedgerUpdateToUserHistoryItem.mockImplementation( + (updates: unknown[]) => { + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map(() => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }, + ); + + provider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ], + }); + }); + describe('fetchHistoricalCandles', () => { + const options = { + symbol: 'BTC', + interval: CandlePeriod.OneHour, + limit: 100, + }; + + it('returns candle data from clientService', async () => { + // Arrange + const mockCandles = { + symbol: 'BTC', + interval: CandlePeriod.OneHour, + candles: [ + { + open: '50000', + close: '51000', + high: '51500', + low: '49500', + volume: '100', + time: 1000, + }, + ], + }; + mockClientService.fetchHistoricalCandles = jest + .fn() + .mockResolvedValue(mockCandles); + + // Act + const result = await provider.fetchHistoricalCandles(options); + + // Assert + expect(mockClientService.ensureInitialized).toHaveBeenCalled(); + expect(mockClientService.fetchHistoricalCandles).toHaveBeenCalledWith( + options, + ); + expect(result).toStrictEqual(mockCandles); + }); + + it('returns empty candles when clientService returns null', async () => { + // Arrange + mockClientService.fetchHistoricalCandles = jest + .fn() + .mockResolvedValue(null); + + // Act + const result = await provider.fetchHistoricalCandles(options); + + // Assert + expect(result).toStrictEqual({ + symbol: options.symbol, + interval: options.interval, + candles: [], + }); + }); + }); + + describe('getFunding', () => { + const makeFundingRecord = (time: number, coin = 'BTC') => ({ + delta: { coin, usdc: '0.001', fundingRate: '0.0001' }, + hash: `0x${time.toString(16)}`, + time, + }); + + it('returns funding records for the default 30-day window with a single API call', async () => { + // Arrange + const records = [ + makeFundingRecord(Date.now() - 2000, 'ETH'), + makeFundingRecord(Date.now() - 1000, 'BTC'), + ]; + const mockUserFunding = jest.fn().mockResolvedValue(records); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue( + createMockInfoClient({ userFunding: mockUserFunding }), + ); + + // Act + const result = await provider.getFunding(); + + // Assert — exactly one API call for the default 30-day window + expect(mockUserFunding).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(2); + expect(result[0].symbol).toBe('ETH'); + expect(result[1].symbol).toBe('BTC'); + }); + + it('auto-splits window when API returns the record cap and recovers all records from sub-windows', async () => { + // Arrange — first call hits the cap (500 records); the function discards + // those and refetches the two halves. Each half is under the cap. + const apiLimit = + PERPS_TRANSACTIONS_HISTORY_CONSTANTS.FUNDING_HISTORY_API_LIMIT; + const capRecords = Array.from({ length: apiLimit }, (_, i) => + makeFundingRecord(1_700_000_000_000 + i * 1000), + ); + const leftHalfRecords = [makeFundingRecord(1_700_000_001_000)]; + const rightHalfRecords = [ + makeFundingRecord(1_700_000_002_000), + makeFundingRecord(1_700_000_003_000), + ]; + + const mockUserFunding = jest + .fn() + .mockResolvedValueOnce(capRecords) + .mockResolvedValueOnce(leftHalfRecords) + .mockResolvedValueOnce(rightHalfRecords); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue( + createMockInfoClient({ userFunding: mockUserFunding }), + ); + + // Act — 2-day explicit window (> 1 h minimum) so splitting is allowed + const endTime = 1_700_000_100_000; + const twoDaysMs = 2 * 24 * 60 * 60 * 1000; + const result = await provider.getFunding({ + startTime: endTime - twoDaysMs, + endTime, + }); + + // Assert — 3 calls total: original window + left half + right half + expect(mockUserFunding).toHaveBeenCalledTimes(3); + // Combined result comes from the sub-windows (not the capped initial call) + expect(result).toHaveLength( + leftHalfRecords.length + rightHalfRecords.length, + ); + }); + + it('does not split when window is at or below the minimum split size', async () => { + // Arrange — even with a full 500-record response the 1-hour window must + // not recurse (prevents infinite recursion at the minimum boundary) + const apiLimit = + PERPS_TRANSACTIONS_HISTORY_CONSTANTS.FUNDING_HISTORY_API_LIMIT; + const capRecords = Array.from({ length: apiLimit }, (_, i) => + makeFundingRecord(Date.now() - i * 1000), + ); + const mockUserFunding = jest.fn().mockResolvedValue(capRecords); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue( + createMockInfoClient({ userFunding: mockUserFunding }), + ); + + // Act — 1-hour window equals minSplitWindowMs; no split should occur + const oneHourMs = 60 * 60 * 1000; + const endTime = Date.now(); + await provider.getFunding({ startTime: endTime - oneHourMs, endTime }); + + // Assert — exactly one call, no recursive splitting + expect(mockUserFunding).toHaveBeenCalledTimes(1); + }); + + it('passes explicit startTime and endTime directly to the API', async () => { + // Arrange + const mockUserFunding = jest.fn().mockResolvedValue([]); + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue( + createMockInfoClient({ userFunding: mockUserFunding }), + ); + + // Act + const startTime = 1_700_000_000_000; + const endTime = 1_702_592_000_000; // startTime + 30 days + await provider.getFunding({ startTime, endTime }); + + // Assert — explicit bounds forwarded verbatim to the API + expect(mockUserFunding).toHaveBeenCalledWith( + expect.objectContaining({ startTime, endTime }), + ); + }); + }); + + describe('buildAssetMapping with perpDexs network failure', () => { + it('completes asset mapping using fallback when perpDexs throws', async () => { + // Arrange — perpDexs throws, so getValidatedDexs falls back to [null] + const freshProvider = createTestProvider({ hip3Enabled: true }); + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + perpDexs: jest.fn().mockRejectedValue(new Error('Network timeout')), + }), + ); + MockedHyperLiquidClientService.mockImplementation( + () => mockClientService, + ); + + // Act — triggering ensureReady -> buildAssetMapping via getPositions + await freshProvider.initialize(); + const markets = await freshProvider.getMarkets(); + + // Assert — provider remains functional with main DEX only + expect(Array.isArray(markets)).toBe(true); + }); + }); + + describe('getExchangeClient escape hatch', () => { + it('delegates to the client service and resolves with the underlying ExchangeClient', async () => { + const sentinel = mockClientService.getExchangeClient(); + await expect(provider.getExchangeClient()).resolves.toBe(sentinel); + }); + + it('propagates errors thrown by the client service', async () => { + const bomb = new Error('client not initialized'); + mockClientService.getExchangeClient = jest.fn(() => { + throw bomb; + }); + await expect(provider.getExchangeClient()).rejects.toBe(bomb); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts new file mode 100644 index 0000000000..2efcdcc879 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts @@ -0,0 +1,1510 @@ +/* eslint-disable */ +jest.mock('@nktkas/hyperliquid', () => ({})); + +import type { CaipAssetId, Hex } from '@metamask/utils'; + +import { CandlePeriod } from '../../../src/constants/chartConfig'; +import { + BUILDER_FEE_CONFIG, + REFERRAL_CONFIG, +} from '../../../src/constants/hyperLiquidConfig'; +import { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from '../../../src/constants/transactionsHistoryConfig'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes'; +import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider'; +import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService'; +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService'; +import { TradingReadinessCache } from '../../../src/services/TradingReadinessCache'; +import type { + ClosePositionParams, + DepositParams, + Order, + PerpsPlatformDependencies, + LiveDataConfig, + OrderParams, +} from '../../../src/types'; +import { + validateAssetSupport, + validateBalance, + validateCoinExists, + validateDepositParams, + validateOrderParams, + validateWithdrawalParams, +} from '../../../src/utils/hyperLiquidValidation'; +import { createStandaloneInfoClient } from '../../../src/utils/standaloneInfoClient'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks'; + +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidWalletService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); +// Mock stream manager - will be set up in test +let mockStreamManagerInstance: any; +const mockGetStreamManagerInstance = jest.fn(() => mockStreamManagerInstance); +jest.mock( + '../../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: mockGetStreamManagerInstance, + }), + { virtual: true }, +); + +// Mock standalone info client for standalone mode tests +let mockStandaloneInfoClient: any; +jest.mock('../../../src/utils/standaloneInfoClient', () => ({ + ...jest.requireActual('../../../src/utils/standaloneInfoClient'), + createStandaloneInfoClient: jest.fn(() => mockStandaloneInfoClient), +})); + +jest.mock('../../../src/utils/hyperLiquidValidation', () => ({ + validateOrderParams: jest.fn(), + validateWithdrawalParams: jest.fn(), + validateDepositParams: jest.fn(), + validateCoinExists: jest.fn(), + validateAssetSupport: jest.fn(), + validateBalance: jest.fn(), + getSupportedPaths: jest + .fn() + .mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]), + getBridgeInfo: jest.fn().mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }), + createErrorResult: jest.fn((error, defaultResponse) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + })), +})); + +// Mock adapter functions +jest.mock('../../../src/utils/hyperLiquidAdapter', () => { + const actual = jest.requireActual('../../../src/utils/hyperLiquidAdapter'); + return { + ...actual, + adaptHyperLiquidLedgerUpdateToUserHistoryItem: jest.fn((updates) => { + // Return mock history items based on input + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map((_update: unknown) => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }), + }; +}); + +// Mock TradingReadinessCache - global singleton for signing operation caching +// Use jest.createMockFromModule for proper mock creation +jest.mock('../../../src/services/TradingReadinessCache'); + +const MockedHyperLiquidClientService = + HyperLiquidClientService as jest.MockedClass; +const MockedHyperLiquidWalletService = + HyperLiquidWalletService as jest.MockedClass; +const MockedHyperLiquidSubscriptionService = + HyperLiquidSubscriptionService as jest.MockedClass< + typeof HyperLiquidSubscriptionService + >; +const mockValidateOrderParams = validateOrderParams as jest.MockedFunction< + typeof validateOrderParams +>; +const mockValidateWithdrawalParams = + validateWithdrawalParams as jest.MockedFunction< + typeof validateWithdrawalParams + >; +const mockValidateDepositParams = validateDepositParams as jest.MockedFunction< + typeof validateDepositParams +>; +const mockValidateCoinExists = validateCoinExists as jest.MockedFunction< + typeof validateCoinExists +>; +const mockValidateAssetSupport = validateAssetSupport as jest.MockedFunction< + typeof validateAssetSupport +>; +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'), + 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(); + +const mockMessenger = createMockMessenger(); + +/** + * Helper to create HyperLiquidProvider with mock platform dependencies + * @param options + * @param options.isTestnet + * @param options.hip3Enabled + * @param options.allowlistMarkets + * @param options.blocklistMarkets + * @param options.useUnifiedAccount + */ +const createTestProvider = ( + options: { + isTestnet?: boolean; + hip3Enabled?: boolean; + allowlistMarkets?: string[]; + blocklistMarkets?: string[]; + useUnifiedAccount?: boolean; + initialAssetMapping?: [string, number][]; + } = {}, +): HyperLiquidProvider => + new HyperLiquidProvider({ + ...options, + platformDependencies: mockPlatformDependencies, + messenger: mockMessenger, + }); + +describe('HyperLiquidProvider', () => { + let provider: HyperLiquidProvider; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionService: jest.Mocked; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + ( + mockPlatformDependencies.marketDataFormatters.formatVolume as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(0)); + ( + mockPlatformDependencies.marketDataFormatters.formatPerpsFiat as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(2)); + ( + mockPlatformDependencies.marketDataFormatters + .formatPercentage as jest.Mock + ).mockImplementation((value: number) => `${value.toFixed(2)}%`); + ( + mockPlatformDependencies.featureFlags.validateVersionGated as jest.Mock + ).mockReturnValue(undefined); + (mockPlatformDependencies.metrics.isEnabled as jest.Mock).mockReturnValue( + true, + ); + + // Reset TradingReadinessCache mock state (using imported mocked module) + const mockedCache = TradingReadinessCache as jest.Mocked< + typeof TradingReadinessCache + >; + mockedCache.get.mockReturnValue(undefined); + mockedCache.getBuilderFee.mockReturnValue(undefined); + mockedCache.getReferral.mockReturnValue(undefined); + mockedCache.isInFlight.mockReturnValue(undefined); + mockedCache.setInFlight.mockReturnValue(jest.fn()); + + // Initialize mock stream manager instance + mockStreamManagerInstance = { + clearAllChannels: jest.fn(), + }; + + // Create mocked service instances using factory functions + 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; + + mockWalletService = { + setTestnetMode: jest.fn(), + getCurrentAccountId: jest + .fn() + .mockReturnValue( + 'eip155:42161:0x1234567890123456789012345678901234567890', + ), + createWalletAdapter: jest.fn().mockReturnValue({ + request: jest + .fn() + .mockResolvedValue(['0x1234567890123456789012345678901234567890']), + }), + getUserAddress: jest + .fn() + .mockReturnValue('0x1234567890123456789012345678901234567890'), + getUserAddressWithDefault: jest + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890'), + isKeyringUnlocked: jest.fn().mockReturnValue(true), + isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + } as Partial as jest.Mocked; + + mockSubscriptionService = { + subscribeToPrices: jest.fn().mockResolvedValue(jest.fn()), // Returns Promise + subscribeToPositions: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + subscribeToOrderFills: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + clearAll: jest.fn(), + isPositionsCacheInitialized: jest.fn().mockReturnValue(false), + getCachedPositions: jest.fn().mockReturnValue([]), + updateFeatureFlags: jest.fn().mockResolvedValue(undefined), + // Cache methods used by buildAssetMapping optimization + setDexMetaCache: jest.fn(), + setDexAssetCtxsCache: jest.fn(), + getDexAssetCtxsCache: jest.fn().mockReturnValue(undefined), + // Price cache used by placeOrder, editOrder, closePosition optimizations + getCachedPrice: jest.fn().mockImplementation((symbol: string) => { + const prices: Record = { BTC: '50000', ETH: '3000' }; + return prices[symbol]; + }), + getLastAllMidsSnapshot: jest.fn().mockReturnValue(null), + // Orders cache used by updatePositionTPSL and getOpenOrders + isOrdersCacheInitialized: jest.fn().mockReturnValue(false), + getCachedOrders: jest.fn().mockReturnValue([]), + // Atomic getter - returns null when cache not initialized (prevents race condition) + getOrdersCacheIfInitialized: jest.fn().mockReturnValue(null), + // Abstraction-mode resolved-mode setter (unified account migration) + setUserAbstractionMode: jest.fn(), + } as Partial as jest.Mocked; + + // Mock constructors + MockedHyperLiquidClientService.mockImplementation(() => mockClientService); + MockedHyperLiquidWalletService.mockImplementation(() => mockWalletService); + MockedHyperLiquidSubscriptionService.mockImplementation( + () => mockSubscriptionService, + ); + + // Mock validation + mockValidateOrderParams.mockReturnValue({ isValid: true }); + mockValidateWithdrawalParams.mockReturnValue({ isValid: true }); + mockValidateDepositParams.mockReturnValue({ isValid: true }); + mockValidateCoinExists.mockReturnValue({ isValid: true }); + mockValidateAssetSupport.mockReturnValue({ isValid: true }); + mockValidateBalance.mockReturnValue({ isValid: true }); + const hyperLiquidValidation = jest.requireMock( + '../../../src/utils/hyperLiquidValidation', + ); + hyperLiquidValidation.getSupportedPaths.mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]); + hyperLiquidValidation.getBridgeInfo.mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }); + hyperLiquidValidation.createErrorResult.mockImplementation( + (error: unknown, defaultResponse: Record) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + }), + ); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptHyperLiquidLedgerUpdateToUserHistoryItem.mockImplementation( + (updates: unknown[]) => { + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map(() => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }, + ); + + provider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ], + }); + }); + describe('WebSocket connection state methods', () => { + // Import actual enum to ensure type compatibility + const { WebSocketConnectionState } = jest.requireActual( + '../../../src/services/HyperLiquidClientService', + ); + + beforeEach(() => { + // Add WebSocket methods to mock client service + mockClientService.getConnectionState = jest + .fn() + .mockReturnValue(WebSocketConnectionState.Connected); + mockClientService.subscribeToConnectionState = jest + .fn() + .mockReturnValue(jest.fn()); + mockClientService.reconnect = jest.fn().mockResolvedValue(undefined); + }); + + it('getWebSocketConnectionState delegates to clientService', () => { + // Arrange + mockClientService.getConnectionState.mockReturnValue( + WebSocketConnectionState.Connected, + ); + + // Act + const result = provider.getWebSocketConnectionState(); + + // Assert + expect(result).toBe(WebSocketConnectionState.Connected); + expect(mockClientService.getConnectionState).toHaveBeenCalled(); + }); + + it('subscribeToConnectionState delegates to clientService', () => { + // Arrange + const mockUnsubscribe = jest.fn(); + mockClientService.subscribeToConnectionState.mockReturnValue( + mockUnsubscribe, + ); + const listener = jest.fn(); + + // Act + const unsubscribe = provider.subscribeToConnectionState(listener); + + // Assert + expect(mockClientService.subscribeToConnectionState).toHaveBeenCalledWith( + listener, + ); + expect(unsubscribe).toBe(mockUnsubscribe); + }); + + it('reconnect delegates to clientService', async () => { + // Arrange + mockClientService.reconnect.mockResolvedValue(undefined); + + // Act + await provider.reconnect(); + + // Assert + expect(mockClientService.reconnect).toHaveBeenCalled(); + }); + }); + + describe('getOrFetchFills - Cache-First Pattern', () => { + const mockFills = [ + { + orderId: '123', + symbol: 'BTC', + side: 'buy' as const, + size: '0.1', + price: '50000', + fee: '5', + feeToken: 'USDC', + timestamp: Date.now(), + pnl: '100', + direction: 'Open Long', + success: true, + }, + { + orderId: '124', + symbol: 'ETH', + side: 'sell' as const, + size: '1.0', + price: '3000', + fee: '3', + feeToken: 'USDC', + timestamp: Date.now() - 1000, + pnl: '-50', + direction: 'Close Short', + success: true, + }, + ]; + + it('uses cached fills when cache is initialized', async () => { + // Arrange + mockSubscriptionService.getFillsCacheIfInitialized = jest + .fn() + .mockReturnValue(mockFills); + + // Act + const result = await provider.getOrFetchFills({}); + + // Assert + expect(result).toEqual(mockFills); + expect( + mockSubscriptionService.getFillsCacheIfInitialized, + ).toHaveBeenCalled(); + // Should NOT call REST API + expect(mockClientService.getInfoClient).not.toHaveBeenCalled(); + }); + + it('falls back to REST API when cache returns null', async () => { + // Arrange - cache not initialized + mockSubscriptionService.getFillsCacheIfInitialized = jest + .fn() + .mockReturnValue(null); + + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + userFills: jest.fn().mockResolvedValue([ + { + oid: 125, + coin: 'BTC', + side: 'B', + sz: '0.5', + px: '49000', + fee: '2', + feeToken: 'USDC', + time: Date.now(), + closedPnl: '50', + dir: 'Open Long', + }, + ]), + }); + + // Act + const result = await provider.getOrFetchFills({}); + + // Assert + expect( + mockSubscriptionService.getFillsCacheIfInitialized, + ).toHaveBeenCalled(); + expect(mockClientService.getInfoClient).toHaveBeenCalled(); + expect(result.length).toBe(1); + expect(result[0].symbol).toBe('BTC'); + }); + + it('filters cached fills by startTime', async () => { + // Arrange + const now = Date.now(); + const fillsWithDifferentTimes = [ + { ...mockFills[0], timestamp: now }, + { ...mockFills[1], timestamp: now - 100000 }, // Older fill + ]; + mockSubscriptionService.getFillsCacheIfInitialized = jest + .fn() + .mockReturnValue(fillsWithDifferentTimes); + + // Act - filter to only include recent fills + const result = await provider.getOrFetchFills({ startTime: now - 50000 }); + + // Assert - should only include the more recent fill + expect(result.length).toBe(1); + expect(result[0].timestamp).toBe(now); + }); + + it('filters cached fills by symbol', async () => { + // Arrange + mockSubscriptionService.getFillsCacheIfInitialized = jest + .fn() + .mockReturnValue(mockFills); + + // Act - filter to only BTC fills + const result = await provider.getOrFetchFills({ symbol: 'BTC' }); + + // Assert - should only include BTC fill + expect(result.length).toBe(1); + expect(result[0].symbol).toBe('BTC'); + }); + + it('filters cached fills by both startTime and symbol', async () => { + // Arrange + const now = Date.now(); + const fillsWithDifferentTimesAndSymbols = [ + { ...mockFills[0], symbol: 'BTC', timestamp: now }, + { ...mockFills[0], symbol: 'BTC', timestamp: now - 100000 }, + { ...mockFills[0], symbol: 'ETH', timestamp: now }, + ]; + mockSubscriptionService.getFillsCacheIfInitialized = jest + .fn() + .mockReturnValue(fillsWithDifferentTimesAndSymbols); + + // Act - filter to recent BTC fills only + const result = await provider.getOrFetchFills({ + startTime: now - 50000, + symbol: 'BTC', + }); + + // Assert - should only include recent BTC fill + expect(result.length).toBe(1); + expect(result[0].symbol).toBe('BTC'); + expect(result[0].timestamp).toBe(now); + }); + + it('returns all fills when no filter params provided', async () => { + // Arrange + mockSubscriptionService.getFillsCacheIfInitialized = jest + .fn() + .mockReturnValue(mockFills); + + // Act - no filter params + const result = await provider.getOrFetchFills(); + + // Assert - should return all fills + expect(result).toEqual(mockFills); + }); + + it('returns empty array when cache is initialized but empty', async () => { + // Arrange - cache initialized but no fills + mockSubscriptionService.getFillsCacheIfInitialized = jest + .fn() + .mockReturnValue([]); + + // Act + const result = await provider.getOrFetchFills({}); + + // Assert + expect(result).toEqual([]); + // Should NOT call REST API since cache is initialized + expect(mockClientService.getInfoClient).not.toHaveBeenCalled(); + }); + }); + + describe('standalone mode', () => { + const mockUserAddress = '0xabcdef1234567890abcdef1234567890abcdef12'; + const mockCreateStandaloneInfoClient = + createStandaloneInfoClient as jest.MockedFunction< + typeof createStandaloneInfoClient + >; + + beforeEach(() => { + // Reset standalone client mock + mockStandaloneInfoClient = { + clearinghouseState: jest.fn(), + frontendOpenOrders: jest.fn(), + perpDexs: jest.fn().mockResolvedValue([null]), + spotClearinghouseState: jest.fn().mockResolvedValue({ balances: [] }), + // Mode-aware fold gate requires userAbstraction on standalone info + // clients as well; default to unifiedAccount for pre-existing tests. + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + }; + mockCreateStandaloneInfoClient.mockImplementation( + () => mockStandaloneInfoClient, + ); + }); + + describe('getPositions with standalone mode', () => { + it('returns positions via standalone client when standalone mode enabled', async () => { + // Arrange + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.5', + entryPx: '45000', + positionValue: '22500', + unrealizedPnl: '500', + marginUsed: '2250', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '40000', + maxLeverage: 50, + returnOnEquity: '22.22', + cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + }, + type: 'oneWay', + }, + ], + marginSummary: { + totalMarginUsed: '2250', + accountValue: '25000', + }, + }); + + // Act + const positions = await provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert + expect(mockCreateStandaloneInfoClient).toHaveBeenCalledWith({ + isTestnet: false, + }); + expect( + mockStandaloneInfoClient.clearinghouseState, + ).toHaveBeenCalledWith({ user: mockUserAddress }); + expect(positions).toHaveLength(1); + expect(positions[0].symbol).toBe('BTC'); + expect(positions[0].size).toBe('0.5'); + }); + + it('filters zero-size positions in standalone mode', async () => { + // Arrange - include positions with zero size + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.5', + entryPx: '45000', + positionValue: '22500', + unrealizedPnl: '500', + marginUsed: '2250', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '40000', + maxLeverage: 50, + returnOnEquity: '22.22', + cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, + }, + type: 'oneWay', + }, + { + position: { + coin: 'ETH', + szi: '0', // Zero size - should be filtered out + entryPx: '3000', + positionValue: '0', + unrealizedPnl: '0', + marginUsed: '0', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '0', + maxLeverage: 50, + returnOnEquity: '0', + cumFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + }, + type: 'oneWay', + }, + ], + marginSummary: { + totalMarginUsed: '2250', + accountValue: '25000', + }, + }); + + // Act + const positions = await provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert - ETH position with zero size should be filtered out + expect(positions).toHaveLength(1); + expect(positions[0].symbol).toBe('BTC'); + }); + + it('uses testnet endpoint when provider is in testnet mode', async () => { + // Arrange - override isTestnetMode to return true for this test + mockClientService.isTestnetMode.mockReturnValue(true); + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [], + marginSummary: { totalMarginUsed: '0', accountValue: '0' }, + }); + + // Act + await provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert + expect(mockCreateStandaloneInfoClient).toHaveBeenCalledWith({ + isTestnet: true, + }); + }); + + it('returns empty array when standalone client fails', async () => { + // Arrange - getPositions catches errors and returns empty array + mockStandaloneInfoClient.clearinghouseState.mockRejectedValue( + new Error('Network error'), + ); + + // Act + const positions = await provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert - returns empty array instead of throwing (matches implementation) + expect(positions).toEqual([]); + }); + }); + + describe('getAccountState with standalone mode', () => { + it('returns account state via standalone client when standalone mode enabled', async () => { + // Arrange + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [], + marginSummary: { + totalMarginUsed: '1000', + accountValue: '50000', + }, + withdrawable: '45000', + crossMarginSummary: { + accountValue: '50000', + totalMarginUsed: '1000', + }, + }); + + // Act + const accountState = await provider.getAccountState({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert + expect(mockCreateStandaloneInfoClient).toHaveBeenCalledWith({ + isTestnet: false, + }); + expect( + mockStandaloneInfoClient.clearinghouseState, + ).toHaveBeenCalledWith({ user: mockUserAddress }); + expect(accountState.totalBalance).toBeDefined(); + }); + + it('uses testnet endpoint when provider is in testnet mode', async () => { + // Arrange - override isTestnetMode to return true for this test + mockClientService.isTestnetMode.mockReturnValue(true); + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [], + marginSummary: { totalMarginUsed: '0', accountValue: '0' }, + withdrawable: '0', + crossMarginSummary: { accountValue: '0', totalMarginUsed: '0' }, + }); + + // Act + await provider.getAccountState({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert + expect(mockCreateStandaloneInfoClient).toHaveBeenCalledWith({ + isTestnet: true, + }); + }); + + it('returns fallback account state when standalone client fails', async () => { + // Arrange + mockStandaloneInfoClient.clearinghouseState.mockRejectedValue( + new Error('API unavailable'), + ); + + // Act + const result = await provider.getAccountState({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert — all DEX queries failed, aggregateAccountStates([]) returns fallback + expect(result).toEqual({ + spendableBalance: '--', + withdrawableBalance: '--', + totalBalance: '--', + marginUsed: '--', + unrealizedPnl: '--', + returnOnEquity: '--', + }); + }); + }); + + describe('getOpenOrders with standalone mode', () => { + it('returns orders via standalone client when standalone mode enabled', async () => { + // Arrange - mock with all required FrontendOrder fields for adaptOrderFromSDK + mockStandaloneInfoClient.frontendOpenOrders.mockResolvedValue([ + { + coin: 'BTC', + oid: 12345, + side: 'B', + limitPx: '50000', + sz: '0.1', + origSz: '0.1', + timestamp: Date.now(), + orderType: 'Limit', + isTrigger: false, + reduceOnly: false, + isPositionTpsl: false, + cloid: undefined, + children: [], + }, + ]); + + // Act + const orders = await provider.getOpenOrders({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert + expect(mockCreateStandaloneInfoClient).toHaveBeenCalledWith({ + isTestnet: false, + }); + expect( + mockStandaloneInfoClient.frontendOpenOrders, + ).toHaveBeenCalledWith({ user: mockUserAddress }); + expect(orders).toHaveLength(1); + expect(orders[0].symbol).toBe('BTC'); + expect(orders[0].side).toBe('buy'); + }); + + it('returns empty array when standalone client fails', async () => { + // Arrange + mockStandaloneInfoClient.frontendOpenOrders.mockRejectedValue( + new Error('API unavailable'), + ); + + // Act + const orders = await provider.getOpenOrders({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert — all DEX queries failed, flatMap([]) returns empty + expect(orders).toEqual([]); + }); + }); + + describe('multi-DEX standalone mode (HIP-3)', () => { + let hip3Provider: HyperLiquidProvider; + + beforeEach(() => { + hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + }); + + // Mock perpDexs to return main DEX + HIP-3 DEX + mockStandaloneInfoClient.perpDexs.mockResolvedValue([ + null, // main DEX + { name: 'xyz' }, + ]); + }); + + it('returns positions from both main DEX and HIP-3 DEXs', async () => { + // Arrange: main DEX has BTC, xyz DEX has TSLA + mockStandaloneInfoClient.clearinghouseState + .mockResolvedValueOnce({ + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '0.5', + entryPx: '45000', + positionValue: '22500', + unrealizedPnl: '500', + marginUsed: '2250', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '40000', + maxLeverage: 50, + returnOnEquity: '22.22', + cumFunding: { + allTime: '10', + sinceOpen: '5', + sinceChange: '2', + }, + }, + type: 'oneWay', + }, + ], + marginSummary: { + totalMarginUsed: '2250', + accountValue: '25000', + }, + }) + .mockResolvedValueOnce({ + assetPositions: [ + { + position: { + coin: 'TSLA', + szi: '10', + entryPx: '250', + positionValue: '2500', + unrealizedPnl: '100', + marginUsed: '500', + leverage: { type: 'cross', value: 5 }, + liquidationPx: '200', + maxLeverage: 20, + returnOnEquity: '20', + cumFunding: { + allTime: '1', + sinceOpen: '0.5', + sinceChange: '0.1', + }, + }, + type: 'oneWay', + }, + ], + marginSummary: { + totalMarginUsed: '500', + accountValue: '2600', + }, + }); + + // Act + const positions = await hip3Provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert - should have positions from both DEXs + expect(positions).toHaveLength(2); + expect(positions[0].symbol).toBe('BTC'); + expect(positions[1].symbol).toBe('TSLA'); + // Main DEX called without dex param, HIP-3 called with dex param + expect( + mockStandaloneInfoClient.clearinghouseState, + ).toHaveBeenCalledWith({ user: mockUserAddress }); + expect( + mockStandaloneInfoClient.clearinghouseState, + ).toHaveBeenCalledWith({ user: mockUserAddress, dex: 'xyz' }); + }); + + it('falls back to main DEX only when perpDexs() fails', async () => { + // Arrange: perpDexs fails + mockStandaloneInfoClient.perpDexs.mockRejectedValue( + new Error('Network error'), + ); + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '1', + entryPx: '45000', + positionValue: '45000', + unrealizedPnl: '0', + marginUsed: '4500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '40000', + maxLeverage: 50, + returnOnEquity: '0', + cumFunding: { + allTime: '0', + sinceOpen: '0', + sinceChange: '0', + }, + }, + type: 'oneWay', + }, + ], + marginSummary: { + totalMarginUsed: '4500', + accountValue: '45000', + }, + }); + + // Act + const positions = await hip3Provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert - should fall back to main DEX only + expect(positions).toHaveLength(1); + expect(positions[0].symbol).toBe('BTC'); + expect( + mockStandaloneInfoClient.clearinghouseState, + ).toHaveBeenCalledTimes(1); + expect( + mockStandaloneInfoClient.clearinghouseState, + ).toHaveBeenCalledWith({ user: mockUserAddress }); + + // Verify cache was NOT poisoned: a subsequent call should retry perpDexs() + mockStandaloneInfoClient.perpDexs.mockResolvedValue([ + null, + { name: 'xyz' }, + ]); + mockStandaloneInfoClient.clearinghouseState + .mockResolvedValueOnce({ + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '1', + entryPx: '45000', + positionValue: '45000', + unrealizedPnl: '0', + marginUsed: '4500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '40000', + maxLeverage: 50, + returnOnEquity: '0', + cumFunding: { + allTime: '0', + sinceOpen: '0', + sinceChange: '0', + }, + }, + type: 'oneWay', + }, + ], + marginSummary: { + totalMarginUsed: '4500', + accountValue: '45000', + }, + }) + .mockResolvedValueOnce({ + assetPositions: [ + { + position: { + coin: 'TSLA', + szi: '10', + entryPx: '200', + positionValue: '2000', + unrealizedPnl: '50', + marginUsed: '200', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '180', + maxLeverage: 50, + returnOnEquity: '25', + cumFunding: { + allTime: '0', + sinceOpen: '0', + sinceChange: '0', + }, + }, + type: 'oneWay', + }, + ], + marginSummary: { + totalMarginUsed: '200', + accountValue: '2000', + }, + }); + + const retryPositions = await hip3Provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // perpDexs should have been called again (retry after transient failure) + expect(mockStandaloneInfoClient.perpDexs).toHaveBeenCalledTimes(2); + // Should now see positions from both DEXs + expect(retryPositions).toHaveLength(2); + const symbols = retryPositions.map((p) => p.symbol).sort(); + expect(symbols).toEqual(['BTC', 'TSLA']); + }); + + it('returns only main DEX positions when hip3Enabled is false', async () => { + // Arrange: use provider with HIP-3 disabled + const disabledProvider = createTestProvider({ + hip3Enabled: false, + }); + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [ + { + position: { + coin: 'ETH', + szi: '5', + entryPx: '3000', + positionValue: '15000', + unrealizedPnl: '200', + marginUsed: '1500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '2500', + maxLeverage: 50, + returnOnEquity: '13.33', + cumFunding: { + allTime: '5', + sinceOpen: '2', + sinceChange: '1', + }, + }, + type: 'oneWay', + }, + ], + marginSummary: { + totalMarginUsed: '1500', + accountValue: '15200', + }, + }); + + // Act + const positions = await disabledProvider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert - perpDexs should NOT be called + expect(mockStandaloneInfoClient.perpDexs).not.toHaveBeenCalled(); + expect(positions).toHaveLength(1); + expect(positions[0].symbol).toBe('ETH'); + }); + + it('caches validated DEXs across multiple readonly calls', async () => { + // Arrange + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [], + marginSummary: { totalMarginUsed: '0', accountValue: '0' }, + withdrawable: '0', + }); + + // Act - call getPositions twice on same provider instance + await hip3Provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + await hip3Provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert - perpDexs should only be called once (cached on second call) + expect(mockStandaloneInfoClient.perpDexs).toHaveBeenCalledTimes(1); + }); + + it('aggregates account state across multiple DEXs in standalone mode', async () => { + // Arrange: main DEX + xyz DEX both have balances + mockStandaloneInfoClient.clearinghouseState + .mockResolvedValueOnce({ + assetPositions: [], + marginSummary: { + totalMarginUsed: '1000', + accountValue: '50000', + }, + withdrawable: '45000', + }) + .mockResolvedValueOnce({ + assetPositions: [], + marginSummary: { + totalMarginUsed: '500', + accountValue: '5000', + }, + withdrawable: '4000', + }); + + // Act + const accountState = await hip3Provider.getAccountState({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert - balances should be aggregated + expect(parseFloat(accountState.totalBalance)).toBe(55000); + expect(parseFloat(accountState.marginUsed)).toBe(1500); + }); + + it('does not poison fully-initialized cache when standalone perpDexs() fails', async () => { + // Arrange: standalone perpDexs fails (transient network error) + mockStandaloneInfoClient.perpDexs.mockRejectedValue( + new Error('Network error'), + ); + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [], + marginSummary: { totalMarginUsed: '0', accountValue: '0' }, + }); + + // Act: standalone call falls back to main DEX only + await hip3Provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Now set up the fully-initialized path's perpDexs to succeed + const infoClient = mockClientService.getInfoClient(); + (infoClient.perpDexs as jest.Mock).mockResolvedValue([ + null, + { name: 'xyz' }, + ]); + + // Initialize the provider for fully-initialized path + await hip3Provider.initialize(); + + // Act: fully-initialized getPositions should discover HIP-3 DEXs + await hip3Provider.getPositions(); + + // Assert: fully-initialized path called perpDexs (cache was NOT poisoned) + expect(infoClient.perpDexs).toHaveBeenCalled(); + // clearinghouseState should be called for both main + xyz DEX + expect(infoClient.clearinghouseState).toHaveBeenCalledTimes(2); + }); + + it('does not cache invalid perpDexs response in standalone mode', async () => { + // Arrange: perpDexs returns invalid (non-array) response + mockStandaloneInfoClient.perpDexs.mockResolvedValue(null); + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [], + marginSummary: { totalMarginUsed: '0', accountValue: '0' }, + }); + + // Act: first call gets invalid response, falls back to main DEX + await hip3Provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + expect(mockStandaloneInfoClient.perpDexs).toHaveBeenCalledTimes(1); + + // Fix perpDexs to return valid response + mockStandaloneInfoClient.perpDexs.mockResolvedValue([ + null, + { name: 'xyz' }, + ]); + + // Act: second standalone call should retry perpDexs (not cached) + await hip3Provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert: perpDexs was called again on the second call + expect(mockStandaloneInfoClient.perpDexs).toHaveBeenCalledTimes(2); + }); + + it('shares cache between standalone and fully-initialized when standalone succeeds', async () => { + // Arrange: standalone perpDexs succeeds + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [], + marginSummary: { totalMarginUsed: '0', accountValue: '0' }, + }); + + // Act: standalone call succeeds and caches the validated DEXs + await hip3Provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + expect(mockStandaloneInfoClient.perpDexs).toHaveBeenCalledTimes(1); + + // Initialize the provider for fully-initialized path + await hip3Provider.initialize(); + const infoClient = mockClientService.getInfoClient(); + + // Act: fully-initialized getPositions should reuse standalone's cache + await hip3Provider.getPositions(); + + // Assert: fully-initialized path did NOT call perpDexs (reused cache) + expect(infoClient.perpDexs).not.toHaveBeenCalled(); + // clearinghouseState should be called for both main + xyz DEX (from cache) + expect(infoClient.clearinghouseState).toHaveBeenCalledTimes(2); + }); + + it('filters DEXs via testnet config when in testnet mode', async () => { + // Arrange: testnet mode with TESTNET_HIP3_CONFIG.EnabledDexs = ['xyz'] + (mockClientService.isTestnetMode as jest.Mock).mockReturnValue(true); + const testnetProvider = createTestProvider({ + hip3Enabled: true, + isTestnet: true, + }); + + // perpDexs returns main + xyz + other DEX + mockStandaloneInfoClient.perpDexs.mockResolvedValue([ + null, + { name: 'xyz' }, + { name: 'otherdex' }, + ]); + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [], + marginSummary: { totalMarginUsed: '0', accountValue: '0' }, + }); + + // Act + await testnetProvider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert: clearinghouseState called for main + xyz only (otherdex filtered out) + expect( + mockStandaloneInfoClient.clearinghouseState, + ).toHaveBeenCalledTimes(2); + // Restore + (mockClientService.isTestnetMode as jest.Mock).mockReturnValue(false); + }); + + it('updates unified state atomically when standalone perpDexs succeeds', async () => { + // Arrange + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [], + marginSummary: { totalMarginUsed: '0', accountValue: '0' }, + }); + + // Act: first standalone call populates dexDiscoveryCache + await hip3Provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + + // Assert: second call reuses cached state — perpDexs NOT called again + mockStandaloneInfoClient.perpDexs.mockClear(); + await hip3Provider.getPositions({ + standalone: true, + userAddress: mockUserAddress, + }); + expect(mockStandaloneInfoClient.perpDexs).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts new file mode 100644 index 0000000000..28e0eea6c7 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts @@ -0,0 +1,2034 @@ +/* eslint-disable */ +jest.mock('@nktkas/hyperliquid', () => ({})); + +import type { CaipAssetId, Hex } from '@metamask/utils'; + +import { CandlePeriod } from '../../../src/constants/chartConfig'; +import { + BUILDER_FEE_CONFIG, + REFERRAL_CONFIG, +} from '../../../src/constants/hyperLiquidConfig'; +import { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from '../../../src/constants/transactionsHistoryConfig'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes'; +import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider'; +import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService'; +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService'; +import { TradingReadinessCache } from '../../../src/services/TradingReadinessCache'; +import type { + ClosePositionParams, + DepositParams, + Order, + PerpsPlatformDependencies, + LiveDataConfig, + OrderParams, +} from '../../../src/types'; +import { + validateAssetSupport, + validateBalance, + validateCoinExists, + validateDepositParams, + validateOrderParams, + validateWithdrawalParams, +} from '../../../src/utils/hyperLiquidValidation'; +import { createStandaloneInfoClient } from '../../../src/utils/standaloneInfoClient'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks'; + +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidWalletService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); +// Mock stream manager - will be set up in test +let mockStreamManagerInstance: any; +const mockGetStreamManagerInstance = jest.fn(() => mockStreamManagerInstance); +jest.mock( + '../../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: mockGetStreamManagerInstance, + }), + { virtual: true }, +); + +// Mock standalone info client for standalone mode tests +let mockStandaloneInfoClient: any; +jest.mock('../../../src/utils/standaloneInfoClient', () => ({ + ...jest.requireActual('../../../src/utils/standaloneInfoClient'), + createStandaloneInfoClient: jest.fn(() => mockStandaloneInfoClient), +})); + +jest.mock('../../../src/utils/hyperLiquidValidation', () => ({ + validateOrderParams: jest.fn(), + validateWithdrawalParams: jest.fn(), + validateDepositParams: jest.fn(), + validateCoinExists: jest.fn(), + validateAssetSupport: jest.fn(), + validateBalance: jest.fn(), + getSupportedPaths: jest + .fn() + .mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]), + getBridgeInfo: jest.fn().mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }), + createErrorResult: jest.fn((error, defaultResponse) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + })), +})); + +// Mock adapter functions +jest.mock('../../../src/utils/hyperLiquidAdapter', () => { + const actual = jest.requireActual('../../../src/utils/hyperLiquidAdapter'); + return { + ...actual, + adaptHyperLiquidLedgerUpdateToUserHistoryItem: jest.fn((updates) => { + // Return mock history items based on input + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map((_update: unknown) => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }), + }; +}); + +// Mock TradingReadinessCache - global singleton for signing operation caching +// Use jest.createMockFromModule for proper mock creation +jest.mock('../../../src/services/TradingReadinessCache'); + +const MockedHyperLiquidClientService = + HyperLiquidClientService as jest.MockedClass; +const MockedHyperLiquidWalletService = + HyperLiquidWalletService as jest.MockedClass; +const MockedHyperLiquidSubscriptionService = + HyperLiquidSubscriptionService as jest.MockedClass< + typeof HyperLiquidSubscriptionService + >; +const mockValidateOrderParams = validateOrderParams as jest.MockedFunction< + typeof validateOrderParams +>; +const mockValidateWithdrawalParams = + validateWithdrawalParams as jest.MockedFunction< + typeof validateWithdrawalParams + >; +const mockValidateDepositParams = validateDepositParams as jest.MockedFunction< + typeof validateDepositParams +>; +const mockValidateCoinExists = validateCoinExists as jest.MockedFunction< + typeof validateCoinExists +>; +const mockValidateAssetSupport = validateAssetSupport as jest.MockedFunction< + typeof validateAssetSupport +>; +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'), + 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(); + +const mockMessenger = createMockMessenger(); + +/** + * Helper to create HyperLiquidProvider with mock platform dependencies + * @param options + * @param options.isTestnet + * @param options.hip3Enabled + * @param options.allowlistMarkets + * @param options.blocklistMarkets + * @param options.useUnifiedAccount + */ +const createTestProvider = ( + options: { + isTestnet?: boolean; + hip3Enabled?: boolean; + allowlistMarkets?: string[]; + blocklistMarkets?: string[]; + useUnifiedAccount?: boolean; + initialAssetMapping?: [string, number][]; + } = {}, +): HyperLiquidProvider => + new HyperLiquidProvider({ + ...options, + platformDependencies: mockPlatformDependencies, + messenger: mockMessenger, + }); + +describe('HyperLiquidProvider', () => { + let provider: HyperLiquidProvider; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionService: jest.Mocked; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + ( + mockPlatformDependencies.marketDataFormatters.formatVolume as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(0)); + ( + mockPlatformDependencies.marketDataFormatters.formatPerpsFiat as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(2)); + ( + mockPlatformDependencies.marketDataFormatters + .formatPercentage as jest.Mock + ).mockImplementation((value: number) => `${value.toFixed(2)}%`); + ( + mockPlatformDependencies.featureFlags.validateVersionGated as jest.Mock + ).mockReturnValue(undefined); + (mockPlatformDependencies.metrics.isEnabled as jest.Mock).mockReturnValue( + true, + ); + + // Reset TradingReadinessCache mock state (using imported mocked module) + const mockedCache = TradingReadinessCache as jest.Mocked< + typeof TradingReadinessCache + >; + mockedCache.get.mockReturnValue(undefined); + mockedCache.getBuilderFee.mockReturnValue(undefined); + mockedCache.getReferral.mockReturnValue(undefined); + mockedCache.isInFlight.mockReturnValue(undefined); + mockedCache.setInFlight.mockReturnValue(jest.fn()); + + // Initialize mock stream manager instance + mockStreamManagerInstance = { + clearAllChannels: jest.fn(), + }; + + // Create mocked service instances using factory functions + 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; + + mockWalletService = { + setTestnetMode: jest.fn(), + getCurrentAccountId: jest + .fn() + .mockReturnValue( + 'eip155:42161:0x1234567890123456789012345678901234567890', + ), + createWalletAdapter: jest.fn().mockReturnValue({ + request: jest + .fn() + .mockResolvedValue(['0x1234567890123456789012345678901234567890']), + }), + getUserAddress: jest + .fn() + .mockReturnValue('0x1234567890123456789012345678901234567890'), + getUserAddressWithDefault: jest + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890'), + isKeyringUnlocked: jest.fn().mockReturnValue(true), + isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + } as Partial as jest.Mocked; + + mockSubscriptionService = { + subscribeToPrices: jest.fn().mockResolvedValue(jest.fn()), // Returns Promise + subscribeToPositions: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + subscribeToOrderFills: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + clearAll: jest.fn(), + isPositionsCacheInitialized: jest.fn().mockReturnValue(false), + getCachedPositions: jest.fn().mockReturnValue([]), + updateFeatureFlags: jest.fn().mockResolvedValue(undefined), + // Cache methods used by buildAssetMapping optimization + setDexMetaCache: jest.fn(), + setDexAssetCtxsCache: jest.fn(), + getDexAssetCtxsCache: jest.fn().mockReturnValue(undefined), + // Price cache used by placeOrder, editOrder, closePosition optimizations + getCachedPrice: jest.fn().mockImplementation((symbol: string) => { + const prices: Record = { BTC: '50000', ETH: '3000' }; + return prices[symbol]; + }), + getLastAllMidsSnapshot: jest.fn().mockReturnValue(null), + // Orders cache used by updatePositionTPSL and getOpenOrders + isOrdersCacheInitialized: jest.fn().mockReturnValue(false), + getCachedOrders: jest.fn().mockReturnValue([]), + // Atomic getter - returns null when cache not initialized (prevents race condition) + getOrdersCacheIfInitialized: jest.fn().mockReturnValue(null), + // Abstraction-mode resolved-mode setter (unified account migration) + setUserAbstractionMode: jest.fn(), + } as Partial as jest.Mocked; + + // Mock constructors + MockedHyperLiquidClientService.mockImplementation(() => mockClientService); + MockedHyperLiquidWalletService.mockImplementation(() => mockWalletService); + MockedHyperLiquidSubscriptionService.mockImplementation( + () => mockSubscriptionService, + ); + + // Mock validation + mockValidateOrderParams.mockReturnValue({ isValid: true }); + mockValidateWithdrawalParams.mockReturnValue({ isValid: true }); + mockValidateDepositParams.mockReturnValue({ isValid: true }); + mockValidateCoinExists.mockReturnValue({ isValid: true }); + mockValidateAssetSupport.mockReturnValue({ isValid: true }); + mockValidateBalance.mockReturnValue({ isValid: true }); + const hyperLiquidValidation = jest.requireMock( + '../../../src/utils/hyperLiquidValidation', + ); + hyperLiquidValidation.getSupportedPaths.mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]); + hyperLiquidValidation.getBridgeInfo.mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }); + hyperLiquidValidation.createErrorResult.mockImplementation( + (error: unknown, defaultResponse: Record) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + }), + ); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptHyperLiquidLedgerUpdateToUserHistoryItem.mockImplementation( + (updates: unknown[]) => { + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map(() => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }, + ); + + provider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ], + }); + }); + describe('Trading Operations', () => { + it('places a market order successfully', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + currentPrice: 50000, + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + expect(result.orderId).toBe('123'); + + // Verify market orders use FrontendMarket (HyperLiquid standard for market execution) + expect(mockClientService.getExchangeClient().order).toHaveBeenCalledWith( + expect.objectContaining({ + orders: [ + expect.objectContaining({ + t: { limit: { tif: 'FrontendMarket' } }, + }), + ], + }), + ); + }); + + it('places a limit order successfully', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + price: '51000', + orderType: 'limit', + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + + // Verify limit orders use Gtc (standard limit order behavior) + expect(mockClientService.getExchangeClient().order).toHaveBeenCalledWith( + expect.objectContaining({ + orders: [ + expect.objectContaining({ + t: { limit: { tif: 'Gtc' } }, + }), + ], + }), + ); + }); + + it('uses Gtc TIF for limit orders (regression test)', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + price: '51000', + orderType: 'limit', + }; + + await provider.placeOrder(orderParams); + + // Verify that the order was called with Gtc TIF for limit orders + expect(mockClientService.getExchangeClient().order).toHaveBeenCalledWith( + expect.objectContaining({ + orders: [ + expect.objectContaining({ + a: 0, // BTC asset ID + b: true, // isBuy + t: { limit: { tif: 'Gtc' } }, // Limit orders use Gtc TIF + }), + ], + }), + ); + }); + + it('tracks performance measurements when placing order', async () => { + const orderParams: OrderParams = { + symbol: 'ETH', + isBuy: true, + size: '1.0', + orderType: 'market', + leverage: 10, + currentPrice: 3000, // ETH price for USD calculation + }; + + await provider.placeOrder(orderParams); + }); + + it('calculates USD position size correctly for market orders', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.5', // 0.5 BTC + orderType: 'market', + currentPrice: 45000, // BTC at $45,000 + }; + + await provider.placeOrder(orderParams); + }); + + it('calculates USD position size correctly for limit orders', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.2', // 0.2 BTC + orderType: 'limit', + price: '44000', // Limit price at $44,000 + currentPrice: 45000, // Current price (not used for USD calculation in limit orders) + }; + + await provider.placeOrder(orderParams); + }); + + it('handles order placement errors', async () => { + ( + mockClientService.getExchangeClient().order as jest.Mock + ).mockResolvedValueOnce({ + status: 'error', + response: { message: 'Order failed' }, + }); + + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(false); + }); + + it('edits an order successfully', async () => { + const editParams = { + orderId: '123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.2', + price: '52000', + orderType: 'limit', + } as OrderParams, + }; + + const result = await provider.editOrder(editParams); + + expect(result.success).toBe(true); + + // Verify limit orders use Gtc TIF in edit operations + expect(mockClientService.getExchangeClient().modify).toHaveBeenCalledWith( + expect.objectContaining({ + order: expect.objectContaining({ + t: { limit: { tif: 'Gtc' } }, + }), + }), + ); + }); + + it('edits a market order with slippage calculation', async () => { + const editParams = { + orderId: '123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + slippage: 0.02, // 2% slippage + } as OrderParams, + }; + + const result = await provider.editOrder(editParams); + + expect(result.success).toBe(true); + // Price is fetched from WebSocket cache (getCachedPrice) or REST API (allMids) as fallback + + // Verify market orders use FrontendMarket TIF in edit operations + expect(mockClientService.getExchangeClient().modify).toHaveBeenCalledWith( + expect.objectContaining({ + order: expect.objectContaining({ + t: { limit: { tif: 'FrontendMarket' } }, + }), + }), + ); + }); + + it('handles editOrder when asset is not found', async () => { + ( + mockClientService.getInfoClient().meta as jest.Mock + ).mockResolvedValueOnce({ + universe: [], // Empty universe - asset not found + }); + + const editParams = { + orderId: '123', + newOrder: { + symbol: 'UNKNOWN', + isBuy: true, + size: '0.1', + orderType: 'limit', + price: '50000', + } as OrderParams, + }; + + const result = await provider.editOrder(editParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('Asset UNKNOWN not found'); + }); + + it('handles editOrder when no price is available', async () => { + // Mock both WebSocket cache and REST API to return no price + mockSubscriptionService.getCachedPrice.mockReturnValueOnce(undefined); + ( + mockClientService.getInfoClient().allMids as jest.Mock + ).mockResolvedValueOnce({}); // Empty price data + + const editParams = { + orderId: '123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + } as OrderParams, + }; + + const result = await provider.editOrder(editParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid price for BTC'); + }); + + it('falls back to REST API when cached price is zero', async () => { + // Mock WebSocket cache to return "0" (invalid price) + mockSubscriptionService.getCachedPrice.mockReturnValueOnce('0'); + // Mock REST API to return valid price + ( + mockClientService.getInfoClient().allMids as jest.Mock + ).mockResolvedValueOnce({ BTC: '50000' }); + + const editParams = { + orderId: '123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + } as OrderParams, + }; + + const result = await provider.editOrder(editParams); + + // Should succeed because it fell back to REST API + expect(result.success).toBe(true); + // Verify REST API was called as fallback + expect(mockClientService.getInfoClient().allMids).toHaveBeenCalled(); + }); + + it('falls back to REST API when cached price is NaN', async () => { + // Mock WebSocket cache to return invalid string + mockSubscriptionService.getCachedPrice.mockReturnValueOnce('invalid'); + // Mock REST API to return valid price + ( + mockClientService.getInfoClient().allMids as jest.Mock + ).mockResolvedValueOnce({ BTC: '50000' }); + + const editParams = { + orderId: '123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + } as OrderParams, + }; + + const result = await provider.editOrder(editParams); + + // Should succeed because it fell back to REST API + expect(result.success).toBe(true); + // Verify REST API was called as fallback + expect(mockClientService.getInfoClient().allMids).toHaveBeenCalled(); + }); + + it('falls back to REST API when cached price is negative', async () => { + // Mock WebSocket cache to return negative price (invalid for crypto) + mockSubscriptionService.getCachedPrice.mockReturnValueOnce('-100'); + // Mock REST API to return valid price + ( + mockClientService.getInfoClient().allMids as jest.Mock + ).mockResolvedValueOnce({ BTC: '50000' }); + + const editParams = { + orderId: '123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + } as OrderParams, + }; + + const result = await provider.editOrder(editParams); + + // Should succeed because it fell back to REST API + expect(result.success).toBe(true); + // Verify REST API was called as fallback + expect(mockClientService.getInfoClient().allMids).toHaveBeenCalled(); + }); + + it('falls back to REST API when cached price is Infinity', async () => { + // Mock WebSocket cache to return Infinity (invalid price) + mockSubscriptionService.getCachedPrice.mockReturnValueOnce('Infinity'); + // Mock REST API to return valid price + ( + mockClientService.getInfoClient().allMids as jest.Mock + ).mockResolvedValueOnce({ BTC: '50000' }); + + const editParams = { + orderId: '123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + } as OrderParams, + }; + + const result = await provider.editOrder(editParams); + + // Should succeed because it fell back to REST API + expect(result.success).toBe(true); + // Verify REST API was called as fallback + expect(mockClientService.getInfoClient().allMids).toHaveBeenCalled(); + }); + + it('throws error when REST price is negative', async () => { + // Mock WebSocket cache miss + mockSubscriptionService.getCachedPrice.mockReturnValueOnce(undefined); + // Mock REST API to return negative price + ( + mockClientService.getInfoClient().allMids as jest.Mock + ).mockResolvedValueOnce({ BTC: '-50000' }); + + const editParams = { + orderId: '123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + } as OrderParams, + }; + + const result = await provider.editOrder(editParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid price for BTC'); + }); + + it('throws error when REST price is Infinity', async () => { + // Mock WebSocket cache miss + mockSubscriptionService.getCachedPrice.mockReturnValueOnce(undefined); + // Mock REST API to return Infinity + ( + mockClientService.getInfoClient().allMids as jest.Mock + ).mockResolvedValueOnce({ BTC: 'Infinity' }); + + const editParams = { + orderId: '123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + } as OrderParams, + }; + + const result = await provider.editOrder(editParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid price for BTC'); + }); + + it('handles editOrder when asset ID is not found', async () => { + const editParams = { + orderId: '123', + newOrder: { + symbol: 'UNKNOWN_ASSET', + isBuy: true, + size: '0.1', + orderType: 'limit', + price: '50000', + } as OrderParams, + }; + + const result = await provider.editOrder(editParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('UNKNOWN_ASSET not found'); + }); + + it('cancels an order successfully', async () => { + const cancelParams = { + orderId: '123', + symbol: 'BTC', + }; + + const result = await provider.cancelOrder(cancelParams); + + expect(result.success).toBe(true); + }); + + it('retries USD-based order when rejected for $10 minimum with adjusted amount', async () => { + // Create provider with PUMP in the asset mapping + provider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ['PUMP', 2], + ], + }); + + const pumpUniverse = [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + { name: 'PUMP', szDecimals: 2, maxLeverage: 20 }, + ]; + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ universe: pumpUniverse }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { universe: pumpUniverse }, + [ + { + 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', + }, + { + funding: '0.0001', + openInterest: '100', + prevDayPx: '0.003', + dayNtlVlm: '10000', + markPx: '0.003918', + midPx: '0.003918', + oraclePx: '0.003918', + }, + ], + ]), + allMids: jest + .fn() + .mockResolvedValue({ BTC: '50000', ETH: '3000', PUMP: '0.003918' }), + }), + ); + + const orderParams: OrderParams = { + symbol: 'PUMP', + isBuy: true, + size: '2553', + orderType: 'market', + usdAmount: '10.00', + currentPrice: 0.003918, + }; + + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ + ...createMockExchangeClient(), + order: jest + .fn() + .mockRejectedValueOnce( + new Error('Order must have minimum value of $10'), + ) + .mockResolvedValueOnce({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 456 } }] } }, + }), + }); + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + expect(mockClientService.getExchangeClient().order).toHaveBeenCalledTimes( + 2, + ); + }); + + it('retries size-based order with currentPrice when rejected for $10 minimum', async () => { + // Create provider with PUMP in the asset mapping + provider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ['PUMP', 2], + ], + }); + + const pumpUniverse = [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + { name: 'PUMP', szDecimals: 2, maxLeverage: 20 }, + ]; + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ universe: pumpUniverse }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { universe: pumpUniverse }, + [ + { + 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', + }, + { + funding: '0.0001', + openInterest: '100', + prevDayPx: '0.003', + dayNtlVlm: '10000', + markPx: '0.003918', + midPx: '0.003918', + oraclePx: '0.003918', + }, + ], + ]), + allMids: jest + .fn() + .mockResolvedValue({ BTC: '50000', ETH: '3000', PUMP: '0.003918' }), + }), + ); + + const orderParams: OrderParams = { + symbol: 'PUMP', + isBuy: true, + size: '2553', + orderType: 'market', + currentPrice: 0.003918, + }; + + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ + ...createMockExchangeClient(), + order: jest + .fn() + .mockRejectedValueOnce( + new Error('Order 0: Order must have minimum value'), + ) + .mockResolvedValueOnce({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 789 } }] } }, + }), + }); + + const result = await provider.placeOrder(orderParams); + + expect(result.success).toBe(true); + expect(mockClientService.getExchangeClient().order).toHaveBeenCalledTimes( + 2, + ); + }); + + it('retries with adjusted USD when price-less order hits $10 minimum (uses fetched price from allMids)', async () => { + // Create provider with PUMP in the asset mapping + provider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ['PUMP', 2], + ], + }); + + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + { name: 'PUMP', szDecimals: 2, maxLeverage: 20 }, + ], + }), + allMids: jest + .fn() + .mockResolvedValue({ BTC: '50000', ETH: '3000', PUMP: '0.003918' }), + }), + ); + + const orderParams: OrderParams = { + symbol: 'PUMP', + isBuy: true, + size: '2553', + orderType: 'market', + // No currentPrice: provider fetches live price (0.003918) and uses it + // for both validation and the $10-minimum retry path. + }; + + const mockOrder = jest + .fn() + .mockRejectedValueOnce( + new Error('Order must have minimum value of $10'), + ) + .mockResolvedValueOnce({ + status: 'ok', + response: { + data: { + statuses: [ + { filled: { oid: 123, totalSz: '2553', avgPx: '0.004' } }, + ], + }, + }, + }); + + mockClientService.getExchangeClient = jest.fn().mockReturnValue({ + ...createMockExchangeClient(), + order: mockOrder, + }); + + const result = await provider.placeOrder(orderParams); + + // The live price is fetched → validation passes → order is submitted → + // first call hits $10 minimum → retry uses fetched price to compute + // adjusted usdAmount → second call succeeds. + expect(result.success).toBe(true); + expect(mockOrder).toHaveBeenCalledTimes(2); + }); + + it('closes a position successfully', async () => { + const closeParams: ClosePositionParams = { + symbol: 'BTC', + orderType: 'market', + }; + + const result = await provider.closePosition(closeParams); + + expect(result.success).toBe(true); + }); + + it('repairs missing HIP-3 asset IDs during closePosition after degraded discovery', async () => { + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + useUnifiedAccount: true, + }); + const mockOrder = jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 123 } }] } }, + }); + const mockInfoClient = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + metaAndAssetCtxs: jest + .fn() + .mockImplementation((params?: { dex?: string }) => { + if (params?.dex === 'xyz') { + return Promise.reject( + new Error('Transient xyz discovery failure'), + ); + } + + return Promise.resolve([ + { universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }] }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + ], + ]); + }), + meta: jest.fn().mockImplementation((params?: { dex?: string }) => { + if (params?.dex === 'xyz') { + return Promise.resolve({ + universe: [ + { name: 'xyz:STOCK1', szDecimals: 2, maxLeverage: 20 }, + ], + }); + } + + return Promise.resolve({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }); + }), + allMids: jest.fn().mockImplementation((params?: { dex?: string }) => { + if (params?.dex === 'xyz') { + return Promise.resolve({ 'xyz:STOCK1': '100' }); + } + + return Promise.resolve({ BTC: '50000' }); + }), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(createMockExchangeClient({ order: mockOrder })); + + const result = await hip3Provider.closePosition({ + symbol: 'xyz:STOCK1', + orderType: 'market', + position: { + symbol: 'xyz:STOCK1', + size: '10', + entryPrice: '95', + positionValue: '1000', + unrealizedPnl: '50', + marginUsed: '100', + leverage: { type: 'isolated', value: 5 }, + liquidationPrice: '70', + maxLeverage: 20, + returnOnEquity: '10', + cumulativeFunding: { + allTime: '0', + sinceOpen: '0', + sinceChange: '0', + }, + takeProfitCount: 0, + stopLossCount: 0, + }, + }); + + expect(result.success).toBe(true); + expect(mockOrder).toHaveBeenCalledWith( + expect.objectContaining({ + orders: [expect.objectContaining({ a: 110000, r: true })], + }), + ); + }); + }); + + describe('closePosition with TP/SL handling', () => { + beforeEach(() => { + // Clear debugLogger mock to capture logs for this test suite + (mockPlatformDependencies.debugLogger.log as jest.Mock).mockClear(); + }); + + it('closes position without TP/SL successfully', async () => { + // Position without TP/SL - using factory for standard BTC position + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(createMockInfoClient()); + + const closeParams: ClosePositionParams = { + symbol: 'BTC', + orderType: 'market', + }; + + const result = await provider.closePosition(closeParams); + + expect(result.success).toBe(true); + // No TP/SL logging expected since we removed this functionality + }); + + it('handles position with TP/SL successfully', async () => { + // Mock position with TP/SL + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + 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', + }, + ], + }), + frontendOpenOrders: jest + .fn() + .mockResolvedValueOnce([ + // First call for getPositions + { + coin: 'BTC', + oid: 1001, + reduceOnly: true, + isTrigger: true, + orderType: 'Take Profit Market', + triggerPx: '55000', + isPositionTpsl: true, + }, + { + coin: 'BTC', + oid: 1002, + reduceOnly: true, + isTrigger: true, + orderType: 'Stop Market', + triggerPx: '45000', + isPositionTpsl: true, + }, + ]) + .mockResolvedValueOnce([ + // Second call for closePosition TP/SL check + { + coin: 'BTC', + oid: 1001, + reduceOnly: true, + isTrigger: true, + orderType: 'Take Profit Market', + triggerPx: '55000', + isPositionTpsl: true, + side: 'A', + }, + { + coin: 'BTC', + oid: 1002, + reduceOnly: true, + isTrigger: true, + orderType: 'Stop Market', + triggerPx: '45000', + isPositionTpsl: true, + side: 'B', + }, + ]), + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }] }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + ], + ]), + perpDexs: jest.fn().mockResolvedValue([null]), + allMids: jest.fn().mockResolvedValue({ BTC: '50000' }), + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + }), + maxBuilderFee: jest.fn().mockResolvedValue(1), + }); + + const closeParams: ClosePositionParams = { + symbol: 'BTC', + orderType: 'market', + }; + + const result = await provider.closePosition(closeParams); + + expect(result.success).toBe(true); + + // TP/SL orders are automatically handled by Hyperliquid + // No additional logging needed + }); + + it('handles partial position close with TP/SL', async () => { + // Mock position with TP/SL + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [ + { + 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', + }, + ], + }), + frontendOpenOrders: jest + .fn() + .mockResolvedValueOnce([ + // First call for getPositions + { + coin: 'ETH', + oid: 2001, + reduceOnly: true, + isTrigger: true, + orderType: 'Take Profit Limit', + triggerPx: '3500', + limitPx: '3490', + isPositionTpsl: true, + }, + ]) + .mockResolvedValueOnce([ + // Second call for closePosition TP/SL check + { + coin: 'ETH', + oid: 2001, + reduceOnly: true, + isTrigger: true, + orderType: 'Take Profit Limit', + triggerPx: '3500', + limitPx: '3490', + isPositionTpsl: true, + side: 'A', + }, + ]), + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'ETH', szDecimals: 4, maxLeverage: 50 }], + }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { universe: [{ name: 'ETH', szDecimals: 4, maxLeverage: 50 }] }, + [ + { + funding: '0.0001', + openInterest: '500', + prevDayPx: '2900', + dayNtlVlm: '500000', + markPx: '3000', + midPx: '3000', + oraclePx: '3000', + }, + ], + ]), + perpDexs: jest.fn().mockResolvedValue([null]), + allMids: jest.fn().mockResolvedValue({ ETH: '3000' }), + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + }), + maxBuilderFee: jest.fn().mockResolvedValue(1), + }); + + const closeParams: ClosePositionParams = { + symbol: 'ETH', + size: '0.5', // Partial close + orderType: 'limit', + price: '3100', + }; + + const result = await provider.closePosition(closeParams); + + expect(result.success).toBe(true); + + // Verify partial close size is used (with HyperLiquid's short property names) + expect(mockClientService.getExchangeClient().order).toHaveBeenCalledWith( + expect.objectContaining({ + orders: [ + expect.objectContaining({ + s: '0.5', // 's' is the short form for 'sz' (size) + r: true, // 'r' is the short form for 'reduceOnly' + }), + ], + }), + ); + + // TP/SL orders are automatically handled by Hyperliquid for partial closes too + }); + + it('handles position without open TP/SL orders', async () => { + // Position exists but no open TP/SL orders + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(createMockInfoClient()); + + const closeParams: ClosePositionParams = { + symbol: 'BTC', + orderType: 'market', + }; + + const result = await provider.closePosition(closeParams); + + expect(result.success).toBe(true); + + // Should not log TP/SL related messages + expect(mockPlatformDependencies.debugLogger.log).not.toHaveBeenCalledWith( + expect.stringContaining('Found open TP/SL orders'), + expect.any(Object), + ); + }); + + it('handles close position when position not found', async () => { + // Override to have NO positions (empty array) + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '0', accountValue: '10000' }, + withdrawable: '10000', + assetPositions: [], // No positions + crossMarginSummary: { accountValue: '10000', totalMarginUsed: '0' }, + }), + }), + ); + + const closeParams: ClosePositionParams = { + symbol: 'BTC', + orderType: 'market', + }; + + const result = await provider.closePosition(closeParams); + + expect(result.success).toBe(false); + expect(result.error).toContain('No position found for BTC'); + }); + + it('handles short position close with TP/SL', async () => { + // Mock short position with TP/SL + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { + totalMarginUsed: '500', + accountValue: '10500', + }, + withdrawable: '9500', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '-0.1', // Short position + entryPx: '50000', + positionValue: '5000', + unrealizedPnl: '-100', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '55000', + maxLeverage: 50, + returnOnEquity: '-20', + cumFunding: { + allTime: '10', + sinceOpen: '5', + sinceChange: '2', + }, + }, + type: 'oneWay', + }, + ], + }), + frontendOpenOrders: jest + .fn() + .mockResolvedValueOnce([ + // First call for getPositions - short position TP/SL + { + coin: 'BTC', + oid: 3001, + reduceOnly: true, + isTrigger: true, + orderType: 'Take Profit Market', + triggerPx: '45000', // TP below entry for short + isPositionTpsl: true, + }, + { + coin: 'BTC', + oid: 3002, + reduceOnly: true, + isTrigger: true, + orderType: 'Stop Market', + triggerPx: '55000', // SL above entry for short + isPositionTpsl: true, + }, + ]) + .mockResolvedValueOnce([ + // Second call for closePosition TP/SL check + { + coin: 'BTC', + oid: 3001, + reduceOnly: true, + isTrigger: true, + orderType: 'Take Profit Market', + triggerPx: '45000', + isPositionTpsl: true, + side: 'B', + }, + { + coin: 'BTC', + oid: 3002, + reduceOnly: true, + isTrigger: true, + orderType: 'Stop Market', + triggerPx: '55000', + isPositionTpsl: true, + side: 'A', + }, + ]), + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }] }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + ], + ]), + perpDexs: jest.fn().mockResolvedValue([null]), + allMids: jest.fn().mockResolvedValue({ BTC: '50000' }), + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + }), + maxBuilderFee: jest.fn().mockResolvedValue(1), + }); + + const closeParams: ClosePositionParams = { + symbol: 'BTC', + orderType: 'market', + }; + + const result = await provider.closePosition(closeParams); + + expect(result.success).toBe(true); + + // Verify buy order is placed to close short (with HyperLiquid's short property names) + expect(mockClientService.getExchangeClient().order).toHaveBeenCalledWith( + expect.objectContaining({ + orders: [ + expect.objectContaining({ + b: true, // 'b' is the short form for 'isBuy' (Buy to close short) + s: '0.1', // 's' is the short form for 'sz' (size) + r: true, // 'r' is the short form for 'reduceOnly' + }), + ], + }), + ); + + // TP/SL orders are automatically handled by Hyperliquid for short positions too + }); + + it('handles position close even if TP/SL info is unavailable', async () => { + // Mock position exists with TP/SL in positions call + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + 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', + }, + ], + }), + frontendOpenOrders: jest.fn().mockResolvedValueOnce([ + // First call for getPositions with TP/SL + { + coin: 'BTC', + oid: 1001, + reduceOnly: true, + isTrigger: true, + orderType: 'Take Profit Market', + triggerPx: '55000', + isPositionTpsl: true, + }, + ]), + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }] }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + ], + ]), + perpDexs: jest.fn().mockResolvedValue([null]), + allMids: jest.fn().mockResolvedValue({ BTC: '50000' }), + referral: jest.fn().mockResolvedValue({ + referrerState: { + stage: 'ready', + data: { code: 'MMCSI' }, + }, + }), + maxBuilderFee: jest.fn().mockResolvedValue(1), + }); + + const closeParams: ClosePositionParams = { + symbol: 'BTC', + orderType: 'market', + }; + + const result = await provider.closePosition(closeParams); + + // Should succeed - TP/SL handling is automatic by Hyperliquid + expect(result.success).toBe(true); + }); + }); + + describe('Batch Operations', () => { + describe('cancelOrders', () => { + it('returns failure when no orders provided', async () => { + const result = await provider.cancelOrders([]); + + expect(result.success).toBe(false); + expect(result.successCount).toBe(0); + expect(result.failureCount).toBe(0); + expect(result.results).toEqual([]); + }); + + it('cancels multiple orders successfully', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + cancel: jest.fn().mockResolvedValue({ + response: { + data: { + statuses: ['success', 'success'], + }, + }, + }), + }), + ); + + const params = [ + { orderId: '123', symbol: 'BTC' }, + { orderId: '456', symbol: 'ETH' }, + ]; + + const result = await provider.cancelOrders(params); + + expect(result.success).toBe(true); + expect(result.successCount).toBe(2); + expect(result.failureCount).toBe(0); + expect(result.results).toHaveLength(2); + expect(result.results[0].success).toBe(true); + }); + + it('handles batch cancel errors', async () => { + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + cancel: jest.fn().mockRejectedValue(new Error('API error')), + }), + ); + + const params = [{ orderId: '123', symbol: 'BTC' }]; + + const result = await provider.cancelOrders(params); + + expect(result.success).toBe(false); + expect(result.successCount).toBe(0); + expect(result.failureCount).toBe(1); + expect(result.results[0].success).toBe(false); + expect(result.results[0].error).toBe('API error'); + }); + }); + + describe('closePositions', () => { + it('returns failure when no positions to close', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '0', accountValue: '10000' }, + withdrawable: '10000', + assetPositions: [], + crossMarginSummary: { + accountValue: '10000', + totalMarginUsed: '0', + }, + }), + }), + ); + + const result = await provider.closePositions({ closeAll: true }); + + expect(result.success).toBe(false); + expect(result.successCount).toBe(0); + expect(result.failureCount).toBe(0); + expect(result.results).toEqual([]); + }); + + it('closes multiple positions successfully', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '1500', accountValue: '11500' }, + withdrawable: '10000', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '1.5', + entryPx: '50000', + positionValue: '75000', + unrealizedPnl: '100', + marginUsed: '1000', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + }, + type: 'oneWay', + }, + { + position: { + coin: 'ETH', + szi: '-2.0', + entryPx: '3000', + positionValue: '6000', + unrealizedPnl: '50', + marginUsed: '500', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '3300', + }, + type: 'oneWay', + }, + ], + crossMarginSummary: { + accountValue: '11500', + totalMarginUsed: '1500', + }, + }), + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }), + allMids: jest.fn().mockResolvedValue({ + BTC: '50000', + ETH: '3000', + }), + }), + ); + + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + order: jest.fn().mockResolvedValue({ + response: { + data: { + statuses: [{ filled: {} }, { filled: {} }], + }, + }, + }), + }), + ); + + const result = await provider.closePositions({ closeAll: true }); + + expect(result.success).toBe(true); + expect(result.successCount).toBe(2); + expect(result.failureCount).toBe(0); + expect(result.results).toHaveLength(2); + expect(result.results[0].symbol).toBe('BTC'); + expect(result.results[1].symbol).toBe('ETH'); + }); + + it('handles batch close errors', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + clearinghouseState: jest.fn().mockResolvedValue({ + marginSummary: { totalMarginUsed: '1000', accountValue: '11000' }, + withdrawable: '10000', + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '1.0', + entryPx: '50000', + positionValue: '50000', + unrealizedPnl: '100', + marginUsed: '1000', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + }, + type: 'oneWay', + }, + ], + crossMarginSummary: { + accountValue: '11000', + totalMarginUsed: '1000', + }, + }), + }), + ); + + mockClientService.getExchangeClient = jest.fn().mockReturnValue( + createMockExchangeClient({ + order: jest.fn().mockRejectedValue(new Error('Order failed')), + }), + ); + + const result = await provider.closePositions({ closeAll: true }); + + expect(result.success).toBe(false); + expect(result.successCount).toBe(0); + expect(result.failureCount).toBe(1); + expect(result.results[0].success).toBe(false); + expect(result.results[0].error).toBe('Order failed'); + }); + }); + }); + + describe('updatePositionTPSL', () => { + it('updates position TP/SL successfully', async () => { + const updateParams = { + symbol: 'ETH', + takeProfitPrice: '3500', + stopLossPrice: '2500', + }; + + const result = await provider.updatePositionTPSL(updateParams); + + expect(result.success).toBe(true); + expect(result.orderId).toBeDefined(); + }); + + it('handles update with only take profit price', async () => { + const updateParams = { + symbol: 'ETH', + takeProfitPrice: '3500', + }; + + const result = await provider.updatePositionTPSL(updateParams); + + expect(result.success).toBe(true); + }); + + it('handles update with only stop loss price', async () => { + const updateParams = { + symbol: 'ETH', + stopLossPrice: '2500', + }; + + const result = await provider.updatePositionTPSL(updateParams); + + expect(result.success).toBe(true); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts new file mode 100644 index 0000000000..b8dc4f2cf8 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.validation.test.ts @@ -0,0 +1,1295 @@ +/* eslint-disable */ +jest.mock('@nktkas/hyperliquid', () => ({})); + +import type { CaipAssetId, Hex } from '@metamask/utils'; + +import { CandlePeriod } from '../../../src/constants/chartConfig'; +import { + BUILDER_FEE_CONFIG, + REFERRAL_CONFIG, +} from '../../../src/constants/hyperLiquidConfig'; +import { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from '../../../src/constants/transactionsHistoryConfig'; +import { PERPS_ERROR_CODES } from '../../../src/perpsErrorCodes'; +import { HyperLiquidProvider } from '../../../src/providers/HyperLiquidProvider'; +import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService'; +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService'; +import { TradingReadinessCache } from '../../../src/services/TradingReadinessCache'; +import type { + ClosePositionParams, + DepositParams, + Order, + PerpsPlatformDependencies, + LiveDataConfig, + OrderParams, +} from '../../../src/types'; +import { + validateAssetSupport, + validateBalance, + validateCoinExists, + validateDepositParams, + validateOrderParams, + validateWithdrawalParams, +} from '../../../src/utils/hyperLiquidValidation'; +import { createStandaloneInfoClient } from '../../../src/utils/standaloneInfoClient'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks'; + +jest.mock('../../../src/services/HyperLiquidClientService'); +jest.mock('../../../src/services/HyperLiquidWalletService'); +jest.mock('../../../src/services/HyperLiquidSubscriptionService'); +// Mock stream manager - will be set up in test +let mockStreamManagerInstance: any; +const mockGetStreamManagerInstance = jest.fn(() => mockStreamManagerInstance); +jest.mock( + '../../../../components/UI/Perps/providers/PerpsStreamManager', + () => ({ + getStreamManagerInstance: mockGetStreamManagerInstance, + }), + { virtual: true }, +); + +// Mock standalone info client for standalone mode tests +let mockStandaloneInfoClient: any; +jest.mock('../../../src/utils/standaloneInfoClient', () => ({ + ...jest.requireActual('../../../src/utils/standaloneInfoClient'), + createStandaloneInfoClient: jest.fn(() => mockStandaloneInfoClient), +})); + +jest.mock('../../../src/utils/hyperLiquidValidation', () => ({ + validateOrderParams: jest.fn(), + validateWithdrawalParams: jest.fn(), + validateDepositParams: jest.fn(), + validateCoinExists: jest.fn(), + validateAssetSupport: jest.fn(), + validateBalance: jest.fn(), + getSupportedPaths: jest + .fn() + .mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]), + getBridgeInfo: jest.fn().mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }), + createErrorResult: jest.fn((error, defaultResponse) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + })), +})); + +// Mock adapter functions +jest.mock('../../../src/utils/hyperLiquidAdapter', () => { + const actual = jest.requireActual('../../../src/utils/hyperLiquidAdapter'); + return { + ...actual, + adaptHyperLiquidLedgerUpdateToUserHistoryItem: jest.fn((updates) => { + // Return mock history items based on input + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map((_update: unknown) => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }), + }; +}); + +// Mock TradingReadinessCache - global singleton for signing operation caching +// Use jest.createMockFromModule for proper mock creation +jest.mock('../../../src/services/TradingReadinessCache'); + +const MockedHyperLiquidClientService = + HyperLiquidClientService as jest.MockedClass; +const MockedHyperLiquidWalletService = + HyperLiquidWalletService as jest.MockedClass; +const MockedHyperLiquidSubscriptionService = + HyperLiquidSubscriptionService as jest.MockedClass< + typeof HyperLiquidSubscriptionService + >; +const mockValidateOrderParams = validateOrderParams as jest.MockedFunction< + typeof validateOrderParams +>; +const mockValidateWithdrawalParams = + validateWithdrawalParams as jest.MockedFunction< + typeof validateWithdrawalParams + >; +const mockValidateDepositParams = validateDepositParams as jest.MockedFunction< + typeof validateDepositParams +>; +const mockValidateCoinExists = validateCoinExists as jest.MockedFunction< + typeof validateCoinExists +>; +const mockValidateAssetSupport = validateAssetSupport as jest.MockedFunction< + typeof validateAssetSupport +>; +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'), + 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(); + +const mockMessenger = createMockMessenger(); + +/** + * Helper to create HyperLiquidProvider with mock platform dependencies + * @param options + * @param options.isTestnet + * @param options.hip3Enabled + * @param options.allowlistMarkets + * @param options.blocklistMarkets + * @param options.useUnifiedAccount + */ +const createTestProvider = ( + options: { + isTestnet?: boolean; + hip3Enabled?: boolean; + allowlistMarkets?: string[]; + blocklistMarkets?: string[]; + useUnifiedAccount?: boolean; + initialAssetMapping?: [string, number][]; + } = {}, +): HyperLiquidProvider => + new HyperLiquidProvider({ + ...options, + platformDependencies: mockPlatformDependencies, + messenger: mockMessenger, + }); + +describe('HyperLiquidProvider', () => { + let provider: HyperLiquidProvider; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionService: jest.Mocked; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + ( + mockPlatformDependencies.marketDataFormatters.formatVolume as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(0)); + ( + mockPlatformDependencies.marketDataFormatters.formatPerpsFiat as jest.Mock + ).mockImplementation((value: number) => '$' + value.toFixed(2)); + ( + mockPlatformDependencies.marketDataFormatters + .formatPercentage as jest.Mock + ).mockImplementation((value: number) => `${value.toFixed(2)}%`); + ( + mockPlatformDependencies.featureFlags.validateVersionGated as jest.Mock + ).mockReturnValue(undefined); + (mockPlatformDependencies.metrics.isEnabled as jest.Mock).mockReturnValue( + true, + ); + + // Reset TradingReadinessCache mock state (using imported mocked module) + const mockedCache = TradingReadinessCache as jest.Mocked< + typeof TradingReadinessCache + >; + mockedCache.get.mockReturnValue(undefined); + mockedCache.getBuilderFee.mockReturnValue(undefined); + mockedCache.getReferral.mockReturnValue(undefined); + mockedCache.isInFlight.mockReturnValue(undefined); + mockedCache.setInFlight.mockReturnValue(jest.fn()); + + // Initialize mock stream manager instance + mockStreamManagerInstance = { + clearAllChannels: jest.fn(), + }; + + // Create mocked service instances using factory functions + 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; + + mockWalletService = { + setTestnetMode: jest.fn(), + getCurrentAccountId: jest + .fn() + .mockReturnValue( + 'eip155:42161:0x1234567890123456789012345678901234567890', + ), + createWalletAdapter: jest.fn().mockReturnValue({ + request: jest + .fn() + .mockResolvedValue(['0x1234567890123456789012345678901234567890']), + }), + getUserAddress: jest + .fn() + .mockReturnValue('0x1234567890123456789012345678901234567890'), + getUserAddressWithDefault: jest + .fn() + .mockResolvedValue('0x1234567890123456789012345678901234567890'), + isKeyringUnlocked: jest.fn().mockReturnValue(true), + isSelectedHardwareWallet: jest.fn().mockReturnValue(false), + } as Partial as jest.Mocked; + + mockSubscriptionService = { + subscribeToPrices: jest.fn().mockResolvedValue(jest.fn()), // Returns Promise + subscribeToPositions: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + subscribeToOrderFills: jest.fn().mockReturnValue(jest.fn()), // Returns function directly + clearAll: jest.fn(), + isPositionsCacheInitialized: jest.fn().mockReturnValue(false), + getCachedPositions: jest.fn().mockReturnValue([]), + updateFeatureFlags: jest.fn().mockResolvedValue(undefined), + // Cache methods used by buildAssetMapping optimization + setDexMetaCache: jest.fn(), + setDexAssetCtxsCache: jest.fn(), + getDexAssetCtxsCache: jest.fn().mockReturnValue(undefined), + // Price cache used by placeOrder, editOrder, closePosition optimizations + getCachedPrice: jest.fn().mockImplementation((symbol: string) => { + const prices: Record = { BTC: '50000', ETH: '3000' }; + return prices[symbol]; + }), + getLastAllMidsSnapshot: jest.fn().mockReturnValue(null), + // Orders cache used by updatePositionTPSL and getOpenOrders + isOrdersCacheInitialized: jest.fn().mockReturnValue(false), + getCachedOrders: jest.fn().mockReturnValue([]), + // Atomic getter - returns null when cache not initialized (prevents race condition) + getOrdersCacheIfInitialized: jest.fn().mockReturnValue(null), + // Abstraction-mode resolved-mode setter (unified account migration) + setUserAbstractionMode: jest.fn(), + } as Partial as jest.Mocked; + + // Mock constructors + MockedHyperLiquidClientService.mockImplementation(() => mockClientService); + MockedHyperLiquidWalletService.mockImplementation(() => mockWalletService); + MockedHyperLiquidSubscriptionService.mockImplementation( + () => mockSubscriptionService, + ); + + // Mock validation + mockValidateOrderParams.mockReturnValue({ isValid: true }); + mockValidateWithdrawalParams.mockReturnValue({ isValid: true }); + mockValidateDepositParams.mockReturnValue({ isValid: true }); + mockValidateCoinExists.mockReturnValue({ isValid: true }); + mockValidateAssetSupport.mockReturnValue({ isValid: true }); + mockValidateBalance.mockReturnValue({ isValid: true }); + const hyperLiquidValidation = jest.requireMock( + '../../../src/utils/hyperLiquidValidation', + ); + hyperLiquidValidation.getSupportedPaths.mockReturnValue([ + 'eip155:42161/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + 'eip155:1/erc20:0xa0b86a33e6776e681a06e0e1622c5e5e3e6a8b13/default', + ]); + hyperLiquidValidation.getBridgeInfo.mockReturnValue({ + chainId: 'eip155:42161', + contractAddress: '0x1234567890123456789012345678901234567890', + }); + hyperLiquidValidation.createErrorResult.mockImplementation( + (error: unknown, defaultResponse: Record) => ({ + ...defaultResponse, + success: false, + error: error instanceof Error ? error.message : String(error), + }), + ); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptHyperLiquidLedgerUpdateToUserHistoryItem.mockImplementation( + (updates: unknown[]) => { + if (!updates || !Array.isArray(updates) || updates.length === 0) { + return []; + } + return updates.map(() => ({ + type: 'deposit' as const, + amount: '100', + timestamp: Date.now(), + hash: '0x123', + })); + }, + ); + + provider = createTestProvider({ + initialAssetMapping: [ + ['BTC', 0], + ['ETH', 1], + ], + }); + }); + describe('validateDeposit', () => { + it('validates valid deposit parameters', async () => { + mockValidateDepositParams.mockReturnValue({ isValid: true }); + + const params: DepositParams = { + amount: '100', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default', + }; + + const result = await provider.validateDeposit(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('rejects empty amount', async () => { + mockValidateDepositParams.mockReturnValue({ + isValid: false, + error: 'Amount is required and must be greater than 0', + }); + + const params: DepositParams = { + amount: '', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default', + }; + + const result = await provider.validateDeposit(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe( + 'Amount is required and must be greater than 0', + ); + }); + + it('rejects zero amount', async () => { + mockValidateDepositParams.mockReturnValue({ + isValid: false, + error: 'Amount is required and must be greater than 0', + }); + + const params: DepositParams = { + amount: '0', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default', + }; + + const result = await provider.validateDeposit(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe( + 'Amount is required and must be greater than 0', + ); + }); + + it('rejects negative amount', async () => { + mockValidateDepositParams.mockReturnValue({ + isValid: false, + error: 'Amount is required and must be greater than 0', + }); + + const params: DepositParams = { + amount: '-10', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default', + }; + + const result = await provider.validateDeposit(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe( + 'Amount is required and must be greater than 0', + ); + }); + + it('rejects invalid amount format', async () => { + mockValidateDepositParams.mockReturnValue({ + isValid: false, + error: 'Amount is required and must be greater than 0', + }); + + const params: DepositParams = { + amount: 'abc', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default', + }; + + const result = await provider.validateDeposit(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe( + 'Amount is required and must be greater than 0', + ); + }); + + it('rejects amount below minimum for mainnet', async () => { + mockClientService.isTestnetMode.mockReturnValue(false); + mockValidateDepositParams.mockReturnValue({ + isValid: false, + error: 'Minimum deposit amount is 5 USDC', + }); + + const params: DepositParams = { + amount: '4.99', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default', + }; + + const result = await provider.validateDeposit(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe('Minimum deposit amount is 5 USDC'); + }); + + it('rejects amount below minimum for testnet', async () => { + mockClientService.isTestnetMode.mockReturnValue(true); + mockValidateDepositParams.mockReturnValue({ + isValid: false, + error: 'Minimum deposit amount is 10 USDC', + }); + + const params: DepositParams = { + amount: '9.99', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default', + }; + + const result = await provider.validateDeposit(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe('Minimum deposit amount is 10 USDC'); + }); + + it('accepts amount at minimum for mainnet', async () => { + mockClientService.isTestnetMode.mockReturnValue(false); + mockValidateDepositParams.mockReturnValue({ isValid: true }); + + const params: DepositParams = { + amount: '5', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default', + }; + + const result = await provider.validateDeposit(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('accepts amount at minimum for testnet', async () => { + mockClientService.isTestnetMode.mockReturnValue(true); + mockValidateDepositParams.mockReturnValue({ isValid: true }); + + const params: DepositParams = { + amount: '10', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default', + }; + + const result = await provider.validateDeposit(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('rejects empty assetId', async () => { + mockValidateDepositParams.mockReturnValue({ + isValid: false, + error: 'AssetId is required for deposit validation', + }); + + const params: DepositParams = { + amount: '100', + assetId: '' as CaipAssetId, + }; + + const result = await provider.validateDeposit(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe('AssetId is required for deposit validation'); + }); + + it('rejects unsupported assetId', async () => { + mockValidateDepositParams.mockReturnValue({ + isValid: false, + error: 'Asset not supported', + }); + + const params: DepositParams = { + amount: '100', + assetId: + 'eip155:1/erc20:0x1234567890123456789012345678901234567890/default', + }; + + const result = await provider.validateDeposit(params); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('not supported'); + }); + + it('handles decimal amounts correctly', async () => { + mockValidateDepositParams.mockReturnValue({ isValid: true }); + + const params: DepositParams = { + amount: '100.123456', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default', + }; + + const result = await provider.validateDeposit(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('handles large amounts correctly', async () => { + mockValidateDepositParams.mockReturnValue({ isValid: true }); + + const params: DepositParams = { + amount: '1000000', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default', + }; + + const result = await provider.validateDeposit(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('handles scientific notation', async () => { + mockValidateDepositParams.mockReturnValue({ isValid: true }); + + const params: DepositParams = { + amount: '1e6', + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default', + }; + + const result = await provider.validateDeposit(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + }); + + describe('validateClosePosition', () => { + it('validates full close position successfully', async () => { + const params: ClosePositionParams = { + symbol: 'BTC', + orderType: 'market', + }; + + const result = await provider.validateClosePosition(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('validates partial close position successfully', async () => { + const params: ClosePositionParams = { + symbol: 'BTC', + size: '0.5', + orderType: 'market', + currentPrice: 45000, + }; + + const result = await provider.validateClosePosition(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('rejects close position below minimum value on mainnet', async () => { + mockClientService.isTestnetMode.mockReturnValue(false); + + const params: ClosePositionParams = { + symbol: 'BTC', + size: '0.0001', // $4.50 at $45,000 BTC, below $10 minimum + orderType: 'market', + currentPrice: 45000, + }; + + const result = await provider.validateClosePosition(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_SIZE_MIN); + }); + + it('rejects close position below minimum value on testnet', async () => { + mockClientService.isTestnetMode.mockReturnValue(true); + + const params: ClosePositionParams = { + symbol: 'BTC', + size: '0.00022', // $9.90 at $45,000 BTC, below $11 testnet minimum + orderType: 'market', + currentPrice: 45000, + }; + + const result = await provider.validateClosePosition(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_SIZE_MIN); + }); + + it('accepts close position at minimum value', async () => { + mockClientService.isTestnetMode.mockReturnValue(false); + + const params: ClosePositionParams = { + symbol: 'BTC', + size: '0.00023', // $10.35 at $45,000 BTC, above $10 minimum + orderType: 'market', + currentPrice: 45000, + }; + + const result = await provider.validateClosePosition(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('validates limit close position with price', async () => { + const params: ClosePositionParams = { + symbol: 'BTC', + size: '1.0', + orderType: 'limit', + price: '44000', + currentPrice: 45000, + }; + + const result = await provider.validateClosePosition(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('rejects limit close without price', async () => { + const params: ClosePositionParams = { + symbol: 'BTC', + size: '1.0', + orderType: 'limit', + currentPrice: 45000, + }; + + const result = await provider.validateClosePosition(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_LIMIT_PRICE_REQUIRED); + }); + + it('handles validation when currentPrice is not provided', async () => { + const params: ClosePositionParams = { + symbol: 'BTC', + size: '0.5', + orderType: 'market', + // currentPrice not provided + }; + + const result = await provider.validateClosePosition(params); + + // Should still validate basic params but skip minimum order value check + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + }); + + describe('calculateLiquidationPrice', () => { + beforeEach(() => { + // Set up mock for asset info with maxLeverage: 20 for BTC (test expectations) + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 20 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 20 }, + ], + }), + }), + ); + }); + + it('calculates liquidation price for long position correctly', async () => { + const params = { + entryPrice: 50000, + leverage: 10, + direction: 'long' as const, + asset: 'BTC', + }; + + const result = await provider.calculateLiquidationPrice(params); + + // With 10x leverage and 20x max leverage: + // maintenance margin = 1 / (2 * 20) = 0.025 + // initial margin = 1 / 10 = 0.1 + // margin available = 0.1 - 0.025 = 0.075 + // l = 1 / 40 = 0.025 + // liquidation = 50000 - (1 * 0.075 * 50000) / (1 - 0.025 * 1) + // liquidation = 50000 - 3750 / 0.975 = 50000 - 3846.15 = 46153.85 + expect(parseFloat(result)).toBeCloseTo(46153.85, 2); + }); + + it('calculates liquidation price for short position correctly', async () => { + const params = { + entryPrice: 50000, + leverage: 10, + direction: 'short' as const, + asset: 'BTC', + }; + + const result = await provider.calculateLiquidationPrice(params); + + // With 10x leverage and 20x max leverage: + // maintenance margin = 1 / (2 * 20) = 0.025 + // initial margin = 1 / 10 = 0.1 + // margin available = 0.1 - 0.025 = 0.075 + // l = 1 / 40 = 0.025 + // liquidation = 50000 - (-1 * 0.075 * 50000) / (1 - 0.025 * -1) + // liquidation = 50000 + 3750 / 1.025 = 50000 + 3658.54 = 53658.54 + expect(parseFloat(result)).toBeCloseTo(53658.54, 2); + }); + + it('throws error for leverage exceeding maintenance leverage', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 20 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 20 }, + ], + }), + }), + ); + + const params = { + entryPrice: 50000, + leverage: 41, // Exceeds maintenance leverage (2 * 20 = 40) + direction: 'long' as const, + asset: 'BTC', + }; + + await expect(provider.calculateLiquidationPrice(params)).rejects.toThrow( + 'Invalid leverage: 41x exceeds maximum allowed leverage of 40x', + ); + }); + + it('handles invalid inputs', async () => { + const invalidCases = [ + { entryPrice: 0, leverage: 10, direction: 'long' as const }, + { entryPrice: 50000, leverage: 0, direction: 'long' as const }, + { entryPrice: NaN, leverage: 10, direction: 'long' as const }, + { entryPrice: 50000, leverage: Infinity, direction: 'long' as const }, + { entryPrice: -100, leverage: 10, direction: 'long' as const }, + ]; + + for (const params of invalidCases) { + const result = await provider.calculateLiquidationPrice(params); + expect(result).toBe('0.00'); + } + }); + + it('uses default max leverage when asset is not provided', async () => { + const params = { + entryPrice: 50000, + leverage: 4, + direction: 'long' as const, + // No asset provided, so default 3x will be used + }; + + const result = await provider.calculateLiquidationPrice(params); + + // Should use default 3x max leverage (since no asset provided) + // maintenance leverage = 2 * 3 = 6x + // l = 1 / 6 = 0.1667 + // initial margin = 1 / 4 = 0.25 + // maintenance margin = 1 / 6 = 0.1667 + // margin available = 0.25 - 0.1667 = 0.0833 + // liq price = 50000 - 1 * 0.0833 * 50000 / (1 - 0.1667 * 1) + // liq price = 50000 - 4165 / 0.8333 = 50000 - 4998 = 45002 + expect(parseFloat(result)).toBeCloseTo(45002, -1); + }); + + it('throws error when leverage exceeds default max leverage', async () => { + const params = { + entryPrice: 50000, + leverage: 10, + direction: 'long' as const, + // No asset provided, so default 3x will be used + }; + + await expect(provider.calculateLiquidationPrice(params)).rejects.toThrow( + 'Invalid leverage: 10x exceeds maximum allowed leverage of 6x', + ); + }); + }); + + describe('calculateMaintenanceMargin', () => { + it('calculates maintenance margin correctly for 40x max leverage asset', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC', maxLeverage: 40, szDecimals: 5 }], + }), + }), + ); + + const result = await provider.calculateMaintenanceMargin({ + asset: 'BTC', + }); + + // Maintenance margin = 1 / (2 * 40) = 0.0125 (1.25%) + expect(result).toBe(0.0125); + }); + + it('calculates maintenance margin correctly for 3x max leverage asset', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'DOGE', maxLeverage: 3, szDecimals: 0 }], + }), + }); + + const result = await provider.calculateMaintenanceMargin({ + asset: 'DOGE', + }); + + // Maintenance margin = 1 / (2 * 3) = 0.1667 (16.67%) + expect(result).toBeCloseTo(0.1667, 4); + }); + + it('returns default maintenance margin when asset not found', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + meta: jest.fn().mockResolvedValue({ + universe: [], + }), + }); + + const result = await provider.calculateMaintenanceMargin({ + asset: 'UNKNOWN', + }); + + // Should use default max leverage of 3, so maintenance margin = 1/(2*3) = 0.16666... + expect(result).toBeCloseTo(0.16666666666666666); + }); + }); + + describe('getMaxLeverage', () => { + it('returns max leverage for an asset', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue( + createMockInfoClient({ + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'ETH', maxLeverage: 30, szDecimals: 4 }], + }), + }), + ); + + const result = await provider.getMaxLeverage('ETH'); + + expect(result).toBe(30); + }); + + it('returns default max leverage when asset not found', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + meta: jest.fn().mockResolvedValue({ + universe: [], + }), + }); + + const result = await provider.getMaxLeverage('UNKNOWN'); + + // Should return default max leverage of 3 + expect(result).toBe(3); + }); + + it('returns default max leverage on network failure', async () => { + mockClientService.getInfoClient = jest.fn().mockReturnValue({ + meta: jest.fn().mockRejectedValue(new Error('Network error')), + }); + + const result = await provider.getMaxLeverage('BTC'); + + // Should return default max leverage of 3 on error + expect(result).toBe(3); + }); + }); + + describe('validateOrder', () => { + beforeEach(() => { + mockValidateOrderParams.mockReturnValue({ isValid: true }); + }); + + it('validates order successfully with valid params and price', async () => { + const params: OrderParams = { + symbol: 'BTC', + size: '0.1', + isBuy: true, + orderType: 'market', + currentPrice: 50000, + leverage: 10, + }; + + const result = await provider.validateOrder(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + expect(mockValidateOrderParams).toHaveBeenCalledWith({ + coin: 'BTC', // validateOrderParams uses 'coin' (internal util), provider maps symbol -> coin + size: '0.1', + price: undefined, + orderType: 'market', + }); + }); + + it('fails validation when currentPrice is missing', async () => { + const params: OrderParams = { + symbol: 'BTC', + size: '0.1', + isBuy: true, + orderType: 'market', + // currentPrice missing + leverage: 10, + }; + + const result = await provider.validateOrder(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_PRICE_REQUIRED); + }); + + it('fails validation when order value is below minimum', async () => { + const params: OrderParams = { + symbol: 'BTC', + size: '0.00001', // Very small size + isBuy: true, + orderType: 'market', + currentPrice: 50000, // 0.00001 * 50000 = $0.5 (below minimum) + leverage: 10, + }; + + const result = await provider.validateOrder(params); + + expect(result.isValid).toBe(false); + expect(result.error).toContain(PERPS_ERROR_CODES.ORDER_SIZE_MIN); + }); + + it('fails validation when basic params are invalid', async () => { + mockValidateOrderParams.mockReturnValue({ + isValid: false, + error: 'Invalid coin', + }); + + const params: OrderParams = { + symbol: 'INVALID', + size: '0.1', + isBuy: true, + orderType: 'market', + currentPrice: 50000, + leverage: 10, + }; + + const result = await provider.validateOrder(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe('Invalid coin'); + }); + + it('validates limit order with price', async () => { + const params: OrderParams = { + symbol: 'ETH', + size: '1', + isBuy: true, + orderType: 'limit', + price: '3000', + currentPrice: 3050, + leverage: 5, + }; + + const result = await provider.validateOrder(params); + + expect(result.isValid).toBe(true); + expect(mockValidateOrderParams).toHaveBeenCalledWith({ + coin: 'ETH', // validateOrderParams uses 'coin' (internal util), provider maps symbol -> coin + size: '1', + price: '3000', + orderType: 'limit', + }); + }); + + it('handles validation errors gracefully', async () => { + mockValidateOrderParams.mockImplementation(() => { + throw new Error('Unexpected error'); + }); + + const params: OrderParams = { + symbol: 'BTC', + size: '0.1', + isBuy: true, + orderType: 'market', + currentPrice: 50000, + leverage: 10, + }; + + const result = await provider.validateOrder(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe('Unexpected error'); + }); + + describe('existing position leverage validation', () => { + it('allows order when leverage equals existing position leverage', async () => { + const params: OrderParams = { + symbol: 'BTC', + size: '0.1', + isBuy: true, + orderType: 'market', + currentPrice: 50000, + leverage: 10, + existingPositionLeverage: 10, + }; + + const result = await provider.validateOrder(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('allows order when leverage exceeds existing position leverage', async () => { + const params: OrderParams = { + symbol: 'BTC', + size: '0.1', + isBuy: true, + orderType: 'market', + currentPrice: 50000, + leverage: 15, + existingPositionLeverage: 10, + }; + + const result = await provider.validateOrder(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('rejects order when leverage below existing position leverage', async () => { + const params: OrderParams = { + symbol: 'BTC', + size: '0.1', + isBuy: true, + orderType: 'market', + currentPrice: 50000, + leverage: 5, + existingPositionLeverage: 10, + }; + + const result = await provider.validateOrder(params); + + expect(result.isValid).toBe(false); + expect(result.error).toBe( + PERPS_ERROR_CODES.ORDER_LEVERAGE_BELOW_POSITION, + ); + }); + + it('allows any leverage when no existing position', async () => { + const params: OrderParams = { + symbol: 'BTC', + size: '0.1', + isBuy: true, + orderType: 'market', + currentPrice: 50000, + leverage: 3, + }; + + const result = await provider.validateOrder(params); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/providers/MYXProvider.test.ts b/packages/perps-controller/tests/src/providers/MYXProvider.test.ts new file mode 100644 index 0000000000..5156356323 --- /dev/null +++ b/packages/perps-controller/tests/src/providers/MYXProvider.test.ts @@ -0,0 +1,1256 @@ +/* eslint-disable */ +import { + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks'; + +jest.mock('@myx-trade/sdk', () => ({ + MyxClient: jest.fn(), + OrderStatusEnum: { Successful: 9 }, +})); + +import { CandlePeriod } from '../../../src/constants/chartConfig'; +import { MYXProvider } from '../../../src/providers/MYXProvider'; +import { MYXClientService } from '../../../src/services/MYXClientService'; +import { WebSocketConnectionState } from '../../../src/types'; +import type { PerpsPlatformDependencies } from '../../../src/types'; +import type { MYXPoolSymbol, MYXTicker } from '../../../src/types/myx-types'; +import { + adaptMarketFromMYX, + adaptMarketDataFromMYX, + adaptPriceFromMYX, + filterMYXExclusiveMarkets, + buildPoolSymbolMap, +} from '../../../src/utils/myxAdapter'; + +// ============================================================================ +// Mocks +// ============================================================================ + +jest.mock( + '../../../../core/AppConstants', + () => ({ + __esModule: true, + default: { ZERO_ADDRESS: '0x0000000000000000000000000000000000000000' }, + }), + { virtual: true }, +); +jest.mock('../../../src/services/MYXClientService'); +jest.mock('../../../src/services/MYXWalletService', () => ({ + MYXWalletService: jest.fn().mockImplementation(() => ({ + createEthersSigner: jest.fn().mockReturnValue({}), + createWalletClient: jest.fn().mockReturnValue({}), + getUserAddress: jest.fn().mockReturnValue('0xuser123'), + getCurrentAccountId: jest.fn().mockResolvedValue('eip155:421614:0xuser123'), + })), +})); +jest.mock('../../../src/utils/myxAdapter', () => ({ + adaptMarketFromMYX: jest.fn(), + adaptMarketDataFromMYX: jest.fn(), + adaptPriceFromMYX: jest.fn(), + adaptCandleFromMYX: jest.fn(), + adaptCandleFromMYXWebSocket: jest.fn(), + adaptPositionFromMYX: jest.fn(), + adaptOrderFromMYX: jest.fn(), + adaptAccountStateFromMYX: jest.fn(), + adaptOrderFillFromMYX: jest.fn(), + adaptFundingFromMYX: jest.fn(), + adaptUserHistoryFromMYX: jest.fn(), + filterMYXExclusiveMarkets: jest.fn(), + buildPoolSymbolMap: jest.fn(), + toMYXKlineResolution: jest.fn().mockReturnValue('1h'), +})); +// WebSocketConnectionState is now defined inline in types/index.ts (no mock needed) + +const MockedMYXClientService = MYXClientService as jest.MockedClass< + typeof MYXClientService +>; +const mockAdaptMarketFromMYX = adaptMarketFromMYX as jest.MockedFunction< + typeof adaptMarketFromMYX +>; +const mockAdaptMarketDataFromMYX = + adaptMarketDataFromMYX as jest.MockedFunction; +const mockAdaptPriceFromMYX = adaptPriceFromMYX as jest.MockedFunction< + typeof adaptPriceFromMYX +>; +const mockFilterMYXExclusiveMarkets = + filterMYXExclusiveMarkets as jest.MockedFunction< + typeof filterMYXExclusiveMarkets + >; +const mockBuildPoolSymbolMap = buildPoolSymbolMap as jest.MockedFunction< + typeof buildPoolSymbolMap +>; + +// ============================================================================ +// Test Fixtures +// ============================================================================ + +function makePool(overrides: Partial = {}): MYXPoolSymbol { + return { + chainId: 421614, + marketId: 'market-1', + poolId: '0xpool1', + baseSymbol: 'RHEA', + quoteSymbol: 'USDT', + baseTokenIcon: '', + baseToken: '0xbase', + quoteToken: '0xquote', + ...overrides, + }; +} + +function makeTicker(overrides: Partial = {}): MYXTicker { + return { + chainId: 421614, + poolId: '0xpool1', + oracleId: 1, + price: '1500.00', + change: '2.5', + high: '0', + low: '0', + volume: '1000000', + turnover: '0', + ...overrides, + }; +} + +function createProvider( + deps: jest.Mocked, + isTestnet = true, +): MYXProvider { + return new MYXProvider({ + isTestnet, + platformDependencies: deps, + }); +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('MYXProvider', () => { + let provider: MYXProvider; + let mockDeps: jest.Mocked; + let mockClientService: jest.Mocked; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + + mockDeps = createMockInfrastructure(); + + // Setup default adapter mock returns + mockFilterMYXExclusiveMarkets.mockImplementation((pools) => pools); + mockBuildPoolSymbolMap.mockReturnValue(new Map([['0xpool1', 'RHEA']])); + mockAdaptMarketFromMYX.mockReturnValue({ + name: 'RHEA', + szDecimals: 18, + maxLeverage: 100, + marginTableId: 0, + minimumOrderSize: 10, + providerId: 'myx', + }); + + provider = createProvider(mockDeps); + + // Get reference to the mocked client service instance + mockClientService = MockedMYXClientService.mock + .instances[0] as jest.Mocked; + }); + + afterEach(() => { + jest.useRealTimers(); + jest.resetAllMocks(); + }); + + // ========================================================================== + // Constructor + // ========================================================================== + + describe('constructor', () => { + it('sets protocolId to myx', () => { + expect(provider.protocolId).toBe('myx'); + }); + + it('defaults to testnet when isTestnet is undefined', () => { + const defaultProvider = new MYXProvider({ + platformDependencies: mockDeps, + }); + + expect(defaultProvider.protocolId).toBe('myx'); + // Constructor logs the isTestnet value + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + '[MYXProvider] Constructor complete', + expect.objectContaining({ isTestnet: true }), + ); + }); + + it('initializes MYXClientService with correct config', () => { + expect(MockedMYXClientService).toHaveBeenCalledWith(mockDeps, { + isTestnet: true, + }); + }); + + it('logs constructor completion', () => { + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + '[MYXProvider] Constructor complete', + expect.objectContaining({ + protocolId: 'myx', + isTestnet: true, + }), + ); + }); + }); + + // ========================================================================== + // Initialization & Lifecycle + // ========================================================================== + + describe('initialize', () => { + it('returns success after fetching and filtering markets', async () => { + const pools = [makePool()]; + mockClientService.getMarkets.mockResolvedValueOnce(pools); + + const result = await provider.initialize(); + + expect(result).toEqual({ success: true }); + expect(mockClientService.getMarkets).toHaveBeenCalled(); + expect(mockFilterMYXExclusiveMarkets).toHaveBeenCalledWith(pools); + expect(mockBuildPoolSymbolMap).toHaveBeenCalled(); + }); + + it('returns failure with error message on SDK failure', async () => { + mockClientService.getMarkets.mockRejectedValueOnce( + new Error('Init failed'), + ); + + const result = await provider.initialize(); + + expect(result).toEqual({ + success: false, + error: 'Init failed', + }); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('disconnect', () => { + it('returns success and calls clientService.disconnect', async () => { + const result = await provider.disconnect(); + + expect(result).toEqual({ success: true }); + expect(mockClientService.disconnect).toHaveBeenCalled(); + }); + + it('returns failure when disconnect throws', async () => { + mockClientService.disconnect.mockImplementation(() => { + throw new Error('Disconnect error'); + }); + + const result = await provider.disconnect(); + + expect(result).toEqual({ + success: false, + error: 'Disconnect error', + }); + }); + }); + + describe('ping', () => { + it('delegates to clientService.ping', async () => { + mockClientService.ping.mockResolvedValueOnce(undefined); + + await provider.ping(3000); + + expect(mockClientService.ping).toHaveBeenCalledWith(3000); + }); + + it('delegates without timeout argument', async () => { + mockClientService.ping.mockResolvedValueOnce(undefined); + + await provider.ping(); + + expect(mockClientService.ping).toHaveBeenCalledWith(undefined); + }); + }); + + describe('toggleTestnet', () => { + it('returns failure with testnet-only message', async () => { + const result = await provider.toggleTestnet(); + + expect(result).toEqual({ + success: false, + isTestnet: true, + error: 'MYX mainnet not yet available', + }); + }); + }); + + describe('isReadyToTrade', () => { + it('returns not ready with trading not supported message', async () => { + const result = await provider.isReadyToTrade(); + + expect(result).toEqual({ + ready: false, + error: 'MYX provider requires messenger for wallet operations', + walletConnected: false, + networkSupported: true, + }); + }); + }); + + // ========================================================================== + // Market Data Operations + // ========================================================================== + + describe('getMarkets', () => { + it('fetches markets, filters, and adapts them', async () => { + const pools = [ + makePool(), + makePool({ poolId: '0xpool2', baseSymbol: 'PARTI' }), + ]; + mockClientService.getMarkets.mockResolvedValueOnce(pools); + mockFilterMYXExclusiveMarkets.mockReturnValueOnce(pools); + + const result = await provider.getMarkets(); + + expect(mockClientService.getMarkets).toHaveBeenCalled(); + expect(mockFilterMYXExclusiveMarkets).toHaveBeenCalledWith(pools); + expect(mockAdaptMarketFromMYX).toHaveBeenCalledTimes(2); + expect(result).toHaveLength(2); + }); + + it('returns empty array on failure', async () => { + mockClientService.getMarkets.mockRejectedValueOnce( + new Error('Market fetch failed'), + ); + + const result = await provider.getMarkets(); + expect(result).toEqual([]); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('getMarketDataWithPrices', () => { + it('fetches markets if cache is empty then returns market data', async () => { + const pools = [makePool()]; + const tickers = [makeTicker()]; + mockClientService.getMarkets.mockResolvedValue(pools); + mockFilterMYXExclusiveMarkets.mockReturnValue(pools); + mockClientService.getTickers.mockResolvedValueOnce(tickers); + mockAdaptMarketDataFromMYX.mockReturnValue({ + symbol: 'RHEA', + name: 'Rhea Finance', + maxLeverage: '100x', + price: '$1,500.00', + change24h: '+$37.50', + change24hPercent: '+2.50%', + volume: '$1.00M', + providerId: 'myx', + }); + + const result = await provider.getMarketDataWithPrices(); + + expect(result).toHaveLength(1); + expect(result[0].symbol).toBe('RHEA'); + expect(mockAdaptMarketDataFromMYX).toHaveBeenCalledWith( + pools[0], + tickers[0], + mockDeps.marketDataFormatters, + ); + }); + + it('filters out pools with no matching ticker', async () => { + const pools = [makePool({ poolId: '0xpool1' })]; + const tickers = [makeTicker({ poolId: '0xDIFFERENT' })]; + mockClientService.getMarkets.mockResolvedValue(pools); + mockFilterMYXExclusiveMarkets.mockReturnValue(pools); + mockClientService.getTickers.mockResolvedValueOnce(tickers); + + const result = await provider.getMarketDataWithPrices(); + + expect(result).toHaveLength(0); + expect(mockAdaptMarketDataFromMYX).not.toHaveBeenCalled(); + }); + + it('returns empty array on failure', async () => { + mockClientService.getMarkets.mockRejectedValueOnce( + new Error('Data error'), + ); + + const result = await provider.getMarketDataWithPrices(); + expect(result).toEqual([]); + }); + }); + + // ========================================================================== + // Price Subscriptions + // ========================================================================== + + describe('subscribeToPrices', () => { + beforeEach(async () => { + // Pre-populate pools cache via initialize + const pools = [makePool({ poolId: '0xpool1', baseSymbol: 'RHEA' })]; + mockClientService.getMarkets.mockResolvedValueOnce(pools); + mockFilterMYXExclusiveMarkets.mockReturnValue(pools); + + await provider.initialize(); + }); + + it('starts price polling and returns unsubscribe function', () => { + const callback = jest.fn(); + + const unsubscribe = provider.subscribeToPrices({ + symbols: ['RHEA'], + callback, + }); + + expect(mockClientService.startPricePolling).toHaveBeenCalledWith( + ['0xpool1'], + expect.any(Function), + ); + expect(typeof unsubscribe).toBe('function'); + }); + + it('calls callback with empty array when no pool IDs match', () => { + const callback = jest.fn(); + + provider.subscribeToPrices({ + symbols: ['NONEXISTENT'], + callback, + }); + + jest.advanceTimersByTime(1); + expect(callback).toHaveBeenCalledWith([]); + }); + + it('transforms tickers to PriceUpdate format in polling callback', () => { + const callback = jest.fn(); + mockAdaptPriceFromMYX.mockReturnValue({ + price: '1500', + change24h: 2.5, + }); + + provider.subscribeToPrices({ + symbols: ['RHEA'], + callback, + }); + + // Get the polling callback that was passed to startPricePolling + const pollingCallback = + mockClientService.startPricePolling.mock.calls[0][1]; + + // Simulate polling callback + pollingCallback([makeTicker({ poolId: '0xpool1' })]); + + expect(callback).toHaveBeenCalledWith([ + expect.objectContaining({ + symbol: 'RHEA', + price: '1500', + providerId: 'myx', + percentChange24h: '2.50', + }), + ]); + }); + + it('unsubscribe stops price polling', () => { + const callback = jest.fn(); + + const unsubscribe = provider.subscribeToPrices({ + symbols: ['RHEA'], + callback, + }); + + unsubscribe(); + + expect(mockClientService.stopPricePolling).toHaveBeenCalled(); + }); + }); + + // ========================================================================== + // Asset Routes (Stage 1 - Stubbed) + // ========================================================================== + + describe('getDepositRoutes', () => { + it('returns empty array', () => { + expect(provider.getDepositRoutes()).toEqual([]); + }); + }); + + describe('getWithdrawalRoutes', () => { + it('returns empty array', () => { + expect(provider.getWithdrawalRoutes()).toEqual([]); + }); + }); + + // ========================================================================== + // Trading Operations (Stage 1 - All Stubbed) + // ========================================================================== + + describe('trading operations return not-supported errors', () => { + it('placeOrder returns failure', async () => { + const result = await provider.placeOrder( + {} as Parameters[0], + ); + + expect(result).toEqual({ + success: false, + error: 'MYX trading not yet supported', + }); + }); + + it('editOrder returns failure', async () => { + const result = await provider.editOrder( + {} as Parameters[0], + ); + + expect(result).toEqual({ + success: false, + error: 'MYX trading not yet supported', + }); + }); + + it('cancelOrder returns failure', async () => { + const result = await provider.cancelOrder( + {} as Parameters[0], + ); + + expect(result).toEqual({ + success: false, + error: 'MYX trading not yet supported', + }); + }); + + it('cancelOrders returns zero counts', async () => { + const result = await provider.cancelOrders( + {} as Parameters[0], + ); + + expect(result).toEqual({ + success: false, + successCount: 0, + failureCount: 0, + results: [], + }); + }); + + it('closePosition returns failure', async () => { + const result = await provider.closePosition( + {} as Parameters[0], + ); + + expect(result).toEqual({ + success: false, + error: 'MYX trading not yet supported', + }); + }); + + it('closePositions returns zero counts', async () => { + const result = await provider.closePositions( + {} as Parameters[0], + ); + + expect(result).toEqual({ + success: false, + successCount: 0, + failureCount: 0, + results: [], + }); + }); + + it('updatePositionTPSL returns failure', async () => { + const result = await provider.updatePositionTPSL( + {} as Parameters[0], + ); + + expect(result).toEqual({ + success: false, + error: 'MYX trading not yet supported', + }); + }); + + it('updateMargin returns failure', async () => { + const result = await provider.updateMargin({ + symbol: 'RHEA', + amount: '100', + }); + + expect(result).toEqual({ + success: false, + error: 'MYX trading not yet supported', + }); + }); + + it('withdraw returns failure', async () => { + const result = await provider.withdraw( + {} as Parameters[0], + ); + + expect(result).toEqual({ + success: false, + error: 'MYX trading not yet supported', + }); + }); + }); + + // ========================================================================== + // Account Operations (Stage 1 - Empty Returns) + // ========================================================================== + + describe('account operations return empty defaults', () => { + it('getPositions returns empty array', async () => { + expect(await provider.getPositions()).toEqual([]); + }); + + it('getAccountState returns zeroed state', async () => { + const result = await provider.getAccountState(); + + expect(result).toEqual({ + spendableBalance: '0', + withdrawableBalance: '0', + totalBalance: '0', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + }); + + it('getOrders returns empty array', async () => { + expect(await provider.getOrders()).toEqual([]); + }); + + it('getOpenOrders returns empty array', async () => { + expect(await provider.getOpenOrders()).toEqual([]); + }); + + it('getOrderFills returns empty array', async () => { + expect(await provider.getOrderFills()).toEqual([]); + }); + + it('getOrFetchFills returns empty array', async () => { + expect(await provider.getOrFetchFills()).toEqual([]); + }); + + it('getFunding returns empty array', async () => { + expect(await provider.getFunding()).toEqual([]); + }); + + it('getHistoricalPortfolio returns zeroed result', async () => { + const result = await provider.getHistoricalPortfolio(); + + expect(result.accountValue1dAgo).toBe('0'); + expect(result.timestamp).toBeDefined(); + }); + + it('getUserNonFundingLedgerUpdates returns empty array', async () => { + expect(await provider.getUserNonFundingLedgerUpdates()).toEqual([]); + }); + + it('getUserHistory returns empty array', async () => { + expect(await provider.getUserHistory()).toEqual([]); + }); + }); + + // ========================================================================== + // Validation Operations (Stage 1 - All Invalid) + // ========================================================================== + + describe('validation operations return invalid', () => { + it('validateDeposit returns not valid', async () => { + const result = await provider.validateDeposit( + {} as Parameters[0], + ); + + expect(result).toEqual({ + isValid: false, + error: 'MYX trading not yet supported', + }); + }); + + it('validateOrder returns not valid', async () => { + const result = await provider.validateOrder( + {} as Parameters[0], + ); + + expect(result).toEqual({ + isValid: false, + error: 'MYX trading not yet supported', + }); + }); + + it('validateClosePosition returns not valid', async () => { + const result = await provider.validateClosePosition( + {} as Parameters[0], + ); + + expect(result).toEqual({ + isValid: false, + error: 'MYX trading not yet supported', + }); + }); + + it('validateWithdrawal returns not valid', async () => { + const result = await provider.validateWithdrawal( + {} as Parameters[0], + ); + + expect(result).toEqual({ + isValid: false, + error: 'MYX trading not yet supported', + }); + }); + }); + + // ========================================================================== + // Protocol Calculations (Stage 1 - Default Values) + // ========================================================================== + + describe('protocol calculations return defaults', () => { + it('calculateLiquidationPrice returns "0"', async () => { + expect( + await provider.calculateLiquidationPrice( + {} as Parameters[0], + ), + ).toBe('0'); + }); + + it('calculateMaintenanceMargin returns 0', async () => { + expect( + await provider.calculateMaintenanceMargin( + {} as Parameters[0], + ), + ).toBe(0); + }); + + it('getMaxLeverage returns 100', async () => { + expect(await provider.getMaxLeverage('RHEA')).toBe(100); + }); + + it('calculateFees returns default fee rates', async () => { + const result = await provider.calculateFees( + {} as Parameters[0], + ); + + expect(result).toEqual({ + feeRate: 0.0005, + protocolFeeRate: 0.0005, + }); + }); + }); + + // ========================================================================== + // Subscriptions (Stage 1 - No-op) + // ========================================================================== + + describe('subscriptions call back with empty data', () => { + it('subscribeToPositions calls back with empty array', () => { + const callback = jest.fn(); + + const unsub = provider.subscribeToPositions({ callback }); + jest.advanceTimersByTime(1); + + expect(callback).toHaveBeenCalledWith([]); + expect(typeof unsub).toBe('function'); + }); + + it('subscribeToOrderFills calls back with empty array', () => { + const callback = jest.fn(); + + const unsub = provider.subscribeToOrderFills({ callback }); + jest.advanceTimersByTime(1); + + expect(callback).toHaveBeenCalledWith([]); + expect(typeof unsub).toBe('function'); + }); + + it('subscribeToOrders calls back with empty array', () => { + const callback = jest.fn(); + + const unsub = provider.subscribeToOrders({ callback }); + jest.advanceTimersByTime(1); + + expect(callback).toHaveBeenCalledWith([]); + expect(typeof unsub).toBe('function'); + }); + + it('subscribeToAccount calls back with zeroed state', () => { + const callback = jest.fn(); + + const unsub = provider.subscribeToAccount({ callback }); + jest.advanceTimersByTime(1); + + expect(callback).toHaveBeenCalledWith({ + spendableBalance: '0', + withdrawableBalance: '0', + totalBalance: '0', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }); + expect(typeof unsub).toBe('function'); + }); + + it('subscribeToOICaps calls back with empty array', () => { + const callback = jest.fn(); + + const unsub = provider.subscribeToOICaps({ callback }); + jest.advanceTimersByTime(1); + + expect(callback).toHaveBeenCalledWith([]); + expect(typeof unsub).toBe('function'); + }); + + it('subscribeToCandles calls back with empty candles', () => { + const callback = jest.fn(); + + const unsub = provider.subscribeToCandles({ + symbol: 'RHEA', + interval: CandlePeriod.OneHour, + callback, + }); + jest.advanceTimersByTime(1); + + expect(callback).toHaveBeenCalledWith({ + symbol: 'RHEA', + interval: CandlePeriod.OneHour, + candles: [], + }); + expect(typeof unsub).toBe('function'); + }); + + it('subscribeToOrderBook calls back with empty book', () => { + const callback = jest.fn(); + + const unsub = provider.subscribeToOrderBook({ + symbol: 'RHEA', + callback, + }); + jest.advanceTimersByTime(1); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + bids: [], + asks: [], + spread: '0', + spreadPercentage: '0', + midPrice: '0', + maxTotal: '0', + }), + ); + expect(typeof unsub).toBe('function'); + }); + }); + + describe('setLiveDataConfig', () => { + it('does not throw (no-op)', () => { + expect(() => provider.setLiveDataConfig({})).not.toThrow(); + }); + }); + + // ========================================================================== + // Connection State (Stage 1 - REST Only) + // ========================================================================== + + describe('connection state', () => { + it('getWebSocketConnectionState returns Connected', () => { + expect(provider.getWebSocketConnectionState()).toBe( + WebSocketConnectionState.Connected, + ); + }); + + it('subscribeToConnectionState returns noop unsubscribe', () => { + const listener = jest.fn(); + const unsub = provider.subscribeToConnectionState(listener); + + expect(typeof unsub).toBe('function'); + // Listener not called since REST has no state changes + expect(listener).not.toHaveBeenCalled(); + }); + + it('reconnect is a no-op', async () => { + await expect(provider.reconnect()).resolves.toBeUndefined(); + }); + }); + + // ========================================================================== + // Block Explorer + // ========================================================================== + + describe('getBlockExplorerUrl', () => { + it('returns testnet explorer URL without address', () => { + expect(provider.getBlockExplorerUrl()).toBe( + 'https://sepolia.arbiscan.io', + ); + }); + + it('returns testnet explorer URL with address', () => { + expect(provider.getBlockExplorerUrl('0xabc')).toBe( + 'https://sepolia.arbiscan.io/address/0xabc', + ); + }); + + it('returns mainnet explorer URL without address', () => { + const mainnetProvider = createProvider(mockDeps, false); + + expect(mainnetProvider.getBlockExplorerUrl()).toBe('https://bscscan.com'); + }); + + it('returns mainnet explorer URL with address', () => { + const mainnetProvider = createProvider(mockDeps, false); + + expect(mainnetProvider.getBlockExplorerUrl('0xdef')).toBe( + 'https://bscscan.com/address/0xdef', + ); + }); + }); + + // ========================================================================== + // Authenticated Read Operations + // ========================================================================== + + /* eslint-disable @typescript-eslint/no-explicit-any */ + describe('authenticated reads', () => { + let authProvider: MYXProvider; + let authClientService: jest.Mocked; + + beforeEach(() => { + // Re-set MYXWalletService mock (cleared by outer jest.clearAllMocks) + const { MYXWalletService } = jest.requireMock( + '../../../src/services/MYXWalletService', + ) as { + MYXWalletService: jest.Mock; + }; + MYXWalletService.mockImplementation(() => ({ + createEthersSigner: jest.fn().mockReturnValue({}), + createWalletClient: jest.fn().mockReturnValue({}), + getUserAddress: jest.fn().mockReturnValue('0xuser123'), + getCurrentAccountId: jest + .fn() + .mockResolvedValue('eip155:421614:0xuser123'), + })); + + const { createMockMessenger: createMsg } = jest.requireActual( + '../../helpers/serviceMocks', + ) as { createMockMessenger: typeof createMockMessenger }; + const messenger = createMsg(); + + authProvider = new MYXProvider({ + isTestnet: true, + platformDependencies: mockDeps, + messenger: messenger as any, + }); + const instances = MockedMYXClientService.mock.instances; + authClientService = instances[ + instances.length - 1 + ] as jest.Mocked; + + // Pre-authenticate so all reads succeed + authClientService.isAuthenticatedForAddress.mockReturnValue(true); + authClientService.authenticate.mockResolvedValue(undefined); + }); + + describe('getPositions', () => { + it('returns adapted positions after authentication', async () => { + const mockRawPositions = [ + { size: '1.5', symbol: 'BTC', poolId: '0xpool1' }, + ]; + authClientService.listPositions.mockResolvedValue({ + data: mockRawPositions, + } as any); + const { adaptPositionFromMYX: mockAdapt } = jest.requireMock( + '../../../src/utils/myxAdapter', + ) as { adaptPositionFromMYX: jest.Mock }; + mockAdapt.mockReturnValue({ + symbol: 'BTC', + size: '1.5', + providerId: 'myx', + }); + + const result = await authProvider.getPositions(); + + expect(result).toHaveLength(1); + expect(result[0].symbol).toBe('BTC'); + expect(authClientService.listPositions).toHaveBeenCalledWith( + '0xuser123', + ); + }); + + it('filters out zero-size positions', async () => { + authClientService.listPositions.mockResolvedValue({ + data: [ + { size: '0', symbol: 'BTC', poolId: '0xpool1' }, + { size: '1.0', symbol: 'ETH', poolId: '0xpool2' }, + ], + } as any); + const { adaptPositionFromMYX: mockAdapt } = jest.requireMock( + '../../../src/utils/myxAdapter', + ) as { adaptPositionFromMYX: jest.Mock }; + mockAdapt.mockReturnValue({ symbol: 'ETH', size: '1.0' }); + + const result = await authProvider.getPositions(); + + expect(result).toHaveLength(1); + }); + + it('returns empty array when data is null', async () => { + authClientService.listPositions.mockResolvedValue({ + data: null, + } as any); + + const result = await authProvider.getPositions(); + + expect(result).toEqual([]); + }); + }); + + describe('getAccountState', () => { + it('returns adapted account state', async () => { + authClientService.getChainId.mockReturnValue(421614); + authClientService.getWalletQuoteTokenBalance.mockResolvedValue({ + data: '1000', + } as any); + authClientService.getAccountInfo.mockResolvedValue({ + data: { balance: '1000' }, + } as any); + + const { adaptAccountStateFromMYX: mockAdapt } = jest.requireMock( + '../../../src/utils/myxAdapter', + ) as { adaptAccountStateFromMYX: jest.Mock }; + mockAdapt.mockReturnValue({ + totalBalance: '1000', + spendableBalance: '800', + withdrawableBalance: '800', + marginUsed: '200', + unrealizedPnl: '50', + returnOnEquity: '5', + }); + + // Need pools cache for account info fetch + authClientService.getMarkets.mockResolvedValue([makePool()]); + mockFilterMYXExclusiveMarkets.mockImplementation((pools) => pools); + mockBuildPoolSymbolMap.mockReturnValue(new Map()); + await authProvider.initialize(); + + const result = await authProvider.getAccountState(); + + expect(result.totalBalance).toBe('1000'); + expect(authClientService.getWalletQuoteTokenBalance).toHaveBeenCalled(); + expect(authClientService.getAccountInfo).toHaveBeenCalled(); + }); + }); + + describe('getOrders', () => { + it('returns adapted orders', async () => { + authClientService.getOrderHistory.mockResolvedValue({ + data: [{ orderId: 'o1', orderStatus: 1 }], + } as any); + const { adaptOrderFromMYX: mockAdapt } = jest.requireMock( + '../../../src/utils/myxAdapter', + ) as { adaptOrderFromMYX: jest.Mock }; + mockAdapt.mockReturnValue({ + orderId: 'o1', + status: 'open', + symbol: 'BTC', + }); + + const result = await authProvider.getOrders(); + + expect(result).toHaveLength(1); + expect(result[0].orderId).toBe('o1'); + }); + + it('returns empty array when data is null', async () => { + authClientService.getOrderHistory.mockResolvedValue({ + data: null, + } as any); + + const result = await authProvider.getOrders(); + + expect(result).toEqual([]); + }); + }); + + describe('getOrderFills', () => { + it('returns adapted fills for successful orders', async () => { + authClientService.getOrderHistory.mockResolvedValue({ + data: [ + { orderId: 'o1', orderStatus: 9 }, // Successful (OrderStatusEnum.Successful = 9) + { orderId: 'o2', orderStatus: 0 }, // Not successful + ], + } as any); + const { adaptOrderFillFromMYX: mockAdapt } = jest.requireMock( + '../../../src/utils/myxAdapter', + ) as { adaptOrderFillFromMYX: jest.Mock }; + mockAdapt.mockReturnValue({ orderId: 'o1', symbol: 'BTC' }); + + const result = await authProvider.getOrderFills(); + + expect(result).toHaveLength(1); + }); + }); + + describe('getFunding', () => { + it('returns adapted funding data', async () => { + authClientService.getTradeFlow.mockResolvedValue({ + data: [{ amount: '100' }], + } as any); + const { adaptFundingFromMYX: mockAdapt } = jest.requireMock( + '../../../src/utils/myxAdapter', + ) as { adaptFundingFromMYX: jest.Mock }; + mockAdapt.mockReturnValue([{ amount: '100', symbol: 'BTC' }]); + + const result = await authProvider.getFunding(); + + expect(result).toHaveLength(1); + }); + + it('returns empty array when data is null', async () => { + authClientService.getTradeFlow.mockResolvedValue({ + data: null, + } as any); + + const result = await authProvider.getFunding(); + + expect(result).toEqual([]); + }); + }); + }); + + // ========================================================================== + // Fee Discount + // ========================================================================== + + describe('setUserFeeDiscount', () => { + it('does not throw (no-op)', () => { + expect(() => provider.setUserFeeDiscount(100)).not.toThrow(); + expect(() => provider.setUserFeeDiscount(undefined)).not.toThrow(); + }); + }); + + // ========================================================================== + // HIP-3 Operations (N/A for MYX) + // ========================================================================== + + describe('getAvailableDexs', () => { + it('returns empty array', async () => { + expect(await provider.getAvailableDexs()).toEqual([]); + }); + }); + + // ========================================================================== + // Authentication flow (isReadyToTrade + ensureAuthenticated) + // ========================================================================== + + describe('isReadyToTrade with messenger', () => { + let authProvider: MYXProvider; + let authClientService: jest.Mocked; + + beforeEach(() => { + // Re-set MYXWalletService mock (cleared by outer jest.clearAllMocks) + const { MYXWalletService } = jest.requireMock( + '../../../src/services/MYXWalletService', + ) as { + MYXWalletService: jest.Mock; + }; + MYXWalletService.mockImplementation(() => ({ + createEthersSigner: jest.fn().mockReturnValue({}), + createWalletClient: jest.fn().mockReturnValue({}), + getUserAddress: jest.fn().mockReturnValue('0xuser123'), + getCurrentAccountId: jest + .fn() + .mockResolvedValue('eip155:421614:0xuser123'), + })); + + const messenger = createMockMessenger(); + authProvider = new MYXProvider({ + isTestnet: true, + platformDependencies: mockDeps, + messenger: messenger as any, + }); + // The new MYXProvider creates a new MYXClientService instance; + // grab the latest one + const instances = MockedMYXClientService.mock.instances; + authClientService = instances[ + instances.length - 1 + ] as jest.Mocked; + }); + + it('returns ready when already authenticated for current address', async () => { + authClientService.isAuthenticatedForAddress.mockReturnValue(true); + authClientService.getAuthenticatedAddress.mockReturnValue('0xuser123'); + + const result = await authProvider.isReadyToTrade(); + + expect(result.ready).toBe(true); + expect(result.walletConnected).toBe(true); + expect(result.networkSupported).toBe(true); + expect(result.authenticatedAddress).toBe('0xuser123'); + }); + + it('authenticates and returns ready when not yet authenticated', async () => { + authClientService.isAuthenticatedForAddress.mockReturnValue(false); + authClientService.authenticate.mockResolvedValue(undefined); + authClientService.getAuthenticatedAddress.mockReturnValue('0xuser123'); + + const result = await authProvider.isReadyToTrade(); + + expect(result.ready).toBe(true); + expect(result.walletConnected).toBe(true); + expect(authClientService.authenticate).toHaveBeenCalledWith( + expect.anything(), // signer + expect.anything(), // walletClient + '0xuser123', // address + ); + }); + + it('returns not ready when authentication fails', async () => { + authClientService.isAuthenticatedForAddress.mockReturnValue(false); + authClientService.authenticate.mockRejectedValue( + new Error('Auth rejected by user'), + ); + + const result = await authProvider.isReadyToTrade(); + + expect(result.ready).toBe(false); + expect(result.error).toContain('Auth rejected by user'); + expect(result.walletConnected).toBe(false); + }); + + it('skips authentication when already authenticated', async () => { + // First call returns not-authenticated, triggering auth + authClientService.isAuthenticatedForAddress.mockReturnValue(false); + authClientService.authenticate.mockResolvedValue(undefined); + authClientService.getAuthenticatedAddress.mockReturnValue('0xuser123'); + + const result1 = await authProvider.isReadyToTrade(); + expect(result1.ready).toBe(true); + expect(authClientService.authenticate).toHaveBeenCalledTimes(1); + + // Second call finds already-authenticated — should skip auth + authClientService.isAuthenticatedForAddress.mockReturnValue(true); + authClientService.authenticate.mockClear(); + + const result2 = await authProvider.isReadyToTrade(); + expect(result2.ready).toBe(true); + expect(authClientService.authenticate).not.toHaveBeenCalled(); + }); + + it('re-authenticates when deduped auth was for a different address', async () => { + // First call: not authenticated, authenticate succeeds + let callCount = 0; + authClientService.isAuthenticatedForAddress.mockImplementation(() => { + callCount++; + // Not authenticated for any address on first 3 checks + // Authenticated after second authenticate call + return callCount > 3; + }); + authClientService.authenticate.mockResolvedValue(undefined); + authClientService.getAuthenticatedAddress.mockReturnValue('0xuser123'); + + const result = await authProvider.isReadyToTrade(); + + expect(result.ready).toBe(true); + }); + }); + /* eslint-enable @typescript-eslint/no-explicit-any */ +}); diff --git a/packages/perps-controller/tests/src/routing/ProviderRouter.test.ts b/packages/perps-controller/tests/src/routing/ProviderRouter.test.ts new file mode 100644 index 0000000000..20e20ff7f5 --- /dev/null +++ b/packages/perps-controller/tests/src/routing/ProviderRouter.test.ts @@ -0,0 +1,180 @@ +/* eslint-disable */ +import { ProviderRouter } from '../../../src/routing/ProviderRouter'; + +describe('ProviderRouter', () => { + let router: ProviderRouter; + + beforeEach(() => { + router = new ProviderRouter({ defaultProvider: 'hyperliquid' }); + }); + + describe('constructor', () => { + it('sets default provider from options', () => { + const customRouter = new ProviderRouter({ defaultProvider: 'myx' }); + expect(customRouter.getDefaultProvider()).toBe('myx'); + }); + + it('sets default strategy to default_provider', () => { + expect(router.getStrategy()).toBe('default_provider'); + }); + + it('accepts custom strategy', () => { + const customRouter = new ProviderRouter({ + defaultProvider: 'hyperliquid', + strategy: 'default_provider', + }); + expect(customRouter.getStrategy()).toBe('default_provider'); + }); + }); + + describe('selectProvider', () => { + it('returns explicit providerId when provided', () => { + const result = router.selectProvider({ providerId: 'myx' }); + expect(result).toBe('myx'); + }); + + it('returns explicit providerId even when symbol is provided', () => { + router.updateProviderMarkets('hyperliquid', ['BTC', 'ETH']); + const result = router.selectProvider({ + symbol: 'BTC', + providerId: 'myx', + }); + expect(result).toBe('myx'); + }); + + it('returns default provider when no providerId is specified', () => { + const result = router.selectProvider({ symbol: 'BTC' }); + expect(result).toBe('hyperliquid'); + }); + + it('returns default provider when params are empty', () => { + const result = router.selectProvider({}); + expect(result).toBe('hyperliquid'); + }); + + it('uses specified provider when provided', () => { + const result = router.selectProvider({ providerId: 'myx' }); + expect(result).toBe('myx'); + }); + }); + + describe('getProvidersForMarket', () => { + beforeEach(() => { + router.updateProviderMarkets('hyperliquid', ['BTC', 'ETH', 'SOL']); + router.updateProviderMarkets('myx', ['BTC', 'ETH', 'ARB']); + }); + + it('returns all providers that support a market', () => { + const providers = router.getProvidersForMarket('BTC'); + expect(providers).toContain('hyperliquid'); + expect(providers).toContain('myx'); + expect(providers).toHaveLength(2); + }); + + it('returns single provider for exclusive market', () => { + const providers = router.getProvidersForMarket('SOL'); + expect(providers).toEqual(['hyperliquid']); + }); + + it('returns empty array for unknown market', () => { + const providers = router.getProvidersForMarket('UNKNOWN'); + expect(providers).toEqual([]); + }); + }); + + describe('updateProviderMarkets', () => { + it('adds markets for a provider', () => { + router.updateProviderMarkets('hyperliquid', ['BTC', 'ETH']); + + expect(router.providerSupportsMarket('hyperliquid', 'BTC')).toBe(true); + expect(router.providerSupportsMarket('hyperliquid', 'ETH')).toBe(true); + expect(router.providerSupportsMarket('hyperliquid', 'SOL')).toBe(false); + }); + + it('replaces existing markets when called again', () => { + router.updateProviderMarkets('hyperliquid', ['BTC', 'ETH']); + router.updateProviderMarkets('hyperliquid', ['SOL', 'ARB']); + + expect(router.providerSupportsMarket('hyperliquid', 'BTC')).toBe(false); + expect(router.providerSupportsMarket('hyperliquid', 'SOL')).toBe(true); + }); + + it('handles empty markets array', () => { + router.updateProviderMarkets('hyperliquid', []); + expect(router.providerSupportsMarket('hyperliquid', 'BTC')).toBe(false); + }); + }); + + describe('clearProviderMarkets', () => { + it('removes all markets for a provider', () => { + router.updateProviderMarkets('hyperliquid', ['BTC', 'ETH']); + router.clearProviderMarkets('hyperliquid'); + + expect(router.providerSupportsMarket('hyperliquid', 'BTC')).toBe(false); + expect(router.getProvidersForMarket('BTC')).toEqual([]); + }); + + it('does not affect other providers', () => { + router.updateProviderMarkets('hyperliquid', ['BTC']); + router.updateProviderMarkets('myx', ['BTC']); + router.clearProviderMarkets('hyperliquid'); + + expect(router.getProvidersForMarket('BTC')).toEqual(['myx']); + }); + }); + + describe('setDefaultProvider', () => { + it('updates the default provider', () => { + router.setDefaultProvider('myx'); + expect(router.getDefaultProvider()).toBe('myx'); + }); + + it('affects subsequent selectProvider calls', () => { + router.setDefaultProvider('myx'); + const result = router.selectProvider({ symbol: 'BTC' }); + expect(result).toBe('myx'); + }); + }); + + describe('providerSupportsMarket', () => { + it('returns true when provider supports market', () => { + router.updateProviderMarkets('hyperliquid', ['BTC', 'ETH']); + expect(router.providerSupportsMarket('hyperliquid', 'BTC')).toBe(true); + }); + + it('returns false when provider does not support market', () => { + router.updateProviderMarkets('hyperliquid', ['BTC', 'ETH']); + expect(router.providerSupportsMarket('hyperliquid', 'SOL')).toBe(false); + }); + + it('returns false for unknown provider', () => { + // @ts-expect-error Testing error handling with invalid provider type + expect(router.providerSupportsMarket('unknown', 'BTC')).toBe(false); + }); + }); + + describe('getRegisteredProviders', () => { + it('returns empty array when no providers registered', () => { + expect(router.getRegisteredProviders()).toEqual([]); + }); + + it('returns all providers with registered markets', () => { + router.updateProviderMarkets('hyperliquid', ['BTC']); + router.updateProviderMarkets('myx', ['ETH']); + + const providers = router.getRegisteredProviders(); + expect(providers).toContain('hyperliquid'); + expect(providers).toContain('myx'); + expect(providers).toHaveLength(2); + }); + + it('does not include cleared providers', () => { + router.updateProviderMarkets('hyperliquid', ['BTC']); + router.updateProviderMarkets('myx', ['ETH']); + router.clearProviderMarkets('hyperliquid'); + + const providers = router.getRegisteredProviders(); + expect(providers).toEqual(['myx']); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/selectors.test.ts b/packages/perps-controller/tests/src/selectors.test.ts new file mode 100644 index 0000000000..679556c845 --- /dev/null +++ b/packages/perps-controller/tests/src/selectors.test.ts @@ -0,0 +1,540 @@ +/* eslint-disable */ +import { MARKET_SORTING_CONFIG } from '../../src/constants/perpsConfig'; +import type { PerpsControllerState } from '../../src/PerpsController'; +import { + selectIsFirstTimeUser, + selectTradeConfiguration, + selectPendingTradeConfiguration, + selectWatchlistMarkets, + selectIsWatchlistMarket, + selectHasPlacedFirstOrder, + selectMarketFilterPreferences, + selectOrderBookGrouping, +} from '../../src/selectors'; + +describe('PerpsController selectors', () => { + describe('selectIsFirstTimeUser', () => { + it('returns true when state is undefined', () => { + expect(selectIsFirstTimeUser(undefined)).toBe(true); + }); + + it('returns true when isFirstTimeUser is true', () => { + const state = { + isFirstTimeUser: { testnet: true, mainnet: true }, + } as PerpsControllerState; + expect(selectIsFirstTimeUser(state)).toBe(true); + }); + + it('returns false when isFirstTimeUser is false', () => { + const state = { + isFirstTimeUser: { testnet: false, mainnet: false }, + } as PerpsControllerState; + expect(selectIsFirstTimeUser(state)).toBe(false); + }); + + it('returns true when isFirstTimeUser is undefined in state', () => { + const state = {} as PerpsControllerState; + expect(selectIsFirstTimeUser(state)).toBe(true); + }); + }); + + describe('selectTradeConfiguration', () => { + it('returns saved config for mainnet when not testnet', () => { + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: { + BTC: { leverage: 10 }, + }, + testnet: {}, + }, + } as unknown as PerpsControllerState; + + const result = selectTradeConfiguration(state, 'BTC'); + + expect(result).toEqual({ leverage: 10 }); + }); + + it('returns saved config for testnet when testnet', () => { + const state = { + isTestnet: true, + tradeConfigurations: { + mainnet: {}, + testnet: { + ETH: { leverage: 5 }, + }, + }, + } as unknown as PerpsControllerState; + + const result = selectTradeConfiguration(state, 'ETH'); + + expect(result).toEqual({ leverage: 5 }); + }); + + it('returns undefined when no config exists for asset', () => { + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: {}, + testnet: {}, + }, + } as PerpsControllerState; + + const result = selectTradeConfiguration(state, 'BTC'); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when config exists but has no leverage', () => { + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: { + BTC: {}, + }, + testnet: {}, + }, + } as unknown as PerpsControllerState; + + const result = selectTradeConfiguration(state, 'BTC'); + + expect(result).toBeUndefined(); + }); + + it('returns config for specific asset when multiple assets configured', () => { + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: { + BTC: { leverage: 10 }, + ETH: { leverage: 5 }, + SOL: { leverage: 3 }, + }, + testnet: {}, + }, + } as unknown as PerpsControllerState; + + const ethResult = selectTradeConfiguration(state, 'ETH'); + const btcResult = selectTradeConfiguration(state, 'BTC'); + + expect(ethResult).toEqual({ leverage: 5 }); + expect(btcResult).toEqual({ leverage: 10 }); + }); + }); + + describe('selectWatchlistMarkets', () => { + it('returns mainnet watchlist when not on testnet', () => { + const state = { + isTestnet: false, + watchlistMarkets: { + mainnet: ['BTC', 'ETH', 'SOL'], + testnet: ['DOGE'], + }, + } as unknown as PerpsControllerState; + + const result = selectWatchlistMarkets(state); + + expect(result).toEqual(['BTC', 'ETH', 'SOL']); + }); + + it('returns testnet watchlist when on testnet', () => { + const state = { + isTestnet: true, + watchlistMarkets: { + mainnet: ['BTC', 'ETH'], + testnet: ['DOGE', 'PEPE'], + }, + } as unknown as PerpsControllerState; + + const result = selectWatchlistMarkets(state); + + expect(result).toEqual(['DOGE', 'PEPE']); + }); + + it('returns empty array when watchlist is undefined', () => { + const state = { + isTestnet: false, + } as unknown as PerpsControllerState; + + const result = selectWatchlistMarkets(state); + + expect(result).toEqual([]); + }); + }); + + describe('selectIsWatchlistMarket', () => { + it('returns true when market is in watchlist', () => { + const state = { + isTestnet: false, + watchlistMarkets: { + mainnet: ['BTC', 'ETH', 'SOL'], + testnet: [], + }, + } as unknown as PerpsControllerState; + + const result = selectIsWatchlistMarket(state, 'ETH'); + + expect(result).toBe(true); + }); + + it('returns false when market is not in watchlist', () => { + const state = { + isTestnet: false, + watchlistMarkets: { + mainnet: ['BTC', 'ETH'], + testnet: [], + }, + } as unknown as PerpsControllerState; + + const result = selectIsWatchlistMarket(state, 'SOL'); + + expect(result).toBe(false); + }); + }); + + describe('selectHasPlacedFirstOrder', () => { + it('returns mainnet value when not on testnet', () => { + const state = { + isTestnet: false, + hasPlacedFirstOrder: { + mainnet: true, + testnet: false, + }, + } as unknown as PerpsControllerState; + + const result = selectHasPlacedFirstOrder(state); + + expect(result).toBe(true); + }); + + it('returns testnet value when on testnet', () => { + const state = { + isTestnet: true, + hasPlacedFirstOrder: { + mainnet: true, + testnet: false, + }, + } as unknown as PerpsControllerState; + + const result = selectHasPlacedFirstOrder(state); + + expect(result).toBe(false); + }); + + it('returns false when hasPlacedFirstOrder is undefined', () => { + const state = { + isTestnet: false, + } as unknown as PerpsControllerState; + + const result = selectHasPlacedFirstOrder(state); + + expect(result).toBe(false); + }); + }); + + describe('selectMarketFilterPreferences', () => { + it('returns saved filter preferences when defined', () => { + const state = { + marketFilterPreferences: { + optionId: 'priceChange', + direction: 'asc', + }, + } as unknown as PerpsControllerState; + + const result = selectMarketFilterPreferences(state); + + expect(result).toEqual({ + optionId: 'priceChange', + direction: 'asc', + }); + }); + + it('returns default preferences when preference is undefined', () => { + const state = {} as unknown as PerpsControllerState; + + const result = selectMarketFilterPreferences(state); + + expect(result).toEqual({ + optionId: MARKET_SORTING_CONFIG.DefaultSortOptionId, + direction: MARKET_SORTING_CONFIG.DefaultDirection, + }); + }); + + it('handles legacy string format (backward compatibility)', () => { + const state = { + marketFilterPreferences: 'priceChange', + } as unknown as PerpsControllerState; + + const result = selectMarketFilterPreferences(state); + + expect(result).toEqual({ + optionId: 'priceChange', + direction: MARKET_SORTING_CONFIG.DefaultDirection, + }); + }); + + it('handles legacy compound ID priceChange-desc (backward compatibility)', () => { + const state = { + marketFilterPreferences: 'priceChange-desc', + } as unknown as PerpsControllerState; + + const result = selectMarketFilterPreferences(state); + + expect(result).toEqual({ + optionId: 'priceChange', + direction: 'desc', + }); + }); + + it('handles legacy compound ID priceChange-asc (backward compatibility)', () => { + const state = { + marketFilterPreferences: 'priceChange-asc', + } as unknown as PerpsControllerState; + + const result = selectMarketFilterPreferences(state); + + expect(result).toEqual({ + optionId: 'priceChange', + direction: 'asc', + }); + }); + + it('handles other legacy simple strings (backward compatibility)', () => { + const state = { + marketFilterPreferences: 'volume', + } as unknown as PerpsControllerState; + + const result = selectMarketFilterPreferences(state); + + expect(result).toEqual({ + optionId: 'volume', + direction: MARKET_SORTING_CONFIG.DefaultDirection, + }); + }); + }); + + describe('selectPendingTradeConfiguration', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns pending config for mainnet when not testnet and not expired', () => { + const now = Date.now(); + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: { + BTC: { + leverage: 10, + pendingConfig: { + amount: '100', + leverage: 5, + takeProfitPrice: '50000', + stopLossPrice: '40000', + limitPrice: '45000', + orderType: 'limit', + timestamp: now, + }, + }, + }, + testnet: {}, + }, + } as unknown as PerpsControllerState; + + const result = selectPendingTradeConfiguration(state, 'BTC'); + + expect(result).toEqual({ + amount: '100', + leverage: 5, + takeProfitPrice: '50000', + stopLossPrice: '40000', + limitPrice: '45000', + orderType: 'limit', + }); + }); + + it('returns undefined for expired pending config (more than 5 minutes)', () => { + const fiveMinutesAgo = Date.now() - 6 * 60 * 1000; // 6 minutes ago + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: { + BTC: { + leverage: 10, + pendingConfig: { + amount: '100', + leverage: 5, + timestamp: fiveMinutesAgo, + }, + }, + }, + testnet: {}, + }, + } as unknown as PerpsControllerState; + + const result = selectPendingTradeConfiguration(state, 'BTC'); + + expect(result).toBeUndefined(); + }); + + it('returns pending config for valid config (less than 5 minutes)', () => { + const twoMinutesAgo = Date.now() - 2 * 60 * 1000; // 2 minutes ago + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: { + BTC: { + leverage: 10, + pendingConfig: { + amount: '100', + leverage: 5, + orderType: 'market', + timestamp: twoMinutesAgo, + }, + }, + }, + testnet: {}, + }, + } as unknown as PerpsControllerState; + + const result = selectPendingTradeConfiguration(state, 'BTC'); + + expect(result).toEqual({ + amount: '100', + leverage: 5, + orderType: 'market', + }); + }); + + it('returns undefined when no pending config exists', () => { + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: { + BTC: { + leverage: 10, + }, + }, + testnet: {}, + }, + } as unknown as PerpsControllerState; + + const result = selectPendingTradeConfiguration(state, 'BTC'); + + expect(result).toBeUndefined(); + }); + + it('returns pending config for testnet when on testnet', () => { + const now = Date.now(); + const state = { + isTestnet: true, + tradeConfigurations: { + mainnet: {}, + testnet: { + ETH: { + leverage: 5, + pendingConfig: { + amount: '200', + leverage: 10, + timestamp: now, + }, + }, + }, + }, + } as unknown as PerpsControllerState; + + const result = selectPendingTradeConfiguration(state, 'ETH'); + + expect(result).toEqual({ + amount: '200', + leverage: 10, + }); + }); + + it('returns undefined when config exists but has no pendingConfig', () => { + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: { + BTC: { + leverage: 10, + }, + }, + testnet: {}, + }, + } as unknown as PerpsControllerState; + + const result = selectPendingTradeConfiguration(state, 'BTC'); + + expect(result).toBeUndefined(); + }); + }); + + describe('selectOrderBookGrouping', () => { + it('returns mainnet order book grouping when not on testnet', () => { + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: { + BTC: { orderBookGrouping: 10 }, + }, + testnet: {}, + }, + } as unknown as PerpsControllerState; + + const result = selectOrderBookGrouping(state, 'BTC'); + + expect(result).toBe(10); + }); + + it('returns testnet order book grouping when on testnet', () => { + const state = { + isTestnet: true, + tradeConfigurations: { + mainnet: {}, + testnet: { + ETH: { orderBookGrouping: 0.01 }, + }, + }, + } as unknown as PerpsControllerState; + + const result = selectOrderBookGrouping(state, 'ETH'); + + expect(result).toBe(0.01); + }); + + it('returns undefined when no config exists for asset', () => { + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: {}, + testnet: {}, + }, + } as PerpsControllerState; + + const result = selectOrderBookGrouping(state, 'SOL'); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when orderBookGrouping is not set', () => { + const state = { + isTestnet: false, + tradeConfigurations: { + mainnet: { + BTC: { leverage: 10 }, + }, + testnet: {}, + }, + } as unknown as PerpsControllerState; + + const result = selectOrderBookGrouping(state, 'BTC'); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/AccountService.test.ts b/packages/perps-controller/tests/src/services/AccountService.test.ts new file mode 100644 index 0000000000..2f6bb9b750 --- /dev/null +++ b/packages/perps-controller/tests/src/services/AccountService.test.ts @@ -0,0 +1,664 @@ +import type { PerpsControllerState } from '../../../src/PerpsController'; +import { AccountService } from '../../../src/services/AccountService'; +import type { ServiceContext } from '../../../src/services/ServiceContext'; +import { PerpsAnalyticsEvent } from '../../../src/types'; +import type { + PerpsProvider, + WithdrawParams, + WithdrawResult, + PerpsPlatformDependencies, +} from '../../../src/types'; +/* eslint-disable */ +import { createMockHyperLiquidProvider } from '../../helpers/providerMocks'; +import { + createMockServiceContext, + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks'; + +jest.mock('uuid', () => ({ v4: () => 'mock-withdrawal-trace-id' })); +jest.mock('../../../src/constants/eventNames', () => ({ + PERPS_EVENT_PROPERTY: { + STATUS: 'status', + WITHDRAWAL_AMOUNT: 'withdrawal_amount', + COMPLETION_DURATION: 'completion_duration', + ERROR_MESSAGE: 'error_message', + }, + PERPS_EVENT_VALUE: { + STATUS: { + EXECUTED: 'executed', + FAILED: 'failed', + }, + }, +})); +jest.mock('../../../src/constants/hyperLiquidConfig', () => ({ + USDC_SYMBOL: 'USDC', +})); +jest.mock('../../../src/perpsErrorCodes', () => ({ + PERPS_ERROR_CODES: { + WITHDRAW_FAILED: 'WITHDRAW_FAILED', + }, +})); +// Note: EVM account is now retrieved via messenger.call('AccountTreeController:getAccountsFromSelectedAccountGroup') +// The mock is set up via createMockMessenger() in serviceMocks.ts + +describe('AccountService', () => { + let mockProvider: jest.Mocked; + let mockContext: ServiceContext; + let mockRefreshAccountState: jest.Mock; + let mockDeps: PerpsPlatformDependencies; + let mockMessenger: ReturnType; + let accountService: AccountService; + + const mockWithdrawParams: WithdrawParams = { + assetId: 'eip155:42161/erc20:0xTokenAddress/default', + amount: '100', + destination: '0xDestination', + }; + + beforeEach(() => { + mockProvider = + createMockHyperLiquidProvider() as unknown as jest.Mocked; + mockContext = createMockServiceContext({ + errorContext: { controller: 'AccountService', method: 'test' }, + }); + mockRefreshAccountState = jest.fn().mockResolvedValue(undefined); + + // Create mock dependencies and service instance + mockDeps = createMockInfrastructure(); + mockMessenger = createMockMessenger(); + accountService = new AccountService(mockDeps, mockMessenger); + + jest.clearAllMocks(); + + // Mock Date.now() to return a stable timestamp + jest.spyOn(Date, 'now').mockReturnValue(1234567890000); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('withdraw', () => { + it('executes successful withdrawal with tx hash', async () => { + const mockResult: WithdrawResult = { + success: true, + txHash: '0xTransactionHash', + withdrawalId: 'withdrawal-123', + }; + mockProvider.withdraw.mockResolvedValue(mockResult); + + const result = await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(result).toEqual(mockResult); + expect(mockProvider.withdraw).toHaveBeenCalledWith(mockWithdrawParams); + }); + + it('starts trace with correct parameters', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xHash', + }); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Perps Withdraw', + id: 'mock-withdrawal-trace-id', + tags: expect.objectContaining({ + assetId: mockWithdrawParams.assetId, + provider: 'hyperliquid', + isTestnet: 'false', + }), + }), + ); + }); + + it('ends trace on successful withdrawal', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xHash', + withdrawalId: 'withdrawal-123', + }); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(mockDeps.tracer.endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Perps Withdraw', + id: 'mock-withdrawal-trace-id', + data: expect.objectContaining({ + success: true, + txHash: '0xHash', + withdrawalId: 'withdrawal-123', + }), + }), + ); + }); + + it('sets withdrawal in progress state before provider call', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xHash', + }); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + }); + + it('calculates net amount after $1 USDC fee', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xHash', + }); + + await accountService.withdraw({ + provider: mockProvider, + params: { ...mockWithdrawParams, amount: '100' }, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + const updateCall = (mockContext.stateManager?.update as jest.Mock).mock + .calls[0][0]; + const mockState: Pick< + PerpsControllerState, + | 'withdrawInProgress' + | 'withdrawalRequests' + | 'lastError' + | 'lastUpdateTimestamp' + | 'lastWithdrawResult' + > = { + withdrawInProgress: false, + withdrawalRequests: [], + lastError: null, + lastUpdateTimestamp: 0, + lastWithdrawResult: null, + }; + updateCall(mockState); + + expect(mockState.withdrawalRequests[0].amount).toBe('99'); + }); + + it('creates withdrawal request with pending status', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xHash', + }); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + const updateCall = (mockContext.stateManager?.update as jest.Mock).mock + .calls[0][0]; + const mockState = { + withdrawInProgress: false, + withdrawalRequests: [], + lastError: null, + lastUpdateTimestamp: 0, + lastWithdrawResult: null, + }; + updateCall(mockState); + + expect(mockState.withdrawalRequests[0]).toEqual( + expect.objectContaining({ + status: 'pending', + success: false, + asset: 'USDC', + destination: mockWithdrawParams.destination, + }), + ); + }); + + it('removes withdrawal request from queue when provider returns tx hash', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xTransactionHash', + withdrawalId: 'withdrawal-123', + }); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + const updateCalls = (mockContext.stateManager?.update as jest.Mock).mock + .calls; + + // Run the first updater to discover the generated withdrawal ID + const setupUpdater = updateCalls[0][0]; + const setupState = { + withdrawInProgress: false, + withdrawalRequests: [] as { id: string; status: string }[], + }; + setupUpdater(setupState); + const generatedId = setupState.withdrawalRequests[0].id; + + // Build mock state with the real ID so the success updater can find it + const successUpdateCall = updateCalls[1][0]; + const mockState = { + withdrawInProgress: true, + withdrawalRequests: [{ id: generatedId, status: 'pending' }], + lastError: null, + lastUpdateTimestamp: 0, + lastWithdrawResult: null, + lastCompletedWithdrawalTimestamp: null, + lastCompletedWithdrawalTxHashes: [] as string[], + }; + + successUpdateCall(mockState); + + expect(mockState.withdrawInProgress).toBe(false); + expect(mockState.withdrawalRequests).toHaveLength(0); + expect(mockState.lastCompletedWithdrawalTimestamp).toBeNull(); + expect(mockState.lastCompletedWithdrawalTxHashes).toEqual([ + '0xTransactionHash', + ]); + expect(mockState.lastWithdrawResult).toEqual( + expect.objectContaining({ + success: true, + txHash: '0xTransactionHash', + }), + ); + }); + + it('updates state with bridging status when no tx hash', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + withdrawalId: 'withdrawal-123', + }); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + const updateCalls = (mockContext.stateManager?.update as jest.Mock).mock + .calls; + expect(updateCalls.length).toBeGreaterThan(1); + }); + + it('triggers account refresh after successful withdrawal', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xHash', + }); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(mockRefreshAccountState).toHaveBeenCalledTimes(1); + }); + + it('tracks analytics event on successful withdrawal', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xHash', + }); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.WithdrawalTransaction, + expect.objectContaining({ + status: 'executed', + }), + ); + }); + + it('handles withdrawal failure from provider', async () => { + const mockResult: WithdrawResult = { + success: false, + error: 'Insufficient balance', + }; + mockProvider.withdraw.mockResolvedValue(mockResult); + + const result = await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(result).toEqual(mockResult); + expect(result.success).toBe(false); + expect(result.error).toBe('Insufficient balance'); + }); + + it('removes withdrawal request from queue on provider failure', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: false, + error: 'Insufficient balance', + }); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + const updateCalls = (mockContext.stateManager?.update as jest.Mock).mock + .calls; + + const setupUpdater = updateCalls[0][0]; + const setupState = { + withdrawInProgress: false, + withdrawalRequests: [] as { id: string; status: string }[], + }; + setupUpdater(setupState); + const generatedId = setupState.withdrawalRequests[0].id; + + const failureUpdateCall = updateCalls[updateCalls.length - 1][0]; + const mockState: Pick< + PerpsControllerState, + | 'withdrawInProgress' + | 'withdrawalRequests' + | 'lastError' + | 'lastUpdateTimestamp' + | 'lastWithdrawResult' + > = { + withdrawInProgress: true, + withdrawalRequests: [ + { + id: generatedId, + status: 'pending', + success: false, + amount: '100', + asset: 'USDC', + accountAddress: expect.any(String) as string, + timestamp: Date.now(), + }, + ], + lastError: null, + lastUpdateTimestamp: 0, + lastWithdrawResult: null, + }; + + failureUpdateCall(mockState); + + expect(mockState.withdrawalRequests).toHaveLength(0); + expect(mockState.withdrawInProgress).toBe(false); + expect(mockState.lastError).toBe('Insufficient balance'); + expect(mockState.lastWithdrawResult?.success).toBe(false); + }); + + it('tracks analytics event on withdrawal failure', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: false, + error: 'Insufficient balance', + }); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.WithdrawalTransaction, + expect.objectContaining({ + status: 'failed', + }), + ); + }); + + it('does not trigger account refresh on failure', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: false, + error: 'Insufficient balance', + }); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(mockRefreshAccountState).not.toHaveBeenCalled(); + }); + + it('handles exception during withdrawal', async () => { + const error = new Error('Network error'); + mockProvider.withdraw.mockRejectedValue(error); + + const result = await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Network error'); + }); + + it('logs error on exception', async () => { + const error = new Error('Network error'); + mockProvider.withdraw.mockRejectedValue(error); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + + it('updates state with error on exception', async () => { + mockProvider.withdraw.mockRejectedValue(new Error('Network error')); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + const updateCalls = (mockContext.stateManager?.update as jest.Mock).mock + .calls; + + const setupUpdater = updateCalls[0][0]; + const setupState = { + withdrawInProgress: false, + withdrawalRequests: [] as { id: string; status: string }[], + }; + setupUpdater(setupState); + const generatedId = setupState.withdrawalRequests[0].id; + + const errorUpdateCall = updateCalls[updateCalls.length - 1][0]; + const mockState = { + withdrawInProgress: true, + withdrawalRequests: [ + { id: generatedId, status: 'pending', success: false }, + ], + lastError: null, + lastUpdateTimestamp: 0, + lastWithdrawResult: null, + }; + + errorUpdateCall(mockState); + + expect(mockState.lastError).toBe('Network error'); + expect(mockState.withdrawalRequests).toHaveLength(0); + expect(mockState.withdrawInProgress).toBe(false); + }); + + it('ends trace with error data on exception', async () => { + mockProvider.withdraw.mockRejectedValue(new Error('Network error')); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(mockDeps.tracer.endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Perps Withdraw', + id: 'mock-withdrawal-trace-id', + data: expect.objectContaining({ + success: false, + error: 'Network error', + }), + }), + ); + }); + + it('handles refresh account state error gracefully', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xHash', + }); + mockRefreshAccountState.mockRejectedValue(new Error('Refresh failed')); + + const result = await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + expect(result.success).toBe(true); + }); + + it('generates unique withdrawal ID for tracking', async () => { + mockProvider.withdraw.mockResolvedValue({ + success: true, + txHash: '0xHash', + }); + + await accountService.withdraw({ + provider: mockProvider, + params: mockWithdrawParams, + context: mockContext, + refreshAccountState: mockRefreshAccountState, + }); + + const updateCall = (mockContext.stateManager?.update as jest.Mock).mock + .calls[0][0]; + const mockState: Pick< + PerpsControllerState, + | 'withdrawInProgress' + | 'withdrawalRequests' + | 'lastError' + | 'lastUpdateTimestamp' + | 'lastWithdrawResult' + > = { + withdrawInProgress: false, + withdrawalRequests: [], + lastError: null, + lastUpdateTimestamp: 0, + lastWithdrawResult: null, + }; + updateCall(mockState); + + expect(mockState.withdrawalRequests[0].id).toMatch( + /^withdraw-\d+-[a-z0-9]+$/, + ); + }); + }); + + describe('validateWithdrawal', () => { + it('delegates to provider validateWithdrawal', async () => { + const mockValidation = { isValid: true }; + mockProvider.validateWithdrawal.mockResolvedValue(mockValidation); + + const result = await accountService.validateWithdrawal({ + provider: mockProvider, + params: mockWithdrawParams, + }); + + expect(result).toEqual(mockValidation); + expect(mockProvider.validateWithdrawal).toHaveBeenCalledWith( + mockWithdrawParams, + ); + }); + + it('returns invalid when provider validation fails', async () => { + const mockValidation = { + isValid: false, + error: 'Amount exceeds balance', + }; + mockProvider.validateWithdrawal.mockResolvedValue(mockValidation); + + const result = await accountService.validateWithdrawal({ + provider: mockProvider, + params: mockWithdrawParams, + }); + + expect(result.isValid).toBe(false); + expect(result.error).toBe('Amount exceeds balance'); + }); + + it('throws error on exception', async () => { + const error = new Error('Validation error'); + mockProvider.validateWithdrawal.mockRejectedValue(error); + + await expect( + accountService.validateWithdrawal({ + provider: mockProvider, + params: mockWithdrawParams, + }), + ).rejects.toThrow('Validation error'); + }); + + it('logs error on exception', async () => { + const error = new Error('Validation error'); + mockProvider.validateWithdrawal.mockRejectedValue(error); + + await expect( + accountService.validateWithdrawal({ + provider: mockProvider, + params: mockWithdrawParams, + }), + ).rejects.toThrow(); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/DataLakeService.test.ts b/packages/perps-controller/tests/src/services/DataLakeService.test.ts new file mode 100644 index 0000000000..f51f0596a9 --- /dev/null +++ b/packages/perps-controller/tests/src/services/DataLakeService.test.ts @@ -0,0 +1,503 @@ +import { DataLakeService } from '../../../src/services/DataLakeService'; +import type { ServiceContext } from '../../../src/services/ServiceContext'; +import type { PerpsPlatformDependencies } from '../../../src/types'; +/* eslint-disable */ +import { + createMockServiceContext, + createMockEvmAccount, + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks'; + +jest.mock('uuid', () => ({ v4: () => 'mock-trace-id' })); + +global.fetch = jest.fn(); +global.setTimeout = jest.fn((fn: () => void) => { + fn(); + return 0 as unknown as NodeJS.Timeout; +}) as unknown as typeof setTimeout; + +describe('DataLakeService', () => { + let mockContext: ServiceContext; + let mockDeps: jest.Mocked; + let mockMessenger: ReturnType; + let dataLakeService: DataLakeService; + const mockEvmAccount = createMockEvmAccount(); + const mockToken = 'mock-bearer-token'; + + /** + * Sets up the default messenger mock that returns a valid account and token. + * Called in beforeEach and after any mid-test jest.clearAllMocks(). + */ + function setupDefaultMessenger() { + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [mockEvmAccount]; + } + if (action === 'AuthenticationController:getBearerToken') { + return Promise.resolve(mockToken); + } + return undefined; + }); + } + + beforeEach(() => { + jest.clearAllMocks(); + + mockDeps = createMockInfrastructure(); + mockMessenger = createMockMessenger(); + dataLakeService = new DataLakeService(mockDeps, mockMessenger); + + mockContext = createMockServiceContext({ + errorContext: { controller: 'DataLakeService', method: 'test' }, + tracingContext: { + provider: 'hyperliquid', + isTestnet: false, + }, + }); + + setupDefaultMessenger(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('reportOrder', () => { + it('skips reporting for testnet', async () => { + const result = await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + isTestnet: true, + context: mockContext, + }); + + expect(result).toEqual({ success: true, error: 'Skipped for testnet' }); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'DataLake API: Skipping for testnet', + expect.objectContaining({ network: 'testnet' }), + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('reports order successfully on first attempt', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 201, + text: jest.fn().mockResolvedValue(''), + }); + + const result = await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + slPrice: 45000, + tpPrice: 55000, + isTestnet: false, + context: mockContext, + }); + + expect(result).toEqual({ success: true }); + expect(mockMessenger.call).toHaveBeenCalledWith( + 'AuthenticationController:getBearerToken', + ); + expect(fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${mockToken}`, + }), + body: JSON.stringify({ + user_id: mockEvmAccount.address, + symbol: 'BTC', + sl_price: 45000, + tp_price: 55000, + }), + }), + ); + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Perps Data Lake Report', + tags: expect.objectContaining({ action: 'open', symbol: 'BTC' }), + }), + ); + expect(mockDeps.tracer.endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ success: true, retries: 0 }), + }), + ); + }); + + it('includes performance measurement on success', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 201, + text: jest.fn().mockResolvedValue(''), + }); + + await dataLakeService.reportOrder({ + action: 'close', + symbol: 'ETH', + isTestnet: false, + context: mockContext, + }); + + expect(mockDeps.tracer.setMeasurement).toHaveBeenCalledWith( + 'perps.api.data_lake_call', + expect.any(Number), + 'millisecond', + ); + }); + + it('returns error when account is missing', async () => { + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return []; + } + if (action === 'AuthenticationController:getBearerToken') { + return Promise.resolve(mockToken); + } + return undefined; + }); + + const result = await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + isTestnet: false, + context: mockContext, + }); + + expect(result).toEqual({ + success: false, + error: 'No account or token available', + }); + expect(fetch).not.toHaveBeenCalled(); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'DataLake API: Missing requirements', + expect.objectContaining({ hasAccount: false }), + ); + }); + + it('returns error when token is missing', async () => { + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [mockEvmAccount]; + } + if (action === 'AuthenticationController:getBearerToken') { + return Promise.resolve(null); + } + return undefined; + }); + + const result = await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + isTestnet: false, + context: mockContext, + }); + + expect(result).toEqual({ + success: false, + error: 'No account or token available', + }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('retries on network error with exponential backoff', async () => { + (fetch as jest.Mock) + .mockRejectedValueOnce(new Error('Network error')) + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValueOnce({ + ok: true, + status: 201, + text: jest.fn().mockResolvedValue(''), + }); + + const result = await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + isTestnet: false, + context: mockContext, + }); + + expect(result).toEqual({ success: false, error: 'Network error' }); + expect(mockDeps.logger.error).toHaveBeenCalled(); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'DataLake API: Scheduling retry', + expect.objectContaining({ nextAttempt: 2 }), + ); + expect(setTimeout).toHaveBeenCalled(); + }); + + it('retries up to 3 times then gives up', async () => { + (fetch as jest.Mock).mockRejectedValue(new Error('Persistent error')); + + const result = await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + isTestnet: false, + context: mockContext, + retryCount: 3, + }); + + expect(result).toEqual({ + success: false, + error: 'Persistent error', + }); + expect(mockDeps.logger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + context: expect.objectContaining({ + data: expect.objectContaining({ + operation: 'finalFailure', + retryCount: 3, + }), + }), + }), + ); + expect(mockDeps.tracer.endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + success: false, + totalRetries: 3, + }), + }), + ); + }); + + it('calculates exponential backoff delays correctly', async () => { + (fetch as jest.Mock).mockRejectedValue(new Error('Network error')); + + await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + isTestnet: false, + context: mockContext, + retryCount: 0, + }); + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 1000); + + jest.clearAllMocks(); + setupDefaultMessenger(); + (fetch as jest.Mock).mockRejectedValue(new Error('Network error')); + + await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + isTestnet: false, + context: mockContext, + retryCount: 1, + }); + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 2000); + + jest.clearAllMocks(); + setupDefaultMessenger(); + (fetch as jest.Mock).mockRejectedValue(new Error('Network error')); + + await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + isTestnet: false, + context: mockContext, + retryCount: 2, + }); + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 4000); + }); + + it('handles API error responses', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 500, + text: jest.fn().mockResolvedValue('Internal Server Error'), + }); + + const result = await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + isTestnet: false, + context: mockContext, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('DataLake API error: 500'); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + + it('handles API 4xx error responses', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 400, + text: jest.fn().mockResolvedValue('Bad Request'), + }); + + const result = await dataLakeService.reportOrder({ + action: 'open', + symbol: 'INVALID', + isTestnet: false, + context: mockContext, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('DataLake API error: 400'); + }); + + it('logs all retry attempts correctly', async () => { + (fetch as jest.Mock).mockRejectedValue(new Error('Network error')); + + await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + isTestnet: false, + context: mockContext, + }); + + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'DataLake API: Starting order report', + expect.objectContaining({ attempt: 1, maxAttempts: 4 }), + ); + }); + + it('uses custom trace ID when provided', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 201, + text: jest.fn().mockResolvedValue(''), + }); + + await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + isTestnet: false, + context: mockContext, + _traceId: 'custom-trace-id', + }); + + expect(mockDeps.tracer.endTrace).toHaveBeenCalledWith( + expect.objectContaining({ id: 'custom-trace-id' }), + ); + }); + + it('reports close action with TP/SL prices', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 201, + text: jest.fn().mockResolvedValue(''), + }); + + await dataLakeService.reportOrder({ + action: 'close', + symbol: 'BTC', + slPrice: 45000, + tpPrice: 55000, + isTestnet: false, + context: mockContext, + }); + + expect(fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: JSON.stringify({ + user_id: mockEvmAccount.address, + symbol: 'BTC', + sl_price: 45000, + tp_price: 55000, + }), + }), + ); + }); + + it('reports order without TP/SL prices when not provided', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 201, + text: jest.fn().mockResolvedValue(''), + }); + + await dataLakeService.reportOrder({ + action: 'open', + symbol: 'ETH', + isTestnet: false, + context: mockContext, + }); + + expect(fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: JSON.stringify({ + user_id: mockEvmAccount.address, + symbol: 'ETH', + sl_price: undefined, + tp_price: undefined, + }), + }), + ); + }); + + it('handles response with body text', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 201, + text: jest.fn().mockResolvedValue('{"orderId": "123"}'), + }); + + const result = await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + isTestnet: false, + context: mockContext, + }); + + expect(result).toEqual({ success: true }); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'DataLake API: Order reported successfully', + expect.objectContaining({ responseBody: '{"orderId": "123"}' }), + ); + }); + + it('handles empty response body', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 201, + text: jest.fn().mockResolvedValue(''), + }); + + const result = await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + isTestnet: false, + context: mockContext, + }); + + expect(result).toEqual({ success: true }); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'DataLake API: Order reported successfully', + expect.objectContaining({ responseBody: 'empty' }), + ); + }); + + it('only starts trace on first attempt', async () => { + (fetch as jest.Mock).mockResolvedValue({ + ok: true, + status: 201, + text: jest.fn().mockResolvedValue(''), + }); + + await dataLakeService.reportOrder({ + action: 'open', + symbol: 'BTC', + isTestnet: false, + context: mockContext, + retryCount: 2, + }); + + expect(mockDeps.tracer.trace).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/DepositService.test.ts b/packages/perps-controller/tests/src/services/DepositService.test.ts new file mode 100644 index 0000000000..4eddca74d4 --- /dev/null +++ b/packages/perps-controller/tests/src/services/DepositService.test.ts @@ -0,0 +1,350 @@ +/* eslint-disable */ +import { toHex } from '@metamask/controller-utils'; +import { parseCaipAssetId } from '@metamask/utils'; + +import { DepositService } from '../../../src/services/DepositService'; +import type { + PerpsProvider, + PerpsPlatformDependencies, +} from '../../../src/types'; +import { generateDepositId } from '../../../src/utils/idUtils'; +import { generateERC20TransferData } from '../../../src/utils/transferData'; +import { createMockHyperLiquidProvider } from '../../helpers/providerMocks'; +import { + createMockEvmAccount, + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks'; + +jest.mock('../../../src/utils/idUtils'); +jest.mock('@metamask/utils'); +// Mock generateERC20TransferData from portable transferData util +jest.mock('../../../src/utils/transferData'); +jest.mock('@metamask/controller-utils', () => { + const actual = jest.requireActual('@metamask/controller-utils'); + return { + ...actual, + toHex: jest.fn((value: string | number) => { + if (typeof value === 'number') { + return `0x${value.toString(16)}`; + } + if (typeof value === 'string' && !value.startsWith('0x')) { + return `0x${parseInt(value, 10).toString(16)}`; + } + return value; + }), + }; +}); + +describe('DepositService', () => { + let mockProvider: jest.Mocked; + let mockDeps: jest.Mocked; + let mockMessenger: ReturnType; + let service: DepositService; + const mockEvmAccount = createMockEvmAccount(); + const mockDepositId = 'deposit-123'; + const mockBridgeAddress = '0xBridgeContract'; + const mockTokenAddress = '0xTokenAddress'; + const mockAssetId = 'eip155:42161/erc20:0xTokenAddress/default'; + + beforeEach(() => { + mockProvider = + createMockHyperLiquidProvider() as unknown as jest.Mocked; + + mockDeps = createMockInfrastructure(); + mockMessenger = createMockMessenger(); + service = new DepositService(mockDeps, mockMessenger); + + mockProvider.getDepositRoutes.mockReturnValue([ + { + assetId: mockAssetId, + contractAddress: mockBridgeAddress, + chainId: 'eip155:42161', + }, + ]); + + // Setup mock EVM account via messenger + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [mockEvmAccount]; + } + return undefined; + }); + (generateDepositId as jest.Mock).mockReturnValue(mockDepositId); + // Mock generateERC20TransferData to return a valid ERC-20 transfer data + (generateERC20TransferData as jest.Mock).mockReturnValue( + '0xa9059cbb000000000000000000000000', + ); + (parseCaipAssetId as jest.Mock).mockReturnValue({ + chainId: 'eip155:42161', + assetReference: mockTokenAddress, + }); + (toHex as jest.Mock).mockImplementation((value: string | number) => { + if (typeof value === 'number') { + return `0x${value.toString(16)}`; + } + if (typeof value === 'string' && !value.startsWith('0x')) { + return `0x${parseInt(value, 10).toString(16)}`; + } + return value; + }); + + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('prepareTransaction', () => { + it('successfully prepares deposit transaction with all fields', async () => { + const result = await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(result).toEqual({ + transaction: { + from: mockEvmAccount.address, + to: mockTokenAddress, + value: '0x0', + data: expect.stringMatching(/^0xa9059cbb/), // ERC-20 transfer function signature + gas: '0x186a0', + }, + assetChainId: '0xa4b1', + currentDepositId: mockDepositId, + }); + }); + + it('generates unique deposit ID for tracking', async () => { + await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(generateDepositId).toHaveBeenCalledTimes(1); + }); + + it('retrieves deposit routes from provider', async () => { + await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(mockProvider.getDepositRoutes).toHaveBeenCalledWith({ + isTestnet: false, + }); + }); + + it('uses first deposit route from provider', async () => { + mockProvider.getDepositRoutes.mockReturnValue([ + { + assetId: mockAssetId, + contractAddress: mockBridgeAddress, + chainId: 'eip155:42161', + }, + { + assetId: 'eip155:1/erc20:0xOtherToken/default', + contractAddress: '0xOtherBridge', + chainId: 'eip155:1', + }, + ]); + + const result = await service.prepareTransaction({ + provider: mockProvider, + }); + + // Verify transfer data is generated with ERC-20 transfer function signature + expect(result.transaction.data).toMatch(/^0xa9059cbb/); + }); + + it('generates transfer data for ERC-20 token transfer', async () => { + const result = await service.prepareTransaction({ + provider: mockProvider, + }); + + // Verify ERC-20 transfer function signature (0xa9059cbb) is at the start + expect(result.transaction.data).toMatch(/^0xa9059cbb/); + }); + + it('retrieves EVM account from messenger via accountTree action', async () => { + await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ); + }); + + it('throws error when no EVM account is found', async () => { + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return []; + } + return undefined; + }); + + await expect( + service.prepareTransaction({ + provider: mockProvider, + }), + ).rejects.toThrow( + 'No EVM-compatible account found in selected account group', + ); + + expect(parseCaipAssetId).not.toHaveBeenCalled(); + }); + + it('parses CAIP asset ID to extract chain and token', async () => { + await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(parseCaipAssetId).toHaveBeenCalledWith(mockAssetId); + }); + + it('converts chain ID to hex format', async () => { + await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(toHex).toHaveBeenCalledWith('42161'); + }); + + it('sets fixed gas limit for deposit transaction', async () => { + const result = await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(result.transaction.gas).toBe('0x186a0'); + }); + + it('sets transaction value to 0x0', async () => { + const result = await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(result.transaction.value).toBe('0x0'); + }); + + it('uses token address as transaction recipient', async () => { + const result = await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(result.transaction.to).toBe(mockTokenAddress); + }); + + it('uses account address as transaction sender', async () => { + const result = await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(result.transaction.from).toBe(mockEvmAccount.address); + }); + + it('includes generated transfer data in transaction', async () => { + const result = await service.prepareTransaction({ + provider: mockProvider, + }); + + // Verify transfer data starts with ERC-20 transfer function signature + expect(result.transaction.data).toMatch(/^0xa9059cbb/); + }); + + it('returns asset chain ID in hex format', async () => { + const result = await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(result.assetChainId).toBe('0xa4b1'); + }); + + it('returns current deposit ID for tracking', async () => { + const result = await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(result.currentDepositId).toBe(mockDepositId); + }); + + it('handles different chain IDs correctly', async () => { + (parseCaipAssetId as jest.Mock).mockReturnValue({ + chainId: 'eip155:1', + assetReference: mockTokenAddress, + }); + + await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(toHex).toHaveBeenCalledWith('1'); + }); + + it('handles different token addresses correctly', async () => { + const differentTokenAddress = '0xDifferentToken'; + (parseCaipAssetId as jest.Mock).mockReturnValue({ + chainId: 'eip155:42161', + assetReference: differentTokenAddress, + }); + + const result = await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(result.transaction.to).toBe(differentTokenAddress); + }); + + it('prepares transaction for different bridge contracts', async () => { + const differentBridgeAddress = '0xDifferentBridge'; + mockProvider.getDepositRoutes.mockReturnValue([ + { + assetId: mockAssetId, + contractAddress: differentBridgeAddress, + chainId: 'eip155:42161', + }, + ]); + + const result = await service.prepareTransaction({ + provider: mockProvider, + }); + + // Verify transfer data is generated with ERC-20 transfer function signature + expect(result.transaction.data).toMatch(/^0xa9059cbb/); + }); + + it('logs debug messages during transaction preparation', async () => { + await service.prepareTransaction({ + provider: mockProvider, + }); + + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'DepositService: Preparing deposit transaction', + ); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'DepositService: Deposit transaction prepared', + expect.objectContaining({ + depositId: mockDepositId, + assetChainId: '0xa4b1', + }), + ); + }); + }); + + describe('instance isolation', () => { + it('each instance uses its own deps', async () => { + const mockDeps2 = createMockInfrastructure(); + const mockMessenger2 = createMockMessenger(); + const service2 = new DepositService(mockDeps2, mockMessenger2); + + await service.prepareTransaction({ provider: mockProvider }); + await service2.prepareTransaction({ provider: mockProvider }); + + // Each instance should use its own logger + expect(mockDeps.debugLogger.log).toHaveBeenCalledTimes(2); + expect(mockDeps2.debugLogger.log).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/EligibilityService.test.ts b/packages/perps-controller/tests/src/services/EligibilityService.test.ts new file mode 100644 index 0000000000..ab162e1b22 --- /dev/null +++ b/packages/perps-controller/tests/src/services/EligibilityService.test.ts @@ -0,0 +1,103 @@ +import { EligibilityService } from '../../../src/services/EligibilityService'; +import type { PerpsPlatformDependencies } from '../../../src/types'; +import { createMockInfrastructure } from '../../helpers/serviceMocks'; + +describe('EligibilityService', () => { + let mockDeps: jest.Mocked; + let service: EligibilityService; + + beforeEach(() => { + jest.clearAllMocks(); + mockDeps = createMockInfrastructure(); + service = new EligibilityService(mockDeps); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('checkEligibility', () => { + it('returns true when user is not in blocked regions', async () => { + const result = await service.checkEligibility({ + blockedRegions: ['US', 'CN'], + geoLocation: 'FR', + }); + + expect(result).toBe(true); + }); + + it('returns false when user is in blocked region', async () => { + const result = await service.checkEligibility({ + blockedRegions: ['US', 'CN'], + geoLocation: 'US', + }); + + expect(result).toBe(false); + }); + + it('returns false when user is in any blocked region from list', async () => { + const result = await service.checkEligibility({ + blockedRegions: ['US', 'CN', 'KP', 'IR'], + geoLocation: 'CN', + }); + + expect(result).toBe(false); + }); + + it('returns true when blocked regions list is empty', async () => { + const result = await service.checkEligibility({ + blockedRegions: [], + geoLocation: 'US', + }); + + expect(result).toBe(true); + }); + + it('returns true when location is UNKNOWN (defaults to eligible)', async () => { + const result = await service.checkEligibility({ + blockedRegions: ['US', 'CN'], + geoLocation: 'UNKNOWN', + }); + + expect(result).toBe(true); + }); + + it('handles partial region codes (e.g., US-NY)', async () => { + const result = await service.checkEligibility({ + blockedRegions: ['US'], + geoLocation: 'US-NY', + }); + + expect(result).toBe(false); + }); + + it('performs case-insensitive region matching', async () => { + const result = await service.checkEligibility({ + blockedRegions: ['US'], + geoLocation: 'us', + }); + + expect(result).toBe(false); + }); + + it('returns true on error (fail-safe)', async () => { + const brokenDeps = { + ...mockDeps, + debugLogger: { + log: () => { + throw new Error('Logging failure'); + }, + }, + } as unknown as jest.Mocked; + + const brokenService = new EligibilityService(brokenDeps); + + const result = await brokenService.checkEligibility({ + blockedRegions: ['US', 'CN'], + geoLocation: 'US', + }); + + expect(result).toBe(true); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/FeatureFlagConfigurationService.test.ts b/packages/perps-controller/tests/src/services/FeatureFlagConfigurationService.test.ts new file mode 100644 index 0000000000..f620e31425 --- /dev/null +++ b/packages/perps-controller/tests/src/services/FeatureFlagConfigurationService.test.ts @@ -0,0 +1,698 @@ +/* eslint-disable */ +import type { RemoteFeatureFlagControllerState } from '@metamask/remote-feature-flag-controller'; + +import { FeatureFlagConfigurationService } from '../../../src/services/FeatureFlagConfigurationService'; +import type { ServiceContext } from '../../../src/services/ServiceContext'; +import type { PerpsPlatformDependencies } from '../../../src/types'; +import { validateMarketPattern } from '../../../src/utils/marketUtils'; +import { + parseCommaSeparatedString, + stripQuotes, +} from '../../../src/utils/stringParseUtils'; +import { + createMockServiceContext, + createMockInfrastructure, +} from '../../helpers/serviceMocks'; + +jest.mock('../../../src/utils/stringParseUtils'); +jest.mock('../../../src/utils/marketUtils', () => ({ + ...jest.requireActual('../../../src/utils/marketUtils'), + validateMarketPattern: jest.fn(), +})); + +describe('FeatureFlagConfigurationService', () => { + let mockContext: ServiceContext; + let mockDeps: jest.Mocked; + let featureFlagConfigurationService: FeatureFlagConfigurationService; + let mockRemoteFeatureFlagState: RemoteFeatureFlagControllerState; + let mockCurrentHip3Config: { + enabled: boolean; + allowlistMarkets: string[]; + blocklistMarkets: string[]; + source: 'remote' | 'fallback'; + }; + let mockCurrentBlockedRegionList: { + list: string[]; + source: 'remote' | 'fallback'; + }; + + beforeEach(() => { + mockDeps = createMockInfrastructure(); + featureFlagConfigurationService = new FeatureFlagConfigurationService( + mockDeps, + ); + + mockCurrentHip3Config = { + enabled: false, + allowlistMarkets: [], + blocklistMarkets: [], + source: 'fallback', + }; + + mockCurrentBlockedRegionList = { + list: [], + source: 'fallback', + }; + + mockContext = createMockServiceContext({ + errorContext: { + controller: 'FeatureFlagConfigurationService', + method: 'test', + }, + getHip3Config: jest.fn(() => mockCurrentHip3Config), + setHip3Config: jest.fn((config) => { + Object.assign(mockCurrentHip3Config, config); + }), + incrementHip3ConfigVersion: jest.fn(() => 1), + getBlockedRegionList: jest.fn(() => mockCurrentBlockedRegionList), + setBlockedRegionList: jest.fn((list, source) => { + mockCurrentBlockedRegionList = { list, source }; + }), + refreshEligibility: jest.fn().mockResolvedValue(undefined), + }); + + mockRemoteFeatureFlagState = { + remoteFeatureFlags: {}, + cacheTimestamp: Date.now(), + }; + + (parseCommaSeparatedString as jest.Mock).mockImplementation((str: string) => + str + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0), + ); + + // stripQuotes is called after parseCommaSeparatedString - mock it to pass through values + (stripQuotes as jest.Mock).mockImplementation((s: string) => s); + + // validateMarketPattern passes by default + (validateMarketPattern as jest.Mock).mockImplementation(() => true); + + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('refreshHip3Config', () => { + it('throws error when required callbacks are missing', () => { + const contextWithoutCallbacks = createMockServiceContext({ + getHip3Config: undefined, + }); + + expect(() => { + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: contextWithoutCallbacks, + }); + }).toThrow('Required HIP-3 callbacks not available in ServiceContext'); + }); + + it('updates config when equity flag changes', () => { + // isVersionGatedFeatureFlag is now a real function from types - provide valid flag data + (mockDeps.featureFlags.validateVersionGated as jest.Mock).mockReturnValue( + true, + ); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3Enabled: { enabled: true, minimumVersion: '1.0.0' }, + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setHip3Config).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + source: 'remote', + }), + ); + }); + + it('increments version when equity flag changes', () => { + // isVersionGatedFeatureFlag is now a real function from types - provide valid flag data + (mockDeps.featureFlags.validateVersionGated as jest.Mock).mockReturnValue( + true, + ); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3Enabled: { enabled: true, minimumVersion: '1.0.0' }, + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.incrementHip3ConfigVersion).toHaveBeenCalledTimes(1); + }); + + it('parses allowlist markets from comma-separated string', () => { + // No equity flag in this test (isVersionGatedFeatureFlag returns false for non-flag objects) + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3AllowlistMarkets: 'BTC,ETH,SOL', + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(parseCommaSeparatedString).toHaveBeenCalledWith('BTC,ETH,SOL'); + expect(mockContext.setHip3Config).toHaveBeenCalledWith( + expect.objectContaining({ + allowlistMarkets: ['BTC', 'ETH', 'SOL'], + }), + ); + }); + + it('parses allowlist markets from array', () => { + // No equity flag in this test (isVersionGatedFeatureFlag returns false for non-flag objects) + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3AllowlistMarkets: ['BTC', 'ETH', 'SOL'], + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setHip3Config).toHaveBeenCalledWith( + expect.objectContaining({ + allowlistMarkets: ['BTC', 'ETH', 'SOL'], + }), + ); + }); + + it('trims and filters empty allowlist markets from array', () => { + // No equity flag in this test (isVersionGatedFeatureFlag returns false for non-flag objects) + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3AllowlistMarkets: ['BTC ', ' ETH', ' ', 'SOL'], + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setHip3Config).toHaveBeenCalledWith( + expect.objectContaining({ + allowlistMarkets: ['BTC', 'ETH', 'SOL'], + }), + ); + }); + + it('strips quotes from array values', () => { + // No equity flag in this test (isVersionGatedFeatureFlag returns false for non-flag objects) + // Mock stripQuotes to actually strip quotes for this test + (stripQuotes as jest.Mock).mockImplementation((s: string) => + s.replace(/^["']|["']$/g, ''), + ); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3AllowlistMarkets: ['"BTC"', '"ETH"', "'SOL'"], + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(stripQuotes).toHaveBeenCalledWith('"BTC"'); + expect(stripQuotes).toHaveBeenCalledWith('"ETH"'); + expect(stripQuotes).toHaveBeenCalledWith("'SOL'"); + expect(mockContext.setHip3Config).toHaveBeenCalledWith( + expect.objectContaining({ + allowlistMarkets: ['BTC', 'ETH', 'SOL'], + }), + ); + }); + + it('skips invalid allowlist markets format', () => { + // No equity flag in this test (isVersionGatedFeatureFlag returns false for non-flag objects) + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3AllowlistMarkets: 123, + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setHip3Config).not.toHaveBeenCalled(); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('validation FAILED'), + expect.anything(), + ); + }); + + it('parses blocklist markets from comma-separated string', () => { + // No equity flag in this test (isVersionGatedFeatureFlag returns false for non-flag objects) + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3BlocklistMarkets: 'MEME,DOGE', + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(parseCommaSeparatedString).toHaveBeenCalledWith('MEME,DOGE'); + expect(mockContext.setHip3Config).toHaveBeenCalledWith( + expect.objectContaining({ + blocklistMarkets: ['MEME', 'DOGE'], + }), + ); + }); + + it('parses blocklist markets from array', () => { + // No equity flag in this test (isVersionGatedFeatureFlag returns false for non-flag objects) + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3BlocklistMarkets: ['MEME', 'DOGE'], + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setHip3Config).toHaveBeenCalledWith( + expect.objectContaining({ + blocklistMarkets: ['MEME', 'DOGE'], + }), + ); + }); + + it('detects no change when config is identical', () => { + mockCurrentHip3Config.enabled = true; + mockCurrentHip3Config.allowlistMarkets = ['BTC', 'ETH']; + mockCurrentHip3Config.blocklistMarkets = ['MEME']; + + // isVersionGatedFeatureFlag is now a real function from types - provide valid flag data + (mockDeps.featureFlags.validateVersionGated as jest.Mock).mockReturnValue( + true, + ); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3Enabled: { enabled: true, minimumVersion: '1.0.0' }, + perpsHip3AllowlistMarkets: ['BTC', 'ETH'], + perpsHip3BlocklistMarkets: ['MEME'], + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setHip3Config).not.toHaveBeenCalled(); + expect(mockContext.incrementHip3ConfigVersion).not.toHaveBeenCalled(); + }); + + it('detects change even when markets are in different order', () => { + mockCurrentHip3Config.allowlistMarkets = ['BTC', 'ETH']; + // No equity flag in this test (isVersionGatedFeatureFlag returns false for non-flag objects) + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3AllowlistMarkets: ['ETH', 'SOL'], + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setHip3Config).toHaveBeenCalled(); + }); + + it('logs config change details', () => { + // isVersionGatedFeatureFlag is now a real function from types - provide valid flag data + (mockDeps.featureFlags.validateVersionGated as jest.Mock).mockReturnValue( + true, + ); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3Enabled: { enabled: true, minimumVersion: '1.0.0' }, + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('HIP-3 config changed'), + expect.objectContaining({ + equityChanged: true, + oldEquity: false, + newEquity: true, + }), + ); + }); + + it('logs version increment', () => { + // isVersionGatedFeatureFlag is now a real function from types - provide valid flag data + (mockDeps.featureFlags.validateVersionGated as jest.Mock).mockReturnValue( + true, + ); + (mockContext.incrementHip3ConfigVersion as jest.Mock).mockReturnValue(42); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3Enabled: { enabled: true, minimumVersion: '1.0.0' }, + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('Incremented hip3ConfigVersion'), + expect.objectContaining({ newVersion: 42 }), + ); + }); + + it('handles empty string for allowlist markets', () => { + // No equity flag in this test (isVersionGatedFeatureFlag returns false for non-flag objects) + (parseCommaSeparatedString as jest.Mock).mockReturnValue([]); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3AllowlistMarkets: '', + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('allowlistMarkets string was empty'), + expect.anything(), + ); + }); + + it('handles empty string for blocklist markets', () => { + // No equity flag in this test (isVersionGatedFeatureFlag returns false for non-flag objects) + (parseCommaSeparatedString as jest.Mock).mockReturnValue([]); + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3BlocklistMarkets: '', + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('blocklistMarkets string was empty'), + expect.anything(), + ); + }); + + it('filters out invalid patterns from string allowlist', () => { + (parseCommaSeparatedString as jest.Mock).mockReturnValue([ + 'xyz:TSLA', + '"bad"pattern"', + 'valid:*', + ]); + (stripQuotes as jest.Mock).mockImplementation((s: string) => s); + (validateMarketPattern as jest.Mock).mockImplementation( + (pattern: string) => { + if (pattern === '"bad"pattern"') { + throw new Error('Market pattern contains invalid characters'); + } + return true; + }, + ); + + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3AllowlistMarkets: 'xyz:TSLA,"bad"pattern",valid:*', + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setHip3Config).toHaveBeenCalledWith( + expect.objectContaining({ + allowlistMarkets: ['xyz:TSLA', 'valid:*'], + }), + ); + }); + + it('filters out invalid patterns from array blocklist', () => { + (stripQuotes as jest.Mock).mockImplementation((s: string) => s); + (validateMarketPattern as jest.Mock).mockImplementation( + (pattern: string) => { + if (pattern === '"invalid"') { + throw new Error('Market pattern contains invalid characters'); + } + return true; + }, + ); + + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3BlocklistMarkets: ['valid:BTC', '"invalid"', 'also:valid'], + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setHip3Config).toHaveBeenCalledWith( + expect.objectContaining({ + blocklistMarkets: ['valid:BTC', 'also:valid'], + }), + ); + }); + + it('preserves current config when all array patterns are invalid', () => { + mockCurrentHip3Config.allowlistMarkets = ['existing:BTC']; + (stripQuotes as jest.Mock).mockImplementation((s: string) => s); + (validateMarketPattern as jest.Mock).mockImplementation(() => { + throw new Error('Market pattern contains invalid characters'); + }); + + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3AllowlistMarkets: ['"bad1"', '"bad2"'], + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + // Should NOT overwrite existing config with empty array + expect(mockContext.setHip3Config).not.toHaveBeenCalled(); + }); + it('logs warning for dropped invalid patterns', () => { + (parseCommaSeparatedString as jest.Mock).mockReturnValue([ + '"bad"pattern"', + ]); + (stripQuotes as jest.Mock).mockImplementation((s: string) => s); + (validateMarketPattern as jest.Mock).mockImplementation(() => { + throw new Error('Market pattern contains invalid characters'); + }); + + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsHip3AllowlistMarkets: '"bad"pattern"', + }; + + featureFlagConfigurationService.refreshHip3Config({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockDeps.logger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + context: expect.objectContaining({ + data: expect.objectContaining({ pattern: '"bad"pattern"' }), + }), + }), + ); + }); + }); + + describe('refreshEligibility', () => { + it('extracts blocked regions from remote feature flag', () => { + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: ['US', 'CA', 'UK'], + }, + }; + + featureFlagConfigurationService.refreshEligibility({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setBlockedRegionList).toHaveBeenCalledWith( + ['US', 'CA', 'UK'], + 'remote', + ); + }); + + it('calls refreshHip3Config', () => { + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: ['US'], + }, + }; + + const refreshHip3ConfigSpy = jest.spyOn( + featureFlagConfigurationService, + 'refreshHip3Config', + ); + + featureFlagConfigurationService.refreshEligibility({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(refreshHip3ConfigSpy).toHaveBeenCalledWith({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + refreshHip3ConfigSpy.mockRestore(); + }); + + it('skips setting blocked regions when not an array', () => { + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsPerpTradingGeoBlockedCountriesV2: { + blockedRegions: 'invalid', + }, + }; + + featureFlagConfigurationService.refreshEligibility({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + + expect(mockContext.setBlockedRegionList).not.toHaveBeenCalled(); + }); + + it('handles missing blocked regions gracefully', () => { + mockRemoteFeatureFlagState.remoteFeatureFlags = { + perpsPerpTradingGeoBlockedCountriesV2: {}, + }; + + expect(() => { + featureFlagConfigurationService.refreshEligibility({ + remoteFeatureFlagControllerState: mockRemoteFeatureFlagState, + context: mockContext, + }); + }).not.toThrow(); + }); + }); + + describe('setBlockedRegions', () => { + it('throws error when required callbacks are missing', () => { + const contextWithoutCallbacks = createMockServiceContext({ + getBlockedRegionList: undefined, + }); + + expect(() => { + featureFlagConfigurationService.setBlockedRegions({ + list: ['US'], + source: 'remote', + context: contextWithoutCallbacks, + }); + }).toThrow( + 'Required blocked region callbacks not available in ServiceContext', + ); + }); + + it('sets blocked region list', () => { + featureFlagConfigurationService.setBlockedRegions({ + list: ['US', 'CA', 'UK'], + source: 'remote', + context: mockContext, + }); + + expect(mockContext.setBlockedRegionList).toHaveBeenCalledWith( + ['US', 'CA', 'UK'], + 'remote', + ); + }); + + it('triggers eligibility refresh after setting list', () => { + featureFlagConfigurationService.setBlockedRegions({ + list: ['US'], + source: 'remote', + context: mockContext, + }); + + expect(mockContext.refreshEligibility).toHaveBeenCalledTimes(1); + }); + + it('implements sticky remote pattern - does not downgrade from remote to fallback', () => { + mockCurrentBlockedRegionList.source = 'remote'; + + featureFlagConfigurationService.setBlockedRegions({ + list: ['US'], + source: 'fallback', + context: mockContext, + }); + + expect(mockContext.setBlockedRegionList).not.toHaveBeenCalled(); + expect(mockContext.refreshEligibility).not.toHaveBeenCalled(); + }); + + it('allows upgrade from fallback to remote', () => { + mockCurrentBlockedRegionList.source = 'fallback'; + + featureFlagConfigurationService.setBlockedRegions({ + list: ['US', 'CA'], + source: 'remote', + context: mockContext, + }); + + expect(mockContext.setBlockedRegionList).toHaveBeenCalledWith( + ['US', 'CA'], + 'remote', + ); + }); + + it('handles eligibility refresh error gracefully', () => { + (mockContext.refreshEligibility as jest.Mock).mockRejectedValue( + new Error('Refresh failed'), + ); + + expect(() => { + featureFlagConfigurationService.setBlockedRegions({ + list: ['US'], + source: 'remote', + context: mockContext, + }); + }).not.toThrow(); + }); + + it('logs error when eligibility refresh fails', async () => { + (mockContext.refreshEligibility as jest.Mock).mockRejectedValue( + new Error('Refresh failed'), + ); + + featureFlagConfigurationService.setBlockedRegions({ + list: ['US'], + source: 'remote', + context: mockContext, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + + it('handles empty blocked region list', () => { + featureFlagConfigurationService.setBlockedRegions({ + list: [], + source: 'remote', + context: mockContext, + }); + + expect(mockContext.setBlockedRegionList).toHaveBeenCalledWith( + [], + 'remote', + ); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/HyperLiquidClientService.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidClientService.test.ts new file mode 100644 index 0000000000..9e7976eedb --- /dev/null +++ b/packages/perps-controller/tests/src/services/HyperLiquidClientService.test.ts @@ -0,0 +1,1918 @@ +/* eslint-disable */ + +/* eslint-disable @typescript-eslint/no-require-imports */ +/** + * Unit tests for HyperLiquidClientService + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { CandlePeriod } from '../../../src/constants/chartConfig'; +import { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService'; +import type { ValidCandleInterval } from '../../../src/services/HyperLiquidClientService'; +import { resetPerpsRestCacheForTests } from '../../../src/utils/coalescePerpsRestRequest'; +import { createMockInfrastructure } from '../../helpers/serviceMocks'; + +// Mock WebSocket for Jest environment (React Native provides this globally) +(global as any).WebSocket = jest.fn(); + +// Mock HyperLiquid SDK - using 'mock' prefix for Jest compatibility +const mockExchangeClient = { initialized: true }; +const mockInfoClientWs = { + initialized: true, + transport: 'websocket', + candleSnapshot: jest.fn(), + historicalOrders: jest.fn(), +}; +const mockInfoClientHttp = { + initialized: true, + transport: 'http', + candleSnapshot: jest.fn(), +}; +const mockWsTransportReady = jest.fn().mockResolvedValue(undefined); +const mockSubscriptionClient = { + initialized: true, + config_: { + transport: { + ready: mockWsTransportReady, + }, + }, +}; +const mockSocket = { + addEventListener: jest.fn(), + removeEventListener: jest.fn(), +}; +const mockWsTransport = { + url: 'ws://mock', + close: jest.fn().mockResolvedValue(undefined), + ready: mockWsTransportReady, + socket: mockSocket, +}; +const mockHttpTransport = { + url: 'http://mock', +}; + +// Counter for InfoClient mock - using 'mock' prefix so Jest allows it +let mockInfoClientCallCount = 0; +jest.mock('@nktkas/hyperliquid', () => ({ + ExchangeClient: jest.fn(() => mockExchangeClient), + InfoClient: jest.fn(() => { + mockInfoClientCallCount++; + // First call is WebSocket (default), second is HTTP (fallback) + return mockInfoClientCallCount % 2 === 1 + ? mockInfoClientWs + : mockInfoClientHttp; + }), + SubscriptionClient: jest.fn(() => mockSubscriptionClient), + WebSocketTransport: jest.fn(() => mockWsTransport), + HttpTransport: jest.fn(() => mockHttpTransport), +})); + +// Mock configuration +jest.mock('../../../src/constants/hyperLiquidConfig', () => ({ + HYPERLIQUID_TRANSPORT_CONFIG: { + timeout: 10_000, + keepAlive: { interval: 30_000 }, + reconnect: { + maxRetries: 5, + connectionTimeout: 10_000, + }, + }, +})); + +// Mock DevLogger +jest.mock( + '../../../../core/SDKConnect/utils/DevLogger', + () => ({ + DevLogger: { + log: jest.fn(), + }, + }), + { virtual: true }, +); + +describe('HyperLiquidClientService', () => { + let service: HyperLiquidClientService; + let mockWallet: any; + let mockDeps: ReturnType; + + // Use fake timers globally to ensure all intervals/timeouts can be cleared + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + // Final cleanup - ensure all mocks and timers are reset + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + resetPerpsRestCacheForTests(); + mockInfoClientCallCount = 0; // Reset InfoClient call counter + + const hyperLiquid = jest.requireMock('@nktkas/hyperliquid'); + hyperLiquid.ExchangeClient.mockImplementation(() => mockExchangeClient); + hyperLiquid.InfoClient.mockImplementation(() => { + mockInfoClientCallCount++; + return mockInfoClientCallCount % 2 === 1 + ? mockInfoClientWs + : mockInfoClientHttp; + }); + hyperLiquid.SubscriptionClient.mockImplementation( + () => mockSubscriptionClient, + ); + hyperLiquid.WebSocketTransport.mockImplementation(() => mockWsTransport); + hyperLiquid.HttpTransport.mockImplementation(() => mockHttpTransport); + + // Restore default mock for transport ready + mockWsTransportReady.mockResolvedValue(undefined); + // Restore default mock for transport close + mockWsTransport.close.mockResolvedValue(undefined); + // Reset socket event listener mock + mockSocket.addEventListener.mockClear(); + + mockWallet = { + request: jest.fn().mockResolvedValue('0x123'), + }; + + mockDeps = createMockInfrastructure(); + service = new HyperLiquidClientService(mockDeps); + }); + + afterEach(async () => { + // Clean up the service to stop health check monitoring and close connections + try { + await service.disconnect(); + } catch { + // Ignore disconnect errors in cleanup + } + // Clear all pending timers to prevent open handles + jest.clearAllTimers(); + }); + + describe('Constructor and Configuration', () => { + it('initializes with mainnet by default', () => { + expect(service.isTestnetMode()).toBe(false); + expect(service.getNetwork()).toBe('mainnet'); + }); + + it('initializes with testnet when specified', () => { + const testnetService = new HyperLiquidClientService(mockDeps, { + isTestnet: true, + }); + + expect(testnetService.isTestnetMode()).toBe(true); + expect(testnetService.getNetwork()).toBe('testnet'); + }); + + it('updates testnet mode', () => { + service.setTestnetMode(true); + + expect(service.isTestnetMode()).toBe(true); + expect(service.getNetwork()).toBe('testnet'); + }); + }); + + describe('Client Initialization', () => { + it('initializes clients successfully with dual transports', async () => { + await service.initialize(mockWallet); + + expect(service.isInitialized()).toBe(true); + + const { + ExchangeClient, + InfoClient, + SubscriptionClient, + WebSocketTransport, + HttpTransport, + } = require('@nktkas/hyperliquid'); + + // Verify HTTP transport uses isTestnet flag (SDK handles endpoint selection) + expect(HttpTransport).toHaveBeenCalledWith({ + isTestnet: false, + timeout: 10_000, + }); + + // Verify WebSocket transport uses isTestnet flag (SDK handles endpoint selection) + expect(WebSocketTransport).toHaveBeenCalledWith({ + isTestnet: false, + timeout: 10_000, + keepAlive: { interval: 30_000 }, + reconnect: expect.objectContaining({ + WebSocket: expect.any(Function), + maxRetries: 5, + connectionTimeout: 10_000, + }), + }); + + // ExchangeClient uses HTTP transport + expect(ExchangeClient).toHaveBeenCalledWith({ + wallet: mockWallet, + transport: mockHttpTransport, + }); + + // InfoClient is created twice: once with WebSocket (default), once with HTTP (fallback) + expect(InfoClient).toHaveBeenCalledTimes(2); + expect(InfoClient).toHaveBeenNthCalledWith(1, { + transport: mockWsTransport, + }); + expect(InfoClient).toHaveBeenNthCalledWith(2, { + transport: mockHttpTransport, + }); + + // SubscriptionClient uses WebSocket transport + expect(SubscriptionClient).toHaveBeenCalledWith({ + transport: mockWsTransport, + }); + }); + + it('handles initialization errors', async () => { + const { ExchangeClient } = require('@nktkas/hyperliquid'); + ExchangeClient.mockImplementationOnce(() => { + throw new Error('Client initialization failed'); + }); + + await expect(service.initialize(mockWallet)).rejects.toThrow( + 'Client initialization failed', + ); + }); + + it('initializes with testnet configuration', async () => { + const testnetService = new HyperLiquidClientService(mockDeps, { + isTestnet: true, + }); + await testnetService.initialize(mockWallet); + + const { + ExchangeClient, + WebSocketTransport, + HttpTransport, + } = require('@nktkas/hyperliquid'); + + // Verify testnet flag is passed (SDK auto-selects testnet endpoints) + expect(HttpTransport).toHaveBeenCalledWith({ + isTestnet: true, + timeout: 10_000, + }); + + expect(WebSocketTransport).toHaveBeenCalledWith({ + isTestnet: true, + timeout: 10_000, + keepAlive: { interval: 30_000 }, + reconnect: expect.objectContaining({ + WebSocket: expect.any(Function), + }), + }); + + // ExchangeClient uses HTTP transport + expect(ExchangeClient).toHaveBeenCalledWith({ + wallet: mockWallet, + transport: mockHttpTransport, + }); + }); + }); + + describe('Client Access', () => { + beforeEach(async () => { + await service.initialize(mockWallet); + }); + + it('provides access to exchange client', () => { + const exchangeClient = service.getExchangeClient(); + + expect(exchangeClient).toBe(mockExchangeClient); + }); + + it('provides access to info client (WebSocket by default)', () => { + const infoClient = service.getInfoClient(); + + expect(infoClient).toBe(mockInfoClientWs); + expect((infoClient as any).transport).toBe('websocket'); + }); + + it('provides access to HTTP info client when useHttp option is true', () => { + const infoClient = service.getInfoClient({ useHttp: true }); + + expect(infoClient).toBe(mockInfoClientHttp); + expect((infoClient as any).transport).toBe('http'); + }); + + it('returns WebSocket info client when useHttp option is false', () => { + const infoClient = service.getInfoClient({ useHttp: false }); + + expect(infoClient).toBe(mockInfoClientWs); + expect((infoClient as any).transport).toBe('websocket'); + }); + + it('returns WebSocket info client when options is empty object', () => { + const infoClient = service.getInfoClient({}); + + expect(infoClient).toBe(mockInfoClientWs); + expect((infoClient as any).transport).toBe('websocket'); + }); + + it('provides access to subscription client', () => { + const subscriptionClient = service.getSubscriptionClient(); + + expect(subscriptionClient).toBe(mockSubscriptionClient); + }); + + it('throws when accessing uninitialized exchange client', () => { + const uninitializedService = new HyperLiquidClientService(mockDeps); + + expect(() => uninitializedService.getExchangeClient()).toThrow( + 'CLIENT_NOT_INITIALIZED', + ); + }); + + it('throws when accessing uninitialized info client', () => { + const uninitializedService = new HyperLiquidClientService(mockDeps); + + expect(() => uninitializedService.getInfoClient()).toThrow( + 'CLIENT_NOT_INITIALIZED', + ); + }); + + it('returns undefined for uninitialized subscription client', () => { + const uninitializedService = new HyperLiquidClientService(mockDeps); + + expect(uninitializedService.getSubscriptionClient()).toBeUndefined(); + }); + }); + + describe('Initialization State Management', () => { + it('reports not initialized before setup', () => { + expect(service.isInitialized()).toBe(false); + }); + + it('reports initialized after setup', async () => { + await service.initialize(mockWallet); + + expect(service.isInitialized()).toBe(true); + }); + + it('ensures initialization succeeds when clients are ready', async () => { + await service.initialize(mockWallet); + + expect(() => service.ensureInitialized()).not.toThrow(); + }); + + it('throws when ensuring initialization on uninitialized service', () => { + expect(() => service.ensureInitialized()).toThrow( + 'CLIENT_NOT_INITIALIZED', + ); + }); + + it('ensures subscription client is available', async () => { + await service.initialize(mockWallet); + + await expect( + service.ensureSubscriptionClient(mockWallet), + ).resolves.not.toThrow(); + }); + + it('reinitializes when subscription client is missing', async () => { + // Start with partial initialization to simulate missing subscription client + const uninitializedService = new HyperLiquidClientService(mockDeps); + + await uninitializedService.ensureSubscriptionClient(mockWallet); + + expect(uninitializedService.isInitialized()).toBe(true); + }); + }); + + describe('Network Management', () => { + it('toggles between mainnet and testnet', async () => { + expect(service.getNetwork()).toBe('mainnet'); + + const newNetwork = await service.toggleTestnet(mockWallet); + + expect(newNetwork).toBe('testnet'); + expect(service.getNetwork()).toBe('testnet'); + expect(service.isTestnetMode()).toBe(true); + }); + + it('toggles back from testnet to mainnet', async () => { + service.setTestnetMode(true); + + const newNetwork = await service.toggleTestnet(mockWallet); + + expect(newNetwork).toBe('mainnet'); + expect(service.getNetwork()).toBe('mainnet'); + expect(service.isTestnetMode()).toBe(false); + }); + }); + + describe('Disconnection', () => { + beforeEach(async () => { + await service.initialize(mockWallet); + }); + + it('disconnects successfully and close only WebSocket transport', async () => { + await service.disconnect(); + + // Only WebSocket transport should be closed (HTTP is stateless) + expect(mockWsTransport.close).toHaveBeenCalled(); + expect(service.getSubscriptionClient()).toBeUndefined(); + }); + + it('handles disconnect errors gracefully', async () => { + mockWsTransport.close.mockRejectedValueOnce( + new Error('Disconnect failed'), + ); + + // Should not throw, error is caught and logged + await expect(service.disconnect()).resolves.not.toThrow(); + + // Verify the error was attempted to be handled + expect(mockWsTransport.close).toHaveBeenCalled(); + }); + + it('clears all client references after disconnect', async () => { + await service.disconnect(); + + expect(service.isInitialized()).toBe(false); + expect(service.getSubscriptionClient()).toBeUndefined(); + expect(() => service.getExchangeClient()).toThrow(); + expect(() => service.getInfoClient()).toThrow(); + }); + + it('handles disconnect when subscription client is already undefined', async () => { + // Manually clear subscription client to simulate partial state + Object.defineProperty(service, 'subscriptionClient', { + value: undefined, + writable: true, + }); + + await expect(service.disconnect()).resolves.not.toThrow(); + }); + }); + + describe('Error Handling', () => { + it('handles transport creation errors', async () => { + const { WebSocketTransport } = require('@nktkas/hyperliquid'); + WebSocketTransport.mockImplementationOnce(() => { + throw new Error('Transport creation failed'); + }); + + await expect(service.initialize(mockWallet)).rejects.toThrow( + 'Transport creation failed', + ); + }); + + it('maintains network state through errors', async () => { + service.setTestnetMode(true); + + try { + const { ExchangeClient } = require('@nktkas/hyperliquid'); + ExchangeClient.mockImplementationOnce(() => { + throw new Error('Initialization failed'); + }); + await service.initialize(mockWallet); + } catch { + // Expected error + } + + expect(service.isTestnetMode()).toBe(true); + expect(service.getNetwork()).toBe('testnet'); + }); + + it('handles transport ready timeout', async () => { + // Make transport.ready() reject with abort error + mockWsTransportReady.mockRejectedValueOnce(new Error('Aborted')); + + await expect(service.initialize(mockWallet)).rejects.toThrow('Aborted'); + expect(service.isInitialized()).toBe(false); + }); + }); + + describe('Logging and Debugging', () => { + it('logs initialization events', async () => { + await service.initialize(mockWallet); + + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'HyperLiquid SDK clients initialized', + expect.objectContaining({ + testnet: false, + timestamp: expect.any(String), + connectionState: 'connected', + }), + ); + }); + + it('logs disconnect events', async () => { + await service.initialize(mockWallet); + + await service.disconnect(); + + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'HyperLiquid: Disconnecting SDK clients', + expect.objectContaining({ + isTestnet: false, + timestamp: expect.any(String), + }), + ); + }); + + it('logs transport creation events', async () => { + await service.initialize(mockWallet); + + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'HyperLiquid: Creating transports', + expect.objectContaining({ + isTestnet: false, + timestamp: expect.any(String), + }), + ); + }); + }); + + describe('fetchHistoricalCandles', () => { + beforeEach(async () => { + await service.initialize(mockWallet); + }); + + it('fetches historical candles successfully', async () => { + // Arrange + const mockResponse = [ + { t: 1700000000000, o: 50000, h: 51000, l: 49000, c: 50500, v: 100 }, + { t: 1700003600000, o: 50500, h: 51500, l: 50000, c: 51000, v: 150 }, + ]; + + mockInfoClientHttp.candleSnapshot = jest + .fn() + .mockResolvedValue(mockResponse); + + // Act + const result = await service.fetchHistoricalCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + limit: 100, + }); + + // Assert + expect(result).toEqual({ + symbol: 'BTC', + interval: '1h', + candles: [ + { + time: 1700000000000, + open: '50000', + high: '51000', + low: '49000', + close: '50500', + volume: '100', + }, + { + time: 1700003600000, + open: '50500', + high: '51500', + low: '50000', + close: '51000', + volume: '150', + }, + ], + }); + expect(mockInfoClientHttp.candleSnapshot).toHaveBeenCalledWith({ + coin: 'BTC', // SDK uses 'coin' terminology + interval: '1h', + startTime: expect.any(Number), + endTime: expect.any(Number), + }); + }); + + it('uses the default limit and forwards an explicit abort signal', async () => { + const abortController = new AbortController(); + mockInfoClientHttp.candleSnapshot = jest.fn().mockResolvedValue([]); + + await service.fetchHistoricalCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + signal: abortController.signal, + }); + + // signal is not forwarded through the coalesce path (no endTime), + // so candleSnapshot is called with only the request object + expect(mockInfoClientHttp.candleSnapshot).toHaveBeenCalledWith({ + coin: 'BTC', + interval: '1h', + startTime: expect.any(Number), + endTime: expect.any(Number), + }); + + const request = mockInfoClientHttp.candleSnapshot.mock.calls[0][0]; + expect(request.endTime - request.startTime).toBe(100 * 60 * 60 * 1000); + }); + + it('throws AbortError and skips the REST call when signal is already aborted', async () => { + const abortController = new AbortController(); + abortController.abort(); + mockInfoClientHttp.candleSnapshot = jest.fn().mockResolvedValue([]); + + await expect( + service.fetchHistoricalCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + signal: abortController.signal, + }), + ).rejects.toMatchObject({ name: 'AbortError' }); + + expect(mockInfoClientHttp.candleSnapshot).not.toHaveBeenCalled(); + }); + + it('forwards AbortSignal for non-coalesced paginated fetches', async () => { + const abortController = new AbortController(); + const endTime = 1700000000000; + mockInfoClientHttp.candleSnapshot = jest.fn().mockResolvedValue([]); + + await service.fetchHistoricalCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + limit: 10, + endTime, + signal: abortController.signal, + }); + + expect(mockInfoClientHttp.candleSnapshot).toHaveBeenCalledWith( + { + coin: 'BTC', + interval: '1h', + startTime: endTime - 10 * 60 * 60 * 1000, + endTime, + }, + abortController.signal, + ); + }); + + it('coalesces concurrent identical fetches into one REST call', async () => { + mockInfoClientHttp.candleSnapshot = jest.fn().mockResolvedValue([]); + + await Promise.all([ + service.fetchHistoricalCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + limit: 100, + }), + service.fetchHistoricalCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + limit: 100, + }), + ]); + + expect(mockInfoClientHttp.candleSnapshot).toHaveBeenCalledTimes(1); + }); + + it('handles empty candles response', async () => { + // Arrange + const mockResponse: any[] = []; + + mockInfoClientHttp.candleSnapshot = jest + .fn() + .mockResolvedValue(mockResponse); + + // Act + const result = await service.fetchHistoricalCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + limit: 100, + }); + + // Assert + expect(result).toEqual({ + symbol: 'BTC', + interval: '1h', + candles: [], + }); + }); + + it('handles API errors gracefully', async () => { + // Arrange + const errorMessage = 'API request failed'; + mockInfoClientHttp.candleSnapshot = jest + .fn() + .mockRejectedValue(new Error(errorMessage)); + + // Act & Assert + await expect( + service.fetchHistoricalCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + limit: 100, + }), + ).rejects.toThrow(errorMessage); + }); + + it('calculates correct time range for different intervals', async () => { + // Arrange + const mockResponse = { + symbol: 'ETH', + interval: '5m', + candles: [], + }; + + mockInfoClientHttp.candleSnapshot = jest + .fn() + .mockResolvedValue(mockResponse); + + // Act + await service.fetchHistoricalCandles({ + symbol: 'ETH', + interval: '5m' as ValidCandleInterval, + limit: 50, + }); + + // Assert + expect(mockInfoClientHttp.candleSnapshot).toHaveBeenCalledWith({ + coin: 'ETH', // SDK uses 'coin' terminology + interval: '5m', + startTime: expect.any(Number), + endTime: expect.any(Number), + }); + + // Verify time range calculation + const callArgs = mockInfoClientHttp.candleSnapshot.mock.calls[0][0]; + const timeDiff = callArgs.endTime - callArgs.startTime; + const expectedTimeDiff = 50 * 5 * 60 * 1000; // 50 intervals * 5 minutes * 60 seconds * 1000ms + expect(timeDiff).toBe(expectedTimeDiff); + }); + + it('handles different interval formats', async () => { + // Arrange + const testCases = [ + { interval: CandlePeriod.ThreeMinutes, expected: 180000 }, // 3 minutes = 3 * 60 * 1000 + { interval: CandlePeriod.OneHour, expected: 3600000 }, + { interval: CandlePeriod.OneDay, expected: 86400000 }, + ]; + + for (const { interval, expected } of testCases) { + const mockResponse: any[] = []; + + // Reset mock before each iteration + jest.clearAllMocks(); + mockInfoClientHttp.candleSnapshot = jest + .fn() + .mockResolvedValue(mockResponse); + + // Act + await service.fetchHistoricalCandles({ + symbol: 'BTC', + interval, + limit: 10, + }); + + // Assert + const callArgs = mockInfoClientHttp.candleSnapshot.mock.calls[0][0]; + const timeDiff = callArgs.endTime - callArgs.startTime; + expect(timeDiff).toBe(10 * expected); + } + }); + + it('uses testnet endpoint when in testnet mode', async () => { + // Arrange + const testnetService = new HyperLiquidClientService(mockDeps, { + isTestnet: true, + }); + await testnetService.initialize(mockWallet); + + const mockResponse: any[] = []; + + mockInfoClientHttp.candleSnapshot = jest + .fn() + .mockResolvedValue(mockResponse); + + // Act + await testnetService.fetchHistoricalCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + limit: 100, + }); + + // Assert + expect(mockInfoClientHttp.candleSnapshot).toHaveBeenCalled(); + // The testnet configuration is handled in the service initialization + }); + + it('throws error when service not initialized', async () => { + // Arrange + const uninitializedService = new HyperLiquidClientService(mockDeps); + + // Act & Assert + await expect( + uninitializedService.fetchHistoricalCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + limit: 100, + }), + ).rejects.toThrow('CLIENT_NOT_INITIALIZED'); + }); + + it('uses HTTP transport instead of WebSocket for historical fetches (TAT-2954)', async () => { + // Arrange + const mockResponse = [ + { t: 1700000000000, o: 50000, h: 51000, l: 49000, c: 50500, v: 100 }, + ]; + mockInfoClientHttp.candleSnapshot = jest + .fn() + .mockResolvedValue(mockResponse); + + // Spy on getInfoClient to verify useHttp option + const getInfoClientSpy = jest.spyOn(service, 'getInfoClient'); + + // Act + await service.fetchHistoricalCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + limit: 100, + }); + + // Assert — HTTP transport used, not WebSocket + expect(getInfoClientSpy).toHaveBeenCalledWith({ useHttp: true }); + expect(mockInfoClientHttp.candleSnapshot).toHaveBeenCalled(); + + getInfoClientSpy.mockRestore(); + }); + }); + + describe('fetchHistoricalOrders', () => { + const userAddress = '0x1234567890123456789012345678901234567890' as const; + + beforeEach(async () => { + await service.initialize(mockWallet); + jest.clearAllMocks(); + resetPerpsRestCacheForTests(); + }); + + it('fetches historical orders and coalesces concurrent calls', async () => { + const mockOrders = [{ order: { oid: 1 } }]; + mockInfoClientWs.historicalOrders.mockResolvedValue(mockOrders); + + const [a, b] = await Promise.all([ + service.fetchHistoricalOrders(userAddress), + service.fetchHistoricalOrders(userAddress), + ]); + + expect(a).toEqual(mockOrders); + expect(b).toEqual(mockOrders); + // Coalesce ensures only one underlying REST call + expect(mockInfoClientWs.historicalOrders).toHaveBeenCalledTimes(1); + }); + + it('bypasses coalesce cache when forceRefresh is true', async () => { + const mockOrders = [{ order: { oid: 1 } }]; + mockInfoClientWs.historicalOrders.mockResolvedValue(mockOrders); + + // First call populates cache + await service.fetchHistoricalOrders(userAddress); + // Second call with forceRefresh should bypass cache + await service.fetchHistoricalOrders(userAddress, { forceRefresh: true }); + + expect(mockInfoClientWs.historicalOrders).toHaveBeenCalledTimes(2); + }); + + it('returns empty array when SDK returns null', async () => { + mockInfoClientWs.historicalOrders.mockResolvedValue(null); + + const result = await service.fetchHistoricalOrders(userAddress); + + expect(result).toEqual([]); + }); + }); + + describe('subscribeToCandles', () => { + beforeEach(async () => { + await service.initialize(mockWallet); + jest.clearAllMocks(); + }); + + it('throws error when service not initialized', () => { + // Arrange + const uninitializedService = new HyperLiquidClientService(mockDeps); + + // Act & Assert + expect(() => + uninitializedService.subscribeToCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + callback: jest.fn(), + }), + ).toThrow('CLIENT_NOT_INITIALIZED'); + }); + + it('throws error when subscription client unavailable', async () => { + // Arrange + const serviceWithNoSubClient = new HyperLiquidClientService(mockDeps); + await serviceWithNoSubClient.initialize(mockWallet); + // Mock public getter to return undefined + jest + .spyOn(serviceWithNoSubClient, 'getSubscriptionClient') + .mockReturnValue(undefined); + + // Act & Assert + expect(() => + serviceWithNoSubClient.subscribeToCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + callback: jest.fn(), + }), + ).toThrow('SUBSCRIPTION_CLIENT_NOT_AVAILABLE'); + }); + + it('fetches historical data and setup WebSocket subscription', async () => { + // Arrange + const mockHistoricalData = [ + { + t: 1700000000000, + o: 50000, + h: 51000, + l: 49000, + c: 50500, + v: 100, + }, + { + t: 1700003600000, + o: 50500, + h: 52000, + l: 50000, + c: 51500, + v: 120, + }, + ]; + + mockInfoClientHttp.candleSnapshot = jest + .fn() + .mockResolvedValue(mockHistoricalData); + + const mockUnsubscribe = jest.fn(); + const mockCandleSubscription = Promise.resolve({ + unsubscribe: mockUnsubscribe, + }); + (mockSubscriptionClient as any).candle = jest + .fn() + .mockReturnValue(mockCandleSubscription); + + const callback = jest.fn(); + + // Act + const unsubscribe = service.subscribeToCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + callback, + }); + + // Wait for async operations + await jest.advanceTimersByTimeAsync(100); + + // Assert - should have fetched historical data (SDK uses 'coin' terminology) + // Signal is intentionally dropped inside the coalesced fetch path. + expect(mockInfoClientHttp.candleSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + coin: 'BTC', + interval: '1h', + }), + ); + + // Assert - callback invoked with historical data + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + symbol: 'BTC', + interval: '1h', + candles: expect.arrayContaining([ + expect.objectContaining({ + time: 1700000000000, + open: '50000', + high: '51000', + low: '49000', + close: '50500', + volume: '100', + }), + ]), + }), + ); + + // Assert - WebSocket subscription created + expect((mockSubscriptionClient as any).candle).toHaveBeenCalled(); + + // Assert - unsubscribe function returned + expect(typeof unsubscribe).toBe('function'); + }); + + it('transforms historical candle data correctly', async () => { + // Arrange + const mockHistoricalData = [ + { + t: 1700000000000, + o: 50000.5, + h: 51000.75, + l: 49000.25, + c: 50500.5, + v: 100.123, + }, + ]; + + mockInfoClientHttp.candleSnapshot = jest + .fn() + .mockResolvedValue(mockHistoricalData); + + (mockSubscriptionClient as any).candle = jest + .fn() + .mockResolvedValue({ unsubscribe: jest.fn() }); + + const callback = jest.fn(); + + // Act + service.subscribeToCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + callback, + }); + + await jest.advanceTimersByTimeAsync(100); + + // Assert - numbers converted to strings + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + candles: [ + { + time: 1700000000000, + open: '50000.5', + high: '51000.75', + low: '49000.25', + close: '50500.5', + volume: '100.123', + }, + ], + }), + ); + }); + + it('handles WebSocket updates for existing candle', async () => { + // Arrange + const mockHistoricalData = [ + { + t: 1700000000000, + o: 50000, + h: 51000, + l: 49000, + c: 50500, + v: 100, + }, + ]; + + mockInfoClientHttp.candleSnapshot = jest + .fn() + .mockResolvedValue(mockHistoricalData); + + let wsCallback: any; + (mockSubscriptionClient as any).candle = jest + .fn() + .mockImplementation((_params, callback) => { + wsCallback = callback; + return Promise.resolve({ unsubscribe: jest.fn() }); + }); + + const callback = jest.fn(); + + // Act + service.subscribeToCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + callback, + }); + + await jest.advanceTimersByTimeAsync(100); + + // Clear previous callback invocations + callback.mockClear(); + + // Simulate WebSocket update for existing candle (same timestamp) + const updatedCandle = { + t: 1700000000000, // Same timestamp + o: 50000, + h: 51500, // Updated high + l: 49000, + c: 51000, // Updated close + v: 150, // Updated volume + }; + + wsCallback(updatedCandle); + + // Assert - callback invoked with updated candle + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + candles: [ + { + time: 1700000000000, + open: '50000', + high: '51500', + low: '49000', + close: '51000', + volume: '150', + }, + ], + }), + ); + }); + + it('handles WebSocket updates for new candle', async () => { + // Arrange + const mockHistoricalData = [ + { + t: 1700000000000, + o: 50000, + h: 51000, + l: 49000, + c: 50500, + v: 100, + }, + ]; + + mockInfoClientHttp.candleSnapshot = jest + .fn() + .mockResolvedValue(mockHistoricalData); + + let wsCallback: any; + (mockSubscriptionClient as any).candle = jest + .fn() + .mockImplementation((_params, callback) => { + wsCallback = callback; + return Promise.resolve({ unsubscribe: jest.fn() }); + }); + + const callback = jest.fn(); + + // Act + service.subscribeToCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + callback, + }); + + await jest.advanceTimersByTimeAsync(100); + + // Clear previous callback invocations + callback.mockClear(); + + // Simulate WebSocket update for new candle (different timestamp) + const newCandle = { + t: 1700003600000, // Different timestamp + o: 50500, + h: 52000, + l: 50000, + c: 51500, + v: 120, + }; + + wsCallback(newCandle); + + // Assert - callback invoked with appended candle + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + candles: [ + { + time: 1700000000000, + open: '50000', + high: '51000', + low: '49000', + close: '50500', + volume: '100', + }, + { + time: 1700003600000, + open: '50500', + high: '52000', + low: '50000', + close: '51500', + volume: '120', + }, + ], + }), + ); + }); + + it('creates immutable candles array for React re-renders', async () => { + // Arrange + const mockHistoricalData = [ + { + t: 1700000000000, + o: 50000, + h: 51000, + l: 49000, + c: 50500, + v: 100, + }, + ]; + + mockInfoClientHttp.candleSnapshot = jest + .fn() + .mockResolvedValue(mockHistoricalData); + + let wsCallback: any; + (mockSubscriptionClient as any).candle = jest + .fn() + .mockImplementation((_params, callback) => { + wsCallback = callback; + return Promise.resolve({ unsubscribe: jest.fn() }); + }); + + const callback = jest.fn(); + + // Act + service.subscribeToCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + callback, + }); + + await jest.advanceTimersByTimeAsync(100); + + const firstCallCandles = callback.mock.calls[0][0].candles; + + // Simulate WebSocket update + wsCallback({ + t: 1700000000000, + o: 50000, + h: 51500, + l: 49000, + c: 51000, + v: 150, + }); + + const secondCallCandles = callback.mock.calls[1][0].candles; + + // Assert - different array references (immutable) + expect(firstCallCandles).not.toBe(secondCallCandles); + }); + + it('handles empty historical data', async () => { + // Arrange + mockInfoClientHttp.candleSnapshot = jest.fn().mockResolvedValue([]); + + (mockSubscriptionClient as any).candle = jest + .fn() + .mockResolvedValue({ unsubscribe: jest.fn() }); + + const callback = jest.fn(); + + // Act + service.subscribeToCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + callback, + }); + + await jest.advanceTimersByTimeAsync(100); + + // Assert - callback invoked with empty candles + expect(callback).toHaveBeenCalledWith({ + symbol: 'BTC', + interval: '1h', + candles: [], + }); + }); + + it('invokes unsubscribe when cleanup function called', async () => { + // Arrange + mockInfoClientHttp.candleSnapshot = jest.fn().mockResolvedValue([]); + + const mockWsUnsubscribe = jest.fn(); + (mockSubscriptionClient as any).candle = jest + .fn() + .mockResolvedValue({ unsubscribe: mockWsUnsubscribe }); + + const callback = jest.fn(); + + // Act + const unsubscribe = service.subscribeToCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + callback, + }); + + // Wait for subscription to complete + await jest.advanceTimersByTimeAsync(100); + + // Call unsubscribe + unsubscribe(); + + // Assert - WebSocket unsubscribe called + expect(mockWsUnsubscribe).toHaveBeenCalled(); + }); + + it('handles unsubscribe before WebSocket established', async () => { + // Arrange - delay the promise resolution to simulate slow network + let resolveSnapshot: (value: any) => void = () => { + /* noop */ + }; + const delayedPromise = new Promise((resolve) => { + resolveSnapshot = resolve; + }); + + mockInfoClientHttp.candleSnapshot = jest + .fn() + .mockReturnValue(delayedPromise); + + const mockCandleSubscription = jest.fn(); + (mockSubscriptionClient as any).candle = mockCandleSubscription; + + const callback = jest.fn(); + + // Act - subscribe and immediately unsubscribe + const unsubscribe = service.subscribeToCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + callback, + }); + + // Call unsubscribe immediately before WebSocket establishes + expect(() => unsubscribe()).not.toThrow(); + + // Now resolve the snapshot to let the async chain continue + resolveSnapshot([]); + + // Wait for async operations to complete + await jest.advanceTimersByTimeAsync(100); + + // Assert - WebSocket subscription should not be created because + // we already unsubscribed before the async chain completed + expect(mockCandleSubscription).not.toHaveBeenCalled(); + expect(callback).not.toHaveBeenCalled(); // Callback should not be invoked after unsubscribe + }); + + it('suppresses error when cleanup aborts in-flight REST call', async () => { + // Arrange - make snapshot reject with abort error + const abortError = new Error('AbortError'); + abortError.name = 'AbortError'; + mockInfoClientHttp.candleSnapshot = jest + .fn() + .mockRejectedValue(abortError); + + const onError = jest.fn(); + const callback = jest.fn(); + + // Act - subscribe then immediately unsubscribe (triggers abort) + const unsubscribe = service.subscribeToCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + callback, + onError, + }); + + unsubscribe(); + + // Wait for async rejection to propagate + await jest.advanceTimersByTimeAsync(100); + + // Assert - error suppressed (abort is intentional), callback not invoked + expect(onError).not.toHaveBeenCalled(); + expect(callback).not.toHaveBeenCalled(); + }); + + it('cleans up WebSocket when unsubscribed during subscription establishment', async () => { + // Arrange - fast snapshot, slow WebSocket subscription + mockInfoClientHttp.candleSnapshot = jest.fn().mockResolvedValue([]); + + let resolveWsSubscription: (value: any) => void = () => { + /* noop */ + }; + const delayedWsPromise = new Promise((resolve) => { + resolveWsSubscription = resolve; + }); + + const mockWsUnsubscribe = jest.fn(); + (mockSubscriptionClient as any).candle = jest + .fn() + .mockReturnValue(delayedWsPromise); + + const callback = jest.fn(); + + // Act - subscribe + const unsubscribe = service.subscribeToCandles({ + symbol: 'BTC', + interval: '1h' as ValidCandleInterval, + callback, + }); + + // Wait for snapshot to complete + await jest.advanceTimersByTimeAsync(50); + + // Unsubscribe while WebSocket is still being established + unsubscribe(); + + // Now resolve the WebSocket subscription + resolveWsSubscription({ unsubscribe: mockWsUnsubscribe }); + + // Wait for async cleanup to complete + await jest.advanceTimersByTimeAsync(100); + + // Assert - WebSocket should be cleaned up immediately after establishing + expect(mockWsUnsubscribe).toHaveBeenCalled(); + }); + }); + + describe('Reconnection and Terminate Event', () => { + afterEach(() => { + // Restore default mock implementations that may have been changed by tests + const { WebSocketTransport } = require('@nktkas/hyperliquid'); + (WebSocketTransport as jest.Mock).mockImplementation( + () => mockWsTransport, + ); + mockWsTransportReady.mockResolvedValue(undefined); + }); + + it('sets reconnection callback', () => { + const callback = jest.fn().mockResolvedValue(undefined); + + service.setOnReconnectCallback(callback); + + // Callback is stored internally, verify it can be set without error + expect(() => service.setOnReconnectCallback(callback)).not.toThrow(); + }); + + it('sets terminate callback', () => { + const callback = jest.fn(); + + service.setOnTerminateCallback(callback); + + // Callback is stored internally, verify it can be set without error + expect(() => service.setOnTerminateCallback(callback)).not.toThrow(); + }); + + it('clears terminate callback when set to null', () => { + const callback = jest.fn(); + + service.setOnTerminateCallback(callback); + service.setOnTerminateCallback(null); + + // Callback should be cleared without error + expect(() => service.setOnTerminateCallback(null)).not.toThrow(); + }); + + it('registers terminate event listener on WebSocket transport', () => { + service.initialize(mockWallet); + + // Verify that addEventListener was called with 'terminate' + expect(mockSocket.addEventListener).toHaveBeenCalledWith( + 'terminate', + expect.any(Function), + ); + }); + + it('calls terminate callback when terminate event is fired', () => { + const terminateCallback = jest.fn(); + service.initialize(mockWallet); + service.setOnTerminateCallback(terminateCallback); + + // Get the terminate event handler that was registered + const terminateHandler = mockSocket.addEventListener.mock.calls.find( + (call: [string, (...args: unknown[]) => unknown]) => + call[0] === 'terminate', + )?.[1] as (event: Event) => void; + + expect(terminateHandler).toBeDefined(); + + // Simulate terminate event with error detail + const mockEvent = { + detail: { code: 1006 }, + } as unknown as Event; + + terminateHandler(mockEvent); + + // Verify callback was called with an error + expect(terminateCallback).toHaveBeenCalledWith(expect.any(Error)); + expect(terminateCallback.mock.calls[0][0].message).toContain( + 'WebSocket terminated', + ); + }); + + it('calls terminate callback with Error instance when detail is Error', () => { + const terminateCallback = jest.fn(); + service.initialize(mockWallet); + service.setOnTerminateCallback(terminateCallback); + + // Get the terminate event handler + const terminateHandler = mockSocket.addEventListener.mock.calls.find( + (call: [string, (...args: unknown[]) => unknown]) => + call[0] === 'terminate', + )?.[1] as (event: Event) => void; + + // Simulate terminate event with Error detail + const originalError = new Error('Connection failed'); + const mockEvent = { + detail: originalError, + } as unknown as Event; + + terminateHandler(mockEvent); + + // Verify callback was called with the original error + expect(terminateCallback).toHaveBeenCalledWith(originalError); + }); + + it('updates connection state to DISCONNECTED when terminate event fires', async () => { + const { + WebSocketConnectionState, + } = require('../../../src/services/HyperLiquidClientService'); + await service.initialize(mockWallet); + + // Verify initial state is CONNECTED + expect(service.getConnectionState()).toBe( + WebSocketConnectionState.Connected, + ); + + // Get the terminate event handler + const terminateHandler = mockSocket.addEventListener.mock.calls.find( + (call: [string, (...args: unknown[]) => unknown]) => + call[0] === 'terminate', + )?.[1] as (event: Event) => void; + + // Fire terminate event + terminateHandler({ detail: { code: 1006 } } as unknown as Event); + + // Verify state changed to DISCONNECTED + expect(service.getConnectionState()).toBe( + WebSocketConnectionState.Disconnected, + ); + }); + + it('does not throw when terminate callback is not set', () => { + service.initialize(mockWallet); + + // Get the terminate event handler + const terminateHandler = mockSocket.addEventListener.mock.calls.find( + (call: [string, (...args: unknown[]) => unknown]) => + call[0] === 'terminate', + )?.[1] as (event: Event) => void; + + // Fire terminate event without setting callback + expect(() => { + terminateHandler({ detail: { code: 1006 } } as unknown as Event); + }).not.toThrow(); + }); + + it('clears terminate callback on disconnect', async () => { + const terminateCallback = jest.fn(); + service.initialize(mockWallet); + service.setOnTerminateCallback(terminateCallback); + + await service.disconnect(); + + // After disconnect, the callback should be cleared + // Initialize again to get a new terminate handler + service.initialize(mockWallet); + + // Get the new terminate event handler + const terminateHandler = mockSocket.addEventListener.mock.calls + .filter( + (call: [string, (...args: unknown[]) => unknown]) => + call[0] === 'terminate', + ) + .pop()?.[1] as (event: Event) => void; + + // Fire terminate event + terminateHandler({ detail: { code: 1006 } } as unknown as Event); + + // Callback should NOT be called since it was cleared on disconnect + expect(terminateCallback).not.toHaveBeenCalled(); + }); + }); + + describe('Reconnection Logic', () => { + afterEach(() => { + // Restore default mock implementations that may have been changed by tests + const { WebSocketTransport } = require('@nktkas/hyperliquid'); + (WebSocketTransport as jest.Mock).mockImplementation( + () => mockWsTransport, + ); + mockWsTransportReady.mockResolvedValue(undefined); + }); + + it('reconnect() triggers reconnection and maintains CONNECTED state on success', async () => { + const { + WebSocketConnectionState, + } = require('../../../src/services/HyperLiquidClientService'); + await service.initialize(mockWallet); + + // Verify initial state is CONNECTED + expect(service.getConnectionState()).toBe( + WebSocketConnectionState.Connected, + ); + + // Call reconnect + await service.reconnect(); + + // After successful reconnect, state should be CONNECTED again + expect(service.getConnectionState()).toBe( + WebSocketConnectionState.Connected, + ); + }); + + it('reconnect() calls wsTransport.close() to cleanup existing transport', async () => { + await service.initialize(mockWallet); + mockWsTransport.close.mockClear(); + + // Call reconnect + await service.reconnect(); + + // Verify close was called during reconnection + expect(mockWsTransport.close).toHaveBeenCalled(); + }); + + it('reconnect() creates new clients after reconnection', async () => { + const { InfoClient, SubscriptionClient } = require('@nktkas/hyperliquid'); + await service.initialize(mockWallet); + + const infoClientCallsBefore = (InfoClient as jest.Mock).mock.calls.length; + const subscriptionClientCallsBefore = (SubscriptionClient as jest.Mock) + .mock.calls.length; + + await service.reconnect(); + + // New clients should have been created + expect((InfoClient as jest.Mock).mock.calls.length).toBeGreaterThan( + infoClientCallsBefore, + ); + expect( + (SubscriptionClient as jest.Mock).mock.calls.length, + ).toBeGreaterThan(subscriptionClientCallsBefore); + }); + + it('performDisconnection resets isReconnecting flag', async () => { + const { + WebSocketConnectionState, + } = require('../../../src/services/HyperLiquidClientService'); + await service.initialize(mockWallet); + + // Disconnect (which calls performDisconnection internally) + await service.disconnect(); + + expect(service.getConnectionState()).toBe( + WebSocketConnectionState.Disconnected, + ); + + // Verify we can reconnect after disconnect (isReconnecting was reset) + // Reset ready mock to succeed + mockWsTransportReady.mockResolvedValue(undefined); + + // Reset InfoClient counter since initialize creates new clients + mockInfoClientCallCount = 0; + + await service.initialize(mockWallet); + + expect(service.getConnectionState()).toBe( + WebSocketConnectionState.Connected, + ); + }); + }); + + describe('Connection State Listeners', () => { + afterEach(() => { + // Restore default mock implementations + const { WebSocketTransport } = require('@nktkas/hyperliquid'); + (WebSocketTransport as jest.Mock).mockImplementation( + () => mockWsTransport, + ); + mockWsTransportReady.mockResolvedValue(undefined); + }); + + it('subscribeToConnectionState immediately notifies with current state', async () => { + const { + WebSocketConnectionState, + } = require('../../../src/services/HyperLiquidClientService'); + await service.initialize(mockWallet); + + const listener = jest.fn(); + + service.subscribeToConnectionState(listener); + + // Should be called immediately with current state + expect(listener).toHaveBeenCalledWith( + WebSocketConnectionState.Connected, + 0, + ); + }); + + it('listener receives state changes when connection state updates', async () => { + const { + WebSocketConnectionState, + } = require('../../../src/services/HyperLiquidClientService'); + await service.initialize(mockWallet); + + const listener = jest.fn(); + service.subscribeToConnectionState(listener); + + // Clear the initial call + listener.mockClear(); + + // Trigger a state change by firing terminate event + const terminateHandler = mockSocket.addEventListener.mock.calls.find( + (call: [string, (...args: unknown[]) => unknown]) => + call[0] === 'terminate', + )?.[1] as (event: Event) => void; + + terminateHandler({ detail: { code: 1006 } } as unknown as Event); + + // Listener should be notified of DISCONNECTED state + expect(listener).toHaveBeenCalledWith( + WebSocketConnectionState.Disconnected, + 0, + ); + }); + + it('unsubscribe function removes listener', async () => { + await service.initialize(mockWallet); + + const listener = jest.fn(); + const unsubscribe = service.subscribeToConnectionState(listener); + + // Clear the initial call + listener.mockClear(); + + // Unsubscribe + unsubscribe(); + + // Trigger a state change + const terminateHandler = mockSocket.addEventListener.mock.calls.find( + (call: [string, (...args: unknown[]) => unknown]) => + call[0] === 'terminate', + )?.[1] as (event: Event) => void; + + terminateHandler({ detail: { code: 1006 } } as unknown as Event); + + // Listener should NOT be called after unsubscribe + expect(listener).not.toHaveBeenCalled(); + }); + + it('multiple listeners all receive notifications', async () => { + const { + WebSocketConnectionState, + } = require('../../../src/services/HyperLiquidClientService'); + await service.initialize(mockWallet); + + const listener1 = jest.fn(); + const listener2 = jest.fn(); + const listener3 = jest.fn(); + + service.subscribeToConnectionState(listener1); + service.subscribeToConnectionState(listener2); + service.subscribeToConnectionState(listener3); + + // All should be called with initial state + expect(listener1).toHaveBeenCalledWith( + WebSocketConnectionState.Connected, + 0, + ); + expect(listener2).toHaveBeenCalledWith( + WebSocketConnectionState.Connected, + 0, + ); + expect(listener3).toHaveBeenCalledWith( + WebSocketConnectionState.Connected, + 0, + ); + + // Clear all + listener1.mockClear(); + listener2.mockClear(); + listener3.mockClear(); + + // Trigger state change + const terminateHandler = mockSocket.addEventListener.mock.calls.find( + (call: [string, (...args: unknown[]) => unknown]) => + call[0] === 'terminate', + )?.[1] as (event: Event) => void; + + terminateHandler({ detail: { code: 1006 } } as unknown as Event); + + // All should be notified + expect(listener1).toHaveBeenCalledWith( + WebSocketConnectionState.Disconnected, + 0, + ); + expect(listener2).toHaveBeenCalledWith( + WebSocketConnectionState.Disconnected, + 0, + ); + expect(listener3).toHaveBeenCalledWith( + WebSocketConnectionState.Disconnected, + 0, + ); + }); + + it('reconnection triggers CONNECTING state notification', async () => { + const { + WebSocketConnectionState, + } = require('../../../src/services/HyperLiquidClientService'); + await service.initialize(mockWallet); + + const listener = jest.fn(); + service.subscribeToConnectionState(listener); + + // Clear initial call + listener.mockClear(); + + // Start a reconnection attempt + await service.reconnect(); + + // Listener should have been called with CONNECTING state + const connectingCall = listener.mock.calls.find( + (call: [string, number]) => + call[0] === WebSocketConnectionState.Connecting, + ); + expect(connectingCall).toBeDefined(); + }); + + it('successful reconnection notifies listeners with CONNECTED state', async () => { + const { + WebSocketConnectionState, + } = require('../../../src/services/HyperLiquidClientService'); + await service.initialize(mockWallet); + + const listener = jest.fn(); + service.subscribeToConnectionState(listener); + + // Clear initial call + listener.mockClear(); + + // Trigger reconnect + await service.reconnect(); + + // Find the CONNECTED call after reconnection + const connectedCalls = listener.mock.calls.filter( + (call: [string, number]) => + call[0] === WebSocketConnectionState.Connected, + ); + + expect(connectedCalls.length).toBeGreaterThan(0); + }); + }); + + describe('ensureTransportReady', () => { + it('resolves immediately when transport is ready', async () => { + await service.initialize(mockWallet); + + // Should resolve without error + await expect(service.ensureTransportReady()).resolves.toBeUndefined(); + }); + + it('throws error when subscription client not initialized', async () => { + // Service not initialized - subscription client is undefined + await expect(service.ensureTransportReady()).rejects.toThrow( + 'Subscription client not initialized', + ); + }); + + it('throws timeout error when transport not ready', async () => { + await service.initialize(mockWallet); + + // Reset mock to simulate a never-resolving ready() call + // The AbortController in ensureTransportReady will abort after timeout + mockWsTransportReady.mockImplementationOnce( + (signal?: AbortSignal) => + new Promise((_resolve, reject) => { + // If there's an abort signal, listen to it and reject when aborted + if (signal) { + signal.addEventListener('abort', () => { + reject(new Error('Aborted')); + }); + } + // Never resolves on its own - waits for abort + }), + ); + + // Use expect().rejects pattern with async timer advancement + // The promise and timer advancement need to happen concurrently + const promiseResult = service + .ensureTransportReady({ timeoutMs: 50 }) + .catch((e) => e); + + // Advance timers to trigger the timeout + await jest.advanceTimersByTimeAsync(100); + + // Now check the result + const error = await promiseResult; + expect(error).toBeInstanceOf(Error); + expect(error.message).toContain('WebSocket transport ready timeout'); + }); + + it('throws a proper Error (not undefined) when transport.ready() rejects with undefined', async () => { + await service.initialize(mockWallet); + + // Simulate the HyperLiquid SDK rejecting with undefined (the root cause of Sentry issues + // 5E7M, 5EF8, 5GBE, 5G91: "Unknown error (no details provided)") + mockWsTransportReady.mockImplementationOnce(() => + Promise.reject(undefined), + ); + + const error = await service.ensureTransportReady().catch((e) => e); + + // Must be a real Error instance, not undefined + expect(error).toBeInstanceOf(Error); + expect(error.message).toContain( + 'HyperLiquidClientService.ensureTransportReady', + ); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts new file mode 100644 index 0000000000..22fabe1e93 --- /dev/null +++ b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts @@ -0,0 +1,2491 @@ +/* eslint-disable */ +/** + * Unit tests for HyperLiquidSubscriptionService + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { CaipAccountId, Hex } from '@metamask/utils'; + +import { ABSTRACTION_MODE_REFRESH_THROTTLE_MS } from '../../../src/constants/perpsConfig'; +import type { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService'; +import type { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService'; +import type { + SubscribeOrderBookParams, + SubscribeOrderFillsParams, + SubscribePositionsParams, + SubscribePricesParams, +} from '../../../src/types'; +import { + adaptAccountStateFromSDK, + parseAssetName, +} from '../../../src/utils/hyperLiquidAdapter'; +import { createMockInfrastructure } from '../../helpers/serviceMocks'; + +// Mock HyperLiquid SDK types +interface MockSubscription { + unsubscribe: jest.Mock; +} + +// Mock adapter +jest.mock('../../../src/utils/hyperLiquidAdapter', () => ({ + adaptPositionFromSDK: jest.fn((assetPos: any) => ({ + symbol: 'BTC', + size: assetPos.position.szi, + entryPrice: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '2500', + leverage: { type: 'isolated', value: 2 }, + liquidationPrice: '40000', + maxLeverage: 100, + returnOnEquity: '4.0', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + })), + adaptOrderFromSDK: jest.fn((order: any) => ({ + orderId: order.oid.toString(), + symbol: order.coin, + side: order.side === 'B' ? 'buy' : 'sell', + orderType: 'limit', + size: order.sz, + originalSize: order.sz, + price: order.limitPx || order.triggerPx || '0', + filledSize: '0', + remainingSize: order.sz, + status: 'open', + timestamp: Date.now(), + detailedOrderType: order.orderType || 'Limit', + isTrigger: order.isTrigger ?? false, + reduceOnly: order.reduceOnly ?? false, + triggerPrice: order.triggerPx, + ...(typeof order.isPositionTpsl === 'boolean' + ? { isPositionTpsl: order.isPositionTpsl } + : {}), + })), + adaptAccountStateFromSDK: jest.fn(() => ({ + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', + marginUsed: '500.00', + unrealizedPnl: '100.00', + returnOnEquity: '20.0', + totalBalance: '10100.00', + })), + parseAssetName: jest.fn((symbol: string) => ({ + symbol, + dex: null, + })), +})); + +// Mock DevLogger +jest.mock( + '../../../../core/SDKConnect/utils/DevLogger', + () => ({ + DevLogger: { + log: jest.fn(), + }, + }), + { virtual: true }, +); + +// Mock trace utilities +jest.mock( + '../../../../util/trace', + () => ({ + trace: jest.fn(), + TraceName: { + PerpsWebSocketConnected: 'Perps WebSocket Connected', + PerpsWebSocketDisconnected: 'Perps WebSocket Disconnected', + }, + TraceOperation: { + PerpsMarketData: 'perps.market_data', + }, + }), + { virtual: true }, +); + +// Mock Sentry +jest.mock( + '@sentry/react-native', + () => ({ + setMeasurement: jest.fn(), + }), + { virtual: true }, +); + +describe('HyperLiquidSubscriptionService', () => { + let service: HyperLiquidSubscriptionService; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionClient: any; + let mockWalletAdapter: any; + let mockDeps: ReturnType; + let mockSpotClearinghouseState: jest.Mock; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + mockDeps = createMockInfrastructure(); + jest.mocked(parseAssetName).mockImplementation((symbol: string) => ({ + symbol, + dex: null, + })); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptPositionFromSDK.mockImplementation( + (assetPos: any) => ({ + symbol: 'BTC', + size: assetPos.position.szi, + entryPrice: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '2500', + leverage: { type: 'isolated', value: 2 }, + liquidationPrice: '40000', + maxLeverage: 100, + returnOnEquity: '4.0', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }), + ); + hyperLiquidAdapter.adaptOrderFromSDK.mockImplementation((order: any) => ({ + orderId: order.oid.toString(), + symbol: order.coin, + side: order.side === 'B' ? 'buy' : 'sell', + orderType: 'limit', + size: order.sz, + originalSize: order.sz, + price: order.limitPx || order.triggerPx || '0', + filledSize: '0', + remainingSize: order.sz, + status: 'open', + timestamp: Date.now(), + detailedOrderType: order.orderType || 'Limit', + isTrigger: order.isTrigger ?? false, + reduceOnly: order.reduceOnly ?? false, + triggerPrice: order.triggerPx, + ...(typeof order.isPositionTpsl === 'boolean' + ? { isPositionTpsl: order.isPositionTpsl } + : {}), + })); + hyperLiquidAdapter.adaptAccountStateFromSDK.mockImplementation(() => ({ + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', + marginUsed: '500.00', + unrealizedPnl: '100.00', + returnOnEquity: '20.0', + totalBalance: '10100.00', + })); + + // Mock subscription client + const mockSubscription: MockSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + mockSubscriptionClient = { + allMids: jest.fn((paramsOrCallback: any, maybeCallback?: any) => { + const callback = + typeof paramsOrCallback === 'function' + ? paramsOrCallback + : maybeCallback; + // Simulate allMids data + setTimeout(() => { + callback({ + mids: { + BTC: 50000, + ETH: 3000, + }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + activeAssetCtx: jest.fn((params: any, callback: any) => { + // Simulate activeAssetCtx data + setTimeout(() => { + callback({ + coin: params.coin, + ctx: { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', // Raw token units from API + dayNtlVlm: '50000000', + oraclePx: '50100', + midPx: '50000', // Price used for openInterest USD conversion: 1M tokens * $50K = $50B + }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + webData3: jest.fn((_params: any, callback: any) => { + // Simulate webData3 data with perpDexStates structure + // First callback immediately + setTimeout(() => { + callback({ + perpDexStates: [ + { + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.1' }, + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], + }, + ], + }); + }, 0); + + // Second callback with changed data to ensure updates are triggered + setTimeout(() => { + callback({ + perpDexStates: [ + { + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.2' }, // Changed position size + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 12346, // Changed order ID + coin: 'BTC', + side: 'S', + sz: '0.3', + origSz: '0.5', + limitPx: '51000', + orderType: 'Limit', + timestamp: 1234567890001, + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], + }, + ], + }); + }, 10); + + return Promise.resolve(mockSubscription); + }), + webData2: jest.fn((_params: any, callback: any) => { + // Simulate webData2 data with clearinghouseState (HIP-3 disabled) + setTimeout(() => { + callback({ + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.1' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + openOrders: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + userFills: jest.fn((_params: any, callback: any) => { + // Simulate order fill data + setTimeout(() => { + callback({ + fills: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.1', + px: '50000', + fee: '5', + time: Date.now(), + }, + ], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + l2Book: jest.fn((_params: any, callback: any) => { + // Simulate l2Book data + setTimeout(() => { + callback({ + coin: _params.coin, + levels: { bids: [], asks: [] }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + bbo: jest.fn((_params: any, callback: any) => { + // Simulate BBO data + setTimeout(() => { + callback({ + coin: _params.coin, + time: Date.now(), + bbo: [ + { px: '49900', sz: '1.5', n: 1 }, + { px: '50100', sz: '2.0', n: 1 }, + ], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + activeAsset: jest.fn((params: any, callback: any) => { + // Simulate activeAsset data (similar to activeAssetCtx) + setTimeout(() => { + callback({ + coin: params.coin, + data: 'test', + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + clearinghouseState: jest.fn((_params: any, callback: any) => { + // Simulate clearinghouseState data for individual subscription + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.1' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + openOrders: jest.fn((_params: any, callback: any) => { + // Simulate openOrders data for individual subscription + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + triggerCondition: '', + triggerPx: '', + children: [], + isPositionTpsl: false, + tif: null, + cloid: null, + }, + ], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + assetCtxs: jest.fn(() => Promise.resolve(mockSubscription)), + spotState: jest.fn((_params: any, _callback: any) => + Promise.resolve(mockSubscription), + ), + }; + + mockWalletAdapter = { + request: jest.fn(), + }; + + // Mock client service + mockSpotClearinghouseState = jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', total: '100.76531791' }], + }); + + mockClientService = { + ensureSubscriptionClient: jest.fn().mockResolvedValue(undefined), + getSubscriptionClient: jest.fn(() => mockSubscriptionClient), + getInfoClient: jest.fn(() => ({ + spotClearinghouseState: mockSpotClearinghouseState, + // Mode-aware fold gate reads userAbstraction; default to unifiedAccount + // so existing spot-fold assertions behave as before the gate was added. + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + })), + isTestnetMode: jest.fn(() => false), + ensureTransportReady: jest.fn().mockResolvedValue(undefined), + getConnectionState: jest.fn(() => 'connected'), + } as any; + + // Mock wallet service + mockWalletService = { + createWalletAdapter: jest.fn(() => mockWalletAdapter), + getUserAddressWithDefault: jest.fn().mockResolvedValue('0x123' as Hex), + } as any; + + service = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + true, // hip3Enabled - test expects webData3 + ); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + describe('OI Cap Subscriptions', () => { + it('subscribes to OI cap updates successfully', async () => { + const mockCallback = jest.fn(); + + const unsubscribe = service.subscribeToOICaps({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.webData3).toHaveBeenCalled(); + expect(typeof unsubscribe).toBe('function'); + }); + + it('immediately provides cached OI caps if available', async () => { + const mockCallback = jest.fn(); + + // Mock webData3 to provide OI caps data + mockSubscriptionClient.webData3.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + perpDexStates: [ + { + clearinghouseState: { assetPositions: [] }, + openOrders: [], + perpsAtOpenInterestCap: ['BTC', 'ETH'], + }, + ], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + // First subscription to populate cache + const unsubscribe1 = service.subscribeToOICaps({ callback: jest.fn() }); + await jest.runAllTimersAsync(); + + // Second subscription should get cached data immediately + const unsubscribe2 = service.subscribeToOICaps({ + callback: mockCallback, + }); + + expect(mockCallback).toHaveBeenCalledWith(['BTC', 'ETH']); + + unsubscribe1(); + unsubscribe2(); + }); + }); + + describe('Account Subscriptions', () => { + it('subscribes to account updates successfully', async () => { + const mockCallback = jest.fn(); + + const unsubscribe = service.subscribeToAccount({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.webData3).toHaveBeenCalled(); + expect(mockCallback).toHaveBeenCalled(); + expect(typeof unsubscribe).toBe('function'); + }); + + it('immediately provides cached account state if available', async () => { + const mockCallback = jest.fn(); + + // First subscription to populate cache + const unsubscribe1 = service.subscribeToAccount({ + callback: jest.fn(), + }); + await jest.runAllTimersAsync(); + + // Second subscription should get cached data immediately + const unsubscribe2 = service.subscribeToAccount({ + callback: mockCallback, + }); + + expect(mockCallback).toHaveBeenCalled(); + + unsubscribe1(); + unsubscribe2(); + }); + }); + + describe('spotState WebSocket Subscription', () => { + it('establishes a spotState subscription on subscribeToAccount', async () => { + const unsubscribe = service.subscribeToAccount({ + callback: jest.fn(), + }); + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.spotState).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.stringMatching(/^0x/) }), + expect.any(Function), + ); + + unsubscribe(); + }); + + it('does not re-subscribe spotState for the same user', async () => { + const unsubscribe1 = service.subscribeToAccount({ + callback: jest.fn(), + }); + const unsubscribe2 = service.subscribeToAccount({ + callback: jest.fn(), + }); + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.spotState).toHaveBeenCalledTimes(1); + + unsubscribe1(); + unsubscribe2(); + }); + + it('re-notifies account subscribers when a spotState push arrives', async () => { + const firstCallback = jest.fn(); + const firstUnsubscribe = service.subscribeToAccount({ + callback: firstCallback, + }); + await jest.runAllTimersAsync(); + + const notifyCallback = jest.fn(); + const unsubscribe = service.subscribeToAccount({ + callback: notifyCallback, + }); + await jest.runAllTimersAsync(); + + const callsBefore = notifyCallback.mock.calls.length; + + const spotListener = mockSubscriptionClient.spotState.mock.calls[0][1]; + spotListener({ + user: '0x123', + spotState: { + balances: [ + { + coin: 'USDC', + token: 0, + hold: '0', + total: '123.45', + entryNtl: '123.45', + }, + ], + }, + }); + + expect(notifyCallback.mock.calls.length).toBeGreaterThan(callsBefore); + + firstUnsubscribe(); + unsubscribe(); + }); + + it('preserves the abstraction REST result when WS spot push arrives first, and re-aggregates with the correct fold', async () => { + // Setup: first userAbstraction call hangs until we manually resolve it. + // This simulates a slow REST response while the WS spot subscription + // pushes a snapshot first, bumping #spotStateGeneration so the in-flight + // refresh would otherwise discard the abstraction result. + let resolveAbstraction: (mode: 'unifiedAccount') => void = jest.fn(); + const abstractionPromise = new Promise<'unifiedAccount'>((resolve) => { + resolveAbstraction = resolve; + }); + let resolveAbstractionStarted: () => void = jest.fn(); + const abstractionStarted = new Promise((resolve) => { + resolveAbstractionStarted = resolve; + }); + const userAbstractionMock = jest.fn().mockImplementationOnce(() => { + resolveAbstractionStarted(); + return abstractionPromise; + }); + + let spotListener: ((event: any) => void) | undefined; + let resolveSpotStateSubscribed: () => void = jest.fn(); + const spotStateSubscribed = new Promise((resolve) => { + resolveSpotStateSubscribed = resolve; + }); + mockSubscriptionClient.spotState.mockImplementationOnce( + (_params: any, callback: any) => { + spotListener = callback; + resolveSpotStateSubscribed(); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockClientService.getInfoClient = jest.fn(() => ({ + spotClearinghouseState: mockSpotClearinghouseState, + userAbstraction: userAbstractionMock, + })) as never; + + const accountCallback = jest.fn(); + const unsubscribe = service.subscribeToAccount({ + callback: accountCallback, + }); + await Promise.all([abstractionStarted, spotStateSubscribed]); + expect(userAbstractionMock).toHaveBeenCalledTimes(1); + + // Simulate the WS spot push arriving before REST userAbstraction + // resolves. The WS callback bumps #spotStateGeneration so the + // in-flight refresh's spot result would be discarded by the + // generation guard. + expect(spotListener).toBeDefined(); + spotListener?.({ + user: '0x123', + spotState: { + balances: [ + { + coin: 'USDC', + token: 0, + hold: '0', + total: '123.45', + entryNtl: '123.45', + }, + ], + }, + }); + await jest.runAllTimersAsync(); + + // Resolve the REST userAbstraction. The refresh path must record the + // mode (it's user-keyed, independent of spot generation) and trigger + // a re-aggregation so the active subscriber sees folded balance — + // not wait for another subscribe/action to repair the state. + accountCallback.mockClear(); + resolveAbstraction('unifiedAccount'); + await jest.runAllTimersAsync(); + + expect(accountCallback).toHaveBeenCalled(); + const recoveredCall = accountCallback.mock.calls.at(-1)?.[0]; + // unifiedAccount → fold=true → spot USDC ($123.45) folds into + // spendable/withdrawable (default $1000 perps + $123.45 spot ≈ $1123.45). + expect(parseFloat(recoveredCall?.spendableBalance)).toBeCloseTo( + 1123.45, + 2, + ); + expect(parseFloat(recoveredCall?.withdrawableBalance)).toBeCloseTo( + 1123.45, + 2, + ); + + // A subsequent subscribe must take the fast path — the cache is now + // sealed for this user, so no redundant userAbstraction REST round-trip. + service.subscribeToAccount({ callback: jest.fn() }); + await jest.runAllTimersAsync(); + expect(userAbstractionMock).toHaveBeenCalledTimes(1); + + unsubscribe(); + }); + + it('ignores spotState events for a different user', async () => { + const unsubscribe = service.subscribeToAccount({ + callback: jest.fn(), + }); + await jest.runAllTimersAsync(); + + const observerCallback = jest.fn(); + const observerUnsubscribe = service.subscribeToAccount({ + callback: observerCallback, + }); + await jest.runAllTimersAsync(); + + const callsBefore = observerCallback.mock.calls.length; + + const spotListener = mockSubscriptionClient.spotState.mock.calls[0][1]; + spotListener({ + user: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + spotState: { balances: [] }, + }); + + expect(observerCallback.mock.calls.length).toBe(callsBefore); + + observerUnsubscribe(); + unsubscribe(); + }); + + it('refreshes abstraction mode per user when spotState ticks overlap account switches', async () => { + const userA = '0xaaa'; + const userB = '0xbbb'; + const accountA = 'eip155:42161:0xaaa' as CaipAccountId; + const accountB = 'eip155:42161:0xbbb' as CaipAccountId; + const spotState = { + balances: [ + { + coin: 'USDC', + token: 0, + hold: '0', + total: '100', + entryNtl: '100', + }, + ], + }; + const spotListeners = new Map void>(); + let resolveUserARefresh: (mode: 'unifiedAccount') => void = () => + undefined; + let userACalls = 0; + let userBCalls = 0; + const userAbstraction = jest.fn(({ user }: { user: string }) => { + const normalizedUser = user.toLowerCase(); + + if (normalizedUser === userA) { + userACalls += 1; + if (userACalls === 1) { + return Promise.resolve('unifiedAccount'); + } + return new Promise<'unifiedAccount'>((resolve) => { + resolveUserARefresh = resolve; + }); + } + + if (normalizedUser === userB) { + userBCalls += 1; + if (userBCalls === 1) { + return Promise.reject(new Error('transient userAbstraction error')); + } + return Promise.resolve('disabled'); + } + + return Promise.resolve('unifiedAccount'); + }); + + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', + totalBalance: '10100.00', + marginUsed: '500.00', + unrealizedPnl: '100.00', + returnOnEquity: '20.0', + })); + mockSpotClearinghouseState.mockResolvedValue(spotState); + mockClientService.getInfoClient.mockReturnValue({ + spotClearinghouseState: mockSpotClearinghouseState, + userAbstraction, + } as any); + mockWalletService.getUserAddressWithDefault.mockImplementation( + async (accountId?: CaipAccountId) => + accountId === accountB ? (userB as Hex) : (userA as Hex), + ); + mockSubscriptionClient.spotState.mockImplementation( + (_params: any, callback: any) => { + spotListeners.set(_params.user.toLowerCase(), callback); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribeA = service.subscribeToAccount({ + accountId: accountA, + callback: jest.fn(), + }); + await jest.runAllTimersAsync(); + + jest.advanceTimersByTime(ABSTRACTION_MODE_REFRESH_THROTTLE_MS + 1); + spotListeners.get(userA)?.({ user: userA, spotState }); + expect(userACalls).toBe(2); + + const callbackB = jest.fn(); + const unsubscribeB = service.subscribeToAccount({ + accountId: accountB, + callback: callbackB, + }); + await jest.runAllTimersAsync(); + + const bCallsBeforeTick = userBCalls; + spotListeners.get(userB)?.({ user: userB, spotState }); + await jest.runAllTimersAsync(); + + expect(bCallsBeforeTick).toBe(1); + expect(userBCalls).toBe(2); + expect(userAbstraction).toHaveBeenLastCalledWith({ user: userB }); + expect(callbackB.mock.calls.at(-1)[0].spendableBalance).toBe('1000'); + expect(callbackB.mock.calls.at(-1)[0].withdrawableBalance).toBe('1000'); + + resolveUserARefresh('unifiedAccount'); + await jest.runAllTimersAsync(); + + unsubscribeB(); + unsubscribeA(); + }); + + it('refreshes abstraction mode on the first spotState tick even inside the throttle window', async () => { + const user = '0xaaa'; + const accountId = 'eip155:42161:0xaaa' as CaipAccountId; + const callback = jest.fn(); + const spotState = { + balances: [ + { + coin: 'USDC', + token: 0, + hold: '0', + total: '100', + entryNtl: '100', + }, + ], + }; + const userAbstraction = jest + .fn() + .mockResolvedValueOnce('unifiedAccount') + .mockResolvedValueOnce('disabled'); + + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', + totalBalance: '10100.00', + marginUsed: '500.00', + unrealizedPnl: '100.00', + returnOnEquity: '20.0', + })); + mockSpotClearinghouseState.mockResolvedValue(spotState); + mockClientService.getInfoClient.mockReturnValue({ + spotClearinghouseState: mockSpotClearinghouseState, + userAbstraction, + } as any); + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + user as Hex, + ); + + service.subscribeToAccount({ accountId, callback }); + await jest.runAllTimersAsync(); + + expect(userAbstraction).toHaveBeenCalledTimes(1); + + const spotListener = mockSubscriptionClient.spotState.mock.calls[0][1]; + spotListener({ user, spotState }); + await jest.runAllTimersAsync(); + + expect(userAbstraction).toHaveBeenCalledTimes(2); + expect(userAbstraction).toHaveBeenLastCalledWith({ user }); + }); + + it('unsubscribes spotState when the last account subscriber leaves', async () => { + const unsubSpot = jest.fn().mockResolvedValue(undefined); + mockSubscriptionClient.spotState.mockResolvedValueOnce({ + unsubscribe: unsubSpot, + }); + + const unsubscribe = service.subscribeToAccount({ + callback: jest.fn(), + }); + await jest.runAllTimersAsync(); + + unsubscribe(); + await jest.runAllTimersAsync(); + + expect(unsubSpot).toHaveBeenCalled(); + }); + }); + + describe('setUserAbstractionMode', () => { + it('does not throw for an address with no prior cache entry', async () => { + expect(() => + service.setUserAbstractionMode('0x123', 'unifiedAccount'), + ).not.toThrow(); + }); + + it('lowercases the key so checksummed addresses hit the cached entry', async () => { + expect(() => + service.setUserAbstractionMode( + '0xABCDEF1234567890ABCDEF1234567890ABCDEF12', + 'unifiedAccount', + ), + ).not.toThrow(); + }); + + it('flips the fold state and notifies subscribers when the mode changes', async () => { + // Start without a resolved mode — the spot WS push and REST fetch run + // through the standard subscribeToAccount path. The default mock + // resolves userAbstraction = 'unifiedAccount' so the initial subscribe + // already records that mode and folds spot. Setting back to + // dexAbstraction should flip the fold off and re-notify. + mockClientService.getInfoClient = jest.fn(() => ({ + spotClearinghouseState: mockSpotClearinghouseState, + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + })) as never; + + const accountCallback = jest.fn(); + const unsubscribe = service.subscribeToAccount({ + callback: accountCallback, + }); + await jest.runAllTimersAsync(); + + expect(accountCallback).toHaveBeenCalled(); + accountCallback.mockClear(); + + // Switch the recorded mode to dexAbstraction (no fold). Account state + // hash flips because spendable/withdrawable drop the folded spot. + service.setUserAbstractionMode('0x123', 'dexAbstraction'); + await jest.runAllTimersAsync(); + + expect(accountCallback).toHaveBeenCalled(); + const lastCall = accountCallback.mock.calls.at(-1)?.[0]; + expect(lastCall?.spendableBalance).toBeDefined(); + expect(lastCall?.withdrawableBalance).toBeDefined(); + + unsubscribe(); + }); + }); + + describe('userAbstraction fetch failure handling', () => { + it('does not seal the spot cache when userAbstraction fails, so the next refresh retries', async () => { + // Without this guard, a transient userAbstraction failure leaves + // #cachedSpotStateUserAddress set, the early-return in #ensureSpotState + // takes the fast path forever, and Standard / dexAbstraction users + // keep seeing spot folded into availableToTradeBalance via the + // fail-open Unified default. + const userAbstractionMock = jest + .fn() + .mockRejectedValueOnce(new Error('transient HL outage')) + .mockResolvedValueOnce('dexAbstraction'); + + mockClientService.getInfoClient = jest.fn(() => ({ + spotClearinghouseState: mockSpotClearinghouseState, + userAbstraction: userAbstractionMock, + })) as never; + + const unsub1 = service.subscribeToAccount({ callback: jest.fn() }); + await jest.runAllTimersAsync(); + expect(userAbstractionMock).toHaveBeenCalledTimes(1); + + // Second subscribe (same user) must trigger another refresh because + // the prior failure left the cache unsealed. + const unsub2 = service.subscribeToAccount({ callback: jest.fn() }); + await jest.runAllTimersAsync(); + expect(userAbstractionMock).toHaveBeenCalledTimes(2); + + unsub1(); + unsub2(); + }); + + it('seals the cache normally once a prior abstraction mode has been resolved', async () => { + // Sanity check: when userAbstraction has already resolved successfully, + // a subsequent refresh failure must not force pointless retries. + const userAbstractionMock = jest + .fn() + .mockResolvedValueOnce('dexAbstraction') + .mockRejectedValueOnce(new Error('transient HL outage')); + + mockClientService.getInfoClient = jest.fn(() => ({ + spotClearinghouseState: mockSpotClearinghouseState, + userAbstraction: userAbstractionMock, + })) as never; + + const unsub1 = service.subscribeToAccount({ callback: jest.fn() }); + await jest.runAllTimersAsync(); + expect(userAbstractionMock).toHaveBeenCalledTimes(1); + + // Subsequent subscribe takes the early-return path; the second mock + // entry (the rejection) is never consumed. + const unsub2 = service.subscribeToAccount({ callback: jest.fn() }); + await jest.runAllTimersAsync(); + expect(userAbstractionMock).toHaveBeenCalledTimes(1); + + unsub1(); + unsub2(); + }); + }); + + describe('spot-adjusted account balance parity', () => { + it('includes spot balance exactly once in streamed totalBalance across multiple DEXs', async () => { + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + spendableBalance: '0', + withdrawableBalance: '0', + totalBalance: '0', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + })); + + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [], + marginSummary: { + accountValue: '0', + totalMarginUsed: '0', + }, + withdrawable: '0', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => callback({ dex: _params.dex || '', orders: [] }), 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const hip3Service = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + true, + ); + + await hip3Service.updateFeatureFlags(true, ['xyz'], [], []); + + const mockCallback = jest.fn(); + const unsubscribe = hip3Service.subscribeToAccount({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalled(); + const accountState = mockCallback.mock.calls.at(-1)[0]; + // Unified-mode default: spot USDC folds into total, spendable, and + // withdrawable (all three carry the same value when perps balances + // are zero). Per-DEX subAccountBreakdown entries stay perps-only — + // the fold is applied once at the aggregation level, not per DEX. + expect(accountState.totalBalance).toBe('100.76531791'); + expect(accountState.spendableBalance).toBe('100.76531791'); + expect(accountState.withdrawableBalance).toBe('100.76531791'); + expect(accountState.subAccountBreakdown).toEqual({ + main: { + spendableBalance: '0', + withdrawableBalance: '0', + totalBalance: '0', + }, + xyz: { + spendableBalance: '0', + withdrawableBalance: '0', + totalBalance: '0', + }, + }); + expect(mockSpotClearinghouseState).toHaveBeenCalledTimes(1); + + unsubscribe(); + }); + + it('does not use non-USDC spot coins in streamed spendable/withdrawable', async () => { + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + spendableBalance: '0', + withdrawableBalance: '0', + totalBalance: '0', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + })); + + const infoClient = { + spotClearinghouseState: jest.fn().mockResolvedValue({ + balances: [ + { coin: 'mUSD', hold: '10', total: '100' }, + { coin: 'HYPE', hold: '0', total: '999' }, + ], + }), + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + }; + mockClientService.getInfoClient = jest.fn(() => infoClient) as never; + + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [], + marginSummary: { + accountValue: '0', + totalMarginUsed: '0', + }, + withdrawable: '0', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => callback({ dex: _params.dex || '', orders: [] }), 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const hip3Service = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + true, + ); + + await hip3Service.updateFeatureFlags(true, ['xyz'], [], []); + + const mockCallback = jest.fn(); + const unsubscribe = hip3Service.subscribeToAccount({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + const accountState = mockCallback.mock.calls.at(-1)[0]; + expect(accountState.spendableBalance).toBe('0'); + expect(accountState.withdrawableBalance).toBe('0'); + expect(accountState.totalBalance).toBe('0'); + + unsubscribe(); + }); + + it('includes spot balance in webData2 (single-DEX) account updates without flickering', async () => { + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + spendableBalance: '50', + withdrawableBalance: '50', + totalBalance: '200', + marginUsed: '10', + unrealizedPnl: '5', + returnOnEquity: '0.05', + })); + + let webData2Callback: ((data: any) => void) | undefined; + mockSubscriptionClient.webData2.mockImplementation( + (_params: any, callback: any) => { + webData2Callback = callback; + setTimeout(() => { + callback({ + clearinghouseState: { + assetPositions: [], + marginSummary: { + accountValue: '200', + totalMarginUsed: '10', + }, + withdrawable: '50', + }, + openOrders: [], + perpsAtOpenInterestCap: [], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const singleDexService = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + false, + ); + + const mockCallback = jest.fn(); + const unsubscribe = singleDexService.subscribeToAccount({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalled(); + const firstUpdate = mockCallback.mock.calls.at(-1)[0]; + // Unified-mode default: freeSpot ($100.77) folds into spendable and + // withdrawable on top of perps-side values, and into total. + // total = perps.accountValue (200) + spot (100.77) = 300.77 + // spendable = perps.withdrawable (50) + spot (100.77) = 150.77 + // withdrawable = perps.withdrawable (50) + spot (100.77) = 150.77 + expect(firstUpdate.totalBalance).toBe('300.76531791'); + expect(firstUpdate.spendableBalance).toBe('150.76531791'); + expect(firstUpdate.withdrawableBalance).toBe('150.76531791'); + + // Simulate a second WebSocket tick — should still include spot balance, + // not revert to perps-only 200. + mockCallback.mockClear(); + expect(webData2Callback).toBeDefined(); + + webData2Callback!({ + clearinghouseState: { + assetPositions: [], + marginSummary: { + accountValue: '200', + totalMarginUsed: '10', + }, + withdrawable: '50', + }, + openOrders: [], + perpsAtOpenInterestCap: [], + }); + + await jest.runAllTimersAsync(); + + if (mockCallback.mock.calls.length > 0) { + const secondUpdate = mockCallback.mock.calls.at(-1)[0]; + expect(secondUpdate.totalBalance).toBe('300.76531791'); + } + + unsubscribe(); + }); + }); + + describe('aggregateAccountStates - returnOnEquity calculation', () => { + it('calculates positive ROE when unrealizedPnl is positive', async () => { + // Override the adapter mock + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + spendableBalance: '100', + withdrawableBalance: '100', + totalBalance: '1100', + marginUsed: '1000', + unrealizedPnl: '100', + returnOnEquity: '10.0', + })); + + const mockCallback = jest.fn(); + + // Mock webData3 + mockSubscriptionClient.webData3.mockImplementation( + (_params: any, callback: any) => { + const mockData = { + perpDexStates: [ + { + clearinghouseState: { assetPositions: [] }, + openOrders: [], + perpsAtOpenInterestCap: [], + }, + ], + }; + + setTimeout(() => callback(mockData), 10); + return { unsubscribe: jest.fn() }; + }, + ); + + const unsubscribe = service.subscribeToAccount({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalled(); + const accountState = mockCallback.mock.calls[0][0]; + expect(accountState.marginUsed).toBe('1000'); + expect(accountState.unrealizedPnl).toBe('100'); + expect(accountState.returnOnEquity).toBe('10'); + + unsubscribe(); + }); + + it('calculates negative ROE when unrealizedPnl is negative', async () => { + // Override the adapter mock + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + spendableBalance: '0', + withdrawableBalance: '0', + totalBalance: '950', + marginUsed: '1000', + unrealizedPnl: '-50', + returnOnEquity: '-5.0', + })); + + const mockCallback = jest.fn(); + + // Mock webData3 + mockSubscriptionClient.webData3.mockImplementation( + (_params: any, callback: any) => { + const mockData = { + perpDexStates: [ + { + clearinghouseState: { assetPositions: [] }, + openOrders: [], + perpsAtOpenInterestCap: [], + }, + ], + }; + + setTimeout(() => callback(mockData), 10); + return { unsubscribe: jest.fn() }; + }, + ); + + const unsubscribe = service.subscribeToAccount({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalled(); + const accountState = mockCallback.mock.calls[0][0]; + expect(accountState.marginUsed).toBe('1000'); + expect(accountState.unrealizedPnl).toBe('-50'); + expect(accountState.returnOnEquity).toBe('-5'); + + unsubscribe(); + }); + + it('returns zero ROE when marginUsed is zero', async () => { + // Override the adapter mock + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + spendableBalance: '1000', + withdrawableBalance: '1000', + totalBalance: '1000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + })); + + const mockCallback = jest.fn(); + + // Mock webData3 + mockSubscriptionClient.webData3.mockImplementation( + (_params: any, callback: any) => { + const mockData = { + perpDexStates: [ + { + clearinghouseState: { assetPositions: [] }, + openOrders: [], + perpsAtOpenInterestCap: [], + }, + ], + }; + + setTimeout(() => callback(mockData), 10); + return { unsubscribe: jest.fn() }; + }, + ); + + const unsubscribe = service.subscribeToAccount({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalled(); + const accountState = mockCallback.mock.calls[0][0]; + expect(accountState.marginUsed).toBe('0'); + expect(accountState.unrealizedPnl).toBe('0'); + expect(accountState.returnOnEquity).toBe('0'); + + unsubscribe(); + }); + + it('calculates correct ROE with mixed profit and loss positions', async () => { + // Override the adapter mock + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + spendableBalance: '75', + withdrawableBalance: '75', + totalBalance: '1575', + marginUsed: '1500', + unrealizedPnl: '75', + returnOnEquity: '5.0', + })); + + const mockCallback = jest.fn(); + + // Mock webData3 - simulates account with multiple positions + // marginUsed=1500, unrealizedPnl=75 → ROE=5.0% + mockSubscriptionClient.webData3.mockImplementation( + (_params: any, callback: any) => { + const mockData = { + perpDexStates: [ + { + clearinghouseState: { assetPositions: [] }, + openOrders: [], + perpsAtOpenInterestCap: [], + }, + ], + }; + + setTimeout(() => callback(mockData), 10); + return { unsubscribe: jest.fn() }; + }, + ); + + const unsubscribe = service.subscribeToAccount({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalled(); + const accountState = mockCallback.mock.calls[0][0]; + expect(accountState.marginUsed).toBe('1500'); + expect(accountState.unrealizedPnl).toBe('75'); + expect(accountState.returnOnEquity).toBe('5'); + + unsubscribe(); + }); + + it('calculates high ROE with large percentage gains', async () => { + // Override the adapter mock + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + spendableBalance: '200', + withdrawableBalance: '200', + totalBalance: '300', + marginUsed: '100', + unrealizedPnl: '200', + returnOnEquity: '200.0', + })); + + const mockCallback = jest.fn(); + + // Mock webData3 + mockSubscriptionClient.webData3.mockImplementation( + (_params: any, callback: any) => { + const mockData = { + perpDexStates: [ + { + clearinghouseState: { assetPositions: [] }, + openOrders: [], + perpsAtOpenInterestCap: [], + }, + ], + }; + + setTimeout(() => callback(mockData), 10); + return { unsubscribe: jest.fn() }; + }, + ); + + const unsubscribe = service.subscribeToAccount({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalled(); + const accountState = mockCallback.mock.calls[0][0]; + expect(accountState.marginUsed).toBe('100'); + expect(accountState.unrealizedPnl).toBe('200'); + expect(accountState.returnOnEquity).toBe('200'); + + unsubscribe(); + }); + + it('stores raw ROE without rounding', async () => { + // Override the adapter mock + jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ + spendableBalance: '100', + withdrawableBalance: '100', + totalBalance: '433', + marginUsed: '333', + unrealizedPnl: '100', + returnOnEquity: '30.0', + })); + + const mockCallback = jest.fn(); + + // Mock webData3 + mockSubscriptionClient.webData3.mockImplementation( + (_params: any, callback: any) => { + const mockData = { + perpDexStates: [ + { + clearinghouseState: { assetPositions: [] }, + openOrders: [], + perpsAtOpenInterestCap: [], + }, + ], + }; + + setTimeout(() => callback(mockData), 10); + return { unsubscribe: jest.fn() }; + }, + ); + + const unsubscribe = service.subscribeToAccount({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalled(); + const accountState = mockCallback.mock.calls[0][0]; + expect(accountState.marginUsed).toBe('333'); + expect(accountState.unrealizedPnl).toBe('100'); + expect(accountState.returnOnEquity).toBe('30'); + + unsubscribe(); + }); + }); + + describe('restoreSubscriptions', () => { + it('restores allMids subscription when price subscribers exist', async () => { + const callback = jest.fn(); + const mockUnsubscribe = jest.fn(); + const mockSubscription = { unsubscribe: mockUnsubscribe }; + + // Subscribe to prices first + mockSubscriptionClient.allMids.mockImplementation((cb: any) => { + setTimeout(() => { + cb({ mids: { BTC: '50000' } }); + }, 10); + return Promise.resolve(mockSubscription); + }); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback, + }); + + await jest.runAllTimersAsync(); + + // Clear the subscription reference to simulate reconnection + (service as any).globalAllMidsSubscription = undefined; + (service as any).globalAllMidsPromise = undefined; + + // Restore subscriptions + await service.restoreSubscriptions(); + + // Verify allMids subscription was re-established + expect(mockSubscriptionClient.allMids).toHaveBeenCalledTimes(2); + + unsubscribe(); + }); + + it('does not restore allMids subscription when no price subscribers exist', async () => { + // No subscriptions created + + await service.restoreSubscriptions(); + + // Verify allMids was not called + expect(mockSubscriptionClient.allMids).not.toHaveBeenCalled(); + }); + + // TODO: Refactor to test restoreSubscriptions through public disconnect/reconnect API + + it.skip('restores webData3 subscription when user data subscribers exist', async () => { + const positionCallback = jest.fn(); + const mockUnsubscribe = jest.fn().mockResolvedValue(undefined); + + // Simulate DEX discovery to skip the wait + await service.updateFeatureFlags(true, [''], [], []); + + mockSubscriptionClient.webData3.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + perpDexStates: [ + { + clearinghouseState: { assetPositions: [] }, + openOrders: [], + perpDexStates: [], + }, + ], + }); + }, 10); + return Promise.resolve({ unsubscribe: mockUnsubscribe }); + }, + ); + + const unsubscribe = await service.subscribeToPositions({ + callback: positionCallback, + }); + + await jest.runAllTimersAsync(); + + // Clear subscription references to simulate reconnection + (service as any).webData3Subscriptions.clear(); + (service as any).webData3SubscriptionPromise = undefined; + + // Restore subscriptions + await service.restoreSubscriptions(); + + // Verify webData3 subscription was re-established + expect(mockSubscriptionClient.webData3).toHaveBeenCalledTimes(2); + + // Cleanup + unsubscribe(); + }); + + // TODO: Refactor to test through public disconnect/reconnect API + + it.skip('restores activeAsset subscriptions for all market data subscribers', async () => { + const marketDataCallback = jest.fn(); + const mockUnsubscribe = jest.fn(); + const mockSubscription = { unsubscribe: mockUnsubscribe }; + + mockSubscriptionClient.activeAssetCtx.mockImplementation( + (params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: params.coin, + ctx: { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', + dayNtlVlm: '50000000', + oraclePx: '50100', + midPx: '50000', + }, + }); + }, 10); + return Promise.resolve(mockSubscription); + }, + ); + + // Subscribe to market data for multiple symbols + const unsubscribe1 = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: marketDataCallback, + includeMarketData: true, + }); + const unsubscribe2 = await service.subscribeToPrices({ + symbols: ['ETH'], + callback: marketDataCallback, + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + // Clear subscriptions to simulate reconnection + (service as any).globalActiveAssetSubscriptions.clear(); + + // Restore subscriptions + await service.restoreSubscriptions(); + + // Verify activeAssetCtx was called for each symbol (2 initial + 2 restored) + expect(mockSubscriptionClient.activeAssetCtx).toHaveBeenCalledTimes(4); + + unsubscribe1(); + unsubscribe2(); + }); + + // TODO: Refactor to test through public disconnect/reconnect API + + it.skip('clears BBO subscriptions during restoration', async () => { + const mockUnsubscribe = jest.fn().mockResolvedValue(undefined); + const mockSubscription = { unsubscribe: mockUnsubscribe }; + let subscriptionCallCount = 0; + + mockSubscriptionClient.bbo.mockImplementation( + (_params: any, bboCallback: any) => { + subscriptionCallCount++; + setTimeout(() => { + bboCallback({ + coin: _params.coin, + time: Date.now(), + bbo: [ + { px: '49900', sz: '1.5', n: 1 }, + { px: '50100', sz: '2.0', n: 1 }, + ], + }); + }, 10); + return Promise.resolve(mockSubscription); + }, + ); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: jest.fn(), + includeOrderBook: true, + }); + + await jest.runAllTimersAsync(); + + // Verify initial subscription was created + expect((service as any).globalBboSubscriptions.size).toBe(1); + const initialCallCount = subscriptionCallCount; + + // Set up a different subscription reference to verify it's cleared + const oldSubscription = { unsubscribe: jest.fn() }; + (service as any).globalBboSubscriptions.set('BTC', oldSubscription); + + // Restore subscriptions + await service.restoreSubscriptions(); + + await jest.runAllTimersAsync(); + + // Verify old subscription was cleared and new one was re-established + // The map should have the new subscription, not the old one + const currentSubscription = (service as any).globalBboSubscriptions.get( + 'BTC', + ); + expect(currentSubscription).toBeDefined(); + expect(currentSubscription).not.toBe(oldSubscription); + // Verify bbo was called again to re-establish the subscription + expect(subscriptionCallCount).toBeGreaterThan(initialCallCount); + + unsubscribe(); + }); + + it('schedules retry when assetCtxs restoration fails', async () => { + jest.mocked(parseAssetName).mockImplementation((symbol: string) => ({ + symbol, + dex: symbol === 'BTC:UNISWAP' ? 'UNISWAP' : null, + })); + + mockClientService.getInfoClient = jest.fn( + () => + ({ + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC:UNISWAP' }], + }), + }) as any, + ); + + mockSubscriptionClient.assetCtxs = jest + .fn() + .mockResolvedValueOnce({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }) + .mockRejectedValueOnce(new Error('Subscription failed')) + .mockResolvedValueOnce({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC:UNISWAP'], + callback: jest.fn(), + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + await expect(service.restoreSubscriptions()).resolves.not.toThrow(); + expect(mockSubscriptionClient.assetCtxs).toHaveBeenCalledTimes(2); + + await jest.advanceTimersByTimeAsync(1000); + + expect(mockSubscriptionClient.assetCtxs).toHaveBeenCalledTimes(3); + expect(mockDeps.logger.error).toHaveBeenCalled(); + + unsubscribe(); + }); + + // TODO: Refactor to test through public disconnect/reconnect API + + it.skip('restores all subscription types when multiple subscriber types exist', async () => { + const priceCallback = jest.fn(); + const positionCallback = jest.fn(); + const allTypesMarketDataCallback = jest.fn(); + const mockUnsubscribe = jest.fn().mockResolvedValue(undefined); + const mockSubscription = { unsubscribe: mockUnsubscribe }; + + // Simulate DEX discovery to skip the wait + await service.updateFeatureFlags(true, [''], [], []); + + mockSubscriptionClient.allMids.mockImplementation((cb: any) => { + setTimeout(() => { + cb({ mids: { BTC: '50000' } }); + }, 10); + return Promise.resolve(mockSubscription); + }); + + mockSubscriptionClient.webData3.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + perpDexStates: [ + { + clearinghouseState: { assetPositions: [] }, + openOrders: [], + perpDexStates: [], + }, + ], + }); + }, 10); + return Promise.resolve(mockSubscription); + }, + ); + + mockSubscriptionClient.activeAssetCtx.mockImplementation( + (params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: params.coin, + ctx: { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', + dayNtlVlm: '50000000', + oraclePx: '50100', + midPx: '50000', + }, + }); + }, 10); + return Promise.resolve(mockSubscription); + }, + ); + + // Create subscriptions for all types + const unsubscribe1 = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: priceCallback, + }); + const unsubscribe2 = await service.subscribeToPositions({ + callback: positionCallback, + }); + const unsubscribe3 = await service.subscribeToPrices({ + symbols: ['ETH'], + callback: allTypesMarketDataCallback, + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + // Clear all subscription references + (service as any).globalAllMidsSubscription = undefined; + (service as any).globalAllMidsPromise = undefined; + (service as any).webData3Subscriptions.clear(); + (service as any).webData3SubscriptionPromise = undefined; + (service as any).globalActiveAssetSubscriptions.clear(); + + // Restore all subscriptions + await service.restoreSubscriptions(); + + // Verify all subscription types were restored + expect(mockSubscriptionClient.allMids).toHaveBeenCalledTimes(2); + expect(mockSubscriptionClient.webData3).toHaveBeenCalledTimes(2); + expect(mockSubscriptionClient.activeAssetCtx).toHaveBeenCalledTimes(2); + + unsubscribe1(); + unsubscribe2(); + unsubscribe3(); + }); + }); + + describe('subscribeToOrderBook (L2Book)', () => { + it('should subscribe to L2Book with correct params', async () => { + const mockCallback = jest.fn(); + const mockL2BookSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + mockSubscriptionClient.l2Book.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: 'BTC', + levels: [ + [{ px: '49900', sz: '1.5', n: 3 }], + [{ px: '50100', sz: '2.0', n: 5 }], + ], + }); + }, 0); + return Promise.resolve(mockL2BookSubscription); + }, + ); + + const params: SubscribeOrderBookParams = { + symbol: 'BTC', + levels: 10, + nSigFigs: 5, + callback: mockCallback, + }; + + const unsubscribe = service.subscribeToOrderBook(params); + + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.l2Book).toHaveBeenCalledWith( + { coin: 'BTC', nSigFigs: 5, mantissa: undefined }, + expect.any(Function), + ); + + expect(typeof unsubscribe).toBe('function'); + }); + + it('should process L2Book data and call callback with OrderBookData', async () => { + const mockCallback = jest.fn(); + const mockL2BookSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + mockSubscriptionClient.l2Book.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: 'BTC', + levels: [ + [ + { px: '49900', sz: '1.0', n: 2 }, + { px: '49800', sz: '2.0', n: 3 }, + ], + [ + { px: '50100', sz: '1.5', n: 4 }, + { px: '50200', sz: '2.5', n: 5 }, + ], + ], + }); + }, 0); + return Promise.resolve(mockL2BookSubscription); + }, + ); + + service.subscribeToOrderBook({ + symbol: 'BTC', + levels: 10, + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalledWith( + expect.objectContaining({ + bids: expect.arrayContaining([ + expect.objectContaining({ + price: '49900', + size: '1.0', + }), + ]), + asks: expect.arrayContaining([ + expect.objectContaining({ + price: '50100', + size: '1.5', + }), + ]), + spread: expect.any(String), + spreadPercentage: expect.any(String), + midPrice: expect.any(String), + lastUpdated: expect.any(Number), + maxTotal: expect.any(String), + }), + ); + }); + + it('should unsubscribe when cleanup function is called', async () => { + const mockCallback = jest.fn(); + const mockUnsubscribe = jest.fn().mockResolvedValue(undefined); + + mockSubscriptionClient.l2Book.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: 'BTC', + levels: [ + [{ px: '49900', sz: '1.5', n: 3 }], + [{ px: '50100', sz: '2.0', n: 5 }], + ], + }); + }, 0); + return Promise.resolve({ unsubscribe: mockUnsubscribe }); + }, + ); + + const unsubscribe = service.subscribeToOrderBook({ + symbol: 'BTC', + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + // Unsubscribe + unsubscribe(); + + await jest.runAllTimersAsync(); + + expect(mockUnsubscribe).toHaveBeenCalled(); + }); + + it('should call onError callback when subscription fails', async () => { + const mockCallback = jest.fn(); + const mockOnError = jest.fn(); + + mockSubscriptionClient.l2Book.mockRejectedValue( + new Error('L2Book subscription failed'), + ); + + service.subscribeToOrderBook({ + symbol: 'BTC', + callback: mockCallback, + onError: mockOnError, + }); + + await jest.runAllTimersAsync(); + + expect(mockOnError).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'L2Book subscription failed', + }), + ); + }); + + it('should handle subscription client not available', async () => { + mockClientService.getSubscriptionClient.mockReturnValue(undefined); + + const mockCallback = jest.fn(); + const mockOnError = jest.fn(); + + const unsubscribe = service.subscribeToOrderBook({ + symbol: 'BTC', + callback: mockCallback, + onError: mockOnError, + }); + + await jest.runAllTimersAsync(); + + // Should call onError with appropriate message + expect(mockOnError).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Subscription client not available', + }), + ); + + // Should return a no-op unsubscribe function + expect(typeof unsubscribe).toBe('function'); + expect(mockSubscriptionClient.l2Book).not.toHaveBeenCalled(); + }); + + it('should handle missing levels gracefully', async () => { + const mockCallback = jest.fn(); + + mockSubscriptionClient.l2Book.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: 'BTC', + levels: undefined, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + service.subscribeToOrderBook({ + symbol: 'BTC', + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + // Should not crash - callback should not be called for invalid data + // (the implementation checks for data?.levels being truthy) + expect(mockCallback).not.toHaveBeenCalled(); + }); + + it('should ignore data for different coins', async () => { + const mockCallback = jest.fn(); + + mockSubscriptionClient.l2Book.mockImplementation( + (_params: any, callback: any) => { + // First send data for wrong coin + setTimeout(() => { + callback({ + coin: 'ETH', + levels: [ + [{ px: '2900', sz: '10', n: 1 }], + [{ px: '3000', sz: '20', n: 1 }], + ], + }); + }, 0); + // Then send data for correct coin + setTimeout(() => { + callback({ + coin: 'BTC', + levels: [ + [{ px: '49900', sz: '1.5', n: 3 }], + [{ px: '50100', sz: '2.0', n: 5 }], + ], + }); + }, 10); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + service.subscribeToOrderBook({ + symbol: 'BTC', + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + // Should only receive data for BTC, not ETH + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenCalledWith( + expect.objectContaining({ + bids: expect.arrayContaining([ + expect.objectContaining({ price: '49900' }), + ]), + }), + ); + }); + + it('should pass mantissa parameter when provided', async () => { + const mockCallback = jest.fn(); + + mockSubscriptionClient.l2Book.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: 'BTC', + levels: [ + [{ px: '49900', sz: '1.5', n: 3 }], + [{ px: '50100', sz: '2.0', n: 5 }], + ], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + service.subscribeToOrderBook({ + symbol: 'BTC', + nSigFigs: 5, + mantissa: 2, + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.l2Book).toHaveBeenCalledWith( + { coin: 'BTC', nSigFigs: 5, mantissa: 2 }, + expect.any(Function), + ); + }); + + it('should calculate cumulative totals correctly', async () => { + const mockCallback = jest.fn(); + + mockSubscriptionClient.l2Book.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: 'BTC', + levels: [ + [ + { px: '50000', sz: '1.0', n: 1 }, + { px: '49900', sz: '2.0', n: 1 }, + { px: '49800', sz: '3.0', n: 1 }, + ], + [ + { px: '50100', sz: '0.5', n: 1 }, + { px: '50200', sz: '1.5', n: 1 }, + ], + ], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + service.subscribeToOrderBook({ + symbol: 'BTC', + levels: 10, + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + const orderBookData = mockCallback.mock.calls[0][0]; + + // Verify cumulative bid totals: 1.0, 3.0, 6.0 + expect(parseFloat(orderBookData.bids[0].total)).toBe(1); + expect(parseFloat(orderBookData.bids[1].total)).toBe(3); + expect(parseFloat(orderBookData.bids[2].total)).toBe(6); + + // Verify cumulative ask totals: 0.5, 2.0 + expect(parseFloat(orderBookData.asks[0].total)).toBe(0.5); + expect(parseFloat(orderBookData.asks[1].total)).toBe(2); + + // Verify maxTotal is the larger of bid/ask cumulative totals + expect(parseFloat(orderBookData.maxTotal)).toBe(6); + }); + + it('should limit levels based on the levels parameter', async () => { + const mockCallback = jest.fn(); + + mockSubscriptionClient.l2Book.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: 'BTC', + levels: [ + [ + { px: '50000', sz: '1.0', n: 1 }, + { px: '49900', sz: '2.0', n: 1 }, + { px: '49800', sz: '3.0', n: 1 }, + { px: '49700', sz: '4.0', n: 1 }, + { px: '49600', sz: '5.0', n: 1 }, + ], + [ + { px: '50100', sz: '0.5', n: 1 }, + { px: '50200', sz: '1.5', n: 1 }, + { px: '50300', sz: '2.5', n: 1 }, + { px: '50400', sz: '3.5', n: 1 }, + { px: '50500', sz: '4.5', n: 1 }, + ], + ], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + service.subscribeToOrderBook({ + symbol: 'BTC', + levels: 3, + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + const orderBookData = mockCallback.mock.calls[0][0]; + + // Should only have 3 levels on each side + expect(orderBookData.bids.length).toBe(3); + expect(orderBookData.asks.length).toBe(3); + }); + }); + + describe('Subscription Race Guards (#28141)', () => { + it('unsubscribes stale assetCtxs when a newer pending promise exists', async () => { + jest.mocked(parseAssetName).mockImplementation((symbol: string) => ({ + symbol, + dex: symbol === 'BTC:UNISWAP' ? 'UNISWAP' : null, + })); + service.setDexMetaCache('UNISWAP', { + universe: [{ name: 'BTC:UNISWAP' }], + } as any); + + let resolveFirst: (sub: MockSubscription) => void = () => undefined; + const firstMockSub: MockSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + const secondMockSub: MockSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + mockSubscriptionClient.allMids.mockResolvedValue({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + + let callCount = 0; + mockSubscriptionClient.assetCtxs.mockImplementation(() => { + callCount += 1; + if (callCount === 1) { + return new Promise((resolve) => { + resolveFirst = resolve; + }); + } + return Promise.resolve(secondMockSub); + }); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC:UNISWAP'], + callback: jest.fn(), + includeMarketData: true, + }); + await jest.runAllTimersAsync(); + + unsubscribe(); + + await service.subscribeToPrices({ + symbols: ['BTC:UNISWAP'], + callback: jest.fn(), + includeMarketData: true, + }); + await jest.runAllTimersAsync(); + + resolveFirst(firstMockSub); + await jest.runAllTimersAsync(); + + expect(firstMockSub.unsubscribe).toHaveBeenCalled(); + expect(secondMockSub.unsubscribe).not.toHaveBeenCalled(); + }); + + it('unsubscribes stale BBO when a newer pending promise exists', async () => { + let resolveFirst: (sub: MockSubscription) => void = () => undefined; + const firstMockSub: MockSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + const secondMockSub: MockSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + let callCount = 0; + mockSubscriptionClient.bbo.mockImplementation(() => { + callCount += 1; + if (callCount === 1) { + return new Promise((resolve) => { + resolveFirst = resolve; + }); + } + + return Promise.resolve(secondMockSub); + }); + + const unsubscribe1 = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: jest.fn(), + includeOrderBook: true, + }); + await jest.runAllTimersAsync(); + + unsubscribe1(); + + await service.subscribeToPrices({ + symbols: ['BTC'], + callback: jest.fn(), + includeOrderBook: true, + }); + await jest.runAllTimersAsync(); + + resolveFirst(firstMockSub); + await jest.runAllTimersAsync(); + + expect(firstMockSub.unsubscribe).toHaveBeenCalled(); + expect(secondMockSub.unsubscribe).not.toHaveBeenCalled(); + }); + + it('unsubscribes stale activeAssetCtx when a newer pending promise exists', async () => { + // Arrange: make first activeAssetCtx return a deferred promise + let resolveFirst: (sub: MockSubscription) => void = () => undefined; + const firstMockSub: MockSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + const secondMockSub: MockSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + let callCount = 0; + mockSubscriptionClient.activeAssetCtx.mockImplementation(() => { + callCount += 1; + if (callCount === 1) { + // First call: deferred promise (simulates slow network) + return new Promise((resolve) => { + resolveFirst = resolve; + }); + } + // Second call: resolves immediately + return Promise.resolve(secondMockSub); + }); + + // Act: first subscription + const unsubscribe1 = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: jest.fn(), + includeMarketData: true, + }); + await jest.runAllTimersAsync(); + + // Cleanup first subscription (decrements count, clears pending) + unsubscribe1(); + + // Second subscription for same symbol (creates new pending promise) + await service.subscribeToPrices({ + symbols: ['BTC'], + callback: jest.fn(), + includeMarketData: true, + }); + await jest.runAllTimersAsync(); + + // Now resolve the first (stale) promise + resolveFirst(firstMockSub); + await jest.runAllTimersAsync(); + + // Assert: stale subscription was unsubscribed + expect(firstMockSub.unsubscribe).toHaveBeenCalled(); + // Fresh subscription was NOT unsubscribed + expect(secondMockSub.unsubscribe).not.toHaveBeenCalled(); + }); + + it('handles activeAssetCtx subscription error gracefully', async () => { + // Arrange: make activeAssetCtx reject + mockSubscriptionClient.activeAssetCtx.mockRejectedValue( + new Error('WebSocket connection failed'), + ); + + // Act: subscribe with market data — should not throw + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: jest.fn(), + includeMarketData: true, + }); + await jest.runAllTimersAsync(); + + // Assert: error was logged, service still functional + expect(mockDeps.logger.error).toHaveBeenCalled(); + expect(typeof unsubscribe).toBe('function'); + }); + + it('logs method name (not class name) for transient SDK errors', async () => { + // Arrange: make activeAssetCtx reject with a WebSocketRequestError + const transientError = new Error( + 'Unknown error while making a WebSocket request', + ); + transientError.name = 'WebSocketRequestError'; + mockSubscriptionClient.activeAssetCtx.mockRejectedValue(transientError); + + // Act + await service.subscribeToPrices({ + symbols: ['BTC'], + callback: jest.fn(), + includeMarketData: true, + }); + await jest.runAllTimersAsync(); + + // Assert: debugLogger received method context, not the class name + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('ensureActiveAssetSubscription'), + ); + expect(mockDeps.debugLogger.log).not.toHaveBeenCalledWith( + expect.stringContaining('HyperLiquidSubscriptionService:'), + ); + // Sentry logger should NOT have been called with the transient error + expect(mockDeps.logger.error).not.toHaveBeenCalledWith( + transientError, + expect.anything(), + ); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.lifecycle.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.lifecycle.test.ts new file mode 100644 index 0000000000..4b88a23515 --- /dev/null +++ b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.lifecycle.test.ts @@ -0,0 +1,852 @@ +/* eslint-disable */ +/** + * Unit tests for HyperLiquidSubscriptionService + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { CaipAccountId, Hex } from '@metamask/utils'; + +import { ABSTRACTION_MODE_REFRESH_THROTTLE_MS } from '../../../src/constants/perpsConfig'; +import type { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService'; +import type { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService'; +import type { + SubscribeOrderBookParams, + SubscribeOrderFillsParams, + SubscribePositionsParams, + SubscribePricesParams, +} from '../../../src/types'; +import { + adaptAccountStateFromSDK, + parseAssetName, +} from '../../../src/utils/hyperLiquidAdapter'; +import { createMockInfrastructure } from '../../helpers/serviceMocks'; + +// Mock HyperLiquid SDK types +interface MockSubscription { + unsubscribe: jest.Mock; +} + +// Mock adapter +jest.mock('../../../src/utils/hyperLiquidAdapter', () => ({ + adaptPositionFromSDK: jest.fn((assetPos: any) => ({ + symbol: 'BTC', + size: assetPos.position.szi, + entryPrice: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '2500', + leverage: { type: 'isolated', value: 2 }, + liquidationPrice: '40000', + maxLeverage: 100, + returnOnEquity: '4.0', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + })), + adaptOrderFromSDK: jest.fn((order: any) => ({ + orderId: order.oid.toString(), + symbol: order.coin, + side: order.side === 'B' ? 'buy' : 'sell', + orderType: 'limit', + size: order.sz, + originalSize: order.sz, + price: order.limitPx || order.triggerPx || '0', + filledSize: '0', + remainingSize: order.sz, + status: 'open', + timestamp: Date.now(), + detailedOrderType: order.orderType || 'Limit', + isTrigger: order.isTrigger ?? false, + reduceOnly: order.reduceOnly ?? false, + triggerPrice: order.triggerPx, + ...(typeof order.isPositionTpsl === 'boolean' + ? { isPositionTpsl: order.isPositionTpsl } + : {}), + })), + adaptAccountStateFromSDK: jest.fn(() => ({ + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', + marginUsed: '500.00', + unrealizedPnl: '100.00', + returnOnEquity: '20.0', + totalBalance: '10100.00', + })), + parseAssetName: jest.fn((symbol: string) => ({ + symbol, + dex: null, + })), +})); + +// Mock DevLogger +jest.mock( + '../../../../core/SDKConnect/utils/DevLogger', + () => ({ + DevLogger: { + log: jest.fn(), + }, + }), + { virtual: true }, +); + +// Mock trace utilities +jest.mock( + '../../../../util/trace', + () => ({ + trace: jest.fn(), + TraceName: { + PerpsWebSocketConnected: 'Perps WebSocket Connected', + PerpsWebSocketDisconnected: 'Perps WebSocket Disconnected', + }, + TraceOperation: { + PerpsMarketData: 'perps.market_data', + }, + }), + { virtual: true }, +); + +// Mock Sentry +jest.mock( + '@sentry/react-native', + () => ({ + setMeasurement: jest.fn(), + }), + { virtual: true }, +); + +describe('HyperLiquidSubscriptionService', () => { + let service: HyperLiquidSubscriptionService; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionClient: any; + let mockWalletAdapter: any; + let mockDeps: ReturnType; + let mockSpotClearinghouseState: jest.Mock; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + mockDeps = createMockInfrastructure(); + jest.mocked(parseAssetName).mockImplementation((symbol: string) => ({ + symbol, + dex: null, + })); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptPositionFromSDK.mockImplementation( + (assetPos: any) => ({ + symbol: 'BTC', + size: assetPos.position.szi, + entryPrice: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '2500', + leverage: { type: 'isolated', value: 2 }, + liquidationPrice: '40000', + maxLeverage: 100, + returnOnEquity: '4.0', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }), + ); + hyperLiquidAdapter.adaptOrderFromSDK.mockImplementation((order: any) => ({ + orderId: order.oid.toString(), + symbol: order.coin, + side: order.side === 'B' ? 'buy' : 'sell', + orderType: 'limit', + size: order.sz, + originalSize: order.sz, + price: order.limitPx || order.triggerPx || '0', + filledSize: '0', + remainingSize: order.sz, + status: 'open', + timestamp: Date.now(), + detailedOrderType: order.orderType || 'Limit', + isTrigger: order.isTrigger ?? false, + reduceOnly: order.reduceOnly ?? false, + triggerPrice: order.triggerPx, + ...(typeof order.isPositionTpsl === 'boolean' + ? { isPositionTpsl: order.isPositionTpsl } + : {}), + })); + hyperLiquidAdapter.adaptAccountStateFromSDK.mockImplementation(() => ({ + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', + marginUsed: '500.00', + unrealizedPnl: '100.00', + returnOnEquity: '20.0', + totalBalance: '10100.00', + })); + + // Mock subscription client + const mockSubscription: MockSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + mockSubscriptionClient = { + allMids: jest.fn((paramsOrCallback: any, maybeCallback?: any) => { + const callback = + typeof paramsOrCallback === 'function' + ? paramsOrCallback + : maybeCallback; + // Simulate allMids data + setTimeout(() => { + callback({ + mids: { + BTC: 50000, + ETH: 3000, + }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + activeAssetCtx: jest.fn((params: any, callback: any) => { + // Simulate activeAssetCtx data + setTimeout(() => { + callback({ + coin: params.coin, + ctx: { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', // Raw token units from API + dayNtlVlm: '50000000', + oraclePx: '50100', + midPx: '50000', // Price used for openInterest USD conversion: 1M tokens * $50K = $50B + }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + webData3: jest.fn((_params: any, callback: any) => { + // Simulate webData3 data with perpDexStates structure + // First callback immediately + setTimeout(() => { + callback({ + perpDexStates: [ + { + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.1' }, + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], + }, + ], + }); + }, 0); + + // Second callback with changed data to ensure updates are triggered + setTimeout(() => { + callback({ + perpDexStates: [ + { + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.2' }, // Changed position size + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 12346, // Changed order ID + coin: 'BTC', + side: 'S', + sz: '0.3', + origSz: '0.5', + limitPx: '51000', + orderType: 'Limit', + timestamp: 1234567890001, + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], + }, + ], + }); + }, 10); + + return Promise.resolve(mockSubscription); + }), + webData2: jest.fn((_params: any, callback: any) => { + // Simulate webData2 data with clearinghouseState (HIP-3 disabled) + setTimeout(() => { + callback({ + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.1' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + openOrders: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + userFills: jest.fn((_params: any, callback: any) => { + // Simulate order fill data + setTimeout(() => { + callback({ + fills: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.1', + px: '50000', + fee: '5', + time: Date.now(), + }, + ], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + l2Book: jest.fn((_params: any, callback: any) => { + // Simulate l2Book data + setTimeout(() => { + callback({ + coin: _params.coin, + levels: { bids: [], asks: [] }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + bbo: jest.fn((_params: any, callback: any) => { + // Simulate BBO data + setTimeout(() => { + callback({ + coin: _params.coin, + time: Date.now(), + bbo: [ + { px: '49900', sz: '1.5', n: 1 }, + { px: '50100', sz: '2.0', n: 1 }, + ], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + activeAsset: jest.fn((params: any, callback: any) => { + // Simulate activeAsset data (similar to activeAssetCtx) + setTimeout(() => { + callback({ + coin: params.coin, + data: 'test', + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + clearinghouseState: jest.fn((_params: any, callback: any) => { + // Simulate clearinghouseState data for individual subscription + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.1' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + openOrders: jest.fn((_params: any, callback: any) => { + // Simulate openOrders data for individual subscription + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + triggerCondition: '', + triggerPx: '', + children: [], + isPositionTpsl: false, + tif: null, + cloid: null, + }, + ], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + assetCtxs: jest.fn(() => Promise.resolve(mockSubscription)), + spotState: jest.fn((_params: any, _callback: any) => + Promise.resolve(mockSubscription), + ), + }; + + mockWalletAdapter = { + request: jest.fn(), + }; + + // Mock client service + mockSpotClearinghouseState = jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', total: '100.76531791' }], + }); + + mockClientService = { + ensureSubscriptionClient: jest.fn().mockResolvedValue(undefined), + getSubscriptionClient: jest.fn(() => mockSubscriptionClient), + getInfoClient: jest.fn(() => ({ + spotClearinghouseState: mockSpotClearinghouseState, + // Mode-aware fold gate reads userAbstraction; default to unifiedAccount + // so existing spot-fold assertions behave as before the gate was added. + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + })), + isTestnetMode: jest.fn(() => false), + ensureTransportReady: jest.fn().mockResolvedValue(undefined), + getConnectionState: jest.fn(() => 'connected'), + } as any; + + // Mock wallet service + mockWalletService = { + createWalletAdapter: jest.fn(() => mockWalletAdapter), + getUserAddressWithDefault: jest.fn().mockResolvedValue('0x123' as Hex), + } as any; + + service = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + true, // hip3Enabled - test expects webData3 + ); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + describe('Subscription Lifecycle', () => { + it('should unsubscribe from position updates successfully', async () => { + const mockCallback = jest.fn(); + const mockSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + mockSubscriptionClient.webData3.mockResolvedValue(mockSubscription); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + // Wait for subscription to be established + await jest.runAllTimersAsync(); + + // Unsubscribe + unsubscribe(); + + // Wait for unsubscribe to complete + await jest.runAllTimersAsync(); + + expect(mockSubscription.unsubscribe).toHaveBeenCalled(); + }); + + it('should unsubscribe from order fill updates successfully', async () => { + const mockCallback = jest.fn(); + const mockSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + mockSubscriptionClient.userFills.mockResolvedValue(mockSubscription); + + const unsubscribe = service.subscribeToOrderFills({ + callback: mockCallback, + }); + + // Wait for subscription to be established + await jest.runAllTimersAsync(); + + // Unsubscribe + unsubscribe(); + + // Wait for unsubscribe to complete + await jest.runAllTimersAsync(); + + expect(mockSubscription.unsubscribe).toHaveBeenCalled(); + }); + + it('should handle unsubscribe errors gracefully', async () => { + const mockCallback = jest.fn(); + const mockSubscription = { + unsubscribe: jest + .fn() + .mockRejectedValue(new Error('Unsubscribe failed')), + }; + + mockSubscriptionClient.webData3.mockResolvedValue(mockSubscription); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + // Wait for subscription to be established + await jest.runAllTimersAsync(); + + // Unsubscribe should not throw + expect(() => unsubscribe()).not.toThrow(); + }); + }); + + describe('Cache Management', () => { + it('should create price updates with 24h change calculation', async () => { + const mockCallback = jest.fn(); + + // First subscription to populate cache + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeMarketData: true, // Enable market data to get percentChange24h + }); + + // Wait for cache to populate + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalledWith([ + expect.objectContaining({ + symbol: 'BTC', + price: expect.any(String), + timestamp: expect.any(Number), + percentChange24h: expect.any(String), + }), + ]); + + unsubscribe(); + }); + + it('should maintain separate caches for market data', async () => { + const mockCallback = jest.fn(); + + // Mock activeAssetCtx with market data + mockSubscriptionClient.activeAssetCtx.mockImplementation( + (params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: params.coin, + ctx: { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', + dayNtlVlm: '50000000', + oraclePx: '50100', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + }); + + // Wait for cache updates + await jest.runAllTimersAsync(); + + // Verify market data is processed + expect(mockCallback).toHaveBeenCalled(); + + unsubscribe(); + }); + }); + + describe('Cleanup and Error Handling', () => { + it('should clear all subscriptions and cache', async () => { + service.clearAll(); + + // Verify cache is cleared by trying to subscribe + const mockCallback = jest.fn(); + await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + }); + + // Should not have cached data + expect(mockCallback).not.toHaveBeenCalled(); + }); + + it('should handle subscription errors gracefully', async () => { + mockSubscriptionClient.allMids.mockRejectedValue( + new Error('Subscription failed'), + ); + + const mockCallback = jest.fn(); + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + }); + + // Should not throw + expect(typeof unsubscribe).toBe('function'); + }); + + it('should handle missing subscription client in position subscription', async () => { + mockClientService.getSubscriptionClient.mockReturnValue(undefined); + + const mockCallback = jest.fn(); + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + // Wait for async operations + await jest.runAllTimersAsync(); + + expect(typeof unsubscribe).toBe('function'); + expect(mockSubscriptionClient.webData3).not.toHaveBeenCalled(); + }); + + it('should handle missing subscription client in order fill subscription', () => { + mockClientService.getSubscriptionClient.mockReturnValue(undefined); + + const mockCallback = jest.fn(); + const unsubscribe = service.subscribeToOrderFills({ + callback: mockCallback, + }); + + expect(typeof unsubscribe).toBe('function'); + expect( + mockWalletService.getUserAddressWithDefault, + ).not.toHaveBeenCalled(); + }); + }); + + describe('Data Transformation', () => { + it('should handle both perps and spot context types', async () => { + const mockCallback = jest.fn(); + + // Mock spot context (without perps-specific fields) + mockSubscriptionClient.activeAssetCtx.mockImplementation( + (params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: params.coin, + ctx: { + prevDayPx: '49000', + dayNtlVlm: '50000000', + // No funding, openInterest, oraclePx (spot context) + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + }); + + // Wait for processing + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalled(); + + unsubscribe(); + }); + + it('should handle missing position data gracefully', async () => { + const mockCallback = jest.fn(); + + // HIP-3 mode uses individual subscriptions + // Mock clearinghouseState with no position data + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [], // Empty array instead of undefined + marginSummary: { accountValue: '10000', totalMarginUsed: '0' }, + withdrawable: '10000', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [], // Empty orders + }); + }, 5); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + // Wait for processing + await jest.runAllTimersAsync(); + + // Should call callback with empty positions to fix loading state + // This ensures the UI can transition from loading to empty state for new users without cached positions + expect(mockCallback).toHaveBeenCalledWith([]); + + unsubscribe(); + }); + }); + + describe('Market Data Subscription Control', () => { + it('should not include market data when includeMarketData is false', async () => { + const mockCallback = jest.fn(); + + // Subscribe without market data + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeMarketData: false, + }); + + // Ensure activeAssetCtx is NOT called + expect(mockSubscriptionClient.activeAssetCtx).not.toHaveBeenCalled(); + + // Wait for allMids data + await jest.runAllTimersAsync(); + + // Check that market data fields are undefined + expect(mockCallback).toHaveBeenCalledWith([ + expect.objectContaining({ + symbol: 'BTC', + price: expect.any(String), + timestamp: expect.any(Number), + funding: undefined, + openInterest: undefined, + volume24h: undefined, + }), + ]); + + unsubscribe(); + }); + + it('should include market data when includeMarketData is true', async () => { + const mockCallback = jest.fn(); + const mockSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + // Mock activeAssetCtx with market data + mockSubscriptionClient.activeAssetCtx.mockImplementation( + (params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: params.coin, + ctx: { + prevDayPx: 45000, + funding: 0.0001, + openInterest: 1000000, // Raw token units from API + dayNtlVlm: 5000000, + oraclePx: 50100, + midPx: 50000, // Price used for openInterest USD conversion: 1M tokens * $50K = $50B + }, + }); + }, 10); + return Promise.resolve(mockSubscription); + }, + ); + + // Subscribe with market data + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeMarketData: true, + }); + + // Ensure activeAssetCtx is called + expect(mockSubscriptionClient.activeAssetCtx).toHaveBeenCalledWith( + { coin: 'BTC' }, + expect.any(Function), + ); + + // Wait for data + await jest.runAllTimersAsync(); + + // Check that market data fields are included + expect(mockCallback).toHaveBeenCalledWith([ + expect.objectContaining({ + symbol: 'BTC', + price: expect.any(String), + timestamp: expect.any(Number), + funding: 0.0001, + openInterest: 50000000000, // 1M tokens * $50K price = $50B + volume24h: 5000000, + }), + ]); + + unsubscribe(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.market-data.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.market-data.test.ts new file mode 100644 index 0000000000..d87a1380ce --- /dev/null +++ b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.market-data.test.ts @@ -0,0 +1,2562 @@ +/* eslint-disable */ +/** + * Unit tests for HyperLiquidSubscriptionService + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { CaipAccountId, Hex } from '@metamask/utils'; + +import { ABSTRACTION_MODE_REFRESH_THROTTLE_MS } from '../../../src/constants/perpsConfig'; +import type { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService'; +import type { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService'; +import type { + SubscribeOrderBookParams, + SubscribeOrderFillsParams, + SubscribePositionsParams, + SubscribePricesParams, +} from '../../../src/types'; +import { + adaptAccountStateFromSDK, + parseAssetName, +} from '../../../src/utils/hyperLiquidAdapter'; +import { createMockInfrastructure } from '../../helpers/serviceMocks'; + +// Mock HyperLiquid SDK types +interface MockSubscription { + unsubscribe: jest.Mock; +} + +// Mock adapter +jest.mock('../../../src/utils/hyperLiquidAdapter', () => ({ + adaptPositionFromSDK: jest.fn((assetPos: any) => ({ + symbol: 'BTC', + size: assetPos.position.szi, + entryPrice: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '2500', + leverage: { type: 'isolated', value: 2 }, + liquidationPrice: '40000', + maxLeverage: 100, + returnOnEquity: '4.0', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + })), + adaptOrderFromSDK: jest.fn((order: any) => ({ + orderId: order.oid.toString(), + symbol: order.coin, + side: order.side === 'B' ? 'buy' : 'sell', + orderType: 'limit', + size: order.sz, + originalSize: order.sz, + price: order.limitPx || order.triggerPx || '0', + filledSize: '0', + remainingSize: order.sz, + status: 'open', + timestamp: Date.now(), + detailedOrderType: order.orderType || 'Limit', + isTrigger: order.isTrigger ?? false, + reduceOnly: order.reduceOnly ?? false, + triggerPrice: order.triggerPx, + ...(typeof order.isPositionTpsl === 'boolean' + ? { isPositionTpsl: order.isPositionTpsl } + : {}), + })), + adaptAccountStateFromSDK: jest.fn(() => ({ + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', + marginUsed: '500.00', + unrealizedPnl: '100.00', + returnOnEquity: '20.0', + totalBalance: '10100.00', + })), + parseAssetName: jest.fn((symbol: string) => ({ + symbol, + dex: null, + })), +})); + +// Mock DevLogger +jest.mock( + '../../../../core/SDKConnect/utils/DevLogger', + () => ({ + DevLogger: { + log: jest.fn(), + }, + }), + { virtual: true }, +); + +// Mock trace utilities +jest.mock( + '../../../../util/trace', + () => ({ + trace: jest.fn(), + TraceName: { + PerpsWebSocketConnected: 'Perps WebSocket Connected', + PerpsWebSocketDisconnected: 'Perps WebSocket Disconnected', + }, + TraceOperation: { + PerpsMarketData: 'perps.market_data', + }, + }), + { virtual: true }, +); + +// Mock Sentry +jest.mock( + '@sentry/react-native', + () => ({ + setMeasurement: jest.fn(), + }), + { virtual: true }, +); + +describe('HyperLiquidSubscriptionService', () => { + let service: HyperLiquidSubscriptionService; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionClient: any; + let mockWalletAdapter: any; + let mockDeps: ReturnType; + let mockSpotClearinghouseState: jest.Mock; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + mockDeps = createMockInfrastructure(); + jest.mocked(parseAssetName).mockImplementation((symbol: string) => ({ + symbol, + dex: null, + })); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptPositionFromSDK.mockImplementation( + (assetPos: any) => ({ + symbol: 'BTC', + size: assetPos.position.szi, + entryPrice: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '2500', + leverage: { type: 'isolated', value: 2 }, + liquidationPrice: '40000', + maxLeverage: 100, + returnOnEquity: '4.0', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }), + ); + hyperLiquidAdapter.adaptOrderFromSDK.mockImplementation((order: any) => ({ + orderId: order.oid.toString(), + symbol: order.coin, + side: order.side === 'B' ? 'buy' : 'sell', + orderType: 'limit', + size: order.sz, + originalSize: order.sz, + price: order.limitPx || order.triggerPx || '0', + filledSize: '0', + remainingSize: order.sz, + status: 'open', + timestamp: Date.now(), + detailedOrderType: order.orderType || 'Limit', + isTrigger: order.isTrigger ?? false, + reduceOnly: order.reduceOnly ?? false, + triggerPrice: order.triggerPx, + ...(typeof order.isPositionTpsl === 'boolean' + ? { isPositionTpsl: order.isPositionTpsl } + : {}), + })); + hyperLiquidAdapter.adaptAccountStateFromSDK.mockImplementation(() => ({ + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', + marginUsed: '500.00', + unrealizedPnl: '100.00', + returnOnEquity: '20.0', + totalBalance: '10100.00', + })); + + // Mock subscription client + const mockSubscription: MockSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + mockSubscriptionClient = { + allMids: jest.fn((paramsOrCallback: any, maybeCallback?: any) => { + const callback = + typeof paramsOrCallback === 'function' + ? paramsOrCallback + : maybeCallback; + // Simulate allMids data + setTimeout(() => { + callback({ + mids: { + BTC: 50000, + ETH: 3000, + }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + activeAssetCtx: jest.fn((params: any, callback: any) => { + // Simulate activeAssetCtx data + setTimeout(() => { + callback({ + coin: params.coin, + ctx: { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', // Raw token units from API + dayNtlVlm: '50000000', + oraclePx: '50100', + midPx: '50000', // Price used for openInterest USD conversion: 1M tokens * $50K = $50B + }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + webData3: jest.fn((_params: any, callback: any) => { + // Simulate webData3 data with perpDexStates structure + // First callback immediately + setTimeout(() => { + callback({ + perpDexStates: [ + { + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.1' }, + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], + }, + ], + }); + }, 0); + + // Second callback with changed data to ensure updates are triggered + setTimeout(() => { + callback({ + perpDexStates: [ + { + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.2' }, // Changed position size + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 12346, // Changed order ID + coin: 'BTC', + side: 'S', + sz: '0.3', + origSz: '0.5', + limitPx: '51000', + orderType: 'Limit', + timestamp: 1234567890001, + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], + }, + ], + }); + }, 10); + + return Promise.resolve(mockSubscription); + }), + webData2: jest.fn((_params: any, callback: any) => { + // Simulate webData2 data with clearinghouseState (HIP-3 disabled) + setTimeout(() => { + callback({ + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.1' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + openOrders: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + userFills: jest.fn((_params: any, callback: any) => { + // Simulate order fill data + setTimeout(() => { + callback({ + fills: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.1', + px: '50000', + fee: '5', + time: Date.now(), + }, + ], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + l2Book: jest.fn((_params: any, callback: any) => { + // Simulate l2Book data + setTimeout(() => { + callback({ + coin: _params.coin, + levels: { bids: [], asks: [] }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + bbo: jest.fn((_params: any, callback: any) => { + // Simulate BBO data + setTimeout(() => { + callback({ + coin: _params.coin, + time: Date.now(), + bbo: [ + { px: '49900', sz: '1.5', n: 1 }, + { px: '50100', sz: '2.0', n: 1 }, + ], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + activeAsset: jest.fn((params: any, callback: any) => { + // Simulate activeAsset data (similar to activeAssetCtx) + setTimeout(() => { + callback({ + coin: params.coin, + data: 'test', + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + clearinghouseState: jest.fn((_params: any, callback: any) => { + // Simulate clearinghouseState data for individual subscription + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.1' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + openOrders: jest.fn((_params: any, callback: any) => { + // Simulate openOrders data for individual subscription + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + triggerCondition: '', + triggerPx: '', + children: [], + isPositionTpsl: false, + tif: null, + cloid: null, + }, + ], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + assetCtxs: jest.fn(() => Promise.resolve(mockSubscription)), + spotState: jest.fn((_params: any, _callback: any) => + Promise.resolve(mockSubscription), + ), + }; + + mockWalletAdapter = { + request: jest.fn(), + }; + + // Mock client service + mockSpotClearinghouseState = jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', total: '100.76531791' }], + }); + + mockClientService = { + ensureSubscriptionClient: jest.fn().mockResolvedValue(undefined), + getSubscriptionClient: jest.fn(() => mockSubscriptionClient), + getInfoClient: jest.fn(() => ({ + spotClearinghouseState: mockSpotClearinghouseState, + // Mode-aware fold gate reads userAbstraction; default to unifiedAccount + // so existing spot-fold assertions behave as before the gate was added. + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + })), + isTestnetMode: jest.fn(() => false), + ensureTransportReady: jest.fn().mockResolvedValue(undefined), + getConnectionState: jest.fn(() => 'connected'), + } as any; + + // Mock wallet service + mockWalletService = { + createWalletAdapter: jest.fn(() => mockWalletAdapter), + getUserAddressWithDefault: jest.fn().mockResolvedValue('0x123' as Hex), + } as any; + + service = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + true, // hip3Enabled - test expects webData3 + ); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + describe('BBO (Order Book) Subscriptions', () => { + it('should subscribe to BBO when includeOrderBook is true', async () => { + const mockCallback = jest.fn(); + const mockBboSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + mockSubscriptionClient.bbo.mockImplementation( + (_params: any, callback: any) => { + // Simulate BBO data + setTimeout(() => { + callback({ + coin: 'BTC', + time: Date.now(), + bbo: [ + { px: '49900', sz: '1.5', n: 1 }, // Bid + { px: '50100', sz: '2.0', n: 1 }, // Ask + ], + }); + }, 0); + return Promise.resolve(mockBboSubscription); + }, + ); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeOrderBook: true, + }); + + // Wait for subscription and data processing + await jest.runAllTimersAsync(); + + // Verify BBO subscription was created + expect(mockSubscriptionClient.bbo).toHaveBeenCalledWith( + { coin: 'BTC' }, + expect.any(Function), + ); + + // Verify callback received bid/ask data + expect(mockCallback).toHaveBeenCalled(); + const lastCall = + mockCallback.mock.calls[mockCallback.mock.calls.length - 1][0]; + expect(lastCall).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'BTC', + bestBid: '49900', + bestAsk: '50100', + }), + ]), + ); + + unsubscribe(); + }); + + it('should not subscribe to BBO when includeOrderBook is false', async () => { + const mockCallback = jest.fn(); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeOrderBook: false, + }); + + // Wait for any potential subscriptions + await jest.runAllTimersAsync(); + + // Verify BBO subscription was NOT created + expect(mockSubscriptionClient.bbo).not.toHaveBeenCalled(); + + unsubscribe(); + }); + + it('should handle multiple BBO subscriptions with reference counting', async () => { + const mockCallback1 = jest.fn(); + const mockCallback2 = jest.fn(); + const mockUnsubscribe = jest.fn().mockResolvedValue(undefined); + mockSubscriptionClient.bbo.mockResolvedValue({ + unsubscribe: mockUnsubscribe, + }); + + // First subscription + const unsubscribe1 = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback1, + includeOrderBook: true, + }); + + await jest.runAllTimersAsync(); + + // Second subscription to same symbol + const unsubscribe2 = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback2, + includeOrderBook: true, + }); + + await jest.runAllTimersAsync(); + + // Should only create one L2 book subscription + expect(mockSubscriptionClient.bbo).toHaveBeenCalledTimes(1); + + // Unsubscribe first + unsubscribe1(); + await jest.runAllTimersAsync(); + + // BBO subscription should still be active + expect(mockSubscriptionClient.bbo).toHaveBeenCalledTimes(1); + expect(mockUnsubscribe).not.toHaveBeenCalled(); + + // Unsubscribe second + unsubscribe2(); + await jest.runAllTimersAsync(); + expect(mockUnsubscribe).toHaveBeenCalledTimes(1); + }); + + it('should handle BBO data with missing levels gracefully', async () => { + const mockCallback = jest.fn(); + + mockSubscriptionClient.bbo.mockImplementation( + (_params: any, callback: any) => { + // Simulate BBO data with missing levels + setTimeout(() => { + callback({ + coin: 'BTC', + time: Date.now(), + bbo: [undefined, undefined], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeOrderBook: true, + }); + + // Wait for subscription and data processing + await jest.runAllTimersAsync(); + + // Should still receive price updates, but without bid/ask + expect(mockCallback).toHaveBeenCalled(); + const { calls } = mockCallback.mock; + const lastCall = calls[calls.length - 1][0]; + + // Check that bestBid and bestAsk are either undefined or '0' + if (lastCall?.[0]) { + expect( + lastCall[0].bestBid === undefined || lastCall[0].bestBid === '0', + ).toBeTruthy(); + expect( + lastCall[0].bestAsk === undefined || lastCall[0].bestAsk === '0', + ).toBeTruthy(); + } + + unsubscribe(); + }); + + it('should handle BBO subscription errors', async () => { + const mockCallback = jest.fn(); + + mockSubscriptionClient.bbo.mockRejectedValue( + new Error('BBO subscription failed'), + ); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeOrderBook: true, + }); + + // Wait for subscription attempt + await jest.runAllTimersAsync(); + + // Error should be handled internally + // Just verify the subscription still works + expect(mockCallback).toHaveBeenCalled(); + + unsubscribe(); + }); + + it('should calculate spread from bid/ask prices', async () => { + const mockCallback = jest.fn(); + + mockSubscriptionClient.bbo.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: 'BTC', + time: Date.now(), + bbo: [ + { px: '49900', sz: '1.5', n: 1 }, // Bid + { px: '50100', sz: '2.0', n: 1 }, // Ask + ], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeOrderBook: true, + }); + + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalled(); + const { calls } = mockCallback.mock; + const lastCall = calls[calls.length - 1][0]; + expect(lastCall).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'BTC', + bestBid: '49900', + bestAsk: '50100', + spread: '200.00000', // 50100 - 49900 + }), + ]), + ); + + unsubscribe(); + }); + }); + + describe('TP/SL Order Processing', () => { + it('should process Take Profit orders correctly', async () => { + const mockCallback = jest.fn(); + + // HIP-3 mode uses individual subscriptions (clearinghouseState + openOrders) + // Mock clearinghouseState with position data + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { szi: '1.0', coin: 'BTC' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + // Mock openOrders with TP/SL trigger orders + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + oid: 123, + coin: 'BTC', + side: 'S', // Sell order (opposite of long position) + sz: '1.0', + triggerPx: '55000', // Take profit trigger price + orderType: 'Take Profit', + reduceOnly: true, + isPositionTpsl: true, + limitPx: '55000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + ], + }); + }, 5); // Slight delay to ensure clearinghouseState fires first + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + // Should receive position with takeProfitPrice set + expect(mockCallback).toHaveBeenCalledWith([ + expect.objectContaining({ + symbol: 'BTC', + takeProfitPrice: '55000', + takeProfitCount: 1, + stopLossCount: 0, + }), + ]); + + unsubscribe(); + }); + + it('should process Stop Loss orders correctly', async () => { + const mockCallback = jest.fn(); + + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { szi: '1.0', coin: 'BTC' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + oid: 124, + coin: 'BTC', + side: 'S', + sz: '1.0', + triggerPx: '45000', // Stop loss trigger price + orderType: 'Stop', + reduceOnly: true, + isPositionTpsl: true, + limitPx: '45000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + ], + }); + }, 5); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + // Should receive position with stopLossPrice set + expect(mockCallback).toHaveBeenCalledWith([ + expect.objectContaining({ + symbol: 'BTC', + stopLossPrice: '45000', + takeProfitCount: 0, + stopLossCount: 1, + }), + ]); + + unsubscribe(); + }); + + it('should handle multiple TP/SL orders for same position', async () => { + const mockCallback = jest.fn(); + + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { szi: '2.0', coin: 'BTC' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + oid: 125, + coin: 'BTC', + side: 'S', + sz: '1.0', + triggerPx: '55000', + orderType: 'Take Profit', + reduceOnly: true, + isPositionTpsl: true, + limitPx: '55000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + { + oid: 126, + coin: 'BTC', + side: 'S', + sz: '1.0', + triggerPx: '56000', + orderType: 'Take Profit', + reduceOnly: true, + isPositionTpsl: true, + limitPx: '56000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + { + oid: 127, + coin: 'BTC', + side: 'S', + sz: '0.5', + triggerPx: '45000', + orderType: 'Stop', + reduceOnly: true, + isPositionTpsl: true, + limitPx: '45000', + origSz: '0.5', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + ], + }); + }, 5); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + // Should receive position with correct counts but only last TP/SL prices + expect(mockCallback).toHaveBeenCalledWith([ + expect.objectContaining({ + symbol: 'BTC', + takeProfitCount: 2, + stopLossCount: 1, + // Should have the last processed prices + takeProfitPrice: expect.any(String), + stopLossPrice: '45000', + }), + ]); + + unsubscribe(); + }); + + it('should fallback to price-based TP/SL detection when orderType is ambiguous', async () => { + const mockCallback = jest.fn(); + + // Mock the adapter to include entryPrice + const mockAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + mockAdapter.adaptPositionFromSDK.mockImplementationOnce(() => ({ + symbol: 'BTC', + size: '1.0', + entryPrice: '50000', + positionValue: '50000', + unrealizedPnl: '5000', + marginUsed: '25000', + leverage: { type: 'cross', value: 2 }, + liquidationPrice: '40000', + maxLeverage: 100, + returnOnEquity: '10.0', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + })); + + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { + szi: '1.0', + coin: 'BTC', + entryPrice: '50000', + }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + oid: 128, + coin: 'BTC', + side: 'S', + sz: '1.0', + triggerPx: '55000', // Above entry price = Take Profit for long + orderType: 'Trigger', // Ambiguous order type + reduceOnly: true, + isPositionTpsl: true, + limitPx: '55000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + { + oid: 129, + coin: 'BTC', + side: 'S', + sz: '1.0', + triggerPx: '45000', // Below entry price = Stop Loss for long + orderType: 'Trigger', // Ambiguous order type + reduceOnly: true, + isPositionTpsl: true, + limitPx: '45000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + ], + }); + }, 5); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + // Should correctly identify TP/SL based on trigger price vs entry price + // With the fix, ambiguous 'Trigger' orders are now counted correctly using price-based fallback + expect(mockCallback).toHaveBeenCalledWith([ + expect.objectContaining({ + symbol: 'BTC', + takeProfitPrice: '55000', // Above entry price + stopLossPrice: '45000', // Below entry price + takeProfitCount: 1, // Ambiguous orders now counted via price-based fallback + stopLossCount: 1, // Ambiguous orders now counted via price-based fallback + }), + ]); + + unsubscribe(); + }); + + it('should handle short position TP/SL logic correctly', async () => { + const mockCallback = jest.fn(); + + // Mock the adapter for short position + const mockAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + mockAdapter.adaptPositionFromSDK.mockImplementationOnce(() => ({ + symbol: 'BTC', + size: '-1.0', // Short position + entryPrice: '50000', + positionValue: '50000', + unrealizedPnl: '5000', + marginUsed: '25000', + leverage: { type: 'cross', value: 2 }, + liquidationPrice: '60000', + maxLeverage: 100, + returnOnEquity: '10.0', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + })); + + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { + szi: '-1.0', // Short position (negative size) + coin: 'BTC', + entryPrice: '50000', + }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + oid: 130, + coin: 'BTC', + side: 'B', // Buy order (opposite of short position) + sz: '1.0', + triggerPx: '45000', // Below entry price = Take Profit for short + orderType: 'Trigger', + reduceOnly: true, + isPositionTpsl: true, + limitPx: '45000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + { + oid: 131, + coin: 'BTC', + side: 'B', + sz: '1.0', + triggerPx: '55000', // Above entry price = Stop Loss for short + orderType: 'Trigger', + reduceOnly: true, + isPositionTpsl: true, + limitPx: '55000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + ], + }); + }, 5); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + // For short positions: TP when trigger < entry, SL when trigger > entry + // With the fix, ambiguous 'Trigger' orders are now counted correctly using price-based fallback + expect(mockCallback).toHaveBeenCalledWith([ + expect.objectContaining({ + symbol: 'BTC', + takeProfitPrice: '45000', // Below entry price for short + stopLossPrice: '55000', // Above entry price for short + takeProfitCount: 1, // Ambiguous orders now counted via price-based fallback + stopLossCount: 1, // Ambiguous orders now counted via price-based fallback + }), + ]); + + unsubscribe(); + }); + + it('should include TP/SL orders in the orders list', async () => { + const mockCallback = jest.fn(); + + // Create service with enabledDexs to skip DEX discovery wait + const hip3Service = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + true, // hip3Enabled + [], // enabledDexs - empty but we'll call updateFeatureFlags + ); + + // Simulate DEX discovery by calling updateFeatureFlags + await hip3Service.updateFeatureFlags(true, [''], [], []); + + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { szi: '1.0', coin: 'BTC' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + oid: 132, + coin: 'BTC', + side: 'S', + sz: '1.0', + triggerPx: '55000', + orderType: 'Take Profit', + reduceOnly: true, + isPositionTpsl: true, + limitPx: '55000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + { + oid: 133, + coin: 'BTC', + side: 'B', + sz: '0.5', + limitPx: '49000', + orderType: 'Limit', + reduceOnly: false, + isPositionTpsl: false, + origSz: '0.5', + timestamp: Date.now(), + isTrigger: false, + triggerCondition: '', + triggerPx: '', + children: [], + tif: null, + cloid: null, + }, + ], + }); + }, 5); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = hip3Service.subscribeToOrders({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + // Should include both TP/SL and regular orders + expect(mockCallback).toHaveBeenCalledWith([ + expect.objectContaining({ + orderId: '132', + symbol: 'BTC', + detailedOrderType: 'Take Profit', + }), + expect.objectContaining({ + orderId: '133', + symbol: 'BTC', + detailedOrderType: 'Limit', + }), + ]); + + unsubscribe(); + }); + + it('should handle positions without matching TP/SL orders', async () => { + const mockCallback = jest.fn(); + + // Mock the adapter to return both positions + const mockAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + mockAdapter.adaptPositionFromSDK + .mockImplementationOnce((_assetPos: any) => ({ + symbol: 'BTC', + size: '1.0', + entryPrice: '50000', + positionValue: '50000', + unrealizedPnl: '5000', + marginUsed: '25000', + leverage: { type: 'cross', value: 2 }, + liquidationPrice: '40000', + maxLeverage: 100, + returnOnEquity: '10.0', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + })) + .mockImplementationOnce(() => ({ + symbol: 'ETH', + size: '2.0', + entryPrice: '3000', + positionValue: '6000', + unrealizedPnl: '1000', + marginUsed: '3000', + leverage: { type: 'isolated', value: 2 }, + liquidationPrice: '2500', + maxLeverage: 50, + returnOnEquity: '16.7', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + })); + + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { szi: '1.0', coin: 'BTC' }, + coin: 'BTC', + }, + { + position: { szi: '2.0', coin: 'ETH' }, + coin: 'ETH', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + oid: 134, + coin: 'BTC', // Only BTC has TP/SL orders + side: 'S', + sz: '1.0', + triggerPx: '55000', + orderType: 'Take Profit', + reduceOnly: true, + isPositionTpsl: true, + limitPx: '55000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + ], + }); + }, 5); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + // Should handle positions with and without TP/SL + expect(mockCallback).toHaveBeenCalledWith([ + expect.objectContaining({ + symbol: 'BTC', + takeProfitPrice: '55000', + takeProfitCount: 1, + stopLossCount: 0, + }), + expect.objectContaining({ + symbol: 'ETH', + takeProfitPrice: undefined, + stopLossPrice: undefined, + takeProfitCount: 0, + stopLossCount: 0, + }), + ]); + + unsubscribe(); + }); + + it('should re-extract TP/SL from cached orders when clearinghouseState updates', async () => { + // Arrange + const mockCallback = jest.fn(); + let clearinghouseStateCallback: (data: any) => void = () => undefined; + + // Setup adapter to return positions with symbol matching the orders + const mockAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + mockAdapter.adaptPositionFromSDK.mockImplementation((assetPos: any) => ({ + symbol: assetPos.position.coin || assetPos.coin, + size: assetPos.position.szi, + entryPrice: '50000', + positionValue: '50000', + unrealizedPnl: '5000', + marginUsed: '25000', + leverage: { type: 'cross', value: 2 }, + liquidationPrice: '40000', + maxLeverage: 100, + returnOnEquity: '10.0', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + })); + + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + // Store callback for later invocation + clearinghouseStateCallback = callback; + // Fire first update immediately (before orders are cached) + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { szi: '1.0', coin: 'BTC' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + // openOrders fires at 10ms to cache trigger orders + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + oid: 200, + coin: 'BTC', + side: 'S', + sz: '1.0', + triggerPx: '60000', + orderType: 'Take Profit', + reduceOnly: true, + isPositionTpsl: true, + limitPx: '60000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + { + oid: 201, + coin: 'BTC', + side: 'S', + sz: '1.0', + triggerPx: '40000', + orderType: 'Stop Market', + reduceOnly: true, + isPositionTpsl: true, + limitPx: '40000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + ], + }); + }, 10); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + // Act - subscribe to positions + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + // Wait for openOrders to fire and cache orders + await jest.runAllTimersAsync(); + + // Simulate a subsequent clearinghouseState update (which will use cached orders) + clearinghouseStateCallback({ + dex: '', + clearinghouseState: { + assetPositions: [ + { + position: { szi: '1.5', coin: 'BTC' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '11000', + totalMarginUsed: '600', + }, + withdrawable: '10400', + }, + }); + + await jest.runAllTimersAsync(); + + // Assert - callback should have been called with TP/SL re-extracted from cached orders + const lastCall = + mockCallback.mock.calls[mockCallback.mock.calls.length - 1]; + expect(lastCall[0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'BTC', + takeProfitPrice: '60000', + stopLossPrice: '40000', + takeProfitCount: 1, + stopLossCount: 1, + }), + ]), + ); + + unsubscribe(); + }); + + it('preserves TP/SL data from cached orders with ambiguous Trigger type on clearinghouseState updates', async () => { + const mockCallback = jest.fn(); + + // Mock the adapter for long position - returns position with size from input + const mockAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + mockAdapter.adaptPositionFromSDK.mockImplementation( + (assetPos: { position: { szi: string } }) => ({ + symbol: 'BTC', + size: assetPos.position.szi, // Use actual size from input + entryPrice: '50000', + positionValue: '50000', + unrealizedPnl: '5000', + marginUsed: '25000', + leverage: { type: 'cross', value: 2 }, + liquidationPrice: '40000', + maxLeverage: 100, + returnOnEquity: '10.0', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }), + ); + + // Track callback invocations for clearinghouseState + const callbackRef: { current: ((data: any) => void) | null } = { + current: null, + }; + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + callbackRef.current = callback; + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { + szi: '1.0', + coin: 'BTC', + entryPrice: '50000', + }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + // Orders with ambiguous 'Trigger' type (no 'Take Profit' or 'Stop' in orderType) + // These should be classified by price: above entry = TP, below entry = SL + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + oid: 200, + coin: 'BTC', + side: 'S', + sz: '1.0', + triggerPx: '55000', // Above entry price = Take Profit for long + orderType: 'Trigger', // Ambiguous order type + reduceOnly: true, + isPositionTpsl: true, + limitPx: '55000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + { + oid: 201, + coin: 'BTC', + side: 'S', + sz: '1.0', + triggerPx: '45000', // Below entry price = Stop Loss for long + orderType: 'Trigger', // Ambiguous order type + reduceOnly: true, + isPositionTpsl: true, + limitPx: '45000', + origSz: '1.0', + timestamp: Date.now(), + isTrigger: true, + triggerCondition: '', + children: [], + tif: null, + cloid: null, + }, + ], + }); + }, 5); // openOrders arrives after clearinghouseState + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + // Wait for initial subscription setup and callbacks + await jest.runAllTimersAsync(); + + // Verify initial TP/SL extraction worked + expect(mockCallback).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'BTC', + takeProfitPrice: '55000', + stopLossPrice: '45000', + }), + ]), + ); + + // Clear mock to track subsequent calls + mockCallback.mockClear(); + + // Simulate a subsequent clearinghouseState update (e.g., position size change) + // This triggers re-extraction of TP/SL from CACHED orders + // Note: We change szi slightly to ensure positionsHash changes and callback is triggered + expect(callbackRef.current).not.toBeNull(); + if (callbackRef.current) { + callbackRef.current({ + dex: '', + clearinghouseState: { + assetPositions: [ + { + position: { + szi: '1.1', // Changed from 1.0 - ensures positionsHash differs and callback fires + coin: 'BTC', + entryPrice: '50000', + }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10500', // Changed - simulates PnL update + totalMarginUsed: '500', + }, + withdrawable: '10000', + }, + }); + } + + await jest.runAllTimersAsync(); + + // TP/SL should still be present after re-extraction from cached orders + // This is the bug fix: cached orders with 'Trigger' type should use price-based fallback + expect(mockCallback).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'BTC', + takeProfitPrice: '55000', // Should persist + stopLossPrice: '45000', // Should persist + }), + ]), + ); + + unsubscribe(); + }); + }); + + describe('Race condition prevention', () => { + it('should prevent duplicate allMids subscriptions when multiple subscribeToPrices calls happen simultaneously', async () => { + const callbacks = [jest.fn(), jest.fn(), jest.fn()]; + const unsubscribes: (() => void)[] = []; + + // Call subscribeToPrices multiple times simultaneously + const subscribePromises = callbacks.map(async (callback) => { + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback, + }); + unsubscribes.push(unsubscribe); + }); + + // Wait for all subscriptions to complete + await Promise.all(subscribePromises); + + // Advance timers for async callbacks + await jest.runAllTimersAsync(); + + // Should only create one allMids subscription despite multiple simultaneous calls + expect(mockSubscriptionClient.allMids).toHaveBeenCalledTimes(1); + + // All callbacks should still work + await jest.runAllTimersAsync(); + callbacks.forEach((callback) => { + expect(callback).toHaveBeenCalled(); + }); + + // Cleanup + unsubscribes.forEach((unsubscribe) => unsubscribe()); + }); + + it('should retry allMids subscription if initial attempt fails', async () => { + const callback = jest.fn(); + const mockUnsubscribeFn = jest.fn(); + const mockSubscriptionObj = { + unsubscribe: mockUnsubscribeFn, + }; + + // Make first attempt fail + mockSubscriptionClient.allMids.mockImplementationOnce(() => + Promise.reject(new Error('Connection failed')), + ); + + // Second attempt succeeds + mockSubscriptionClient.allMids.mockImplementationOnce((cb: any) => { + setTimeout(() => { + cb({ + mids: { + BTC: '50000', + }, + }); + }, 10); + return Promise.resolve(mockSubscriptionObj); + }); + + // First subscription attempt + const unsubscribe1 = await service.subscribeToPrices({ + symbols: ['BTC'], + callback, + }); + + // Wait for first attempt to fail + await jest.runAllTimersAsync(); + + // Second subscription attempt should retry + const unsubscribe2 = await service.subscribeToPrices({ + symbols: ['ETH'], + callback, + }); + + // Wait for second attempt to succeed + await jest.runAllTimersAsync(); + + // Should have tried twice total + expect(mockSubscriptionClient.allMids).toHaveBeenCalledTimes(2); + + // Cleanup + unsubscribe1(); + unsubscribe2(); + }); + }); + + it('should not repeatedly notify subscribers with empty positions', async () => { + const mockCallback = jest.fn(); + + // HIP-3 mode uses individual subscriptions + // Mock clearinghouseState to send multiple empty updates + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + // Send first update + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [], + marginSummary: { accountValue: '10000', totalMarginUsed: '0' }, + withdrawable: '10000', + }, + }); + }, 0); + + // Send second update (still empty) + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [], + marginSummary: { accountValue: '10000', totalMarginUsed: '0' }, + withdrawable: '10000', + }, + }); + }, 20); + + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [], + }); + }, 5); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + // Wait for both updates to process + await jest.runAllTimersAsync(); + + // Should only be called once with empty positions (initial notification) + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenCalledWith([]); + + unsubscribe(); + }); + + it('should notify price subscribers on first update even with zero prices', async () => { + const mockCallback = jest.fn(); + + // Mock allMids with zero prices + mockSubscriptionClient.allMids.mockImplementation((callback: any) => { + // Send first update + setTimeout(() => { + callback({ + mids: { + BTC: '0', + ETH: '0', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC', 'ETH'], + callback: mockCallback, + }); + + // Wait for processing + await jest.runAllTimersAsync(); + + // Should call callback with zero prices to enable UI state + expect(mockCallback).toHaveBeenCalledWith([ + expect.objectContaining({ + symbol: 'BTC', + price: '0', + }), + expect.objectContaining({ + symbol: 'ETH', + price: '0', + }), + ]); + + unsubscribe(); + }); + + describe('HIP-3 Feature Flags and Multi-DEX Support', () => { + it('initializes service with HIP-3 DEXs enabled', () => { + const hip3Service = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + true, // hip3Enabled + ['dex1', 'dex2'], // enabledDexs + ); + + expect(hip3Service).toBeDefined(); + }); + + it('returns only main DEX when equity is disabled', () => { + const subscriptionService = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + false, // hip3Enabled + [], + ); + + expect(subscriptionService).toBeDefined(); + }); + + it('updates feature flags and establishes new DEX subscriptions', async () => { + // Start with market data subscribers to trigger assetCtxs subscriptions + const mockCallback = jest.fn(); + const mockInfoClient = { + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC' }, { name: 'ETH' }], + }), + }; + mockClientService.getInfoClient = jest.fn(() => mockInfoClient as any); + + const assetCtxsSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + mockSubscriptionClient.assetCtxs = jest + .fn() + .mockResolvedValue(assetCtxsSubscription); + + // Subscribe to prices with market data to create market data subscribers + await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + // Now update feature flags to enable new DEXs + await service.updateFeatureFlags(true, ['newdex1', 'newdex2'], [], []); + + expect(mockInfoClient.meta).toHaveBeenCalledWith({ dex: 'newdex1' }); + expect(mockInfoClient.meta).toHaveBeenCalledWith({ dex: 'newdex2' }); + }); + + it('handles errors when establishing assetCtxs subscriptions for new DEXs', async () => { + const mockCallback = jest.fn(); + + // Mock successful meta call but failing assetCtxs subscription + const mockInfoClient = { + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC' }], + }), + }; + mockClientService.getInfoClient = jest.fn(() => mockInfoClient as any); + + // Make assetCtxs subscription fail + mockSubscriptionClient.assetCtxs = jest + .fn() + .mockRejectedValue(new Error('AssetCtxs subscription failed')); + + // Subscribe to prices with market data to create market data subscribers + await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + // Update feature flags - should handle error gracefully without throwing + await service.updateFeatureFlags(true, ['failingdex'], [], []); + + // Wait for async error handling + await jest.runAllTimersAsync(); + + // Verify updateFeatureFlags completed without throwing + expect(mockInfoClient.meta).toHaveBeenCalledWith({ dex: 'failingdex' }); + }); + + it('handles errors when establishing clearinghouseState subscriptions for new DEXs', async () => { + const mockPositionCallback = jest.fn(); + mockSubscriptionClient.clearinghouseState = jest + .fn() + .mockRejectedValue(new Error('Subscription failed')); + + // Subscribe to positions first + service.subscribeToPositions({ + callback: mockPositionCallback, + }); + + await jest.runAllTimersAsync(); + + // Update feature flags - should handle error gracefully + await expect( + service.updateFeatureFlags(true, ['failingdex2'], [], []), + ).resolves.not.toThrow(); + }); + + it('handles getUserAddress errors during feature flag updates', async () => { + const mockPositionCallback = jest.fn(); + mockWalletService.getUserAddressWithDefault.mockRejectedValue( + new Error('Wallet error'), + ); + + // Subscribe to positions first + service.subscribeToPositions({ + callback: mockPositionCallback, + }); + + await jest.runAllTimersAsync(); + + // Update feature flags - should handle wallet error gracefully + await expect( + service.updateFeatureFlags(true, ['newdex'], [], []), + ).resolves.not.toThrow(); + + // Reset mock for other tests + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + '0x123' as Hex, + ); + }); + + it('does not establish subscriptions when no new DEXs are added', async () => { + const mockCallback = jest.fn(); + await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeMarketData: true, + }); + await jest.runAllTimersAsync(); + + const initialCallCount = mockSubscriptionClient.assetCtxs + ? (mockSubscriptionClient.assetCtxs as jest.Mock).mock.calls.length + : 0; + + // Update with same DEXs (no new ones) + await service.updateFeatureFlags(false, [], [], []); + + // Should not create new subscriptions + const finalCallCount = mockSubscriptionClient.assetCtxs + ? (mockSubscriptionClient.assetCtxs as jest.Mock).mock.calls.length + : 0; + expect(finalCallCount).toBe(initialCallCount); + }); + + it('cleans up failed assetCtxs subscriptions so later HIP-3 resubscribes reconnect cleanly', async () => { + const mockCallback = jest.fn(); + jest.mocked(parseAssetName).mockImplementation((symbol: string) => ({ + symbol, + dex: symbol === 'BTC:UNISWAP' ? 'UNISWAP' : null, + })); + + mockClientService.getInfoClient = jest.fn( + () => + ({ + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC:UNISWAP' }], + }), + }) as any, + ); + + mockSubscriptionClient.assetCtxs = jest + .fn() + .mockRejectedValueOnce(new Error('Subscription failed')) + .mockResolvedValueOnce({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }) + .mockResolvedValueOnce({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + + const failedUnsubscribe = await service.subscribeToPrices({ + symbols: ['BTC:UNISWAP'], + callback: mockCallback, + includeMarketData: true, + }); + await jest.runAllTimersAsync(); + failedUnsubscribe(); + await jest.runAllTimersAsync(); + + const recoveredUnsubscribe = await service.subscribeToPrices({ + symbols: ['BTC:UNISWAP'], + callback: mockCallback, + includeMarketData: true, + }); + await jest.runAllTimersAsync(); + recoveredUnsubscribe(); + await jest.runAllTimersAsync(); + + const finalUnsubscribe = await service.subscribeToPrices({ + symbols: ['BTC:UNISWAP'], + callback: mockCallback, + includeMarketData: true, + }); + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.assetCtxs).toHaveBeenCalledTimes(3); + finalUnsubscribe(); + }); + }); + + describe('Market Data Cache Initialization', () => { + it('uses setDexMetaCache to pre-populate meta cache instead of API call', async () => { + // Test that setDexMetaCache can be used to pre-populate the cache + // This is how Provider shares cached meta with SubscriptionService + const mockMeta = { + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + { name: 'SOL', szDecimals: 2, maxLeverage: 20 }, + ], + }; + + // Pre-populate cache via setDexMetaCache (simulating what Provider does) + service.setDexMetaCache('', mockMeta); + + const mockCallback = jest.fn(); + const mockInfoClient = { + // These should NOT be called since cache is populated + meta: jest.fn().mockResolvedValue(mockMeta), + metaAndAssetCtxs: jest.fn().mockResolvedValue([mockMeta, []]), + }; + + mockClientService.getInfoClient = jest.fn(() => mockInfoClient as any); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC', 'ETH', 'SOL'], + callback: mockCallback, + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + // Verify that metaAndAssetCtxs was NOT called (cache was used) + // Note: meta() may still be called by createAssetCtxsSubscription fallback if cache miss, + // but with proper cache population, it should hit the cache + expect(mockInfoClient.metaAndAssetCtxs).not.toHaveBeenCalled(); + + unsubscribe(); + }); + + it('handles errors when caching initial market data', async () => { + const mockCallback = jest.fn(); + const mockInfoClient = { + meta: jest.fn().mockRejectedValue(new Error('Meta fetch failed')), + metaAndAssetCtxs: jest + .fn() + .mockRejectedValue(new Error('AssetCtxs fetch failed')), + }; + + mockClientService.getInfoClient = jest.fn(() => mockInfoClient as any); + + // Should not throw even if initial cache fails + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + // Subscription should still work despite cache error + expect(unsubscribe).toBeDefined(); + expect(typeof unsubscribe).toBe('function'); + + unsubscribe(); + }); + + it('skips caching when includeMarketData is false', async () => { + const mockCallback = jest.fn(); + const mockInfoClient = { + meta: jest.fn(), + metaAndAssetCtxs: jest.fn(), + }; + + mockClientService.getInfoClient = jest.fn(() => mockInfoClient as any); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeMarketData: false, + }); + + await jest.runAllTimersAsync(); + + // assetCtxs subscription is always established (lightweight, 1 per DEX) + // so meta may be called for the assetCtxs mapping, but metaAndAssetCtxs should not + expect(mockInfoClient.metaAndAssetCtxs).not.toHaveBeenCalled(); + + unsubscribe(); + }); + + it('handles partial market data in cache', async () => { + const mockCallback = jest.fn(); + const mockInfoClient = { + meta: jest.fn().mockResolvedValue({ + universe: [{ name: 'BTC' }, { name: 'ETH' }], + }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + {}, + [ + { + funding: '0.0001', + prevDayPx: '49000', + }, + null, // Missing asset context for ETH + ], + ]), + }; + + mockClientService.getInfoClient = jest.fn(() => mockInfoClient as any); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC', 'ETH'], + callback: mockCallback, + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + // Should handle partial data gracefully + expect(mockCallback).toHaveBeenCalled(); + + unsubscribe(); + }); + }); + + describe('Multi-DEX Error Handling', () => { + it('handles webData3 subscription errors gracefully', async () => { + const mockCallback = jest.fn(); + mockSubscriptionClient.webData3 = jest + .fn() + .mockRejectedValue(new Error('WebData3 subscription failed')); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + // Should return unsubscribe function despite error + expect(typeof unsubscribe).toBe('function'); + expect(() => unsubscribe()).not.toThrow(); + }); + + it('handles clearinghouseState subscription errors for HIP-3 DEXs', async () => { + const mockCallback = jest.fn(); + mockSubscriptionClient.clearinghouseState = jest + .fn() + .mockRejectedValue(new Error('ClearinghouseState subscription failed')); + + // Create service with HIP-3 enabled + const hip3Service = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + true, + ['failingdex'], + ); + + const unsubscribe = hip3Service.subscribeToPositions({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + // Should handle error gracefully + expect(typeof unsubscribe).toBe('function'); + expect(() => unsubscribe()).not.toThrow(); + }); + + it('handles unsubscribe errors for HIP-3 clearinghouseState', async () => { + const mockCallback = jest.fn(); + const mockInfoClient = { + frontendOpenOrders: jest.fn().mockResolvedValue([]), + }; + mockClientService.getInfoClient = jest.fn(() => mockInfoClient as any); + + const clearinghouseStateSubscription = { + unsubscribe: jest + .fn() + .mockRejectedValue(new Error('Unsubscribe failed')), + }; + + mockSubscriptionClient.clearinghouseState = jest.fn( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + user: '0x123', + clearinghouseState: { + assetPositions: [], + }, + }); + }, 0); + return Promise.resolve(clearinghouseStateSubscription); + }, + ); + + // Create service with HIP-3 enabled + const hip3Service = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + true, + ['testdex'], + ); + + const unsubscribe = hip3Service.subscribeToPositions({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + // Unsubscribe should not throw even if underlying unsubscribe fails + expect(() => unsubscribe()).not.toThrow(); + }); + }); + + describe('Cache Initialization Checks', () => { + it('returns false for OI caps cache before initialization', () => { + const result = service.isOICapsCacheInitialized(); + + expect(result).toBe(false); + }); + + it('returns false for orders cache before initialization', () => { + const result = service.isOrdersCacheInitialized(); + + expect(result).toBe(false); + }); + + it('returns false for positions cache before initialization', () => { + const result = service.isPositionsCacheInitialized(); + + expect(result).toBe(false); + }); + + it('returns null for cached positions before initialization', () => { + const result = service.getCachedPositions(); + + expect(result).toBeNull(); + }); + + it('returns null for cached orders before initialization', () => { + const result = service.getCachedOrders(); + + expect(result).toBeNull(); + }); + + it('getOrdersCacheIfInitialized returns null when cache not initialized', () => { + const result = service.getOrdersCacheIfInitialized(); + + expect(result).toBeNull(); + }); + + it('getOrdersCacheIfInitialized returns empty array when initialized but no orders', async () => { + // First subscribe to trigger initialization + const callback = jest.fn(); + service.subscribeToOrders({ callback }); + + // Manually set the cache as initialized with empty data + // We need to simulate WebSocket message to trigger initialization + // For unit test, we verify the method exists and returns correct type + const result = service.getOrdersCacheIfInitialized(); + + // Before any WebSocket data, should return null + expect(result).toBeNull(); + }); + + it('getOrdersCacheIfInitialized returns defensive copy of orders', async () => { + // This test verifies the atomic getter returns a copy, not the original + // We test indirectly by verifying the method signature and behavior + const result1 = service.getOrdersCacheIfInitialized(); + const result2 = service.getOrdersCacheIfInitialized(); + + // Both should be null before initialization + expect(result1).toBeNull(); + expect(result2).toBeNull(); + }); + + it('returns null for cached fills before initialization', () => { + const result = service.getCachedFills(); + + expect(result).toBeNull(); + }); + + it('getLastAllMidsSnapshot returns a defensive copy and null for unknown dexes', async () => { + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: jest.fn(), + }); + + await jest.runAllTimersAsync(); + + const snapshot = service.getLastAllMidsSnapshot(); + expect(snapshot).toEqual( + expect.objectContaining({ + BTC: 50000, + ETH: 3000, + }), + ); + + if (!snapshot) { + throw new Error('Expected allMids snapshot to be populated'); + } + + delete snapshot.BTC; + + expect(service.getLastAllMidsSnapshot()).toEqual( + expect.objectContaining({ + BTC: 50000, + ETH: 3000, + }), + ); + expect(service.getLastAllMidsSnapshot('missing-dex')).toBeNull(); + + unsubscribe(); + }); + + it('getFillsCacheIfInitialized returns null when cache not initialized', () => { + const result = service.getFillsCacheIfInitialized(); + + expect(result).toBeNull(); + }); + + it('getFillsCacheIfInitialized returns defensive copy of fills', () => { + // This test verifies the atomic getter returns a copy, not the original + // We test indirectly by verifying the method signature and behavior + const result1 = service.getFillsCacheIfInitialized(); + const result2 = service.getFillsCacheIfInitialized(); + + // Both should be null before initialization + expect(result1).toBeNull(); + expect(result2).toBeNull(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.streams.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.streams.test.ts new file mode 100644 index 0000000000..c18fd2e541 --- /dev/null +++ b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.streams.test.ts @@ -0,0 +1,1217 @@ +/* eslint-disable */ +/** + * Unit tests for HyperLiquidSubscriptionService + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { CaipAccountId, Hex } from '@metamask/utils'; + +import { ABSTRACTION_MODE_REFRESH_THROTTLE_MS } from '../../../src/constants/perpsConfig'; +import type { HyperLiquidClientService } from '../../../src/services/HyperLiquidClientService'; +import { HyperLiquidSubscriptionService } from '../../../src/services/HyperLiquidSubscriptionService'; +import type { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService'; +import type { + SubscribeOrderBookParams, + SubscribeOrderFillsParams, + SubscribePositionsParams, + SubscribePricesParams, +} from '../../../src/types'; +import { + adaptAccountStateFromSDK, + parseAssetName, +} from '../../../src/utils/hyperLiquidAdapter'; +import { createMockInfrastructure } from '../../helpers/serviceMocks'; + +// Mock HyperLiquid SDK types +interface MockSubscription { + unsubscribe: jest.Mock; +} + +// Mock adapter +jest.mock('../../../src/utils/hyperLiquidAdapter', () => ({ + adaptPositionFromSDK: jest.fn((assetPos: any) => ({ + symbol: 'BTC', + size: assetPos.position.szi, + entryPrice: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '2500', + leverage: { type: 'isolated', value: 2 }, + liquidationPrice: '40000', + maxLeverage: 100, + returnOnEquity: '4.0', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + })), + adaptOrderFromSDK: jest.fn((order: any) => ({ + orderId: order.oid.toString(), + symbol: order.coin, + side: order.side === 'B' ? 'buy' : 'sell', + orderType: 'limit', + size: order.sz, + originalSize: order.sz, + price: order.limitPx || order.triggerPx || '0', + filledSize: '0', + remainingSize: order.sz, + status: 'open', + timestamp: Date.now(), + detailedOrderType: order.orderType || 'Limit', + isTrigger: order.isTrigger ?? false, + reduceOnly: order.reduceOnly ?? false, + triggerPrice: order.triggerPx, + ...(typeof order.isPositionTpsl === 'boolean' + ? { isPositionTpsl: order.isPositionTpsl } + : {}), + })), + adaptAccountStateFromSDK: jest.fn(() => ({ + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', + marginUsed: '500.00', + unrealizedPnl: '100.00', + returnOnEquity: '20.0', + totalBalance: '10100.00', + })), + parseAssetName: jest.fn((symbol: string) => ({ + symbol, + dex: null, + })), +})); + +// Mock DevLogger +jest.mock( + '../../../../core/SDKConnect/utils/DevLogger', + () => ({ + DevLogger: { + log: jest.fn(), + }, + }), + { virtual: true }, +); + +// Mock trace utilities +jest.mock( + '../../../../util/trace', + () => ({ + trace: jest.fn(), + TraceName: { + PerpsWebSocketConnected: 'Perps WebSocket Connected', + PerpsWebSocketDisconnected: 'Perps WebSocket Disconnected', + }, + TraceOperation: { + PerpsMarketData: 'perps.market_data', + }, + }), + { virtual: true }, +); + +// Mock Sentry +jest.mock( + '@sentry/react-native', + () => ({ + setMeasurement: jest.fn(), + }), + { virtual: true }, +); + +describe('HyperLiquidSubscriptionService', () => { + let service: HyperLiquidSubscriptionService; + let mockClientService: jest.Mocked; + let mockWalletService: jest.Mocked; + let mockSubscriptionClient: any; + let mockWalletAdapter: any; + let mockDeps: ReturnType; + let mockSpotClearinghouseState: jest.Mock; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + mockDeps = createMockInfrastructure(); + jest.mocked(parseAssetName).mockImplementation((symbol: string) => ({ + symbol, + dex: null, + })); + const hyperLiquidAdapter = jest.requireMock( + '../../../src/utils/hyperLiquidAdapter', + ); + hyperLiquidAdapter.adaptPositionFromSDK.mockImplementation( + (assetPos: any) => ({ + symbol: 'BTC', + size: assetPos.position.szi, + entryPrice: '50000', + positionValue: '5000', + unrealizedPnl: '100', + marginUsed: '2500', + leverage: { type: 'isolated', value: 2 }, + liquidationPrice: '40000', + maxLeverage: 100, + returnOnEquity: '4.0', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }), + ); + hyperLiquidAdapter.adaptOrderFromSDK.mockImplementation((order: any) => ({ + orderId: order.oid.toString(), + symbol: order.coin, + side: order.side === 'B' ? 'buy' : 'sell', + orderType: 'limit', + size: order.sz, + originalSize: order.sz, + price: order.limitPx || order.triggerPx || '0', + filledSize: '0', + remainingSize: order.sz, + status: 'open', + timestamp: Date.now(), + detailedOrderType: order.orderType || 'Limit', + isTrigger: order.isTrigger ?? false, + reduceOnly: order.reduceOnly ?? false, + triggerPrice: order.triggerPx, + ...(typeof order.isPositionTpsl === 'boolean' + ? { isPositionTpsl: order.isPositionTpsl } + : {}), + })); + hyperLiquidAdapter.adaptAccountStateFromSDK.mockImplementation(() => ({ + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', + marginUsed: '500.00', + unrealizedPnl: '100.00', + returnOnEquity: '20.0', + totalBalance: '10100.00', + })); + + // Mock subscription client + const mockSubscription: MockSubscription = { + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + mockSubscriptionClient = { + allMids: jest.fn((paramsOrCallback: any, maybeCallback?: any) => { + const callback = + typeof paramsOrCallback === 'function' + ? paramsOrCallback + : maybeCallback; + // Simulate allMids data + setTimeout(() => { + callback({ + mids: { + BTC: 50000, + ETH: 3000, + }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + activeAssetCtx: jest.fn((params: any, callback: any) => { + // Simulate activeAssetCtx data + setTimeout(() => { + callback({ + coin: params.coin, + ctx: { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', // Raw token units from API + dayNtlVlm: '50000000', + oraclePx: '50100', + midPx: '50000', // Price used for openInterest USD conversion: 1M tokens * $50K = $50B + }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + webData3: jest.fn((_params: any, callback: any) => { + // Simulate webData3 data with perpDexStates structure + // First callback immediately + setTimeout(() => { + callback({ + perpDexStates: [ + { + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.1' }, + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], + }, + ], + }); + }, 0); + + // Second callback with changed data to ensure updates are triggered + setTimeout(() => { + callback({ + perpDexStates: [ + { + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.2' }, // Changed position size + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 12346, // Changed order ID + coin: 'BTC', + side: 'S', + sz: '0.3', + origSz: '0.5', + limitPx: '51000', + orderType: 'Limit', + timestamp: 1234567890001, + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], + }, + ], + }); + }, 10); + + return Promise.resolve(mockSubscription); + }), + webData2: jest.fn((_params: any, callback: any) => { + // Simulate webData2 data with clearinghouseState (HIP-3 disabled) + setTimeout(() => { + callback({ + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.1' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + openOrders: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + userFills: jest.fn((_params: any, callback: any) => { + // Simulate order fill data + setTimeout(() => { + callback({ + fills: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.1', + px: '50000', + fee: '5', + time: Date.now(), + }, + ], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + l2Book: jest.fn((_params: any, callback: any) => { + // Simulate l2Book data + setTimeout(() => { + callback({ + coin: _params.coin, + levels: { bids: [], asks: [] }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + bbo: jest.fn((_params: any, callback: any) => { + // Simulate BBO data + setTimeout(() => { + callback({ + coin: _params.coin, + time: Date.now(), + bbo: [ + { px: '49900', sz: '1.5', n: 1 }, + { px: '50100', sz: '2.0', n: 1 }, + ], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + activeAsset: jest.fn((params: any, callback: any) => { + // Simulate activeAsset data (similar to activeAssetCtx) + setTimeout(() => { + callback({ + coin: params.coin, + data: 'test', + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + clearinghouseState: jest.fn((_params: any, callback: any) => { + // Simulate clearinghouseState data for individual subscription + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.1' }, + coin: 'BTC', + }, + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + openOrders: jest.fn((_params: any, callback: any) => { + // Simulate openOrders data for individual subscription + setTimeout(() => { + callback({ + dex: _params.dex || '', + orders: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + triggerCondition: '', + triggerPx: '', + children: [], + isPositionTpsl: false, + tif: null, + cloid: null, + }, + ], + }); + }, 0); + return Promise.resolve(mockSubscription); + }), + assetCtxs: jest.fn(() => Promise.resolve(mockSubscription)), + spotState: jest.fn((_params: any, _callback: any) => + Promise.resolve(mockSubscription), + ), + }; + + mockWalletAdapter = { + request: jest.fn(), + }; + + // Mock client service + mockSpotClearinghouseState = jest.fn().mockResolvedValue({ + balances: [{ coin: 'USDC', total: '100.76531791' }], + }); + + mockClientService = { + ensureSubscriptionClient: jest.fn().mockResolvedValue(undefined), + getSubscriptionClient: jest.fn(() => mockSubscriptionClient), + getInfoClient: jest.fn(() => ({ + spotClearinghouseState: mockSpotClearinghouseState, + // Mode-aware fold gate reads userAbstraction; default to unifiedAccount + // so existing spot-fold assertions behave as before the gate was added. + userAbstraction: jest.fn().mockResolvedValue('unifiedAccount'), + })), + isTestnetMode: jest.fn(() => false), + ensureTransportReady: jest.fn().mockResolvedValue(undefined), + getConnectionState: jest.fn(() => 'connected'), + } as any; + + // Mock wallet service + mockWalletService = { + createWalletAdapter: jest.fn(() => mockWalletAdapter), + getUserAddressWithDefault: jest.fn().mockResolvedValue('0x123' as Hex), + } as any; + + service = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + true, // hip3Enabled - test expects webData3 + ); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + describe('Price Subscriptions', () => { + it('should subscribe to price updates successfully', async () => { + const mockCallback = jest.fn(); + const params: SubscribePricesParams = { + symbols: ['BTC', 'ETH'], + callback: mockCallback, + includeMarketData: true, // Enable market data to test activeAssetCtx subscription + }; + + const unsubscribe = await service.subscribeToPrices(params); + + expect(mockSubscriptionClient.allMids).toHaveBeenCalled(); + expect(mockSubscriptionClient.activeAssetCtx).toHaveBeenCalledWith( + { coin: 'BTC' }, + expect.any(Function), + ); + expect(mockSubscriptionClient.activeAssetCtx).toHaveBeenCalledWith( + { coin: 'ETH' }, + expect.any(Function), + ); + + // Advance timers to trigger async callbacks + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalled(); + expect(typeof unsubscribe).toBe('function'); + }); + + it('should handle subscription client not available', async () => { + mockClientService.getSubscriptionClient.mockReturnValue(undefined); + + const mockCallback = jest.fn(); + const params: SubscribePricesParams = { + symbols: ['BTC'], + callback: mockCallback, + }; + + const unsubscribe = await service.subscribeToPrices(params); + + expect(typeof unsubscribe).toBe('function'); + expect(mockSubscriptionClient.allMids).not.toHaveBeenCalled(); + }); + + it('should send cached price data immediately', async () => { + const mockCallback = jest.fn(); + + // First subscription to populate cache + const firstUnsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: jest.fn(), + }); + + // Advance timers for cache to populate + await jest.runAllTimersAsync(); + + // Second subscription should get cached data immediately + const secondUnsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + }); + + expect(mockCallback).toHaveBeenCalled(); + + firstUnsubscribe(); + secondUnsubscribe(); + }); + + it('should cleanup subscriptions with reference counting', async () => { + const mockCallback1 = jest.fn(); + const mockCallback2 = jest.fn(); + + // Test that subscribing without market data does not call activeAssetCtx + const unsubscribe1 = await service.subscribeToPrices({ + symbols: ['ETH'], + callback: mockCallback1, + includeMarketData: false, + }); + + const unsubscribe2 = await service.subscribeToPrices({ + symbols: ['ETH'], + callback: mockCallback2, + includeMarketData: false, + }); + + // Should not call activeAssetCtx when includeMarketData is false + expect(mockSubscriptionClient.activeAssetCtx).not.toHaveBeenCalledWith( + { coin: 'ETH' }, + expect.any(Function), + ); + + // Cleanup + unsubscribe1(); + unsubscribe2(); + + // Verify cleanup functions exist + expect(typeof unsubscribe1).toBe('function'); + expect(typeof unsubscribe2).toBe('function'); + }); + }); + + describe('Position Subscriptions', () => { + it('should subscribe to position updates successfully', async () => { + const mockCallback = jest.fn(); + const params: SubscribePositionsParams = { + accountId: 'eip155:42161:0x123' as CaipAccountId, + callback: mockCallback, + }; + + const unsubscribe = service.subscribeToPositions(params); + + // Wait for async operations (individual subscription setup for HIP-3 mode) + // Need to flush both timers and microtask queue since subscription uses fire-and-forget promises + await jest.runAllTimersAsync(); + // Flush microtask queue to allow promise chains to complete + await Promise.resolve(); + await jest.runAllTimersAsync(); + + expect(mockWalletService.getUserAddressWithDefault).toHaveBeenCalledWith( + params.accountId, + ); + + // HIP-3 mode uses individual subscriptions (clearinghouseState + openOrders) + // and webData3 only for OI caps + expect(mockSubscriptionClient.clearinghouseState).toHaveBeenCalledWith( + { user: '0x123', dex: undefined }, + expect.any(Function), + ); + expect(mockSubscriptionClient.openOrders).toHaveBeenCalledWith( + { user: '0x123', dex: undefined }, + expect.any(Function), + ); + expect(mockSubscriptionClient.webData3).toHaveBeenCalledWith( + { user: '0x123' }, + expect.any(Function), + ); + expect(mockCallback).toHaveBeenCalled(); + expect(typeof unsubscribe).toBe('function'); + }); + + it('should handle wallet service errors', async () => { + mockWalletService.getUserAddressWithDefault.mockRejectedValue( + new Error('Wallet error'), + ); + + const mockCallback = jest.fn(); + const params: SubscribePositionsParams = { + accountId: 'eip155:42161:0x123' as CaipAccountId, + callback: mockCallback, + }; + + const unsubscribe = service.subscribeToPositions(params); + + // Wait for async operations + await jest.runAllTimersAsync(); + + // Should not call any subscriptions when wallet service fails + expect(mockSubscriptionClient.clearinghouseState).not.toHaveBeenCalled(); + expect(mockSubscriptionClient.openOrders).not.toHaveBeenCalled(); + expect(mockSubscriptionClient.webData3).not.toHaveBeenCalled(); + expect(typeof unsubscribe).toBe('function'); + }); + + it('should handle subscription client not available', async () => { + mockClientService.getSubscriptionClient.mockReturnValue(undefined); + + const mockCallback = jest.fn(); + const params: SubscribePositionsParams = { + callback: mockCallback, + }; + + const unsubscribe = service.subscribeToPositions(params); + + // Wait for async operations + await jest.runAllTimersAsync(); + + expect(typeof unsubscribe).toBe('function'); + // Should not call any subscriptions when client not available + expect(mockSubscriptionClient.clearinghouseState).not.toHaveBeenCalled(); + expect(mockSubscriptionClient.openOrders).not.toHaveBeenCalled(); + expect(mockSubscriptionClient.webData3).not.toHaveBeenCalled(); + }); + + it('should filter out zero-size positions', async () => { + const mockCallback = jest.fn(); + + // Mock clearinghouseState with mixed positions (HIP-3 mode uses individual subscriptions) + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: _params.dex || '', + clearinghouseState: { + assetPositions: [ + { position: { szi: '0.1' }, coin: 'BTC' }, // Should be included + { position: { szi: '0' }, coin: 'ETH' }, // Should be filtered out + ], + marginSummary: { + accountValue: '10000', + totalMarginUsed: '500', + }, + withdrawable: '9500', + }, + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToPositions({ + callback: mockCallback, + }); + + // Wait for async operations + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ size: '0.1' })]), + ); + + unsubscribe(); + }); + }); + + describe('Order Fill Subscriptions', () => { + it('should subscribe to order fill updates successfully', async () => { + const mockCallback = jest.fn(); + const params: SubscribeOrderFillsParams = { + accountId: 'eip155:42161:0x123' as CaipAccountId, + callback: mockCallback, + }; + + const unsubscribe = service.subscribeToOrderFills(params); + + expect(mockWalletService.getUserAddressWithDefault).toHaveBeenCalledWith( + params.accountId, + ); + + // Wait for async operations + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.userFills).toHaveBeenCalledWith( + { user: '0x123' }, + expect.any(Function), + ); + expect(mockCallback).toHaveBeenCalled(); + expect(typeof unsubscribe).toBe('function'); + }); + + it('should transform order fill data correctly', async () => { + const mockCallback = jest.fn(); + + const unsubscribe = service.subscribeToOrderFills({ + callback: mockCallback, + }); + + // Wait for async operations + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalledWith( + [ + expect.objectContaining({ + orderId: '12345', + symbol: 'BTC', + side: 'B', + size: '0.1', + price: '50000', + fee: '5', + timestamp: expect.any(Number), + }), + ], + undefined, // isSnapshot is undefined for mock data without it + ); + + unsubscribe(); + }); + + it('should handle wallet service errors in order fills', async () => { + mockWalletService.getUserAddressWithDefault.mockRejectedValue( + new Error('Wallet error'), + ); + + const mockCallback = jest.fn(); + const unsubscribe = service.subscribeToOrderFills({ + callback: mockCallback, + }); + + // Wait for async operations + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.userFills).not.toHaveBeenCalled(); + expect(typeof unsubscribe).toBe('function'); + }); + + it('should handle order fills with liquidation data', async () => { + const mockCallback = jest.fn(); + + // Update mock data to include liquidation + mockSubscriptionClient.userFills.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + fills: [ + { + oid: BigInt(12345), + coin: 'BTC', + side: 'A', + sz: '0.1', + px: '45000', + fee: '5', + time: Date.now(), + closedPnl: '-500', + dir: 'Close Long', + feeToken: 'USDC', + liquidation: { + liquidatedUser: '0x123', + markPx: '44900', + method: 'market', + }, + }, + ], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToOrderFills({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalledWith( + [ + expect.objectContaining({ + orderId: '12345', + symbol: 'BTC', + liquidation: { + liquidatedUser: '0x123', + markPx: '44900', + method: 'market', + }, + }), + ], + undefined, // isSnapshot is undefined for mock data without it + ); + + unsubscribe(); + }); + + it('enriches WS fills with detailedOrderType from cached orders', async () => { + // Arrange — subscribe to orders first so #cachedOrders gets populated + const orderCallback = jest.fn(); + service.subscribeToOrders({ callback: orderCallback }); + await jest.runAllTimersAsync(); + + // Now subscribe to fills — the callback should enrich with cached order types + const fillCallback = jest.fn(); + mockSubscriptionClient.userFills.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + fills: [ + { + oid: BigInt(12345), + coin: 'BTC', + side: 'B', + sz: '0.1', + px: '50000', + fee: '5', + time: Date.now(), + closedPnl: '0', + dir: 'Open Long', + feeToken: 'USDC', + startPosition: '0', + }, + ], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + // Act + const unsubscribe = service.subscribeToOrderFills({ + callback: fillCallback, + }); + await jest.runAllTimersAsync(); + + // Assert — fill received with orderId mapped and detailedOrderType enriched + expect(fillCallback).toHaveBeenCalledWith( + [ + expect.objectContaining({ + orderId: '12345', + symbol: 'BTC', + detailedOrderType: 'Limit', + }), + ], + undefined, + ); + + unsubscribe(); + }); + + it('should pass isSnapshot flag to callback', async () => { + const mockCallback = jest.fn(); + + // Update mock data to include isSnapshot: true (snapshot message) + mockSubscriptionClient.userFills.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + fills: [ + { + oid: BigInt(12345), + coin: 'BTC', + side: 'B', + sz: '0.1', + px: '50000', + fee: '5', + time: Date.now(), + }, + ], + isSnapshot: true, // This is a snapshot message + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToOrderFills({ + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockCallback).toHaveBeenCalledWith( + expect.any(Array), + true, // isSnapshot should be passed through + ); + + unsubscribe(); + }); + }); + + describe('Shared WebData3 Subscription', () => { + it('should share webData3 subscription between positions and orders', async () => { + const positionCallback = jest.fn(); + const orderCallback = jest.fn(); + + // Mock getUserAddressWithDefault to return immediately + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + '0x123' as Hex, + ); + + // Subscribe to positions first + const unsubscribePositions = service.subscribeToPositions({ + callback: positionCallback, + }); + + // Wait for subscription to be established and initial callback + // This will trigger the first webData3 callback which caches both positions and orders + await jest.runAllTimersAsync(); + + // Verify position callback was called + expect(positionCallback).toHaveBeenCalled(); + + // Subscribe to orders - should reuse same webData3 subscription + // and immediately get cached data + const unsubscribeOrders = service.subscribeToOrders({ + callback: orderCallback, + }); + + // Orders should get cached data immediately (synchronously) + // or after the second webData3 update with changed data + await jest.runAllTimersAsync(); + + // Should only call webData3 once for shared subscription + expect(mockSubscriptionClient.webData3).toHaveBeenCalledTimes(1); + + // Both callbacks should be called with their respective data + expect(positionCallback).toHaveBeenCalled(); + expect(orderCallback).toHaveBeenCalled(); + + // Cleanup + unsubscribePositions(); + unsubscribeOrders(); + }); + + it('should maintain subscription when one subscriber unsubscribes', async () => { + const positionCallback1 = jest.fn(); + const positionCallback2 = jest.fn(); + + // Subscribe two position callbacks + const unsubscribe1 = service.subscribeToPositions({ + callback: positionCallback1, + }); + + const unsubscribe2 = service.subscribeToPositions({ + callback: positionCallback2, + }); + + await jest.runAllTimersAsync(); + + // Unsubscribe first callback + unsubscribe1(); + + // Second callback should still receive updates + mockSubscriptionClient.webData3.mock.calls[0][1]({ + perpDexStates: [ + { + clearinghouseState: { + assetPositions: [ + { + position: { coin: 'BTC', szi: '1.0' }, + }, + ], + }, + openOrders: [], + perpsAtOpenInterestCap: [], + }, + ], + }); + + expect(positionCallback2).toHaveBeenCalled(); + + unsubscribe2(); + }); + + it('should cache positions and orders data', async () => { + const positionCallback = jest.fn(); + + // Setup webData3 mock to call callback with data + mockSubscriptionClient.webData3.mockImplementation( + (_addr: any, callback: any) => { + setTimeout(() => { + callback({ + perpDexStates: [ + { + clearinghouseState: { + assetPositions: [ + { + position: { szi: '1.0' }, + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 123, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '0.5', + limitPx: '50000', + orderType: 'Limit', + timestamp: Date.now(), + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], + }, + ], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = service.subscribeToPositions({ + callback: positionCallback, + }); + + await jest.runAllTimersAsync(); + + // Should receive cached data on new subscription + const newCallback = jest.fn(); + const unsubscribe2 = service.subscribeToPositions({ + callback: newCallback, + }); + + // New subscriber should get cached data immediately + expect(newCallback).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ symbol: 'BTC' })]), + ); + + unsubscribe(); + unsubscribe2(); + }); + + it('uses webData2 subscription when HIP-3 is disabled', async () => { + // Arrange + const positionCallback = jest.fn(); + const orderCallback = jest.fn(); + const accountCallback = jest.fn(); + const oiCapCallback = jest.fn(); + + // Create service with HIP-3 disabled + const serviceWithoutHip3 = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + false, // hip3Enabled = false + [], // enabledDexs + ); + + mockWalletService.getUserAddressWithDefault.mockResolvedValue( + '0x123' as Hex, + ); + + // Mock webData2 to call callback with clearinghouseState data + mockSubscriptionClient.webData2.mockImplementation( + (_addr: any, callback: any) => { + setTimeout(() => { + callback({ + clearinghouseState: { + assetPositions: [ + { + position: { + coin: 'BTC', + szi: '1.5', + entryPx: '50000', + positionValue: '75000', + unrealizedPnl: '5000', + returnOnEquity: '0.1', + leverage: { type: 'cross', value: 10 }, + liquidationPx: '45000', + marginUsed: '7500', + }, + }, + ], + marginSummary: { + accountValue: '100000', + totalMarginUsed: '7500', + totalNtlPos: '75000', + totalRawUsd: '100000', + }, + withdrawable: '92500', + crossMarginSummary: { + accountValue: '100000', + totalMarginUsed: '7500', + totalNtlPos: '75000', + totalRawUsd: '100000', + }, + time: Date.now(), + }, + openOrders: [ + { + oid: 456, + coin: 'ETH', + side: 'A', + sz: '2.0', + origSz: '2.0', + limitPx: '3000', + orderType: 'Limit', + timestamp: Date.now(), + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: ['BTC', 'DOGE'], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + // Act + const unsubscribePositions = serviceWithoutHip3.subscribeToPositions({ + callback: positionCallback, + }); + const unsubscribeOrders = serviceWithoutHip3.subscribeToOrders({ + callback: orderCallback, + }); + const unsubscribeAccount = serviceWithoutHip3.subscribeToAccount({ + callback: accountCallback, + }); + const unsubscribeOICaps = serviceWithoutHip3.subscribeToOICaps({ + callback: oiCapCallback, + }); + + await jest.runAllTimersAsync(); + + // Assert + expect(mockSubscriptionClient.webData2).toHaveBeenCalledTimes(1); + expect(mockSubscriptionClient.webData2).toHaveBeenCalledWith( + { user: '0x123' }, + expect.any(Function), + ); + expect(mockSubscriptionClient.webData3).not.toHaveBeenCalled(); + + expect(positionCallback).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'BTC', + size: '1.5', + }), + ]), + ); + + expect(orderCallback).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + orderId: '456', + symbol: 'ETH', + }), + ]), + ); + + expect(accountCallback).toHaveBeenCalledWith( + expect.objectContaining({ + totalBalance: expect.any(String), + marginUsed: expect.any(String), + }), + ); + + expect(oiCapCallback).toHaveBeenCalledWith(['BTC', 'DOGE']); + + // Cleanup + unsubscribePositions(); + unsubscribeOrders(); + unsubscribeAccount(); + unsubscribeOICaps(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts new file mode 100644 index 0000000000..1524555553 --- /dev/null +++ b/packages/perps-controller/tests/src/services/HyperLiquidWalletService.test.ts @@ -0,0 +1,570 @@ +/* eslint-disable */ +/** + * Unit tests for HyperLiquidWalletService + */ + +// Mock keyring-api to avoid import issues with definePattern +jest.mock('@metamask/keyring-api', () => ({ + isEvmAccountType: jest.fn((accountType: string) => + accountType?.startsWith('eip155:'), + ), +})); + +// Mock MetaMask utils +jest.mock('@metamask/utils', () => ({ + hasProperty: jest.fn((object: object, property: string) => + Object.prototype.hasOwnProperty.call(object, property), + ), + parseCaipAccountId: jest.fn((accountId: string) => { + const parts = accountId.split(':'); + return { + chainNamespace: parts[0], + chainReference: parts[1], + address: parts[2], + }; + }), + isValidHexAddress: jest.fn((address: string) => + /^0x[0-9a-fA-F]{40}$/.test(address), + ), +})); + +// Mock config +jest.mock('../../../src/constants/hyperLiquidConfig', () => ({ + getChainId: jest.fn((isTestnet: boolean) => (isTestnet ? '421614' : '42161')), +})); + +// Mock DevLogger +jest.mock( + '../../../../core/SDKConnect/utils/DevLogger', + () => ({ + DevLogger: { + log: jest.fn(), + }, + }), + { virtual: true }, +); + +import type { CaipAccountId } from '@metamask/utils'; + +import { HyperLiquidWalletService } from '../../../src/services/HyperLiquidWalletService'; +import { + createMockInfrastructure, + createMockEvmAccount, + createMockMessenger, +} from '../../helpers/serviceMocks'; + +describe('HyperLiquidWalletService', () => { + let service: HyperLiquidWalletService; + let mockDeps: ReturnType; + let mockMessenger: ReturnType; + const mockEvmAccount = createMockEvmAccount(); + + beforeEach(() => { + jest.clearAllMocks(); + const keyringApi = jest.requireMock('@metamask/keyring-api'); + keyringApi.isEvmAccountType.mockImplementation((accountType: string) => + accountType?.startsWith('eip155:'), + ); + const utils = jest.requireMock('@metamask/utils'); + utils.hasProperty.mockImplementation((object: object, property: string) => + Object.prototype.hasOwnProperty.call(object, property), + ); + utils.parseCaipAccountId.mockImplementation((accountId: string) => { + const parts = accountId.split(':'); + return { + chainNamespace: parts[0], + chainReference: parts[1], + address: parts[2], + }; + }); + utils.isValidHexAddress.mockImplementation((address: string) => + /^0x[0-9a-fA-F]{40}$/.test(address), + ); + const hyperLiquidConfig = jest.requireMock( + '../../../src/constants/hyperLiquidConfig', + ); + hyperLiquidConfig.getChainId.mockImplementation((isTestnet: boolean) => + isTestnet ? '421614' : '42161', + ); + mockDeps = createMockInfrastructure(); + mockMessenger = createMockMessenger(); + service = new HyperLiquidWalletService(mockDeps, mockMessenger); + }); + + describe('Constructor and Configuration', () => { + it('should initialize with mainnet by default', () => { + expect(service.isTestnetMode()).toBe(false); + }); + + it('should initialize with testnet when specified', () => { + const testnetService = new HyperLiquidWalletService( + mockDeps, + mockMessenger, + { isTestnet: true }, + ); + + expect(testnetService.isTestnetMode()).toBe(true); + }); + + it('should update testnet mode', () => { + service.setTestnetMode(true); + + expect(service.isTestnetMode()).toBe(true); + }); + }); + + describe('Wallet Adapter Creation', () => { + let walletAdapter: { + signTypedData: (params: { + domain: { + name: string; + version: string; + chainId: number; + verifyingContract: `0x${string}`; + }; + types: { + [key: string]: { name: string; type: string }[]; + }; + primaryType: string; + message: Record; + }) => Promise<`0x${string}`>; + getChainId?: () => Promise; + }; + + beforeEach(() => { + walletAdapter = service.createWalletAdapter(); + }); + + it('should create wallet adapter with signTypedData method', () => { + expect(walletAdapter).toHaveProperty('signTypedData'); + expect(typeof walletAdapter.signTypedData).toBe('function'); + }); + + it('should have getChainId method', () => { + expect(walletAdapter).toHaveProperty('getChainId'); + expect(typeof walletAdapter.getChainId).toBe('function'); + }); + + describe('getChainId method', () => { + it('should return mainnet chain ID', async () => { + expect(walletAdapter.getChainId).toBeDefined(); + const chainId = await walletAdapter.getChainId?.(); + + expect(chainId).toBe(42161); + }); + + it('should return testnet chain ID when in testnet mode', async () => { + const testnetService = new HyperLiquidWalletService( + mockDeps, + mockMessenger, + { isTestnet: true }, + ); + const testnetAdapter = testnetService.createWalletAdapter(); + + expect(testnetAdapter.getChainId).toBeDefined(); + const chainId = await testnetAdapter.getChainId?.(); + + expect(chainId).toBe(421614); + }); + }); + + describe('signTypedData method', () => { + const mockTypedDataParams = { + domain: { + name: 'HyperLiquid', + version: '1', + chainId: 42161, + verifyingContract: + '0x0000000000000000000000000000000000000000' as `0x${string}`, + }, + types: { + Order: [ + { name: 'asset', type: 'uint32' }, + { name: 'isBuy', type: 'bool' }, + { name: 'limitPx', type: 'uint64' }, + { name: 'sz', type: 'uint64' }, + { name: 'reduceOnly', type: 'bool' }, + { name: 'timestamp', type: 'uint64' }, + ], + }, + primaryType: 'Order', + message: { + asset: 0, + isBuy: true, + limitPx: '30000', + sz: '1', + reduceOnly: false, + timestamp: Date.now(), + }, + }; + + it('should sign typed data successfully', async () => { + const result = await walletAdapter.signTypedData(mockTypedDataParams); + + expect(result).toBe('0xSignatureResult'); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'HyperLiquidWalletService: Signing typed data', + { + address: mockEvmAccount.address, + primaryType: 'Order', + domain: mockTypedDataParams.domain, + }, + ); + expect(mockMessenger.call).toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + { + from: mockEvmAccount.address, + data: { + domain: mockTypedDataParams.domain, + types: mockTypedDataParams.types, + primaryType: mockTypedDataParams.primaryType, + message: mockTypedDataParams.message, + }, + }, + 'V4', + ); + }); + + it('should throw error when no account selected', async () => { + // Mock accountTree to return empty array (no account selected) + (mockMessenger.call as jest.Mock).mockImplementation( + (action: string) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return []; + } + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } + if (action === 'KeyringController:signTypedMessage') { + return Promise.resolve('0xSignatureResult'); + } + return undefined; + }, + ); + + // Creating wallet adapter should throw when no account + expect(() => service.createWalletAdapter()).toThrow( + 'NO_ACCOUNT_SELECTED', + ); + }); + + it('should handle keyring controller errors', async () => { + (mockMessenger.call as jest.Mock).mockImplementation( + (action: string) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [mockEvmAccount]; + } + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } + if (action === 'KeyringController:signTypedMessage') { + return Promise.reject(new Error('Signing failed')); + } + return undefined; + }, + ); + + // Need to recreate the adapter after changing the mock + const freshAdapter = service.createWalletAdapter(); + + await expect( + freshAdapter.signTypedData(mockTypedDataParams), + ).rejects.toThrow('Signing failed'); + }); + }); + }); + + describe('Account Management', () => { + it('should get current account ID for mainnet', async () => { + const accountId = await service.getCurrentAccountId(); + + expect(accountId).toBe(`eip155:42161:${mockEvmAccount.address}`); + }); + + it('should get current account ID for testnet', async () => { + service.setTestnetMode(true); + + const accountId = await service.getCurrentAccountId(); + + expect(accountId).toBe(`eip155:421614:${mockEvmAccount.address}`); + }); + + it('should throw error when getting account ID with no selected account', async () => { + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return []; + } + return undefined; + }); + + await expect(service.getCurrentAccountId()).rejects.toThrow( + 'NO_ACCOUNT_SELECTED', + ); + }); + + it('should parse user address from account ID', () => { + const accountId = + 'eip155:42161:0x1234567890123456789012345678901234567890' as CaipAccountId; + + const address = service.getUserAddress(accountId); + + expect(address).toBe('0x1234567890123456789012345678901234567890'); + }); + + it('should throw error for invalid address format', () => { + const { isValidHexAddress } = jest.requireMock('@metamask/utils'); + isValidHexAddress.mockReturnValueOnce(false); + + const accountId = 'eip155:42161:invalid-address' as CaipAccountId; + + expect(() => service.getUserAddress(accountId)).toThrow( + 'INVALID_ADDRESS_FORMAT', + ); + }); + + it('should get user address with provided account ID', async () => { + const accountId = + 'eip155:42161:0x9999999999999999999999999999999999999999' as CaipAccountId; + + const address = await service.getUserAddressWithDefault(accountId); + + expect(address).toBe('0x9999999999999999999999999999999999999999'); + }); + + it('should get user address with default fallback', async () => { + const address = await service.getUserAddressWithDefault(); + + expect(address).toBe(mockEvmAccount.address); + }); + + it('returns false for software wallet', () => { + expect(service.isSelectedHardwareWallet()).toBe(false); + }); + + it.each([ + 'Ledger Hardware', + 'Trezor Hardware', + 'OneKey Hardware', + 'Lattice Hardware', + 'QR Hardware Wallet Device', + ])('returns true for %s wallet', (keyringType) => { + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + ...mockEvmAccount, + metadata: { + ...mockEvmAccount.metadata, + keyring: { type: keyringType }, + }, + }, + ]; + } + return undefined; + }); + + expect(service.isSelectedHardwareWallet()).toBe(true); + }); + }); + + describe('Network Management', () => { + it('should update testnet mode correctly', () => { + expect(service.isTestnetMode()).toBe(false); + + service.setTestnetMode(true); + expect(service.isTestnetMode()).toBe(true); + + service.setTestnetMode(false); + expect(service.isTestnetMode()).toBe(false); + }); + + it('should affect chain ID in account ID generation', async () => { + // Test mainnet + service.setTestnetMode(false); + const mainnetAccountId = await service.getCurrentAccountId(); + expect(mainnetAccountId).toContain('eip155:42161:'); + + // Test testnet + service.setTestnetMode(true); + const testnetAccountId = await service.getCurrentAccountId(); + expect(testnetAccountId).toContain('eip155:421614:'); + }); + }); + + describe('Error Handling', () => { + it('should handle accountTree errors gracefully', async () => { + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + throw new Error('Store error'); + } + return undefined; + }); + + await expect(service.getCurrentAccountId()).rejects.toThrow( + 'Store error', + ); + }); + + it('should handle malformed CAIP account IDs', () => { + const { parseCaipAccountId } = jest.requireMock('@metamask/utils'); + parseCaipAccountId.mockImplementationOnce(() => { + throw new Error('Invalid CAIP account ID'); + }); + + const accountId = 'invalid-caip-id' as CaipAccountId; + + expect(() => service.getUserAddress(accountId)).toThrow( + 'Invalid CAIP account ID', + ); + }); + + it('should throw KEYRING_LOCKED when keyring is locked', async () => { + const walletAdapter = service.createWalletAdapter(); + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [mockEvmAccount]; + } + if (action === 'KeyringController:getState') { + return { isUnlocked: false }; + } + return undefined; + }); + + const mockTypedData = { + domain: { + name: 'Test', + version: '1', + chainId: 42161, + verifyingContract: + '0x0000000000000000000000000000000000000000' as `0x${string}`, + }, + types: { + Test: [{ name: 'value', type: 'string' }], + }, + primaryType: 'Test', + message: { value: 'test' }, + }; + + await expect(walletAdapter.signTypedData(mockTypedData)).rejects.toThrow( + 'KEYRING_LOCKED', + ); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + expect.anything(), + expect.anything(), + ); + }); + + it('should return keyring unlocked status via isKeyringUnlocked()', () => { + expect(service.isKeyringUnlocked()).toBe(true); + + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if (action === 'KeyringController:getState') { + return { isUnlocked: false }; + } + return undefined; + }); + + expect(service.isKeyringUnlocked()).toBe(false); + }); + + it('should handle keyring controller initialization errors', async () => { + const walletAdapter = service.createWalletAdapter(); + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [mockEvmAccount]; + } + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } + if (action === 'KeyringController:signTypedMessage') { + return Promise.reject(new Error('Keyring not initialized')); + } + return undefined; + }); + + const mockTypedData = { + domain: { + name: 'Test', + version: '1', + chainId: 42161, + verifyingContract: + '0x0000000000000000000000000000000000000000' as `0x${string}`, + }, + types: { + Test: [{ name: 'value', type: 'string' }], + }, + primaryType: 'Test', + message: { value: 'test' }, + }; + + await expect(walletAdapter.signTypedData(mockTypedData)).rejects.toThrow( + 'Keyring not initialized', + ); + }); + }); + + describe('Integration Scenarios', () => { + it('should handle full wallet adapter workflow', async () => { + const walletAdapter = service.createWalletAdapter(); + + // Get chain ID + expect(walletAdapter.getChainId).toBeDefined(); + const chainId = await ( + walletAdapter.getChainId as () => Promise + )(); + expect(chainId).toBe(42161); + + // Sign typed data + const mockTypedData = { + domain: { + name: 'Test', + version: '1', + chainId, + verifyingContract: + '0x0000000000000000000000000000000000000000' as `0x${string}`, + }, + types: { + Test: [{ name: 'value', type: 'string' }], + }, + primaryType: 'Test', + message: { value: 'test' }, + }; + + const signature = await walletAdapter.signTypedData(mockTypedData); + expect(signature).toBe('0xSignatureResult'); + }); + + it('should maintain consistency between wallet adapter and service methods', async () => { + const walletAdapter = service.createWalletAdapter(); + + // Get chain ID through wallet adapter + expect(walletAdapter.getChainId).toBeDefined(); + const chainId = await walletAdapter.getChainId?.(); + + // Get account through service method + const accountId = await service.getCurrentAccountId(); + const serviceAddress = service.getUserAddress(accountId); + + // Chain ID should match + expect(accountId).toContain(`eip155:${chainId}:`); + expect(accountId).toContain(serviceAddress); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/MYXClientService.test.ts b/packages/perps-controller/tests/src/services/MYXClientService.test.ts new file mode 100644 index 0000000000..9fe209c956 --- /dev/null +++ b/packages/perps-controller/tests/src/services/MYXClientService.test.ts @@ -0,0 +1,1079 @@ +/* eslint-disable */ +import type { PerpsPlatformDependencies } from '@metamask/perps-controller'; + +import { MYX_PRICE_POLLING_INTERVAL_MS } from '../../../src/constants/myxConfig'; +import { MYXClientService } from '../../../src/services/MYXClientService'; +import type { MYXPoolSymbol, MYXTicker } from '../../../src/types/myx-types'; +import { createMockInfrastructure } from '../../helpers/serviceMocks'; + +// ============================================================================ +// Mock @myx-trade/sdk +// Uses the same pattern as HyperLiquidClientService.test.ts: +// 'mock'-prefixed variables at module level are hoisted by Jest's babel plugin +// and can be referenced inside jest.mock() factories. +// ============================================================================ + +const mockGetPoolSymbolAll = jest.fn().mockResolvedValue([]); +const mockGetTickerList = jest.fn().mockResolvedValue([]); +const mockWsConnect = jest.fn(); +const mockWsDisconnect = jest.fn(); +const mockListPositions = jest.fn(); +const mockGetOrders = jest.fn(); +const mockGetOrderHistory = jest.fn(); +const mockGetPositionHistory = jest.fn(); +const mockGetAccountInfo = jest.fn(); +const mockGetWalletQuoteTokenBalance = jest.fn(); +const mockGetTradeFlow = jest.fn(); +const mockGetKlineList = jest.fn(); +const mockGetMarketDetail = jest.fn(); +const mockSubscribeKline = jest.fn(); +const mockUnsubscribeKline = jest.fn(); +const mockAuth = jest.fn(); + +jest.mock('@myx-trade/sdk', () => ({ + MyxClient: jest.fn(() => ({ + markets: { + getPoolSymbolAll: mockGetPoolSymbolAll, + getTickerList: mockGetTickerList, + getKlineList: mockGetKlineList, + getMarketDetail: mockGetMarketDetail, + }, + subscription: { + connect: mockWsConnect, + disconnect: mockWsDisconnect, + subscribeKline: mockSubscribeKline, + unsubscribeKline: mockUnsubscribeKline, + }, + position: { + listPositions: mockListPositions, + getPositionHistory: mockGetPositionHistory, + }, + order: { + getOrders: mockGetOrders, + getOrderHistory: mockGetOrderHistory, + }, + account: { + getAccountInfo: mockGetAccountInfo, + getWalletQuoteTokenBalance: mockGetWalletQuoteTokenBalance, + getTradeFlow: mockGetTradeFlow, + }, + auth: mockAuth, + })), +})); + +// ============================================================================ +// Test Fixtures +// ============================================================================ + +function makePool(overrides: Partial = {}): MYXPoolSymbol { + return { + chainId: 59141, + marketId: 'market-1', + poolId: '0xpool1', + baseSymbol: 'RHEA', + quoteSymbol: 'USDT', + baseTokenIcon: '', + baseToken: '0xbase', + quoteToken: '0xquote', + ...overrides, + }; +} + +function makeTicker(overrides: Partial = {}): MYXTicker { + return { + chainId: 59141, + poolId: '0xpool1', + oracleId: 1, + price: '1500.00', + change: '2.5', + high: '0', + low: '0', + volume: '1000000', + turnover: '0', + ...overrides, + }; +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('MYXClientService', () => { + let service: MYXClientService; + let mockDeps: jest.Mocked; + + beforeEach(() => { + jest.useFakeTimers(); + // clearAllMocks resets call counts/results but preserves implementations. + // Do NOT use resetAllMocks — it strips mockImplementation from MyxClient, + // causing all subsequent `new MyxClient()` calls to return empty objects. + jest.clearAllMocks(); + const { MyxClient } = jest.requireMock('@myx-trade/sdk'); + MyxClient.mockImplementation(() => ({ + markets: { + getPoolSymbolAll: mockGetPoolSymbolAll, + getTickerList: mockGetTickerList, + getKlineList: mockGetKlineList, + getMarketDetail: mockGetMarketDetail, + }, + subscription: { + connect: mockWsConnect, + disconnect: mockWsDisconnect, + subscribeKline: mockSubscribeKline, + unsubscribeKline: mockUnsubscribeKline, + }, + position: { + listPositions: mockListPositions, + getPositionHistory: mockGetPositionHistory, + }, + order: { + getOrders: mockGetOrders, + getOrderHistory: mockGetOrderHistory, + }, + account: { + getAccountInfo: mockGetAccountInfo, + getWalletQuoteTokenBalance: mockGetWalletQuoteTokenBalance, + getTradeFlow: mockGetTradeFlow, + }, + auth: mockAuth, + })); + + mockDeps = createMockInfrastructure(); + service = new MYXClientService(mockDeps, { isTestnet: true }); + }); + + afterEach(() => { + service.disconnect(); + jest.useRealTimers(); + }); + + // ========================================================================== + // Constructor + // ========================================================================== + + describe('constructor', () => { + it('initializes with testnet configuration', () => { + const isTestnet = service.getIsTestnet(); + + expect(isTestnet).toBe(true); + }); + + it('initializes with mainnet configuration', () => { + const mainnetService = new MYXClientService(mockDeps, { + isTestnet: false, + }); + + expect(mainnetService.getIsTestnet()).toBe(false); + mainnetService.disconnect(); + }); + + it('logs initialization details', () => { + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + '[MYXClientService] Initialized with SDK', + expect.objectContaining({ + isTestnet: true, + chainId: 59141, + }), + ); + }); + }); + + // ========================================================================== + // getMarkets + // ========================================================================== + + describe('getMarkets', () => { + it('fetches markets from SDK and caches them', async () => { + const pools = [ + makePool(), + makePool({ poolId: '0xpool2', baseSymbol: 'PARTI' }), + ]; + mockGetPoolSymbolAll.mockResolvedValueOnce(pools); + + const result = await service.getMarkets(); + + expect(result).toEqual(pools); + expect(mockGetPoolSymbolAll).toHaveBeenCalledTimes(1); + }); + + it('returns cached markets within TTL', async () => { + const pools = [makePool()]; + mockGetPoolSymbolAll.mockResolvedValueOnce(pools); + + await service.getMarkets(); + const cachedResult = await service.getMarkets(); + + expect(cachedResult).toEqual(pools); + expect(mockGetPoolSymbolAll).toHaveBeenCalledTimes(1); + }); + + it('refetches markets after cache TTL expires', async () => { + const pools = [makePool()]; + const updatedPools = [makePool(), makePool({ poolId: '0xpool2' })]; + mockGetPoolSymbolAll.mockResolvedValueOnce(pools); + mockGetPoolSymbolAll.mockResolvedValueOnce(updatedPools); + + await service.getMarkets(); + + // Advance past cache TTL (5 minutes) + jest.advanceTimersByTime(5 * 60 * 1000 + 1); + + const result = await service.getMarkets(); + + expect(result).toEqual(updatedPools); + expect(mockGetPoolSymbolAll).toHaveBeenCalledTimes(2); + }); + + it('returns stale cache on error when cache exists', async () => { + const pools = [makePool()]; + mockGetPoolSymbolAll.mockResolvedValueOnce(pools); + + await service.getMarkets(); + + // Expire cache + jest.advanceTimersByTime(5 * 60 * 1000 + 1); + + mockGetPoolSymbolAll.mockRejectedValueOnce(new Error('API down')); + + const result = await service.getMarkets(); + + expect(result).toEqual(pools); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + + it('throws error when fetch fails with no cache', async () => { + mockGetPoolSymbolAll.mockRejectedValueOnce(new Error('Network error')); + + await expect(service.getMarkets()).rejects.toThrow('Network error'); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + + it('handles null response from SDK', async () => { + mockGetPoolSymbolAll.mockResolvedValueOnce(null); + + const result = await service.getMarkets(); + + expect(result).toEqual([]); + }); + + it('logs fetching and results', async () => { + const pools = [makePool()]; + mockGetPoolSymbolAll.mockResolvedValueOnce(pools); + + await service.getMarkets(); + + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + '[MYXClientService] Fetching markets via SDK', + ); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + '[MYXClientService] Markets fetched', + { count: 1 }, + ); + }); + }); + + // ========================================================================== + // getTickers + // ========================================================================== + + describe('getTickers', () => { + it('returns empty array for empty poolIds', async () => { + const result = await service.getTickers([]); + + expect(result).toEqual([]); + expect(mockGetTickerList).not.toHaveBeenCalled(); + }); + + it('fetches tickers for given pool IDs', async () => { + const tickers = [makeTicker()]; + mockGetTickerList.mockResolvedValueOnce(tickers); + + const result = await service.getTickers(['0xpool1']); + + expect(result).toEqual(tickers); + expect(mockGetTickerList).toHaveBeenCalledWith({ + chainId: 59141, + poolIds: ['0xpool1'], + }); + }); + + it('handles null response from SDK', async () => { + mockGetTickerList.mockResolvedValueOnce(null); + + const result = await service.getTickers(['0xpool1']); + + expect(result).toEqual([]); + }); + + it('throws error on SDK failure', async () => { + mockGetTickerList.mockRejectedValueOnce(new Error('Ticker fetch failed')); + + await expect(service.getTickers(['0xpool1'])).rejects.toThrow( + 'Ticker fetch failed', + ); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + // ========================================================================== + // getAllTickers + // ========================================================================== + + describe('getAllTickers', () => { + it('fetches markets then tickers for all pool IDs', async () => { + const pools = [ + makePool({ poolId: '0xpool1' }), + makePool({ poolId: '0xpool2', baseSymbol: 'PARTI' }), + ]; + const tickers = [ + makeTicker({ poolId: '0xpool1' }), + makeTicker({ poolId: '0xpool2' }), + ]; + mockGetPoolSymbolAll.mockResolvedValueOnce(pools); + mockGetTickerList.mockResolvedValueOnce(tickers); + + const result = await service.getAllTickers(); + + expect(result).toEqual(tickers); + expect(mockGetTickerList).toHaveBeenCalledWith({ + chainId: 59141, + poolIds: ['0xpool1', '0xpool2'], + }); + }); + + it('returns empty array when no markets exist', async () => { + mockGetPoolSymbolAll.mockResolvedValueOnce([]); + + const result = await service.getAllTickers(); + + expect(result).toEqual([]); + expect(mockGetTickerList).not.toHaveBeenCalled(); + }); + + it('throws error on failure', async () => { + mockGetPoolSymbolAll.mockRejectedValueOnce(new Error('Markets failed')); + + await expect(service.getAllTickers()).rejects.toThrow('Markets failed'); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + // ========================================================================== + // Price Polling + // ========================================================================== + + describe('startPricePolling', () => { + it('fetches tickers immediately and invokes callback', async () => { + const tickers = [makeTicker()]; + mockGetTickerList.mockResolvedValueOnce(tickers); + const callback = jest.fn(); + + service.startPricePolling(['0xpool1'], callback); + + // Allow the immediate poll to complete + await jest.advanceTimersByTimeAsync(0); + + expect(callback).toHaveBeenCalledWith(tickers); + }); + + it('schedules subsequent poll after interval', async () => { + const tickers1 = [makeTicker({ price: '1000' })]; + const tickers2 = [makeTicker({ price: '2000' })]; + mockGetTickerList.mockResolvedValueOnce(tickers1); + mockGetTickerList.mockResolvedValueOnce(tickers2); + const callback = jest.fn(); + + service.startPricePolling(['0xpool1'], callback); + + // Complete first poll + await jest.advanceTimersByTimeAsync(0); + expect(callback).toHaveBeenCalledTimes(1); + + // Advance to next polling interval + await jest.advanceTimersByTimeAsync(MYX_PRICE_POLLING_INTERVAL_MS); + + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenLastCalledWith(tickers2); + }); + + it('stops previous polling when starting new one', async () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + mockGetTickerList.mockResolvedValue([makeTicker()]); + + service.startPricePolling(['0xpool1'], callback1); + service.startPricePolling(['0xpool2'], callback2); + + await jest.advanceTimersByTimeAsync(0); + + // Only the second callback receives updates after re-start + expect(callback2).toHaveBeenCalled(); + }); + + it('continues polling even when a poll fails', async () => { + const tickers = [makeTicker()]; + mockGetTickerList.mockRejectedValueOnce(new Error('Temporary failure')); + mockGetTickerList.mockResolvedValueOnce(tickers); + const callback = jest.fn(); + + service.startPricePolling(['0xpool1'], callback); + + // First poll fails + await jest.advanceTimersByTimeAsync(0); + expect(callback).not.toHaveBeenCalled(); + + // Next poll succeeds + await jest.advanceTimersByTimeAsync(MYX_PRICE_POLLING_INTERVAL_MS); + expect(callback).toHaveBeenCalledWith(tickers); + }); + + it('does not invoke callback if polling stopped during fetch', async () => { + const tickers = [makeTicker()]; + mockGetTickerList.mockImplementation(async () => { + // Simulate stopping polling during the async fetch + service.stopPricePolling(); + return tickers; + }); + const callback = jest.fn(); + + service.startPricePolling(['0xpool1'], callback); + await jest.advanceTimersByTimeAsync(0); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe('stopPricePolling', () => { + it('clears active polling', async () => { + mockGetTickerList.mockResolvedValue([makeTicker()]); + const callback = jest.fn(); + + service.startPricePolling(['0xpool1'], callback); + await jest.advanceTimersByTimeAsync(0); + + callback.mockClear(); + service.stopPricePolling(); + + // Advance past multiple polling intervals + await jest.advanceTimersByTimeAsync(MYX_PRICE_POLLING_INTERVAL_MS * 3); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('is safe to call when no polling is active', () => { + expect(() => service.stopPricePolling()).not.toThrow(); + }); + + it('logs when stopping', () => { + service.stopPricePolling(); + + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + '[MYXClientService] Stopped price polling', + ); + }); + }); + + // ========================================================================== + // ping + // ========================================================================== + + describe('ping', () => { + it('resolves when SDK call succeeds', async () => { + mockGetTickerList.mockResolvedValueOnce([]); + + await expect(service.ping()).resolves.toBeUndefined(); + }); + + it('throws on SDK failure', async () => { + mockGetTickerList.mockRejectedValueOnce(new Error('Connection refused')); + + await expect(service.ping()).rejects.toThrow('Connection refused'); + }); + + it('throws on timeout', async () => { + mockGetTickerList.mockImplementation( + () => + new Promise(() => { + // Never resolves + }), + ); + + const pingPromise = service.ping(100); + + // Advance timer past the timeout + jest.advanceTimersByTime(150); + + await expect(pingPromise).rejects.toThrow('MYX ping timeout'); + }); + + it('uses default 5000ms timeout', async () => { + mockGetTickerList.mockImplementation( + () => + new Promise(() => { + // Never resolves + }), + ); + + const pingPromise = service.ping(); + jest.advanceTimersByTime(5001); + + await expect(pingPromise).rejects.toThrow('MYX ping timeout'); + }); + + it('clears timeout on successful ping', async () => { + mockGetTickerList.mockResolvedValueOnce([]); + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); + + await service.ping(); + + expect(clearTimeoutSpy).toHaveBeenCalled(); + clearTimeoutSpy.mockRestore(); + }); + }); + + // ========================================================================== + // disconnect + // ========================================================================== + + describe('disconnect', () => { + it('stops polling and clears cache', async () => { + const pools = [makePool()]; + mockGetPoolSymbolAll.mockResolvedValueOnce(pools); + await service.getMarkets(); + + service.disconnect(); + + // After disconnect, next getMarkets call requires a new fetch + mockGetPoolSymbolAll.mockResolvedValueOnce([ + makePool({ poolId: '0xnew' }), + ]); + const result = await service.getMarkets(); + + expect(result).toEqual([makePool({ poolId: '0xnew' })]); + }); + + it('logs disconnection', () => { + service.disconnect(); + + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + '[MYXClientService] Disconnected', + ); + }); + }); + + // ========================================================================== + // getIsTestnet + // ========================================================================== + + describe('getIsTestnet', () => { + it('returns true for testnet configuration', () => { + expect(service.getIsTestnet()).toBe(true); + }); + + it('returns false for mainnet configuration', () => { + const mainnetService = new MYXClientService(mockDeps, { + isTestnet: false, + }); + + expect(mainnetService.getIsTestnet()).toBe(false); + mainnetService.disconnect(); + }); + }); + + // ========================================================================== + // Error Context + // ========================================================================== + + describe('error context', () => { + it('includes testnet tag in error context for testnet service', async () => { + mockGetPoolSymbolAll.mockRejectedValueOnce(new Error('fail')); + + try { + await service.getMarkets(); + } catch { + // expected + } + + expect(mockDeps.logger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: expect.objectContaining({ + network: 'testnet', + service: 'MYXClientService', + }), + }), + ); + }); + + it('includes mainnet tag in error context for mainnet service', async () => { + const mainnetService = new MYXClientService(mockDeps, { + isTestnet: false, + }); + mockGetPoolSymbolAll.mockRejectedValueOnce(new Error('fail')); + + try { + await mainnetService.getMarkets(); + } catch { + // expected + } + + expect(mockDeps.logger.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: expect.objectContaining({ + network: 'mainnet', + }), + }), + ); + + mainnetService.disconnect(); + }); + }); + + // ========================================================================== + // Authenticated Read Operations + // ========================================================================== + + describe('listPositions', () => { + it('delegates to SDK and returns result', async () => { + const mockResult = { code: 9200, data: [{ poolId: '0x1', size: '100' }] }; + mockListPositions.mockResolvedValueOnce(mockResult); + + const result = await service.listPositions('0xuser'); + + expect(result).toEqual(mockResult); + expect(mockListPositions).toHaveBeenCalledWith('0xuser'); + }); + + it('wraps and rethrows errors', async () => { + mockListPositions.mockRejectedValueOnce(new Error('API error')); + + await expect(service.listPositions('0xuser')).rejects.toThrow( + 'API error', + ); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('getOrders', () => { + it('delegates to SDK and returns result', async () => { + const mockResult = { code: 9200, data: [] }; + mockGetOrders.mockResolvedValueOnce(mockResult); + + const result = await service.getOrders('0xuser'); + + expect(result).toEqual(mockResult); + expect(mockGetOrders).toHaveBeenCalledWith('0xuser'); + }); + + it('wraps and rethrows errors', async () => { + mockGetOrders.mockRejectedValueOnce(new Error('Order error')); + + await expect(service.getOrders('0xuser')).rejects.toThrow('Order error'); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('getOrderHistory', () => { + it('delegates to SDK with params and address', async () => { + const params = { limit: 50, chainId: 59141 }; + const mockResult = { code: 9200, data: [] }; + mockGetOrderHistory.mockResolvedValueOnce(mockResult); + + const result = await service.getOrderHistory( + params as Parameters[0], + '0xuser', + ); + + expect(result).toEqual(mockResult); + expect(mockGetOrderHistory).toHaveBeenCalledWith(params, '0xuser'); + }); + + it('wraps and rethrows errors', async () => { + mockGetOrderHistory.mockRejectedValueOnce(new Error('History error')); + + await expect( + service.getOrderHistory( + { limit: 50 } as Parameters[0], + '0xuser', + ), + ).rejects.toThrow('History error'); + }); + }); + + describe('getPositionHistory', () => { + it('delegates to SDK with params and address', async () => { + const params = { limit: 50 }; + const mockResult = { code: 9200, data: [] }; + mockGetPositionHistory.mockResolvedValueOnce(mockResult); + + const result = await service.getPositionHistory( + params as Parameters[0], + '0xuser', + ); + + expect(result).toEqual(mockResult); + expect(mockGetPositionHistory).toHaveBeenCalledWith(params, '0xuser'); + }); + + it('wraps and rethrows errors', async () => { + mockGetPositionHistory.mockRejectedValueOnce(new Error('Pos history')); + + await expect( + service.getPositionHistory( + { limit: 50 } as Parameters[0], + '0xuser', + ), + ).rejects.toThrow('Pos history'); + }); + }); + + describe('getAccountInfo', () => { + it('delegates to SDK with chainId, address, poolId', async () => { + const mockResult = { code: 9200, data: { totalCollateral: '1000' } }; + mockGetAccountInfo.mockResolvedValueOnce(mockResult); + + const result = await service.getAccountInfo(59141, '0xuser', '0xpool1'); + + expect(result).toEqual(mockResult); + expect(mockGetAccountInfo).toHaveBeenCalledWith( + 59141, + '0xuser', + '0xpool1', + ); + }); + + it('wraps and rethrows errors', async () => { + mockGetAccountInfo.mockRejectedValueOnce(new Error('Account error')); + + await expect( + service.getAccountInfo(59141, '0xuser', '0xpool1'), + ).rejects.toThrow('Account error'); + }); + }); + + describe('getWalletQuoteTokenBalance', () => { + it('delegates to SDK', async () => { + const mockResult = { code: 9200, data: '500000000' }; + mockGetWalletQuoteTokenBalance.mockResolvedValueOnce(mockResult); + + const result = await service.getWalletQuoteTokenBalance(59141, '0xuser'); + + expect(result).toEqual(mockResult); + expect(mockGetWalletQuoteTokenBalance).toHaveBeenCalledWith( + 59141, + '0xuser', + ); + }); + + it('wraps and rethrows errors', async () => { + mockGetWalletQuoteTokenBalance.mockRejectedValueOnce( + new Error('Balance error'), + ); + + await expect( + service.getWalletQuoteTokenBalance(59141, '0xuser'), + ).rejects.toThrow('Balance error'); + }); + }); + + describe('getTradeFlow', () => { + it('delegates to SDK with params and address', async () => { + const params = { limit: 50 }; + const mockResult = { code: 9200, data: [] }; + mockGetTradeFlow.mockResolvedValueOnce(mockResult); + + const result = await service.getTradeFlow( + params as Parameters[0], + '0xuser', + ); + + expect(result).toEqual(mockResult); + expect(mockGetTradeFlow).toHaveBeenCalledWith(params, '0xuser'); + }); + + it('wraps and rethrows errors', async () => { + mockGetTradeFlow.mockRejectedValueOnce(new Error('Flow error')); + + await expect( + service.getTradeFlow( + { limit: 50 } as Parameters[0], + '0xuser', + ), + ).rejects.toThrow('Flow error'); + }); + }); + + // ========================================================================== + // Kline (Candle) Data + // ========================================================================== + + describe('getKlineData', () => { + it('fetches kline data from SDK', async () => { + const klineData = [ + { + time: 1700000000, + open: '50000', + close: '51000', + high: '52000', + low: '49000', + }, + ]; + mockGetKlineList.mockResolvedValueOnce(klineData); + + const result = await service.getKlineData({ + poolId: '0xpool1', + interval: '1h' as Parameters< + typeof service.getKlineData + >[0]['interval'], + limit: 100, + }); + + expect(result).toEqual(klineData); + expect(mockGetKlineList).toHaveBeenCalledWith( + expect.objectContaining({ + poolId: '0xpool1', + chainId: 59141, + interval: '1h', + limit: 100, + }), + ); + }); + + it('returns empty array when SDK returns null', async () => { + mockGetKlineList.mockResolvedValueOnce(null); + + const result = await service.getKlineData({ + poolId: '0xpool1', + interval: '1h' as Parameters< + typeof service.getKlineData + >[0]['interval'], + limit: 100, + }); + + expect(result).toEqual([]); + }); + + it('wraps and rethrows errors', async () => { + mockGetKlineList.mockRejectedValueOnce(new Error('Kline error')); + + await expect( + service.getKlineData({ + poolId: '0xpool1', + interval: '1h' as Parameters< + typeof service.getKlineData + >[0]['interval'], + limit: 100, + }), + ).rejects.toThrow('Kline error'); + }); + }); + + // ========================================================================== + // Global ID + // ========================================================================== + + describe('getGlobalId', () => { + it('fetches globalId from market detail and caches it', async () => { + mockGetMarketDetail.mockResolvedValueOnce({ globalId: 42 }); + + const result = await service.getGlobalId('0xpool1'); + + expect(result).toBe(42); + expect(mockGetMarketDetail).toHaveBeenCalledWith({ + chainId: 59141, + poolId: '0xpool1', + }); + }); + + it('returns cached globalId on subsequent calls', async () => { + mockGetMarketDetail.mockResolvedValueOnce({ globalId: 42 }); + + await service.getGlobalId('0xpool1'); + const result = await service.getGlobalId('0xpool1'); + + expect(result).toBe(42); + expect(mockGetMarketDetail).toHaveBeenCalledTimes(1); + }); + + it('wraps and rethrows errors', async () => { + mockGetMarketDetail.mockRejectedValueOnce(new Error('Detail error')); + + await expect(service.getGlobalId('0xpool1')).rejects.toThrow( + 'Detail error', + ); + }); + }); + + // ========================================================================== + // Kline WebSocket Subscriptions + // ========================================================================== + + describe('subscribeToKline', () => { + it('delegates to SDK subscription', () => { + const callback = jest.fn(); + + service.subscribeToKline( + 42, + '1h' as Parameters[1], + callback, + ); + + expect(mockSubscribeKline).toHaveBeenCalledWith(42, '1h', callback); + }); + }); + + describe('unsubscribeFromKline', () => { + it('delegates to SDK unsubscription', () => { + const callback = jest.fn(); + + service.unsubscribeFromKline( + 42, + '1h' as Parameters[1], + callback, + ); + + expect(mockUnsubscribeKline).toHaveBeenCalledWith(42, '1h', callback); + }); + }); + + // ========================================================================== + // Simple Getters + // ========================================================================== + + describe('getChainId', () => { + it('returns testnet chain ID', () => { + expect(service.getChainId()).toBe(59141); + }); + + it('returns mainnet chain ID', () => { + const mainnetService = new MYXClientService(mockDeps, { + isTestnet: false, + }); + + expect(mainnetService.getChainId()).toBe(56); + mainnetService.disconnect(); + }); + }); + + describe('getNetwork', () => { + it('returns testnet for testnet service', () => { + expect(service.getNetwork()).toBe('testnet'); + }); + + it('returns mainnet for mainnet service', () => { + const mainnetService = new MYXClientService(mockDeps, { + isTestnet: false, + }); + + expect(mainnetService.getNetwork()).toBe('mainnet'); + mainnetService.disconnect(); + }); + }); + + describe('isAuthenticated', () => { + it('returns false before authentication', () => { + expect(service.isAuthenticated()).toBe(false); + }); + + it('returns true after successful authentication', async () => { + // authenticate() calls myxClient.auth() synchronously, then sets #authenticated + await service.authenticate({}, {}, '0xuser'); + + expect(service.isAuthenticated()).toBe(true); + }); + }); + + describe('isAuthenticatedForAddress', () => { + it('returns false before authentication', () => { + expect(service.isAuthenticatedForAddress('0xuser')).toBe(false); + }); + + it('returns true for the authenticated address', async () => { + await service.authenticate({}, {}, '0xuser'); + + expect(service.isAuthenticatedForAddress('0xuser')).toBe(true); + }); + + it('returns true regardless of address casing', async () => { + await service.authenticate({}, {}, '0xUser'); + + expect(service.isAuthenticatedForAddress('0xuser')).toBe(true); + expect(service.isAuthenticatedForAddress('0xUSER')).toBe(true); + }); + + it('returns false for a different address', async () => { + await service.authenticate({}, {}, '0xuser'); + + expect(service.isAuthenticatedForAddress('0xother')).toBe(false); + }); + + it('returns false after disconnect', async () => { + await service.authenticate({}, {}, '0xuser'); + service.disconnect(); + + expect(service.isAuthenticatedForAddress('0xuser')).toBe(false); + }); + }); + + // ========================================================================== + // authenticate + // ========================================================================== + + describe('authenticate', () => { + it('calls SDK auth with signer, getAccessToken, and walletClient', async () => { + const signer = { signMessage: jest.fn() }; + const walletClient = {}; + + await service.authenticate(signer, walletClient, '0xuser'); + + expect(mockAuth).toHaveBeenCalledWith( + expect.objectContaining({ + signer, + walletClient, + getAccessToken: expect.any(Function), + }), + ); + }); + + it('skips if already authenticated', async () => { + await service.authenticate({}, {}, '0xuser'); + mockAuth.mockClear(); + + await service.authenticate({}, {}, '0xuser'); + + expect(mockAuth).not.toHaveBeenCalled(); + }); + + it('deduplicates concurrent auth calls', async () => { + // Slow auth: resolve after a tick + let resolveAuth: () => void = () => undefined; + mockAuth.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveAuth = resolve; + }), + ); + + const p1 = service.authenticate({}, {}, '0xuser'); + const p2 = service.authenticate({}, {}, '0xuser'); + + resolveAuth(); + await Promise.all([p1, p2]); + + // Only one SDK auth call despite two authenticate() calls + expect(mockAuth).toHaveBeenCalledTimes(1); + }); + + it('wraps and rethrows SDK auth errors', async () => { + mockAuth.mockImplementationOnce(() => { + throw new Error('Auth failed'); + }); + + await expect(service.authenticate({}, {}, '0xuser')).rejects.toThrow( + 'Auth failed', + ); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/MYXWalletService.test.ts b/packages/perps-controller/tests/src/services/MYXWalletService.test.ts new file mode 100644 index 0000000000..c223f87418 --- /dev/null +++ b/packages/perps-controller/tests/src/services/MYXWalletService.test.ts @@ -0,0 +1,484 @@ +/* eslint-disable */ +/** + * Unit tests for MYXWalletService + */ + +// Mock keyring-api to avoid import issues with definePattern +jest.mock('@metamask/keyring-api', () => ({ + isEvmAccountType: jest.fn((accountType: string) => + accountType?.startsWith('eip155:'), + ), +})); + +// Mock keyring-controller to avoid superstruct/abi-utils chain errors +jest.mock('@metamask/keyring-controller', () => ({ + SignTypedDataVersion: { V4: 'V4' }, +})); + +// Mock MetaMask utils +jest.mock('@metamask/utils', () => ({ + parseCaipAccountId: jest.fn((accountId: string) => { + const parts = accountId.split(':'); + return { + chainNamespace: parts[0], + chainReference: parts[1], + address: parts[2], + }; + }), + isValidHexAddress: jest.fn((address: string) => + /^0x[0-9a-fA-F]{40}$/.test(address), + ), +})); + +// Mock MYX config +jest.mock('../../../src/constants/myxConfig', () => ({ + getMYXChainId: jest.fn((network: string) => + network === 'testnet' ? 421614 : 56, + ), + MYX_TESTNET_CHAIN_ID: '421614', + MYX_MAINNET_CHAIN_ID: '56', +})); + +// Mock DevLogger +jest.mock( + '../../../../core/SDKConnect/utils/DevLogger', + () => ({ + DevLogger: { + log: jest.fn(), + }, + }), + { virtual: true }, +); + +import type { CaipAccountId } from '@metamask/utils'; + +import { MYXWalletService } from '../../../src/services/MYXWalletService'; +import { + createMockInfrastructure, + createMockEvmAccount, + createMockMessenger, +} from '../../helpers/serviceMocks'; + +describe('MYXWalletService', () => { + let service: MYXWalletService; + let mockDeps: ReturnType; + let mockMessenger: ReturnType; + const mockEvmAccount = createMockEvmAccount(); + + beforeEach(() => { + jest.clearAllMocks(); + const keyringApi = jest.requireMock('@metamask/keyring-api'); + keyringApi.isEvmAccountType.mockImplementation((accountType: string) => + accountType?.startsWith('eip155:'), + ); + const utils = jest.requireMock('@metamask/utils'); + utils.parseCaipAccountId.mockImplementation((accountId: string) => { + const parts = accountId.split(':'); + return { + chainNamespace: parts[0], + chainReference: parts[1], + address: parts[2], + }; + }); + utils.isValidHexAddress.mockImplementation((address: string) => + /^0x[0-9a-fA-F]{40}$/.test(address), + ); + const myxConfig = jest.requireMock('../../../src/constants/myxConfig'); + myxConfig.getMYXChainId.mockImplementation((network: string) => + network === 'testnet' ? 421614 : 56, + ); + mockDeps = createMockInfrastructure(); + mockMessenger = createMockMessenger(); + service = new MYXWalletService(mockDeps, mockMessenger); + }); + + describe('Constructor and Configuration', () => { + it('initializes with mainnet by default', () => { + expect(service.isTestnetMode()).toBe(false); + }); + + it('initializes with testnet when specified', () => { + const testnetService = new MYXWalletService(mockDeps, mockMessenger, { + isTestnet: true, + }); + + expect(testnetService.isTestnetMode()).toBe(true); + }); + + it('setTestnetMode / isTestnetMode toggles correctly', () => { + service.setTestnetMode(true); + expect(service.isTestnetMode()).toBe(true); + + service.setTestnetMode(false); + expect(service.isTestnetMode()).toBe(false); + }); + + it('isKeyringUnlocked returns keyring state', () => { + expect(service.isKeyringUnlocked()).toBe(true); + + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if (action === 'KeyringController:getState') { + return { isUnlocked: false }; + } + return undefined; + }); + + expect(service.isKeyringUnlocked()).toBe(false); + }); + }); + + describe('createEthersSigner', () => { + it('throws NO_ACCOUNT_SELECTED when no account', () => { + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return []; + } + return undefined; + }); + + expect(() => service.createEthersSigner()).toThrow('NO_ACCOUNT_SELECTED'); + }); + + it('getAddress() returns current account address', async () => { + const signer = service.createEthersSigner(); + const address = await signer.getAddress(); + + expect(address).toBe(mockEvmAccount.address); + }); + + it('getAddress() throws when account disappears', async () => { + const signer = service.createEthersSigner(); + + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return []; + } + return undefined; + }); + + await expect(signer.getAddress()).rejects.toThrow('NO_ACCOUNT_SELECTED'); + }); + + it('signTypedData() calls messenger with correct params and returns signature', async () => { + const signer = service.createEthersSigner(); + const domain = { name: 'MYX', version: '1', chainId: 56 }; + const types = { + Order: [ + { name: 'asset', type: 'uint32' }, + { name: 'isBuy', type: 'bool' }, + ], + }; + const value = { asset: 0, isBuy: true }; + + const result = await signer.signTypedData(domain, types, value); + + expect(result).toBe('0xSignatureResult'); + expect(mockMessenger.call).toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + { + from: mockEvmAccount.address, + data: { + domain, + types, + primaryType: 'Order', + message: value, + }, + }, + 'V4', + ); + }); + + it('signTypedData() derives primaryType (non-EIP712Domain key)', async () => { + const signer = service.createEthersSigner(); + const domain = { name: 'MYX' }; + const types = { + EIP712Domain: [{ name: 'name', type: 'string' }], + Transfer: [{ name: 'amount', type: 'uint256' }], + }; + const value = { amount: '1000' }; + + await signer.signTypedData(domain, types, value); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + expect.objectContaining({ + data: expect.objectContaining({ primaryType: 'Transfer' }), + }), + 'V4', + ); + }); + + it('signTypedData() falls back to EIP712Domain when types only has that key', async () => { + const signer = service.createEthersSigner(); + const domain = { name: 'MYX' }; + const types = { + EIP712Domain: [{ name: 'name', type: 'string' }], + }; + const value = {}; + + await signer.signTypedData(domain, types, value); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + expect.objectContaining({ + data: expect.objectContaining({ primaryType: 'EIP712Domain' }), + }), + 'V4', + ); + }); + + it('signTypedData() throws NO_ACCOUNT_SELECTED when account disappears', async () => { + const signer = service.createEthersSigner(); + + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return []; + } + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } + return undefined; + }); + + await expect( + signer.signTypedData({ name: 'MYX' }, { Test: [] }, {}), + ).rejects.toThrow('NO_ACCOUNT_SELECTED'); + }); + + it('signTypedData() throws KEYRING_LOCKED when keyring locked', async () => { + const signer = service.createEthersSigner(); + + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [mockEvmAccount]; + } + if (action === 'KeyringController:getState') { + return { isUnlocked: false }; + } + return undefined; + }); + + await expect( + signer.signTypedData({ name: 'MYX' }, { Test: [] }, {}), + ).rejects.toThrow('KEYRING_LOCKED'); + }); + + it('provider is null', () => { + const signer = service.createEthersSigner(); + + expect(signer.provider).toBeNull(); + }); + }); + + describe('createWalletClient', () => { + it('throws NO_ACCOUNT_SELECTED when no account', () => { + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return []; + } + return undefined; + }); + + expect(() => service.createWalletClient()).toThrow('NO_ACCOUNT_SELECTED'); + }); + + it('returns correct account address and chain ID for mainnet (56)', () => { + const client = service.createWalletClient(); + + expect(client.account.address).toBe(mockEvmAccount.address); + expect(client.chain.id).toBe(56); + }); + + it('returns correct chain ID for testnet (421614)', () => { + const testnetService = new MYXWalletService(mockDeps, mockMessenger, { + isTestnet: true, + }); + const client = testnetService.createWalletClient(); + + expect(client.chain.id).toBe(421614); + }); + + it('signTypedData() calls messenger and returns signature', async () => { + const client = service.createWalletClient(); + const args = { + domain: { name: 'MYX', chainId: 56 }, + types: { Order: [{ name: 'asset', type: 'uint32' }] }, + primaryType: 'Order', + message: { asset: 1 }, + }; + + const result = await client.signTypedData(args); + + expect(result).toBe('0xSignatureResult'); + expect(mockMessenger.call).toHaveBeenCalledWith( + 'KeyringController:signTypedMessage', + { + from: mockEvmAccount.address, + data: { + domain: args.domain, + types: args.types, + primaryType: args.primaryType, + message: args.message, + }, + }, + 'V4', + ); + }); + + it('signTypedData() throws NO_ACCOUNT_SELECTED when account disappears', async () => { + const client = service.createWalletClient(); + + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return []; + } + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } + return undefined; + }); + + await expect( + client.signTypedData({ + domain: {}, + types: {}, + primaryType: 'Test', + message: {}, + }), + ).rejects.toThrow('NO_ACCOUNT_SELECTED'); + }); + + it('signTypedData() throws KEYRING_LOCKED when keyring locked', async () => { + const client = service.createWalletClient(); + + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [mockEvmAccount]; + } + if (action === 'KeyringController:getState') { + return { isUnlocked: false }; + } + return undefined; + }); + + await expect( + client.signTypedData({ + domain: {}, + types: {}, + primaryType: 'Test', + message: {}, + }), + ).rejects.toThrow('KEYRING_LOCKED'); + }); + }); + + describe('getUserAddress', () => { + it('returns address as Hex', () => { + const address = service.getUserAddress(); + + expect(address).toBe(mockEvmAccount.address); + }); + + it('throws NO_ACCOUNT_SELECTED when no account', () => { + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return []; + } + return undefined; + }); + + expect(() => service.getUserAddress()).toThrow('NO_ACCOUNT_SELECTED'); + }); + + it('throws INVALID_ADDRESS_FORMAT when isValidHexAddress returns false', () => { + const { isValidHexAddress } = jest.requireMock('@metamask/utils'); + isValidHexAddress.mockReturnValueOnce(false); + + expect(() => service.getUserAddress()).toThrow('INVALID_ADDRESS_FORMAT'); + }); + }); + + describe('getCurrentAccountId', () => { + it('returns CAIP ID with mainnet chain (eip155:56:address)', async () => { + const accountId = await service.getCurrentAccountId(); + + expect(accountId).toBe(`eip155:56:${mockEvmAccount.address}`); + }); + + it('returns CAIP ID with testnet chain (eip155:421614:address)', async () => { + service.setTestnetMode(true); + + const accountId = await service.getCurrentAccountId(); + + expect(accountId).toBe(`eip155:421614:${mockEvmAccount.address}`); + }); + + it('throws NO_ACCOUNT_SELECTED when no account', async () => { + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return []; + } + return undefined; + }); + + await expect(service.getCurrentAccountId()).rejects.toThrow( + 'NO_ACCOUNT_SELECTED', + ); + }); + }); + + describe('getUserAddressFromAccountId / getUserAddressWithDefault', () => { + it('parses address from CAIP account ID', () => { + const accountId = + 'eip155:56:0x1234567890123456789012345678901234567890' as CaipAccountId; + + const address = service.getUserAddressFromAccountId(accountId); + + expect(address).toBe('0x1234567890123456789012345678901234567890'); + }); + + it('throws INVALID_ADDRESS_FORMAT for invalid address', () => { + const { isValidHexAddress } = jest.requireMock('@metamask/utils'); + isValidHexAddress.mockReturnValueOnce(false); + + const accountId = 'eip155:56:invalid-address' as CaipAccountId; + + expect(() => service.getUserAddressFromAccountId(accountId)).toThrow( + 'INVALID_ADDRESS_FORMAT', + ); + }); + + it('getUserAddressWithDefault uses provided accountId', async () => { + const accountId = + 'eip155:56:0x9999999999999999999999999999999999999999' as CaipAccountId; + + const address = await service.getUserAddressWithDefault(accountId); + + expect(address).toBe('0x9999999999999999999999999999999999999999'); + }); + + it('getUserAddressWithDefault falls back to getCurrentAccountId', async () => { + const address = await service.getUserAddressWithDefault(); + + expect(address).toBe(mockEvmAccount.address); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/MarketDataService.test.ts b/packages/perps-controller/tests/src/services/MarketDataService.test.ts new file mode 100644 index 0000000000..28cfc5f9c2 --- /dev/null +++ b/packages/perps-controller/tests/src/services/MarketDataService.test.ts @@ -0,0 +1,1128 @@ +import type { CandlePeriod } from '../../../src/constants/chartConfig'; +import { MarketDataService } from '../../../src/services/MarketDataService'; +import type { ServiceContext } from '../../../src/services/ServiceContext'; +import type { + PerpsProvider, + Position, + AccountState, + Order, + OrderFill, + Funding, + MarketInfo, + FeeCalculationResult, + FeeCalculationParams, + AssetRoute, + PerpsPlatformDependencies, +} from '../../../src/types'; +import type { CandleData } from '../../../src/types/perps-types'; +import { resetPerpsRestCacheForTests } from '../../../src/utils/coalescePerpsRestRequest'; +/* eslint-disable */ +import { + createMockHyperLiquidProvider, + createMockPosition, + createMockOrder, +} from '../../helpers/providerMocks'; +import { + createMockServiceContext, + createMockInfrastructure, +} from '../../helpers/serviceMocks'; + +jest.mock('uuid', () => ({ v4: () => 'mock-trace-id' })); + +describe('MarketDataService', () => { + let mockProvider: jest.Mocked; + let mockContext: ServiceContext; + let mockDeps: jest.Mocked; + let marketDataService: MarketDataService; + + beforeEach(() => { + mockProvider = + createMockHyperLiquidProvider() as unknown as jest.Mocked; + mockDeps = createMockInfrastructure(); + marketDataService = new MarketDataService(mockDeps); + mockContext = createMockServiceContext({ + errorContext: { controller: 'MarketDataService', method: 'test' }, + }); + jest.clearAllMocks(); + // REST coalesce cache is module-scoped and persists across tests; reset + // so each test starts from a clean slate. + resetPerpsRestCacheForTests(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('getPositions', () => { + it('fetches and returns positions successfully', async () => { + const mockPositions: Position[] = [createMockPosition()]; + mockProvider.getPositions.mockResolvedValue(mockPositions); + + const result = await marketDataService.getPositions({ + provider: mockProvider, + context: mockContext, + }); + + expect(result).toEqual(mockPositions); + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Perps Get Positions' }), + ); + expect(mockDeps.tracer.endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Perps Get Positions', + data: { success: true }, + }), + ); + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + }); + + it('updates state with lastUpdateTimestamp on success', async () => { + const mockPositions: Position[] = [createMockPosition()]; + mockProvider.getPositions.mockResolvedValue(mockPositions); + + await marketDataService.getPositions({ + provider: mockProvider, + context: mockContext, + }); + + expect(mockContext.stateManager?.update).toHaveBeenCalledWith( + expect.any(Function), + ); + }); + + it('handles errors and updates state', async () => { + const mockError = new Error('Network error'); + mockProvider.getPositions.mockRejectedValue(mockError); + + await expect( + marketDataService.getPositions({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow('Network error'); + + expect(mockDeps.tracer.endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ success: false }), + }), + ); + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + }); + + it('works without stateManager', async () => { + const mockPositions: Position[] = [createMockPosition()]; + mockProvider.getPositions.mockResolvedValue(mockPositions); + const contextWithoutState = createMockServiceContext({ + errorContext: { controller: 'MarketDataService', method: 'test' }, + stateManager: undefined, + }); + + const result = await marketDataService.getPositions({ + provider: mockProvider, + context: contextWithoutState, + }); + + expect(result).toEqual(mockPositions); + }); + + it('passes params to provider', async () => { + const mockPositions: Position[] = []; + mockProvider.getPositions.mockResolvedValue(mockPositions); + const params = { skipCache: true }; + + await marketDataService.getPositions({ + provider: mockProvider, + params, + context: mockContext, + }); + + expect(mockProvider.getPositions).toHaveBeenCalledWith(params); + }); + + it('handles provider exception during getPositions', async () => { + const error = new Error('Network timeout'); + mockProvider.getPositions.mockRejectedValue(error); + + await expect( + marketDataService.getPositions({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow('Network timeout'); + + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + }); + }); + + describe('getOrderFills', () => { + it('fetches and returns order fills successfully', async () => { + const mockOrderFills: OrderFill[] = [ + { + orderId: 'fill-1', + symbol: 'BTC', + side: 'buy', + price: '50000', + size: '0.1', + pnl: '100', + direction: 'long', + fee: '5', + feeToken: 'USDC', + timestamp: Date.now(), + }, + ]; + mockProvider.getOrderFills.mockResolvedValue(mockOrderFills); + + const result = await marketDataService.getOrderFills({ + provider: mockProvider, + context: mockContext, + }); + + expect(result).toEqual(mockOrderFills); + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Perps Order Fills Fetch' }), + ); + expect(mockDeps.tracer.endTrace).toHaveBeenCalledWith( + expect.objectContaining({ data: { success: true } }), + ); + }); + + it('handles errors and logs them', async () => { + const mockError = new Error('API error'); + mockProvider.getOrderFills.mockRejectedValue(mockError); + + await expect( + marketDataService.getOrderFills({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow('API error'); + + expect(mockDeps.logger.error).toHaveBeenCalledWith( + mockError, + expect.objectContaining({ + tags: expect.objectContaining({ feature: 'perps' }), + }), + ); + }); + + it('passes params to provider', async () => { + mockProvider.getOrderFills.mockResolvedValue([]); + const params = { startTime: Date.now() - 86400000, limit: 50 }; + + await marketDataService.getOrderFills({ + provider: mockProvider, + params, + context: mockContext, + }); + + expect(mockProvider.getOrderFills).toHaveBeenCalledWith(params, { + forceRefresh: undefined, + }); + }); + }); + + describe('getOrders', () => { + it('fetches and returns orders successfully', async () => { + const mockOrders: Order[] = [createMockOrder()]; + mockProvider.getOrders.mockResolvedValue(mockOrders); + + const result = await marketDataService.getOrders({ + provider: mockProvider, + context: mockContext, + }); + + expect(result).toEqual(mockOrders); + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Perps Orders Fetch' }), + ); + expect(mockDeps.tracer.endTrace).toHaveBeenCalledWith( + expect.objectContaining({ data: { success: true } }), + ); + }); + + it('handles errors and logs them', async () => { + const mockError = new Error('Failed to fetch orders'); + mockProvider.getOrders.mockRejectedValue(mockError); + + await expect( + marketDataService.getOrders({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow('Failed to fetch orders'); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + expect(mockDeps.tracer.endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ success: false }), + }), + ); + }); + }); + + describe('getOpenOrders', () => { + it('fetches open orders successfully', async () => { + const mockOrders: Order[] = [createMockOrder({ status: 'open' })]; + mockProvider.getOpenOrders.mockResolvedValue(mockOrders); + + const result = await marketDataService.getOpenOrders({ + provider: mockProvider, + context: mockContext, + }); + + expect(result).toEqual(mockOrders); + expect(mockDeps.tracer.trace).toHaveBeenCalled(); + expect(mockDeps.tracer.setMeasurement).toHaveBeenCalled(); + }); + + it('handles errors in open orders fetch', async () => { + const mockError = new Error('Connection timeout'); + mockProvider.getOpenOrders.mockRejectedValue(mockError); + + await expect( + marketDataService.getOpenOrders({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow('Connection timeout'); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('getFunding', () => { + it('fetches funding rates successfully', async () => { + const mockFunding: Funding[] = [ + { + symbol: 'BTC', + amountUsd: '10', + rate: '0.0001', + timestamp: Date.now(), + }, + ]; + mockProvider.getFunding.mockResolvedValue(mockFunding); + + const result = await marketDataService.getFunding({ + provider: mockProvider, + context: mockContext, + }); + + expect(result).toEqual(mockFunding); + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Perps Funding Fetch' }), + ); + }); + + it('handles funding fetch errors', async () => { + const mockError = new Error('Funding data unavailable'); + mockProvider.getFunding.mockRejectedValue(mockError); + + await expect( + marketDataService.getFunding({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow('Funding data unavailable'); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('REST request coalesce', () => { + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); + }); + + it('dedupes concurrent getOrderFills calls with identical params', async () => { + let resolveFetch: (value: OrderFill[]) => void = () => undefined; + mockProvider.getOrderFills.mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + const a = marketDataService.getOrderFills({ + provider: mockProvider, + params: { aggregateByTime: false }, + context: mockContext, + }); + const b = marketDataService.getOrderFills({ + provider: mockProvider, + params: { aggregateByTime: false }, + context: mockContext, + }); + + // Flush microtasks so both calls reach coalescePerpsRestRequest and the + // provider mock captures the real resolver. Without this, resolveFetch + // still points at the initial stub and both pending fetches hang. + await Promise.resolve(); + await Promise.resolve(); + resolveFetch([]); + await Promise.all([a, b]); + + expect(mockProvider.getOrderFills).toHaveBeenCalledTimes(1); + }); + + it('returns cached result for second getOrders call within TTL', async () => { + mockProvider.getOrders.mockResolvedValue([]); + + await marketDataService.getOrders({ + provider: mockProvider, + context: mockContext, + }); + await marketDataService.getOrders({ + provider: mockProvider, + context: mockContext, + }); + + expect(mockProvider.getOrders).toHaveBeenCalledTimes(1); + }); + + it('bypasses cache when forceRefresh is true on getFunding', async () => { + mockProvider.getFunding.mockResolvedValue([]); + + await marketDataService.getFunding({ + provider: mockProvider, + context: mockContext, + }); + await marketDataService.getFunding({ + provider: mockProvider, + context: mockContext, + forceRefresh: true, + }); + + expect(mockProvider.getFunding).toHaveBeenCalledTimes(2); + }); + + it('bypasses coalesce for paginated getOrderFills (limit/endTime)', async () => { + mockProvider.getOrderFills.mockResolvedValue([]); + + await marketDataService.getOrderFills({ + provider: mockProvider, + params: { limit: 50 }, + context: mockContext, + }); + await marketDataService.getOrderFills({ + provider: mockProvider, + params: { limit: 50 }, + context: mockContext, + }); + + expect(mockProvider.getOrderFills).toHaveBeenCalledTimes(2); + }); + + it('buckets getOrderFills by startTime day so 90d and all-history callers do not collide', async () => { + mockProvider.getOrderFills + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + // Caller A — unbounded (no startTime) + await marketDataService.getOrderFills({ + provider: mockProvider, + params: { aggregateByTime: false }, + context: mockContext, + }); + // Caller B — 90-day window (startTime set) + await marketDataService.getOrderFills({ + provider: mockProvider, + params: { + aggregateByTime: false, + startTime: Date.now() - 90 * 86_400_000, + }, + context: mockContext, + }); + + expect(mockProvider.getOrderFills).toHaveBeenCalledTimes(2); + }); + + it('keys cache by resolved account so account switch does not serve stale data', async () => { + mockProvider.getOrders.mockResolvedValue([]); + + // Caller A — resolved account 0x...001 + (mockProvider.getCurrentAccountId as jest.Mock).mockResolvedValueOnce( + 'eip155:1:0x0000000000000000000000000000000000000001', + ); + await marketDataService.getOrders({ + provider: mockProvider, + context: mockContext, + }); + + // Caller B — resolved account 0x...002 (user switched accounts) + (mockProvider.getCurrentAccountId as jest.Mock).mockResolvedValueOnce( + 'eip155:1:0x0000000000000000000000000000000000000002', + ); + await marketDataService.getOrders({ + provider: mockProvider, + context: mockContext, + }); + + // Two distinct accounts must each trigger a real fetch rather than + // sharing a single cached payload via a 'default' sentinel key. + expect(mockProvider.getOrders).toHaveBeenCalledTimes(2); + }); + }); + + describe('getAccountState', () => { + it('fetches account state and updates state', async () => { + const mockAccountState: AccountState = { + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '15000', + marginUsed: '5000', + unrealizedPnl: '1000', + returnOnEquity: '0.2', + }; + mockProvider.getAccountState.mockResolvedValue(mockAccountState); + + const result = await marketDataService.getAccountState({ + provider: mockProvider, + context: mockContext, + }); + + expect(result).toEqual(mockAccountState); + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Perps Get Account State' }), + ); + }); + + it('throws error when account state is null', async () => { + mockProvider.getAccountState.mockResolvedValue(null as never); + + await expect( + marketDataService.getAccountState({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow( + 'Failed to get account state: received null/undefined response', + ); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + + it('handles errors and updates error state', async () => { + const mockError = new Error('Account fetch failed'); + mockProvider.getAccountState.mockRejectedValue(mockError); + + await expect( + marketDataService.getAccountState({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow('Account fetch failed'); + + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + expect(mockDeps.tracer.endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + success: false, + error: 'Account fetch failed', + }), + }), + ); + }); + + it('passes source param in trace tags', async () => { + const mockAccountState: AccountState = { + spendableBalance: '10000', + withdrawableBalance: '10000', + totalBalance: '15000', + marginUsed: '5000', + unrealizedPnl: '1000', + returnOnEquity: '0.2', + }; + mockProvider.getAccountState.mockResolvedValue(mockAccountState); + + await marketDataService.getAccountState({ + provider: mockProvider, + params: { source: 'user-action' }, + context: mockContext, + }); + + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ + tags: expect.objectContaining({ source: 'user-action' }), + }), + ); + }); + }); + + describe('getHistoricalPortfolio', () => { + it('fetches historical portfolio data successfully', async () => { + const mockResult = { + accountValue1dAgo: '9500', + timestamp: Date.now(), + }; + mockProvider.getHistoricalPortfolio.mockResolvedValue(mockResult); + + const result = await marketDataService.getHistoricalPortfolio({ + provider: mockProvider, + context: mockContext, + }); + + expect(result).toEqual(mockResult); + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Perps Get Historical Portfolio' }), + ); + }); + + it('throws error when provider does not support historical portfolio', async () => { + const providerWithoutMethod = { + ...mockProvider, + getHistoricalPortfolio: undefined, + }; + + await expect( + marketDataService.getHistoricalPortfolio({ + provider: providerWithoutMethod as never, + context: mockContext, + }), + ).rejects.toThrow('Historical portfolio not supported by provider'); + }); + + it('handles errors and updates error state', async () => { + const mockError = new Error('Portfolio data error'); + mockProvider.getHistoricalPortfolio.mockRejectedValue(mockError); + + await expect( + marketDataService.getHistoricalPortfolio({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow('Portfolio data error'); + + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('getMarkets', () => { + it('fetches markets successfully', async () => { + const mockMarkets: MarketInfo[] = [ + { name: 'BTC', szDecimals: 5, maxLeverage: 20, marginTableId: 1 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 15, marginTableId: 2 }, + ]; + mockProvider.getMarkets.mockResolvedValue(mockMarkets); + + const result = await marketDataService.getMarkets({ + provider: mockProvider, + context: mockContext, + }); + + expect(result).toEqual(mockMarkets); + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Perps Get Markets' }), + ); + }); + + it('includes symbol count in trace tags when symbols provided', async () => { + mockProvider.getMarkets.mockResolvedValue([]); + + await marketDataService.getMarkets({ + provider: mockProvider, + params: { symbols: ['BTC', 'ETH', 'SOL'] }, + context: mockContext, + }); + + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ + tags: expect.objectContaining({ symbolCount: '3' }), + }), + ); + }); + + it('handles market fetch errors and updates state', async () => { + const mockError = new Error('Markets unavailable'); + mockProvider.getMarkets.mockRejectedValue(mockError); + + await expect( + marketDataService.getMarkets({ + provider: mockProvider, + context: mockContext, + }), + ).rejects.toThrow('Markets unavailable'); + + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('getAvailableDexs', () => { + it('fetches available DEXs when supported', async () => { + const mockDexs = ['hyperliquid', 'vertex']; + const providerWithDexs = { + ...mockProvider, + getAvailableDexs: jest.fn().mockResolvedValue(mockDexs), + }; + + const result = await marketDataService.getAvailableDexs({ + provider: providerWithDexs as never, + context: mockContext, + }); + + expect(result).toEqual(mockDexs); + }); + + it('throws error when provider does not support HIP-3 DEXs', async () => { + const providerWithoutDexs = { + ...mockProvider, + getAvailableDexs: undefined, + }; + + await expect( + marketDataService.getAvailableDexs({ + provider: providerWithoutDexs as never, + context: mockContext, + }), + ).rejects.toThrow('Provider does not support HIP-3 DEXs'); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('calculateLiquidationPrice', () => { + it('calculates liquidation price successfully', async () => { + const params = { + entryPrice: 50000, + leverage: 10, + direction: 'long' as const, + positionSize: 0.5, + }; + mockProvider.calculateLiquidationPrice.mockResolvedValue('45000'); + + const result = await marketDataService.calculateLiquidationPrice({ + provider: mockProvider, + params, + context: mockContext, + }); + + expect(result).toBe('45000'); + expect(mockProvider.calculateLiquidationPrice).toHaveBeenCalledWith( + params, + ); + }); + + it('handles calculation errors', async () => { + const params = { + entryPrice: 50000, + leverage: 10, + direction: 'long' as const, + positionSize: 0.5, + }; + const mockError = new Error('Calculation failed'); + mockProvider.calculateLiquidationPrice.mockRejectedValue(mockError); + + await expect( + marketDataService.calculateLiquidationPrice({ + provider: mockProvider, + params, + context: mockContext, + }), + ).rejects.toThrow('Calculation failed'); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('calculateMaintenanceMargin', () => { + it('calculates maintenance margin successfully', async () => { + const params = { + asset: 'BTC', + positionSize: 0.5, + }; + mockProvider.calculateMaintenanceMargin.mockResolvedValue(500); + + const result = await marketDataService.calculateMaintenanceMargin({ + provider: mockProvider, + params, + context: mockContext, + }); + + expect(result).toBe(500); + }); + + it('handles maintenance margin errors', async () => { + const params = { + asset: 'BTC', + positionSize: 0.5, + }; + const mockError = new Error('Margin calculation error'); + mockProvider.calculateMaintenanceMargin.mockRejectedValue(mockError); + + await expect( + marketDataService.calculateMaintenanceMargin({ + provider: mockProvider, + params, + context: mockContext, + }), + ).rejects.toThrow('Margin calculation error'); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('getMaxLeverage', () => { + it('fetches max leverage for asset', async () => { + mockProvider.getMaxLeverage.mockResolvedValue(20); + + const result = await marketDataService.getMaxLeverage({ + provider: mockProvider, + asset: 'BTC', + context: mockContext, + }); + + expect(result).toBe(20); + expect(mockProvider.getMaxLeverage).toHaveBeenCalledWith('BTC'); + }); + + it('handles max leverage errors', async () => { + const mockError = new Error('Asset not found'); + mockProvider.getMaxLeverage.mockRejectedValue(mockError); + + await expect( + marketDataService.getMaxLeverage({ + provider: mockProvider, + asset: 'INVALID', + context: mockContext, + }), + ).rejects.toThrow('Asset not found'); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('calculateFees', () => { + it('calculates fees successfully', async () => { + const params: FeeCalculationParams = { + orderType: 'market', + symbol: 'BTC', + amount: '0.1', + isMaker: false, + }; + const mockFees: FeeCalculationResult = { + feeRate: 0.0005, + feeAmount: 2.5, + protocolFeeRate: 0.0003, + protocolFeeAmount: 1.5, + metamaskFeeRate: 0.0002, + }; + mockProvider.calculateFees.mockResolvedValue(mockFees); + + const result = await marketDataService.calculateFees({ + provider: mockProvider, + params, + context: mockContext, + }); + + expect(result).toEqual(mockFees); + }); + + it('handles fee calculation errors', async () => { + const params: FeeCalculationParams = { + orderType: 'limit', + symbol: 'BTC', + amount: '0.1', + isMaker: true, + }; + const mockError = new Error('Fee calculation failed'); + mockProvider.calculateFees.mockRejectedValue(mockError); + + await expect( + marketDataService.calculateFees({ + provider: mockProvider, + params, + context: mockContext, + }), + ).rejects.toThrow('Fee calculation failed'); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('validateOrder', () => { + it('validates order successfully', async () => { + const params = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market' as const, + }; + const mockResult = { isValid: true }; + mockProvider.validateOrder.mockResolvedValue(mockResult); + + const result = await marketDataService.validateOrder({ + provider: mockProvider, + params, + context: mockContext, + }); + + expect(result).toEqual(mockResult); + }); + + it('returns validation error when order invalid', async () => { + const params = { + symbol: 'BTC', + isBuy: true, + size: '0.001', + orderType: 'market' as const, + }; + const mockResult = { isValid: false, error: 'Size too small' }; + mockProvider.validateOrder.mockResolvedValue(mockResult); + + const result = await marketDataService.validateOrder({ + provider: mockProvider, + params, + context: mockContext, + }); + + expect(result).toEqual(mockResult); + }); + + it('handles validation errors', async () => { + const params = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market' as const, + }; + const mockError = new Error('Validation service unavailable'); + mockProvider.validateOrder.mockRejectedValue(mockError); + + await expect( + marketDataService.validateOrder({ + provider: mockProvider, + params, + context: mockContext, + }), + ).rejects.toThrow('Validation service unavailable'); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); + + describe('validateClosePosition', () => { + it('validates close position request', async () => { + const params = { + symbol: 'BTC', + size: '0.5', + }; + const mockResult = { isValid: true }; + mockProvider.validateClosePosition.mockResolvedValue(mockResult); + + const result = await marketDataService.validateClosePosition({ + provider: mockProvider, + params, + context: mockContext, + }); + + expect(result).toEqual(mockResult); + }); + + it('returns error when close position invalid', async () => { + const params = { + symbol: 'BTC', + size: '10', + }; + const mockResult = { isValid: false, error: 'Position size mismatch' }; + mockProvider.validateClosePosition.mockResolvedValue(mockResult); + + const result = await marketDataService.validateClosePosition({ + provider: mockProvider, + params, + context: mockContext, + }); + + expect(result).toEqual(mockResult); + }); + }); + + describe('getWithdrawalRoutes', () => { + it('fetches withdrawal routes successfully', () => { + const mockRoutes: AssetRoute[] = [ + { + assetId: + 'eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831/default', + chainId: 'eip155:42161', + contractAddress: '0xBridgeAddress', + constraints: { minAmount: '10' }, + }, + ]; + mockProvider.getWithdrawalRoutes.mockReturnValue(mockRoutes); + + const result = marketDataService.getWithdrawalRoutes({ + provider: mockProvider, + }); + + expect(result).toEqual(mockRoutes); + }); + + it('returns empty array on error', () => { + mockProvider.getWithdrawalRoutes.mockImplementation(() => { + throw new Error('Routes unavailable'); + }); + + const result = marketDataService.getWithdrawalRoutes({ + provider: mockProvider, + }); + + // Silent fail - withdrawal routes are not critical + expect(result).toEqual([]); + }); + }); + + describe('getBlockExplorerUrl', () => { + it('returns block explorer URL without address', () => { + mockProvider.getBlockExplorerUrl.mockReturnValue( + 'https://explorer.example.com', + ); + + const result = marketDataService.getBlockExplorerUrl({ + provider: mockProvider, + }); + + expect(result).toBe('https://explorer.example.com'); + }); + + it('returns block explorer URL with address', () => { + const address = '0x1234'; + mockProvider.getBlockExplorerUrl.mockReturnValue( + `https://explorer.example.com/address/${address}`, + ); + + const result = marketDataService.getBlockExplorerUrl({ + provider: mockProvider, + address, + }); + + expect(result).toBe(`https://explorer.example.com/address/${address}`); + expect(mockProvider.getBlockExplorerUrl).toHaveBeenCalledWith(address); + }); + }); + + describe('fetchHistoricalCandles', () => { + const mockCandleData: CandleData = { + symbol: 'BTC', + interval: '1h' as CandlePeriod, + candles: [ + { + time: 1700000000, + open: '50000', + high: '51000', + low: '49500', + close: '50500', + volume: '1000', + }, + ], + }; + + it('fetches historical candles successfully', async () => { + mockProvider.fetchHistoricalCandles = jest + .fn() + .mockResolvedValue(mockCandleData); + + const result = await marketDataService.fetchHistoricalCandles({ + provider: mockProvider, + symbol: 'BTC', + interval: '1h' as CandlePeriod, + limit: 100, + context: mockContext, + }); + + expect(result).toEqual(mockCandleData); + expect(mockProvider.fetchHistoricalCandles).toHaveBeenCalledWith({ + symbol: 'BTC', + interval: '1h', + limit: 100, + endTime: undefined, + }); + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Perps Fetch Historical Candles' }), + ); + expect(mockDeps.tracer.endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Perps Fetch Historical Candles', + data: { success: true }, + }), + ); + }); + + it('throws error when provider lacks clientService support', async () => { + const providerWithoutClient = { ...mockProvider }; + + await expect( + marketDataService.fetchHistoricalCandles({ + provider: providerWithoutClient, + symbol: 'BTC', + interval: '1h' as CandlePeriod, + context: mockContext, + }), + ).rejects.toThrow('Historical candles not supported by provider'); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + expect(mockDeps.tracer.endTrace).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ success: false }), + }), + ); + }); + + it('updates error state on failure', async () => { + const mockError = new Error('Network timeout'); + mockProvider.fetchHistoricalCandles = jest + .fn() + .mockRejectedValue(mockError); + + await expect( + marketDataService.fetchHistoricalCandles({ + provider: mockProvider, + symbol: 'BTC', + interval: '1h' as CandlePeriod, + context: mockContext, + }), + ).rejects.toThrow('Network timeout'); + + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + }); + + it('skips Sentry logging for abort errors', async () => { + const abortError = new Error('The operation was aborted'); + abortError.name = 'AbortError'; + mockProvider.fetchHistoricalCandles = jest + .fn() + .mockRejectedValue(abortError); + + await expect( + marketDataService.fetchHistoricalCandles({ + provider: mockProvider, + symbol: 'BTC', + interval: '1h' as CandlePeriod, + context: mockContext, + }), + ).rejects.toThrow(); + + expect(mockDeps.logger.error).not.toHaveBeenCalled(); + expect(mockContext.stateManager?.update).not.toHaveBeenCalled(); + }); + + it('logs to Sentry for real fetch failures', async () => { + const networkError = new Error('Network timeout'); + mockProvider.fetchHistoricalCandles = jest + .fn() + .mockRejectedValue(networkError); + + await expect( + marketDataService.fetchHistoricalCandles({ + provider: mockProvider, + symbol: 'BTC', + interval: '1h' as CandlePeriod, + context: mockContext, + }), + ).rejects.toThrow('Network timeout'); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts new file mode 100644 index 0000000000..f733177cb8 --- /dev/null +++ b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts @@ -0,0 +1,283 @@ +import { RewardsIntegrationService } from '../../../src/services/RewardsIntegrationService'; +import type { PerpsPlatformDependencies } from '../../../src/types'; +/* eslint-disable */ +import { + createMockEvmAccount, + createMockInfrastructure, + createMockMessenger, +} from '../../helpers/serviceMocks'; + +describe('RewardsIntegrationService', () => { + let mockDeps: jest.Mocked; + let mockMessenger: ReturnType; + let service: RewardsIntegrationService; + const mockEvmAccount = createMockEvmAccount(); + + /** + * Helper to set up mockMessenger.call with standard defaults, + * plus optional overrides for specific actions. + */ + const setupMessengerDefaults = (overrides: Record = {}) => { + (mockMessenger.call as jest.Mock).mockImplementation( + (action: string, ...args: unknown[]) => { + if (action in overrides) { + const val = overrides[action]; + return typeof val === 'function' + ? (val as (...a: unknown[]) => unknown)(...args) + : val; + } + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [mockEvmAccount]; + } + if (action === 'NetworkController:getState') { + return { selectedNetworkClientId: 'mainnet' }; + } + if (action === 'NetworkController:getNetworkClientById') { + return { configuration: { chainId: '0x1' } }; + } + return undefined; + }, + ); + }; + + beforeEach(() => { + mockDeps = createMockInfrastructure(); + mockMessenger = createMockMessenger(); + service = new RewardsIntegrationService(mockDeps, mockMessenger); + + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('calculateUserFeeDiscount', () => { + it('calculates fee discount successfully with valid discount', async () => { + const mockDiscountBips = 6500; // 65% + + setupMessengerDefaults(); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(mockDiscountBips); + + const result = await service.calculateUserFeeDiscount(); + + expect(result).toBe(6500); + expect(mockDeps.rewards.getPerpsDiscountForAccount).toHaveBeenCalledWith( + expect.stringMatching(/^eip155:1:0x/), + 10, + ); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'RewardsIntegrationService: Fee discount calculated', + expect.objectContaining({ + discountBips: 6500, + discountPercentage: 65, + }), + ); + }); + + it('returns 0 when no discount available', async () => { + setupMessengerDefaults(); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); + + const result = await service.calculateUserFeeDiscount(); + + expect(result).toBe(0); + }); + + it('returns undefined when rewards subscription state has not hydrated yet', async () => { + setupMessengerDefaults(); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(null); + + const result = await service.calculateUserFeeDiscount(); + + expect(result).toBeUndefined(); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'RewardsIntegrationService: Fee discount unavailable (subscription state not hydrated)', + expect.objectContaining({ + caipAccountId: expect.any(String), + }), + ); + }); + + it('returns undefined when no EVM account found', async () => { + setupMessengerDefaults({ + 'AccountTreeController:getAccountsFromSelectedAccountGroup': [], + }); + + const result = await service.calculateUserFeeDiscount(); + + expect(result).toBeUndefined(); + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'RewardsIntegrationService: No EVM account found for fee discount', + ); + expect( + mockDeps.rewards.getPerpsDiscountForAccount, + ).not.toHaveBeenCalled(); + }); + + it('returns undefined when chain ID not found', async () => { + setupMessengerDefaults({ + 'NetworkController:getNetworkClientById': () => { + throw new Error('Network client not found'); + }, + }); + + const result = await service.calculateUserFeeDiscount(); + + expect(result).toBeUndefined(); + expect( + mockDeps.rewards.getPerpsDiscountForAccount, + ).not.toHaveBeenCalled(); + }); + + it('returns undefined when getFeeDiscount throws error', async () => { + const mockError = new Error('Rewards API error'); + + setupMessengerDefaults(); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockRejectedValue(mockError); + + const result = await service.calculateUserFeeDiscount(); + + expect(result).toBeUndefined(); + expect(mockDeps.logger.error).toHaveBeenCalledWith( + mockError, + expect.objectContaining({ + context: expect.objectContaining({ + name: 'RewardsIntegrationService.calculateUserFeeDiscount', + }), + }), + ); + }); + + it('returns undefined when NetworkController throws error', async () => { + const mockError = new Error('Network error'); + + setupMessengerDefaults({ + 'NetworkController:getState': () => { + throw mockError; + }, + }); + + const result = await service.calculateUserFeeDiscount(); + + expect(result).toBeUndefined(); + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + + it('handles different chain IDs correctly', async () => { + const chains = [ + { chainId: '0x1', name: 'Mainnet' }, + { chainId: '0x89', name: 'Polygon' }, + { chainId: '0xa4b1', name: 'Arbitrum' }, + ]; + + for (const chain of chains) { + jest.clearAllMocks(); + mockDeps = createMockInfrastructure(); + mockMessenger = createMockMessenger(); + service = new RewardsIntegrationService(mockDeps, mockMessenger); + + (mockMessenger.call as jest.Mock).mockImplementation( + (action: string) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [mockEvmAccount]; + } + if (action === 'NetworkController:getState') { + return { selectedNetworkClientId: chain.name.toLowerCase() }; + } + if (action === 'NetworkController:getNetworkClientById') { + return { configuration: { chainId: chain.chainId } }; + } + return undefined; + }, + ); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(5000); + + const result = await service.calculateUserFeeDiscount(); + + expect(result).toBe(5000); + } + }); + + it('calculates discount percentage correctly in logs', async () => { + const testCases = [ + { bips: 6500, percentage: 65 }, + { bips: 5000, percentage: 50 }, + { bips: 2500, percentage: 25 }, + { bips: 1000, percentage: 10 }, + { bips: 0, percentage: 0 }, + ]; + + for (const testCase of testCases) { + jest.clearAllMocks(); + + setupMessengerDefaults(); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(testCase.bips); + + await service.calculateUserFeeDiscount(); + + expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( + 'RewardsIntegrationService: Fee discount calculated', + expect.objectContaining({ + discountBips: testCase.bips, + discountPercentage: testCase.percentage, + }), + ); + } + }); + }); + + describe('instance isolation', () => { + it('each instance uses its own deps', async () => { + const mockDeps2 = createMockInfrastructure(); + const mockMessenger2 = createMockMessenger(); + const service2 = new RewardsIntegrationService(mockDeps2, mockMessenger2); + + // First service - no EVM account + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return []; + } + return undefined; + }); + await service.calculateUserFeeDiscount(); + + // Second service - no EVM account + (mockMessenger2.call as jest.Mock).mockImplementation( + (action: string) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return []; + } + return undefined; + }, + ); + await service2.calculateUserFeeDiscount(); + + // Each instance should use its own logger + expect(mockDeps.debugLogger.log).toHaveBeenCalledTimes(1); + expect(mockDeps2.debugLogger.log).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/TradingReadinessCache.test.ts b/packages/perps-controller/tests/src/services/TradingReadinessCache.test.ts new file mode 100644 index 0000000000..7538148633 --- /dev/null +++ b/packages/perps-controller/tests/src/services/TradingReadinessCache.test.ts @@ -0,0 +1,776 @@ +/* eslint-disable */ +import { + TradingReadinessCache, + PerpsSigningCache, +} from '../../../src/services/TradingReadinessCache'; + +describe('TradingReadinessCache / PerpsSigningCache', () => { + // Both exports reference the same singleton instance + describe('Singleton Pattern', () => { + it('TradingReadinessCache and PerpsSigningCache are the same instance', () => { + expect(TradingReadinessCache).toBe(PerpsSigningCache); + }); + }); + + beforeEach(() => { + // Clear all cache entries before each test + TradingReadinessCache.clearAll(); + }); + + describe('DEX Abstraction (Legacy API)', () => { + const network = 'mainnet' as const; + const userAddress = '0x1234567890123456789012345678901234567890'; + + describe('get()', () => { + it('returns undefined when no entry exists', () => { + const result = TradingReadinessCache.get(network, userAddress); + expect(result).toBeUndefined(); + }); + + it('returns entry with correct structure when set', () => { + TradingReadinessCache.set(network, userAddress, { + attempted: true, + enabled: true, + }); + + const result = TradingReadinessCache.get(network, userAddress); + expect(result).toEqual({ + attempted: true, + enabled: true, + timestamp: expect.any(Number), + }); + }); + + it('normalizes address to lowercase for cache key', () => { + const mixedCaseAddress = '0xAbCdEf1234567890123456789012345678901234'; + TradingReadinessCache.set(network, mixedCaseAddress, { + attempted: true, + enabled: false, + }); + + // Should be accessible with lowercase address + const result = TradingReadinessCache.get( + network, + mixedCaseAddress.toLowerCase(), + ); + expect(result).toBeDefined(); + expect(result?.attempted).toBe(true); + }); + }); + + describe('set()', () => { + it('creates new entry when none exists', () => { + expect(TradingReadinessCache.size()).toBe(0); + + TradingReadinessCache.set(network, userAddress, { + attempted: true, + enabled: true, + }); + + expect(TradingReadinessCache.size()).toBe(1); + }); + + it('updates timestamp on each set', () => { + // Use fake timers to control timestamp + jest.useFakeTimers(); + const startTime = Date.now(); + + TradingReadinessCache.set(network, userAddress, { + attempted: true, + enabled: false, + }); + const firstTimestamp = TradingReadinessCache.get( + network, + userAddress, + )?.timestamp; + + // Advance time by 100ms + jest.advanceTimersByTime(100); + + TradingReadinessCache.set(network, userAddress, { + attempted: true, + enabled: true, + }); + const secondTimestamp = TradingReadinessCache.get( + network, + userAddress, + )?.timestamp; + + expect(firstTimestamp).toBeGreaterThanOrEqual(startTime); + expect(secondTimestamp).toBeGreaterThan(firstTimestamp as number); + + jest.useRealTimers(); + }); + + it('differentiates between mainnet and testnet', () => { + TradingReadinessCache.set('mainnet', userAddress, { + attempted: true, + enabled: true, + }); + TradingReadinessCache.set('testnet', userAddress, { + attempted: true, + enabled: false, + }); + + expect(TradingReadinessCache.get('mainnet', userAddress)?.enabled).toBe( + true, + ); + expect(TradingReadinessCache.get('testnet', userAddress)?.enabled).toBe( + false, + ); + }); + }); + }); + + describe('Builder Fee API', () => { + const network = 'testnet' as const; + const userAddress = '0xBuilderFeeUser123456789012345678901234'; + + describe('getBuilderFee()', () => { + it('returns undefined when no entry exists', () => { + const result = PerpsSigningCache.getBuilderFee(network, userAddress); + expect(result).toBeUndefined(); + }); + + it('returns builder fee state when set', () => { + PerpsSigningCache.setBuilderFee(network, userAddress, { + attempted: true, + success: true, + }); + + const result = PerpsSigningCache.getBuilderFee(network, userAddress); + expect(result).toEqual({ + attempted: true, + success: true, + }); + }); + }); + + describe('setBuilderFee()', () => { + it('creates entry if it does not exist', () => { + PerpsSigningCache.setBuilderFee(network, userAddress, { + attempted: true, + success: false, + }); + + const result = PerpsSigningCache.getBuilderFee(network, userAddress); + expect(result?.attempted).toBe(true); + expect(result?.success).toBe(false); + }); + + it('updates existing entry without affecting other fields', () => { + // Set DEX abstraction first + TradingReadinessCache.set(network, userAddress, { + attempted: true, + enabled: true, + }); + + // Set builder fee + PerpsSigningCache.setBuilderFee(network, userAddress, { + attempted: true, + success: true, + }); + + // Both should be preserved + const dexResult = TradingReadinessCache.get(network, userAddress); + const builderResult = PerpsSigningCache.getBuilderFee( + network, + userAddress, + ); + + expect(dexResult?.enabled).toBe(true); + expect(builderResult?.success).toBe(true); + }); + }); + }); + + describe('Referral API', () => { + const network = 'mainnet' as const; + const userAddress = '0xReferralUser1234567890123456789012345'; + + describe('getReferral()', () => { + it('returns undefined when no entry exists', () => { + const result = PerpsSigningCache.getReferral(network, userAddress); + expect(result).toBeUndefined(); + }); + + it('returns referral state when set', () => { + PerpsSigningCache.setReferral(network, userAddress, { + attempted: true, + success: false, + }); + + const result = PerpsSigningCache.getReferral(network, userAddress); + expect(result).toEqual({ + attempted: true, + success: false, + }); + }); + }); + + describe('setReferral()', () => { + it('creates entry if it does not exist', () => { + PerpsSigningCache.setReferral(network, userAddress, { + attempted: true, + success: true, + }); + + const result = PerpsSigningCache.getReferral(network, userAddress); + expect(result?.attempted).toBe(true); + expect(result?.success).toBe(true); + }); + }); + }); + + describe('In-Flight Lock Methods', () => { + const network = 'mainnet' as const; + const userAddress = '0xInFlightUser12345678901234567890123456'; + + describe('isInFlight()', () => { + it('returns undefined when no in-flight operation', () => { + const result = PerpsSigningCache.isInFlight( + 'unifiedAccount', + network, + userAddress, + ); + expect(result).toBeUndefined(); + }); + + it('returns promise when operation is in-flight', () => { + PerpsSigningCache.setInFlight('unifiedAccount', network, userAddress); + + const result = PerpsSigningCache.isInFlight( + 'unifiedAccount', + network, + userAddress, + ); + expect(result).toBeInstanceOf(Promise); + }); + + it('differentiates by operation type', () => { + // Use unique addresses to avoid state pollution from other tests + const uniqueAddress = '0xUniqueAddressForDifferentiationTest123'; + + // Set only builderFee in-flight + const completeBuilder = PerpsSigningCache.setInFlight( + 'builderFee', + network, + uniqueAddress, + ); + + // Different operation type should not be in-flight + expect( + PerpsSigningCache.isInFlight( + 'unifiedAccount', + network, + uniqueAddress, + ), + ).toBeUndefined(); + expect( + PerpsSigningCache.isInFlight('builderFee', network, uniqueAddress), + ).toBeInstanceOf(Promise); + + // Clean up + completeBuilder(); + }); + + it('normalizes address to lowercase', () => { + const mixedCaseAddress = '0xMixedCaseUser123456789012345678901234'; + PerpsSigningCache.setInFlight( + 'referral', + network, + mixedCaseAddress.toUpperCase(), + ); + + const result = PerpsSigningCache.isInFlight( + 'referral', + network, + mixedCaseAddress.toLowerCase(), + ); + expect(result).toBeInstanceOf(Promise); + }); + }); + + describe('setInFlight()', () => { + it('returns a completion function', () => { + const complete = PerpsSigningCache.setInFlight( + 'unifiedAccount', + network, + userAddress, + ); + expect(typeof complete).toBe('function'); + }); + + it('calling completion function removes in-flight status', () => { + const complete = PerpsSigningCache.setInFlight( + 'unifiedAccount', + network, + userAddress, + ); + + // Should be in-flight + expect( + PerpsSigningCache.isInFlight('unifiedAccount', network, userAddress), + ).toBeDefined(); + + // Complete the operation + complete(); + + // Should no longer be in-flight + expect( + PerpsSigningCache.isInFlight('unifiedAccount', network, userAddress), + ).toBeUndefined(); + }); + + it('calling completion function resolves waiting promises', async () => { + const complete = PerpsSigningCache.setInFlight( + 'builderFee', + network, + userAddress, + ); + + const waitingPromise = PerpsSigningCache.isInFlight( + 'builderFee', + network, + userAddress, + ); + + // Start waiting + let resolved = false; + const waitPromise = waitingPromise?.then(() => { + resolved = true; + }); + + // Should not be resolved yet + expect(resolved).toBe(false); + + // Complete the operation + complete(); + + // Wait for resolution + await waitPromise; + expect(resolved).toBe(true); + }); + + it('handles multiple concurrent waiters', async () => { + const complete = PerpsSigningCache.setInFlight( + 'referral', + network, + userAddress, + ); + + const waitingPromise = PerpsSigningCache.isInFlight( + 'referral', + network, + userAddress, + ); + + // Multiple waiters + const results: boolean[] = []; + const waiter1 = waitingPromise?.then(() => results.push(true)); + const waiter2 = waitingPromise?.then(() => results.push(true)); + const waiter3 = waitingPromise?.then(() => results.push(true)); + + // Complete and wait + complete(); + await Promise.all([waiter1, waiter2, waiter3]); + + expect(results).toHaveLength(3); + expect(results.every((r) => r === true)).toBe(true); + }); + }); + }); + + describe('General Methods', () => { + const mainnetAddress = '0xMainnetUser1234567890123456789012345'; + const testnetAddress = '0xTestnetUser1234567890123456789012345'; + + describe('clearUnifiedAccount()', () => { + it('clears only DEX abstraction state, preserving other states', () => { + // Setup all three operation states + TradingReadinessCache.set('mainnet', mainnetAddress, { + attempted: true, + enabled: true, + }); + PerpsSigningCache.setBuilderFee('mainnet', mainnetAddress, { + attempted: true, + success: true, + }); + PerpsSigningCache.setReferral('mainnet', mainnetAddress, { + attempted: true, + success: true, + }); + + // Clear only DEX abstraction + TradingReadinessCache.clearUnifiedAccount('mainnet', mainnetAddress); + + // DEX abstraction should be reset + const dexResult = TradingReadinessCache.get('mainnet', mainnetAddress); + expect(dexResult?.attempted).toBe(false); + expect(dexResult?.enabled).toBe(false); + + // Builder fee and referral should be preserved + expect( + PerpsSigningCache.getBuilderFee('mainnet', mainnetAddress)?.success, + ).toBe(true); + expect( + PerpsSigningCache.getReferral('mainnet', mainnetAddress)?.success, + ).toBe(true); + + // Entry should still exist + expect(TradingReadinessCache.size()).toBe(1); + }); + + it('does nothing when entry does not exist', () => { + TradingReadinessCache.clearUnifiedAccount('mainnet', mainnetAddress); + expect(TradingReadinessCache.size()).toBe(0); + }); + }); + + describe('clearBuilderFee()', () => { + it('clears only builder fee state, preserving other states', () => { + // Setup all three operation states + TradingReadinessCache.set('mainnet', mainnetAddress, { + attempted: true, + enabled: true, + }); + PerpsSigningCache.setBuilderFee('mainnet', mainnetAddress, { + attempted: true, + success: true, + }); + PerpsSigningCache.setReferral('mainnet', mainnetAddress, { + attempted: true, + success: true, + }); + + // Clear only builder fee + TradingReadinessCache.clearBuilderFee('mainnet', mainnetAddress); + + // Builder fee should be reset + const builderResult = PerpsSigningCache.getBuilderFee( + 'mainnet', + mainnetAddress, + ); + expect(builderResult?.attempted).toBe(false); + expect(builderResult?.success).toBe(false); + + // DEX abstraction and referral should be preserved + expect( + TradingReadinessCache.get('mainnet', mainnetAddress)?.enabled, + ).toBe(true); + expect( + PerpsSigningCache.getReferral('mainnet', mainnetAddress)?.success, + ).toBe(true); + }); + + it('does nothing when entry does not exist', () => { + TradingReadinessCache.clearBuilderFee('mainnet', mainnetAddress); + expect(TradingReadinessCache.size()).toBe(0); + }); + }); + + describe('clearReferral()', () => { + it('clears only referral state, preserving other states', () => { + // Setup all three operation states + TradingReadinessCache.set('mainnet', mainnetAddress, { + attempted: true, + enabled: true, + }); + PerpsSigningCache.setBuilderFee('mainnet', mainnetAddress, { + attempted: true, + success: true, + }); + PerpsSigningCache.setReferral('mainnet', mainnetAddress, { + attempted: true, + success: true, + }); + + // Clear only referral + TradingReadinessCache.clearReferral('mainnet', mainnetAddress); + + // Referral should be reset + const referralResult = PerpsSigningCache.getReferral( + 'mainnet', + mainnetAddress, + ); + expect(referralResult?.attempted).toBe(false); + expect(referralResult?.success).toBe(false); + + // DEX abstraction and builder fee should be preserved + expect( + TradingReadinessCache.get('mainnet', mainnetAddress)?.enabled, + ).toBe(true); + expect( + PerpsSigningCache.getBuilderFee('mainnet', mainnetAddress)?.success, + ).toBe(true); + }); + + it('does nothing when entry does not exist', () => { + TradingReadinessCache.clearReferral('mainnet', mainnetAddress); + expect(TradingReadinessCache.size()).toBe(0); + }); + }); + + describe('clear()', () => { + it('removes entire cache entry (all signing states)', () => { + TradingReadinessCache.set('mainnet', mainnetAddress, { + attempted: true, + enabled: true, + }); + PerpsSigningCache.setBuilderFee('mainnet', mainnetAddress, { + attempted: true, + success: true, + }); + TradingReadinessCache.set('testnet', testnetAddress, { + attempted: true, + enabled: false, + }); + + expect(TradingReadinessCache.size()).toBe(2); + + TradingReadinessCache.clear('mainnet', mainnetAddress); + + expect(TradingReadinessCache.size()).toBe(1); + // Entire entry including builder fee should be gone + expect( + TradingReadinessCache.get('mainnet', mainnetAddress), + ).toBeUndefined(); + expect( + PerpsSigningCache.getBuilderFee('mainnet', mainnetAddress), + ).toBeUndefined(); + expect( + TradingReadinessCache.get('testnet', testnetAddress), + ).toBeDefined(); + }); + + it('does nothing when entry does not exist', () => { + TradingReadinessCache.set('mainnet', mainnetAddress, { + attempted: true, + enabled: true, + }); + + expect(TradingReadinessCache.size()).toBe(1); + + // Clear non-existent entry + TradingReadinessCache.clear('testnet', testnetAddress); + + expect(TradingReadinessCache.size()).toBe(1); + }); + }); + + describe('clearAll()', () => { + it('removes all cache entries', () => { + TradingReadinessCache.set('mainnet', mainnetAddress, { + attempted: true, + enabled: true, + }); + TradingReadinessCache.set('testnet', testnetAddress, { + attempted: true, + enabled: false, + }); + PerpsSigningCache.setBuilderFee('mainnet', mainnetAddress, { + attempted: true, + success: true, + }); + + expect(TradingReadinessCache.size()).toBe(2); + + TradingReadinessCache.clearAll(); + + expect(TradingReadinessCache.size()).toBe(0); + }); + }); + + describe('getAll()', () => { + it('returns a copy of all cache entries', () => { + TradingReadinessCache.set('mainnet', mainnetAddress, { + attempted: true, + enabled: true, + }); + + const allEntries = TradingReadinessCache.getAll(); + + expect(allEntries).toBeInstanceOf(Map); + expect(allEntries.size).toBe(1); + + // Verify it's a copy (modifying returned map doesn't affect cache) + allEntries.clear(); + expect(TradingReadinessCache.size()).toBe(1); + }); + }); + + describe('size()', () => { + it('returns correct count of entries', () => { + expect(TradingReadinessCache.size()).toBe(0); + + TradingReadinessCache.set('mainnet', mainnetAddress, { + attempted: true, + enabled: true, + }); + expect(TradingReadinessCache.size()).toBe(1); + + TradingReadinessCache.set('testnet', mainnetAddress, { + attempted: true, + enabled: false, + }); + expect(TradingReadinessCache.size()).toBe(2); + }); + }); + + describe('debugState()', () => { + it('returns empty string for empty cache', () => { + const state = TradingReadinessCache.debugState(); + expect(state).toBe('(empty)'); + }); + + it('returns formatted string with all entries', () => { + TradingReadinessCache.set('mainnet', mainnetAddress, { + attempted: true, + enabled: true, + }); + PerpsSigningCache.setBuilderFee('mainnet', mainnetAddress, { + attempted: true, + success: false, + }); + PerpsSigningCache.setReferral('mainnet', mainnetAddress, { + attempted: false, + success: false, + }); + + const state = TradingReadinessCache.debugState(); + + expect(state).toContain('mainnet:'); + expect(state).toContain('unified=true/true'); + expect(state).toContain('builder=true/false'); + expect(state).toContain('referral=false/false'); + }); + }); + }); + + describe('Integration Scenarios', () => { + const userAddress = '0xIntegrationUser12345678901234567890123'; + + it('tracks all three signing operations for same user/network', () => { + const network = 'mainnet' as const; + + // Set all three operations + TradingReadinessCache.set(network, userAddress, { + attempted: true, + enabled: true, + }); + PerpsSigningCache.setBuilderFee(network, userAddress, { + attempted: true, + success: true, + }); + PerpsSigningCache.setReferral(network, userAddress, { + attempted: true, + success: false, + }); + + // All should be retrievable + expect(TradingReadinessCache.get(network, userAddress)?.enabled).toBe( + true, + ); + expect( + PerpsSigningCache.getBuilderFee(network, userAddress)?.success, + ).toBe(true); + expect(PerpsSigningCache.getReferral(network, userAddress)?.success).toBe( + false, + ); + + // Single entry in cache (all operations share same entry) + expect(TradingReadinessCache.size()).toBe(1); + }); + + it('handles concurrent in-flight operations of different types', async () => { + const network = 'mainnet' as const; + + // Start all three operations + const completeDex = PerpsSigningCache.setInFlight( + 'unifiedAccount', + network, + userAddress, + ); + const completeBuilder = PerpsSigningCache.setInFlight( + 'builderFee', + network, + userAddress, + ); + const completeReferral = PerpsSigningCache.setInFlight( + 'referral', + network, + userAddress, + ); + + // All should be in-flight + expect( + PerpsSigningCache.isInFlight('unifiedAccount', network, userAddress), + ).toBeDefined(); + expect( + PerpsSigningCache.isInFlight('builderFee', network, userAddress), + ).toBeDefined(); + expect( + PerpsSigningCache.isInFlight('referral', network, userAddress), + ).toBeDefined(); + + // Complete them in different order + completeBuilder(); + expect( + PerpsSigningCache.isInFlight('builderFee', network, userAddress), + ).toBeUndefined(); + expect( + PerpsSigningCache.isInFlight('unifiedAccount', network, userAddress), + ).toBeDefined(); + + completeDex(); + completeReferral(); + + // All should be cleared + expect( + PerpsSigningCache.isInFlight('unifiedAccount', network, userAddress), + ).toBeUndefined(); + expect( + PerpsSigningCache.isInFlight('referral', network, userAddress), + ).toBeUndefined(); + }); + + it('isolates cache between different users on same network', () => { + const network = 'mainnet' as const; + const user1 = '0xUser1000000000000000000000000000000001'; + const user2 = '0xUser2000000000000000000000000000000002'; + + TradingReadinessCache.set(network, user1, { + attempted: true, + enabled: true, + }); + TradingReadinessCache.set(network, user2, { + attempted: true, + enabled: false, + }); + + expect(TradingReadinessCache.get(network, user1)?.enabled).toBe(true); + expect(TradingReadinessCache.get(network, user2)?.enabled).toBe(false); + }); + + it('isolates cache between networks for same user', () => { + TradingReadinessCache.set('mainnet', userAddress, { + attempted: true, + enabled: true, + }); + TradingReadinessCache.set('testnet', userAddress, { + attempted: true, + enabled: false, + }); + + expect(TradingReadinessCache.get('mainnet', userAddress)?.enabled).toBe( + true, + ); + expect(TradingReadinessCache.get('testnet', userAddress)?.enabled).toBe( + false, + ); + + // Two separate entries + expect(TradingReadinessCache.size()).toBe(2); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts new file mode 100644 index 0000000000..9a23c20a48 --- /dev/null +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -0,0 +1,2413 @@ +import { PERPS_EVENT_VALUE } from '../../../src/constants/eventNames'; +import type { ServiceContext } from '../../../src/services/ServiceContext'; +import { TradingService } from '../../../src/services/TradingService'; +import { PerpsAnalyticsEvent } from '../../../src/types'; +import type { + PerpsProvider, + OrderParams, + OrderResult, + EditOrderParams, + CancelOrderParams, + CancelOrdersParams, + ClosePositionParams, + ClosePositionsParams, + Position, + Order, + UpdatePositionTPSLParams, + PerpsPlatformDependencies, +} from '../../../src/types'; +/* eslint-disable */ +import { createMockHyperLiquidProvider } from '../../helpers/providerMocks'; +import { + createMockServiceContext, + createMockPerpsControllerState, + createMockInfrastructure, +} from '../../helpers/serviceMocks'; + +jest.mock('uuid', () => ({ v4: () => 'mock-trace-id' })); + +describe('TradingService', () => { + let mockProvider: jest.Mocked; + let mockContext: ServiceContext; + let mockDeps: jest.Mocked; + let tradingService: TradingService; + let mockReportOrderToDataLake: jest.Mock; + let mockWithStreamPause: jest.Mock; + let mockGetPositions: jest.Mock; + let mockGetOpenOrders: jest.Mock; + let mockSaveTradeConfiguration: jest.Mock; + let mockRewardsIntegrationService: { calculateUserFeeDiscount: jest.Mock }; + + const createContextWithRewards = (): ServiceContext => + createMockServiceContext({ + errorContext: { controller: 'TradingService', method: 'test' }, + stateManager: { + update: jest.fn(), + getState: jest.fn(() => createMockPerpsControllerState()), + }, + }); + + beforeEach(() => { + mockDeps = createMockInfrastructure(); + tradingService = new TradingService(mockDeps); + mockRewardsIntegrationService = { + calculateUserFeeDiscount: jest.fn().mockResolvedValue(undefined), + }; + // Set controller dependencies for fee discount calculation + tradingService.setControllerDependencies({ + rewardsIntegrationService: mockRewardsIntegrationService as never, + }); + mockProvider = + createMockHyperLiquidProvider() as unknown as jest.Mocked; + mockSaveTradeConfiguration = jest.fn(); + mockContext = createMockServiceContext({ + errorContext: { controller: 'TradingService', method: 'test' }, + stateManager: { + update: jest.fn(), + getState: jest.fn(() => createMockPerpsControllerState()), + }, + saveTradeConfiguration: mockSaveTradeConfiguration, + }); + mockReportOrderToDataLake = jest.fn().mockResolvedValue(undefined); + mockWithStreamPause = jest.fn(async (callback) => await callback()); + mockGetPositions = jest.fn().mockResolvedValue([]); + mockGetOpenOrders = jest.fn().mockResolvedValue([]); + + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('placeOrder', () => { + it('places order successfully without fee discount', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + filledSize: '0.1', + averagePrice: '50000', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + const result = await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(result).toEqual(mockOrderResult); + expect(mockProvider.placeOrder).toHaveBeenCalledWith(orderParams); + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(undefined); + }); + + it('places order successfully with fee discount applied and cleared', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + leverage: 10, + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + filledSize: '0.1', + averagePrice: '50000', + }; + const contextWithRewards = createContextWithRewards(); + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + 6500, + ); + + const result = await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: contextWithRewards, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(result).toEqual(mockOrderResult); + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(6500); + expect(mockProvider.placeOrder).toHaveBeenCalledWith(orderParams); + expect(mockProvider.setUserFeeDiscount).toHaveBeenLastCalledWith( + undefined, + ); + }); + + it('clears fee discount when order placement fails', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + const contextWithRewards = createContextWithRewards(); + + mockProvider.placeOrder.mockRejectedValue( + new Error('Order placement failed'), + ); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + 6500, + ); + + await expect( + tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: contextWithRewards, + reportOrderToDataLake: mockReportOrderToDataLake, + }), + ).rejects.toThrow('Order placement failed'); + + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(6500); + expect(mockProvider.setUserFeeDiscount).toHaveBeenLastCalledWith( + undefined, + ); + }); + + it('adds and removes order from pending state optimistically', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + }); + + it('saves trade configuration when leverage is provided', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + leverage: 10, + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockSaveTradeConfiguration).toHaveBeenCalledWith('BTC', 10); + }); + + it('tracks analytics event when order succeeds', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + leverage: 10, + trackingData: { + totalFee: 5, + marketPrice: 50000, + marginUsed: 5000, + metamaskFee: 5, + metamaskFeeRate: 0.001, + feeDiscountPercentage: 0.65, + estimatedPoints: 100, + }, + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + filledSize: '0.1', + averagePrice: '50000', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.TradeTransaction, + expect.objectContaining({ + status: 'executed', + }), + ); + }); + + it('includes trade_with_token and mm_pay fields when trackingData has tradeWithToken and pay token/network', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + leverage: 10, + trackingData: { + totalFee: 0, + marketPrice: 50000, + tradeWithToken: true, + mmPayTokenSelected: 'USDC', + mmPayNetworkSelected: 'ethereum', + }, + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + filledSize: '0.1', + averagePrice: '50000', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.TradeTransaction, + expect.objectContaining({ + status: 'executed', + trade_with_token: true, + mm_pay_token_selected: 'USDC', + mm_pay_network_selected: 'ethereum', + }), + ); + }); + + it('includes mm_pay_token_selected "Perps Balance" when user uses perps balance', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + leverage: 10, + trackingData: { + totalFee: 0, + marketPrice: 50000, + tradeWithToken: false, + }, + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + filledSize: '0.1', + averagePrice: '50000', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.TradeTransaction, + expect.objectContaining({ + status: 'executed', + trade_with_token: false, + mm_pay_token_selected: PERPS_EVENT_VALUE.MM_PAY_TOKEN.PERPS_BALANCE, + }), + ); + }); + + it('tracks analytics event when order fails', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + const mockOrderResult: OrderResult = { + success: false, + error: 'Insufficient margin', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.TradeTransaction, + expect.objectContaining({ + status: 'failed', + }), + ); + }); + + it('reports order to data lake on success (fire-and-forget)', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + takeProfitPrice: '55000', + stopLossPrice: '45000', + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockReportOrderToDataLake).toHaveBeenCalledWith({ + action: 'open', + symbol: 'BTC', + slPrice: 45000, + tpPrice: 55000, + }); + }); + + it('does not throw when data lake reporting fails', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + mockReportOrderToDataLake.mockRejectedValue(new Error('Data lake error')); + + await expect( + tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }), + ).resolves.toBeDefined(); + }); + + it('creates trace for order placement', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.any(String), + id: 'mock-trace-id', + tags: expect.objectContaining({ + payment_token: 'perps_balance', + }), + }), + ); + expect(mockDeps.tracer.endTrace).toHaveBeenCalled(); + }); + + it('adds payment_token tag for order trace (perps_balance when not tradeWithToken)', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'order-123', + filledSize: '0.1', + averagePrice: '50000', + }); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ + tags: expect.objectContaining({ + payment_token: 'perps_balance', + }), + }), + ); + }); + + it('adds payment_token tag for order trace (token symbol when tradeWithToken)', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + trackingData: { + totalFee: 0, + marketPrice: 50000, + tradeWithToken: true, + mmPayTokenSelected: 'ETH', + mmPayNetworkSelected: 'arbitrum', + }, + }; + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'order-123', + filledSize: '0.1', + averagePrice: '50000', + }); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ + tags: expect.objectContaining({ + payment_token: 'ETH', + }), + }), + ); + }); + + it('handles order placement failure', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + mockProvider.placeOrder.mockResolvedValue({ + success: false, + error: 'Insufficient margin', + }); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + const result = await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Insufficient margin'); + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalled(); + }); + + it('handles provider exception during order placement', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + const error = new Error('Network timeout'); + mockProvider.placeOrder.mockRejectedValue(error); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await expect( + tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }), + ).rejects.toThrow('Network timeout'); + + expect(mockDeps.logger.error).toHaveBeenCalledWith( + error, + expect.any(Object), + ); + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalled(); + }); + + it('handles data lake reporting failure', async () => { + const orderParams: OrderParams = { + symbol: 'BTC', + isBuy: true, + size: '0.1', + orderType: 'market', + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + filledSize: '0.1', + averagePrice: '50000', + }; + mockProvider.placeOrder.mockResolvedValue(mockOrderResult); + mockReportOrderToDataLake.mockRejectedValue( + new Error('Data lake unavailable'), + ); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + const result = await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(result.success).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(mockDeps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Data lake unavailable' }), + expect.any(Object), + ); + }); + }); + + describe('editOrder', () => { + it('edits order successfully without fee discount', async () => { + const editParams: EditOrderParams = { + orderId: 'order-123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.2', + orderType: 'limit', + price: '51000', + }, + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.editOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + const result = await tradingService.editOrder({ + provider: mockProvider, + params: editParams, + context: mockContext, + }); + + expect(result).toEqual(mockOrderResult); + expect(mockProvider.editOrder).toHaveBeenCalledWith(editParams); + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(undefined); + }); + + it('edits order successfully with fee discount applied and cleared', async () => { + const editParams: EditOrderParams = { + orderId: 'order-123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.2', + orderType: 'limit', + price: '51000', + }, + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.editOrder.mockResolvedValue(mockOrderResult); + const contextWithRewards = createContextWithRewards(); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + 6500, + ); + + const result = await tradingService.editOrder({ + provider: mockProvider, + params: editParams, + context: contextWithRewards, + }); + + expect(result).toEqual(mockOrderResult); + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(6500); + expect(mockProvider.editOrder).toHaveBeenCalledWith(editParams); + expect(mockProvider.setUserFeeDiscount).toHaveBeenLastCalledWith( + undefined, + ); + }); + + it('tracks analytics event when edit succeeds', async () => { + const editParams: EditOrderParams = { + orderId: 'order-123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.2', + orderType: 'limit', + price: '51000', + }, + }; + const mockOrderResult: OrderResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.editOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.editOrder({ + provider: mockProvider, + params: editParams, + context: mockContext, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.TradeTransaction, + expect.objectContaining({ + status: 'executed', + }), + ); + }); + + it('tracks analytics event when edit fails', async () => { + const editParams: EditOrderParams = { + orderId: 'order-123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.2', + orderType: 'limit', + price: '51000', + }, + }; + const mockOrderResult: OrderResult = { + success: false, + error: 'Order not found', + }; + + mockProvider.editOrder.mockResolvedValue(mockOrderResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.editOrder({ + provider: mockProvider, + params: editParams, + context: mockContext, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.TradeTransaction, + expect.objectContaining({ + status: 'failed', + }), + ); + }); + + it('clears fee discount when edit throws exception', async () => { + const editParams: EditOrderParams = { + orderId: 'order-123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.2', + orderType: 'limit', + price: '51000', + }, + }; + + mockProvider.editOrder.mockRejectedValue(new Error('Edit failed')); + const contextWithRewards = createContextWithRewards(); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + 6500, + ); + + await expect( + tradingService.editOrder({ + provider: mockProvider, + params: editParams, + context: contextWithRewards, + }), + ).rejects.toThrow('Edit failed'); + + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(6500); + expect(mockProvider.setUserFeeDiscount).toHaveBeenLastCalledWith( + undefined, + ); + }); + + it('handles order edit failure', async () => { + const editParams: EditOrderParams = { + orderId: 'order-123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.2', + orderType: 'market', + }, + }; + mockProvider.editOrder.mockResolvedValue({ + success: false, + error: 'Order not found', + }); + + const result = await tradingService.editOrder({ + provider: mockProvider, + params: editParams, + context: mockContext, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Order not found'); + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalled(); + }); + + it('handles provider exception during order edit', async () => { + const editParams: EditOrderParams = { + orderId: 'order-123', + newOrder: { + symbol: 'BTC', + isBuy: true, + size: '0.2', + orderType: 'market', + }, + }; + const error = new Error('Network timeout'); + mockProvider.editOrder.mockRejectedValue(error); + + await expect( + tradingService.editOrder({ + provider: mockProvider, + params: editParams, + context: mockContext, + }), + ).rejects.toThrow('Network timeout'); + + expect(mockDeps.logger.error).toHaveBeenCalledWith( + error, + expect.any(Object), + ); + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalled(); + }); + }); + + describe('cancelOrder', () => { + it('cancels order successfully', async () => { + const cancelParams: CancelOrderParams = { + orderId: 'order-123', + symbol: 'BTC', + }; + const mockResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.cancelOrder.mockResolvedValue(mockResult); + + const result = await tradingService.cancelOrder({ + provider: mockProvider, + params: cancelParams, + context: mockContext, + }); + + expect(result).toEqual(mockResult); + expect(mockProvider.cancelOrder).toHaveBeenCalledWith(cancelParams); + }); + + it('tracks analytics event when cancellation succeeds', async () => { + const cancelParams: CancelOrderParams = { + orderId: 'order-123', + symbol: 'BTC', + }; + const mockResult = { + success: true, + orderId: 'order-123', + }; + + mockProvider.cancelOrder.mockResolvedValue(mockResult); + + await tradingService.cancelOrder({ + provider: mockProvider, + params: cancelParams, + context: mockContext, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.OrderCancelTransaction, + expect.objectContaining({ + status: 'executed', + }), + ); + }); + + it('tracks analytics event when cancellation fails', async () => { + const cancelParams: CancelOrderParams = { + orderId: 'order-123', + symbol: 'BTC', + }; + const mockResult = { + success: false, + error: 'Order not found', + }; + + mockProvider.cancelOrder.mockResolvedValue(mockResult); + + await tradingService.cancelOrder({ + provider: mockProvider, + params: cancelParams, + context: mockContext, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.OrderCancelTransaction, + expect.objectContaining({ + status: 'failed', + }), + ); + }); + + it('logs error when cancellation throws exception', async () => { + const cancelParams: CancelOrderParams = { + orderId: 'order-123', + symbol: 'BTC', + }; + + mockProvider.cancelOrder.mockRejectedValue(new Error('Cancel failed')); + + await expect( + tradingService.cancelOrder({ + provider: mockProvider, + params: cancelParams, + context: mockContext, + }), + ).rejects.toThrow('Cancel failed'); + + expect(mockDeps.logger.error).toHaveBeenCalled(); + }); + + it('handles order cancel failure', async () => { + const cancelParams: CancelOrderParams = { + orderId: 'order-123', + symbol: 'BTC', + }; + mockProvider.cancelOrder.mockResolvedValue({ + success: false, + error: 'Order already filled', + }); + + const result = await tradingService.cancelOrder({ + provider: mockProvider, + params: cancelParams, + context: mockContext, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Order already filled'); + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalled(); + }); + + it('logs error when provider returns a failure result without throwing', async () => { + const cancelParams: CancelOrderParams = { + orderId: 'order-123', + symbol: 'BTC', + }; + mockProvider.cancelOrder.mockResolvedValue({ + success: false, + error: 'Order already filled', + }); + + const result = await tradingService.cancelOrder({ + provider: mockProvider, + params: cancelParams, + context: mockContext, + }); + + expect(result.success).toBe(false); + expect(mockDeps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Order already filled' }), + expect.objectContaining({ + controller: 'TradingService', + method: 'cancelOrder', + symbol: 'BTC', + }), + ); + }); + + it('handles provider exception during order cancel', async () => { + const cancelParams: CancelOrderParams = { + orderId: 'order-123', + symbol: 'BTC', + }; + const error = new Error('Network error'); + mockProvider.cancelOrder.mockRejectedValue(error); + + await expect( + tradingService.cancelOrder({ + provider: mockProvider, + params: cancelParams, + context: mockContext, + }), + ).rejects.toThrow('Network error'); + + expect(mockDeps.logger.error).toHaveBeenCalledWith( + error, + expect.any(Object), + ); + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalled(); + }); + }); + + describe('cancelOrders', () => { + const mockOrders: Order[] = [ + { + orderId: 'order-1', + symbol: 'BTC', + side: 'buy', + orderType: 'limit', + price: '50000', + size: '0.1', + originalSize: '0.1', + filledSize: '0', + remainingSize: '0.1', + status: 'open', + timestamp: 1234567890, + }, + { + orderId: 'order-2', + symbol: 'ETH', + side: 'sell', + orderType: 'market', + detailedOrderType: 'Stop Market', + isTrigger: true, + reduceOnly: true, + price: '3000', + size: '1.0', + originalSize: '1.0', + filledSize: '0', + remainingSize: '1.0', + status: 'open', + timestamp: 1234567891, + }, + { + orderId: 'order-3', + symbol: 'BTC', + side: 'buy', + orderType: 'limit', + detailedOrderType: 'Take Profit Limit', + isTrigger: true, + reduceOnly: true, + price: '55000', + size: '0.1', + originalSize: '0.1', + filledSize: '0', + remainingSize: '0.1', + status: 'open', + timestamp: 1234567892, + }, + ]; + + it('cancels all orders excluding TP/SL when cancelAll is true', async () => { + const params: CancelOrdersParams = { + cancelAll: true, + }; + + mockGetOpenOrders.mockResolvedValue(mockOrders); + (mockProvider.cancelOrders as jest.Mock).mockResolvedValue({ + success: true, + results: [{ success: true, orderId: 'order-1' }], + }); + + const result = await tradingService.cancelOrders({ + provider: mockProvider, + params, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + expect(result.success).toBe(true); + expect(mockProvider.cancelOrders).toHaveBeenCalledWith([ + { symbol: 'BTC', orderId: 'order-1' }, + ]); + }); + + it('allows canceling TP/SL orders when specified by orderId', async () => { + const params: CancelOrdersParams = { + orderIds: ['order-2', 'order-3'], + }; + + mockGetOpenOrders.mockResolvedValue(mockOrders); + (mockProvider.cancelOrders as jest.Mock).mockResolvedValue({ + success: true, + results: [ + { success: true, orderId: 'order-2' }, + { success: true, orderId: 'order-3' }, + ], + }); + + const result = await tradingService.cancelOrders({ + provider: mockProvider, + params, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + expect(result.success).toBe(true); + expect(result.results).toHaveLength(2); + }); + + it('cancels orders for specific coins when provided', async () => { + const params: CancelOrdersParams = { + symbols: ['BTC'], + }; + + mockGetOpenOrders.mockResolvedValue(mockOrders); + (mockProvider.cancelOrders as jest.Mock).mockResolvedValue({ + success: true, + results: [{ success: true, orderId: 'order-1' }], + }); + + const result = await tradingService.cancelOrders({ + provider: mockProvider, + params, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + expect(result.success).toBe(true); + expect(mockProvider.cancelOrders).toHaveBeenCalledWith([ + { symbol: 'BTC', orderId: 'order-1' }, + { symbol: 'BTC', orderId: 'order-3' }, + ]); + }); + + it('returns empty results when no orders match filters', async () => { + const params: CancelOrdersParams = { + symbols: ['SOL'], + }; + + mockGetOpenOrders.mockResolvedValue(mockOrders); + + const result = await tradingService.cancelOrders({ + provider: mockProvider, + params, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + expect(result.success).toBe(false); + expect(result.results).toEqual([]); + expect(mockProvider.cancelOrders).not.toHaveBeenCalled(); + }); + + it('handles partial failures gracefully', async () => { + const params: CancelOrdersParams = { + cancelAll: true, + }; + + mockGetOpenOrders.mockResolvedValue(mockOrders); + (mockProvider.cancelOrders as jest.Mock).mockResolvedValue({ + success: false, + results: [ + { success: true, orderId: 'order-1' }, + { success: false, orderId: 'order-2', error: 'Order not found' }, + ], + }); + + const result = await tradingService.cancelOrders({ + provider: mockProvider, + params, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + expect(result.success).toBe(false); + expect(result.results).toHaveLength(2); + }); + + it('pauses and resumes streams during batch cancellation', async () => { + const params: CancelOrdersParams = { + cancelAll: true, + }; + + mockGetOpenOrders.mockResolvedValue(mockOrders); + (mockProvider.cancelOrders as jest.Mock).mockResolvedValue({ + success: true, + results: [{ success: true, orderId: 'order-1' }], + }); + + await tradingService.cancelOrders({ + provider: mockProvider, + params, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + expect(mockWithStreamPause).toHaveBeenCalled(); + }); + + it('resumes streams even when operation throws error', async () => { + const params: CancelOrdersParams = { + cancelAll: true, + }; + + mockGetOpenOrders.mockResolvedValue(mockOrders); + mockWithStreamPause.mockImplementation( + async (callback) => await callback(), + ); + (mockProvider.cancelOrders as jest.Mock).mockRejectedValue( + new Error('Cancel failed'), + ); + + await expect( + tradingService.cancelOrders({ + provider: mockProvider, + params, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }), + ).rejects.toThrow('Cancel failed'); + + expect(mockWithStreamPause).toHaveBeenCalled(); + }); + + it('uses fallback when provider does not support batch cancellation', async () => { + const params: CancelOrdersParams = { + orderIds: ['order-1', 'order-2'], + }; + + mockGetOpenOrders.mockResolvedValue(mockOrders); + delete mockProvider.cancelOrders; + mockProvider.cancelOrder.mockResolvedValue({ success: true }); + + const result = await tradingService.cancelOrders({ + provider: mockProvider, + params, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + expect(result.results).toHaveLength(2); + expect(mockProvider.cancelOrder).toHaveBeenCalledTimes(2); + }); + + it('logs batch error when provider.cancelOrders returns partial/full failure', async () => { + const params: CancelOrdersParams = { cancelAll: true }; + 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: 'rate limit', + }, + { + orderId: 'order-2', + symbol: 'ETH', + success: false, + error: 'not found', + }, + ], + }); + + const result = await tradingService.cancelOrders({ + provider: mockProvider, + params, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + expect(result.success).toBe(false); + expect(mockDeps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining( + 'cancelOrders batch failure: 2/2 failed', + ), + }), + expect.objectContaining({ + controller: 'TradingService', + method: 'cancelOrders', + }), + ); + }); + + it('does NOT log batch error when using fallback path (provider.cancelOrders undefined)', async () => { + const params: CancelOrdersParams = { cancelAll: true }; + mockGetOpenOrders.mockResolvedValue(mockOrders); + mockWithStreamPause.mockImplementation( + async (callback) => await callback(), + ); + delete mockProvider.cancelOrders; + mockProvider.cancelOrder.mockResolvedValue({ success: true }); + + await tradingService.cancelOrders({ + provider: mockProvider, + params, + context: { ...mockContext, getOpenOrders: mockGetOpenOrders }, + withStreamPause: mockWithStreamPause, + }); + + // Batch-level log must not fire; individual leaf logs cover per-order failures + const batchErrorCalls = ( + mockDeps.logger.error as jest.Mock + ).mock.calls.filter( + ([err]: [Error]) => + err instanceof Error && + err.message.includes('cancelOrders batch failure'), + ); + expect(batchErrorCalls).toHaveLength(0); + }); + }); + + describe('closePosition', () => { + const mockPosition: Position = { + symbol: 'BTC', + size: '0.5', + entryPrice: '50000', + liquidationPrice: '45000', + leverage: { type: 'cross', value: 10 }, + marginUsed: '2500', + maxLeverage: 20, + positionValue: '25000', + returnOnEquity: '0.2', + unrealizedPnl: '5000', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }; + + it('closes position successfully without fee discount', async () => { + const params: ClosePositionParams = { + symbol: 'BTC', + }; + const mockResult: OrderResult = { + success: true, + orderId: 'close-123', + filledSize: '0.5', + averagePrice: '55000', + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.closePosition.mockResolvedValue(mockResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + const result = await tradingService.closePosition({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(result).toEqual(mockResult); + expect(mockProvider.closePosition).toHaveBeenCalledWith(params); + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(undefined); + }); + + it('closes position successfully with fee discount applied and cleared', async () => { + const params: ClosePositionParams = { + symbol: 'BTC', + }; + const mockResult: OrderResult = { + success: true, + orderId: 'close-123', + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.closePosition.mockResolvedValue(mockResult); + const contextWithRewards = createContextWithRewards(); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + 6500, + ); + + const result = await tradingService.closePosition({ + provider: mockProvider, + params, + context: { ...contextWithRewards, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(result).toEqual(mockResult); + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(6500); + expect(mockProvider.closePosition).toHaveBeenCalledWith(params); + expect(mockProvider.setUserFeeDiscount).toHaveBeenLastCalledWith( + undefined, + ); + }); + + it('tracks analytics with PNL calculation', async () => { + const params: ClosePositionParams = { + symbol: 'BTC', + }; + const mockResult: OrderResult = { + success: true, + orderId: 'close-123', + filledSize: '0.5', + averagePrice: '55000', + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.closePosition.mockResolvedValue(mockResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.closePosition({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.PositionCloseTransaction, + expect.objectContaining({ + status: 'executed', + }), + ); + }); + + it('reports order to data lake on successful close', async () => { + const params: ClosePositionParams = { + symbol: 'BTC', + }; + const mockResult: OrderResult = { + success: true, + orderId: 'close-123', + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.closePosition.mockResolvedValue(mockResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.closePosition({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockReportOrderToDataLake).toHaveBeenCalledWith({ + action: 'close', + symbol: 'BTC', + }); + }); + + it('detects direction from position size', async () => { + const shortPosition: Position = { + ...mockPosition, + size: '-0.5', + }; + const params: ClosePositionParams = { + symbol: 'BTC', + }; + const mockResult: OrderResult = { + success: true, + orderId: 'close-123', + }; + + mockGetPositions.mockResolvedValue([shortPosition]); + mockProvider.closePosition.mockResolvedValue(mockResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.closePosition({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.PositionCloseTransaction, + expect.objectContaining({ + direction: expect.any(String), + }), + ); + }); + + it('tracks analytics on position close failure', async () => { + const params: ClosePositionParams = { + symbol: 'BTC', + }; + const mockFailureResult: OrderResult = { + success: false, + error: 'Insufficient liquidity', + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.closePosition.mockResolvedValue(mockFailureResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + const result = await tradingService.closePosition({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(result).toEqual(mockFailureResult); + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.PositionCloseTransaction, + expect.objectContaining({ + status: 'failed', + error_message: 'Insufficient liquidity', + }), + ); + }); + + it('logs error when provider returns a failure result without throwing', async () => { + const params: ClosePositionParams = { symbol: 'BTC' }; + const mockFailureResult: OrderResult = { + success: false, + error: 'Insufficient liquidity', + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.closePosition.mockResolvedValue(mockFailureResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + const result = await tradingService.closePosition({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(result).toEqual(mockFailureResult); + expect(mockDeps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Insufficient liquidity' }), + expect.objectContaining({ + controller: 'TradingService', + method: 'closePosition', + symbol: 'BTC', + }), + ); + }); + }); + + describe('closePositions', () => { + const mockPositions: Position[] = [ + { + symbol: 'BTC', + size: '0.5', + entryPrice: '50000', + liquidationPrice: '45000', + leverage: { type: 'cross', value: 10 }, + marginUsed: '2500', + maxLeverage: 20, + positionValue: '25000', + returnOnEquity: '0.2', + unrealizedPnl: '5000', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }, + { + symbol: 'ETH', + size: '5.0', + entryPrice: '3000', + liquidationPrice: '2700', + leverage: { type: 'cross', value: 10 }, + marginUsed: '1500', + maxLeverage: 20, + positionValue: '15000', + returnOnEquity: '0.1', + unrealizedPnl: '1500', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }, + ]; + + it('closes all positions when closeAll is true', async () => { + const params: ClosePositionsParams = { + closeAll: true, + }; + + mockGetPositions.mockResolvedValue(mockPositions); + (mockProvider.closePositions as jest.Mock).mockResolvedValue({ + success: true, + results: [ + { success: true, orderId: 'close-1', symbol: 'BTC' }, + { success: true, orderId: 'close-2', symbol: 'ETH' }, + ], + }); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + const result = await tradingService.closePositions({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(result.success).toBe(true); + expect(result.results).toHaveLength(2); + }); + + it('closes specific coins when provided', async () => { + const params: ClosePositionsParams = { + symbols: ['BTC'], + }; + + mockGetPositions.mockResolvedValue(mockPositions); + (mockProvider.closePositions as jest.Mock).mockResolvedValue({ + success: true, + results: [{ success: true, orderId: 'close-1', symbol: 'BTC' }], + }); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + const result = await tradingService.closePositions({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(result.success).toBe(true); + expect(result.results).toHaveLength(1); + }); + + it('returns empty results when no positions match', async () => { + const params: ClosePositionsParams = { + symbols: ['SOL'], + }; + + mockGetPositions.mockResolvedValue(mockPositions); + (mockProvider.closePositions as jest.Mock).mockResolvedValue({ + success: false, + successCount: 0, + failureCount: 0, + results: [], + }); + + const result = await tradingService.closePositions({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(result.success).toBe(false); + expect(result.results).toEqual([]); + }); + + it('handles partial failures gracefully', async () => { + const params: ClosePositionsParams = { + closeAll: true, + }; + + mockGetPositions.mockResolvedValue(mockPositions); + (mockProvider.closePositions as jest.Mock).mockResolvedValue({ + success: false, + results: [ + { success: true, orderId: 'close-1', symbol: 'BTC' }, + { success: false, symbol: 'ETH', error: 'Insufficient liquidity' }, + ], + }); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + const result = await tradingService.closePositions({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(result.success).toBe(false); + expect(result.results).toHaveLength(2); + }); + + it('uses fallback when provider does not support batch closing', async () => { + const params: ClosePositionsParams = { + symbols: ['BTC'], + }; + + mockGetPositions.mockResolvedValue(mockPositions); + delete mockProvider.closePositions; + mockProvider.closePosition.mockResolvedValue({ + success: true, + orderId: 'close-1', + }); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + const result = await tradingService.closePositions({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(result.results).toHaveLength(1); + expect(mockProvider.closePosition).toHaveBeenCalledTimes(1); + }); + + it('logs batch error when provider.closePositions returns partial/full failure', async () => { + const params: ClosePositionsParams = { closeAll: true }; + (mockProvider.closePositions as jest.Mock).mockResolvedValue({ + success: false, + successCount: 0, + failureCount: 2, + results: [ + { symbol: 'BTC', success: false, error: 'insufficient liquidity' }, + { symbol: 'ETH', success: false, error: 'min size' }, + ], + }); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + const result = await tradingService.closePositions({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(result.success).toBe(false); + expect(mockDeps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining( + 'closePositions batch failure: 2/2 failed', + ), + }), + expect.objectContaining({ + controller: 'TradingService', + method: 'closePositions', + }), + ); + }); + + it('does NOT log batch error when using fallback path (provider.closePositions undefined)', async () => { + const params: ClosePositionsParams = { symbols: ['BTC'] }; + mockGetPositions.mockResolvedValue(mockPositions); + delete mockProvider.closePositions; + mockProvider.closePosition.mockResolvedValue({ + success: true, + orderId: 'close-1', + }); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.closePositions({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + // Batch-level log must not fire; individual leaf logs cover per-position failures + const batchErrorCalls = ( + mockDeps.logger.error as jest.Mock + ).mock.calls.filter( + ([err]: [Error]) => + err instanceof Error && + err.message.includes('closePositions batch failure'), + ); + expect(batchErrorCalls).toHaveLength(0); + }); + }); + + describe('updatePositionTPSL', () => { + const mockPosition: Position = { + symbol: 'BTC', + size: '0.5', + entryPrice: '50000', + liquidationPrice: '45000', + leverage: { type: 'cross', value: 10 }, + marginUsed: '2500', + maxLeverage: 20, + positionValue: '25000', + returnOnEquity: '0.2', + unrealizedPnl: '5000', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }; + + it('updates TP/SL successfully without fee discount', async () => { + const params: UpdatePositionTPSLParams = { + symbol: 'BTC', + takeProfitPrice: '55000', + stopLossPrice: '45000', + }; + const mockResult: OrderResult = { + success: true, + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.updatePositionTPSL.mockResolvedValue(mockResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + const result = await tradingService.updatePositionTPSL({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(result).toEqual(mockResult); + expect(mockProvider.updatePositionTPSL).toHaveBeenCalledWith(params); + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(undefined); + }); + + it('updates TP/SL successfully with fee discount applied and cleared', async () => { + const params: UpdatePositionTPSLParams = { + symbol: 'BTC', + takeProfitPrice: '55000', + }; + const mockResult: OrderResult = { + success: true, + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.updatePositionTPSL.mockResolvedValue(mockResult); + const contextWithRewards = createContextWithRewards(); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + 6500, + ); + + const result = await tradingService.updatePositionTPSL({ + provider: mockProvider, + params, + context: { ...contextWithRewards, getPositions: mockGetPositions }, + }); + + expect(result).toEqual(mockResult); + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(6500); + expect(mockProvider.updatePositionTPSL).toHaveBeenCalledWith(params); + expect(mockProvider.setUserFeeDiscount).toHaveBeenLastCalledWith( + undefined, + ); + }); + + it('tracks analytics event when update succeeds', async () => { + const params: UpdatePositionTPSLParams = { + symbol: 'BTC', + takeProfitPrice: '55000', + stopLossPrice: '45000', + }; + const mockResult: OrderResult = { + success: true, + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.updatePositionTPSL.mockResolvedValue(mockResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.updatePositionTPSL({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.RiskManagement, + expect.objectContaining({ + status: 'executed', + }), + ); + }); + + it('tracks analytics event when update fails', async () => { + const params: UpdatePositionTPSLParams = { + symbol: 'BTC', + takeProfitPrice: '55000', + }; + const mockResult: OrderResult = { + success: false, + error: 'Invalid price', + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.updatePositionTPSL.mockResolvedValue(mockResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.updatePositionTPSL({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.RiskManagement, + expect.objectContaining({ + status: 'failed', + }), + ); + }); + + it('includes direction and size in analytics', async () => { + const params: UpdatePositionTPSLParams = { + symbol: 'BTC', + stopLossPrice: '45000', + trackingData: { + direction: 'long', + positionSize: 0.5, + source: 'tp_sl_view', + }, + }; + const mockResult: OrderResult = { + success: true, + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.updatePositionTPSL.mockResolvedValue(mockResult); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await tradingService.updatePositionTPSL({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.RiskManagement, + expect.objectContaining({ + direction: expect.any(String), + position_size: expect.any(Number), + }), + ); + }); + + it('clears fee discount when update throws exception', async () => { + const params: UpdatePositionTPSLParams = { + symbol: 'BTC', + takeProfitPrice: '55000', + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.updatePositionTPSL.mockRejectedValue( + new Error('Update failed'), + ); + const contextWithRewards = createContextWithRewards(); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + 6500, + ); + + await expect( + tradingService.updatePositionTPSL({ + provider: mockProvider, + params, + context: { ...contextWithRewards, getPositions: mockGetPositions }, + }), + ).rejects.toThrow('Update failed'); + + expect(mockProvider.setUserFeeDiscount).toHaveBeenCalledWith(6500); + expect(mockProvider.setUserFeeDiscount).toHaveBeenLastCalledWith( + undefined, + ); + }); + + it('logs error with message and context when provider throws', async () => { + const params: UpdatePositionTPSLParams = { + symbol: 'BTC', + takeProfitPrice: '55000', + stopLossPrice: '45000', + }; + + mockGetPositions.mockResolvedValue([mockPosition]); + mockProvider.updatePositionTPSL.mockRejectedValue( + new Error('TPSL provider failure'), + ); + mockRewardsIntegrationService.calculateUserFeeDiscount.mockResolvedValue( + undefined, + ); + + await expect( + tradingService.updatePositionTPSL({ + provider: mockProvider, + params, + context: { ...mockContext, getPositions: mockGetPositions }, + }), + ).rejects.toThrow('TPSL provider failure'); + + expect(mockDeps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ message: 'TPSL provider failure' }), + expect.objectContaining({ + controller: 'TradingService', + method: 'updatePositionTPSL', + symbol: 'BTC', + }), + ); + }); + }); + + describe('updateMargin', () => { + it('updates margin successfully when adding margin', async () => { + const mockResult = { success: true }; + mockProvider.updateMargin = jest.fn().mockResolvedValue(mockResult); + + const result = await tradingService.updateMargin({ + provider: mockProvider, + symbol: 'BTC', + amount: '100', + context: mockContext, + }); + + expect(result).toEqual(mockResult); + expect(mockProvider.updateMargin).toHaveBeenCalledWith({ + symbol: 'BTC', + amount: '100', + }); + }); + + it('updates margin successfully when removing margin', async () => { + const mockResult = { success: true }; + mockProvider.updateMargin = jest.fn().mockResolvedValue(mockResult); + + const result = await tradingService.updateMargin({ + provider: mockProvider, + symbol: 'BTC', + amount: '-50', + context: mockContext, + }); + + expect(result).toEqual(mockResult); + expect(mockProvider.updateMargin).toHaveBeenCalledWith({ + symbol: 'BTC', + amount: '-50', + }); + }); + + it('throws error when provider does not support margin adjustment', async () => { + mockProvider.updateMargin = undefined as never; + + await expect( + tradingService.updateMargin({ + provider: mockProvider, + symbol: 'BTC', + amount: '100', + context: mockContext, + }), + ).rejects.toThrow('Provider does not support margin adjustment'); + }); + + it('returns error when margin update fails', async () => { + const mockResult = { success: false, error: 'Insufficient balance' }; + mockProvider.updateMargin = jest.fn().mockResolvedValue(mockResult); + + const result = await tradingService.updateMargin({ + provider: mockProvider, + symbol: 'BTC', + amount: '100', + context: mockContext, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Insufficient balance'); + }); + + it('tracks analytics on success', async () => { + const mockResult = { success: true }; + mockProvider.updateMargin = jest.fn().mockResolvedValue(mockResult); + + await tradingService.updateMargin({ + provider: mockProvider, + symbol: 'BTC', + amount: '100', + context: mockContext, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.RiskManagement, + expect.objectContaining({ + status: 'executed', + }), + ); + }); + + it('tracks analytics on failure with error message', async () => { + mockProvider.updateMargin = jest + .fn() + .mockRejectedValue(new Error('Network error')); + + await expect( + tradingService.updateMargin({ + provider: mockProvider, + symbol: 'BTC', + amount: '100', + context: mockContext, + }), + ).rejects.toThrow('Network error'); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.RiskManagement, + expect.objectContaining({ + status: 'failed', + }), + ); + }); + + it('updates state on success', async () => { + const mockResult = { success: true }; + mockProvider.updateMargin = jest.fn().mockResolvedValue(mockResult); + + await tradingService.updateMargin({ + provider: mockProvider, + symbol: 'BTC', + amount: '100', + context: mockContext, + }); + + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + }); + + it('creates trace for margin update', async () => { + const mockResult = { success: true }; + mockProvider.updateMargin = jest.fn().mockResolvedValue(mockResult); + + await tradingService.updateMargin({ + provider: mockProvider, + symbol: 'BTC', + amount: '100', + context: mockContext, + }); + + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Perps Update Margin', + id: 'mock-trace-id', + }), + ); + expect(mockDeps.tracer.endTrace).toHaveBeenCalled(); + }); + }); + + describe('flipPosition', () => { + const mockPosition: Position = { + symbol: 'BTC', + size: '0.5', + entryPrice: '50000', + liquidationPrice: '45000', + leverage: { type: 'cross', value: 10 }, + marginUsed: '2500', + maxLeverage: 20, + positionValue: '25000', + returnOnEquity: '0.2', + unrealizedPnl: '5000', + cumulativeFunding: { allTime: '0', sinceOpen: '0', sinceChange: '0' }, + takeProfitCount: 0, + stopLossCount: 0, + }; + + it('places order with 2x position size to flip position', async () => { + const mockResult: OrderResult = { + success: true, + orderId: 'flip-123', + filledSize: '1.0', + averagePrice: '50000', + }; + mockProvider.placeOrder.mockResolvedValue(mockResult); + + await tradingService.flipPosition({ + provider: mockProvider, + position: mockPosition, + context: mockContext, + }); + + // Verify order placed with 2x position size (0.5 * 2 = 1.0) + expect(mockProvider.placeOrder).toHaveBeenCalledWith( + expect.objectContaining({ + symbol: 'BTC', + size: '1', + }), + ); + }); + + it('flips long position to short (isBuy=false)', async () => { + const mockResult: OrderResult = { success: true, orderId: 'flip-123' }; + mockProvider.placeOrder.mockResolvedValue(mockResult); + + // Long position (positive size) + await tradingService.flipPosition({ + provider: mockProvider, + position: { ...mockPosition, size: '0.5' }, + context: mockContext, + }); + + expect(mockProvider.placeOrder).toHaveBeenCalledWith( + expect.objectContaining({ + isBuy: false, + }), + ); + }); + + it('flips short position to long (isBuy=true)', async () => { + const mockResult: OrderResult = { success: true, orderId: 'flip-123' }; + mockProvider.placeOrder.mockResolvedValue(mockResult); + + // Short position (negative size) + await tradingService.flipPosition({ + provider: mockProvider, + position: { ...mockPosition, size: '-0.5' }, + context: mockContext, + }); + + expect(mockProvider.placeOrder).toHaveBeenCalledWith( + expect.objectContaining({ + isBuy: true, + }), + ); + }); + + it('does not pass entry price as currentPrice to the provider', async () => { + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'flip-balance-fixed', + filledSize: '1.0', + averagePrice: '50000', + }); + + const result = await tradingService.flipPosition({ + provider: mockProvider, + position: mockPosition, + context: mockContext, + }); + + expect(result.success).toBe(true); + expect(mockProvider.placeOrder).toHaveBeenCalledWith({ + symbol: 'BTC', + isBuy: false, + size: '1', + orderType: 'market', + leverage: 10, + }); + expect(mockProvider.placeOrder).not.toHaveBeenCalledWith( + expect.objectContaining({ + currentPrice: expect.any(Number), + }), + ); + }); + + it('returns error when order placement fails', async () => { + const mockResult: OrderResult = { + success: false, + error: 'Order rejected', + }; + mockProvider.placeOrder.mockResolvedValue(mockResult); + + const result = await tradingService.flipPosition({ + provider: mockProvider, + position: mockPosition, + context: mockContext, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Order rejected'); + }); + + it('tracks analytics on success', async () => { + const mockResult: OrderResult = { + success: true, + orderId: 'flip-123', + averagePrice: '60000', + }; + mockProvider.placeOrder.mockResolvedValue(mockResult); + + await tradingService.flipPosition({ + provider: mockProvider, + position: mockPosition, + context: mockContext, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.TradeTransaction, + expect.objectContaining({ + status: 'executed', + order_value: 30000, + }), + ); + }); + + it('tracks analytics on failure', async () => { + mockProvider.placeOrder.mockRejectedValue(new Error('Network error')); + + await expect( + tradingService.flipPosition({ + provider: mockProvider, + position: mockPosition, + context: mockContext, + }), + ).rejects.toThrow('Network error'); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.TradeTransaction, + expect.objectContaining({ + status: 'failed', + }), + ); + }); + + it('updates state on success', async () => { + const mockResult: OrderResult = { success: true, orderId: 'flip-123' }; + mockProvider.placeOrder.mockResolvedValue(mockResult); + + await tradingService.flipPosition({ + provider: mockProvider, + position: mockPosition, + context: mockContext, + }); + + expect(mockContext.stateManager?.update).toHaveBeenCalled(); + }); + + it('creates trace for flip position', async () => { + const mockResult: OrderResult = { success: true, orderId: 'flip-123' }; + mockProvider.placeOrder.mockResolvedValue(mockResult); + + await tradingService.flipPosition({ + provider: mockProvider, + position: mockPosition, + context: mockContext, + }); + + expect(mockDeps.tracer.trace).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Perps Flip Position', + id: 'mock-trace-id', + }), + ); + expect(mockDeps.tracer.endTrace).toHaveBeenCalled(); + }); + + it('uses correct order params including leverage', async () => { + const mockResult: OrderResult = { success: true, orderId: 'flip-123' }; + mockProvider.placeOrder.mockResolvedValue(mockResult); + + await tradingService.flipPosition({ + provider: mockProvider, + position: mockPosition, + context: mockContext, + }); + + expect(mockProvider.placeOrder).toHaveBeenCalledWith({ + symbol: 'BTC', + isBuy: false, + size: '1', + orderType: 'market', + leverage: 10, + }); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/types/hyperliquid-types.test.ts b/packages/perps-controller/tests/src/types/hyperliquid-types.test.ts new file mode 100644 index 0000000000..f506659f41 --- /dev/null +++ b/packages/perps-controller/tests/src/types/hyperliquid-types.test.ts @@ -0,0 +1,34 @@ +import { hyperLiquidModeFoldsSpot } from '../../../src/types/hyperliquid-types'; + +describe('hyperLiquidModeFoldsSpot', () => { + it('folds for unifiedAccount', () => { + expect(hyperLiquidModeFoldsSpot('unifiedAccount')).toBe(true); + }); + + it('folds for portfolioMargin', () => { + expect(hyperLiquidModeFoldsSpot('portfolioMargin')).toBe(true); + }); + + it('does not fold for dexAbstraction', () => { + expect(hyperLiquidModeFoldsSpot('dexAbstraction')).toBe(false); + }); + + it('does not fold for default', () => { + expect(hyperLiquidModeFoldsSpot('default')).toBe(false); + }); + + it('does not fold for disabled', () => { + expect(hyperLiquidModeFoldsSpot('disabled')).toBe(false); + }); + + it('fail-closes (no fold) when mode is null', () => { + // Critical: must not over-report withdrawable funds for Standard / + // dexAbstraction users when the abstraction mode hasn't been resolved + // yet (e.g. WS spot push arrives before REST userAbstraction completes). + expect(hyperLiquidModeFoldsSpot(null)).toBe(false); + }); + + it('fail-closes (no fold) when mode is undefined', () => { + expect(hyperLiquidModeFoldsSpot(undefined)).toBe(false); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/accountUtils.test.ts b/packages/perps-controller/tests/src/utils/accountUtils.test.ts new file mode 100644 index 0000000000..79cfbbb5f9 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/accountUtils.test.ts @@ -0,0 +1,439 @@ +/* eslint-disable */ +import { PERPS_CONSTANTS } from '../../../src/constants/perpsConfig'; +import type { AccountState } from '../../../src/types'; +import { + addSpotBalanceToAccountState, + aggregateAccountStates, + calculateWeightedReturnOnEquity, + getSpotBalance, +} from '../../../src/utils/accountUtils'; + +describe('aggregateAccountStates', () => { + const fallback: AccountState = { + spendableBalance: PERPS_CONSTANTS.FallbackDataDisplay, + withdrawableBalance: PERPS_CONSTANTS.FallbackDataDisplay, + totalBalance: PERPS_CONSTANTS.FallbackDataDisplay, + marginUsed: PERPS_CONSTANTS.FallbackDataDisplay, + unrealizedPnl: PERPS_CONSTANTS.FallbackDataDisplay, + returnOnEquity: PERPS_CONSTANTS.FallbackDataDisplay, + }; + + it('returns fallback when given an empty array', () => { + expect(aggregateAccountStates([])).toEqual(fallback); + }); + + it('returns the single state unchanged when given one element', () => { + const single: AccountState = { + spendableBalance: '100', + withdrawableBalance: '100', + totalBalance: '200', + marginUsed: '50', + unrealizedPnl: '10', + returnOnEquity: '20', + }; + expect(aggregateAccountStates([single])).toEqual(single); + }); + + it('sums numeric fields from two states and recalculates ROE', () => { + const stateA: AccountState = { + spendableBalance: '100', + withdrawableBalance: '100', + totalBalance: '200', + marginUsed: '50', + unrealizedPnl: '10', + returnOnEquity: '20', + }; + const stateB: AccountState = { + spendableBalance: '50', + withdrawableBalance: '50', + totalBalance: '150', + marginUsed: '30', + unrealizedPnl: '6', + returnOnEquity: '20', + }; + + const result = aggregateAccountStates([stateA, stateB]); + + expect(parseFloat(result.spendableBalance)).toBe(150); + expect(parseFloat(result.withdrawableBalance)).toBe(150); + expect(parseFloat(result.totalBalance)).toBe(350); + expect(parseFloat(result.marginUsed)).toBe(80); + expect(parseFloat(result.unrealizedPnl)).toBe(16); + // ROE = (16 / 80) * 100 = 20 + expect(parseFloat(result.returnOnEquity)).toBe(20); + }); + + it('sums numeric fields from three states', () => { + const states: AccountState[] = [ + { + spendableBalance: '100', + withdrawableBalance: '100', + totalBalance: '200', + marginUsed: '50', + unrealizedPnl: '10', + returnOnEquity: '20', + }, + { + spendableBalance: '200', + withdrawableBalance: '200', + totalBalance: '300', + marginUsed: '100', + unrealizedPnl: '30', + returnOnEquity: '30', + }, + { + spendableBalance: '50', + withdrawableBalance: '50', + totalBalance: '100', + marginUsed: '50', + unrealizedPnl: '5', + returnOnEquity: '10', + }, + ]; + + const result = aggregateAccountStates(states); + + expect(parseFloat(result.spendableBalance)).toBe(350); + expect(parseFloat(result.withdrawableBalance)).toBe(350); + expect(parseFloat(result.totalBalance)).toBe(600); + expect(parseFloat(result.marginUsed)).toBe(200); + expect(parseFloat(result.unrealizedPnl)).toBe(45); + // ROE = (45 / 200) * 100 = 22.5 + expect(parseFloat(result.returnOnEquity)).toBe(22.5); + }); + + it('does not mutate the input state object', () => { + const single: AccountState = { + spendableBalance: '100', + withdrawableBalance: '100', + totalBalance: '200', + marginUsed: '50', + unrealizedPnl: '10', + returnOnEquity: '99', + }; + const result = aggregateAccountStates([single]); + // result gets recalculated ROE = (10/50)*100 = 20 + expect(result.returnOnEquity).toBe('20'); + // original must be untouched + expect(single.returnOnEquity).toBe('99'); + }); + + it('sets ROE to 0 when total marginUsed is 0', () => { + const state: AccountState = { + spendableBalance: '100', + withdrawableBalance: '100', + totalBalance: '100', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + const result = aggregateAccountStates([state]); + expect(result.returnOnEquity).toBe('0'); + }); + + it('handles negative unrealizedPnl correctly', () => { + const stateA: AccountState = { + spendableBalance: '80', + withdrawableBalance: '80', + totalBalance: '180', + marginUsed: '100', + unrealizedPnl: '-20', + returnOnEquity: '-20', + }; + const stateB: AccountState = { + spendableBalance: '40', + withdrawableBalance: '40', + totalBalance: '90', + marginUsed: '50', + unrealizedPnl: '-10', + returnOnEquity: '-20', + }; + + const result = aggregateAccountStates([stateA, stateB]); + + expect(parseFloat(result.marginUsed)).toBe(150); + expect(parseFloat(result.unrealizedPnl)).toBe(-30); + // ROE = (-30 / 150) * 100 = -20 + expect(parseFloat(result.returnOnEquity)).toBe(-20); + }); + + it('handles decimal values correctly', () => { + const stateA: AccountState = { + spendableBalance: '100.50', + withdrawableBalance: '100.50', + totalBalance: '200.75', + marginUsed: '50.25', + unrealizedPnl: '10.10', + returnOnEquity: '20.1', + }; + const stateB: AccountState = { + spendableBalance: '50.50', + withdrawableBalance: '50.50', + totalBalance: '150.25', + marginUsed: '30.75', + unrealizedPnl: '6.90', + returnOnEquity: '22.4', + }; + + const result = aggregateAccountStates([stateA, stateB]); + + expect(parseFloat(result.spendableBalance)).toBeCloseTo(151, 0); + expect(parseFloat(result.withdrawableBalance)).toBeCloseTo(151, 0); + expect(parseFloat(result.totalBalance)).toBeCloseTo(351, 0); + expect(parseFloat(result.marginUsed)).toBeCloseTo(81, 0); + expect(parseFloat(result.unrealizedPnl)).toBeCloseTo(17, 0); + }); +}); + +describe('spot balance helpers', () => { + it('returns zero spot balance when no spot state is provided', () => { + expect(getSpotBalance()).toBe(0); + }); + + it('bumps totalBalance, spendableBalance, and withdrawableBalance by spot USDC without mutating the input', () => { + const accountState: AccountState = { + spendableBalance: '0', + withdrawableBalance: '0', + totalBalance: '100', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const result = addSpotBalanceToAccountState( + accountState, + { + balances: [ + { coin: 'USDC', total: '25.5' }, + { coin: 'HYPE', total: '0.5' }, + ], + } as never, + { foldIntoCollateral: true }, + ); + + // Only USDC contributes — non-stablecoin spot assets are not convertible + // to perps collateral and must not inflate balances. + expect(result.totalBalance).toBe('125.5'); + expect(result.spendableBalance).toBe('25.5'); + expect(result.withdrawableBalance).toBe('25.5'); + expect(accountState.totalBalance).toBe('100'); + }); + + it('ignores non-collateral spot balances entirely', () => { + const accountState: AccountState = { + spendableBalance: '0', + withdrawableBalance: '0', + totalBalance: '50', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const result = addSpotBalanceToAccountState(accountState, { + balances: [ + { coin: 'HYPE', total: '1000' }, + { coin: 'PURR', total: '5000' }, + ], + } as never); + + expect(result).toBe(accountState); + }); + + it('excludes USDH-only spot balance from funded-state totals', () => { + const accountState: AccountState = { + spendableBalance: '0', + withdrawableBalance: '0', + totalBalance: '0', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const result = addSpotBalanceToAccountState(accountState, { + balances: [ + { coin: 'USDH', total: '75.25' }, + { coin: 'HYPE', total: '999' }, + ], + } as never); + + expect(result).toBe(accountState); + }); + + it('adds only the USDC portion when USDC and USDH are both present', () => { + const accountState: AccountState = { + spendableBalance: '0', + withdrawableBalance: '0', + totalBalance: '10', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const result = addSpotBalanceToAccountState( + accountState, + { + balances: [ + { coin: 'USDC', total: '20' }, + { coin: 'USDH', total: '30' }, + { coin: 'HYPE', total: '9999' }, + ], + } as never, + { foldIntoCollateral: true }, + ); + + expect(result.totalBalance).toBe('30'); + expect(result.spendableBalance).toBe('20'); + expect(result.withdrawableBalance).toBe('20'); + }); + + it('does not fold USDC spot collateral into spendable/withdrawable for Standard modes', () => { + const accountState: AccountState = { + spendableBalance: '7', + withdrawableBalance: '7', + totalBalance: '10', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const result = addSpotBalanceToAccountState( + accountState, + { + balances: [{ coin: 'USDC', total: '25', hold: '5' }], + } as never, + { foldIntoCollateral: false }, + ); + + expect(result.totalBalance).toBe('30'); + expect(result.spendableBalance).toBe('7'); + expect(result.withdrawableBalance).toBe('7'); + }); + + it('keeps spot USDC separate from withdrawable even when withdrawable=0 in Standard mode', () => { + // Standard / DEX-abstraction users with $0 perps withdrawable but free + // spot USDC must NOT see spot fold into withdrawable — withdraw3 only + // draws from the perps ledger in those modes. Folding would surface a + // withdrawable amount the API can't actually fulfill. + const accountState: AccountState = { + spendableBalance: '0', + withdrawableBalance: '0', + totalBalance: '0', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const result = addSpotBalanceToAccountState( + accountState, + { + balances: [{ coin: 'USDC', total: '2500', hold: '0' }], + } as never, + { foldIntoCollateral: false }, + ); + + expect(result.spendableBalance).toBe('0'); + expect(result.withdrawableBalance).toBe('0'); + expect(result.totalBalance).toBe('2500'); + }); + + it('subtracts spot hold from total and only folds free spot into spendable/withdrawable', () => { + const accountState: AccountState = { + spendableBalance: '10', + withdrawableBalance: '10', + totalBalance: '100', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const result = addSpotBalanceToAccountState( + accountState, + { + balances: [{ coin: 'USDC', total: '40', hold: '15' }], + } as never, + { foldIntoCollateral: true }, + ); + + // totalBalance += spotTotal - spotHold = 100 + 40 - 15 = 125 + expect(parseFloat(result.totalBalance)).toBe(125); + // spendable/withdrawable += freeSpot = 10 + (40 - 15) = 35 + expect(parseFloat(result.spendableBalance)).toBe(35); + expect(parseFloat(result.withdrawableBalance)).toBe(35); + }); + + it('returns the input untouched when no collateral spot balance is present', () => { + const accountState: AccountState = { + spendableBalance: '1', + withdrawableBalance: '1', + totalBalance: '2', + marginUsed: '3', + unrealizedPnl: '4', + returnOnEquity: '5', + }; + + const result = addSpotBalanceToAccountState(accountState, { + balances: [], + } as never); + + expect(result).toBe(accountState); + }); + + it('does NOT fold spot into spendable/withdrawable when foldIntoCollateral is false (e.g. HL Standard mode)', () => { + const accountState: AccountState = { + spendableBalance: '5', + withdrawableBalance: '5', + totalBalance: '5', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const result = addSpotBalanceToAccountState( + accountState, + { balances: [{ coin: 'USDC', total: '30' }] } as never, + { foldIntoCollateral: false }, + ); + + // Total still reflects combined wealth (display). + expect(parseFloat(result.totalBalance)).toBe(35); + // Spendable/withdrawable must remain perps-only — spot isn't auto-collateral + // on Standard mode, so surfacing a folded value would mislead the validation + // hook into approving submissions HL will reject. + expect(result.spendableBalance).toBe('5'); + expect(result.withdrawableBalance).toBe('5'); + }); + + it('folds spot into spendable/withdrawable when foldIntoCollateral is explicitly true', () => { + const accountState: AccountState = { + spendableBalance: '5', + withdrawableBalance: '5', + totalBalance: '5', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }; + + const result = addSpotBalanceToAccountState( + accountState, + { balances: [{ coin: 'USDC', total: '30' }] } as never, + { foldIntoCollateral: true }, + ); + + expect(parseFloat(result.totalBalance)).toBe(35); + expect(parseFloat(result.spendableBalance)).toBe(35); + expect(parseFloat(result.withdrawableBalance)).toBe(35); + }); +}); + +describe('calculateWeightedReturnOnEquity', () => { + it('returns 0 for empty array', () => { + expect(calculateWeightedReturnOnEquity([])).toBe('0'); + }); + + it('returns the single account ROE for one account', () => { + const result = calculateWeightedReturnOnEquity([ + { unrealizedPnl: '10', returnOnEquity: '20' }, + ]); + expect(parseFloat(result)).toBeCloseTo(20, 5); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/coalescePerpsRestRequest.test.ts b/packages/perps-controller/tests/src/utils/coalescePerpsRestRequest.test.ts new file mode 100644 index 0000000000..63771180c3 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/coalescePerpsRestRequest.test.ts @@ -0,0 +1,180 @@ +import { + coalescePerpsRestRequest, + resetPerpsRestCacheForTests, +} from '../../../src/utils/coalescePerpsRestRequest'; + +describe('coalescePerpsRestRequest', () => { + beforeEach(() => { + resetPerpsRestCacheForTests(); + jest.useFakeTimers(); + jest.setSystemTime(0); + }); + + afterEach(() => { + jest.useRealTimers(); + resetPerpsRestCacheForTests(); + }); + + it('returns the fetcher result on first call', async () => { + const fetcher = jest.fn().mockResolvedValue('v1'); + + const result = await coalescePerpsRestRequest('k', fetcher); + + expect(result).toBe('v1'); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('shares the in-flight promise across concurrent callers', async () => { + let resolveFetch!: (v: string) => void; + const fetcher = jest.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + const p1 = coalescePerpsRestRequest('k', fetcher); + const p2 = coalescePerpsRestRequest('k', fetcher); + const p3 = coalescePerpsRestRequest('k', fetcher); + + resolveFetch('shared'); + const [r1, r2, r3] = await Promise.all([p1, p2, p3]); + + expect(r1).toBe('shared'); + expect(r2).toBe('shared'); + expect(r3).toBe('shared'); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('serves cached value within the TTL window', async () => { + const fetcher = jest.fn().mockResolvedValue('cached'); + + const first = await coalescePerpsRestRequest('k', fetcher, { + ttlMs: 1000, + }); + jest.setSystemTime(500); + const second = await coalescePerpsRestRequest('k', fetcher, { + ttlMs: 1000, + }); + + expect(first).toBe('cached'); + expect(second).toBe('cached'); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('refetches after the TTL expires', async () => { + const fetcher = jest + .fn() + .mockResolvedValueOnce('first') + .mockResolvedValueOnce('second'); + + const a = await coalescePerpsRestRequest('k', fetcher, { ttlMs: 1000 }); + jest.setSystemTime(1001); + const b = await coalescePerpsRestRequest('k', fetcher, { ttlMs: 1000 }); + + expect(a).toBe('first'); + expect(b).toBe('second'); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it('scopes cache by key', async () => { + const fetcherA = jest.fn().mockResolvedValue('A'); + const fetcherB = jest.fn().mockResolvedValue('B'); + + const rA = await coalescePerpsRestRequest('a', fetcherA); + const rB = await coalescePerpsRestRequest('b', fetcherB); + + expect(rA).toBe('A'); + expect(rB).toBe('B'); + expect(fetcherA).toHaveBeenCalledTimes(1); + expect(fetcherB).toHaveBeenCalledTimes(1); + }); + + it('bypasses cache when forceRefresh is true', async () => { + const fetcher = jest + .fn() + .mockResolvedValueOnce('stale') + .mockResolvedValueOnce('fresh'); + + await coalescePerpsRestRequest('k', fetcher); + const forced = await coalescePerpsRestRequest('k', fetcher, { + forceRefresh: true, + }); + + expect(forced).toBe('fresh'); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it('does not let a stale in-flight resolution clobber a forceRefresh result', async () => { + let resolveStale!: (v: string) => void; + const staleFetcher = jest.fn( + () => + new Promise((resolve) => { + resolveStale = resolve; + }), + ); + const freshFetcher = jest.fn().mockResolvedValue('fresh'); + + const stalePromise = coalescePerpsRestRequest('k', staleFetcher); + const freshPromise = coalescePerpsRestRequest('k', freshFetcher, { + forceRefresh: true, + }); + const freshResult = await freshPromise; + + resolveStale('stale'); + await stalePromise; + + // Next cached read must return the fresh value, not the late stale one. + const cachedFetcher = jest.fn(); + const cachedResult = await coalescePerpsRestRequest('k', cachedFetcher); + + expect(freshResult).toBe('fresh'); + expect(cachedResult).toBe('fresh'); + expect(cachedFetcher).not.toHaveBeenCalled(); + }); + + it('does not cache the value when the fetcher rejects', async () => { + const rejectingFetcher = jest.fn().mockRejectedValue(new Error('boom')); + const retryFetcher = jest.fn().mockResolvedValue('ok'); + + await expect( + coalescePerpsRestRequest('k', rejectingFetcher), + ).rejects.toThrow('boom'); + const retry = await coalescePerpsRestRequest('k', retryFetcher); + + expect(retry).toBe('ok'); + expect(retryFetcher).toHaveBeenCalledTimes(1); + }); + + it('evicts expired entries on TTL-miss', async () => { + const fetcher = jest + .fn() + .mockResolvedValueOnce('first') + .mockResolvedValueOnce('second'); + + // Populate under key A and let it expire. + await coalescePerpsRestRequest('a', fetcher, { ttlMs: 1000 }); + jest.setSystemTime(1001); + // Next call under key A must evict the stale entry before running. + const refreshed = await coalescePerpsRestRequest('a', fetcher, { + ttlMs: 1000, + }); + + expect(refreshed).toBe('second'); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it('resetPerpsRestCacheForTests clears cache and in-flight entries', async () => { + const fetcher = jest + .fn() + .mockResolvedValueOnce('first') + .mockResolvedValueOnce('second'); + + await coalescePerpsRestRequest('k', fetcher); + resetPerpsRestCacheForTests(); + const after = await coalescePerpsRestRequest('k', fetcher); + + expect(after).toBe('second'); + expect(fetcher).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/errorUtils.test.ts b/packages/perps-controller/tests/src/utils/errorUtils.test.ts new file mode 100644 index 0000000000..b948e42768 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/errorUtils.test.ts @@ -0,0 +1,102 @@ +import { + isAbortError, + ensureError, + isKeyringLockedError, +} from '../../../src/utils/errorUtils'; + +describe('errorUtils', () => { + describe('isAbortError', () => { + it('returns true for Error with name AbortError', () => { + const error = new Error('The operation was aborted'); + error.name = 'AbortError'; + + expect(isAbortError(error)).toBe(true); + }); + + it('returns true for Error with "signal is aborted" message', () => { + const error = new Error('AbortError: signal is aborted without reason'); + + expect(isAbortError(error)).toBe(true); + }); + + it('returns true for Error with "The operation was aborted" message', () => { + const error = new Error('The operation was aborted'); + + expect(isAbortError(error)).toBe(true); + }); + + it('returns false for regular Error', () => { + const error = new Error('Network timeout'); + + expect(isAbortError(error)).toBe(false); + }); + + it('returns false for non-Error values', () => { + expect(isAbortError('some string')).toBe(false); + expect(isAbortError(null)).toBe(false); + expect(isAbortError(undefined)).toBe(false); + expect(isAbortError(42)).toBe(false); + }); + + it('returns false for DOMException with non-abort name', () => { + const error = new Error('Something failed'); + error.name = 'TypeError'; + + expect(isAbortError(error)).toBe(false); + }); + }); + + describe('ensureError', () => { + it('returns Error instance unchanged', () => { + const error = new Error('test'); + + expect(ensureError(error)).toBe(error); + }); + + it('wraps string in Error', () => { + const result = ensureError('string error'); + + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe('string error'); + }); + + it('wraps undefined with context', () => { + const result = ensureError(undefined, 'TestContext'); + + expect(result).toBeInstanceOf(Error); + expect(result.message).toContain('Unknown error'); + expect(result.message).toContain('TestContext'); + }); + + it('wraps null with context', () => { + const result = ensureError(null, 'TestContext'); + + expect(result).toBeInstanceOf(Error); + expect(result.message).toContain('Unknown error'); + }); + }); + + describe('isKeyringLockedError', () => { + it('returns true for direct KEYRING_LOCKED errors', () => { + const error = new Error('KEYRING_LOCKED'); + + expect(isKeyringLockedError(error)).toBe(true); + }); + + it('returns true for wrapped KEYRING_LOCKED causes', () => { + const error = Object.assign( + new Error('Failed to sign typed data with viem wallet'), + { cause: new Error('KEYRING_LOCKED') }, + ); + + expect(isKeyringLockedError(error)).toBe(true); + }); + + it('returns false for non-keyring errors with cyclic causes', () => { + const error = new Error('Network error') as Error & { cause?: unknown }; + error.cause = error; + + expect(isKeyringLockedError(error)).toBe(false); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/hyperLiquidAbstraction.test.ts b/packages/perps-controller/tests/src/utils/hyperLiquidAbstraction.test.ts new file mode 100644 index 0000000000..7694db7165 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/hyperLiquidAbstraction.test.ts @@ -0,0 +1,24 @@ +import { shouldDeferUnifiedAccountSetup } from '../../../src/utils/hyperLiquidAbstraction'; + +describe('shouldDeferUnifiedAccountSetup', () => { + it.each(['dexAbstraction', 'default', 'disabled'] as const)( + 'defers %s setup when signing is not allowed', + (currentMode) => { + expect(shouldDeferUnifiedAccountSetup(currentMode, false)).toBe(true); + }, + ); + + it.each(['dexAbstraction', 'default', 'disabled'] as const)( + 'allows %s setup when signing is allowed', + (currentMode) => { + expect(shouldDeferUnifiedAccountSetup(currentMode, true)).toBe(false); + }, + ); + + it.each(['unifiedAccount', 'portfolioMargin', undefined] as const)( + 'does not defer %s because no migration is required', + (currentMode) => { + expect(shouldDeferUnifiedAccountSetup(currentMode, false)).toBe(false); + }, + ); +}); diff --git a/packages/perps-controller/tests/src/utils/hyperLiquidOrderBookProcessor.test.ts b/packages/perps-controller/tests/src/utils/hyperLiquidOrderBookProcessor.test.ts new file mode 100644 index 0000000000..f67496d87a --- /dev/null +++ b/packages/perps-controller/tests/src/utils/hyperLiquidOrderBookProcessor.test.ts @@ -0,0 +1,648 @@ +/* eslint-disable */ +/** + * Unit tests for HyperLiquid Order Book Processor + */ + +import type { BboWsEvent, L2BookResponse } from '@nktkas/hyperliquid'; + +import type { PriceUpdate } from '../../../src/types'; +import { + processBboData, + processL2BookData, +} from '../../../src/utils/hyperLiquidOrderBookProcessor'; +import type { + OrderBookCacheEntry, + ProcessBboDataParams, + ProcessL2BookDataParams, +} from '../../../src/utils/hyperLiquidOrderBookProcessor'; + +describe('hyperLiquidOrderBookProcessor', () => { + let mockOrderBookCache: Map; + let mockCachedPriceData: Map; + let mockCreatePriceUpdate: jest.Mock; + let mockNotifySubscribers: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockOrderBookCache = new Map(); + mockCachedPriceData = new Map(); + mockCreatePriceUpdate = jest.fn((symbol: string, price: string) => ({ + symbol, + price, + timestamp: Date.now(), + })); + mockNotifySubscribers = jest.fn(); + }); + + describe('processL2BookData', () => { + it('processes valid L2 book data with bid and ask', () => { + const symbol = 'BTC'; + const data: L2BookResponse = { + coin: 'BTC', + time: Date.now(), + levels: [ + [{ px: '49900', sz: '1.5', n: 3 }], // Bid level + [{ px: '50100', sz: '2.0', n: 5 }], // Ask level + ], + }; + + mockCachedPriceData.set('BTC', { + symbol: 'BTC', + price: '50000', + timestamp: Date.now(), + }); + + const params: ProcessL2BookDataParams = { + symbol, + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processL2BookData(params); + + const cacheEntry = mockOrderBookCache.get('BTC'); + expect(cacheEntry).toBeDefined(); + expect(cacheEntry?.bestBid).toBe('49900'); + expect(cacheEntry?.bestAsk).toBe('50100'); + expect(cacheEntry?.spread).toBe('200.00000'); + expect(cacheEntry?.lastUpdated).toBeGreaterThan(0); + expect(mockCreatePriceUpdate).toHaveBeenCalledWith('BTC', '50000'); + expect(mockNotifySubscribers).toHaveBeenCalledTimes(1); + }); + + it('returns early when symbol does not match', () => { + const data: L2BookResponse = { + coin: 'ETH', + time: Date.now(), + levels: [ + [{ px: '3000', sz: '1.0', n: 2 }], + [{ px: '3100', sz: '1.0', n: 2 }], + ], + }; + + const params: ProcessL2BookDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processL2BookData(params); + + expect(mockOrderBookCache.size).toBe(0); + expect(mockCreatePriceUpdate).not.toHaveBeenCalled(); + expect(mockNotifySubscribers).not.toHaveBeenCalled(); + }); + + it('returns early when levels data is missing', () => { + const data = { + coin: 'BTC', + time: Date.now(), + levels: undefined, + } as unknown as L2BookResponse; + + const params: ProcessL2BookDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processL2BookData(params); + + expect(mockOrderBookCache.size).toBe(0); + expect(mockCreatePriceUpdate).not.toHaveBeenCalled(); + expect(mockNotifySubscribers).not.toHaveBeenCalled(); + }); + + it('returns early when both bid and ask are missing', () => { + const data: L2BookResponse = { + coin: 'BTC', + time: Date.now(), + levels: [[], []], + }; + + const params: ProcessL2BookDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processL2BookData(params); + + expect(mockOrderBookCache.size).toBe(0); + expect(mockCreatePriceUpdate).not.toHaveBeenCalled(); + expect(mockNotifySubscribers).not.toHaveBeenCalled(); + }); + + it('processes data with only bid present', () => { + const data: L2BookResponse = { + coin: 'BTC', + time: Date.now(), + levels: [[{ px: '49900', sz: '1.5', n: 3 }], []], + }; + + mockCachedPriceData.set('BTC', { + symbol: 'BTC', + price: '50000', + timestamp: Date.now(), + }); + + const params: ProcessL2BookDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processL2BookData(params); + + const cacheEntry = mockOrderBookCache.get('BTC'); + expect(cacheEntry).toBeDefined(); + expect(cacheEntry?.bestBid).toBe('49900'); + expect(cacheEntry?.bestAsk).toBeUndefined(); + expect(cacheEntry?.spread).toBeUndefined(); + expect(mockNotifySubscribers).toHaveBeenCalledTimes(1); + }); + + it('processes data with only ask present', () => { + const data: L2BookResponse = { + coin: 'BTC', + time: Date.now(), + levels: [[], [{ px: '50100', sz: '2.0', n: 5 }]], + }; + + mockCachedPriceData.set('BTC', { + symbol: 'BTC', + price: '50000', + timestamp: Date.now(), + }); + + const params: ProcessL2BookDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processL2BookData(params); + + const cacheEntry = mockOrderBookCache.get('BTC'); + expect(cacheEntry).toBeDefined(); + expect(cacheEntry?.bestBid).toBeUndefined(); + expect(cacheEntry?.bestAsk).toBe('50100'); + expect(cacheEntry?.spread).toBeUndefined(); + expect(mockNotifySubscribers).toHaveBeenCalledTimes(1); + }); + + it('returns early when no cached price data exists for symbol', () => { + const data: L2BookResponse = { + coin: 'BTC', + time: Date.now(), + levels: [ + [{ px: '49900', sz: '1.5', n: 3 }], + [{ px: '50100', sz: '2.0', n: 5 }], + ], + }; + + const params: ProcessL2BookDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processL2BookData(params); + + expect(mockOrderBookCache.get('BTC')).toBeDefined(); + expect(mockCreatePriceUpdate).not.toHaveBeenCalled(); + expect(mockNotifySubscribers).not.toHaveBeenCalled(); + }); + + it('handles null cachedPriceData gracefully', () => { + const data: L2BookResponse = { + coin: 'BTC', + time: Date.now(), + levels: [ + [{ px: '49900', sz: '1.5', n: 3 }], + [{ px: '50100', sz: '2.0', n: 5 }], + ], + }; + + const params: ProcessL2BookDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: null, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processL2BookData(params); + + expect(mockOrderBookCache.get('BTC')).toBeDefined(); + expect(mockCreatePriceUpdate).not.toHaveBeenCalled(); + expect(mockNotifySubscribers).not.toHaveBeenCalled(); + }); + + it('calculates spread correctly with valid bid and ask', () => { + const data: L2BookResponse = { + coin: 'BTC', + time: Date.now(), + levels: [ + [{ px: '50000', sz: '1.0', n: 1 }], + [{ px: '50005', sz: '1.0', n: 1 }], + ], + }; + + mockCachedPriceData.set('BTC', { + symbol: 'BTC', + price: '50000', + timestamp: Date.now(), + }); + + const params: ProcessL2BookDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processL2BookData(params); + + const cacheEntry = mockOrderBookCache.get('BTC'); + expect(cacheEntry?.spread).toBe('5.00000'); + }); + + it('sets spread to undefined when only bid is present', () => { + const data: L2BookResponse = { + coin: 'ETH', + time: Date.now(), + levels: [[{ px: '3000', sz: '1.0', n: 1 }], []], + }; + + mockCachedPriceData.set('ETH', { + symbol: 'ETH', + price: '3000', + timestamp: Date.now(), + }); + + const params: ProcessL2BookDataParams = { + symbol: 'ETH', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processL2BookData(params); + + const cacheEntry = mockOrderBookCache.get('ETH'); + expect(cacheEntry?.spread).toBeUndefined(); + }); + + it('updates cached price data with newly created price update', () => { + const existingPrice: PriceUpdate = { + symbol: 'BTC', + price: '50000', + timestamp: Date.now() - 1000, + }; + + const newPrice: PriceUpdate = { + symbol: 'BTC', + price: '50000', + timestamp: Date.now(), + }; + + mockCachedPriceData.set('BTC', existingPrice); + mockCreatePriceUpdate.mockReturnValue(newPrice); + + const data: L2BookResponse = { + coin: 'BTC', + time: Date.now(), + levels: [ + [{ px: '49900', sz: '1.5', n: 3 }], + [{ px: '50100', sz: '2.0', n: 5 }], + ], + }; + + const params: ProcessL2BookDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processL2BookData(params); + + expect(mockCachedPriceData.get('BTC')).toEqual(newPrice); + expect(mockCachedPriceData.get('BTC')?.timestamp).toBeGreaterThan( + existingPrice.timestamp, + ); + }); + + it('updates order book cache with timestamp', () => { + const beforeTimestamp = Date.now(); + + const data: L2BookResponse = { + coin: 'SOL', + time: Date.now(), + levels: [ + [{ px: '100', sz: '10', n: 1 }], + [{ px: '101', sz: '10', n: 1 }], + ], + }; + + mockCachedPriceData.set('SOL', { + symbol: 'SOL', + price: '100', + timestamp: Date.now(), + }); + + const params: ProcessL2BookDataParams = { + symbol: 'SOL', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processL2BookData(params); + + const cacheEntry = mockOrderBookCache.get('SOL'); + expect(cacheEntry?.lastUpdated).toBeGreaterThanOrEqual(beforeTimestamp); + expect(cacheEntry?.lastUpdated).toBeLessThanOrEqual(Date.now()); + }); + + it('calls notifySubscribers only when price data is updated', () => { + const data: L2BookResponse = { + coin: 'BTC', + time: Date.now(), + levels: [ + [{ px: '49900', sz: '1.5', n: 3 }], + [{ px: '50100', sz: '2.0', n: 5 }], + ], + }; + + mockCachedPriceData.set('BTC', { + symbol: 'BTC', + price: '50000', + timestamp: Date.now(), + }); + + const params: ProcessL2BookDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processL2BookData(params); + + expect(mockNotifySubscribers).toHaveBeenCalledTimes(1); + }); + + it('does not call notifySubscribers when cached price data is null', () => { + const data: L2BookResponse = { + coin: 'BTC', + time: Date.now(), + levels: [ + [{ px: '49900', sz: '1.5', n: 3 }], + [{ px: '50100', sz: '2.0', n: 5 }], + ], + }; + + const params: ProcessL2BookDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: null, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processL2BookData(params); + + expect(mockNotifySubscribers).not.toHaveBeenCalled(); + }); + + it('processes multiple symbols independently', () => { + mockCachedPriceData.set('BTC', { + symbol: 'BTC', + price: '50000', + timestamp: Date.now(), + }); + mockCachedPriceData.set('ETH', { + symbol: 'ETH', + price: '3000', + timestamp: Date.now(), + }); + + const btcData: L2BookResponse = { + coin: 'BTC', + time: Date.now(), + levels: [ + [{ px: '49900', sz: '1.5', n: 3 }], + [{ px: '50100', sz: '2.0', n: 5 }], + ], + }; + + const ethData: L2BookResponse = { + coin: 'ETH', + time: Date.now(), + levels: [ + [{ px: '2990', sz: '5.0', n: 2 }], + [{ px: '3010', sz: '5.0', n: 2 }], + ], + }; + + processL2BookData({ + symbol: 'BTC', + data: btcData, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }); + + processL2BookData({ + symbol: 'ETH', + data: ethData, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }); + + const btcCache = mockOrderBookCache.get('BTC'); + const ethCache = mockOrderBookCache.get('ETH'); + + expect(btcCache?.bestBid).toBe('49900'); + expect(btcCache?.bestAsk).toBe('50100'); + expect(ethCache?.bestBid).toBe('2990'); + expect(ethCache?.bestAsk).toBe('3010'); + expect(mockNotifySubscribers).toHaveBeenCalledTimes(2); + }); + }); + + describe('processBboData', () => { + it('processes valid BBO data with bid and ask', () => { + const symbol = 'BTC'; + const data: BboWsEvent = { + coin: 'BTC', + time: Date.now(), + bbo: [ + { px: '49900', sz: '1.5', n: 3 }, // Bid + { px: '50100', sz: '2.0', n: 5 }, // Ask + ], + }; + + mockCachedPriceData.set('BTC', { + symbol: 'BTC', + price: '50000', + timestamp: Date.now(), + }); + + const params: ProcessBboDataParams = { + symbol, + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processBboData(params); + + const cacheEntry = mockOrderBookCache.get('BTC'); + expect(cacheEntry).toBeDefined(); + expect(cacheEntry?.bestBid).toBe('49900'); + expect(cacheEntry?.bestAsk).toBe('50100'); + expect(cacheEntry?.spread).toBe('200.00000'); + expect(cacheEntry?.lastUpdated).toBeGreaterThan(0); + expect(mockCreatePriceUpdate).toHaveBeenCalledWith('BTC', '50000'); + expect(mockNotifySubscribers).toHaveBeenCalledTimes(1); + }); + + it('returns early when coin does not match symbol', () => { + const data: BboWsEvent = { + coin: 'ETH', + time: Date.now(), + bbo: [ + { px: '2990', sz: '5.0', n: 2 }, + { px: '3010', sz: '5.0', n: 2 }, + ], + }; + + const params: ProcessBboDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processBboData(params); + + expect(mockOrderBookCache.size).toBe(0); + expect(mockCreatePriceUpdate).not.toHaveBeenCalled(); + expect(mockNotifySubscribers).not.toHaveBeenCalled(); + }); + + it('returns early when both bid and ask are missing', () => { + const data = { + coin: 'BTC', + time: Date.now(), + bbo: [undefined, undefined], + } as unknown as BboWsEvent; + + const params: ProcessBboDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processBboData(params); + + expect(mockOrderBookCache.size).toBe(0); + expect(mockCreatePriceUpdate).not.toHaveBeenCalled(); + expect(mockNotifySubscribers).not.toHaveBeenCalled(); + }); + + it('returns early when bbo is a truthy non-array value', () => { + const data = { + coin: 'BTC', + time: Date.now(), + bbo: {}, + } as unknown as BboWsEvent; + + const params: ProcessBboDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + expect(() => processBboData(params)).not.toThrow(); + expect(mockOrderBookCache.size).toBe(0); + expect(mockCreatePriceUpdate).not.toHaveBeenCalled(); + expect(mockNotifySubscribers).not.toHaveBeenCalled(); + }); + + it('updates order book cache but does not notify when no cached price exists', () => { + const data: BboWsEvent = { + coin: 'BTC', + time: Date.now(), + bbo: [ + { px: '49900', sz: '1.5', n: 3 }, + { px: '50100', sz: '2.0', n: 5 }, + ], + }; + + const params: ProcessBboDataParams = { + symbol: 'BTC', + data, + orderBookCache: mockOrderBookCache, + cachedPriceData: mockCachedPriceData, + createPriceUpdate: mockCreatePriceUpdate, + notifySubscribers: mockNotifySubscribers, + }; + + processBboData(params); + + expect(mockOrderBookCache.get('BTC')).toBeDefined(); + expect(mockCreatePriceUpdate).not.toHaveBeenCalled(); + expect(mockNotifySubscribers).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/myxAdapter.test.ts b/packages/perps-controller/tests/src/utils/myxAdapter.test.ts new file mode 100644 index 0000000000..bfef1a80cd --- /dev/null +++ b/packages/perps-controller/tests/src/utils/myxAdapter.test.ts @@ -0,0 +1,905 @@ +/* eslint-disable */ +import BigNumber from 'bignumber.js'; + +jest.mock('@myx-trade/sdk', () => ({ + Direction: { LONG: 0, SHORT: 1 }, + OrderType: {}, + OperationType: {}, + TriggerType: {}, + OrderStatus: {}, + TimeInForce: {}, + DirectionEnum: { Long: 0, Short: 1 }, + OrderTypeEnum: { Market: 0, Limit: 1, Stop: 2, Conditional: 3 }, + OperationEnum: { Increase: 0, Decrease: 1 }, + OrderStatusEnum: { Cancelled: 1, Expired: 2, Successful: 9 }, + ExecTypeEnum: { + Market: 1, + Limit: 2, + TP: 3, + SL: 4, + ADL: 5, + ADLTrigger: 6, + Liquidation: 7, + EarlyClose: 8, + MarketClose: 9, + }, + TradeFlowTypeEnum: { + Increase: 0, + Decrease: 1, + AddMargin: 2, + RemoveMargin: 3, + CancelOrder: 4, + ADL: 5, + Liquidation: 6, + MarketClose: 7, + EarlyClose: 8, + AddTPSL: 9, + SecurityDeposit: 10, + TransferToWallet: 11, + MarginAccountDeposit: 12, + ReferralReward: 13, + ReferralRewardClaim: 14, + }, +})); + +import { + MYX_PRICE_DECIMALS, + MYX_SIZE_DECIMALS, + MYX_PRICE_DECIMALS as PRICE_DEC, + MYX_COLLATERAL_DECIMALS, +} from '../../../src/constants/myxConfig'; +import type { MarketDataFormatters } from '../../../src/types'; +import type { + MYXPoolSymbol, + MYXTicker, + MYXPositionType, + MYXHistoryOrderItem, + MYXTradeFlowItem, + MYXKlineData, + MYXKlineWsData, +} from '../../../src/types/myx-types'; +import { + MYXDirection, + MYXDirectionEnum, + MYXOperationEnum, + MYXOrderStatusEnum, + MYXOrderTypeEnum, + MYXExecTypeEnum, + MYXTradeFlowTypeEnum, +} from '../../../src/types/myx-types'; +import { + adaptMarketFromMYX, + adaptPriceFromMYX, + adaptMarketDataFromMYX, + filterMYXExclusiveMarkets, + isOverlappingMarket, + buildPoolSymbolMap, + buildSymbolPoolsMap, + extractSymbolFromPoolId, + adaptPositionFromMYX, + adaptOrderFromMYX, + adaptAccountStateFromMYX, + adaptOrderFillFromMYX, + adaptFundingFromMYX, + adaptUserHistoryFromMYX, + adaptCandleFromMYX, + adaptCandleFromMYXWebSocket, + toMYXKlineResolution, + assertMYXSuccess, +} from '../../../src/utils/myxAdapter'; + +// Mock formatters matching the MarketDataFormatters interface +const mockFormatters: MarketDataFormatters = { + formatVolume: (v: number) => `$${v.toFixed(0)}`, + formatPerpsFiat: (v: number) => `$${v.toFixed(2)}`, + formatPercentage: (p: number) => `${p.toFixed(2)}%`, + priceRangesUniversal: [], +}; + +// Helper: create a minimal MYXPoolSymbol fixture +function makePool(overrides: Partial = {}): MYXPoolSymbol { + return { + chainId: 56, + marketId: 'market-1', + poolId: '0xpool1', + baseSymbol: 'RHEA', + quoteSymbol: 'USDT', + baseTokenIcon: '', + baseToken: '0xbase', + quoteToken: '0xquote', + ...overrides, + }; +} + +// Helper: create a minimal MYXTicker fixture +function makeTicker(overrides: Partial = {}): MYXTicker { + return { + chainId: 56, + poolId: '0xpool1', + oracleId: 1, + price: new BigNumber(1500) + .times(new BigNumber(10).pow(MYX_PRICE_DECIMALS)) + .toFixed(0), + change: '2.5', + high: '0', + low: '0', + volume: '1000000', + turnover: '0', + ...overrides, + }; +} + +describe('myxAdapter', () => { + describe('adaptMarketFromMYX', () => { + it('returns correct MarketInfo from a pool with baseSymbol', () => { + const pool = makePool({ baseSymbol: 'PARTI' }); + const market = adaptMarketFromMYX(pool); + + expect(market.name).toBe('PARTI'); + expect(market.szDecimals).toBe(18); + expect(market.maxLeverage).toBe(100); + expect(market.providerId).toBe('myx'); + expect(market.marginTableId).toBe(0); + expect(market.minimumOrderSize).toBe(10); + }); + + it('falls back to poolId when baseSymbol is missing', () => { + const pool = makePool({ baseSymbol: '', poolId: '0xfallback' }); + const market = adaptMarketFromMYX(pool); + + expect(market.name).toBe('0xfallback'); + }); + }); + + describe('adaptPriceFromMYX', () => { + it('returns correct price and change24h from valid ticker', () => { + const ticker = makeTicker({ change: '3.14' }); + const result = adaptPriceFromMYX(ticker); + + expect(Number.parseFloat(result.price)).toBe(1500); + expect(result.change24h).toBe(3.14); + }); + + it('defaults change24h to 0 when change is falsy', () => { + const ticker = makeTicker({ change: '' }); + const result = adaptPriceFromMYX(ticker); + + expect(result.change24h).toBe(0); + }); + + it('returns "0" price for zero-value ticker', () => { + const ticker = makeTicker({ price: '0' }); + const result = adaptPriceFromMYX(ticker); + + expect(result.price).toBe('0'); + }); + }); + + describe('adaptMarketDataFromMYX', () => { + it('returns full data when ticker is provided', () => { + const pool = makePool({ baseSymbol: 'RHEA' }); + const ticker = makeTicker(); + const data = adaptMarketDataFromMYX(pool, ticker, mockFormatters); + + expect(data.symbol).toBe('RHEA'); + expect(data.providerId).toBe('myx'); + expect(data.maxLeverage).toBe('100x'); + // Price should be formatted (non-zero) + expect(data.price).toBeDefined(); + expect(data.volume).toBeDefined(); + }); + + it('returns zeroed prices when ticker is omitted', () => { + const pool = makePool({ baseSymbol: 'SKYAI' }); + const data = adaptMarketDataFromMYX(pool, undefined, mockFormatters); + + expect(data.symbol).toBe('SKYAI'); + expect(data.providerId).toBe('myx'); + expect(data.maxLeverage).toBe('100x'); + }); + }); + + describe('filterMYXExclusiveMarkets', () => { + it('filters out overlapping markets (BTC, ETH, BNB, PUMP, WLFI)', () => { + const pools = [ + makePool({ baseSymbol: 'BTC' }), + makePool({ baseSymbol: 'ETH' }), + makePool({ baseSymbol: 'BNB' }), + makePool({ baseSymbol: 'PUMP' }), + makePool({ baseSymbol: 'WLFI' }), + makePool({ baseSymbol: 'RHEA' }), + makePool({ baseSymbol: 'PARTI' }), + ]; + + const result = filterMYXExclusiveMarkets(pools); + const symbols = result.map((p) => p.baseSymbol); + + expect(symbols).toEqual(['RHEA', 'PARTI']); + }); + + it('returns all pools when none overlap', () => { + const pools = [ + makePool({ baseSymbol: 'RHEA' }), + makePool({ baseSymbol: 'SKYAI' }), + ]; + + expect(filterMYXExclusiveMarkets(pools)).toHaveLength(2); + }); + }); + + describe('isOverlappingMarket', () => { + it('returns true for BTC', () => { + expect(isOverlappingMarket('BTC')).toBe(true); + }); + + it('returns true for ETH', () => { + expect(isOverlappingMarket('ETH')).toBe(true); + }); + + it('returns false for MYX-exclusive symbol', () => { + expect(isOverlappingMarket('RHEA')).toBe(false); + }); + }); + + describe('buildPoolSymbolMap', () => { + it('builds a map from poolId to symbol', () => { + const pools = [ + makePool({ poolId: '0xA', baseSymbol: 'RHEA' }), + makePool({ poolId: '0xB', baseSymbol: 'PARTI' }), + ]; + + const map = buildPoolSymbolMap(pools); + + expect(map.get('0xA')).toBe('RHEA'); + expect(map.get('0xB')).toBe('PARTI'); + expect(map.size).toBe(2); + }); + + it('returns an empty map for empty input', () => { + expect(buildPoolSymbolMap([]).size).toBe(0); + }); + }); + + describe('buildSymbolPoolsMap', () => { + it('groups poolIds by symbol', () => { + const pools = [ + makePool({ poolId: '0xA', baseSymbol: 'RHEA' }), + makePool({ poolId: '0xB', baseSymbol: 'RHEA' }), + makePool({ poolId: '0xC', baseSymbol: 'PARTI' }), + ]; + + const map = buildSymbolPoolsMap(pools); + + expect(map.get('RHEA')).toEqual(['0xA', '0xB']); + expect(map.get('PARTI')).toEqual(['0xC']); + }); + + it('returns empty map for empty input', () => { + expect(buildSymbolPoolsMap([]).size).toBe(0); + }); + }); + + describe('extractSymbolFromPoolId', () => { + it('returns poolId as fallback', () => { + expect(extractSymbolFromPoolId('0xSomePool')).toBe('0xSomePool'); + }); + }); + + // ============================================================================ + // Position Adapter + // ============================================================================ + + describe('adaptPositionFromMYX', () => { + function makePosition( + overrides: Partial = {}, + ): MYXPositionType { + return { + poolId: '0xpool1', + positionId: 'pos-1', + direction: MYXDirection.LONG, + entryPrice: new BigNumber(50000) + .times(new BigNumber(10).pow(PRICE_DEC)) + .toFixed(0), + fundingRateIndex: '0', + size: new BigNumber(1) + .times(new BigNumber(10).pow(MYX_SIZE_DECIMALS)) + .toFixed(0), + riskTier: 0, + collateralAmount: new BigNumber(5000) + .times(new BigNumber(10).pow(MYX_COLLATERAL_DECIMALS)) + .toFixed(0), + txTime: 1700000000, + ...overrides, + }; + } + + const poolSymbolMap = new Map([['0xpool1', 'BTC']]); + + it('adapts a long position with correct symbol, size, and leverage', () => { + const result = adaptPositionFromMYX(makePosition(), poolSymbolMap); + + expect(result.symbol).toBe('BTC'); + expect(Number(result.size)).toBeGreaterThan(0); // Long = positive + expect(Number(result.entryPrice)).toBe(50000); + expect(result.leverage.type).toBe('isolated'); + expect(result.leverage.value).toBe(10); // 50000 * 1 / 5000 = 10x + expect(result.providerId).toBe('myx'); + }); + + it('adapts a short position with negative size', () => { + const result = adaptPositionFromMYX( + makePosition({ direction: MYXDirection.SHORT }), + poolSymbolMap, + ); + + expect(Number(result.size)).toBeLessThan(0); + }); + + it('falls back to poolId when symbol not in map', () => { + const emptyMap = new Map(); + const result = adaptPositionFromMYX(makePosition(), emptyMap); + + expect(result.symbol).toBe('0xpool1'); + }); + + it('uses leverage 1 when collateral is zero', () => { + const result = adaptPositionFromMYX( + makePosition({ collateralAmount: '0' }), + poolSymbolMap, + ); + + expect(result.leverage.value).toBe(1); + }); + }); + + // ============================================================================ + // Order Adapter + // ============================================================================ + + describe('adaptOrderFromMYX', () => { + function makeHistoryOrder( + overrides: Partial = {}, + ): MYXHistoryOrderItem { + return { + chainId: 56, + poolId: '0xpool1', + orderId: 42, + txTime: 1700000000, + txHash: 0xabc as unknown as number, + orderType: MYXOrderTypeEnum.Market, + operation: MYXOperationEnum.Increase, + triggerType: 0 as MYXHistoryOrderItem['triggerType'], + direction: MYXDirectionEnum.Long, + size: new BigNumber(2) + .times(new BigNumber(10).pow(MYX_SIZE_DECIMALS)) + .toFixed(0), + filledSize: new BigNumber(2) + .times(new BigNumber(10).pow(MYX_SIZE_DECIMALS)) + .toFixed(0), + filledAmount: '0', + price: new BigNumber(60000) + .times(new BigNumber(10).pow(PRICE_DEC)) + .toFixed(0), + lastPrice: '0', + orderStatus: MYXOrderStatusEnum.Successful, + execType: MYXExecTypeEnum.Market, + slippagePct: 0, + executionFeeToken: '0x0' as MYXHistoryOrderItem['executionFeeToken'], + executionFeeAmount: '0', + tradingFee: '0', + fundingFee: '0', + realizedPnl: '0', + baseSymbol: 'BTC', + quoteSymbol: 'USDT', + userLeverage: 10, + ...overrides, + }; + } + + const poolSymbolMap = new Map([['0xpool1', 'BTC']]); + + it('maps a filled long market order correctly', () => { + const result = adaptOrderFromMYX(makeHistoryOrder(), poolSymbolMap); + + expect(result.orderId).toBe('42'); + expect(result.symbol).toBe('BTC'); + expect(result.side).toBe('buy'); + expect(result.orderType).toBe('market'); + expect(result.status).toBe('filled'); + expect(result.isTrigger).toBe(false); + expect(result.providerId).toBe('myx'); + }); + + it('maps a short limit order as sell', () => { + const result = adaptOrderFromMYX( + makeHistoryOrder({ + direction: MYXDirectionEnum.Short, + orderType: MYXOrderTypeEnum.Limit, + orderStatus: MYXOrderStatusEnum.Cancelled, + }), + poolSymbolMap, + ); + + expect(result.side).toBe('sell'); + expect(result.orderType).toBe('limit'); + expect(result.status).toBe('canceled'); + }); + + it('maps expired status to canceled', () => { + const result = adaptOrderFromMYX( + makeHistoryOrder({ orderStatus: MYXOrderStatusEnum.Expired }), + poolSymbolMap, + ); + + expect(result.status).toBe('canceled'); + }); + + it('maps unknown status to open', () => { + const result = adaptOrderFromMYX( + makeHistoryOrder({ orderStatus: 99 as MYXOrderStatusEnum }), + poolSymbolMap, + ); + + expect(result.status).toBe('open'); + }); + + it('detects TP trigger order', () => { + const result = adaptOrderFromMYX( + makeHistoryOrder({ execType: MYXExecTypeEnum.TP }), + poolSymbolMap, + ); + + expect(result.isTrigger).toBe(true); + expect(result.detailedOrderType).toBe('Take Profit'); + }); + + it('detects SL trigger order', () => { + const result = adaptOrderFromMYX( + makeHistoryOrder({ execType: MYXExecTypeEnum.SL }), + poolSymbolMap, + ); + + expect(result.isTrigger).toBe(true); + expect(result.detailedOrderType).toBe('Stop Loss'); + }); + + it('detects liquidation order', () => { + const result = adaptOrderFromMYX( + makeHistoryOrder({ execType: MYXExecTypeEnum.Liquidation }), + poolSymbolMap, + ); + + expect(result.detailedOrderType).toBe('Liquidation'); + }); + + it('sets reduceOnly for decrease operations', () => { + const result = adaptOrderFromMYX( + makeHistoryOrder({ operation: MYXOperationEnum.Decrease }), + poolSymbolMap, + ); + + expect(result.reduceOnly).toBe(true); + }); + + it('falls back to poolSymbolMap then poolId for symbol', () => { + const result = adaptOrderFromMYX( + makeHistoryOrder({ baseSymbol: undefined as unknown as string }), + poolSymbolMap, + ); + expect(result.symbol).toBe('BTC'); + + const emptyMap = new Map(); + const result2 = adaptOrderFromMYX( + makeHistoryOrder({ baseSymbol: undefined as unknown as string }), + emptyMap, + ); + expect(result2.symbol).toBe('0xpool1'); + }); + }); + + // ============================================================================ + // Account State Adapter + // ============================================================================ + + describe('adaptAccountStateFromMYX', () => { + it('computes balances from account info and wallet balance', () => { + const accountInfo = { + totalCollateral: new BigNumber(1000) + .times(new BigNumber(10).pow(MYX_COLLATERAL_DECIMALS)) + .toFixed(0), + unrealizedPnl: new BigNumber(50) + .times(new BigNumber(10).pow(MYX_COLLATERAL_DECIMALS)) + .toFixed(0), + }; + const walletBalance = new BigNumber(500) + .times(new BigNumber(10).pow(MYX_COLLATERAL_DECIMALS)) + .toFixed(0); + + const result = adaptAccountStateFromMYX(accountInfo, walletBalance); + + expect(Number(result.marginUsed)).toBe(1000); + expect(Number(result.unrealizedPnl)).toBe(50); + expect(Number(result.spendableBalance)).toBe(500); + // totalBalance = balance + marginUsed + unrealizedPnl = 500 + 1000 + 50 + expect(Number(result.totalBalance)).toBe(1550); + }); + + it('returns zeros when accountInfo is undefined', () => { + const result = adaptAccountStateFromMYX(undefined); + + expect(Number(result.marginUsed)).toBe(0); + expect(Number(result.unrealizedPnl)).toBe(0); + expect(Number(result.totalBalance)).toBe(0); + expect(Number(result.spendableBalance)).toBe(0); + }); + + it('returns zeros when walletBalance is undefined', () => { + const result = adaptAccountStateFromMYX(undefined, undefined); + + expect(Number(result.spendableBalance)).toBe(0); + }); + }); + + // ============================================================================ + // Order Fill Adapter + // ============================================================================ + + describe('adaptOrderFillFromMYX', () => { + function makeHistoryOrder( + overrides: Partial = {}, + ): MYXHistoryOrderItem { + return { + chainId: 56, + poolId: '0xpool1', + orderId: 99, + txTime: 1700000000, + txHash: 0xdef as unknown as number, + orderType: MYXOrderTypeEnum.Market, + operation: MYXOperationEnum.Increase, + triggerType: 0 as MYXHistoryOrderItem['triggerType'], + direction: MYXDirectionEnum.Long, + size: new BigNumber(3) + .times(new BigNumber(10).pow(MYX_SIZE_DECIMALS)) + .toFixed(0), + filledSize: new BigNumber(3) + .times(new BigNumber(10).pow(MYX_SIZE_DECIMALS)) + .toFixed(0), + filledAmount: '0', + price: new BigNumber(45000) + .times(new BigNumber(10).pow(PRICE_DEC)) + .toFixed(0), + lastPrice: new BigNumber(45100) + .times(new BigNumber(10).pow(PRICE_DEC)) + .toFixed(0), + orderStatus: MYXOrderStatusEnum.Successful, + execType: MYXExecTypeEnum.Market, + slippagePct: 0, + executionFeeToken: '0x0' as MYXHistoryOrderItem['executionFeeToken'], + executionFeeAmount: '0', + tradingFee: new BigNumber(5) + .times(new BigNumber(10).pow(MYX_COLLATERAL_DECIMALS)) + .toFixed(0), + fundingFee: '0', + realizedPnl: new BigNumber(100) + .times(new BigNumber(10).pow(MYX_COLLATERAL_DECIMALS)) + .toFixed(0), + baseSymbol: 'BTC', + quoteSymbol: 'USDT', + userLeverage: 10, + ...overrides, + }; + } + + const poolSymbolMap = new Map([['0xpool1', 'BTC']]); + + it('adapts a filled order to OrderFill with correct fields', () => { + const result = adaptOrderFillFromMYX(makeHistoryOrder(), poolSymbolMap); + + expect(result.orderId).toBe('99'); + expect(result.symbol).toBe('BTC'); + expect(result.side).toBe('buy'); + expect(Number(result.size)).toBe(3); + expect(Number(result.price)).toBe(45100); // Uses lastPrice + expect(Number(result.fee)).toBe(5); + expect(Number(result.pnl)).toBe(100); + expect(result.feeToken).toBe('USDT'); + expect(result.success).toBe(true); + expect(result.orderType).toBe('regular'); + expect(result.providerId).toBe('myx'); + }); + + it('uses size as fallback when filledSize is empty', () => { + const result = adaptOrderFillFromMYX( + makeHistoryOrder({ filledSize: '' }), + poolSymbolMap, + ); + + expect(Number(result.size)).toBe(3); // Falls back to size + }); + + it('uses price as fallback when lastPrice is empty', () => { + const result = adaptOrderFillFromMYX( + makeHistoryOrder({ lastPrice: '' }), + poolSymbolMap, + ); + + expect(Number(result.price)).toBe(45000); // Falls back to price + }); + + it('maps TP exec type to take_profit', () => { + const result = adaptOrderFillFromMYX( + makeHistoryOrder({ execType: MYXExecTypeEnum.TP }), + poolSymbolMap, + ); + + expect(result.orderType).toBe('take_profit'); + }); + + it('maps SL exec type to stop_loss', () => { + const result = adaptOrderFillFromMYX( + makeHistoryOrder({ execType: MYXExecTypeEnum.SL }), + poolSymbolMap, + ); + + expect(result.orderType).toBe('stop_loss'); + }); + + it('maps Liquidation exec type', () => { + const result = adaptOrderFillFromMYX( + makeHistoryOrder({ execType: MYXExecTypeEnum.Liquidation }), + poolSymbolMap, + ); + + expect(result.orderType).toBe('liquidation'); + }); + + it('marks unsuccessful orders', () => { + const result = adaptOrderFillFromMYX( + makeHistoryOrder({ orderStatus: MYXOrderStatusEnum.Cancelled }), + poolSymbolMap, + ); + + expect(result.success).toBe(false); + }); + }); + + // ============================================================================ + // Funding Adapter + // ============================================================================ + + describe('adaptFundingFromMYX', () => { + function makeFlowItem( + overrides: Partial = {}, + ): MYXTradeFlowItem { + return { + chainId: 56, + orderId: 1, + user: '0xuser' as MYXTradeFlowItem['user'], + poolId: '0xpool1', + fundingFee: new BigNumber(10) + .times(new BigNumber(10).pow(MYX_COLLATERAL_DECIMALS)) + .toFixed(0), + tradingFee: '0', + charge: '0', + collateralAmount: '0', + collateralBase: '0', + txHash: '0xhash', + txTime: 1700000000, + type: MYXTradeFlowTypeEnum.Increase, + accountType: 1 as MYXTradeFlowItem['accountType'], + executionFee: '0', + seamlessFee: '0', + seamlessFeeSymbol: '', + basePnl: '0', + quotePnl: '0', + referrerRebate: '0', + referralRebate: '0', + rebateClaimedAmount: '0', + ...overrides, + }; + } + + const poolSymbolMap = new Map([['0xpool1', 'BTC']]); + + it('adapts flows with non-zero funding fees', () => { + const result = adaptFundingFromMYX([makeFlowItem()], poolSymbolMap); + + expect(result).toHaveLength(1); + expect(result[0].symbol).toBe('BTC'); + expect(Number(result[0].amountUsd)).toBe(10); + expect(result[0].transactionHash).toBe('0xhash'); + }); + + it('filters out flows with zero or empty funding fees', () => { + const flows = [ + makeFlowItem({ fundingFee: '0' }), + makeFlowItem({ fundingFee: '' }), + ]; + + const result = adaptFundingFromMYX(flows, poolSymbolMap); + + expect(result).toHaveLength(0); + }); + + it('falls back to poolId when symbol not in map', () => { + const emptyMap = new Map(); + const result = adaptFundingFromMYX([makeFlowItem()], emptyMap); + + expect(result[0].symbol).toBe('0xpool1'); + }); + }); + + // ============================================================================ + // User History Adapter + // ============================================================================ + + describe('adaptUserHistoryFromMYX', () => { + function makeFlowItem( + overrides: Partial = {}, + ): MYXTradeFlowItem { + return { + chainId: 56, + orderId: 1, + user: '0xuser' as MYXTradeFlowItem['user'], + poolId: '0xpool1', + fundingFee: '0', + tradingFee: '0', + charge: '0', + collateralAmount: new BigNumber(200) + .times(new BigNumber(10).pow(MYX_COLLATERAL_DECIMALS)) + .toFixed(0), + collateralBase: '0', + txHash: '0xhash', + txTime: 1700000000, + type: MYXTradeFlowTypeEnum.MarginAccountDeposit, + accountType: 1 as MYXTradeFlowItem['accountType'], + executionFee: '0', + seamlessFee: '0', + seamlessFeeSymbol: '', + basePnl: '0', + quotePnl: '0', + referrerRebate: '0', + referralRebate: '0', + rebateClaimedAmount: '0', + ...overrides, + }; + } + + it('adapts deposit flows', () => { + const result = adaptUserHistoryFromMYX([makeFlowItem()]); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('deposit'); + expect(Number(result[0].amount)).toBe(200); + expect(result[0].asset).toBe('USDT'); + expect(result[0].status).toBe('completed'); + }); + + it('adapts withdrawal flows', () => { + const result = adaptUserHistoryFromMYX([ + makeFlowItem({ type: MYXTradeFlowTypeEnum.TransferToWallet }), + ]); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('withdrawal'); + }); + + it('filters out non-deposit/withdrawal flow types', () => { + const result = adaptUserHistoryFromMYX([ + makeFlowItem({ type: MYXTradeFlowTypeEnum.Increase }), + makeFlowItem({ type: MYXTradeFlowTypeEnum.Decrease }), + makeFlowItem({ type: MYXTradeFlowTypeEnum.Liquidation }), + ]); + + expect(result).toHaveLength(0); + }); + }); + + // ============================================================================ + // Candle Adapters + // ============================================================================ + + describe('adaptCandleFromMYX', () => { + it('maps REST kline fields to CandleStick', () => { + const kline: MYXKlineData = { + time: 1700000000, + open: '50000', + close: '51000', + high: '52000', + low: '49000', + }; + + const result = adaptCandleFromMYX(kline); + + expect(result.time).toBe(1700000000); + expect(result.open).toBe('50000'); + expect(result.close).toBe('51000'); + expect(result.high).toBe('52000'); + expect(result.low).toBe('49000'); + expect(result.volume).toBe('0'); + }); + }); + + describe('adaptCandleFromMYXWebSocket', () => { + it('maps WS single-letter fields to CandleStick', () => { + const wsData: MYXKlineWsData = { + E: 1700000000, + T: '100', + t: 1700000000, + o: '50000', + h: '52000', + l: '49000', + c: '51000', + v: '1500', + }; + + const result = adaptCandleFromMYXWebSocket(wsData); + + expect(result.time).toBe(1700000000); + expect(result.open).toBe('50000'); + expect(result.high).toBe('52000'); + expect(result.low).toBe('49000'); + expect(result.close).toBe('51000'); + expect(result.volume).toBe('1500'); + }); + }); + + // ============================================================================ + // Resolution Mapper + // ============================================================================ + + describe('toMYXKlineResolution', () => { + it.each([ + ['1m', '1m'], + ['5m', '5m'], + ['15m', '15m'], + ['1h', '1h'], + ['4h', '4h'], + ['1d', '1d'], + ['1w', '1w'], + ['1M', '1M'], + ] as const)('maps %s to %s', (input, expected) => { + expect(toMYXKlineResolution(input)).toBe(expected); + }); + + it.each([ + ['3m', '5m'], + ['2h', '4h'], + ['8h', '4h'], + ['12h', '1d'], + ['3d', '1w'], + ] as const)('maps unsupported %s to nearest %s', (input, expected) => { + expect(toMYXKlineResolution(input)).toBe(expected); + }); + + it('defaults unknown periods to 1h', () => { + expect(toMYXKlineResolution('99x')).toBe('1h'); + }); + }); + + // ============================================================================ + // Response Validation + // ============================================================================ + + describe('assertMYXSuccess', () => { + it('does not throw for code 9200', () => { + expect(() => assertMYXSuccess({ code: 9200 }, 'test')).not.toThrow(); + }); + + it('does not throw for code 0', () => { + expect(() => assertMYXSuccess({ code: 0 }, 'test')).not.toThrow(); + }); + + it('throws for non-success code', () => { + expect(() => + assertMYXSuccess({ code: 500, message: 'Server Error' }, 'fetch'), + ).toThrow('MYX fetch failed: code=500 message=Server Error'); + }); + + it('includes "unknown" when message is null', () => { + expect(() => + assertMYXSuccess({ code: 400, message: null }, 'auth'), + ).toThrow('MYX auth failed: code=400 message=unknown'); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/perpsConnectionAttemptContext.test.ts b/packages/perps-controller/tests/src/utils/perpsConnectionAttemptContext.test.ts new file mode 100644 index 0000000000..cbae14bf28 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/perpsConnectionAttemptContext.test.ts @@ -0,0 +1,64 @@ +/* eslint-disable */ +import { + getPerpsConnectionAttemptContext, + withPerpsConnectionAttemptContext, +} from '../../../src/utils/perpsConnectionAttemptContext'; + +describe('perpsConnectionAttemptContext', () => { + it('returns null when no context is active', () => { + expect(getPerpsConnectionAttemptContext()).toBeNull(); + }); + + it('sets context during callback execution and restores it after', async () => { + const context = { source: 'test_source', suppressError: true }; + + await withPerpsConnectionAttemptContext(context, async () => { + expect(getPerpsConnectionAttemptContext()).toEqual(context); + return 'result'; + }); + + expect(getPerpsConnectionAttemptContext()).toBeNull(); + }); + + it('returns the callback result', async () => { + const context = { source: 'test', suppressError: false }; + + const result = await withPerpsConnectionAttemptContext( + context, + async () => 42, + ); + + expect(result).toBe(42); + }); + + it('restores previous context after nested calls', async () => { + const outer = { source: 'outer', suppressError: false }; + const inner = { source: 'inner', suppressError: true }; + + await withPerpsConnectionAttemptContext(outer, async () => { + expect(getPerpsConnectionAttemptContext()).toEqual(outer); + + await withPerpsConnectionAttemptContext(inner, async () => { + expect(getPerpsConnectionAttemptContext()).toEqual(inner); + }); + + // Outer context restored after inner completes + expect(getPerpsConnectionAttemptContext()).toEqual(outer); + }); + + expect(getPerpsConnectionAttemptContext()).toBeNull(); + }); + + it('restores context even when callback throws', async () => { + const context = { source: 'failing', suppressError: false }; + + await expect( + withPerpsConnectionAttemptContext(context, async () => { + throw new Error('callback error'); + }), + ).rejects.toThrow('callback error'); + + // Context should be restored to null despite the error + expect(getPerpsConnectionAttemptContext()).toBeNull(); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/perpsFormatters.test.ts b/packages/perps-controller/tests/src/utils/perpsFormatters.test.ts new file mode 100644 index 0000000000..6950a63557 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/perpsFormatters.test.ts @@ -0,0 +1,297 @@ +/* eslint-disable */ +import { + formatFundingRate, + formatPercentage, + formatPerpsFiat, + formatPnl, + formatPositionSize, + formatWithSignificantDigits, + PRICE_RANGES_MINIMAL_VIEW, + PRICE_RANGES_UNIVERSAL, + PRICE_THRESHOLD, +} from '../../../src/utils/perpsFormatters'; + +describe('PRICE_THRESHOLD', () => { + it('exports expected boundary values', () => { + expect(PRICE_THRESHOLD.VERY_HIGH).toBe(100_000); + expect(PRICE_THRESHOLD.HIGH).toBe(10_000); + expect(PRICE_THRESHOLD.LARGE).toBe(1_000); + expect(PRICE_THRESHOLD.MEDIUM).toBe(100); + expect(PRICE_THRESHOLD.MEDIUM_LOW).toBe(10); + expect(PRICE_THRESHOLD.LOW).toBe(0.01); + expect(PRICE_THRESHOLD.VERY_SMALL).toBe(0.000001); + }); +}); + +describe('PRICE_RANGES_MINIMAL_VIEW', () => { + it('has two ranges', () => { + expect(PRICE_RANGES_MINIMAL_VIEW).toHaveLength(2); + }); + + it('large range condition matches values >= 1000', () => { + expect(PRICE_RANGES_MINIMAL_VIEW[0].condition(1000)).toBe(true); + expect(PRICE_RANGES_MINIMAL_VIEW[0].condition(999)).toBe(false); + }); + + it('fallback range always matches', () => { + expect(PRICE_RANGES_MINIMAL_VIEW[1].condition(0)).toBe(true); + expect(PRICE_RANGES_MINIMAL_VIEW[1].condition(-999)).toBe(true); + }); +}); + +describe('PRICE_RANGES_UNIVERSAL', () => { + it('has seven ranges', () => { + expect(PRICE_RANGES_UNIVERSAL).toHaveLength(7); + }); + + it('very-high range condition matches values above 100000', () => { + expect(PRICE_RANGES_UNIVERSAL[0].condition(100001)).toBe(true); + expect(PRICE_RANGES_UNIVERSAL[0].condition(100000)).toBe(false); + }); + + it('very-small fallback always matches', () => { + expect(PRICE_RANGES_UNIVERSAL[6].condition(0)).toBe(true); + }); +}); + +describe('formatWithSignificantDigits', () => { + it('returns zero with no decimals for input 0', () => { + expect(formatWithSignificantDigits(0, 4)).toEqual({ + value: 0, + decimals: 0, + }); + }); + + it('respects minDecimals for zero', () => { + expect(formatWithSignificantDigits(0, 4, 2)).toEqual({ + value: 0, + decimals: 2, + }); + }); + + it('formats integer >= 1 with correct decimals for 5 sig figs', () => { + // 123.456 has 3 integer digits → 5-3 = 2 decimals + const result = formatWithSignificantDigits(123.456, 5); + expect(result.decimals).toBe(2); + expect(result.value).toBeCloseTo(123.46, 2); + }); + + it('formats large number with 0 decimals when significantDigits exceeded by integer digits', () => { + // 123456 has 6 integer digits → 5-6 = negative → clamped to 0 + const result = formatWithSignificantDigits(123456, 5); + expect(result.decimals).toBe(0); + }); + + it('formats decimal < 1 to correct sig figs', () => { + // 0.003456 to 4 sig figs → 0.003456 + const result = formatWithSignificantDigits(0.003456, 4); + expect(result.value).toBeCloseTo(0.003456, 6); + }); + + it('respects maxDecimals override', () => { + // 12.34567 with 5 sig figs would need 4 decimals, capped at 2 + const result = formatWithSignificantDigits(12.34567, 5, undefined, 2); + expect(result.decimals).toBe(2); + }); + + it('preserves negative sign for values < 1', () => { + const result = formatWithSignificantDigits(-0.001234, 3); + expect(result.value).toBeLessThan(0); + }); +}); + +describe('formatPerpsFiat', () => { + it('returns fallback for NaN', () => { + expect(formatPerpsFiat('not-a-number')).toBe('$---'); + }); + + it('formats a basic value with default minimal view ranges', () => { + // PRICE_RANGES_MINIMAL_VIEW: 2 decimals, fiat-style stripping + expect(formatPerpsFiat(1234.56)).toBe('$1,234.56'); + }); + + it('strips .00 with fiat-style stripping (default ranges)', () => { + expect(formatPerpsFiat(1250)).toBe('$1,250'); + expect(formatPerpsFiat(100)).toBe('$100'); + }); + + it('preserves meaningful decimals with fiat-style stripping', () => { + // $13.40 → stays $13.40 (not $13.4) + expect(formatPerpsFiat(13.4)).toBe('$13.40'); + }); + + it('formats with PRICE_RANGES_UNIVERSAL', () => { + // $12,345.67 → $12,346 (0 decimals, 5 sig figs for high range) + const result = formatPerpsFiat(12345.67, { + ranges: PRICE_RANGES_UNIVERSAL, + }); + expect(result).toBe('$12,346'); + }); + + it('formats small value with universal ranges', () => { + // $1.3445 → ~$1.3445 (5 sig figs, max 6 decimals) + const result = formatPerpsFiat(1.3445, { ranges: PRICE_RANGES_UNIVERSAL }); + expect(result).toBe('$1.3445'); + }); + + it('formats very small value with universal ranges', () => { + // < $0.01 → 4 sig figs + const result = formatPerpsFiat(0.004236, { + ranges: PRICE_RANGES_UNIVERSAL, + }); + expect(result).toBe('$0.004236'); + }); + + it('respects explicit stripTrailingZeros: false', () => { + const result = formatPerpsFiat(1250, { stripTrailingZeros: false }); + expect(result).toBe('$1,250.00'); + }); + + it('handles numeric string input', () => { + expect(formatPerpsFiat('500.50')).toBe('$500.50'); + }); + + it('accepts custom currency', () => { + const result = formatPerpsFiat(100, { + currency: 'EUR', + stripTrailingZeros: false, + }); + expect(result).toContain('100'); + }); +}); + +describe('formatPositionSize', () => { + it('returns "0" for zero', () => { + expect(formatPositionSize(0)).toBe('0'); + }); + + it('returns "0" for NaN', () => { + expect(formatPositionSize('invalid')).toBe('0'); + }); + + it('uses szDecimals when provided', () => { + expect(formatPositionSize(0.00009, 5)).toBe('0.00009'); + expect(formatPositionSize(44, 1)).toBe('44'); + expect(formatPositionSize(1.5, 5)).toBe('1.5'); + }); + + it('strips trailing zeros with szDecimals', () => { + expect(formatPositionSize(44.0, 2)).toBe('44'); + }); + + it('preserves integer trailing zeros when szDecimals=0 (whole-unit assets)', () => { + expect(formatPositionSize(100, 0)).toBe('100'); + expect(formatPositionSize(20, 0)).toBe('20'); + expect(formatPositionSize(1000, 0)).toBe('1000'); + expect(formatPositionSize(1, 0)).toBe('1'); + expect(formatPositionSize(1.7, 0)).toBe('2'); + }); + + it('uses magnitude logic for very small values (< 0.01) without szDecimals', () => { + const result = formatPositionSize(0.00009); + expect(result).toBe('0.00009'); + }); + + it('uses magnitude logic for small values (< 1) without szDecimals', () => { + expect(formatPositionSize(0.0024)).toBe('0.0024'); + }); + + it('uses 2 decimals for values >= 1 without szDecimals', () => { + expect(formatPositionSize(44)).toBe('44'); + expect(formatPositionSize(44.5)).toBe('44.5'); + }); + + it('handles string input', () => { + expect(formatPositionSize('1.23')).toBe('1.23'); + }); +}); + +describe('formatPnl', () => { + it('formats positive PnL with + prefix', () => { + expect(formatPnl(1234.56)).toBe('+$1,234.56'); + }); + + it('formats negative PnL with - prefix', () => { + expect(formatPnl(-500)).toBe('-$500.00'); + }); + + it('formats zero as positive', () => { + expect(formatPnl(0)).toBe('+$0.00'); + }); + + it('returns zero display for NaN', () => { + expect(formatPnl('invalid')).toBe('$0.00'); + }); + + it('handles string input', () => { + expect(formatPnl('250.75')).toBe('+$250.75'); + }); + + it('formats 2 decimal places always', () => { + expect(formatPnl(100)).toBe('+$100.00'); + expect(formatPnl(-0.01)).toBe('-$0.01'); + }); +}); + +describe('formatPercentage', () => { + it('formats positive percentage with + prefix', () => { + expect(formatPercentage(5.25)).toBe('+5.25%'); + }); + + it('formats negative percentage with - prefix', () => { + expect(formatPercentage(-2.75)).toBe('-2.75%'); + }); + + it('formats zero as positive', () => { + expect(formatPercentage(0)).toBe('+0.00%'); + }); + + it('returns "0.00%" for NaN', () => { + expect(formatPercentage('not-a-number')).toBe('0.00%'); + }); + + it('respects custom decimals', () => { + expect(formatPercentage(5.1234, 4)).toBe('+5.1234%'); + expect(formatPercentage(5.1234, 0)).toBe('+5%'); + }); + + it('handles string input', () => { + expect(formatPercentage('10.5')).toBe('+10.50%'); + }); +}); + +describe('formatFundingRate', () => { + it('returns zero display for undefined', () => { + expect(formatFundingRate(undefined)).toBe('0.0000%'); + }); + + it('returns zero display for null', () => { + expect(formatFundingRate(null)).toBe('0.0000%'); + }); + + it('formats positive funding rate as percentage', () => { + // 0.0005 * 100 = 0.05 → "0.0500%" + expect(formatFundingRate(0.0005)).toBe('0.0500%'); + }); + + it('formats negative funding rate as percentage', () => { + // -0.0001 * 100 = -0.01 → "-0.0100%" + expect(formatFundingRate(-0.0001)).toBe('-0.0100%'); + }); + + it('returns zero display for effectively-zero value', () => { + expect(formatFundingRate(0)).toBe('0.0000%'); + }); + + it('returns empty string for undefined when showZero is false', () => { + expect(formatFundingRate(undefined, { showZero: false })).toBe(''); + }); + + it('formats zero value normally when showZero is false (showZero only affects undefined/null)', () => { + expect(formatFundingRate(0, { showZero: false })).toBe('0.0000%'); + }); + + it('still formats non-zero when showZero is false', () => { + expect(formatFundingRate(0.001, { showZero: false })).toBe('0.1000%'); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/rewardsUtils.test.ts b/packages/perps-controller/tests/src/utils/rewardsUtils.test.ts new file mode 100644 index 0000000000..88a5bcef56 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/rewardsUtils.test.ts @@ -0,0 +1,103 @@ +import type { PerpsLogger } from '../../../src/types'; +/* eslint-disable */ +import { + formatAccountToCaipAccountId, + isCaipAccountId, + handleRewardsError, +} from '../../../src/utils/rewardsUtils'; + +describe('rewardsUtils', () => { + describe('formatAccountToCaipAccountId', () => { + it('formats hex chain ID and address to CAIP-10', () => { + const result = formatAccountToCaipAccountId( + '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B', + '0xa4b1', + ); + expect(result).toMatch(/^eip155:42161:0x/); + }); + + it('formats decimal chain ID and address to CAIP-10', () => { + const result = formatAccountToCaipAccountId( + '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B', + '42161', + ); + expect(result).toMatch(/^eip155:42161:0x/); + }); + + it('returns null for invalid chain ID (NaN) and logs error', () => { + const mockLogger: PerpsLogger = { + error: jest.fn(), + }; + + const result = formatAccountToCaipAccountId( + '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B', + 'abc', + mockLogger, + ); + + expect(result).toBeNull(); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Invalid chain ID: abc'), + }), + expect.any(Object), + ); + }); + + it('returns null for empty chain ID and logs error', () => { + const mockLogger: PerpsLogger = { + error: jest.fn(), + }; + + const result = formatAccountToCaipAccountId( + '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B', + '', + mockLogger, + ); + + expect(result).toBeNull(); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it('returns null without logger for invalid chain ID', () => { + const result = formatAccountToCaipAccountId( + '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B', + 'not-a-number', + ); + + expect(result).toBeNull(); + }); + }); + + describe('isCaipAccountId', () => { + it('returns true for valid CAIP-10 account ID', () => { + expect(isCaipAccountId('eip155:1:0xABC')).toBe(true); + }); + + it('returns false for non-string value', () => { + expect(isCaipAccountId(123)).toBe(false); + expect(isCaipAccountId(null)).toBe(false); + }); + + it('returns false for non-eip155 namespace', () => { + expect(isCaipAccountId('solana:1:abc')).toBe(false); + }); + + it('returns false for string with fewer than 3 parts', () => { + expect(isCaipAccountId('eip155:1')).toBe(false); + }); + }); + + describe('handleRewardsError', () => { + it('returns user-friendly error message', () => { + const result = handleRewardsError(new Error('test')); + expect(result).toBe('Rewards operation failed'); + }); + + it('logs error when logger is provided', () => { + const mockLogger: PerpsLogger = { error: jest.fn() }; + handleRewardsError(new Error('test'), mockLogger, { key: 'value' }); + expect(mockLogger.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/significantFigures.test.ts b/packages/perps-controller/tests/src/utils/significantFigures.test.ts new file mode 100644 index 0000000000..68256f1453 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/significantFigures.test.ts @@ -0,0 +1,48 @@ +import { + countSignificantFigures, + hasExceededSignificantFigures, + roundToSignificantFigures, +} from '../../../src/utils/significantFigures'; + +describe('significantFigures utilities', () => { + describe('countSignificantFigures', () => { + it.each([ + ['', 0], + ['0', 0], + ['not-a-number', 0], + ['$1,230.4500', 6], + ['0.001234', 6], + ['1000', 1], + ['-12.340', 4], + ])('counts %s as %s', (input, expected) => { + expect(countSignificantFigures(input)).toBe(expected); + }); + }); + + describe('hasExceededSignificantFigures', () => { + it('returns false for empty, invalid, and integer values', () => { + expect(hasExceededSignificantFigures('')).toBe(false); + expect(hasExceededSignificantFigures('abc')).toBe(false); + expect(hasExceededSignificantFigures('123456789')).toBe(false); + }); + + it('detects decimal values above the configured limit', () => { + expect(hasExceededSignificantFigures('123.456', 5)).toBe(true); + expect(hasExceededSignificantFigures('123.45', 5)).toBe(false); + }); + }); + + describe('roundToSignificantFigures', () => { + it('returns the original string for empty, invalid, and zero values', () => { + expect(roundToSignificantFigures('')).toBe(''); + expect(roundToSignificantFigures('abc')).toBe('abc'); + expect(roundToSignificantFigures('0')).toBe('0'); + }); + + it('rounds decimal values to the allowed significant figures', () => { + expect(roundToSignificantFigures('123.4567', 5)).toBe('123.46'); + expect(roundToSignificantFigures('123.4', 5)).toBe('123.4'); + expect(roundToSignificantFigures('12345.67', 3)).toBe('12346'); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/sortMarkets.test.ts b/packages/perps-controller/tests/src/utils/sortMarkets.test.ts new file mode 100644 index 0000000000..cc10051030 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/sortMarkets.test.ts @@ -0,0 +1,87 @@ +import { MARKET_SORTING_CONFIG } from '../../../src/constants/perpsConfig'; +import type { PerpsMarketData } from '../../../src/types'; +import { parseVolume, sortMarkets } from '../../../src/utils/sortMarkets'; + +const market = (overrides: Partial): PerpsMarketData => + ({ + name: 'BTC', + symbol: 'BTC', + price: '50000', + volume: '$1M', + openInterest: '$1M', + change24hPercent: '+1.00%', + fundingRate: 0, + ...overrides, + }) as PerpsMarketData; + +describe('sortMarkets utilities', () => { + describe('parseVolume', () => { + it.each([ + [undefined, -1], + ['-', -1], + ['$<1', 0.5], + ['$1.5K', 1_500], + ['$2.25M', 2_250_000], + ['$3.5B', 3_500_000_000], + ['$4T', 4_000_000_000_000], + ['$1,234.56', 1234.56], + ['not-a-number', -1], + ])('parses %s as %s', (input, expected) => { + expect(parseVolume(input)).toBe(expected); + }); + }); + + it('sorts by volume descending by default without mutating input', () => { + const markets = [ + market({ name: 'low', volume: '$1M' }), + market({ name: 'high', volume: '$2M' }), + ]; + + const result = sortMarkets({ + markets, + sortBy: MARKET_SORTING_CONFIG.SortFields.Volume, + }); + + expect(result.map(({ name }) => name)).toStrictEqual(['high', 'low']); + expect(markets.map(({ name }) => name)).toStrictEqual(['low', 'high']); + }); + + it('sorts price change, funding rate, and open interest ascending', () => { + const markets = [ + market({ + name: 'a', + change24hPercent: '+10.00%', + fundingRate: 0.02, + openInterest: '$3M', + }), + market({ + name: 'b', + change24hPercent: '-5.00%', + fundingRate: -0.01, + openInterest: '$1M', + }), + ]; + + expect( + sortMarkets({ + markets, + sortBy: MARKET_SORTING_CONFIG.SortFields.PriceChange, + direction: 'asc', + }).map(({ name }) => name), + ).toStrictEqual(['b', 'a']); + expect( + sortMarkets({ + markets, + sortBy: MARKET_SORTING_CONFIG.SortFields.FundingRate, + direction: 'asc', + }).map(({ name }) => name), + ).toStrictEqual(['b', 'a']); + expect( + sortMarkets({ + markets, + sortBy: MARKET_SORTING_CONFIG.SortFields.OpenInterest, + direction: 'asc', + }).map(({ name }) => name), + ).toStrictEqual(['b', 'a']); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/standaloneInfoClient.test.ts b/packages/perps-controller/tests/src/utils/standaloneInfoClient.test.ts new file mode 100644 index 0000000000..a07e84bc1c --- /dev/null +++ b/packages/perps-controller/tests/src/utils/standaloneInfoClient.test.ts @@ -0,0 +1,326 @@ +/* eslint-disable */ +import { HttpTransport, InfoClient } from '@nktkas/hyperliquid'; + +import type { ClearinghouseStateResponse } from '../../../src/types/hyperliquid-types'; +import { + createStandaloneInfoClient, + queryStandaloneClearinghouseStates, + queryStandaloneOpenOrders, +} from '../../../src/utils/standaloneInfoClient'; + +// Mock instances — must use 'mock' prefix for Jest hoisting +const mockHttpTransportInstance = { url: 'http://mock' }; +const mockInfoClientInstance = { + clearinghouseState: jest.fn(), + frontendOpenOrders: jest.fn(), +}; + +jest.mock('@nktkas/hyperliquid', () => ({ + HttpTransport: jest.fn(() => mockHttpTransportInstance), + InfoClient: jest.fn(() => mockInfoClientInstance), +})); + +// After jest.mock hoisting, these imports are the mocked constructors +const MockedHttpTransport = HttpTransport as unknown as jest.Mock; +const MockedInfoClient = InfoClient as unknown as jest.Mock; + +/** + * Factory for mock ClearinghouseStateResponse. + * Returns a minimal valid shape; callers can spread overrides. + * @param overrides + */ +const createMockClearinghouseResponse = ( + overrides: Partial = {}, +): ClearinghouseStateResponse => + ({ + marginSummary: { + totalMarginUsed: '0', + accountValue: '1000', + }, + withdrawable: '1000', + assetPositions: [], + ...overrides, + }) as ClearinghouseStateResponse; + +describe('standaloneInfoClient', () => { + beforeEach(() => { + jest.clearAllMocks(); + MockedHttpTransport.mockImplementation(() => mockHttpTransportInstance); + MockedInfoClient.mockImplementation(() => mockInfoClientInstance); + }); + + // ---------------------------------------------------------------- + // createStandaloneInfoClient + // ---------------------------------------------------------------- + describe('createStandaloneInfoClient', () => { + it('creates HttpTransport with mainnet config and default timeout', () => { + createStandaloneInfoClient({ isTestnet: false }); + + expect(MockedHttpTransport).toHaveBeenCalledWith({ + isTestnet: false, + timeout: 10_000, + }); + }); + + it('creates HttpTransport with testnet config', () => { + createStandaloneInfoClient({ isTestnet: true }); + + expect(MockedHttpTransport).toHaveBeenCalledWith({ + isTestnet: true, + timeout: 10_000, + }); + }); + + it('creates HttpTransport with custom timeout', () => { + createStandaloneInfoClient({ isTestnet: false, timeout: 5000 }); + + expect(MockedHttpTransport).toHaveBeenCalledWith({ + isTestnet: false, + timeout: 5000, + }); + }); + + it('passes HttpTransport instance to InfoClient', () => { + createStandaloneInfoClient({ isTestnet: false }); + + expect(MockedInfoClient).toHaveBeenCalledWith({ + transport: mockHttpTransportInstance, + }); + }); + + it('returns the InfoClient instance', () => { + const result = createStandaloneInfoClient({ isTestnet: false }); + + expect(result).toBe(mockInfoClientInstance); + }); + }); + + // ---------------------------------------------------------------- + // queryStandaloneClearinghouseStates + // ---------------------------------------------------------------- + describe('queryStandaloneClearinghouseStates', () => { + const userAddress = '0xABCDEF1234567890abcdef1234567890ABCDEF12'; + + let mockInfoClient: jest.Mocked>; + + beforeEach(() => { + mockInfoClient = { + clearinghouseState: jest.fn(), + }; + }); + + it('calls clearinghouseState for each DEX in the list', async () => { + const dexs: (string | null)[] = [null, 'xyz', 'abc']; + mockInfoClient.clearinghouseState.mockResolvedValue( + createMockClearinghouseResponse(), + ); + + await queryStandaloneClearinghouseStates( + mockInfoClient as unknown as InfoClient, + userAddress, + dexs, + ); + + expect(mockInfoClient.clearinghouseState).toHaveBeenCalledTimes(3); + }); + + it('passes user address without dex param for null DEX entries', async () => { + mockInfoClient.clearinghouseState.mockResolvedValue( + createMockClearinghouseResponse(), + ); + + await queryStandaloneClearinghouseStates( + mockInfoClient as unknown as InfoClient, + userAddress, + [null], + ); + + expect(mockInfoClient.clearinghouseState).toHaveBeenCalledWith({ + user: userAddress, + }); + }); + + it('passes user address with dex param for non-null DEX entries', async () => { + mockInfoClient.clearinghouseState.mockResolvedValue( + createMockClearinghouseResponse(), + ); + + await queryStandaloneClearinghouseStates( + mockInfoClient as unknown as InfoClient, + userAddress, + ['xyz'], + ); + + expect(mockInfoClient.clearinghouseState).toHaveBeenCalledWith({ + user: userAddress, + dex: 'xyz', + }); + }); + + it('returns all clearinghouseState responses in order', async () => { + const responseA = createMockClearinghouseResponse({ + withdrawable: '100', + }); + const responseB = createMockClearinghouseResponse({ + withdrawable: '200', + }); + const responseC = createMockClearinghouseResponse({ + withdrawable: '300', + }); + + mockInfoClient.clearinghouseState + .mockResolvedValueOnce(responseA) + .mockResolvedValueOnce(responseB) + .mockResolvedValueOnce(responseC); + + const results = await queryStandaloneClearinghouseStates( + mockInfoClient as unknown as InfoClient, + userAddress, + [null, 'xyz', 'abc'], + ); + + expect(results).toEqual([responseA, responseB, responseC]); + }); + + it('returns single response for main-DEX-only list', async () => { + const response = createMockClearinghouseResponse(); + mockInfoClient.clearinghouseState.mockResolvedValue(response); + + const results = await queryStandaloneClearinghouseStates( + mockInfoClient as unknown as InfoClient, + userAddress, + [null], + ); + + expect(mockInfoClient.clearinghouseState).toHaveBeenCalledTimes(1); + expect(results).toEqual([response]); + }); + + it('returns empty array for empty DEX list', async () => { + const results = await queryStandaloneClearinghouseStates( + mockInfoClient as unknown as InfoClient, + userAddress, + [], + ); + + expect(mockInfoClient.clearinghouseState).not.toHaveBeenCalled(); + expect(results).toEqual([]); + }); + + it('returns successful results when some DEX queries fail', async () => { + const responseA = createMockClearinghouseResponse({ + withdrawable: '100', + }); + const responseC = createMockClearinghouseResponse({ + withdrawable: '300', + }); + + mockInfoClient.clearinghouseState + .mockResolvedValueOnce(responseA) + .mockRejectedValueOnce(new Error('HIP-3 DEX timeout')) + .mockResolvedValueOnce(responseC); + + const results = await queryStandaloneClearinghouseStates( + mockInfoClient as unknown as InfoClient, + userAddress, + [null, 'failing-dex', 'healthy-dex'], + ); + + expect(results).toEqual([responseA, responseC]); + }); + + it('returns empty array when all DEX queries fail', async () => { + mockInfoClient.clearinghouseState.mockRejectedValue( + new Error('Network timeout'), + ); + + const results = await queryStandaloneClearinghouseStates( + mockInfoClient as unknown as InfoClient, + userAddress, + [null, 'dex-a'], + ); + + expect(results).toEqual([]); + }); + }); + + // ---------------------------------------------------------------- + // queryStandaloneOpenOrders + // ---------------------------------------------------------------- + describe('queryStandaloneOpenOrders', () => { + const userAddress = '0xABCDEF1234567890abcdef1234567890ABCDEF12'; + + let mockInfoClient: { + frontendOpenOrders: jest.Mock; + }; + + beforeEach(() => { + mockInfoClient = { + frontendOpenOrders: jest.fn(), + }; + }); + + it('returns orders from all DEXs combined', async () => { + const ordersA = [{ oid: 1, coin: 'BTC', side: 'A' }]; + const ordersB = [{ oid: 2, coin: 'ETH', side: 'B' }]; + + mockInfoClient.frontendOpenOrders + .mockResolvedValueOnce(ordersA) + .mockResolvedValueOnce(ordersB); + + const results = await queryStandaloneOpenOrders( + mockInfoClient as unknown as InfoClient, + userAddress, + [null, 'xyz'], + ); + + expect(results).toEqual([ordersA, ordersB]); + expect(mockInfoClient.frontendOpenOrders).toHaveBeenCalledTimes(2); + }); + + it('returns only fulfilled results when some DEX queries fail', async () => { + const ordersA = [{ oid: 1, coin: 'BTC', side: 'A' }]; + + mockInfoClient.frontendOpenOrders + .mockResolvedValueOnce(ordersA) + .mockRejectedValueOnce(new Error('DEX timeout')); + + const results = await queryStandaloneOpenOrders( + mockInfoClient as unknown as InfoClient, + userAddress, + [null, 'failing-dex'], + ); + + expect(results).toEqual([ordersA]); + }); + + it('omits dex param when DEX is null', async () => { + mockInfoClient.frontendOpenOrders.mockResolvedValue([]); + + await queryStandaloneOpenOrders( + mockInfoClient as unknown as InfoClient, + userAddress, + [null], + ); + + expect(mockInfoClient.frontendOpenOrders).toHaveBeenCalledWith({ + user: userAddress, + }); + }); + + it('passes dex param for non-null DEX', async () => { + mockInfoClient.frontendOpenOrders.mockResolvedValue([]); + + await queryStandaloneOpenOrders( + mockInfoClient as unknown as InfoClient, + userAddress, + ['xyz'], + ); + + expect(mockInfoClient.frontendOpenOrders).toHaveBeenCalledWith({ + user: userAddress, + dex: 'xyz', + }); + }); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/stringParseUtils.test.ts b/packages/perps-controller/tests/src/utils/stringParseUtils.test.ts new file mode 100644 index 0000000000..179516c24f --- /dev/null +++ b/packages/perps-controller/tests/src/utils/stringParseUtils.test.ts @@ -0,0 +1,79 @@ +/* eslint-disable */ +import { + stripQuotes, + parseCommaSeparatedString, +} from '../../../src/utils/stringParseUtils'; + +describe('stripQuotes', () => { + it('removes single layer of double quotes', () => { + expect(stripQuotes('"hello"')).toBe('hello'); + }); + + it('removes single layer of single quotes', () => { + expect(stripQuotes("'hello'")).toBe('hello'); + }); + + it('removes nested quotes (single wrapping double)', () => { + // Simulates LaunchDarkly returning '"xyz:TSLA"' (single quotes wrapping double quotes) + expect(stripQuotes(`'"xyz:TSLA"'`)).toBe('xyz:TSLA'); + }); + + it('removes multiple layers of double quotes', () => { + expect(stripQuotes('""xyz""')).toBe('xyz'); + }); + + it('removes mixed nested quotes (double wrapping single)', () => { + expect(stripQuotes(`"'xyz'"`)).toBe('xyz'); + }); + + it('returns string unchanged when no wrapping quotes', () => { + expect(stripQuotes('hello')).toBe('hello'); + }); + + it('returns empty string unchanged', () => { + expect(stripQuotes('')).toBe(''); + }); + + it('does not remove mismatched quotes', () => { + expect(stripQuotes(`"hello'`)).toBe(`"hello'`); + }); + + it('does not remove quotes in the middle', () => { + expect(stripQuotes('hel"lo')).toBe('hel"lo'); + }); + + it('handles deeply nested single quotes', () => { + expect(stripQuotes(`'''xyz'''`)).toBe('xyz'); + }); + + it('handles real LaunchDarkly pattern with nested quotes', () => { + // The actual problematic value: single-quote wrapped double-quoted string + expect(stripQuotes(`'"xyz:TSLA"'`)).toBe('xyz:TSLA'); + }); +}); + +describe('parseCommaSeparatedString', () => { + it('parses comma-separated values', () => { + expect(parseCommaSeparatedString('BTC,ETH,SOL')).toEqual([ + 'BTC', + 'ETH', + 'SOL', + ]); + }); + + it('trims whitespace', () => { + expect(parseCommaSeparatedString(' BTC , ETH , SOL ')).toEqual([ + 'BTC', + 'ETH', + 'SOL', + ]); + }); + + it('filters empty values', () => { + expect(parseCommaSeparatedString('BTC,,SOL')).toEqual(['BTC', 'SOL']); + }); + + it('returns empty array for empty string', () => { + expect(parseCommaSeparatedString('')).toEqual([]); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/transferData.test.ts b/packages/perps-controller/tests/src/utils/transferData.test.ts new file mode 100644 index 0000000000..87d14ca2a3 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/transferData.test.ts @@ -0,0 +1,26 @@ +import { generateERC20TransferData } from '../../../src/utils/transferData'; + +describe('generateERC20TransferData', () => { + it('encodes ERC-20 transfer calldata', () => { + expect( + generateERC20TransferData( + '0x000000000000000000000000000000000000dEaD', + '0x64', + ), + ).toBe( + '0xa9059cbb000000000000000000000000000000000000000000000000000000000000dead0000000000000000000000000000000000000000000000000000000000000064', + ); + }); + + it('requires both recipient and amount', () => { + expect(() => generateERC20TransferData('', '0x64')).toThrow( + "'toAddress' and 'amount' must be defined", + ); + expect(() => + generateERC20TransferData( + '0x000000000000000000000000000000000000dEaD', + '', + ), + ).toThrow("'toAddress' and 'amount' must be defined"); + }); +}); From ea538ee5f3d44f20f02f9dc36907f1fb84302562 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 20 May 2026 18:36:50 +0800 Subject: [PATCH 2/5] test(perps): drain constructor eligibility chain in deposit beforeEach Constructor-initiated refreshEligibility chain (RFFC:getState -> GeolocationController:getGeolocation) was leaking into depositMockCall recordings before assertions ran, causing CI failures on Node 18 while local Node 22 passed due to microtask ordering differences. --- .../tests/src/PerpsController.operations.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/perps-controller/tests/src/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts index 830676e39c..a966b7e577 100644 --- a/packages/perps-controller/tests/src/PerpsController.operations.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.operations.test.ts @@ -1061,7 +1061,7 @@ describe('PerpsController', () => { let depositController: TestablePerpsController; let depositMockCall: jest.Mock; - beforeEach(() => { + beforeEach(async () => { // Mock DepositService jest .spyOn(mockDepositServiceInstance, 'prepareTransaction') @@ -1142,6 +1142,16 @@ describe('PerpsController', () => { state: getDefaultPerpsControllerState(), infrastructure: depositInfrastructure, }); + + // Drain the async eligibility chain started in the constructor + // (RemoteFeatureFlagController:getState → refreshEligibility → + // GeolocationController:getGeolocation) so its messenger calls do not + // leak into per-test assertions on depositMockCall. Microtask ordering + // differs between Node versions, so without this drain the chain can + // bleed into the recorded call list on CI (Node 18) while passing + // locally (Node 22). + await new Promise((resolve) => setImmediate(resolve)); + depositMockCall.mockClear(); }); afterEach(() => { From f59d60dfa9cbe9a30397b9fb15c24197d22841a9 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 20 May 2026 21:14:29 +0800 Subject: [PATCH 3/5] test(perps): drop unused createMockOrderResult/createMockOrderParams helpers --- .../tests/helpers/providerMocks.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/packages/perps-controller/tests/helpers/providerMocks.ts b/packages/perps-controller/tests/helpers/providerMocks.ts index fddf627059..db4509a63c 100644 --- a/packages/perps-controller/tests/helpers/providerMocks.ts +++ b/packages/perps-controller/tests/helpers/providerMocks.ts @@ -64,21 +64,6 @@ export const createMockHyperLiquidProvider = reconnect: jest.fn().mockResolvedValue(undefined), }) as unknown as jest.Mocked; -export const createMockOrderResult = () => ({ - success: true, - orderId: 'order-123', - filledSize: '0.1', - averagePrice: '50000', -}); - -export const createMockOrderParams = () => ({ - symbol: 'BTC', - side: 'buy', - orderType: 'market', - amount: '0.1', - price: '50000', -}); - export const createMockOrder = (overrides = {}) => ({ orderId: 'order-1', symbol: 'BTC', From 24ce9a1a624eecf511e134782cd2b98929adf7ad Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 20 May 2026 21:38:47 +0800 Subject: [PATCH 4/5] test(perps): expect isInternal:true in deposit addTransaction options submitTransaction now spreads isInternal:true into addTransaction options (added via main merge). Update both perpsDeposit and perpsDepositAndOrder assertions to match. --- .../tests/src/PerpsController.operations.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/perps-controller/tests/src/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts index a966b7e577..544340ab5f 100644 --- a/packages/perps-controller/tests/src/PerpsController.operations.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.operations.test.ts @@ -1220,6 +1220,7 @@ describe('PerpsController', () => { origin: 'metamask', type: 'perpsDeposit', skipInitialGasEstimate: true, + isInternal: true, }, ); }); @@ -1611,6 +1612,7 @@ describe('PerpsController', () => { origin: 'metamask', type: 'perpsDepositAndOrder', skipInitialGasEstimate: true, + isInternal: true, }, ); // Should NOT also call with perpsDeposit type From 3ff0529534bfcf1d494e346e21fca63c67393dfa Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 20 May 2026 22:07:04 +0800 Subject: [PATCH 5/5] test(perps): fix afterEach mockClear to check for function not object jest.fn() is typeof 'function', not 'object', so the provider-mock clearing loop never executed. Cleanup still worked via clearAllMocks in beforeEach, but the selective clear was dead code. --- .../tests/src/PerpsController.configuration.test.ts | 2 +- .../tests/src/PerpsController.lifecycle.test.ts | 2 +- .../tests/src/PerpsController.operations.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/perps-controller/tests/src/PerpsController.configuration.test.ts b/packages/perps-controller/tests/src/PerpsController.configuration.test.ts index 347590c7f9..bacb51ff5d 100644 --- a/packages/perps-controller/tests/src/PerpsController.configuration.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.configuration.test.ts @@ -639,7 +639,7 @@ describe('PerpsController', () => { if (mockProvider) { Object.values(mockProvider).forEach((value) => { if ( - typeof value === 'object' && + typeof value === 'function' && value !== null && 'mockClear' in value ) { diff --git a/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts b/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts index eaf03f6860..e88923a6ab 100644 --- a/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts @@ -639,7 +639,7 @@ describe('PerpsController', () => { if (mockProvider) { Object.values(mockProvider).forEach((value) => { if ( - typeof value === 'object' && + typeof value === 'function' && value !== null && 'mockClear' in value ) { diff --git a/packages/perps-controller/tests/src/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts index 544340ab5f..7697bdc850 100644 --- a/packages/perps-controller/tests/src/PerpsController.operations.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.operations.test.ts @@ -639,7 +639,7 @@ describe('PerpsController', () => { if (mockProvider) { Object.values(mockProvider).forEach((value) => { if ( - typeof value === 'object' && + typeof value === 'function' && value !== null && 'mockClear' in value ) {