diff --git a/eslint-suppressions.json b/eslint-suppressions.json index d63a487fe3..89b575d786 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1519,12 +1519,7 @@ }, "packages/perps-controller/src/PerpsController.ts": { "no-restricted-syntax": { - "count": 3 - } - }, - "packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 + "count": 2 } }, "packages/perps-controller/src/utils/myxAdapter.ts": { diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 2e7dbf0a5a..58e15499ef 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -47,6 +47,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Invalid parameters are rejected with a typed `PERPS_ERROR_CODES` value, and nothing invalid is ever signed; see the new error codes entry below for the full list and for which few are decided after a read rather than before any request. - Add `twap`, `scale` and `chase` to `PERPS_EVENT_VALUE.ORDER_TYPE`, which dashboards key on and which `TradingService` emits verbatim ([#9832](https://github.com/MetaMask/core/pull/9832)) - Add the `StrategyOrderType` and `OrdinaryOrderType` types, plus `STRATEGY_ORDER_TYPES`, `isStrategyOrderType`, `SCALE_ORDER_COUNT`, `computeScalePriceLadder`, `splitScaleSizes`, `computeChaseQuotePrice`, `getPriceTick`, `CHASE_ORDER_CONFIG`, and `HYPERLIQUID_TWAP_LIMITS` ([#9832](https://github.com/MetaMask/core/pull/9832)) +- Add an optional schema-v2 Terminal market snapshot path with strict identity, freshness, completeness, unit, and payload validation before falling back to HyperLiquid ([#9815](https://github.com/MetaMask/core/pull/9815)). +- Add `PerpsController.getUserDataSnapshot()` to fetch and cache positions, open orders, and account state as one account- and DEX-scoped result ([#9815](https://github.com/MetaMask/core/pull/9815)). ### Changed @@ -55,6 +57,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `isLimitExecutionOrderType` is unchanged: it answers the narrower question of whether `OrderParams.price` carries a real limit price, which for a strategy placement it does not. - `TriggerOrderType` is now spelled out as `'stop_market' | 'stop_limit' | 'take_profit_market' | 'take_profit_limit'` instead of being derived as `Exclude` ([#9832](https://github.com/MetaMask/core/pull/9832)) - The resolved type is unchanged for existing consumers. Deriving it meant that any order type added to `OrderType` that was neither `market` nor `limit` was pulled into the trigger union automatically and started demanding a trigger price it had no concept of. +- Reuse provider DEX discovery for subscriptions, and start account preloading independently from market preloading to reduce cold-start blocking ([#9815](https://github.com/MetaMask/core/pull/9815)). +- Require a selected EVM address and the current Hyperliquid network/HIP-3/DEX identity before returning cached account data; legacy or mismatched entries now fail closed and refresh ([#9815](https://github.com/MetaMask/core/pull/9815)). ## [11.0.0] diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index 2c00591765..7a242c5feb 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -36,6 +36,18 @@ export type PerpsControllerGetCachedUserDataForActiveProviderAction = { handler: PerpsController['getCachedUserDataForActiveProvider']; }; +/** + * Fetch, validate, and atomically cache a complete user-data snapshot. + * This remains callable after mount so consumers can seed their live channel + * from one coherent positions/orders/account result. + * + * @returns The accepted user-data snapshot. + */ +export type PerpsControllerGetUserDataSnapshotAction = { + type: `PerpsController:getUserDataSnapshot`; + handler: PerpsController['getUserDataSnapshot']; +}; + /** * Initialize the PerpsController providers * Must be called before using any other methods @@ -1123,6 +1135,7 @@ export type PerpsControllerIsCurrentlyReinitializingAction = { export type PerpsControllerMethodActions = | PerpsControllerGetCachedMarketDataForActiveProviderAction | PerpsControllerGetCachedUserDataForActiveProviderAction + | PerpsControllerGetUserDataSnapshotAction | PerpsControllerInitAction | PerpsControllerGetActiveProviderAction | PerpsControllerGetActiveProviderOrNullAction diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 243d6d921e..84d0d20ca7 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -5,6 +5,7 @@ import type { import { BaseController, ControllerGetStateAction, + ControllerStateChangedEvent, ControllerStateChangeEvent, StateMetadata, } from '@metamask/base-controller'; @@ -19,7 +20,12 @@ import { PERPS_EVENT_PROPERTY, PERPS_EVENT_VALUE, } from './constants/eventNames.js'; -import { USDC_SYMBOL } from './constants/hyperLiquidConfig.js'; +import { + canonicalizeHyperLiquidDexes, + MAINNET_HIP3_CONFIG, + TESTNET_HIP3_CONFIG, + USDC_SYMBOL, +} from './constants/hyperLiquidConfig.js'; import { PerpsMeasurementName } from './constants/performanceMetrics.js'; import type { SortOptionId, @@ -30,7 +36,6 @@ import { PERPS_CONSTANTS, MARKET_SORTING_CONFIG, PROVIDER_CONFIG, - PERPS_DISK_CACHE_USER_DATA, buildProviderCacheKey, MAX_SLIPPAGE_BOUNDS, DEFAULT_PERPS_MODE, @@ -120,6 +125,7 @@ import type { PerpsAnalyticsProperties, PerpsAttributionContext, PerpsProviderType, + PerpsUserDataSnapshot, PerpsSelectedPaymentToken, PerpsRemoteFeatureFlagState, PerpsTransactionParams, @@ -141,6 +147,7 @@ import { getSelectedEvmAccountFromMessenger } from './utils/accountUtils.js'; import { ensureError } from './utils/errorUtils.js'; import { parseAssetName } from './utils/hyperLiquidAdapter.js'; import { + clonePerpsMarketData, compileMarketPattern, shouldIncludeMarket, } from './utils/marketUtils.js'; @@ -150,11 +157,49 @@ import { persistMarketEntriesToDisk, persistUserEntriesToDisk, } from './utils/perpsDiskPersistence.js'; +import type { DiskCacheUserEntry } from './utils/perpsDiskPersistence.js'; import { wait } from './utils/wait.js'; /** Derived type for logger options from PerpsLogger interface */ type PerpsLoggerOptions = Parameters[1]; +function cloneUserDataSnapshot( + snapshot: PerpsUserDataSnapshot, +): PerpsUserDataSnapshot { + return { + positions: snapshot.positions.map((position) => ({ + ...position, + leverage: { ...position.leverage }, + cumulativeFunding: { ...position.cumulativeFunding }, + ...(position.takeProfitOrders && { + takeProfitOrders: position.takeProfitOrders.map((order) => ({ + ...order, + })), + }), + ...(position.stopLossOrders && { + stopLossOrders: position.stopLossOrders.map((order) => ({ + ...order, + })), + }), + })), + orders: snapshot.orders.map((order) => ({ ...order })), + accountState: { + ...snapshot.accountState, + ...(snapshot.accountState.subAccountBreakdown && { + subAccountBreakdown: Object.fromEntries( + Object.entries(snapshot.accountState.subAccountBreakdown).map( + ([dex, balances]) => [dex, { ...balances }], + ), + ), + }), + }, + identity: { + ...snapshot.identity, + dexes: [...snapshot.identity.dexes], + }, + }; +} + /** * Returns the first non-empty string from the given values. * Env vars default to '' (not null/undefined), so ?? wouldn't fall through. @@ -442,11 +487,18 @@ export type PerpsControllerState = { // Keyed by "providerId:network" (e.g. 'hyperliquid:mainnet', 'myx:testnet') cachedMarketDataByProvider: Record< string, - { data: PerpsMarketData[]; timestamp: number } + { + data: PerpsMarketData[]; + timestamp: number; + sourceExpiresAt?: number; + hip3ConfigVersion?: number; + dexes?: string[]; + } >; // Cached user data from background preloading (REST snapshots, not WebSocket) - // Keyed by "providerId:network" (e.g. 'hyperliquid:mainnet', 'myx:testnet') + // Keyed by "providerId:network". The entry carries the selected address and + // exact HyperLiquid configuration identity, both validated before reads. cachedUserDataByProvider: Record< string, { @@ -455,6 +507,8 @@ export type PerpsControllerState = { accountState: AccountState | null; timestamp: number; address: string; + hip3ConfigVersion?: number; + dexes?: string[]; } >; }; @@ -733,10 +787,9 @@ const metadata: StateMetadata = { /** * PerpsController events */ -export type PerpsControllerEvents = ControllerStateChangeEvent< - 'PerpsController', - PerpsControllerState ->; +export type PerpsControllerEvents = + | ControllerStateChangeEvent<'PerpsController', PerpsControllerState> + | ControllerStateChangedEvent<'PerpsController', PerpsControllerState>; /** * The action which can be used to retrieve the state of the @@ -792,6 +845,16 @@ type BlockedRegionList = { source: 'remote' | 'fallback'; }; +type UserSnapshotContext = { + provider: PerpsProvider; + standaloneProvider: HyperLiquidProvider | null; + address: string; + isTestnet: boolean; + hip3ConfigVersion: number; + expectedDexes: string[]; + isCurrent: () => boolean; +}; + const MESSENGER_EXPOSED_METHODS = [ 'calculateFees', 'calculateLiquidationPrice', @@ -820,6 +883,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getBlockExplorerUrl', 'getCachedMarketDataForActiveProvider', 'getCachedUserDataForActiveProvider', + 'getUserDataSnapshot', 'getCurrentNetwork', 'getFunding', 'getHistoricalPortfolio', @@ -1017,6 +1081,11 @@ export class PerpsController extends BaseController< #standaloneProviderHip3Version: number | null = null; + readonly #standaloneProviderOperations = new Map< + PerpsProvider, + Set> + >(); + #eligibilityCheckDeferred: boolean; /** @@ -1034,6 +1103,8 @@ export class PerpsController extends BaseController< */ #ausQueue: Promise = Promise.resolve(); + #userDiskWrite: Promise = Promise.resolve(); + // Store options for dependency injection (allows core package to inject platform-specific services) readonly #options: PerpsControllerOptions; @@ -1085,7 +1156,9 @@ export class PerpsController extends BaseController< ...infrastructure, terminalMarketService: infrastructure.terminalMarketService ?? - (infrastructure.terminalApiUrl + (infrastructure.terminalApi?.marketDataUrl || + infrastructure.terminalApiUrl || + infrastructure.terminalApi?.globalSnapshotUrl ? new TerminalMarketService(infrastructure) : undefined), }); @@ -1246,7 +1319,6 @@ export class PerpsController extends BaseController< if (activeProvider === 'aggregated') { // Assemble from all registered provider entries const assembled: PerpsMarketData[] = []; - let oldestTimestamp = Infinity; for (const providerId of this.#getAggregatedCacheProviderIds( Object.keys(cache), )) { @@ -1255,19 +1327,14 @@ export class PerpsController extends BaseController< if (!entry || entry.data.length === 0) { continue; } - oldestTimestamp = Math.min(oldestTimestamp, entry.timestamp); - assembled.push(...entry.data); + if (!this.#isMarketCacheEntryCurrent(providerId, entry, options)) { + continue; + } + assembled.push(...clonePerpsMarketData(entry.data)); } if (assembled.length === 0) { return null; } - // Check TTL against the oldest entry - if ( - !options?.skipTTL && - Date.now() - oldestTimestamp > PerpsController.#preloadGuardMs * 10 - ) { - return null; - } return assembled; } @@ -1277,13 +1344,33 @@ export class PerpsController extends BaseController< if (!entry || entry.data.length === 0) { return null; } - if ( - !options?.skipTTL && - Date.now() - entry.timestamp > PerpsController.#preloadGuardMs * 10 - ) { + if (!this.#isMarketCacheEntryCurrent(activeProvider, entry, options)) { return null; } - return entry.data; + return clonePerpsMarketData(entry.data); + } + + #isMarketCacheEntryCurrent( + providerId: string, + entry: PerpsControllerState['cachedMarketDataByProvider'][string], + options?: { skipTTL?: boolean }, + ): boolean { + if (entry.sourceExpiresAt !== undefined) { + const expectedDexes = this.#getStaticSnapshotDexes(); + return ( + providerId === 'hyperliquid' && + Date.now() < entry.sourceExpiresAt && + entry.hip3ConfigVersion === this.state.hip3ConfigVersion && + expectedDexes !== undefined && + Array.isArray(entry.dexes) && + entry.dexes.length === expectedDexes.length && + entry.dexes.every((dex, index) => dex === expectedDexes[index]) + ); + } + return ( + options?.skipTTL === true || + Date.now() - entry.timestamp <= PerpsController.#preloadGuardMs * 10 + ); } /** @@ -1312,14 +1399,22 @@ export class PerpsController extends BaseController< const evmAccount = getSelectedEvmAccountFromMessenger(this.messenger); currentAddress = evmAccount?.address ?? null; } catch { - // Can't determine current account — trust the cache + // Account identity is required before account-scoped data can be trusted. + } + + if (!currentAddress) { + return null; } + const selectedAddress = currentAddress; const skipTTL = options?.skipTTL ?? false; const isValidEntry = ( - entry: { timestamp: number; address: string } | undefined, - ): entry is { timestamp: number; address: string } => { + providerId: string, + entry: + | PerpsControllerState['cachedUserDataByProvider'][string] + | undefined, + ): entry is PerpsControllerState['cachedUserDataByProvider'][string] => { if (!entry) { return false; } @@ -1327,8 +1422,7 @@ export class PerpsController extends BaseController< return false; } if ( - currentAddress && - entry.address.toLowerCase() !== currentAddress.toLowerCase() + !this.#isUserCacheIdentityCurrent(providerId, entry, selectedAddress) ) { return false; } @@ -1345,9 +1439,12 @@ export class PerpsController extends BaseController< for (const providerId of this.#getAggregatedCacheProviderIds( Object.keys(cache), )) { - const key = buildProviderCacheKey(providerId, this.state.isTestnet); - const entry = cache[key]; - if (!isValidEntry(entry)) { + const providerNetworkKey = buildProviderCacheKey( + providerId, + this.state.isTestnet, + ); + const entry = cache[providerNetworkKey]; + if (!isValidEntry(providerId, entry)) { continue; } hasValidEntry = true; @@ -1371,9 +1468,12 @@ export class PerpsController extends BaseController< } // Single provider mode - const key = buildProviderCacheKey(activeProvider, this.state.isTestnet); - const entry = cache[key]; - if (!entry || !isValidEntry(entry)) { + const providerNetworkKey = buildProviderCacheKey( + activeProvider, + this.state.isTestnet, + ); + const entry = cache[providerNetworkKey]; + if (!entry || !isValidEntry(activeProvider, entry)) { return null; } @@ -1384,6 +1484,199 @@ export class PerpsController extends BaseController< }; } + #isUserCacheIdentityCurrent( + providerId: string, + entry: PerpsControllerState['cachedUserDataByProvider'][string], + address: string, + ): boolean { + if (entry.address.toLowerCase() !== address.toLowerCase()) { + return false; + } + if (providerId !== 'hyperliquid') { + return true; + } + + const expectedDexes = this.#getStaticSnapshotDexes(); + return ( + entry.hip3ConfigVersion === this.state.hip3ConfigVersion && + expectedDexes !== undefined && + Array.isArray(entry.dexes) && + entry.dexes.length === expectedDexes.length && + entry.dexes.every((dex, index) => dex === expectedDexes[index]) + ); + } + + /** + * Fetch, validate, and atomically cache a complete user-data snapshot. + * This remains callable after mount so consumers can seed their live channel + * from one coherent positions/orders/account result. + * + * @returns The accepted user-data snapshot. + */ + async getUserDataSnapshot(): Promise { + const evmAccount = getSelectedEvmAccountFromMessenger(this.messenger); + if (!evmAccount?.address) { + throw new Error('Cannot fetch user data snapshot without an EVM account'); + } + if (this.state.activeProvider !== 'hyperliquid') { + throw new Error('User data snapshots require Hyperliquid provider mode'); + } + + const capturedActiveProvider = this.activeProviderInstance; + const standaloneProvider = capturedActiveProvider + ? null + : this.#getOrCreateStandaloneProvider(); + const provider = capturedActiveProvider ?? standaloneProvider; + if (!provider) { + throw new Error('Cannot create standalone Hyperliquid provider'); + } + const { address } = evmAccount; + const { isTestnet, hip3ConfigVersion } = this.state; + const network = isTestnet ? 'testnet' : 'mainnet'; + const expectedDexes = this.#getStaticSnapshotDexes(); + if (!expectedDexes) { + throw new Error('User data snapshot DEX identity is not static'); + } + const isCurrent = (): boolean => { + let currentAddress: string | undefined; + try { + currentAddress = getSelectedEvmAccountFromMessenger( + this.messenger, + )?.address; + } catch { + return false; + } + + return ( + this.state.activeProvider === 'hyperliquid' && + (!capturedActiveProvider || + this.activeProviderInstance === capturedActiveProvider) && + this.state.isTestnet === isTestnet && + this.state.hip3ConfigVersion === hip3ConfigVersion && + currentAddress?.toLowerCase() === address.toLowerCase() + ); + }; + + const context: UserSnapshotContext = { + provider, + standaloneProvider, + address, + isTestnet, + hip3ConfigVersion, + expectedDexes, + isCurrent, + }; + const requestKey = [ + 'hyperliquid', + network, + address.toLowerCase(), + hip3ConfigVersion, + ...expectedDexes, + ].join('|'); + const existingRequest = this.#userSnapshotRequests.get(requestKey); + if (existingRequest?.provider === provider) { + return existingRequest.promise; + } + + const request = this.#fetchAndCacheUserDataSnapshot(context); + this.#userSnapshotRequests.set(requestKey, { provider, promise: request }); + try { + return await request; + } finally { + if (this.#userSnapshotRequests.get(requestKey)?.promise === request) { + this.#userSnapshotRequests.delete(requestKey); + } + } + } + + async #fetchAndCacheUserDataSnapshot( + context: UserSnapshotContext, + ): Promise { + const { + provider, + standaloneProvider, + address, + isTestnet, + hip3ConfigVersion, + expectedDexes, + isCurrent, + } = context; + if (!isCurrent()) { + throw new Error('User data snapshot context changed'); + } + if (!provider.getUserDataSnapshot) { + throw new Error('Provider has no atomic snapshot API'); + } + const identity = { + provider: 'hyperliquid' as const, + network: isTestnet ? ('testnet' as const) : ('mainnet' as const), + hip3ConfigVersion, + dexes: expectedDexes, + }; + const snapshotRequest = provider.getUserDataSnapshot({ + userAddress: address, + identity, + }); + const snapshot = standaloneProvider + ? await this.#trackStandaloneProviderOperation( + standaloneProvider, + snapshotRequest, + ) + : await snapshotRequest; + + if (!isCurrent()) { + throw new Error('User data snapshot context changed'); + } + + const snapshotIdentity = snapshot.identity; + const hasCompleteBundle = + Array.isArray(snapshot.positions) && + Array.isArray(snapshot.orders) && + snapshot.accountState !== null && + typeof snapshot.accountState === 'object'; + const hasExactIdentity = + snapshotIdentity.provider === identity.provider && + snapshotIdentity.network === identity.network && + snapshotIdentity.hip3ConfigVersion === identity.hip3ConfigVersion && + snapshotIdentity.address.toLowerCase() === address.toLowerCase() && + snapshotIdentity.dexes.length === expectedDexes.length && + snapshotIdentity.dexes.every( + (dex, index) => dex === expectedDexes[index], + ); + if (!hasCompleteBundle || !hasExactIdentity) { + throw new Error('User data snapshot is incomplete or mismatched'); + } + + if (!isCurrent()) { + throw new Error('User data snapshot context changed'); + } + + const cachedSnapshot = cloneUserDataSnapshot(snapshot); + const result = cloneUserDataSnapshot(snapshot); + const timestamp = Date.now(); + const providerNetworkKey = buildProviderCacheKey('hyperliquid', isTestnet); + this.update((state) => { + state.cachedUserDataByProvider[providerNetworkKey] = { + positions: cachedSnapshot.positions, + orders: cachedSnapshot.orders, + accountState: cachedSnapshot.accountState, + timestamp, + address, + hip3ConfigVersion, + dexes: expectedDexes, + }; + }); + this.#persistUserCacheToDisk(); + this.#debugLog('PerpsController: user cache snapshot written', { + writtenKey: providerNetworkKey, + availableKeys: Object.keys(this.state.cachedUserDataByProvider).sort(), + positionCount: cachedSnapshot.positions.length, + orderCount: cachedSnapshot.orders.length, + }); + + return result; + } + /** * Returns a cached standalone HyperLiquidProvider for pre-initialization * discovery queries. Creates a new instance on first call or when the @@ -1403,10 +1696,13 @@ export class PerpsController extends BaseController< return this.#standaloneProvider; } - // Stale or missing — tear down old one (fire-and-forget) + // Stale or missing — retire the old provider after active operations finish. if (this.#standaloneProvider) { const old = this.#standaloneProvider; - Promise.resolve(old.disconnect()).catch(() => { + this.#standaloneProvider = null; + this.#standaloneProviderIsTestnet = null; + this.#standaloneProviderHip3Version = null; + this.#retireStandaloneProvider(old).catch(() => { /* best-effort */ }); } @@ -1432,22 +1728,54 @@ export class PerpsController extends BaseController< return this.#standaloneProvider; } + #trackStandaloneProviderOperation( + provider: PerpsProvider, + operation: Promise, + ): Promise { + const operations = + this.#standaloneProviderOperations.get(provider) ?? new Set(); + this.#standaloneProviderOperations.set(provider, operations); + + const trackedOperation = operation.finally(() => { + operations.delete(trackedOperation); + if (operations.size === 0) { + this.#standaloneProviderOperations.delete(provider); + } + }); + operations.add(trackedOperation); + + return trackedOperation; + } + + async #retireStandaloneProvider( + provider: HyperLiquidProvider, + ): Promise { + const operations = this.#standaloneProviderOperations.get(provider); + if (operations?.size) { + await Promise.allSettled([...operations]); + } + try { + await provider.disconnect(); + } catch { + /* best-effort */ + } finally { + this.#standaloneProviderOperations.delete(provider); + } + } + /** * Disconnect and discard the cached standalone provider (if any). * Best-effort — errors are silently caught. */ async #cleanupStandaloneProvider(): Promise { - if (!this.#standaloneProvider) { + const provider = this.#standaloneProvider; + if (!provider) { return; } - try { - await this.#standaloneProvider.disconnect(); - } catch { - /* best-effort */ - } this.#standaloneProvider = null; this.#standaloneProviderIsTestnet = null; this.#standaloneProviderHip3Version = null; + await this.#retireStandaloneProvider(provider); } /** @@ -2945,7 +3273,10 @@ export class PerpsController extends BaseController< // TODO: When adding new providers (MYX), consider a provider factory pattern const provider = this.activeProviderInstance ?? this.#getOrCreateStandaloneProvider(); - return provider.getPositions(params); + const operation = provider.getPositions(params); + return provider === this.#standaloneProvider + ? this.#trackStandaloneProviderOperation(provider, operation) + : operation; } const provider = this.getActiveProvider(); @@ -3017,7 +3348,10 @@ export class PerpsController extends BaseController< if (params?.standalone && params.userAddress) { const provider = this.activeProviderInstance ?? this.#getOrCreateStandaloneProvider(); - return provider.getOpenOrders(params); + const operation = provider.getOpenOrders(params); + return provider === this.#standaloneProvider + ? this.#trackStandaloneProviderOperation(provider, operation) + : operation; } const provider = this.getActiveProvider(); @@ -3069,7 +3403,10 @@ export class PerpsController extends BaseController< // Fallback to cached standalone provider for pre-initialization discovery const provider = this.activeProviderInstance ?? this.#getOrCreateStandaloneProvider(); - return provider.getAccountState(params); + const operation = provider.getAccountState(params); + return provider === this.#standaloneProvider + ? this.#trackStandaloneProviderOperation(provider, operation) + : operation; } const provider = this.getActiveProvider(); @@ -3113,12 +3450,15 @@ export class PerpsController extends BaseController< if (params?.standalone) { const provider = this.activeProviderInstance ?? this.#getOrCreateStandaloneProvider(); - return this.#marketDataService.getMarkets({ + const operation = this.#marketDataService.getMarkets({ provider, params, context: this.#createServiceContext('getMarkets'), isMarketAllowed, }); + return provider === this.#standaloneProvider + ? this.#trackStandaloneProviderOperation(provider, operation) + : operation; } const provider = this.getActiveProvider(); @@ -3148,24 +3488,99 @@ export class PerpsController extends BaseController< async getMarketDataWithPrices( params?: GetMarketDataWithPricesParams, ): Promise { + const globalSnapshot = this.#buildGlobalSnapshotContext(); + const context = this.#createServiceContext('getMarketDataWithPrices', { + ...(globalSnapshot && { globalSnapshot }), + }); if (params?.standalone) { const provider = this.activeProviderInstance ?? this.#getOrCreateStandaloneProvider(); - return this.#marketDataService.getMarketDataWithPrices({ + const operation = this.#marketDataService.getMarketDataWithPrices({ provider, params, - context: this.#createServiceContext('getMarketDataWithPrices'), + context, }); + return provider === this.#standaloneProvider + ? this.#trackStandaloneProviderOperation(provider, operation) + : operation; } const provider = this.getActiveProvider(); return this.#marketDataService.getMarketDataWithPrices({ provider, params, - context: this.#createServiceContext('getMarketDataWithPrices'), + context, }); } + /** + * Capture the exact static identity required to adopt an atomic snapshot. + * Dynamic DEX discovery and non-Hyperliquid provider modes deliberately opt + * out so they retain the provider path. + * + * @returns Snapshot identity plus a race guard, or undefined when unsafe. + */ + #buildGlobalSnapshotContext(): ServiceContext['globalSnapshot'] { + const snapshotConfigured = + Boolean(this.#options.infrastructure.terminalApi?.globalSnapshotUrl) || + typeof this.#options.infrastructure.terminalMarketService + ?.fetchGlobalSnapshot === 'function'; + if (!snapshotConfigured || this.state.activeProvider !== 'hyperliquid') { + return undefined; + } + + const enabledDexes = this.#getStaticSnapshotDexes(); + if (!enabledDexes) { + return undefined; + } + const { isTestnet, hip3ConfigVersion } = this.state; + return { + request: { + provider: 'hyperliquid', + network: isTestnet ? 'testnet' : 'mainnet', + enabledDexes, + }, + isCurrent: () => + this.state.activeProvider === 'hyperliquid' && + this.state.isTestnet === isTestnet && + this.state.hip3ConfigVersion === hip3ConfigVersion, + isMarketAllowed: this.#buildMarketAllowedFilter(), + }; + } + + #getStaticSnapshotDexes(): string[] | undefined { + if (!this.#hip3Enabled) { + return ['main']; + } + if (this.state.isTestnet) { + return TESTNET_HIP3_CONFIG.AutoDiscoverAll + ? undefined + : canonicalizeHyperLiquidDexes(TESTNET_HIP3_CONFIG.EnabledDexs); + } + if (MAINNET_HIP3_CONFIG.AutoDiscoverAll) { + return undefined; + } + + const dexes = new Set(); + for (const pattern of this.#hip3AllowlistMarkets) { + const colonIndex = pattern.indexOf(':'); + if (colonIndex <= 0) { + if (/^[a-z][a-z0-9]*$/iu.test(pattern)) { + dexes.add(pattern.toLowerCase()); + continue; + } + return undefined; + } + const dex = pattern.slice(0, colonIndex); + if (dex && /^[a-z0-9][a-z0-9-]*$/u.test(dex)) { + dexes.add(dex); + } else { + return undefined; + } + } + return canonicalizeHyperLiquidDexes(dexes); + } + // ============================================================================ // Market Data Preload (client-agnostic background caching) // ============================================================================ @@ -3180,8 +3595,17 @@ export class PerpsController extends BaseController< #isPreloading = false; + #marketPreloadQueued = false; + #isPreloadingUserData = false; + #userPreloadQueued = false; + + readonly #userSnapshotRequests = new Map< + string, + { provider: PerpsProvider; promise: Promise } + >(); + #preloadStateUnsubscribe: (() => void) | null = null; #accountChangeUnsubscribe: (() => void) | null = null; @@ -3230,6 +3654,47 @@ export class PerpsController extends BaseController< }); } + /** Persist the latest selected-account snapshot for each provider/network. */ + #persistUserCacheToDisk(): void { + const entries: DiskCacheUserEntry[] = []; + + for (const [cacheKey, entry] of Object.entries( + this.state.cachedUserDataByProvider, + )) { + const [providerId, network] = cacheKey.split(':'); + if ( + !providerId || + (network !== 'mainnet' && network !== 'testnet') || + providerId === 'aggregated' + ) { + continue; + } + entries.push({ + providerNetworkKey: `${providerId}:${network}`, + address: entry.address, + positions: entry.positions, + orders: entry.orders, + accountState: entry.accountState, + timestamp: entry.timestamp, + ...(entry.hip3ConfigVersion !== undefined && { + hip3ConfigVersion: entry.hip3ConfigVersion, + }), + ...(entry.dexes !== undefined && { dexes: entry.dexes }), + }); + } + + this.#userDiskWrite = this.#userDiskWrite + .then(() => + persistUserEntriesToDisk( + this.#options.infrastructure.diskCache, + entries, + ), + ) + .catch(() => { + // Disk persistence is best-effort and must not block live data. + }); + } + /** * Start background market data preloading. * Fetches market data immediately and refreshes every 5 minutes. @@ -3251,12 +3716,18 @@ export class PerpsController extends BaseController< this.#performMarketDataPreload().catch(() => { /* fire-and-forget */ }); + this.#performUserDataPreload().catch(() => { + /* fire-and-forget */ + }); // Periodic refresh this.#preloadTimer = setInterval(() => { this.#performMarketDataPreload().catch(() => { /* fire-and-forget */ }); + this.#performUserDataPreload().catch(() => { + /* fire-and-forget */ + }); }, PerpsController.#preloadRefreshMs); // Watch for isTestnet / hip3ConfigVersion changes @@ -3302,46 +3773,32 @@ export class PerpsController extends BaseController< this.#performMarketDataPreload().catch(() => { /* fire-and-forget */ }); + this.#performUserDataPreload().catch(() => { + /* fire-and-forget */ + }); } }; - this.messenger.subscribe('PerpsController:stateChange', handler); + this.messenger.subscribe('PerpsController:stateChanged', handler); this.#preloadStateUnsubscribe = (): void => { - this.messenger.unsubscribe('PerpsController:stateChange', handler); + this.messenger.unsubscribe('PerpsController:stateChanged', handler); }; // Watch for selected account changes and selected account group changes. const accountChangeHandler = (): void => { const evmAccount = getSelectedEvmAccountFromMessenger(this.messenger); const currentAddress = evmAccount?.address ?? null; + this.#debugLog('PerpsController: account cache selection', { + address: currentAddress?.toLowerCase() ?? null, + availableKeys: Object.keys(this.state.cachedUserDataByProvider).sort(), + }); - // If any cached entry belongs to a different account, clear all entries. - // Max 4 entries (2 providers × 2 networks) — clearing all is simple and safe. - const hasStaleEntries = Object.values( - this.state.cachedUserDataByProvider, - ).some( - (entry) => - entry.address.toLowerCase() !== currentAddress?.toLowerCase(), - ); - if (hasStaleEntries) { - this.#debugLog( - 'PerpsController: Account changed, clearing user data cache', - ); - this.update((state) => { - state.cachedUserDataByProvider = {}; + // The address guard makes the previous entry unreadable immediately; + // refresh replaces it under the existing provider/network key. + if (currentAddress) { + this.#performUserDataPreload().catch(() => { + /* fire-and-forget */ }); - // Invalidate disk-cached user data for the old account - this.#options.infrastructure.diskCache - .removeItem(PERPS_DISK_CACHE_USER_DATA) - .catch(() => { - /* fire-and-forget */ - }); - // Only preload if the new account is an EVM account - if (currentAddress) { - this.#performUserDataPreload().catch(() => { - /* fire-and-forget */ - }); - } } }; this.messenger.subscribe( @@ -3383,6 +3840,8 @@ export class PerpsController extends BaseController< } this.#previousIsTestnet = null; this.#previousHip3ConfigVersion = null; + this.#marketPreloadQueued = false; + this.#userPreloadQueued = false; this.#cleanupStandaloneProvider().catch(() => { /* fire-and-forget to preserve sync signature */ }); @@ -3393,6 +3852,7 @@ export class PerpsController extends BaseController< */ async #performMarketDataPreload(): Promise { if (this.#isPreloading) { + this.#marketPreloadQueued = true; return; } @@ -3412,11 +3872,18 @@ export class PerpsController extends BaseController< actualProviderId, this.state.isTestnet, ); + const preloadContext = { + activeProvider: this.state.activeProvider, + isTestnet: this.state.isTestnet, + hip3ConfigVersion: this.state.hip3ConfigVersion, + }; + const staticSnapshotDexes = this.#getStaticSnapshotDexes(); const now = Date.now(); const existingEntry = this.state.cachedMarketDataByProvider[cacheKey]; if ( existingEntry && + this.#isMarketCacheEntryCurrent(actualProviderId, existingEntry) && now - existingEntry.timestamp < PerpsController.#preloadGuardMs ) { return; @@ -3448,8 +3915,41 @@ export class PerpsController extends BaseController< markets: data.length, }); + if ( + this.state.activeProvider !== preloadContext.activeProvider || + this.state.isTestnet !== preloadContext.isTestnet || + this.state.hip3ConfigVersion !== preloadContext.hip3ConfigVersion + ) { + traceData = { + success: false, + error: 'Global snapshot preload context changed', + }; + this.#debugLog( + 'PerpsController: Discarding stale global snapshot preload', + ); + return; + } + // Store under per-provider key(s) const ts = Date.now(); + const sourceExpiries = data.flatMap((market) => + market.dataSource === 'terminal-global-snapshot-mark' && + typeof market.sourceExpiresAt === 'number' + ? [market.sourceExpiresAt] + : [], + ); + const sourceExpiresAt = + data.length > 0 && sourceExpiries.length === data.length + ? Math.min(...sourceExpiries) + : undefined; + const snapshotCacheIdentity = + sourceExpiresAt !== undefined && staticSnapshotDexes + ? { + sourceExpiresAt, + hip3ConfigVersion: preloadContext.hip3ConfigVersion, + dexes: staticSnapshotDexes, + } + : {}; const marketDiskEntries: { providerNetworkKey: string; data: PerpsMarketData[]; @@ -3500,6 +4000,7 @@ export class PerpsController extends BaseController< state.cachedMarketDataByProvider[cacheKey] = { data, timestamp: ts, + ...snapshotCacheIdentity, }; }); } @@ -3520,11 +4021,6 @@ export class PerpsController extends BaseController< performance.now() - preloadStart, 'millisecond', ); - - // Also preload user data (fire-and-forget, non-blocking) - this.#performUserDataPreload().catch(() => { - /* fire-and-forget */ - }); } catch (error) { traceData = { success: false, @@ -3544,6 +4040,12 @@ export class PerpsController extends BaseController< data: traceData, }); this.#isPreloading = false; + if (this.#marketPreloadQueued && this.#preloadTimer) { + this.#marketPreloadQueued = false; + this.#performMarketDataPreload().catch(() => { + // Background preload is best-effort. + }); + } } } @@ -3553,6 +4055,11 @@ export class PerpsController extends BaseController< */ async #performUserDataPreload(): Promise { if (this.#isPreloadingUserData) { + this.#userPreloadQueued = true; + return; + } + + if (this.#isReinitializing) { return; } @@ -3563,33 +4070,73 @@ export class PerpsController extends BaseController< } const userAddress = evmAccount.address; + const { activeProvider, isTestnet, hip3ConfigVersion } = this.state; + const { activeProviderInstance } = this; + const hyperliquidDexes = this.#getStaticSnapshotDexes(); + const isCurrent = (): boolean => { + let currentAddress: string | undefined; + try { + currentAddress = getSelectedEvmAccountFromMessenger( + this.messenger, + )?.address; + } catch { + return false; + } + return ( + this.state.activeProvider === activeProvider && + this.activeProviderInstance === activeProviderInstance && + this.state.isTestnet === isTestnet && + this.state.hip3ConfigVersion === hip3ConfigVersion && + currentAddress?.toLowerCase() === userAddress.toLowerCase() + ); + }; // Determine actual provider (same logic as market preload) - const actualProviderId = this.activeProviderInstance - ? this.state.activeProvider // includes 'aggregated' + const actualProviderId = activeProviderInstance + ? activeProvider // includes 'aggregated' : 'hyperliquid'; - const userCacheKey = buildProviderCacheKey( + const providerNetworkKey = buildProviderCacheKey( actualProviderId, - this.state.isTestnet, + isTestnet, ); // Skip if cache is fresh and for same account const now = Date.now(); - const existingEntry = this.state.cachedUserDataByProvider[userCacheKey]; + const existingEntry = + this.state.cachedUserDataByProvider[providerNetworkKey]; + const hasMatchingCache = + existingEntry !== undefined && + this.#isUserCacheIdentityCurrent( + actualProviderId, + existingEntry, + userAddress, + ); + const cacheAgeMs = existingEntry ? now - existingEntry.timestamp : null; + const websocketState = this.getWebSocketConnectionState(); + let selectedEntryKey: string | null = null; + if (this.state.cachedUserDataByProvider[providerNetworkKey]) { + selectedEntryKey = providerNetworkKey; + } + this.#debugLog('PerpsController: user cache preload decision', { + requestedKey: providerNetworkKey, + selectedEntryKey, + availableKeys: Object.keys(this.state.cachedUserDataByProvider).sort(), + hasMatchingCache, + cacheAgeMs, + websocketState, + }); if ( - existingEntry?.address === userAddress && + existingEntry && + hasMatchingCache && now - existingEntry.timestamp < PerpsController.#preloadGuardMs ) { return; } - // Skip standalone REST polling when WebSocket is connected — live data is streaming if ( + hasMatchingCache && this.getWebSocketConnectionState() === WebSocketConnectionState.Connected ) { - this.#debugLog( - 'PerpsController: Skipping user data preload — WebSocket connected', - ); return; } @@ -3611,8 +4158,8 @@ export class PerpsController extends BaseController< id: traceId, op: PerpsTraceOperations.Operation, tags: { - provider: this.state.activeProvider, - isTestnet: this.state.isTestnet, + provider: activeProvider, + isTestnet, }, data: { userAddress }, }); @@ -3621,16 +4168,37 @@ export class PerpsController extends BaseController< userAddress, }); + if (activeProvider === 'hyperliquid') { + const snapshot = await this.getUserDataSnapshot(); + this.#debugLog('PerpsController: User data preloaded', { + positionCount: snapshot.positions.length, + orderCount: snapshot.orders.length, + totalBalance: snapshot.accountState.totalBalance, + }); + traceData = { + success: true, + positionCount: snapshot.positions.length, + orderCount: snapshot.orders.length, + }; + this.#options.infrastructure.tracer.setMeasurement( + PerpsMeasurementName.PerpsUserDataPreload, + performance.now() - preloadStart, + 'millisecond', + ); + return; + } + const [positions, orders, accountState] = await Promise.all([ this.getPositions({ standalone: true, userAddress }), this.getOpenOrders({ standalone: true, userAddress }), this.getAccountState({ standalone: true, userAddress }), ]); - if ( - this.state.activeProvider === 'aggregated' && - this.activeProviderInstance - ) { + if (!isCurrent()) { + throw new Error('User data preload context changed'); + } + + if (activeProvider === 'aggregated' && activeProviderInstance) { // Split by providerId and write one cache entry per provider key // (mirrors the market-data preload pattern at ~line 2976) const ts = Date.now(); @@ -3668,33 +4236,22 @@ export class PerpsController extends BaseController< accountState.providerId ?? fallbackProviderId, ).accountState = accountState; - const diskEntries: { - providerNetworkKey: string; - address: string; - positions: Position[]; - orders: Order[]; - accountState: AccountState | null; - timestamp: number; - }[] = []; this.update((state) => { for (const [pid, data] of byProvider) { - const key = buildProviderCacheKey(pid, this.state.isTestnet); - diskEntries.push({ - providerNetworkKey: key, - address: userAddress, - positions: data.positions, - orders: data.orders, - accountState: data.accountState, - timestamp: ts, - }); + const key = buildProviderCacheKey(pid, isTestnet); state.cachedUserDataByProvider[key] = { ...data, timestamp: ts, address: userAddress, + ...(pid === 'hyperliquid' && + hyperliquidDexes && { + hip3ConfigVersion, + dexes: hyperliquidDexes, + }), }; } // Write aggregated sentinel so the staleness guard sees it - state.cachedUserDataByProvider[userCacheKey] = { + state.cachedUserDataByProvider[providerNetworkKey] = { positions: [], orders: [], accountState: null, @@ -3703,33 +4260,26 @@ export class PerpsController extends BaseController< }; }); - persistUserEntriesToDisk( - this.#options.infrastructure.diskCache, - diskEntries, - ); + this.#persistUserCacheToDisk(); } else { // Single provider — store directly under its key const ts = Date.now(); this.update((state) => { - state.cachedUserDataByProvider[userCacheKey] = { + state.cachedUserDataByProvider[providerNetworkKey] = { positions, orders, accountState, timestamp: ts, address: userAddress, + ...(actualProviderId === 'hyperliquid' && + hyperliquidDexes && { + hip3ConfigVersion, + dexes: hyperliquidDexes, + }), }; }); - persistUserEntriesToDisk(this.#options.infrastructure.diskCache, [ - { - providerNetworkKey: userCacheKey, - address: userAddress, - positions, - orders, - accountState, - timestamp: ts, - }, - ]); + this.#persistUserCacheToDisk(); } this.#debugLog('PerpsController: User data preloaded', { @@ -3768,6 +4318,12 @@ export class PerpsController extends BaseController< data: traceData, }); this.#isPreloadingUserData = false; + if (this.#userPreloadQueued && this.#preloadTimer) { + this.#userPreloadQueued = false; + this.#performUserDataPreload().catch(() => { + // Background preload is best-effort. + }); + } } } @@ -4075,6 +4631,9 @@ export class PerpsController extends BaseController< this.#performMarketDataPreload().catch(() => { /* fire-and-forget */ }); + this.#performUserDataPreload().catch(() => { + /* fire-and-forget */ + }); } } } @@ -4215,6 +4774,9 @@ export class PerpsController extends BaseController< this.#performMarketDataPreload().catch(() => { /* fire-and-forget */ }); + this.#performUserDataPreload().catch(() => { + /* fire-and-forget */ + }); } } } diff --git a/packages/perps-controller/src/constants/hyperLiquidConfig.ts b/packages/perps-controller/src/constants/hyperLiquidConfig.ts index 5a6ecdac9e..a23f61a070 100644 --- a/packages/perps-controller/src/constants/hyperLiquidConfig.ts +++ b/packages/perps-controller/src/constants/hyperLiquidConfig.ts @@ -27,6 +27,20 @@ export const HYPERLIQUID_MAINNET_CAIP_CHAIN_ID = 'eip155:999' as CaipChainId; export const HYPERLIQUID_TESTNET_CAIP_CHAIN_ID = 'eip155:998' as CaipChainId; export const HYPERLIQUID_NETWORK_NAME = 'Hyperliquid'; +/** + * Return the canonical snapshot identity: main first, then unique DEX ids. + * + * @param dexes - DEX identifiers to canonicalize. + * @returns The canonical DEX identifiers. + */ +export function canonicalizeHyperLiquidDexes( + dexes: Iterable, +): string[] { + const additionalDexes = new Set(dexes); + additionalDexes.delete('main'); + return ['main', ...Array.from(additionalDexes).sort()]; +} + // Token constants export const USDC_SYMBOL = 'USDC'; export const USDC_NAME = 'USD Coin'; diff --git a/packages/perps-controller/src/constants/perpsConfig.ts b/packages/perps-controller/src/constants/perpsConfig.ts index 8ea127d834..05807168c0 100644 --- a/packages/perps-controller/src/constants/perpsConfig.ts +++ b/packages/perps-controller/src/constants/perpsConfig.ts @@ -387,7 +387,7 @@ export const DATA_LAKE_API_CONFIG = { /** * Terminal API configuration. * The full endpoint URL is injected at runtime via - * `PerpsPlatformDependencies.terminalApiUrl` from each client build + * `PerpsPlatformDependencies.terminalApi.marketDataUrl` from each client build * (dev/uat/prd); only cache settings live here. */ export const TERMINAL_API_CONFIG = { diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index d19405776e..635231ec6b 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -76,6 +76,7 @@ export type { PerpsControllerGetBlockExplorerUrlAction, PerpsControllerGetCachedMarketDataForActiveProviderAction, PerpsControllerGetCachedUserDataForActiveProviderAction, + PerpsControllerGetUserDataSnapshotAction, PerpsControllerGetCurrentNetworkAction, PerpsControllerGetFundingAction, PerpsControllerGetHistoricalPortfolioAction, @@ -216,6 +217,8 @@ export type { CheckEligibilityParams, GetPositionsParams, GetAccountStateParams, + GetUserDataSnapshotParams, + PerpsUserDataSnapshot, GetOrderFillsParams, GetOrFetchFillsParams, GetOrdersParams, @@ -268,6 +271,8 @@ export type { PerpsRemoteFeatureFlagState, PerpsPlatformDependencies, PerpsTerminalMarketService, + PerpsGlobalSnapshotRequest, + PerpsGlobalSnapshotResult, TerminalAssetMetadata, PerpsCacheType, InvalidateCacheParams, diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 495b89dec9..08be87d6e6 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -14,6 +14,7 @@ import { import { BASIS_POINTS_DIVISOR, BUILDER_FEE_CONFIG, + canonicalizeHyperLiquidDexes, FEE_RATES, getBridgeInfo, getChainId, @@ -90,6 +91,7 @@ import type { GetOrFetchFillsParams, GetPositionsParams, GetSupportedPathsParams, + GetUserDataSnapshotParams, HistoricalPortfolioResult, InitializeResult, PerpsPlatformDependencies, @@ -125,6 +127,7 @@ import type { WithdrawResult, RawLedgerUpdate, PerpsReadOptions, + PerpsUserDataSnapshot, } from '../types/index.js'; import type { PerpsControllerMessengerBase } from '../types/messenger.js'; import type { OrderType, StrategyOrderType } from '../types/perps-types.js'; @@ -609,12 +612,43 @@ function collectPositionTriggerOrders(params: { }): { takeProfitOrders: PositionTriggerOrder[]; stopLossOrders: PositionTriggerOrder[]; + takeProfitPrice?: string; + stopLossPrice?: string; } { const { orders, position, childOrderIds } = params; const byOrderId = new Map(); + let takeProfitPrice: string | undefined; + let stopLossPrice: string | undefined; orders.forEach((rawOrder) => { + if ( + rawOrder.isTrigger && + rawOrder.reduceOnly && + rawOrder.isPositionTpsl === Boolean(TP_SL_CONFIG.UsePositionBoundTpsl) + ) { + if (rawOrder.orderType.includes('Take Profit')) { + takeProfitPrice = rawOrder.triggerPx; + } else if (rawOrder.orderType.includes('Stop')) { + stopLossPrice = rawOrder.triggerPx; + } + } + + rawOrder.children?.forEach((childOrder) => { + if ( + !childOrder.isTrigger || + !childOrder.reduceOnly || + childOrder.isPositionTpsl !== Boolean(TP_SL_CONFIG.UsePositionBoundTpsl) + ) { + return; + } + if (childOrder.orderType.includes('Take Profit')) { + takeProfitPrice = childOrder.triggerPx; + } else if (childOrder.orderType.includes('Stop')) { + stopLossPrice = childOrder.triggerPx; + } + }); + if ( rawOrder.coin !== position.symbol || !rawOrder.isTrigger || @@ -644,6 +678,8 @@ function collectPositionTriggerOrders(params: { stopLossOrders: triggerOrders.filter( (order) => order.direction !== 'take_profit', ), + ...(takeProfitPrice && { takeProfitPrice }), + ...(stopLossPrice && { stopLossPrice }), }; } @@ -863,6 +899,11 @@ export class HyperLiquidProvider implements PerpsProvider { this.#allowlistMarkets, this.#blocklistMarkets, this.#priceDeviationLimit, + async () => { + await this.#ensureClientsInitialized(); + const validatedDexs = await this.#getValidatedDexs(); + return validatedDexs.filter((dex): dex is string => dex !== null); + }, ); // NOTE: Clients are NOT initialized here - they'll be initialized lazily @@ -7148,6 +7189,181 @@ export class HyperLiquidProvider implements PerpsProvider { return state.validated; } + /** + * Fetch a complete standalone user-data bundle. + * + * Each DEX clearinghouse response is shared by position and account-state + * mapping. Any required request failure rejects the entire bundle. + * + * @param params - User and captured controller identity. + * @returns The complete user-data snapshot. + */ + async getUserDataSnapshot( + params: GetUserDataSnapshotParams, + ): Promise { + const { identity, userAddress } = params; + const network = this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet'; + const snapshotStartedAt = this.#deps.performance.now(); + const measure = async ( + stage: string, + request: () => Promise, + dex?: string | null, + ): Promise => { + const startedAt = this.#deps.performance.now(); + const dexDetail = dex === undefined ? {} : { dex: dex ?? 'main' }; + try { + const result = await request(); + this.#deps.debugLogger.log('[PerpsUserSnapshot]', { + stage, + durationMs: Math.round(this.#deps.performance.now() - startedAt), + success: true, + ...dexDetail, + }); + return result; + } catch (error) { + this.#deps.debugLogger.log('[PerpsUserSnapshot]', { + stage, + durationMs: Math.round(this.#deps.performance.now() - startedAt), + success: false, + ...dexDetail, + }); + throw error; + } + }; + + if (identity.provider !== 'hyperliquid' || identity.network !== network) { + throw new Error('User data snapshot identity does not match provider'); + } + + const requestedDexes = identity.dexes; + const canonicalDexes = canonicalizeHyperLiquidDexes(requestedDexes); + const hasValidDexIdentity = + requestedDexes.length > 0 && + new Set(requestedDexes).size === requestedDexes.length && + requestedDexes.every( + (dex) => dex === 'main' || /^[a-z0-9][a-z0-9-]*$/u.test(dex), + ) && + requestedDexes.length === canonicalDexes.length && + requestedDexes.every((dex, index) => dex === canonicalDexes[index]); + if (!hasValidDexIdentity) { + throw new Error('User data snapshot DEX identity is invalid'); + } + const dexs = requestedDexes.map((dex) => (dex === 'main' ? null : dex)); + const standaloneInfoClient = createStandaloneInfoClient({ + isTestnet: network === 'testnet', + }); + const buildUserParams = ( + dex: string | null, + ): { user: string; dex?: string } => ({ + user: userAddress, + ...(dex ? { dex } : {}), + }); + + const [clearinghouseStates, openOrdersByDex, spotState, abstractionMode] = + await Promise.all([ + Promise.all( + dexs.map((dex) => + measure( + 'clearinghouse_state', + () => + standaloneInfoClient.clearinghouseState(buildUserParams(dex)), + dex, + ), + ), + ), + Promise.all( + dexs.map((dex) => + measure( + 'frontend_open_orders', + () => + standaloneInfoClient.frontendOpenOrders(buildUserParams(dex)), + dex, + ), + ), + ), + measure('spot_clearinghouse_state', () => + standaloneInfoClient.spotClearinghouseState({ user: userAddress }), + ), + measure('user_abstraction', () => + standaloneInfoClient.userAbstraction({ user: userAddress }), + ), + ]); + + const rawOrders = openOrdersByDex.flat(); + const childOrderIds = collectChildOrderIds(rawOrders); + const ordersBySymbol = groupOrdersBySymbol(rawOrders); + const positions = clearinghouseStates.flatMap((state) => + state.assetPositions + .filter(({ position }) => position.szi !== '0') + .map((assetPosition) => { + const position = adaptPositionFromSDK(assetPosition); + const { + takeProfitOrders, + stopLossOrders, + takeProfitPrice, + stopLossPrice, + } = collectPositionTriggerOrders({ + orders: ordersBySymbol.get(position.symbol) ?? [], + position, + childOrderIds, + }); + return { + ...position, + takeProfitCount: takeProfitOrders.length, + stopLossCount: stopLossOrders.length, + takeProfitOrders, + stopLossOrders, + ...(takeProfitPrice && { takeProfitPrice }), + ...(stopLossPrice && { stopLossPrice }), + }; + }), + ); + const positionsBySymbol = new Map( + positions.map((position) => [position.symbol, position]), + ); + const orders = rawOrders.map((order) => + adaptOrderFromSDK(order, positionsBySymbol.get(order.coin)), + ); + const dexAccountStates = clearinghouseStates.map((state) => + adaptAccountStateFromSDK(state), + ); + const accountState = addSpotBalanceToAccountState( + aggregateAccountStates(dexAccountStates), + spotState, + { foldIntoCollateral: hyperLiquidModeFoldsSpot(abstractionMode) }, + ); + + accountState.subAccountBreakdown = Object.fromEntries( + dexAccountStates.map((dexAccountState, index) => { + return [ + dexs[index] ?? '', + { + spendableBalance: dexAccountState.spendableBalance, + withdrawableBalance: dexAccountState.withdrawableBalance, + totalBalance: dexAccountState.totalBalance, + }, + ]; + }), + ); + + const snapshot = { + positions, + orders, + accountState, + identity: { + ...identity, + address: userAddress, + }, + }; + this.#deps.debugLogger.log('[PerpsUserSnapshot]', { + stage: 'complete', + durationMs: Math.round(this.#deps.performance.now() - snapshotStartedAt), + success: true, + dexCount: dexs.length, + }); + return snapshot; + } + /** * Query one DEX's positions directly, preserving whether that DEX answered. * diff --git a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts index a034ba1b78..296352b63b 100644 --- a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts +++ b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts @@ -26,7 +26,6 @@ import { import type { SpotClearinghouseStateResponse, HyperLiquidAbstractionMode, - UserAbstractionResponse, } from '../types/hyperliquid-types.js'; import { hyperLiquidModeFoldsSpot } from '../types/hyperliquid-types.js'; import { WebSocketConnectionState } from '../types/index.js'; @@ -106,6 +105,8 @@ export class HyperLiquidSubscriptionService { // Max market-vs-oracle price deviation before a market is reported untradable readonly #priceDeviationLimit: number; + readonly #discoverEnabledDexs?: () => Promise; + #discoveredDexNames: string[] = []; // DEX order for mapping webData3 perpDexStates indices // DEX discovery synchronization - allows subscriptions to wait for HIP-3 DEX discovery @@ -385,6 +386,7 @@ export class HyperLiquidSubscriptionService { allowlistMarkets?: string[], blocklistMarkets?: string[], priceDeviationLimit?: number, + discoverEnabledDexs?: () => Promise, ) { this.#clientService = clientService; this.#walletService = walletService; @@ -396,6 +398,7 @@ export class HyperLiquidSubscriptionService { this.#blocklistMarkets = blocklistMarkets ?? []; this.#priceDeviationLimit = priceDeviationLimit ?? HYPERLIQUID_CONFIG.OraclePriceDeviationLimit; + this.#discoverEnabledDexs = discoverEnabledDexs; } /** @@ -642,6 +645,16 @@ export class HyperLiquidSubscriptionService { }); } + const discovery = this.#discoverEnabledDexs + ? this.#discoverEnabledDexs() + .then((enabledDexs) => { + this.#enabledDexs = enabledDexs; + this.#discoveredDexNames = enabledDexs; + return undefined; + }) + .catch(() => this.#dexDiscoveryPromise ?? Promise.resolve()) + : this.#dexDiscoveryPromise; + // Wait with timeout let timeoutId: NodeJS.Timeout | undefined; const timeoutPromise = new Promise((_resolve, reject) => { @@ -652,7 +665,7 @@ export class HyperLiquidSubscriptionService { }); try { - await Promise.race([this.#dexDiscoveryPromise, timeoutPromise]); + await Promise.race([discovery, timeoutPromise]); } catch { this.#deps.debugLogger.log( 'DEX discovery wait timed out, proceeding with main DEX only', diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index 83721cfcfe..f5553cd37d 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -875,8 +875,8 @@ export class MarketDataService { /** * Get market data with prices (includes price, volume, 24h change). * Applies optional category filtering, sorting, and limit after fetching. - * When `useTerminalApi` is true, enriches provider data with Terminal API metadata - * (name, keywords, tags, categories). On Terminal API failure, falls back silently. + * An explicitly configured global snapshot is the preferred complete source. + * `useTerminalApi` controls only legacy metadata enrichment of provider data. * * @param options - The configuration options. * @param options.provider - The perps provider instance. @@ -890,6 +890,7 @@ export class MarketDataService { context: ServiceContext; }): Promise { const { provider, params, context } = options; + const { globalSnapshot } = context; const useTerminalApi = params?.useTerminalApi; const traceId = uuidv4(); let traceData: { success: boolean; error?: string } | undefined; @@ -911,11 +912,57 @@ export class MarketDataService { }, }); + // Prefer a separately configured atomic snapshot only for an exact, + // still-current provider/network/DEX identity. A rejected snapshot has + // one lexical fallback to the provider below and is not followed by a + // second legacy Terminal request. + let snapshotAttempted = false; + if ( + globalSnapshot && + this.#deps.terminalMarketService?.fetchGlobalSnapshot + ) { + snapshotAttempted = true; + if (!globalSnapshot.isCurrent()) { + throw new Error('Terminal global snapshot context changed'); + } + try { + const snapshot = + await this.#deps.terminalMarketService.fetchGlobalSnapshot( + globalSnapshot.request, + ); + if (!globalSnapshot.isCurrent()) { + throw new Error('Terminal global snapshot context changed'); + } + if (Date.now() >= snapshot.expiresAt) { + throw new Error('Terminal global snapshot expired'); + } + if (snapshot.markets.length > 0) { + traceData = { success: true }; + const allowedMarkets = snapshot.markets.filter((market) => + globalSnapshot.isMarketAllowed(market.symbol), + ); + return applyMarketFilters(allowedMarkets, params); + } + } catch (snapshotError) { + if (!globalSnapshot.isCurrent()) { + throw new Error('Terminal global snapshot context changed'); + } + this.#deps.terminalMarketService.logError( + snapshotError, + 'getMarketDataWithPrices.globalSnapshot', + ); + } + } + // Fetch Terminal API metadata before provider data when enabled. // Terminal metadata enriches the provider result (name, keywords, tags, // categories) but never replaces live pricing / funding data. let terminalMetadata: Map | undefined; - if (useTerminalApi && this.#deps.terminalMarketService) { + if ( + !snapshotAttempted && + useTerminalApi && + this.#deps.terminalMarketService + ) { try { const result = await this.#deps.terminalMarketService.fetchMarkets(); if (result.metadata.size > 0) { @@ -930,6 +977,9 @@ export class MarketDataService { } const markets = await provider.getMarketDataWithPrices(); + if (snapshotAttempted && globalSnapshot && !globalSnapshot.isCurrent()) { + throw new Error('Terminal global snapshot context changed'); + } // Enrich with terminal metadata when available const enriched = terminalMetadata diff --git a/packages/perps-controller/src/services/ServiceContext.ts b/packages/perps-controller/src/services/ServiceContext.ts index 29d09e63cf..1b2a833c66 100644 --- a/packages/perps-controller/src/services/ServiceContext.ts +++ b/packages/perps-controller/src/services/ServiceContext.ts @@ -1,5 +1,9 @@ import type { PerpsControllerState } from '../PerpsController.js'; -import type { Order, Position } from '../types/index.js'; +import type { + Order, + PerpsGlobalSnapshotRequest, + Position, +} from '../types/index.js'; /** * ServiceContext @@ -60,6 +64,16 @@ export type ServiceContext = { getOpenOrders?: () => Promise; getPositions?: () => Promise; + /** + * Exact per-call identity and guards for adopting a global market snapshot. + * Omitted when the active provider or DEX configuration is not static. + */ + globalSnapshot?: { + request: PerpsGlobalSnapshotRequest; + isCurrent: () => boolean; + isMarketAllowed: (symbol: string) => boolean; + }; + /** * Callback functions for controller-specific operations */ diff --git a/packages/perps-controller/src/services/TerminalMarketService.ts b/packages/perps-controller/src/services/TerminalMarketService.ts index 1330b8f5e8..62c9ce85a5 100644 --- a/packages/perps-controller/src/services/TerminalMarketService.ts +++ b/packages/perps-controller/src/services/TerminalMarketService.ts @@ -5,29 +5,94 @@ import { is, nullable, number, + object, optional, string, + tuple, type, union, } from '@metamask/superstruct'; +import { bytesToHex, sha256, stringToBytes } from '@metamask/utils'; +import { canonicalizeHyperLiquidDexes } from '../constants/hyperLiquidConfig.js'; import { PERPS_CONSTANTS, TERMINAL_API_CONFIG, } from '../constants/perpsConfig.js'; import type { MarketInfo, + PerpsGlobalSnapshotRequest, + PerpsGlobalSnapshotResult, + PerpsMarketData, PerpsPlatformDependencies, TerminalAssetMetadata, } from '../types/index.js'; import { MarketCategory } from '../types/index.js'; import { ensureError } from '../utils/errorUtils.js'; +import { formatChange } from '../utils/marketDataTransform.js'; +import { clonePerpsMarketData } from '../utils/marketUtils.js'; const VALID_MARKET_TYPES = new Set(Object.values(MarketCategory)); +const GLOBAL_SNAPSHOT_SCHEMA_VERSION = 2; +const GLOBAL_SNAPSHOT_CONSUMER_MAX_AGE_MS = 30_000; +const GLOBAL_SNAPSHOT_MAX_PAYLOAD_BYTES = 1_048_576; +const GLOBAL_SNAPSHOT_PERCENT_TOLERANCE = 0.01; +const GLOBAL_SNAPSHOT_MAX_FUTURE_CLOCK_SKEW_MS = 5_000; +const MINIMUM_EPOCH_MILLISECONDS = Date.UTC(2000, 0, 1); +const DECIMAL_PATTERN = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/u; +const NON_NEGATIVE_DECIMAL_PATTERN = /^(?:0|[1-9]\d*)(?:\.\d+)?$/u; +const DEX_PATTERN = /^(?:main|[a-z0-9][a-z0-9-]*)$/u; + +const GlobalSnapshotMarketStruct = object({ + symbol: string(), + provider: string(), + dex: string(), + name: nullable(string()), + description: nullable(string()), + iconUrl: nullable(string()), + szDecimals: number(), + maxLeverage: number(), + markPrice: string(), + price: string(), + midPrice: nullable(string()), + oraclePrice: string(), + change24h: string(), + changePercent24h: number(), + funding: string(), + volume24h: string(), + openInterest: string(), + category: nullable(string()), + keywords: nullable(array(string())), + tags: nullable(array(string())), + listedAt: nullable(number()), + trend: array(tuple([number(), string()])), +}); + +const GlobalSnapshotStruct = object({ + schemaVersion: number(), + provider: string(), + network: string(), + enabledDexes: array(string()), + fingerprint: string(), + generatedAt: number(), + receivedAt: number(), + maxAgeMs: number(), + complete: boolean(), + perDexErrors: array( + object({ + dex: string(), + error: string(), + }), + ), + markets: array(GlobalSnapshotMarketStruct), +}); + +type GlobalSnapshotMarket = Infer; +type GlobalSnapshot = Infer; /** * Runtime validation schema for a single market item returned by - * `GET {terminalApiUrl}`. + * `GET {terminalApi.marketDataUrl}`. * * Uses `type()` (loose object matching) so that extra fields the API sends * (e.g. `price`, `iconUrl`, `trend`) are silently accepted. @@ -73,6 +138,15 @@ export class TerminalMarketService { #cache: CacheEntry | null = null; + readonly #globalSnapshotCache = new Map(); + + readonly #globalSnapshotInFlight = new Map< + string, + Promise + >(); + + #globalSnapshotGeneration = 0; + constructor(deps: PerpsPlatformDependencies) { this.#deps = deps; } @@ -97,13 +171,13 @@ export class TerminalMarketService { }; } - if (!this.#deps.terminalApiUrl) { - throw new Error( - 'Terminal API URL not configured (terminalApiUrl is required)', - ); + const marketDataUrl = + this.#deps.terminalApi?.marketDataUrl ?? this.#deps.terminalApiUrl; + if (!marketDataUrl) { + throw new Error('Terminal API market-data URL not configured'); } - const url = this.#deps.terminalApiUrl; + const url = marketDataUrl; const controller = new AbortController(); const timeoutId = setTimeout( () => controller.abort(new Error('Terminal API fetch timed out')), @@ -141,11 +215,470 @@ export class TerminalMarketService { return { markets, metadata }; } + /** + * Fetch, authenticate by exact identity, and map a schema-v2 atomic market + * snapshot. Accepted entries remain inside the source freshness window; + * rejected responses are never cached. + * + * @param request - Exact provider/network/DEX identity expected by the client. + * @returns UI-ready market data and its source-bounded expiry. + */ + async fetchGlobalSnapshot( + request: PerpsGlobalSnapshotRequest, + ): Promise { + const identity = this.#validateRequestedIdentity(request); + if (!this.#deps.terminalApi?.globalSnapshotUrl) { + throw new Error('Terminal global snapshot URL not configured'); + } + + const url = this.#buildGlobalSnapshotUrl( + this.#deps.terminalApi.globalSnapshotUrl, + identity, + ); + const cacheKey = [ + url, + String(GLOBAL_SNAPSHOT_SCHEMA_VERSION), + identity.provider, + identity.network, + identity.enabledDexes.join(','), + ].join('|'); + const now = Date.now(); + const cached = this.#globalSnapshotCache.get(cacheKey); + if (cached && now < cached.expiresAt) { + return this.#cloneGlobalSnapshotResult(cached); + } + if (cached) { + this.#globalSnapshotCache.delete(cacheKey); + } + + const existing = this.#globalSnapshotInFlight.get(cacheKey); + if (existing) { + return this.#cloneGlobalSnapshotResult(await existing); + } + + const generation = this.#globalSnapshotGeneration; + const pending = this.#fetchAndValidateGlobalSnapshot(identity, url).then( + (result) => { + if (this.#globalSnapshotGeneration !== generation) { + return result; + } + this.#globalSnapshotCache.set(cacheKey, result); + return result; + }, + ); + this.#globalSnapshotInFlight.set(cacheKey, pending); + try { + return this.#cloneGlobalSnapshotResult(await pending); + } finally { + if (this.#globalSnapshotInFlight.get(cacheKey) === pending) { + this.#globalSnapshotInFlight.delete(cacheKey); + } + } + } + + async #fetchAndValidateGlobalSnapshot( + identity: PerpsGlobalSnapshotRequest, + url: string, + ): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(new Error('Terminal global snapshot timed out')), + TERMINAL_API_CONFIG.FetchTimeoutMs, + ); + + try { + const response = await fetch(url, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error( + `Terminal global snapshot returned ${String(response.status)}: ${response.statusText}`, + ); + } + + const declaredLength = response.headers?.get('content-length'); + if ( + declaredLength !== null && + declaredLength !== undefined && + /^\d+$/u.test(declaredLength) && + Number(declaredLength) > GLOBAL_SNAPSHOT_MAX_PAYLOAD_BYTES + ) { + throw new Error('Terminal global snapshot payload exceeds 1 MiB'); + } + // React Native fetch does not consistently expose a streaming reader. + // Reject declared oversize bodies before allocation, then enforce the same + // byte cap after text() for servers that omit Content-Length. + const text = await response.text(); + if (stringToBytes(text).byteLength > GLOBAL_SNAPSHOT_MAX_PAYLOAD_BYTES) { + throw new Error('Terminal global snapshot payload exceeds 1 MiB'); + } + let body: unknown; + try { + body = JSON.parse(text) as unknown; + } catch { + throw new Error('Terminal global snapshot returned invalid JSON'); + } + if (!is(body, GlobalSnapshotStruct)) { + throw new Error('Terminal global snapshot failed schema validation'); + } + return this.#validateAndMapGlobalSnapshot(body, identity, Date.now()); + } finally { + clearTimeout(timeoutId); + } + } + + async #validateAndMapGlobalSnapshot( + snapshot: GlobalSnapshot, + identity: PerpsGlobalSnapshotRequest, + now: number, + ): Promise { + if (snapshot.schemaVersion !== GLOBAL_SNAPSHOT_SCHEMA_VERSION) { + throw new Error('Terminal global snapshot schema version mismatch'); + } + if ( + snapshot.provider !== identity.provider || + snapshot.network !== identity.network + ) { + throw new Error('Terminal global snapshot identity mismatch'); + } + + const responseDexes = this.#normalizeDexes(snapshot.enabledDexes); + if ( + responseDexes.length !== identity.enabledDexes.length || + responseDexes.some((dex, index) => dex !== identity.enabledDexes[index]) + ) { + throw new Error('Terminal global snapshot DEX mismatch'); + } + const expectedFingerprint = await this.#createFingerprint(identity); + if (snapshot.fingerprint !== expectedFingerprint) { + throw new Error('Terminal global snapshot fingerprint mismatch'); + } + if (!snapshot.complete || snapshot.perDexErrors.length > 0) { + throw new Error('Terminal global snapshot is incomplete'); + } + if ( + !this.#isNonNegativeSafeInteger(snapshot.generatedAt) || + !this.#isNonNegativeSafeInteger(snapshot.receivedAt) || + !this.#isPositiveSafeInteger(snapshot.maxAgeMs) || + snapshot.receivedAt > snapshot.generatedAt || + snapshot.generatedAt > now + GLOBAL_SNAPSHOT_MAX_FUTURE_CLOCK_SKEW_MS || + snapshot.receivedAt > now + GLOBAL_SNAPSHOT_MAX_FUTURE_CLOCK_SKEW_MS + ) { + throw new Error('Terminal global snapshot has invalid timestamps'); + } + + const trustedMaxAgeMs = Math.min( + snapshot.maxAgeMs, + GLOBAL_SNAPSHOT_CONSUMER_MAX_AGE_MS, + ); + const expiresAt = snapshot.receivedAt + trustedMaxAgeMs; + if (now >= expiresAt) { + throw new Error('Terminal global snapshot is stale'); + } + if (snapshot.markets.length === 0) { + throw new Error('Terminal global snapshot has no markets'); + } + + const marketKeys = new Set(); + const representedDexes = new Set(); + const markets = snapshot.markets + .map((market, index) => { + this.#validateSnapshotMarket( + market, + identity, + index, + snapshot.generatedAt, + ); + const key = `${market.dex}:${market.symbol}`; + if (marketKeys.has(key)) { + throw new Error(`Terminal global snapshot duplicates market ${key}`); + } + marketKeys.add(key); + representedDexes.add(market.dex); + return market; + }) + .map((market) => this.#mapSnapshotMarket(market, expiresAt)); + if (identity.enabledDexes.some((dex) => !representedDexes.has(dex))) { + throw new Error('Terminal global snapshot is missing a requested DEX'); + } + if (markets.length === 0) { + throw new Error('Terminal global snapshot has no tradable markets'); + } + + return { markets, expiresAt }; + } + + #validateRequestedIdentity( + request: PerpsGlobalSnapshotRequest, + ): PerpsGlobalSnapshotRequest { + if (request.provider !== 'hyperliquid') { + throw new Error('Terminal global snapshot provider is unsupported'); + } + if (request.network !== 'mainnet' && request.network !== 'testnet') { + throw new Error('Terminal global snapshot network is unsupported'); + } + return { + provider: request.provider, + network: request.network, + enabledDexes: this.#normalizeDexes(request.enabledDexes), + }; + } + + #buildGlobalSnapshotUrl( + baseUrl: string, + identity: PerpsGlobalSnapshotRequest, + ): string { + const query = new URLSearchParams({ + provider: identity.provider, + network: identity.network, + dexes: identity.enabledDexes.join(','), + }); + return `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}${query.toString()}`; + } + + #normalizeDexes(dexes: string[]): string[] { + if (!Array.isArray(dexes) || dexes.length === 0) { + throw new Error('Terminal global snapshot requires at least one DEX'); + } + const normalized = dexes.map((dex) => { + if (typeof dex !== 'string' || !DEX_PATTERN.test(dex)) { + throw new Error('Terminal global snapshot contains an invalid DEX'); + } + return dex; + }); + if (new Set(normalized).size !== normalized.length) { + throw new Error('Terminal global snapshot contains duplicate DEXes'); + } + if (!normalized.includes('main')) { + throw new Error('Terminal global snapshot requires the main DEX'); + } + return canonicalizeHyperLiquidDexes(normalized); + } + + async #createFingerprint( + identity: PerpsGlobalSnapshotRequest, + ): Promise { + const canonicalIdentity = JSON.stringify({ + provider: identity.provider, + network: identity.network, + enabledDexes: identity.enabledDexes, + }); + const digest = await sha256(stringToBytes(canonicalIdentity)); + return `sha256:${bytesToHex(digest).slice(2)}`; + } + + #validateSnapshotMarket( + market: GlobalSnapshotMarket, + identity: PerpsGlobalSnapshotRequest, + index: number, + generatedAt: number, + ): void { + const invalid = (field: string): Error => + new Error( + `Terminal global snapshot market ${String(index)} has invalid ${field}`, + ); + if (!identity.enabledDexes.includes(market.dex)) { + throw invalid('dex'); + } + const expectedProvider = market.dex === 'main' ? 'hyperliquid' : market.dex; + if (market.provider !== expectedProvider) { + throw invalid('provider'); + } + const expectedPrefix = market.dex === 'main' ? '' : `${market.dex}:`; + if ( + market.symbol.length === 0 || + (expectedPrefix + ? !market.symbol.startsWith(expectedPrefix) + : market.symbol.includes(':')) + ) { + throw invalid('symbol'); + } + if ( + !this.#isNonNegativeSafeInteger(market.szDecimals) || + !this.#isPositiveSafeInteger(market.maxLeverage) || + (market.listedAt !== null && + (!this.#isNonNegativeSafeInteger(market.listedAt) || + market.listedAt < MINIMUM_EPOCH_MILLISECONDS || + market.listedAt > generatedAt)) + ) { + throw invalid('integer field'); + } + + const decimalFields: [string, string, boolean][] = [ + ['markPrice', market.markPrice, true], + ['price', market.price, true], + ['oraclePrice', market.oraclePrice, true], + ['change24h', market.change24h, false], + ['volume24h', market.volume24h, true], + ['openInterest', market.openInterest, true], + ['funding', market.funding, false], + ]; + if (market.midPrice !== null) { + decimalFields.push(['midPrice', market.midPrice, true]); + } + for (const [field, value, nonNegative] of decimalFields) { + const pattern = nonNegative + ? NON_NEGATIVE_DECIMAL_PATTERN + : DECIMAL_PATTERN; + if (!pattern.test(value) || !Number.isFinite(Number(value))) { + throw invalid(field); + } + } + if (market.price !== market.markPrice) { + throw invalid('deprecated price alias'); + } + if ( + Number(market.oraclePrice) <= 0 || + (market.midPrice !== null && Number(market.midPrice) <= 0) + ) { + throw invalid('reference price'); + } + const price = Number(market.markPrice); + const change24h = Number(market.change24h); + const previousPrice = price - change24h; + if (price <= 0 || previousPrice <= 0 || !Number.isFinite(previousPrice)) { + throw invalid('mark/change coherence'); + } + const derivedPercent = (change24h / previousPrice) * 100; + if ( + !Number.isFinite(derivedPercent) || + !Number.isFinite(market.changePercent24h) || + Math.abs(market.changePercent24h - derivedPercent) > + GLOBAL_SNAPSHOT_PERCENT_TOLERANCE + ) { + throw invalid('changePercent24h coherence'); + } + for (const [field, values] of [ + ['keywords', market.keywords], + ['tags', market.tags], + ] as const) { + if ( + values !== null && + (values.some((value) => value.length === 0) || + new Set(values).size !== values.length) + ) { + throw invalid(field); + } + } + for (const [field, value] of [ + ['name', market.name], + ['description', market.description], + ['iconUrl', market.iconUrl], + ['category', market.category], + ] as const) { + if (value !== null && value.length === 0) { + throw invalid(field); + } + } + let previousTrendTimestamp = -1; + for (const [timestamp, trendPrice] of market.trend) { + if ( + !this.#isNonNegativeSafeInteger(timestamp) || + timestamp < MINIMUM_EPOCH_MILLISECONDS || + timestamp > generatedAt || + timestamp <= previousTrendTimestamp || + !NON_NEGATIVE_DECIMAL_PATTERN.test(trendPrice) || + !Number.isFinite(Number(trendPrice)) || + Number(trendPrice) <= 0 + ) { + throw invalid('trend'); + } + previousTrendTimestamp = timestamp; + } + } + + #mapSnapshotMarket( + market: GlobalSnapshotMarket, + sourceExpiresAt: number, + ): PerpsMarketData { + const formatters = this.#deps.marketDataFormatters; + // Keep both provider price semantics explicit in the wire contract. Core + // maps markPrice to its UI price while retaining validation of midPrice. + const price = Number(market.markPrice); + const change24h = Number(market.change24h); + const volume = Number(market.volume24h); + const openInterest = Number(market.openInterest); + const isHip3 = market.dex !== 'main'; + const marketType = this.#marketTypeFor(market.dex, market.category); + + return { + symbol: market.symbol, + name: market.name ?? market.symbol, + ...(market.description !== null && { + description: market.description, + }), + maxLeverage: `${String(market.maxLeverage)}x`, + price: formatters.formatPerpsFiat(price, { + ranges: formatters.priceRangesUniversal, + }), + change24h: formatChange(change24h, formatters), + change24hPercent: formatters.formatPercentage(market.changePercent24h), + volume: formatters.formatVolume(volume), + openInterest: formatters.formatVolume(openInterest), + fundingRate: Number(market.funding), + marketSource: isHip3 ? market.dex : undefined, + marketType, + isHip3, + isNewMarket: isHip3 && marketType === undefined, + ...(market.keywords && { keywords: market.keywords }), + ...(market.tags && { tags: market.tags }), + ...(market.category && { categories: [market.category] }), + ...(market.listedAt !== null && { listedAt: market.listedAt }), + trend: market.trend, + dataSource: 'terminal-global-snapshot-mark', + sourceExpiresAt, + }; + } + + #marketTypeFor( + dex: string, + category: string | null, + ): TerminalAssetMetadata['marketType'] | undefined { + if (dex === 'main') { + return MarketCategory.CryptoCurrency; + } + if (category === 'stocks') { + return MarketCategory.Stock; + } + if (category === 'pre_ipo') { + return MarketCategory.PreIpo; + } + if (category && VALID_MARKET_TYPES.has(category)) { + return category as TerminalAssetMetadata['marketType']; + } + return undefined; + } + + #cloneGlobalSnapshotResult( + result: PerpsGlobalSnapshotResult, + ): PerpsGlobalSnapshotResult { + return { + expiresAt: result.expiresAt, + markets: clonePerpsMarketData(result.markets), + }; + } + + #isNonNegativeSafeInteger(value: unknown): value is number { + return ( + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 + ); + } + + #isPositiveSafeInteger(value: unknown): value is number { + return this.#isNonNegativeSafeInteger(value) && value > 0; + } + /** * Invalidate the internal cache so the next fetch hits the network. */ clearCache(): void { this.#cache = null; + this.#globalSnapshotGeneration += 1; + this.#globalSnapshotCache.clear(); + this.#globalSnapshotInFlight.clear(); } /** @@ -286,7 +819,12 @@ export class TerminalMarketService { tags: { feature: PERPS_CONSTANTS.FeatureName, source: 'terminal-api' }, context: { name: `TerminalMarketService.${method}`, - data: { url: this.#deps.terminalApiUrl }, + data: { + url: method.includes('globalSnapshot') + ? this.#deps.terminalApi?.globalSnapshotUrl + : (this.#deps.terminalApi?.marketDataUrl ?? + this.#deps.terminalApiUrl), + }, }, }, ); diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 7c132da0d2..33f7c113e2 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -651,6 +651,10 @@ export type PerpsMarketData = { * Indicates this market snapshot came from the last known good cache after live fetch failure. */ isStale?: boolean; + /** Identifies an atomic Terminal summary whose price/change use mark semantics. */ + dataSource?: 'terminal-global-snapshot-mark'; + /** Source-bounded expiry for an atomic Terminal summary. */ + sourceExpiresAt?: number; /** * Searchable keywords from Terminal API metadata (e.g., ['defi', 'layer-1']) */ @@ -663,6 +667,8 @@ export type PerpsMarketData = { * Market categories from Terminal API metadata (e.g., ['crypto', 'meme']) */ categories?: string[]; + /** Timestamped hourly price points supplied by the atomic Terminal snapshot. */ + trend?: [timestampMs: number, price: string][]; /** * Epoch ms when this market was listed on the Terminal backend. * Sourced from the Terminal API `listedAt` field. @@ -991,6 +997,23 @@ export type GetAccountStateParams = { userAddress?: string; // Optional: required when standalone is true - user address to query account state for }; +export type GetUserDataSnapshotParams = { + userAddress: string; + identity: { + provider: 'hyperliquid'; + network: 'mainnet' | 'testnet'; + hip3ConfigVersion: number; + dexes: string[]; + }; +}; + +export type PerpsUserDataSnapshot = { + positions: Position[]; + orders: Order[]; + accountState: AccountState; + identity: GetUserDataSnapshotParams['identity'] & { address: string }; +}; + export type GetOrderFillsParams = { accountId?: CaipAccountId; // Optional: defaults to selected account user?: Hex; // Optional: user address (defaults to selected account) @@ -1063,7 +1086,7 @@ export type GetMarketsParams = { dex?: string; // HyperLiquid HIP-3: DEX name (empty string '' or undefined for main DEX). Other protocols: ignored. skipFilters?: boolean; // Skip market filtering (both allowlist and blocklist, default: false). When true, returns all markets without filtering. standalone?: boolean; // Lightweight mode: skip full initialization, only fetch market metadata (no wallet/WebSocket needed). Only main DEX markets returned. Use for discovery use cases like checking if a perps market exists. - useTerminalApi?: boolean; // When true, use Terminal API as market data source. + useTerminalApi?: boolean; // When true, enrich provider data from the legacy Terminal market endpoint. }; /** @@ -1078,7 +1101,7 @@ export type GetMarketDataWithPricesParams = { sortBy?: SortField; // Sort results by this field direction?: SortDirection; // Sort direction (default: desc) limit?: number; // Maximum number of results to return - useTerminalApi?: boolean; // When true, use Terminal API as market data source. + useTerminalApi?: boolean; // When true, enrich provider data from the legacy Terminal market endpoint. }; export type SubscribePricesParams = { @@ -1307,6 +1330,9 @@ export type PerpsProvider = { updateMargin(params: UpdateMarginParams): Promise; getPositions(params?: GetPositionsParams): Promise; getAccountState(params?: GetAccountStateParams): Promise; + getUserDataSnapshot?( + params: GetUserDataSnapshotParams, + ): Promise; getMarkets(params?: GetMarketsParams): Promise; getMarketDataWithPrices(): Promise; withdraw(params: WithdrawParams): Promise; // API operation - stays in provider @@ -1896,6 +1922,22 @@ export type PerpsTerminalMarketService = { }>; clearCache(): void; logError(error: unknown, method: string): void; + fetchGlobalSnapshot?( + request: PerpsGlobalSnapshotRequest, + ): Promise; +}; + +/** Exact identity a client expects from an atomic global Perps snapshot. */ +export type PerpsGlobalSnapshotRequest = { + provider: 'hyperliquid'; + network: 'mainnet' | 'testnet'; + enabledDexes: string[]; +}; + +/** Validated snapshot data and its source-bounded expiry. */ +export type PerpsGlobalSnapshotResult = { + markets: PerpsMarketData[]; + expiresAt: number; }; /** @@ -1949,13 +1991,15 @@ export type PerpsPlatformDependencies = { }; // === Terminal API (market metadata source) === - /** - * Full endpoint URL for the MetaMask Terminal API perpetuals endpoint. - * Each client build (dev/uat/prd) injects the correct environment URL - * (e.g. `https://terminal.api.cx.metamask.io/v1/perpetuals`). - * Never hardcoded in controller code — always provided by the platform. - * Optional: only required when Terminal API features (useTerminalApi) are enabled. - */ + terminalApi?: { + /** Full endpoint URL for the legacy perpetuals market-data endpoint. */ + marketDataUrl?: string; + + /** Full endpoint URL for the schema-v2 atomic global Perps snapshot. */ + globalSnapshotUrl?: string; + }; + + /** @deprecated Use `terminalApi.marketDataUrl`. */ terminalApiUrl?: string; /** diff --git a/packages/perps-controller/src/utils/marketUtils.ts b/packages/perps-controller/src/utils/marketUtils.ts index 7882859d4f..5dfd7f7b14 100644 --- a/packages/perps-controller/src/utils/marketUtils.ts +++ b/packages/perps-controller/src/utils/marketUtils.ts @@ -6,6 +6,23 @@ import type { import type { CandleData, CandleStick } from '../types/perps-types.js'; import { sortMarkets } from './sortMarkets.js'; +export function clonePerpsMarketData( + markets: PerpsMarketData[], +): PerpsMarketData[] { + return markets.map((market) => ({ + ...market, + ...(market.keywords && { keywords: [...market.keywords] }), + ...(market.tags && { tags: [...market.tags] }), + ...(market.categories && { categories: [...market.categories] }), + ...(market.trend && { + trend: market.trend.map(([timestamp, price]): [number, string] => [ + timestamp, + price, + ]), + }), + })); +} + // ============================================================================ // Market category classification (pure functions) // No service dependencies — pure data transformations that can be tested and diff --git a/packages/perps-controller/src/utils/perpsDiskPersistence.ts b/packages/perps-controller/src/utils/perpsDiskPersistence.ts index 560de2c265..35b10f3de2 100644 --- a/packages/perps-controller/src/utils/perpsDiskPersistence.ts +++ b/packages/perps-controller/src/utils/perpsDiskPersistence.ts @@ -42,6 +42,8 @@ export type DiskCacheUserEntry = { orders: Order[]; accountState: AccountState | null; timestamp: number; + hip3ConfigVersion?: number; + dexes?: string[]; }; /** Disk payload shape — either a single entry or a multi-provider wrapper. */ @@ -203,19 +205,15 @@ export function persistMarketEntriesToDisk( * @param diskCache - Disk cache instance from controller infrastructure. * @param entries - Pre-assembled user cache entries to persist. */ -export function persistUserEntriesToDisk( +export async function persistUserEntriesToDisk( diskCache: PerpsDiskCache, entries: DiskCacheUserEntry[], -): void { +): Promise { if (entries.length === 0) { return; } const payload = entries.length === 1 ? entries[0] : { entries }; - diskCache - .setItem(PERPS_DISK_CACHE_USER_DATA, JSON.stringify(payload)) - .catch(() => { - // Disk persistence is best-effort and must never block preload. - }); + await diskCache.setItem(PERPS_DISK_CACHE_USER_DATA, JSON.stringify(payload)); } /** Computed updates returned by hydrateFromDiskSync. */ @@ -229,6 +227,8 @@ export type HydrateFromDiskResult = { accountState: AccountState | null; timestamp: number; address: string; + hip3ConfigVersion?: number; + dexes?: string[]; } >; stats: { @@ -294,12 +294,23 @@ export function hydrateFromDiskSync( if (entry.providerNetworkKey && Array.isArray(entry.data)) { const existing = currentMarketCache[entry.providerNetworkKey]; if (!existing || existing.timestamp < entry.timestamp) { - const strippedData = entry.data.map((market) => ({ - ...market, - price: PERPS_CONSTANTS.FallbackPriceDisplay, - change24h: PERPS_CONSTANTS.FallbackDataDisplay, - change24hPercent: PERPS_CONSTANTS.FallbackPercentageDisplay, - })); + const strippedData = entry.data.map((market) => { + const structuralMarket = { ...market }; + if ( + structuralMarket.dataSource === + 'terminal-global-snapshot-mark' + ) { + delete structuralMarket.trend; + } + delete structuralMarket.dataSource; + delete structuralMarket.sourceExpiresAt; + return { + ...structuralMarket, + price: PERPS_CONSTANTS.FallbackPriceDisplay, + change24h: PERPS_CONSTANTS.FallbackDataDisplay, + change24hPercent: PERPS_CONSTANTS.FallbackPercentageDisplay, + }; + }); marketUpdates[entry.providerNetworkKey] = { data: strippedData, // Disk-hydrated market snapshots are only for structural @@ -338,6 +349,8 @@ export function hydrateFromDiskSync( accountState: entry.accountState, timestamp: Math.min(entry.timestamp, staleHydratedTimestamp), address: entry.address, + hip3ConfigVersion: entry.hip3ConfigVersion, + dexes: entry.dexes, }; userPositions += entry.positions.length; userOrders += entry.orders.length; diff --git a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts index e489da65d1..4e1d258757 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -40,12 +40,16 @@ import { import type { PerpsControllerState } from '../../src/PerpsController.js'; import { PERPS_ERROR_CODES } from '../../src/perpsErrorCodes.js'; import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider.js'; +import type { ServiceContext } from '../../src/services/ServiceContext.js'; import type { AccountState, GetAvailableDexsParams, PerpsProvider, PerpsPlatformDependencies, + PerpsMarketData, PerpsProviderType, + PerpsUserDataSnapshot, + Position, SubscribeAccountParams, } from '../../src/types/index.js'; import { PerpsAnalyticsEvent } from '../../src/types/index.js'; @@ -384,6 +388,7 @@ describe('PerpsController', () => { let controller: TestablePerpsController; let mockProvider: jest.Mocked; let mockInfrastructure: jest.Mocked; + let mockMessenger: ReturnType; // Helper to mark controller as initialized for tests const markControllerAsInitialized = () => { @@ -575,8 +580,9 @@ describe('PerpsController', () => { }); mockInfrastructure = createMockInfrastructure(); + mockMessenger = createMockMessenger({ call: mockCall }); controller = new TestablePerpsController({ - messenger: createMockMessenger({ call: mockCall }), + messenger: mockMessenger, state: getDefaultPerpsControllerState(), infrastructure: mockInfrastructure, }); @@ -985,6 +991,147 @@ describe('PerpsController', () => { MockedHyperLiquidProvider.mockClear(); }); + it('passes only an exact static Hyperliquid snapshot identity and guards config races', async () => { + mockInfrastructure.terminalApi = { + ...mockInfrastructure.terminalApi, + globalSnapshotUrl: 'https://terminal.test/v2/perpetuals', + }; + controller = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + clientConfig: { + fallbackHip3Enabled: true, + fallbackHip3AllowlistMarkets: ['xyz:*'], + fallbackHip3BlocklistMarkets: ['xyz:TSLA'], + }, + infrastructure: mockInfrastructure, + }); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testMarkInitialized(); + mockProvider.getMarketDataWithPrices.mockResolvedValue([]); + + await controller.getMarketDataWithPrices({ + standalone: true, + useTerminalApi: true, + }); + + const call = mockMarketDataServiceInstance.getMarketDataWithPrices.mock + .calls[0]?.[0] as { + context: ServiceContext; + }; + expect(call.context.globalSnapshot?.request).toStrictEqual({ + provider: 'hyperliquid', + network: 'mainnet', + enabledDexes: ['main', 'xyz'], + }); + expect(call.context.globalSnapshot?.isMarketAllowed('xyz:GOLD')).toBe( + true, + ); + expect(call.context.globalSnapshot?.isMarketAllowed('xyz:TSLA')).toBe( + false, + ); + expect(call.context.globalSnapshot?.isCurrent()).toBe(true); + + controller.testUpdate((state) => { + state.hip3ConfigVersion += 1; + }); + expect(call.context.globalSnapshot?.isCurrent()).toBe(false); + + const liveUpdate = { + symbol: 'BTC', + price: '50002', + timestamp: Date.now(), + isTradable: true, + }; + mockProvider.subscribeToPrices.mockImplementation(({ callback }) => { + callback([liveUpdate]); + return jest.fn(); + }); + const priceCallback = jest.fn(); + controller.subscribeToPrices({ + symbols: ['BTC'], + callback: priceCallback, + }); + expect(mockProvider.subscribeToPrices).toHaveBeenCalledTimes(1); + expect(priceCallback).toHaveBeenCalledWith([liveUpdate]); + }); + + it('treats a bare allowlist entry as a DEX shorthand', async () => { + mockInfrastructure.terminalApi = { + ...mockInfrastructure.terminalApi, + globalSnapshotUrl: 'https://terminal.test/v2/perpetuals', + }; + controller = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + clientConfig: { + fallbackHip3Enabled: true, + fallbackHip3AllowlistMarkets: ['xyz'], + }, + infrastructure: mockInfrastructure, + }); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testMarkInitialized(); + mockProvider.getMarketDataWithPrices.mockResolvedValue([]); + + await controller.getMarketDataWithPrices({ + standalone: true, + useTerminalApi: true, + }); + + expect( + mockMarketDataServiceInstance.getMarketDataWithPrices.mock.calls[0]?.[0] + .context.globalSnapshot?.request.enabledDexes, + ).toStrictEqual(['main', 'xyz']); + }); + + it('keeps main first in an exact static snapshot identity', async () => { + mockInfrastructure.terminalApi = { + globalSnapshotUrl: 'https://terminal.test/v2/perpetuals', + }; + controller = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + clientConfig: { + fallbackHip3Enabled: true, + fallbackHip3AllowlistMarkets: ['flx:*', 'xyz:*'], + }, + infrastructure: mockInfrastructure, + }); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testMarkInitialized(); + + await controller.getMarketDataWithPrices({ standalone: true }); + + expect( + mockMarketDataServiceInstance.getMarketDataWithPrices.mock.calls[0]?.[0] + .context.globalSnapshot?.request.enabledDexes, + ).toEqual(['main', 'flx', 'xyz']); + }); + + it('does not enable snapshots for a legacy-only injected Terminal service', async () => { + mockInfrastructure.terminalApi = undefined; + mockInfrastructure.terminalMarketService = { + fetchMarkets: jest.fn(), + clearCache: jest.fn(), + logError: jest.fn(), + }; + controller = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + infrastructure: mockInfrastructure, + }); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testMarkInitialized(); + + await controller.getMarketDataWithPrices({ standalone: true }); + + expect( + mockMarketDataServiceInstance.getMarketDataWithPrices.mock.calls[0]?.[0] + .context.globalSnapshot, + ).toBeUndefined(); + }); + it('uses existing provider for standalone queries when available', async () => { const mockMarketData = [ { @@ -1051,6 +1198,33 @@ describe('PerpsController', () => { expect(result).toEqual(mockMarketData); }); + it('does not disconnect a standalone provider while a market request is in flight', async () => { + let resolveMarketData!: (marketData: unknown[]) => void; + const marketDataPromise = new Promise((resolve) => { + resolveMarketData = resolve; + }); + const tempMockProvider = createMockHyperLiquidProvider(); + tempMockProvider.getMarketDataWithPrices.mockReturnValue( + marketDataPromise as ReturnType< + typeof tempMockProvider.getMarketDataWithPrices + >, + ); + MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); + + const marketRequest = controller.getMarketDataWithPrices({ + standalone: true, + }); + const disconnectRequest = controller.disconnect(); + + expect(tempMockProvider.disconnect).not.toHaveBeenCalled(); + + resolveMarketData([]); + + await expect(marketRequest).resolves.toEqual([]); + await disconnectRequest; + expect(tempMockProvider.disconnect).toHaveBeenCalledTimes(1); + }); + it('uses getActiveProvider for non-standalone queries', async () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); @@ -1136,6 +1310,25 @@ describe('PerpsController', () => { expect(callCountAfter).toBe(callCountBefore); }); + it('stopMarketDataPreload drops a queued trailing refresh', async () => { + let resolvePreload!: (value: PerpsMarketData[]) => void; + mockProvider.getMarketDataWithPrices.mockReturnValue( + new Promise((resolve) => { + resolvePreload = resolve; + }), + ); + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + controller.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(5 * 60 * 1000); + controller.stopMarketDataPreload(); + resolvePreload([]); + await jest.advanceTimersByTimeAsync(0); + + expect(mockProvider.getMarketDataWithPrices).toHaveBeenCalledTimes(1); + }); + it('stopMarketDataPreload is safe to call when not started', () => { expect(() => controller.stopMarketDataPreload()).not.toThrow(); }); @@ -1191,6 +1384,44 @@ describe('PerpsController', () => { ).toHaveLength(1); }); + it('does not hydrate expired Terminal trend provenance from disk', () => { + const infra = createMockInfrastructure(); + (infra.diskCache.getItemSync as jest.Mock).mockImplementation( + (key: string) => + key === PERPS_DISK_CACHE_MARKETS + ? JSON.stringify({ + providerNetworkKey: 'hyperliquid:mainnet', + data: [ + { + symbol: 'BTC', + name: 'Bitcoin', + price: '50000', + change24h: '+100', + change24hPercent: '+0.2%', + maxLeverage: '50x', + volume: '$1B', + dataSource: 'terminal-global-snapshot-mark', + sourceExpiresAt: Date.now() - 1, + trend: [[Date.now() - 3_600_000, '49000']], + }, + ], + timestamp: Date.now(), + }) + : null, + ); + + const ctrl = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + infrastructure: infra, + }); + + expect( + ctrl.state.cachedMarketDataByProvider['hyperliquid:mainnet']?.data[0] + .trend, + ).toBeUndefined(); + }); + it('hydrates multi-provider market data from disk before providers register', () => { const timestamp = Date.now(); const diskMarkets = { @@ -1282,6 +1513,8 @@ describe('PerpsController', () => { providerId: 'hyperliquid', }, timestamp, + hip3ConfigVersion: 0, + dexes: ['main'], }, { providerNetworkKey: 'myx:mainnet', @@ -1367,6 +1600,95 @@ describe('PerpsController', () => { ); }); + it('rejects a disk user snapshot with a mismatched HIP-3 identity', () => { + const diskUserData = { + providerNetworkKey: 'hyperliquid:mainnet', + address: '0x1234567890abcdef1234567890abcdef12345678', + positions: [createMockPosition()], + orders: [], + accountState: null, + timestamp: Date.now(), + hip3ConfigVersion: 9, + dexes: ['main'], + }; + const infra = createMockInfrastructure(); + (infra.diskCache.getItemSync as jest.Mock).mockImplementation( + (key: string) => + key === PERPS_DISK_CACHE_USER_DATA + ? JSON.stringify(diskUserData) + : null, + ); + const ctrl = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + infrastructure: infra, + }); + + const result = ctrl.getCachedUserDataForActiveProvider({ skipTTL: true }); + + expect(result).toBeNull(); + }); + + it('rejects malformed disk DEX identity without throwing', () => { + const diskUserData = { + providerNetworkKey: 'hyperliquid:mainnet', + address: '0x1234567890abcdef1234567890abcdef12345678', + positions: [createMockPosition()], + orders: [], + accountState: null, + timestamp: Date.now(), + hip3ConfigVersion: 0, + dexes: { length: 1 }, + }; + const infra = createMockInfrastructure(); + (infra.diskCache.getItemSync as jest.Mock).mockImplementation( + (key: string) => + key === PERPS_DISK_CACHE_USER_DATA + ? JSON.stringify(diskUserData) + : null, + ); + const ctrl = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + infrastructure: infra, + }); + + const readCache = () => + ctrl.getCachedUserDataForActiveProvider({ skipTTL: true }); + + expect(readCache).not.toThrow(); + expect(readCache()).toBeNull(); + }); + + it('accepts a disk user snapshot with the current exact HIP-3 identity', () => { + const diskUserData = { + providerNetworkKey: 'hyperliquid:mainnet', + address: '0x1234567890abcdef1234567890abcdef12345678', + positions: [createMockPosition()], + orders: [], + accountState: null, + timestamp: Date.now(), + hip3ConfigVersion: 0, + dexes: ['main'], + }; + const infra = createMockInfrastructure(); + (infra.diskCache.getItemSync as jest.Mock).mockImplementation( + (key: string) => + key === PERPS_DISK_CACHE_USER_DATA + ? JSON.stringify(diskUserData) + : null, + ); + const ctrl = new TestablePerpsController({ + messenger: createMockMessenger(), + state: getDefaultPerpsControllerState(), + infrastructure: infra, + }); + + const result = ctrl.getCachedUserDataForActiveProvider({ skipTTL: true }); + + expect(result?.positions).toHaveLength(1); + }); + it('hydrates user data from disk even when address differs (filtered at read time)', () => { const diskUserData = { providerNetworkKey: 'hyperliquid:mainnet', @@ -1488,7 +1810,7 @@ describe('PerpsController', () => { jest.useRealTimers(); }); - it('updates cachedMarketData in state', async () => { + it('writes returned global snapshot data into the provider preload cache', async () => { const mockData = [ { symbol: 'BTC', @@ -1498,6 +1820,8 @@ describe('PerpsController', () => { change24h: '+100', change24hPercent: '+0.2%', volume: '$1B', + dataSource: 'terminal-global-snapshot-mark' as const, + sourceExpiresAt: Date.now() + 20_000, }, ]; markControllerAsInitialized(); @@ -1510,9 +1834,166 @@ describe('PerpsController', () => { const entry = controller.state.cachedMarketDataByProvider['hyperliquid:mainnet']; expect(entry?.data).toEqual(mockData); + expect(entry?.data[0]?.dataSource).toBe('terminal-global-snapshot-mark'); + expect(entry?.sourceExpiresAt).toBe(mockData[0].sourceExpiresAt); + expect(entry?.hip3ConfigVersion).toBe(0); + expect(entry?.dexes).toEqual(['main']); expect(entry?.timestamp).toBeGreaterThan(0); }); + it('refreshes a source-expired snapshot inside the normal preload guard', async () => { + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.testUpdate((state) => { + state.cachedMarketDataByProvider['hyperliquid:mainnet'] = { + data: [{ symbol: 'BTC', name: 'BTC', price: '$1' }], + timestamp: Date.now(), + sourceExpiresAt: Date.now() - 1, + hip3ConfigVersion: 0, + dexes: ['main'], + }; + }); + mockMarketDataServiceInstance.getMarketDataWithPrices.mockResolvedValue( + [], + ); + + controller.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + + expect( + mockMarketDataServiceInstance.getMarketDataWithPrices, + ).toHaveBeenCalledTimes(1); + }); + + it('does not seed memory or disk when snapshot context changes during preload', async () => { + let resolveSnapshot: + | (( + value: Awaited< + ReturnType + >, + ) => void) + | undefined; + mockMarketDataServiceInstance.getMarketDataWithPrices.mockImplementation( + () => + new Promise((resolve) => { + resolveSnapshot = resolve; + }), + ); + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + controller.startMarketDataPreload(); + await Promise.resolve(); + controller.testUpdate((state) => { + state.isTestnet = true; + state.hip3ConfigVersion += 1; + }); + resolveSnapshot?.([ + { + symbol: 'BTC', + name: 'Bitcoin', + price: '$50000.00', + maxLeverage: '50x', + change24h: '+$125.00', + change24hPercent: '0.25%', + volume: '$1000000', + dataSource: 'terminal-global-snapshot-mark', + }, + ]); + await jest.advanceTimersByTimeAsync(100); + + expect( + controller.state.cachedMarketDataByProvider['hyperliquid:mainnet'], + ).toBeUndefined(); + expect( + controller.state.cachedMarketDataByProvider['hyperliquid:testnet'], + ).toBeUndefined(); + expect(mockInfrastructure.diskCache.setItem).not.toHaveBeenCalled(); + }); + + it('does not seed a provider fallback when context changes during preload', async () => { + let resolveProvider: + | (( + value: Awaited< + ReturnType + >, + ) => void) + | undefined; + mockMarketDataServiceInstance.getMarketDataWithPrices.mockImplementation( + () => + new Promise((resolve) => { + resolveProvider = resolve; + }), + ); + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + + controller.startMarketDataPreload(); + await Promise.resolve(); + controller.testUpdate((state) => { + state.hip3ConfigVersion += 1; + }); + resolveProvider?.([ + { + symbol: 'BTC', + name: 'Bitcoin', + price: '$50000.00', + maxLeverage: '50x', + change24h: '+$125.00', + change24hPercent: '0.25%', + volume: '$1000000', + }, + ]); + await jest.advanceTimersByTimeAsync(100); + + expect( + controller.state.cachedMarketDataByProvider['hyperliquid:mainnet'], + ).toBeUndefined(); + expect(mockInfrastructure.diskCache.setItem).not.toHaveBeenCalled(); + }); + + it('runs the latest network preload after an older request completes', async () => { + let resolveMainnet!: (value: PerpsMarketData[]) => void; + mockMarketDataServiceInstance.getMarketDataWithPrices + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveMainnet = resolve; + }), + ) + .mockResolvedValueOnce([ + { + symbol: 'BTC', + name: 'Bitcoin', + price: '$50000', + }, + ]); + markControllerAsInitialized(); + controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); + controller.startMarketDataPreload(); + await Promise.resolve(); + + controller.testUpdate((state) => { + state.isTestnet = true; + }); + const stateChangedHandler = mockMessenger.subscribe.mock.calls.find( + ([event]) => event === 'PerpsController:stateChanged', + )?.[1]; + stateChangedHandler?.(controller.state, [ + { op: 'replace', path: ['isTestnet'], value: true }, + ]); + resolveMainnet([]); + await jest.advanceTimersByTimeAsync(100); + + expect( + mockMarketDataServiceInstance.getMarketDataWithPrices, + ).toHaveBeenCalledTimes(2); + expect( + controller.state.cachedMarketDataByProvider['hyperliquid:testnet'] + ?.data[0].symbol, + ).toBe('BTC'); + }); + it('persists preloaded market data to disk', async () => { const mockData = [ { @@ -1658,6 +2139,36 @@ describe('PerpsController', () => { let preloadController: TestablePerpsController; let preloadMockProvider: jest.Mocked; let preloadInfrastructure: jest.Mocked; + let preloadMessenger: ReturnType; + + const createUserSnapshot = (): PerpsUserDataSnapshot => ({ + positions: [createMockPosition()], + orders: [], + accountState: { + totalBalance: '10000', + spendableBalance: '10000', + withdrawableBalance: '10000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + }, + identity: { + provider: 'hyperliquid', + network: 'mainnet', + address: mockEvmAccount.address, + hip3ConfigVersion: 0, + dexes: ['main'], + }, + }); + + const createDeferredSnapshot = () => { + let resolve!: (value: PerpsUserDataSnapshot) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + + return { promise, resolve }; + }; beforeEach(() => { jest.useFakeTimers(); @@ -1690,22 +2201,535 @@ describe('PerpsController', () => { }); preloadMockProvider.getMarkets.mockResolvedValue([]); preloadMockProvider.getOpenOrders.mockResolvedValue([]); + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockImplementation(async ({ userAddress, identity }) => { + const [positions, orders, accountState] = await Promise.all([ + preloadMockProvider.getPositions({ + standalone: true, + userAddress, + }), + preloadMockProvider.getOpenOrders({ + standalone: true, + userAddress, + }), + preloadMockProvider.getAccountState({ + standalone: true, + userAddress, + }), + ]); + + return { + positions, + orders, + accountState, + identity: { + ...identity, + address: userAddress, + dexes: ['main'], + }, + }; + }); ( HyperLiquidProvider as jest.MockedClass ).mockImplementation(() => preloadMockProvider); + preloadMessenger = createMockMessenger({ call: mockCall }); preloadController = new TestablePerpsController({ - messenger: createMockMessenger({ call: mockCall }), + messenger: preloadMessenger, state: getDefaultPerpsControllerState(), infrastructure: preloadInfrastructure, }); }); afterEach(() => { + mockEvmAccount.address = '0x1234567890123456789012345678901234567890'; preloadController.stopMarketDataPreload(); jest.useRealTimers(); }); - it('fetches positions, orders, and account state', async () => { + it('returns and atomically persists a provider user snapshot', async () => { + const snapshot = createUserSnapshot(); + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockResolvedValue(snapshot); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + + const result = await preloadController.getUserDataSnapshot(); + + expect(result).toEqual(snapshot); + expect( + preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'], + ).toEqual( + expect.objectContaining({ + positions: snapshot.positions, + orders: snapshot.orders, + accountState: snapshot.accountState, + address: mockEvmAccount.address, + }), + ); + expect(preloadInfrastructure.diskCache.setItem).toHaveBeenCalledTimes(1); + }); + + it('keeps the returned user snapshot mutable without mutating the cache', async () => { + const snapshot = createUserSnapshot(); + snapshot.accountState.subAccountBreakdown = { + main: { + spendableBalance: '10', + withdrawableBalance: '10', + totalBalance: '10', + }, + }; + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockResolvedValue(snapshot); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + + const result = await preloadController.getUserDataSnapshot(); + result.positions[0].leverage.value = 99; + const breakdown = result.accountState.subAccountBreakdown; + if (!breakdown) { + throw new Error('Expected sub-account breakdown'); + } + breakdown.main.totalBalance = '99'; + + const cached = + preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet']; + expect(cached.positions[0].leverage.value).not.toBe(99); + expect(cached.accountState?.subAccountBreakdown?.main.totalBalance).toBe( + '10', + ); + }); + + it('fetches through the standalone provider before an active instance exists', async () => { + const snapshot = createUserSnapshot(); + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockResolvedValue(snapshot); + + const result = await preloadController.getUserDataSnapshot(); + + expect(result).toEqual(snapshot); + expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledWith({ + userAddress: mockEvmAccount.address, + identity: { + provider: 'hyperliquid', + network: 'mainnet', + hip3ConfigVersion: 0, + dexes: ['main'], + }, + }); + }); + + it('rejects a snapshot whose DEX identity differs from captured configuration', async () => { + const snapshot = createUserSnapshot(); + snapshot.identity.dexes = ['main', 'xyz']; + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockResolvedValue(snapshot); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + + await expect(preloadController.getUserDataSnapshot()).rejects.toThrow( + 'mismatched', + ); + + expect(preloadController.state.cachedUserDataByProvider).toEqual({}); + expect(preloadInfrastructure.diskCache.setItem).not.toHaveBeenCalled(); + }); + + it('coalesces concurrent requests with the same captured identity', async () => { + const deferred = createDeferredSnapshot(); + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockReturnValue(deferred.promise); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + + const firstRequest = preloadController.getUserDataSnapshot(); + const secondRequest = preloadController.getUserDataSnapshot(); + deferred.resolve(createUserSnapshot()); + + await expect(Promise.all([firstRequest, secondRequest])).resolves.toEqual( + [createUserSnapshot(), createUserSnapshot()], + ); + expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledTimes(1); + expect(preloadInfrastructure.diskCache.setItem).toHaveBeenCalledTimes(1); + }); + + it('preloads a bare DEX allowlist through the atomic snapshot path', async () => { + preloadController = new TestablePerpsController({ + messenger: preloadMessenger, + state: getDefaultPerpsControllerState(), + clientConfig: { + fallbackHip3Enabled: true, + fallbackHip3AllowlistMarkets: ['xyz'], + }, + infrastructure: preloadInfrastructure, + }); + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockImplementation(async ({ userAddress, identity }) => ({ + ...createUserSnapshot(), + identity: { + ...identity, + address: userAddress, + dexes: ['main', 'xyz'], + }, + })); + preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Disconnected, + ); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + + preloadController.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + + expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + identity: expect.objectContaining({ dexes: ['main', 'xyz'] }), + }), + ); + expect( + preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'] + ?.dexes, + ).toStrictEqual(['main', 'xyz']); + }); + + it('does not coalesce requests across provider instances', async () => { + const deferred = createDeferredSnapshot(); + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockReturnValue(deferred.promise); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + + const firstRequest = preloadController.getUserDataSnapshot(); + const replacementProvider = createMockHyperLiquidProvider(); + replacementProvider.getUserDataSnapshot = jest + .fn() + .mockResolvedValue(createUserSnapshot()); + preloadController.testSetProviders( + new Map([['hyperliquid', replacementProvider]]), + ); + + await expect(preloadController.getUserDataSnapshot()).resolves.toEqual( + createUserSnapshot(), + ); + deferred.resolve(createUserSnapshot()); + await expect(firstRequest).rejects.toThrow('context changed'); + + expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledTimes(1); + expect(replacementProvider.getUserDataSnapshot).toHaveBeenCalledTimes(1); + }); + + it('serializes disk writes so an older account cannot overwrite a newer one', async () => { + let resolveFirstWrite!: () => void; + const firstWrite = new Promise((resolve) => { + resolveFirstWrite = resolve; + }); + preloadInfrastructure.diskCache.setItem + .mockReturnValueOnce(firstWrite) + .mockResolvedValue(undefined); + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockImplementation(async () => createUserSnapshot()); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + + await preloadController.getUserDataSnapshot(); + mockEvmAccount.address = '0x9999999999999999999999999999999999999999'; + await preloadController.getUserDataSnapshot(); + expect(preloadInfrastructure.diskCache.setItem).toHaveBeenCalledTimes(1); + + resolveFirstWrite(); + await jest.advanceTimersByTimeAsync(0); + + expect(preloadInfrastructure.diskCache.setItem).toHaveBeenCalledTimes(2); + const lastPayload = JSON.parse( + preloadInfrastructure.diskCache.setItem.mock.calls[1][1] as string, + ) as { address: string }; + expect(lastPayload.address).toBe(mockEvmAccount.address); + }); + + it('refreshes user data while WebSocket is connected independently of market preload', async () => { + const snapshot = createUserSnapshot(); + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockResolvedValue(snapshot); + preloadMockProvider.getMarketDataWithPrices.mockReturnValue( + new Promise(() => undefined), + ); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Connected, + ); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + + preloadController.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + + expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledTimes(1); + expect( + preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'] + ?.positions, + ).toEqual(snapshot.positions); + }); + + it('queues the selected-account refresh when an older preload is in flight', async () => { + const firstSnapshot = createUserSnapshot(); + const firstRequest = createDeferredSnapshot(); + const secondAddress = '0x9999999999999999999999999999999999999999'; + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockReturnValueOnce(firstRequest.promise) + .mockImplementationOnce(async () => createUserSnapshot()); + preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Disconnected, + ); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + + preloadController.startMarketDataPreload(); + await Promise.resolve(); + const accountChangeHandler = preloadMessenger.subscribe.mock.calls.find( + ([event]) => event === 'AccountsController:selectedAccountChange', + )?.[1] as (() => void) | undefined; + mockEvmAccount.address = secondAddress; + accountChangeHandler?.(); + firstRequest.resolve(firstSnapshot); + await jest.advanceTimersByTimeAsync(100); + + expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledTimes(2); + expect( + preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'], + ).toEqual(expect.objectContaining({ address: secondAddress })); + }); + + it('does not poll user REST data when WebSocket and a matching cache are available', async () => { + preloadMockProvider.getUserDataSnapshot = jest.fn(); + preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Connected, + ); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + preloadController.testUpdate((state) => { + state.cachedUserDataByProvider['hyperliquid:mainnet'] = { + positions: [], + orders: [], + accountState: createUserSnapshot().accountState, + timestamp: 1, + address: mockEvmAccount.address, + hip3ConfigVersion: 0, + dexes: ['main'], + }; + }); + + preloadController.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + + expect(preloadMockProvider.getUserDataSnapshot).not.toHaveBeenCalled(); + }); + + it('refreshes once after HIP-3 identity changes while WebSocket is connected', async () => { + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockImplementation(async ({ userAddress, identity }) => ({ + ...createUserSnapshot(), + identity: { + ...identity, + address: userAddress, + dexes: ['main'], + }, + })); + preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Connected, + ); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + preloadController.testUpdate((state) => { + state.cachedUserDataByProvider['hyperliquid:mainnet'] = { + positions: [], + orders: [], + accountState: createUserSnapshot().accountState, + timestamp: Date.now(), + address: mockEvmAccount.address, + hip3ConfigVersion: 0, + dexes: ['main'], + }; + }); + preloadController.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + preloadMockProvider.getUserDataSnapshot.mockClear(); + + preloadController.testUpdate((state) => { + state.hip3ConfigVersion = 1; + }); + await jest.advanceTimersByTimeAsync(100); + await jest.advanceTimersByTimeAsync(300_000); + + expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledTimes(1); + expect( + preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'], + ).toEqual( + expect.objectContaining({ + hip3ConfigVersion: 1, + dexes: ['main'], + }), + ); + }); + + it('starts user preload after network reinitialization completes', async () => { + preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Disconnected, + ); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + preloadController.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(100); + preloadMockProvider.getUserDataSnapshot.mockClear(); + jest.spyOn(preloadController, 'init').mockImplementationOnce(async () => { + preloadController.testUpdate((state) => { + state.initializationState = InitializationState.Initialized; + }); + }); + + await preloadController.toggleTestnet(); + await jest.advanceTimersByTimeAsync(100); + + expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledTimes(1); + expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + identity: expect.objectContaining({ network: 'testnet' }), + }), + ); + }); + + it.each([ + [ + 'provider', + () => + preloadController.testUpdate((state) => { + state.activeProvider = 'myx'; + }), + ], + [ + 'network', + () => + preloadController.testUpdate((state) => { + state.isTestnet = true; + }), + ], + [ + 'HIP-3 configuration', + () => + preloadController.testUpdate((state) => { + state.hip3ConfigVersion += 1; + }), + ], + [ + 'selected address', + () => { + mockEvmAccount.address = '0x9999999999999999999999999999999999999999'; + }, + ], + ])('discards a user snapshot after a %s change', async (_label, mutate) => { + const deferred = createDeferredSnapshot(); + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockReturnValue(deferred.promise); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + + const request = preloadController.getUserDataSnapshot(); + await Promise.resolve(); + mutate(); + deferred.resolve(createUserSnapshot()); + + await expect(request).rejects.toThrow('context changed'); + expect(preloadController.state.cachedUserDataByProvider).toEqual({}); + expect(preloadInfrastructure.diskCache.setItem).not.toHaveBeenCalled(); + }); + + it('preserves last-known-good data when a snapshot request fails', async () => { + const lastKnownGood = { + positions: [createMockPosition({ symbol: 'ETH' })], + orders: [], + accountState: null, + timestamp: 1, + address: mockEvmAccount.address, + }; + preloadMockProvider.getUserDataSnapshot = jest + .fn() + .mockRejectedValue(new Error('partial snapshot')); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + preloadController.testUpdate((state) => { + state.cachedUserDataByProvider['hyperliquid:mainnet'] = lastKnownGood; + }); + + await expect(preloadController.getUserDataSnapshot()).rejects.toThrow( + 'partial snapshot', + ); + + expect( + preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'], + ).toEqual(lastKnownGood); + expect(preloadInfrastructure.diskCache.setItem).not.toHaveBeenCalled(); + }); + + it('fails closed when the provider has no atomic snapshot API', async () => { + preloadMockProvider.getUserDataSnapshot = undefined; + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + + await expect(preloadController.getUserDataSnapshot()).rejects.toThrow( + 'atomic snapshot API', + ); + + expect(preloadMockProvider.getPositions).not.toHaveBeenCalled(); + expect(preloadMockProvider.getOpenOrders).not.toHaveBeenCalled(); + expect(preloadMockProvider.getAccountState).not.toHaveBeenCalled(); + }); + + it('fetches positions, orders, and account state', async () => { const mockPositions = [createMockPosition()]; const mockOrders = [ { @@ -1766,6 +2790,123 @@ describe('PerpsController', () => { expect(entry.timestamp).toBeGreaterThan(0); }); + it('keeps aggregated mode on the legacy provider path with trusted identity', async () => { + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + preloadController.testUpdate((state) => { + state.activeProvider = 'aggregated'; + }); + preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Disconnected, + ); + + preloadController.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(500); + + expect(preloadMockProvider.getUserDataSnapshot).not.toHaveBeenCalled(); + expect(preloadMockProvider.getPositions).toHaveBeenCalledTimes(1); + expect( + preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'], + ).toEqual( + expect.objectContaining({ + hip3ConfigVersion: 0, + dexes: ['main'], + }), + ); + expect( + preloadController.getCachedUserDataForActiveProvider({ + skipTTL: true, + }), + ).not.toBeNull(); + }); + + it('stamps Hyperliquid identity for aggregated preload before initialization', async () => { + preloadController.testUpdate((state) => { + state.activeProvider = 'aggregated'; + }); + preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Disconnected, + ); + + preloadController.startMarketDataPreload(); + await jest.advanceTimersByTimeAsync(500); + + expect( + preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'], + ).toEqual( + expect.objectContaining({ + hip3ConfigVersion: 0, + dexes: ['main'], + }), + ); + }); + + it('discards an aggregated preload after HIP-3 context changes', async () => { + let resolveOldPositions!: (value: Position[]) => void; + preloadMockProvider.getPositions + .mockReturnValueOnce( + new Promise((resolve) => { + resolveOldPositions = resolve; + }), + ) + .mockResolvedValueOnce([ + createMockPosition({ + symbol: 'NEW', + providerId: 'hyperliquid', + }), + ]); + preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); + preloadMockProvider.getWebSocketConnectionState.mockReturnValue( + WSState.Disconnected, + ); + preloadController.testMarkInitialized(); + preloadController.testSetProviders( + new Map([['hyperliquid', preloadMockProvider]]), + ); + preloadController.testUpdate((state) => { + state.activeProvider = 'aggregated'; + }); + + preloadController.startMarketDataPreload(); + await Promise.resolve(); + preloadController.testUpdate((state) => { + state.hip3ConfigVersion = 1; + }); + const stateChangeHandler = preloadMessenger.subscribe.mock.calls.find( + ([event]) => event === 'PerpsController:stateChanged', + )?.[1] as + | (( + state: PerpsControllerState, + patches: { path: (string | number)[] }[], + ) => void) + | undefined; + stateChangeHandler?.(preloadController.state, [ + { path: ['hip3ConfigVersion'] }, + ]); + resolveOldPositions([ + createMockPosition({ + symbol: 'OLD', + providerId: 'hyperliquid', + }), + ]); + await jest.advanceTimersByTimeAsync(500); + + expect(preloadMockProvider.getPositions).toHaveBeenCalledTimes(2); + expect( + preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'], + ).toEqual( + expect.objectContaining({ + positions: [expect.objectContaining({ symbol: 'NEW' })], + hip3ConfigVersion: 1, + dexes: ['main'], + }), + ); + }); + it('persists preloaded user data to disk', async () => { const mockPositions = [createMockPosition()]; const mockOrders = [ @@ -1836,25 +2977,108 @@ describe('PerpsController', () => { expect(persistedPayload.timestamp).toBeGreaterThan(0); }); - it('skips when WebSocket is connected', async () => { + it('replaces the provider cache when the selected account changes', async () => { + const firstAddress = mockEvmAccount.address; + const secondAddress = '0x9999999999999999999999999999999999999999'; preloadController.testMarkInitialized(); preloadController.testSetProviders( new Map([['hyperliquid', preloadMockProvider]]), ); preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); preloadMockProvider.getWebSocketConnectionState.mockReturnValue( - WSState.Connected, + WSState.Disconnected, + ); + preloadMockProvider.getPositions.mockImplementation( + async ({ userAddress }) => [ + createMockPosition({ + symbol: + userAddress.toLowerCase() === firstAddress.toLowerCase() + ? 'BTC' + : 'ETH', + }), + ], ); preloadController.startMarketDataPreload(); await jest.advanceTimersByTimeAsync(500); - expect(preloadInfrastructure.debugLogger.log).toHaveBeenCalledWith( - 'PerpsController: Skipping user data preload \u2014 WebSocket connected', + const accountChangeHandler = preloadMessenger.subscribe.mock.calls.find( + ([event]) => event === 'AccountsController:selectedAccountChange', + )?.[1] as (() => void) | undefined; + expect(accountChangeHandler).toBeDefined(); + + mockEvmAccount.address = secondAddress; + accountChangeHandler?.(); + await jest.advanceTimersByTimeAsync(500); + expect( + preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'], + ).toEqual( + expect.objectContaining({ + address: secondAddress, + hip3ConfigVersion: 0, + dexes: ['main'], + }), + ); + expect( + preloadController.getCachedUserDataForActiveProvider({ + skipTTL: true, + }), + ).toEqual( + expect.objectContaining({ + positions: [expect.objectContaining({ symbol: 'ETH' })], + }), + ); + + mockEvmAccount.address = firstAddress; + accountChangeHandler?.(); + + expect( + preloadController.getCachedUserDataForActiveProvider({ + skipTTL: true, + }), + ).toBeNull(); + await jest.advanceTimersByTimeAsync(500); + expect( + preloadController.getCachedUserDataForActiveProvider({ + skipTTL: true, + })?.positions[0].symbol, + ).toBe('BTC'); + expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledTimes(3); + expect(preloadInfrastructure.diskCache.removeItem).not.toHaveBeenCalled(); + + const userWrites = ( + preloadInfrastructure.diskCache.setItem as jest.Mock + ).mock.calls.filter(([key]) => key === PERPS_DISK_CACHE_USER_DATA); + const retainedPayload = JSON.parse( + userWrites[userWrites.length - 1][1] as string, + ) as { address: string }; + expect(retainedPayload.address.toLowerCase()).toBe( + firstAddress.toLowerCase(), + ); + + const hydratedInfrastructure = createMockInfrastructure(); + hydratedInfrastructure.diskCache.getItemSync.mockImplementation((key) => + key === PERPS_DISK_CACHE_USER_DATA + ? JSON.stringify(retainedPayload) + : null, ); + const hydratedController = new TestablePerpsController({ + messenger: preloadMessenger, + state: getDefaultPerpsControllerState(), + infrastructure: hydratedInfrastructure, + }); + expect( - Object.keys(preloadController.state.cachedUserDataByProvider), - ).toHaveLength(0); + hydratedController.getCachedUserDataForActiveProvider({ + skipTTL: true, + })?.positions[0].symbol, + ).toBe('BTC'); + mockEvmAccount.address = secondAddress; + expect( + hydratedController.getCachedUserDataForActiveProvider({ + skipTTL: true, + }), + ).toBeNull(); }); it('handles errors without throwing', async () => { @@ -2008,6 +3232,60 @@ describe('PerpsController', () => { }); describe('getCachedMarketDataForActiveProvider', () => { + it('rejects an expired Terminal snapshot even when TTL is skipped', () => { + controller.testUpdate((state) => { + state.cachedMarketDataByProvider['hyperliquid:mainnet'] = { + data: [ + { + symbol: 'BTC', + name: 'BTC', + price: '$50000', + dataSource: 'terminal-global-snapshot-mark', + sourceExpiresAt: Date.now() - 1, + }, + ], + timestamp: Date.now(), + sourceExpiresAt: Date.now() - 1, + hip3ConfigVersion: 0, + dexes: ['main'], + }; + }); + + expect( + controller.getCachedMarketDataForActiveProvider({ skipTTL: true }), + ).toBeNull(); + }); + + it('returns defensive copies of current Terminal snapshot data', () => { + const expiresAt = Date.now() + 20_000; + controller.testUpdate((state) => { + state.cachedMarketDataByProvider['hyperliquid:mainnet'] = { + data: [ + { + symbol: 'BTC', + name: 'BTC', + price: '$50000', + trend: [[Date.now() - 3_600_000, '49000']], + dataSource: 'terminal-global-snapshot-mark', + sourceExpiresAt: expiresAt, + }, + ], + timestamp: Date.now(), + sourceExpiresAt: expiresAt, + hip3ConfigVersion: 0, + dexes: ['main'], + }; + }); + + const first = controller.getCachedMarketDataForActiveProvider(); + first?.[0].trend?.push([Date.now(), '1']); + first?.splice(0); + const second = controller.getCachedMarketDataForActiveProvider(); + + expect(second).toHaveLength(1); + expect(second?.[0].trend).toHaveLength(1); + }); + it('returns null when no cache exists', () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); @@ -2136,7 +3414,7 @@ describe('PerpsController', () => { expect(result).toBeNull(); }); - it('returns null in aggregated mode when oldest entry exceeds TTL', () => { + it('keeps current provider data when another aggregated entry is stale', () => { const mockMYXProvider = createMockHyperLiquidProvider(); markControllerAsInitialized(); controller.testSetProviders( @@ -2159,7 +3437,7 @@ describe('PerpsController', () => { const result = controller.getCachedMarketDataForActiveProvider(); - expect(result).toBeNull(); + expect(result).toEqual([expect.objectContaining({ symbol: 'MYX' })]); }); }); @@ -2178,6 +3456,36 @@ describe('PerpsController', () => { expect(result).toBeNull(); }); + it('returns null when the selected account cannot be resolved', () => { + const ctrl = new TestablePerpsController({ + messenger: createMockMessenger({ + call: jest.fn().mockImplementation((action: string) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + return undefined; + }), + }), + state: getDefaultPerpsControllerState(), + infrastructure: createMockInfrastructure(), + }); + ctrl.testUpdate((state) => { + state.cachedUserDataByProvider['hyperliquid:mainnet'] = { + positions: [createMockPosition()], + orders: [], + accountState: null, + timestamp: Date.now(), + address: mockAddress, + hip3ConfigVersion: 0, + dexes: ['main'], + }; + }); + + const result = ctrl.getCachedUserDataForActiveProvider({ skipTTL: true }); + + expect(result).toBeNull(); + }); + it('returns cached user data for single provider', () => { const mockPosition = createMockPosition({ symbol: 'BTC', size: '1.0' }); markControllerAsInitialized(); @@ -2197,6 +3505,8 @@ describe('PerpsController', () => { }, timestamp: Date.now(), address: mockAddress, + hip3ConfigVersion: 0, + dexes: ['main'], }; }); @@ -2234,6 +3544,8 @@ describe('PerpsController', () => { }, timestamp: Date.now(), address: mockAddress, + hip3ConfigVersion: 0, + dexes: ['main'], }; state.cachedUserDataByProvider['myx:mainnet'] = { positions: [myxPosition], @@ -2281,6 +3593,8 @@ describe('PerpsController', () => { accountState: null, timestamp: Date.now() - 999_999_999, // very old address: mockAddress, + hip3ConfigVersion: 0, + dexes: ['main'], }; }); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts index 1e37c034a4..29e3832fc6 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts @@ -767,6 +767,289 @@ describe('HyperLiquidProvider', () => { ); }); + describe('getUserDataSnapshot', () => { + it('reuses one clearinghouse response for positions and account state', async () => { + const clearinghouseState = { + 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', + }, + withdrawable: '22750', + }; + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue( + clearinghouseState, + ); + mockStandaloneInfoClient.frontendOpenOrders.mockResolvedValue([ + { + coin: 'BTC', + oid: 101, + side: 'A', + limitPx: '0', + triggerPx: '55000', + sz: '0', + origSz: '0', + timestamp: Date.now(), + orderType: 'Take Profit Market', + isTrigger: true, + reduceOnly: true, + isPositionTpsl: true, + cloid: undefined, + children: [], + }, + ]); + + const result = await provider.getUserDataSnapshot({ + userAddress: mockUserAddress, + identity: { + provider: 'hyperliquid', + network: 'mainnet', + hip3ConfigVersion: 7, + dexes: ['main'], + }, + }); + + expect(mockStandaloneInfoClient.perpDexs).not.toHaveBeenCalled(); + expect( + mockStandaloneInfoClient.clearinghouseState, + ).toHaveBeenCalledTimes(1); + expect( + mockStandaloneInfoClient.frontendOpenOrders, + ).toHaveBeenCalledTimes(1); + expect( + mockStandaloneInfoClient.spotClearinghouseState, + ).toHaveBeenCalledTimes(1); + expect(mockStandaloneInfoClient.userAbstraction).toHaveBeenCalledTimes( + 1, + ); + expect(result.positions).toHaveLength(1); + expect(result.positions[0]).toEqual( + expect.objectContaining({ + takeProfitCount: 1, + stopLossCount: 0, + takeProfitPrice: '55000', + takeProfitOrders: [ + expect.objectContaining({ + orderId: '101', + size: '0.5', + triggerPrice: '55000', + }), + ], + }), + ); + expect(result.orders).toEqual([ + expect.objectContaining({ + orderId: '101', + size: '0.5', + originalSize: '0.5', + }), + ]); + expect(result.accountState.totalBalance).toBe('25000'); + expect(result.identity).toEqual({ + provider: 'hyperliquid', + network: 'mainnet', + address: mockUserAddress, + hip3ConfigVersion: 7, + dexes: ['main'], + }); + }); + + it('ignores child triggers from the inactive TP/SL grouping', async () => { + 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', + }, + withdrawable: '22750', + }); + mockStandaloneInfoClient.frontendOpenOrders.mockResolvedValue([ + { + coin: 'BTC', + oid: 201, + side: 'A', + limitPx: '0', + triggerPx: '55000', + sz: '0', + origSz: '0', + timestamp: Date.now(), + orderType: 'Take Profit Market', + isTrigger: true, + reduceOnly: true, + isPositionTpsl: true, + cloid: undefined, + children: [], + }, + { + coin: 'BTC', + oid: 202, + side: 'B', + limitPx: '44000', + triggerPx: '0', + sz: '0.5', + origSz: '0.5', + timestamp: Date.now(), + orderType: 'Limit', + isTrigger: false, + reduceOnly: false, + isPositionTpsl: false, + cloid: undefined, + children: [ + { + coin: 'BTC', + oid: 203, + side: 'A', + limitPx: '0', + triggerPx: '', + sz: '0', + origSz: '0', + timestamp: Date.now(), + orderType: 'Take Profit Market', + isTrigger: true, + reduceOnly: true, + isPositionTpsl: false, + cloid: undefined, + children: [], + }, + ], + }, + ]); + + const result = await provider.getUserDataSnapshot({ + userAddress: mockUserAddress, + identity: { + provider: 'hyperliquid', + network: 'mainnet', + hip3ConfigVersion: 0, + dexes: ['main'], + }, + }); + + expect(result.positions[0]).toEqual( + expect.objectContaining({ + takeProfitCount: 1, + takeProfitPrice: '55000', + }), + ); + }); + + it('logs privacy-safe timing for each atomic snapshot stage', async () => { + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [], + marginSummary: { totalMarginUsed: '0', accountValue: '0' }, + withdrawable: '0', + }); + mockStandaloneInfoClient.frontendOpenOrders.mockResolvedValue([]); + + await provider.getUserDataSnapshot({ + userAddress: mockUserAddress, + identity: { + provider: 'hyperliquid', + network: 'mainnet', + hip3ConfigVersion: 0, + dexes: ['main'], + }, + }); + + const timingCalls = ( + mockPlatformDependencies.debugLogger.log as jest.Mock + ).mock.calls.filter(([marker]) => marker === '[PerpsUserSnapshot]'); + const stages = timingCalls.map(([, detail]) => detail.stage); + expect(stages).toHaveLength(5); + expect(stages).toEqual( + expect.arrayContaining([ + 'clearinghouse_state', + 'frontend_open_orders', + 'spot_clearinghouse_state', + 'user_abstraction', + 'complete', + ]), + ); + expect(JSON.stringify(timingCalls)).not.toContain(mockUserAddress); + }); + + it('accepts canonical DEX identity when a DEX sorts before main', async () => { + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [], + marginSummary: { totalMarginUsed: '0', accountValue: '0' }, + withdrawable: '0', + }); + mockStandaloneInfoClient.frontendOpenOrders.mockResolvedValue([]); + + const result = await provider.getUserDataSnapshot({ + userAddress: mockUserAddress, + identity: { + provider: 'hyperliquid', + network: 'mainnet', + hip3ConfigVersion: 0, + dexes: ['main', 'flx'], + }, + }); + + expect(result.identity.dexes).toEqual(['main', 'flx']); + expect( + mockStandaloneInfoClient.clearinghouseState, + ).toHaveBeenCalledTimes(2); + }); + + it('rejects the entire bundle when one required request fails', async () => { + mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ + assetPositions: [], + marginSummary: { totalMarginUsed: '0', accountValue: '0' }, + withdrawable: '0', + }); + mockStandaloneInfoClient.frontendOpenOrders.mockRejectedValue( + new Error('orders unavailable'), + ); + + const request = provider.getUserDataSnapshot({ + userAddress: mockUserAddress, + identity: { + provider: 'hyperliquid', + network: 'mainnet', + hip3ConfigVersion: 0, + dexes: ['main'], + }, + }); + + await expect(request).rejects.toThrow('orders unavailable'); + }); + }); + describe('getPositions with standalone mode', () => { it('returns positions via standalone client when standalone mode enabled', async () => { // Arrange diff --git a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.lifecycle.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.lifecycle.test.ts index 70e27a2b2b..6f024c2d21 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.lifecycle.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.lifecycle.test.ts @@ -480,6 +480,38 @@ describe('HyperLiquidSubscriptionService', () => { jest.useRealTimers(); }); describe('Subscription Lifecycle', () => { + it('uses validated provider DEX discovery without waiting for the timeout', async () => { + const discoverEnabledDexs = jest.fn().mockResolvedValue(['xyz']); + const discoveryService = new HyperLiquidSubscriptionService( + mockClientService, + mockWalletService, + mockDeps, + true, + [], + [], + [], + undefined, + discoverEnabledDexs, + ); + + const unsubscribe = discoveryService.subscribeToPositions({ + callback: jest.fn(), + }); + + await jest.runAllTimersAsync(); + + expect(discoverEnabledDexs).toHaveBeenCalledTimes(1); + expect(mockSubscriptionClient.clearinghouseState).toHaveBeenCalledWith( + { user: '0x123', dex: 'xyz' }, + expect.any(Function), + ); + expect(mockDeps.debugLogger.log).not.toHaveBeenCalledWith( + 'DEX discovery wait timed out, proceeding with main DEX only', + ); + + unsubscribe(); + }); + it('should unsubscribe from position updates successfully', async () => { const mockCallback = jest.fn(); const mockSubscription = { diff --git a/packages/perps-controller/tests/src/services/MarketDataService.test.ts b/packages/perps-controller/tests/src/services/MarketDataService.test.ts index 9f623f2773..068975d113 100644 --- a/packages/perps-controller/tests/src/services/MarketDataService.test.ts +++ b/packages/perps-controller/tests/src/services/MarketDataService.test.ts @@ -1160,8 +1160,13 @@ describe('MarketDataService', () => { ]); beforeEach(() => { + mockDeps.terminalApi = { + ...mockDeps.terminalApi, + globalSnapshotUrl: 'https://terminal.test/v2/perpetuals', + }; mockTerminalService = { fetchMarkets: jest.fn(), + fetchGlobalSnapshot: jest.fn(), clearCache: jest.fn(), logError: jest.fn(), }; @@ -1333,6 +1338,231 @@ describe('MarketDataService', () => { }, ]; + const createGlobalSnapshotContext = ({ + enabledDexes = ['main'], + isCurrent = () => true, + isMarketAllowed = () => true, + }: { + enabledDexes?: string[]; + isCurrent?: () => boolean; + isMarketAllowed?: (symbol: string) => boolean; + } = {}): ServiceContext => ({ + ...mockContext, + globalSnapshot: { + request: { + provider: 'hyperliquid', + network: 'mainnet', + enabledDexes, + }, + isCurrent, + isMarketAllowed, + }, + }); + + it('adopts a configured atomic snapshot independently of the legacy flag', async () => { + const snapshotMarkets: PerpsMarketData[] = [ + { + symbol: 'BTC', + name: 'Bitcoin', + maxLeverage: '50x', + price: '$50001.00', + change24h: '+$125.00', + change24hPercent: '0.25%', + volume: '$1000000', + }, + ]; + mockTerminalService.fetchGlobalSnapshot?.mockResolvedValue({ + markets: snapshotMarkets, + expiresAt: Date.now() + 30_000, + }); + const isCurrent = jest.fn(() => true); + + const result = await serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + params: { useTerminalApi: false }, + context: createGlobalSnapshotContext({ isCurrent }), + }); + + expect(result).toStrictEqual(snapshotMarkets); + expect(mockTerminalService.fetchGlobalSnapshot).toHaveBeenCalledTimes( + 1, + ); + expect(mockProvider.getMarketDataWithPrices).not.toHaveBeenCalled(); + expect(isCurrent).toHaveBeenCalledTimes(2); + }); + + it('falls back when a snapshot expires while being fetched', async () => { + mockTerminalService.fetchGlobalSnapshot?.mockResolvedValue({ + markets: providerMarketData, + expiresAt: Date.now() - 1, + }); + mockProvider.getMarketDataWithPrices.mockResolvedValue( + providerMarketData, + ); + + const result = await serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + context: createGlobalSnapshotContext(), + }); + + expect(result).toStrictEqual(providerMarketData); + expect(mockProvider.getMarketDataWithPrices).toHaveBeenCalledTimes(1); + expect(mockTerminalService.logError).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Terminal global snapshot expired', + }), + 'getMarketDataWithPrices.globalSnapshot', + ); + }); + + it.each([ + ['timeout', new Error('snapshot timeout')], + ['malformed', new Error('snapshot malformed')], + ['stale', new Error('snapshot stale')], + ['context mismatch', new Error('snapshot identity mismatch')], + ])( + 'falls back to the provider exactly once on %s', + async (_name, error) => { + mockTerminalService.fetchGlobalSnapshot?.mockRejectedValue(error); + mockProvider.getMarketDataWithPrices.mockResolvedValue( + providerMarketData, + ); + + const result = await serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + params: { useTerminalApi: true }, + context: createGlobalSnapshotContext(), + }); + + expect(result).toStrictEqual(providerMarketData); + expect(mockProvider.getMarketDataWithPrices).toHaveBeenCalledTimes(1); + expect(mockTerminalService.fetchMarkets).not.toHaveBeenCalled(); + }, + ); + + it('rejects a snapshot context race without calling the captured provider', async () => { + mockTerminalService.fetchGlobalSnapshot?.mockResolvedValue({ + markets: providerMarketData, + expiresAt: Date.now() + 30_000, + }); + mockProvider.getMarketDataWithPrices.mockResolvedValue( + providerMarketData, + ); + const isCurrent = jest + .fn() + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + + await expect( + serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + context: createGlobalSnapshotContext({ isCurrent }), + }), + ).rejects.toThrow('snapshot context changed'); + + expect(mockProvider.getMarketDataWithPrices).not.toHaveBeenCalled(); + expect(mockTerminalService.fetchMarkets).not.toHaveBeenCalled(); + }); + + it('rejects when a failed snapshot fetch also races with a context change', async () => { + mockTerminalService.fetchGlobalSnapshot?.mockRejectedValue( + new Error('snapshot network failure'), + ); + const isCurrent = jest + .fn() + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + + await expect( + serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + context: createGlobalSnapshotContext({ isCurrent }), + }), + ).rejects.toThrow('snapshot context changed'); + expect(mockProvider.getMarketDataWithPrices).not.toHaveBeenCalled(); + expect(mockTerminalService.logError).not.toHaveBeenCalled(); + }); + + it('applies the existing symbol and list filters before adopting a snapshot', async () => { + const snapshotMarkets = [ + providerMarketData[0] as PerpsMarketData, + { + ...(providerMarketData[1] as PerpsMarketData), + symbol: 'xyz:TSLA', + marketSource: 'xyz', + marketType: 'stock' as const, + isHip3: true, + }, + ]; + mockTerminalService.fetchGlobalSnapshot?.mockResolvedValue({ + markets: snapshotMarkets, + expiresAt: Date.now() + 30_000, + }); + + const result = await serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + params: { + useTerminalApi: true, + categories: ['all'], + excludeSymbols: ['ETH'], + limit: 1, + }, + context: createGlobalSnapshotContext({ + enabledDexes: ['main', 'xyz'], + isMarketAllowed: (symbol) => symbol === 'BTC', + }), + }); + + expect(result).toStrictEqual([providerMarketData[0]]); + expect(mockProvider.getMarketDataWithPrices).not.toHaveBeenCalled(); + }); + + it('propagates provider failure without retry after snapshot rejection', async () => { + mockTerminalService.fetchGlobalSnapshot?.mockRejectedValue( + new Error('snapshot rejected'), + ); + mockProvider.getMarketDataWithPrices.mockRejectedValue( + new Error('provider failed'), + ); + + await expect( + serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + params: { useTerminalApi: true }, + context: createGlobalSnapshotContext(), + }), + ).rejects.toThrow('provider failed'); + expect(mockProvider.getMarketDataWithPrices).toHaveBeenCalledTimes(1); + }); + + it('rejects a provider fallback result when snapshot context changes during provider await', async () => { + mockTerminalService.fetchGlobalSnapshot?.mockRejectedValue( + new Error('snapshot rejected'), + ); + let resolveProvider: ((markets: PerpsMarketData[]) => void) | undefined; + mockProvider.getMarketDataWithPrices.mockImplementation( + () => + new Promise((resolve) => { + resolveProvider = resolve; + }), + ); + const isCurrent = jest + .fn() + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + + const pending = serviceWithTerminal.getMarketDataWithPrices({ + provider: mockProvider, + context: createGlobalSnapshotContext({ isCurrent }), + }); + await Promise.resolve(); + resolveProvider?.(providerMarketData); + + await expect(pending).rejects.toThrow('snapshot context changed'); + expect(mockProvider.getMarketDataWithPrices).toHaveBeenCalledTimes(1); + }); + it('enriches provider data with terminal metadata when flag is enabled', async () => { mockTerminalService.fetchMarkets.mockResolvedValue({ markets: terminalMarkets, diff --git a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts index d54ec455e6..a0341d04a2 100644 --- a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts +++ b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts @@ -3,6 +3,67 @@ import { TerminalMarketService } from '../../../src/services/TerminalMarketServi import type { PerpsPlatformDependencies } from '../../../src/types/index.js'; import { createMockInfrastructure } from '../../helpers/serviceMocks.js'; +const SNAPSHOT_NOW = 1_700_000_030_000; +const HUGE_FINITE_DECIMAL = `1${'0'.repeat(308)}`; + +const createSnapshotMarket = ( + overrides: Record = {}, +): Record => ({ + symbol: 'BTC', + provider: 'hyperliquid', + dex: 'main', + name: 'Bitcoin', + description: 'Original cryptocurrency', + iconUrl: 'https://example.com/btc.png', + szDecimals: 5, + maxLeverage: 50, + markPrice: '50000', + price: '50000', + midPrice: '50001', + oraclePrice: '49999', + change24h: '125', + changePercent24h: 0.25, + funding: '0.0001', + volume24h: '1000000', + openInterest: '1000000', + category: 'crypto', + keywords: ['bitcoin'], + tags: ['top-10'], + listedAt: 1_600_000_000_000, + trend: [ + [SNAPSHOT_NOW - 3_600_000, '49000'], + [SNAPSHOT_NOW - 1_000, '50000'], + ], + ...overrides, +}); + +const createGlobalSnapshot = ( + overrides: Record = {}, +): Record => ({ + schemaVersion: 2, + provider: 'hyperliquid', + network: 'mainnet', + enabledDexes: ['main'], + fingerprint: + 'sha256:21c2aec213ce0cf6c0d8624570abfe1a07dd68f7ee2f4e07e9fe2785d3d0212c', + generatedAt: SNAPSHOT_NOW - 1_000, + receivedAt: SNAPSHOT_NOW - 2_000, + maxAgeMs: 60_000, + complete: true, + perDexErrors: [], + markets: [createSnapshotMarket()], + ...overrides, +}); + +const okJsonResponse = (body: unknown): Response => + ({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + }) as Response; + describe('TerminalMarketService', () => { let mockDeps: jest.Mocked; let service: TerminalMarketService; @@ -106,9 +167,10 @@ describe('TerminalMarketService', () => { }); }); - it('uses the full terminalApiUrl without path concatenation', async () => { - (mockDeps as Record).terminalApiUrl = - 'https://terminal.api.cx.metamask.io/v1/perpetuals'; + it('uses the full marketDataUrl without path concatenation', async () => { + mockDeps.terminalApi = { + marketDataUrl: 'https://terminal.api.cx.metamask.io/v1/perpetuals', + }; jest.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, @@ -463,6 +525,527 @@ describe('TerminalMarketService', () => { }); }); + describe('fetchGlobalSnapshot', () => { + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(SNAPSHOT_NOW); + mockDeps.terminalApi = { + ...mockDeps.terminalApi, + globalSnapshotUrl: + 'https://terminal.test-api.cx.metamask.io/v2/perpetuals', + }; + }); + + it('strictly validates and maps a fresh v2 snapshot', async () => { + jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(okJsonResponse(createGlobalSnapshot())); + + const result = await service.fetchGlobalSnapshot({ + provider: 'hyperliquid', + network: 'mainnet', + enabledDexes: ['main'], + }); + + expect(result).toStrictEqual({ + markets: [ + { + symbol: 'BTC', + name: 'Bitcoin', + description: 'Original cryptocurrency', + maxLeverage: '50x', + price: '$50000.00', + change24h: '+$125.00', + change24hPercent: '0.25%', + volume: '$1000000', + openInterest: '$1000000', + fundingRate: 0.0001, + marketSource: undefined, + marketType: 'crypto', + isHip3: false, + isNewMarket: false, + keywords: ['bitcoin'], + tags: ['top-10'], + categories: ['crypto'], + listedAt: 1_600_000_000_000, + trend: [ + [SNAPSHOT_NOW - 3_600_000, '49000'], + [SNAPSHOT_NOW - 1_000, '50000'], + ], + dataSource: 'terminal-global-snapshot-mark', + sourceExpiresAt: SNAPSHOT_NOW + 28_000, + }, + ], + expiresAt: SNAPSHOT_NOW + 28_000, + }); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://terminal.test-api.cx.metamask.io/v2/perpetuals?provider=hyperliquid&network=mainnet&dexes=main', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('keeps main first when canonicalizing requested DEXes', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + } as Response); + + await expect( + service.fetchGlobalSnapshot({ + provider: 'hyperliquid', + network: 'mainnet', + enabledDexes: ['flx', 'main'], + }), + ).rejects.toThrow('Terminal global snapshot returned 503'); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://terminal.test-api.cx.metamask.io/v2/perpetuals?provider=hyperliquid&network=mainnet&dexes=main%2Cflx', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it.each([ + ['unknown top-level key', createGlobalSnapshot({ extra: true })], + [ + 'unknown market key', + createGlobalSnapshot({ + markets: [createSnapshotMarket({ extra: true })], + }), + ], + [ + 'incoherent mark-based percent', + createGlobalSnapshot({ + markets: [createSnapshotMarket({ changePercent24h: 9 })], + }), + ], + [ + 'incoherent deprecated price alias', + createGlobalSnapshot({ + markets: [createSnapshotMarket({ price: '50001' })], + }), + ], + [ + 'overflowing mark/change subtraction', + createGlobalSnapshot({ + markets: [ + createSnapshotMarket({ + markPrice: HUGE_FINITE_DECIMAL, + change24h: `-${HUGE_FINITE_DECIMAL}`, + changePercent24h: 0, + }), + ], + }), + ], + [ + 'unordered trend timestamps', + createGlobalSnapshot({ + markets: [ + createSnapshotMarket({ + trend: [ + [SNAPSHOT_NOW - 1_000, '50000'], + [SNAPSHOT_NOW - 2_000, '49999'], + ], + }), + ], + }), + ], + [ + 'future trend timestamp', + createGlobalSnapshot({ + markets: [ + createSnapshotMarket({ + trend: [[SNAPSHOT_NOW + 1, '50000']], + }), + ], + }), + ], + [ + 'seconds-based listedAt timestamp', + createGlobalSnapshot({ + markets: [createSnapshotMarket({ listedAt: 1_700_000_000 })], + }), + ], + ])('rejects %s', async (_name, snapshot) => { + jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(okJsonResponse(snapshot)); + + await expect( + service.fetchGlobalSnapshot({ + provider: 'hyperliquid', + network: 'mainnet', + enabledDexes: ['main'], + }), + ).rejects.toThrow('Terminal global snapshot'); + }); + + it.each([ + ['empty', []], + ['single-point', [[SNAPSHOT_NOW - 1_000, '50000']]], + [ + 'irregular or stale', + [ + [SNAPSHOT_NOW - 10_800_000, '49000'], + [SNAPSHOT_NOW - 1_000, '50000'], + ], + ], + ])('accepts %s optional trend data', async (_name, trend) => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue( + okJsonResponse( + createGlobalSnapshot({ + markets: [createSnapshotMarket({ trend })], + }), + ), + ); + + const result = await service.fetchGlobalSnapshot({ + provider: 'hyperliquid', + network: 'mainnet', + enabledDexes: ['main'], + }); + + expect(result).toMatchObject({ markets: [{ trend }] }); + }); + + it('rejects a response larger than the snapshot payload limit', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + text: () => Promise.resolve('x'.repeat(1_048_577)), + } as Response); + + await expect( + service.fetchGlobalSnapshot({ + provider: 'hyperliquid', + network: 'mainnet', + enabledDexes: ['main'], + }), + ).rejects.toThrow('payload exceeds'); + }); + + it('rejects an oversized Content-Length before allocating response text', async () => { + const text = jest.fn().mockRejectedValue(new Error('must not read')); + jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + headers: { + get: jest.fn().mockReturnValue('1048577'), + } as unknown as Headers, + text, + } as Response); + + await expect( + service.fetchGlobalSnapshot({ + provider: 'hyperliquid', + network: 'mainnet', + enabledDexes: ['main'], + }), + ).rejects.toThrow('payload exceeds'); + expect(text).not.toHaveBeenCalled(); + }); + + it('aborts when the response body stalls', async () => { + jest.useFakeTimers(); + jest.spyOn(globalThis, 'fetch').mockImplementation((_url, init) => { + const signal = init?.signal as AbortSignal; + return Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => null } as unknown as Headers, + text: () => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { + reject( + signal.reason instanceof Error + ? signal.reason + : new Error(String(signal.reason)), + ); + }); + }), + } as Response); + }); + + const pending = service.fetchGlobalSnapshot({ + provider: 'hyperliquid', + network: 'mainnet', + enabledDexes: ['main'], + }); + await Promise.resolve(); + + jest.advanceTimersByTime(TERMINAL_API_CONFIG.FetchTimeoutMs); + + await expect(pending).rejects.toThrow( + 'Terminal global snapshot timed out', + ); + jest.useRealTimers(); + }); + + it.each([ + ['version', { schemaVersion: 1 }], + ['provider', { provider: 'other' }], + ['network', { network: 'testnet' }], + ['DEX set', { enabledDexes: ['main', 'xyz'] }], + ['fingerprint', { fingerprint: 'sha256:wrong' }], + ['empty markets', { markets: [] }], + ['incomplete', { complete: false }], + ['per-DEX error', { perDexErrors: [{ dex: 'main', error: 'TIMEOUT' }] }], + ['future generatedAt', { generatedAt: SNAPSHOT_NOW + 5_001 }], + ['future receivedAt', { receivedAt: SNAPSHOT_NOW + 5_001 }], + [ + 'stale source age', + { + generatedAt: SNAPSHOT_NOW - 31_000, + receivedAt: SNAPSHOT_NOW - 31_000, + maxAgeMs: 60_000, + }, + ], + ])('rejects a snapshot with invalid %s', async (_name, overrides) => { + jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(okJsonResponse(createGlobalSnapshot(overrides))); + + await expect( + service.fetchGlobalSnapshot({ + provider: 'hyperliquid', + network: 'mainnet', + enabledDexes: ['main'], + }), + ).rejects.toThrow('Terminal global snapshot'); + }); + + it('accepts timestamps within the producer clock-skew allowance', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue( + okJsonResponse( + createGlobalSnapshot({ + generatedAt: SNAPSHOT_NOW + 5_000, + receivedAt: SNAPSHOT_NOW + 5_000, + }), + ), + ); + + const result = await service.fetchGlobalSnapshot({ + provider: 'hyperliquid', + network: 'mainnet', + enabledDexes: ['main'], + }); + + expect(result.markets).toStrictEqual(expect.any(Array)); + }); + + it.each([ + ['oracle price', { oraclePrice: '0' }], + ['mid price', { midPrice: '0' }], + ])('rejects a non-positive %s', async (_name, marketOverrides) => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue( + okJsonResponse( + createGlobalSnapshot({ + markets: [createSnapshotMarket(marketOverrides)], + }), + ), + ); + + await expect( + service.fetchGlobalSnapshot({ + provider: 'hyperliquid', + network: 'mainnet', + enabledDexes: ['main'], + }), + ).rejects.toThrow('reference price'); + }); + + it.each([ + ['duplicate market', [createSnapshotMarket(), createSnapshotMarket()]], + [ + 'missing requested DEX', + [ + createSnapshotMarket(), + createSnapshotMarket({ + symbol: 'BTC2', + }), + ], + ], + ['invalid open interest', [createSnapshotMarket({ openInterest: '-1' })]], + ['empty non-null name', [createSnapshotMarket({ name: '' })]], + ])('rejects %s', async (_name, markets) => { + const needsXyz = _name === 'missing requested DEX'; + jest.spyOn(globalThis, 'fetch').mockResolvedValue( + okJsonResponse( + createGlobalSnapshot({ + ...(needsXyz && { + enabledDexes: ['main', 'xyz'], + fingerprint: + 'sha256:2680c000d74e6b46aaddfc5f944442d235961fcdf1d9063af15989285be39bb7', + }), + markets, + }), + ), + ); + + await expect( + service.fetchGlobalSnapshot({ + provider: 'hyperliquid', + network: 'mainnet', + enabledDexes: needsXyz ? ['main', 'xyz'] : ['main'], + }), + ).rejects.toThrow('Terminal global snapshot'); + }); + + it('coalesces same-key requests and isolates different identities', async () => { + const fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(okJsonResponse(createGlobalSnapshot())) + .mockResolvedValueOnce( + okJsonResponse( + createGlobalSnapshot({ + network: 'testnet', + fingerprint: + 'sha256:0077720707e8b99ea78df074cdaa58522d331b47f7dcd9bd7cff6f706ffd44db', + }), + ), + ); + const request = { + provider: 'hyperliquid' as const, + network: 'mainnet' as const, + enabledDexes: ['main'], + }; + + await Promise.all([ + service.fetchGlobalSnapshot(request), + service.fetchGlobalSnapshot(request), + ]); + await service.fetchGlobalSnapshot({ + ...request, + network: 'testnet', + }); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('bounds cache TTL by source age and the 30-second consumer cap', async () => { + const fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(okJsonResponse(createGlobalSnapshot())); + const request = { + provider: 'hyperliquid' as const, + network: 'mainnet' as const, + enabledDexes: ['main'], + }; + + await service.fetchGlobalSnapshot(request); + jest.spyOn(Date, 'now').mockReturnValue(SNAPSHOT_NOW + 27_999); + await service.fetchGlobalSnapshot(request); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + jest.spyOn(Date, 'now').mockReturnValue(SNAPSHOT_NOW + 28_000); + await expect(service.fetchGlobalSnapshot(request)).rejects.toThrow( + 'stale', + ); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('does not cache rejected data', async () => { + const fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue( + okJsonResponse(createGlobalSnapshot({ fingerprint: 'invalid' })), + ); + const request = { + provider: 'hyperliquid' as const, + network: 'mainnet' as const, + enabledDexes: ['main'], + }; + + await expect(service.fetchGlobalSnapshot(request)).rejects.toThrow( + 'Terminal global snapshot', + ); + await expect(service.fetchGlobalSnapshot(request)).rejects.toThrow( + 'Terminal global snapshot', + ); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('keeps the legacy cache separate and clears both accepted caches', async () => { + const fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(okJsonResponse(createGlobalSnapshot())) + .mockResolvedValueOnce(okJsonResponse(mockApiResponse)) + .mockResolvedValueOnce(okJsonResponse(createGlobalSnapshot())); + const request = { + provider: 'hyperliquid' as const, + network: 'mainnet' as const, + enabledDexes: ['main'], + }; + + await service.fetchGlobalSnapshot(request); + await service.fetchMarkets(); + service.clearCache(); + await service.fetchGlobalSnapshot(request); + + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it('does not reuse or recache an in-flight response after clearCache', async () => { + let resolveFirst: ((response: Response) => void) | undefined; + let resolveSecond: ((response: Response) => void) | undefined; + const fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve; + }), + ); + const request = { + provider: 'hyperliquid' as const, + network: 'mainnet' as const, + enabledDexes: ['main'], + }; + + const oldRequest = service.fetchGlobalSnapshot(request); + service.clearCache(); + const newRequest = service.fetchGlobalSnapshot(request); + resolveFirst?.(okJsonResponse(createGlobalSnapshot())); + await oldRequest; + resolveSecond?.(okJsonResponse(createGlobalSnapshot())); + const fresh = await newRequest; + const cached = await service.fetchGlobalSnapshot(request); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(cached).toStrictEqual(fresh); + expect(cached).not.toBe(fresh); + }); + + it('does not expose mutable references from the validated cache', async () => { + const fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(okJsonResponse(createGlobalSnapshot())); + const request = { + provider: 'hyperliquid' as const, + network: 'mainnet' as const, + enabledDexes: ['main'], + }; + + const first = await service.fetchGlobalSnapshot(request); + first.markets[0].trend?.push([SNAPSHOT_NOW, '1']); + first.markets.splice(0); + const second = await service.fetchGlobalSnapshot(request); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(second.markets).toHaveLength(1); + expect(second.markets[0].trend).toHaveLength(2); + }); + }); + describe('cache behavior', () => { it('returns cached data on second call within TTL', async () => { const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValue({