From e7a729fcf40256bb34cea47569694f1c01e5c738 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 11 Aug 2026 09:26:51 +0800 Subject: [PATCH 01/11] perf(perps): add coherent bootstrap snapshots --- eslint-suppressions.json | 15 - packages/perps-controller/CHANGELOG.md | 5 + .../PerpsController-method-action-types.ts | 9 + .../perps-controller/src/PerpsController.ts | 874 +++++++++--- .../src/constants/hyperLiquidConfig.ts | 9 + .../src/constants/perpsConfig.ts | 2 +- packages/perps-controller/src/index.ts | 3 + .../src/providers/HyperLiquidProvider.ts | 156 ++ .../HyperLiquidSubscriptionService.ts | 17 +- .../src/services/MarketDataService.ts | 56 +- .../src/services/ServiceContext.ts | 16 +- .../src/services/TerminalMarketService.ts | 580 +++++++- packages/perps-controller/src/types/index.ts | 62 +- .../perps-controller/src/types/messenger.ts | 4 +- .../perps-controller/src/utils/marketUtils.ts | 17 + .../src/utils/perpsDiskPersistence.ts | 39 +- .../tests/defer-eligibility.test.ts | 15 +- .../PerpsController.providers-cache.test.ts | 1261 ++++++++++++++++- .../HyperLiquidProvider.standalone.test.ts | 150 ++ ...iquidSubscriptionService.lifecycle.test.ts | 32 + .../src/services/MarketDataService.test.ts | 230 +++ .../services/TerminalMarketService.test.ts | 540 ++++++- .../CHANGELOG.md | 1 + .../src/index.ts | 1 + .../remote-feature-flag-controller.test.ts | 17 + .../src/remote-feature-flag-controller.ts | 14 +- 26 files changed, 3894 insertions(+), 231 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index d63a487fe33..078a2de980a 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1517,16 +1517,6 @@ "count": 1 } }, - "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 - } - }, "packages/perps-controller/src/utils/myxAdapter.ts": { "@typescript-eslint/no-base-to-string": { "count": 2 @@ -1537,11 +1527,6 @@ "count": 1 } }, - "packages/perps-controller/tests/defer-eligibility.test.ts": { - "no-restricted-syntax": { - "count": 1 - } - }, "packages/phishing-controller/src/BulkTokenScan.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 2 diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 2e7dbf0a5a3..25505621022 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,9 @@ 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)). +- **BREAKING:** Subscribe to `RemoteFeatureFlagController:stateChanged` and read initial remote flags from `init()` instead of the constructor. Consumers must delegate the modern event before initializing `PerpsController` ([#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 2c005917654..5b5f91bf3aa 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -36,6 +36,14 @@ export type PerpsControllerGetCachedUserDataForActiveProviderAction = { handler: PerpsController['getCachedUserDataForActiveProvider']; }; +/** + * Fetch, validate, and cache a complete 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 +1131,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 243d6d921e6..e3c66393df8 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), }); @@ -1117,36 +1190,11 @@ export class PerpsController extends BaseController< 'fallback', ); - /** - * Immediately read current state to catch any flags already loaded - * This is necessary to avoid race conditions where the RemoteFeatureFlagController fetches flags - * before the PerpsController initializes its RemoteFeatureFlagController subscription. - * - * We still subscribe in case the RemoteFeatureFlagController is not yet populated and updates later. - */ - try { - const currentRemoteFeatureFlagState = this.messenger.call( - 'RemoteFeatureFlagController:getState', - ); - - this.refreshEligibilityOnFeatureFlagChange(currentRemoteFeatureFlagState); - } catch (error) { - // If we can't read the remote feature flags at construction time, we'll rely on: - // 1. The fallback blocked regions already set above - // 2. The subscription to catch updates when RemoteFeatureFlagController is ready - this.#logError( - ensureError(error, 'PerpsController.constructor'), - this.#getErrorContext('constructor', { - operation: 'readRemoteFeatureFlags', - }), - ); - } - // Subscribe for the full controller lifetime — intentionally not stored; // geo-blocking and HIP-3 flag propagation must remain active across // disconnect → reconnect cycles and must never be torn down. this.messenger.subscribe( - 'RemoteFeatureFlagController:stateChange', + 'RemoteFeatureFlagController:stateChanged', this.refreshEligibilityOnFeatureFlagChange.bind(this), ); @@ -1246,7 +1294,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 +1302,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)) { + return null; + } + 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 +1319,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 +1374,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 +1397,7 @@ export class PerpsController extends BaseController< return false; } if ( - currentAddress && - entry.address.toLowerCase() !== currentAddress.toLowerCase() + !this.#isUserCacheIdentityCurrent(providerId, entry, selectedAddress) ) { return false; } @@ -1345,9 +1414,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 +1443,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 +1459,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) { + return existingRequest; + } + + const request = this.#fetchAndCacheUserDataSnapshot(context); + this.#userSnapshotRequests.set(requestKey, request); + try { + return await request; + } finally { + if (this.#userSnapshotRequests.get(requestKey) === 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 +1671,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 +1703,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); } /** @@ -1705,6 +2008,27 @@ export class PerpsController extends BaseController< this, MESSENGER_EXPOSED_METHODS, ); + + // Read the current state after all controllers have been constructed so + // flags loaded before this controller's subscription are not missed. + try { + const currentRemoteFeatureFlagState = this.messenger.call( + 'RemoteFeatureFlagController:getState', + ); + this.refreshEligibilityOnFeatureFlagChange( + currentRemoteFeatureFlagState, + ); + } catch (error) { + // Keep the configured fallback; the lifetime subscription can still + // apply a later RemoteFeatureFlagController update. + this.#logError( + ensureError(error, 'PerpsController.init'), + this.#getErrorContext('init', { + operation: 'readRemoteFeatureFlags', + }), + ); + } + this.#handlersRegistered = true; } @@ -2945,7 +3269,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 +3344,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 +3399,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 +3446,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 +3484,97 @@ 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) { + // A bare token can mean either a market symbol or DEX shorthand. + // That identity is not exact enough for snapshot adoption. + 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 +3589,17 @@ export class PerpsController extends BaseController< #isPreloading = false; + #marketPreloadQueued = false; + #isPreloadingUserData = false; + #userPreloadQueued = false; + + readonly #userSnapshotRequests = new Map< + string, + Promise + >(); + #preloadStateUnsubscribe: (() => void) | null = null; #accountChangeUnsubscribe: (() => void) | null = null; @@ -3230,6 +3648,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 +3710,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 +3767,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 +3834,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 +3846,7 @@ export class PerpsController extends BaseController< */ async #performMarketDataPreload(): Promise { if (this.#isPreloading) { + this.#marketPreloadQueued = true; return; } @@ -3412,11 +3866,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 +3909,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 +3994,7 @@ export class PerpsController extends BaseController< state.cachedMarketDataByProvider[cacheKey] = { data, timestamp: ts, + ...snapshotCacheIdentity, }; }); } @@ -3520,11 +4015,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 +4034,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 +4049,11 @@ export class PerpsController extends BaseController< */ async #performUserDataPreload(): Promise { if (this.#isPreloadingUserData) { + this.#userPreloadQueued = true; + return; + } + + if (this.#isReinitializing) { return; } @@ -3563,33 +4064,75 @@ export class PerpsController extends BaseController< } const userAddress = evmAccount.address; + const activeProvider = this.state.activeProvider; + const activeProviderInstance = this.activeProviderInstance; + const isTestnet = this.state.isTestnet; + const hip3ConfigVersion = this.state.hip3ConfigVersion; + 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 +4154,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 +4164,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 +4232,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 +4256,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 +4314,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 +4627,9 @@ export class PerpsController extends BaseController< this.#performMarketDataPreload().catch(() => { /* fire-and-forget */ }); + this.#performUserDataPreload().catch(() => { + /* fire-and-forget */ + }); } } } @@ -4215,6 +4770,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 5a6ecdac9ec..3f0b6b78efd 100644 --- a/packages/perps-controller/src/constants/hyperLiquidConfig.ts +++ b/packages/perps-controller/src/constants/hyperLiquidConfig.ts @@ -27,6 +27,15 @@ 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. */ +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 8ea127d834e..05807168c0e 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 d19405776ea..fff291152d3 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, diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 495b89dec95..20bfaa6de34 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'; @@ -863,6 +866,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 +7156,154 @@ 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 positions = clearinghouseStates.flatMap((state) => + state.assetPositions + .filter(({ position }) => position.szi !== '0') + .map((assetPosition) => adaptPositionFromSDK(assetPosition)), + ); + const orders = openOrdersByDex.flatMap((dexOrders) => + dexOrders.map((order) => adaptOrderFromSDK(order, undefined)), + ); + 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 a034ba1b780..296352b63b7 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 83721cfcfec..f5553cd37d8 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 29d09e63cf5..1b2a833c662 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 1330b8f5e85..1125eedf880 100644 --- a/packages/perps-controller/src/services/TerminalMarketService.ts +++ b/packages/perps-controller/src/services/TerminalMarketService.ts @@ -5,11 +5,14 @@ import { is, nullable, number, + object, optional, string, + tuple, type, union, } from '@metamask/superstruct'; +import { bytesToHex, sha256, stringToBytes } from '@metamask/utils'; import { PERPS_CONSTANTS, @@ -17,17 +20,84 @@ import { } 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_OPEN_INTEREST_RELATIVE_TOLERANCE = 0.0001; +const MINIMUM_EPOCH_MILLISECONDS = Date.UTC(2000, 0, 1); +const MINIMUM_TREND_INTERVAL_MS = 30 * 60 * 1000; +const MAXIMUM_TREND_INTERVAL_MS = 2 * 60 * 60 * 1000; +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(), + dex: string(), + name: nullable(string()), + description: nullable(string()), + iconUrl: nullable(string()), + szDecimals: number(), + maxLeverage: number(), + marginTableId: number(), + onlyIsolated: boolean(), + isDelisted: boolean(), + minimumOrderSize: nullable(string()), + markPrice: string(), + midPrice: nullable(string()), + oraclePrice: string(), + change24h: string(), + change24hPercent: string(), + volume24hUsd: string(), + openInterestBase: string(), + openInterestUsd: string(), + fundingRate: string(), + categories: array(string()), + marketType: nullable(string()), + keywords: array(string()), + tags: 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 +143,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 +176,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 +220,493 @@ 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, + ); + + let response: Response; + try { + response = await fetch(url, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + + 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()); + } + + 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 || + snapshot.receivedAt > now + ) { + 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 tradableDexes = 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; + }) + .filter((market) => { + if (!market.isDelisted) { + tradableDexes.add(market.dex); + return true; + } + return false; + }) + .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 (identity.enabledDexes.some((dex) => !tradableDexes.has(dex))) { + throw new Error( + 'Terminal global snapshot has no tradable market for 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'); + } + return normalized.sort(); + } + + 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 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) || + !this.#isNonNegativeSafeInteger(market.marginTableId) || + (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], + ['oraclePrice', market.oraclePrice, true], + ['change24h', market.change24h, false], + ['change24hPercent', market.change24hPercent, false], + ['volume24hUsd', market.volume24hUsd, true], + ['openInterestBase', market.openInterestBase, true], + ['openInterestUsd', market.openInterestUsd, true], + ['fundingRate', market.fundingRate, false], + ]; + if (market.midPrice !== null) { + decimalFields.push(['midPrice', market.midPrice, true]); + } + if (market.minimumOrderSize !== null) { + decimalFields.push(['minimumOrderSize', market.minimumOrderSize, 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); + } + } + const markPrice = Number(market.markPrice); + const change24h = Number(market.change24h); + const change24hPercent = Number(market.change24hPercent); + const previousPrice = markPrice - change24h; + if ( + markPrice <= 0 || + previousPrice <= 0 || + !Number.isFinite(previousPrice) + ) { + throw invalid('mark/change coherence'); + } + const derivedPercent = (change24h / previousPrice) * 100; + if ( + !Number.isFinite(derivedPercent) || + Math.abs(change24hPercent - derivedPercent) > + GLOBAL_SNAPSHOT_PERCENT_TOLERANCE + ) { + throw invalid('change24hPercent coherence'); + } + + const openInterestBase = Number(market.openInterestBase); + const openInterestUsd = Number(market.openInterestUsd); + const derivedOpenInterestUsd = openInterestBase * markPrice; + const openInterestRelativeError = + Math.abs(openInterestUsd - derivedOpenInterestUsd) / + Math.max(1, derivedOpenInterestUsd); + if ( + !Number.isFinite(derivedOpenInterestUsd) || + !Number.isFinite(openInterestRelativeError) || + openInterestRelativeError > + GLOBAL_SNAPSHOT_OPEN_INTEREST_RELATIVE_TOLERANCE + ) { + throw invalid('openInterestUsd coherence'); + } + for (const [field, values] of [ + ['categories', market.categories], + ['keywords', market.keywords], + ['tags', market.tags], + ] as const) { + if ( + values.some((value) => value.length === 0) || + new Set(values).size !== values.length + ) { + throw invalid(field); + } + } + if ( + market.marketType !== null && + !VALID_MARKET_TYPES.has(market.marketType) + ) { + throw invalid('marketType'); + } + for (const [field, value] of [ + ['name', market.name], + ['description', market.description], + ['iconUrl', market.iconUrl], + ] as const) { + if (value !== null && value.length === 0) { + throw invalid(field); + } + } + if (market.trend.length < 2) { + throw invalid('trend'); + } + let previousTrendTimestamp = -1; + for (const [timestamp, price] of market.trend) { + const interval = timestamp - previousTrendTimestamp; + if ( + !this.#isNonNegativeSafeInteger(timestamp) || + timestamp < MINIMUM_EPOCH_MILLISECONDS || + timestamp > generatedAt || + timestamp <= previousTrendTimestamp || + (previousTrendTimestamp >= 0 && + (interval < MINIMUM_TREND_INTERVAL_MS || + interval > MAXIMUM_TREND_INTERVAL_MS)) || + !NON_NEGATIVE_DECIMAL_PATTERN.test(price) || + !Number.isFinite(Number(price)) || + Number(price) <= 0 + ) { + throw invalid('trend'); + } + previousTrendTimestamp = timestamp; + } + if (generatedAt - previousTrendTimestamp > MAXIMUM_TREND_INTERVAL_MS) { + throw invalid('trend'); + } + } + + #mapSnapshotMarket( + market: GlobalSnapshotMarket, + sourceExpiresAt: number, + ): PerpsMarketData { + const formatters = this.#deps.marketDataFormatters; + // The current Terminal monitor derives both change fields from markPx. + // Use markPrice for the summary row so price and change share one source + // semantic. midPrice remains validated for future live-price consumers. + const price = Number(market.markPrice); + const change24h = Number(market.change24h); + const change24hPercent = Number(market.change24hPercent); + const volume = Number(market.volume24hUsd); + const openInterestUsd = Number(market.openInterestUsd); + const isHip3 = market.dex !== 'main'; + const marketType = + market.marketType === null + ? undefined + : (market.marketType as TerminalAssetMetadata['marketType']); + + 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(change24hPercent), + volume: formatters.formatVolume(volume), + openInterest: formatters.formatVolume(openInterestUsd), + fundingRate: Number(market.fundingRate), + marketSource: isHip3 ? market.dex : undefined, + marketType, + isHip3, + isNewMarket: isHip3 && marketType === undefined, + ...(market.keywords.length > 0 && { keywords: market.keywords }), + ...(market.tags.length > 0 && { tags: market.tags }), + ...(market.categories.length > 0 && { categories: market.categories }), + ...(market.listedAt !== null && { listedAt: market.listedAt }), + trend: market.trend, + dataSource: 'terminal-global-snapshot-mark', + sourceExpiresAt, + }; + } + + #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 +847,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 7c132da0d21..33f7c113e2a 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/types/messenger.ts b/packages/perps-controller/src/types/messenger.ts index 96d3a1b6f63..20c19932c13 100644 --- a/packages/perps-controller/src/types/messenger.ts +++ b/packages/perps-controller/src/types/messenger.ts @@ -24,7 +24,7 @@ import type { import type { AuthenticationController } from '@metamask/profile-sync-controller'; import type { RemoteFeatureFlagControllerGetStateAction, - RemoteFeatureFlagControllerStateChangeEvent, + RemoteFeatureFlagControllerStateChangedEvent, } from '@metamask/remote-feature-flag-controller'; import type { TransactionControllerAddTransactionAction } from '@metamask/transaction-controller'; @@ -50,7 +50,7 @@ export type PerpsControllerAllowedActions = * Events from other controllers that PerpsController is allowed to subscribe to. */ export type PerpsControllerAllowedEvents = - | RemoteFeatureFlagControllerStateChangeEvent + | RemoteFeatureFlagControllerStateChangedEvent | AccountsControllerSelectedAccountChangeEvent | AccountTreeControllerSelectedAccountGroupChangeEvent; diff --git a/packages/perps-controller/src/utils/marketUtils.ts b/packages/perps-controller/src/utils/marketUtils.ts index 7882859d4f2..5dfd7f7b140 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 560de2c2657..35b10f3de2b 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/defer-eligibility.test.ts b/packages/perps-controller/tests/defer-eligibility.test.ts index 9e987b305bc..12722c27821 100644 --- a/packages/perps-controller/tests/defer-eligibility.test.ts +++ b/packages/perps-controller/tests/defer-eligibility.test.ts @@ -98,7 +98,7 @@ function getControllerMessenger( 'AuthenticationController:getBearerToken', ], events: [ - 'RemoteFeatureFlagController:stateChange', + 'RemoteFeatureFlagController:stateChanged', 'AccountTreeController:selectedAccountGroupChange', ], messenger, @@ -162,7 +162,7 @@ describe('PerpsController - deferEligibilityCheck', () => { }); rootMessenger.publish( - 'RemoteFeatureFlagController:stateChange', + 'RemoteFeatureFlagController:stateChanged', { ...MOCK_REMOTE_FEATURE_FLAG_STATE }, [], ); @@ -236,7 +236,7 @@ describe('PerpsController - deferEligibilityCheck', () => { expect(callCountAfterStart).toBe(callCountAfterConstruction + 1); rootMessenger.publish( - 'RemoteFeatureFlagController:stateChange', + 'RemoteFeatureFlagController:stateChanged', { ...MOCK_REMOTE_FEATURE_FLAG_STATE }, [], ); @@ -260,7 +260,7 @@ describe('PerpsController - deferEligibilityCheck', () => { controller.stopEligibilityMonitoring(); rootMessenger.publish( - 'RemoteFeatureFlagController:stateChange', + 'RemoteFeatureFlagController:stateChanged', { ...MOCK_REMOTE_FEATURE_FLAG_STATE }, [], ); @@ -313,7 +313,7 @@ describe('PerpsController - deferEligibilityCheck', () => { }); describe('when deferEligibilityCheck is false (default)', () => { - it('triggers eligibility processing during construction', () => { + it('defers eligibility processing until init', async () => { const refreshSpy = jest.spyOn( PerpsController.prototype as unknown as { refreshEligibilityOnFeatureFlagChange: (...args: unknown[]) => void; @@ -321,8 +321,11 @@ describe('PerpsController - deferEligibilityCheck', () => { 'refreshEligibilityOnFeatureFlagChange', ); - buildController({ deferEligibilityCheck: false }); + const { controller } = buildController({ deferEligibilityCheck: false }); + expect(refreshSpy).not.toHaveBeenCalled(); + + await controller.init(); expect(refreshSpy).toHaveBeenCalled(); refreshSpy.mockRestore(); }); 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 e489da65d15..6ea5ace5685 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,148 @@ 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/snapshot', + }; + 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('disables snapshot adoption for ambiguous bare allowlist identity', async () => { + mockInfrastructure.terminalApi = { + ...mockInfrastructure.terminalApi, + globalSnapshotUrl: 'https://terminal.test/v2/perpetuals/snapshot', + }; + 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, + ).toBeUndefined(); + expect(mockProvider.getMarketDataWithPrices).toHaveBeenCalledTimes(1); + }); + + it('keeps main first in an exact static snapshot identity', async () => { + mockInfrastructure.terminalApi = { + globalSnapshotUrl: 'https://terminal.test/v2/perpetuals/snapshot', + }; + 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 +1199,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 +1311,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 +1385,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 +1514,8 @@ describe('PerpsController', () => { providerId: 'hyperliquid', }, timestamp, + hip3ConfigVersion: 0, + dexes: ['main'], }, { providerNetworkKey: 'myx:mainnet', @@ -1367,6 +1601,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 +1811,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 +1821,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 +1835,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 +2140,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,21 +2202,462 @@ 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('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('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 +2719,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 +2906,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( - Object.keys(preloadController.state.cachedUserDataByProvider), - ).toHaveLength(0); + 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( + 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 +3161,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]])); @@ -2178,6 +3385,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 +3434,8 @@ describe('PerpsController', () => { }, timestamp: Date.now(), address: mockAddress, + hip3ConfigVersion: 0, + dexes: ['main'], }; }); @@ -2234,6 +3473,8 @@ describe('PerpsController', () => { }, timestamp: Date.now(), address: mockAddress, + hip3ConfigVersion: 0, + dexes: ['main'], }; state.cachedUserDataByProvider['myx:mainnet'] = { positions: [myxPosition], @@ -2281,6 +3522,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 1e37c034a40..82df7c27e12 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,156 @@ 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([]); + + 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.orders).toEqual([]); + expect(result.accountState.totalBalance).toBe('25000'); + expect(result.identity).toEqual({ + provider: 'hyperliquid', + network: 'mainnet', + address: mockUserAddress, + hip3ConfigVersion: 7, + dexes: ['main'], + }); + }); + + 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 70e27a2b2bd..6f024c2d21b 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 9f623f2773c..9fc3cfa8088 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/snapshot', + }; 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 d54ec455e6d..ae9fe6ed63b 100644 --- a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts +++ b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts @@ -3,6 +3,71 @@ 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', + dex: 'main', + name: 'Bitcoin', + description: 'Original cryptocurrency', + iconUrl: 'https://example.com/btc.png', + szDecimals: 5, + maxLeverage: 50, + marginTableId: 1, + onlyIsolated: false, + isDelisted: false, + minimumOrderSize: '10', + markPrice: '50000', + midPrice: '50001', + oraclePrice: '49999', + change24h: '125', + change24hPercent: '0.25', + volume24hUsd: '1000000', + openInterestBase: '20', + openInterestUsd: '1000000', + fundingRate: '0.0001', + categories: ['crypto'], + marketType: '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 +171,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 +529,474 @@ 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/snapshot', + }; + }); + + 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/snapshot?provider=hyperliquid&network=mainnet&dexes=main', + 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({ change24hPercent: '9' })], + }), + ], + [ + 'incoherent USD open interest', + createGlobalSnapshot({ + markets: [createSnapshotMarket({ openInterestUsd: '1' })], + }), + ], + [ + 'overflowing mark/change subtraction', + createGlobalSnapshot({ + markets: [ + createSnapshotMarket({ + markPrice: HUGE_FINITE_DECIMAL, + change24h: `-${HUGE_FINITE_DECIMAL}`, + change24hPercent: '0', + openInterestBase: '0', + openInterestUsd: '0', + }), + ], + }), + ], + [ + 'overflowing USD open-interest derivation', + createGlobalSnapshot({ + markets: [ + createSnapshotMarket({ + markPrice: HUGE_FINITE_DECIMAL, + change24h: '0', + change24hPercent: '0', + openInterestBase: '10', + openInterestUsd: HUGE_FINITE_DECIMAL, + }), + ], + }), + ], + [ + '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 })], + }), + ], + [ + 'empty trend', + createGlobalSnapshot({ + markets: [createSnapshotMarket({ trend: [] })], + }), + ], + [ + 'single-point trend', + createGlobalSnapshot({ + markets: [ + createSnapshotMarket({ + trend: [[SNAPSHOT_NOW - 1_000, '50000']], + }), + ], + }), + ], + [ + 'irregular trend cadence', + createGlobalSnapshot({ + markets: [ + createSnapshotMarket({ + trend: [ + [SNAPSHOT_NOW - 10_800_000, '49000'], + [SNAPSHOT_NOW - 1_000, '50000'], + ], + }), + ], + }), + ], + ])('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('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.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 + 1 }], + ['future receivedAt', { receivedAt: SNAPSHOT_NOW + 1 }], + [ + '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.each([ + ['duplicate market', [createSnapshotMarket(), createSnapshotMarket()]], + [ + 'missing requested DEX', + [ + createSnapshotMarket(), + createSnapshotMarket({ + symbol: 'BTC2', + }), + ], + ], + [ + 'invalid raw open-interest units', + [createSnapshotMarket({ openInterestBase: '-1' })], + ], + ['empty non-null name', [createSnapshotMarket({ name: '' })]], + [ + 'trend older than its maximum cadence', + [ + createSnapshotMarket({ + trend: [ + [SNAPSHOT_NOW - 4 * 60 * 60 * 1000, '100'], + [SNAPSHOT_NOW - 3 * 60 * 60 * 1000, '101'], + ], + }), + ], + ], + ['delisted-only data', [createSnapshotMarket({ isDelisted: true })]], + [ + 'enabled DEX with only delisted data', + [ + createSnapshotMarket(), + createSnapshotMarket({ + symbol: 'xyz:TSLA', + dex: 'xyz', + isDelisted: true, + }), + ], + ], + ])('rejects %s', async (_name, markets) => { + const needsXyz = + _name === 'missing requested DEX' || + _name === 'enabled DEX with only delisted data'; + 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({ diff --git a/packages/remote-feature-flag-controller/CHANGELOG.md b/packages/remote-feature-flag-controller/CHANGELOG.md index 664b04dc9ae..120a6757165 100644 --- a/packages/remote-feature-flag-controller/CHANGELOG.md +++ b/packages/remote-feature-flag-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Export the modern `RemoteFeatureFlagController:stateChanged` event alongside the deprecated `stateChange` event so consumers can migrate without subscribing to a deprecated event ([#9815](https://github.com/MetaMask/core/pull/9815)). - Add optional `defaultFeatureFlags` constructor option to `RemoteFeatureFlagController` for client-side defaults as the lowest-precedence layer under processed remote flags and local overrides ([#9747](https://github.com/MetaMask/core/pull/9747)) ## [5.0.0] diff --git a/packages/remote-feature-flag-controller/src/index.ts b/packages/remote-feature-flag-controller/src/index.ts index 54efe45eed2..6ee219537ef 100644 --- a/packages/remote-feature-flag-controller/src/index.ts +++ b/packages/remote-feature-flag-controller/src/index.ts @@ -6,6 +6,7 @@ export type { RemoteFeatureFlagControllerGetStateAction, RemoteFeatureFlagControllerEvents, RemoteFeatureFlagControllerStateChangeEvent, + RemoteFeatureFlagControllerStateChangedEvent, } from './remote-feature-flag-controller.js'; export type { RemoteFeatureFlagControllerClearAllFlagOverridesAction, diff --git a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts index e9098ffcb33..3845a61a47b 100644 --- a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts +++ b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts @@ -1420,6 +1420,23 @@ describe('RemoteFeatureFlagController', () => { describe('override functionality', () => { describe('setFlagOverride', () => { + it('publishes the modern stateChanged event', () => { + const { messenger } = createController(); + const listener = jest.fn(); + messenger.subscribe( + 'RemoteFeatureFlagController:stateChanged', + listener, + ); + + messenger.call( + 'RemoteFeatureFlagController:setFlagOverride', + 'testFlag', + true, + ); + + expect(listener).toHaveBeenCalledTimes(1); + }); + it('sets a local override for a feature flag', () => { const { controller, messenger } = createController(); diff --git a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts index 8f1b2b8c055..a608a812d40 100644 --- a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts +++ b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts @@ -2,7 +2,10 @@ import { BaseController, ControllerGetStateAction, } from '@metamask/base-controller'; -import type { ControllerStateChangeEvent } from '@metamask/base-controller'; +import type { + ControllerStateChangeEvent, + ControllerStateChangedEvent, +} from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; import { isValidSemVerVersion } from '@metamask/utils'; import type { Json, SemVerVersion } from '@metamask/utils'; @@ -102,8 +105,15 @@ export type RemoteFeatureFlagControllerStateChangeEvent = RemoteFeatureFlagControllerState >; +export type RemoteFeatureFlagControllerStateChangedEvent = + ControllerStateChangedEvent< + typeof controllerName, + RemoteFeatureFlagControllerState + >; + export type RemoteFeatureFlagControllerEvents = - RemoteFeatureFlagControllerStateChangeEvent; + | RemoteFeatureFlagControllerStateChangeEvent + | RemoteFeatureFlagControllerStateChangedEvent; export type RemoteFeatureFlagControllerMessenger = Messenger< typeof controllerName, From 82caac0735d89ff4211a8666adf78358b67fcc66 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 11 Aug 2026 12:19:53 +0800 Subject: [PATCH 02/11] fix(perps): harden snapshot integration --- .../PerpsController-method-action-types.ts | 6 +- .../perps-controller/src/PerpsController.ts | 6 +- .../src/constants/hyperLiquidConfig.ts | 7 +- .../src/services/TerminalMarketService.ts | 12 ---- .../perps-controller/src/types/messenger.ts | 8 ++- .../src/PerpsController.lifecycle.test.ts | 20 +++--- .../services/TerminalMarketService.test.ts | 68 ++++++++----------- .../CHANGELOG.md | 1 - .../src/index.ts | 1 - .../remote-feature-flag-controller.test.ts | 17 ----- .../src/remote-feature-flag-controller.ts | 14 +--- 11 files changed, 62 insertions(+), 98 deletions(-) diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index 5b5f91bf3aa..7a242c5feb1 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -37,7 +37,11 @@ export type PerpsControllerGetCachedUserDataForActiveProviderAction = { }; /** - * Fetch, validate, and cache a complete user-data snapshot. + * 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`; diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index e3c66393df8..b6e5b9ac734 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -4064,10 +4064,8 @@ export class PerpsController extends BaseController< } const userAddress = evmAccount.address; - const activeProvider = this.state.activeProvider; - const activeProviderInstance = this.activeProviderInstance; - const isTestnet = this.state.isTestnet; - const hip3ConfigVersion = this.state.hip3ConfigVersion; + const { activeProvider, isTestnet, hip3ConfigVersion } = this.state; + const { activeProviderInstance } = this; const hyperliquidDexes = this.#getStaticSnapshotDexes(); const isCurrent = (): boolean => { let currentAddress: string | undefined; diff --git a/packages/perps-controller/src/constants/hyperLiquidConfig.ts b/packages/perps-controller/src/constants/hyperLiquidConfig.ts index 3f0b6b78efd..a23f61a070f 100644 --- a/packages/perps-controller/src/constants/hyperLiquidConfig.ts +++ b/packages/perps-controller/src/constants/hyperLiquidConfig.ts @@ -27,7 +27,12 @@ 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. */ +/** + * 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[] { diff --git a/packages/perps-controller/src/services/TerminalMarketService.ts b/packages/perps-controller/src/services/TerminalMarketService.ts index 1125eedf880..5eb43fbd76e 100644 --- a/packages/perps-controller/src/services/TerminalMarketService.ts +++ b/packages/perps-controller/src/services/TerminalMarketService.ts @@ -38,8 +38,6 @@ const GLOBAL_SNAPSHOT_MAX_PAYLOAD_BYTES = 1_048_576; const GLOBAL_SNAPSHOT_PERCENT_TOLERANCE = 0.01; const GLOBAL_SNAPSHOT_OPEN_INTEREST_RELATIVE_TOLERANCE = 0.0001; const MINIMUM_EPOCH_MILLISECONDS = Date.UTC(2000, 0, 1); -const MINIMUM_TREND_INTERVAL_MS = 30 * 60 * 1000; -const MAXIMUM_TREND_INTERVAL_MS = 2 * 60 * 60 * 1000; 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; @@ -605,20 +603,13 @@ export class TerminalMarketService { throw invalid(field); } } - if (market.trend.length < 2) { - throw invalid('trend'); - } let previousTrendTimestamp = -1; for (const [timestamp, price] of market.trend) { - const interval = timestamp - previousTrendTimestamp; if ( !this.#isNonNegativeSafeInteger(timestamp) || timestamp < MINIMUM_EPOCH_MILLISECONDS || timestamp > generatedAt || timestamp <= previousTrendTimestamp || - (previousTrendTimestamp >= 0 && - (interval < MINIMUM_TREND_INTERVAL_MS || - interval > MAXIMUM_TREND_INTERVAL_MS)) || !NON_NEGATIVE_DECIMAL_PATTERN.test(price) || !Number.isFinite(Number(price)) || Number(price) <= 0 @@ -627,9 +618,6 @@ export class TerminalMarketService { } previousTrendTimestamp = timestamp; } - if (generatedAt - previousTrendTimestamp > MAXIMUM_TREND_INTERVAL_MS) { - throw invalid('trend'); - } } #mapSnapshotMarket( diff --git a/packages/perps-controller/src/types/messenger.ts b/packages/perps-controller/src/types/messenger.ts index 20c19932c13..e9bad54147b 100644 --- a/packages/perps-controller/src/types/messenger.ts +++ b/packages/perps-controller/src/types/messenger.ts @@ -1,3 +1,4 @@ +import type { ControllerStateChangedEvent } from '@metamask/base-controller'; import type { AccountTreeControllerGetAccountsFromSelectedAccountGroupAction, AccountTreeControllerSelectedAccountGroupChangeEvent, @@ -24,10 +25,15 @@ import type { import type { AuthenticationController } from '@metamask/profile-sync-controller'; import type { RemoteFeatureFlagControllerGetStateAction, - RemoteFeatureFlagControllerStateChangedEvent, + RemoteFeatureFlagControllerState, } from '@metamask/remote-feature-flag-controller'; import type { TransactionControllerAddTransactionAction } from '@metamask/transaction-controller'; +type RemoteFeatureFlagControllerStateChangedEvent = ControllerStateChangedEvent< + 'RemoteFeatureFlagController', + RemoteFeatureFlagControllerState +>; + /** * Actions from other controllers that PerpsController is allowed to call. */ diff --git a/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts b/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts index d781f4b623e..33b548ba5c9 100644 --- a/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts @@ -579,7 +579,7 @@ describe('PerpsController', () => { expect(controller.state.isTestnet).toBe(false); // Default to mainnet }); - it('reads current RemoteFeatureFlagController state during construction', () => { + it('reads current RemoteFeatureFlagController state during initialization', async () => { // Given: A messenger that returns remote feature flags state const testMockCall = jest.fn().mockImplementation((action: string) => { if (action === 'RemoteFeatureFlagController:getState') { @@ -595,12 +595,13 @@ describe('PerpsController', () => { }); const testMessenger = createMockMessenger({ call: testMockCall }); - // When: Controller is constructed + // When: Controller is constructed and initialized const testController = new TestablePerpsController({ messenger: testMessenger, state: getDefaultPerpsControllerState(), infrastructure: createMockInfrastructure(), }); + await testController.init(); // Then: Should have called to get RemoteFeatureFlagController state via messenger expect(testController).toBeDefined(); @@ -609,7 +610,7 @@ describe('PerpsController', () => { ); }); - it('applies remote blocked regions when available during construction', () => { + it('applies remote blocked regions when available during initialization', async () => { // Given: Messenger that returns remote feature flags with blocked regions const testMockCall = jest.fn().mockImplementation((action: string) => { if (action === 'RemoteFeatureFlagController:getState') { @@ -624,7 +625,7 @@ describe('PerpsController', () => { return undefined; }); - // When: Controller is constructed + // When: Controller is constructed and initialized const testController = new TestablePerpsController({ messenger: createMockMessenger({ call: testMockCall }), state: getDefaultPerpsControllerState(), @@ -633,6 +634,7 @@ describe('PerpsController', () => { fallbackBlockedRegions: ['FALLBACK-REGION'], }, }); + await testController.init(); // Then: Should have used remote regions (not fallback) // Verify by checking the internal blockedRegionList @@ -668,7 +670,7 @@ describe('PerpsController', () => { expect(blockedRegionList.list).toEqual(['FALLBACK-US', 'FALLBACK-CA']); }); - it('never downgrade from remote to fallback regions', () => { + it('never downgrade from remote to fallback regions', async () => { // Given: Messenger that returns remote feature flags with blocked regions const testMockCall = jest.fn().mockImplementation((action: string) => { if (action === 'RemoteFeatureFlagController:getState') { @@ -683,7 +685,7 @@ describe('PerpsController', () => { return undefined; }); - // When: Controller is constructed with both remote and fallback + // When: Controller is constructed and initialized with both remote and fallback const testController = new TestablePerpsController({ messenger: createMockMessenger({ call: testMockCall }), state: getDefaultPerpsControllerState(), @@ -692,6 +694,7 @@ describe('PerpsController', () => { fallbackBlockedRegions: ['FALLBACK-US'], }, }); + await testController.init(); // Then: Should use remote (set after fallback) let blockedRegionList = testController.testGetBlockedRegionList(); @@ -707,7 +710,7 @@ describe('PerpsController', () => { expect(blockedRegionList.list).toEqual(['REMOTE-US']); }); - it('continues initialization when RemoteFeatureFlagController state call throws error', () => { + it('continues initialization when RemoteFeatureFlagController state call throws error', async () => { const testInfrastructure = createMockInfrastructure(); const testMockCall = jest.fn().mockImplementation((action: string) => { if (action === 'RemoteFeatureFlagController:getState') { @@ -724,6 +727,7 @@ describe('PerpsController', () => { fallbackBlockedRegions: ['FALLBACK-US', 'FALLBACK-CA'], }, }); + await testController.init(); expect(testController).toBeDefined(); const blockedRegionList = testController.testGetBlockedRegionList(); @@ -738,7 +742,7 @@ describe('PerpsController', () => { context: expect.objectContaining({ name: 'PerpsController', data: expect.objectContaining({ - method: 'constructor', + method: 'init', operation: 'readRemoteFeatureFlags', }), }), diff --git a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts index ae9fe6ed63b..ea5dccfcb9f 100644 --- a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts +++ b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts @@ -664,35 +664,6 @@ describe('TerminalMarketService', () => { markets: [createSnapshotMarket({ listedAt: 1_700_000_000 })], }), ], - [ - 'empty trend', - createGlobalSnapshot({ - markets: [createSnapshotMarket({ trend: [] })], - }), - ], - [ - 'single-point trend', - createGlobalSnapshot({ - markets: [ - createSnapshotMarket({ - trend: [[SNAPSHOT_NOW - 1_000, '50000']], - }), - ], - }), - ], - [ - 'irregular trend cadence', - createGlobalSnapshot({ - markets: [ - createSnapshotMarket({ - trend: [ - [SNAPSHOT_NOW - 10_800_000, '49000'], - [SNAPSHOT_NOW - 1_000, '50000'], - ], - }), - ], - }), - ], ])('rejects %s', async (_name, snapshot) => { jest .spyOn(globalThis, 'fetch') @@ -707,6 +678,34 @@ describe('TerminalMarketService', () => { ).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, @@ -795,17 +794,6 @@ describe('TerminalMarketService', () => { [createSnapshotMarket({ openInterestBase: '-1' })], ], ['empty non-null name', [createSnapshotMarket({ name: '' })]], - [ - 'trend older than its maximum cadence', - [ - createSnapshotMarket({ - trend: [ - [SNAPSHOT_NOW - 4 * 60 * 60 * 1000, '100'], - [SNAPSHOT_NOW - 3 * 60 * 60 * 1000, '101'], - ], - }), - ], - ], ['delisted-only data', [createSnapshotMarket({ isDelisted: true })]], [ 'enabled DEX with only delisted data', diff --git a/packages/remote-feature-flag-controller/CHANGELOG.md b/packages/remote-feature-flag-controller/CHANGELOG.md index 120a6757165..664b04dc9ae 100644 --- a/packages/remote-feature-flag-controller/CHANGELOG.md +++ b/packages/remote-feature-flag-controller/CHANGELOG.md @@ -9,7 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Export the modern `RemoteFeatureFlagController:stateChanged` event alongside the deprecated `stateChange` event so consumers can migrate without subscribing to a deprecated event ([#9815](https://github.com/MetaMask/core/pull/9815)). - Add optional `defaultFeatureFlags` constructor option to `RemoteFeatureFlagController` for client-side defaults as the lowest-precedence layer under processed remote flags and local overrides ([#9747](https://github.com/MetaMask/core/pull/9747)) ## [5.0.0] diff --git a/packages/remote-feature-flag-controller/src/index.ts b/packages/remote-feature-flag-controller/src/index.ts index 6ee219537ef..54efe45eed2 100644 --- a/packages/remote-feature-flag-controller/src/index.ts +++ b/packages/remote-feature-flag-controller/src/index.ts @@ -6,7 +6,6 @@ export type { RemoteFeatureFlagControllerGetStateAction, RemoteFeatureFlagControllerEvents, RemoteFeatureFlagControllerStateChangeEvent, - RemoteFeatureFlagControllerStateChangedEvent, } from './remote-feature-flag-controller.js'; export type { RemoteFeatureFlagControllerClearAllFlagOverridesAction, diff --git a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts index 3845a61a47b..e9098ffcb33 100644 --- a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts +++ b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts @@ -1420,23 +1420,6 @@ describe('RemoteFeatureFlagController', () => { describe('override functionality', () => { describe('setFlagOverride', () => { - it('publishes the modern stateChanged event', () => { - const { messenger } = createController(); - const listener = jest.fn(); - messenger.subscribe( - 'RemoteFeatureFlagController:stateChanged', - listener, - ); - - messenger.call( - 'RemoteFeatureFlagController:setFlagOverride', - 'testFlag', - true, - ); - - expect(listener).toHaveBeenCalledTimes(1); - }); - it('sets a local override for a feature flag', () => { const { controller, messenger } = createController(); diff --git a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts index a608a812d40..8f1b2b8c055 100644 --- a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts +++ b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts @@ -2,10 +2,7 @@ import { BaseController, ControllerGetStateAction, } from '@metamask/base-controller'; -import type { - ControllerStateChangeEvent, - ControllerStateChangedEvent, -} from '@metamask/base-controller'; +import type { ControllerStateChangeEvent } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; import { isValidSemVerVersion } from '@metamask/utils'; import type { Json, SemVerVersion } from '@metamask/utils'; @@ -105,15 +102,8 @@ export type RemoteFeatureFlagControllerStateChangeEvent = RemoteFeatureFlagControllerState >; -export type RemoteFeatureFlagControllerStateChangedEvent = - ControllerStateChangedEvent< - typeof controllerName, - RemoteFeatureFlagControllerState - >; - export type RemoteFeatureFlagControllerEvents = - | RemoteFeatureFlagControllerStateChangeEvent - | RemoteFeatureFlagControllerStateChangedEvent; + RemoteFeatureFlagControllerStateChangeEvent; export type RemoteFeatureFlagControllerMessenger = Messenger< typeof controllerName, From c28be5774943ec2ef99c62aea5528ab5b125867c Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 11 Aug 2026 23:17:22 +0800 Subject: [PATCH 03/11] style(perps-controller): format messenger imports --- packages/perps-controller/src/types/messenger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/perps-controller/src/types/messenger.ts b/packages/perps-controller/src/types/messenger.ts index e9bad54147b..5ce2903d9c0 100644 --- a/packages/perps-controller/src/types/messenger.ts +++ b/packages/perps-controller/src/types/messenger.ts @@ -1,4 +1,3 @@ -import type { ControllerStateChangedEvent } from '@metamask/base-controller'; import type { AccountTreeControllerGetAccountsFromSelectedAccountGroupAction, AccountTreeControllerSelectedAccountGroupChangeEvent, @@ -11,6 +10,7 @@ import type { AuthenticatedUserStorageServiceGetNotificationPreferencesAction, AuthenticatedUserStorageServicePutNotificationPreferencesAction, } from '@metamask/authenticated-user-storage'; +import type { ControllerStateChangedEvent } from '@metamask/base-controller'; import type { GeolocationControllerGetGeolocationAction } from '@metamask/geolocation-controller'; import type { KeyringControllerGetStateAction, From 0089941cba4e77ca165a6ada0f871de5941c73dc Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 12 Aug 2026 13:16:34 +0800 Subject: [PATCH 04/11] fix(perps): bound snapshot response body reads --- .../src/services/TerminalMarketService.ts | 69 +++++++++---------- .../services/TerminalMarketService.test.ts | 37 ++++++++++ 2 files changed, 71 insertions(+), 35 deletions(-) diff --git a/packages/perps-controller/src/services/TerminalMarketService.ts b/packages/perps-controller/src/services/TerminalMarketService.ts index 5eb43fbd76e..ffd50f8350c 100644 --- a/packages/perps-controller/src/services/TerminalMarketService.ts +++ b/packages/perps-controller/src/services/TerminalMarketService.ts @@ -289,49 +289,48 @@ export class TerminalMarketService { TERMINAL_API_CONFIG.FetchTimeoutMs, ); - let response: Response; try { - response = await fetch(url, { + const response = await fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json' }, signal: controller.signal, }); - } finally { - clearTimeout(timeoutId); - } - if (!response.ok) { - throw new Error( - `Terminal global snapshot returned ${String(response.status)}: ${response.statusText}`, - ); - } + 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'); + 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); } - return this.#validateAndMapGlobalSnapshot(body, identity, Date.now()); } async #validateAndMapGlobalSnapshot( diff --git a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts index ea5dccfcb9f..b14d1f659ef 100644 --- a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts +++ b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts @@ -745,6 +745,43 @@ describe('TerminalMarketService', () => { 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' }], From c7765de36dc879d519de31f5440202cf24039054 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 12 Aug 2026 20:43:12 +0800 Subject: [PATCH 05/11] fix(perps): align terminal v2 snapshot contract --- .../src/services/TerminalMarketService.ts | 154 ++++++++---------- .../PerpsController.providers-cache.test.ts | 6 +- .../src/services/MarketDataService.test.ts | 2 +- .../services/TerminalMarketService.test.ts | 67 ++------ 4 files changed, 83 insertions(+), 146 deletions(-) diff --git a/packages/perps-controller/src/services/TerminalMarketService.ts b/packages/perps-controller/src/services/TerminalMarketService.ts index ffd50f8350c..a414bd29619 100644 --- a/packages/perps-controller/src/services/TerminalMarketService.ts +++ b/packages/perps-controller/src/services/TerminalMarketService.ts @@ -36,7 +36,6 @@ 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_OPEN_INTEREST_RELATIVE_TOLERANCE = 0.0001; 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; @@ -44,29 +43,25 @@ 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(), - marginTableId: number(), - onlyIsolated: boolean(), - isDelisted: boolean(), - minimumOrderSize: nullable(string()), markPrice: string(), + price: string(), midPrice: nullable(string()), oraclePrice: string(), change24h: string(), - change24hPercent: string(), - volume24hUsd: string(), - openInterestBase: string(), - openInterestUsd: string(), - fundingRate: string(), - categories: array(string()), - marketType: nullable(string()), - keywords: array(string()), - tags: array(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()])), }); @@ -387,7 +382,6 @@ export class TerminalMarketService { const marketKeys = new Set(); const representedDexes = new Set(); - const tradableDexes = new Set(); const markets = snapshot.markets .map((market, index) => { this.#validateSnapshotMarket( @@ -404,22 +398,10 @@ export class TerminalMarketService { representedDexes.add(market.dex); return market; }) - .filter((market) => { - if (!market.isDelisted) { - tradableDexes.add(market.dex); - return true; - } - return false; - }) .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 (identity.enabledDexes.some((dex) => !tradableDexes.has(dex))) { - throw new Error( - 'Terminal global snapshot has no tradable market for a requested DEX', - ); - } if (markets.length === 0) { throw new Error('Terminal global snapshot has no tradable markets'); } @@ -496,6 +478,10 @@ export class TerminalMarketService { 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 || @@ -508,7 +494,6 @@ export class TerminalMarketService { if ( !this.#isNonNegativeSafeInteger(market.szDecimals) || !this.#isPositiveSafeInteger(market.maxLeverage) || - !this.#isNonNegativeSafeInteger(market.marginTableId) || (market.listedAt !== null && (!this.#isNonNegativeSafeInteger(market.listedAt) || market.listedAt < MINIMUM_EPOCH_MILLISECONDS || @@ -519,20 +504,16 @@ export class TerminalMarketService { const decimalFields: [string, string, boolean][] = [ ['markPrice', market.markPrice, true], + ['price', market.price, true], ['oraclePrice', market.oraclePrice, true], ['change24h', market.change24h, false], - ['change24hPercent', market.change24hPercent, false], - ['volume24hUsd', market.volume24hUsd, true], - ['openInterestBase', market.openInterestBase, true], - ['openInterestUsd', market.openInterestUsd, true], - ['fundingRate', market.fundingRate, false], + ['volume24h', market.volume24h, true], + ['openInterest', market.openInterest, true], + ['funding', market.funding, false], ]; if (market.midPrice !== null) { decimalFields.push(['midPrice', market.midPrice, true]); } - if (market.minimumOrderSize !== null) { - decimalFields.push(['minimumOrderSize', market.minimumOrderSize, true]); - } for (const [field, value, nonNegative] of decimalFields) { const pattern = nonNegative ? NON_NEGATIVE_DECIMAL_PATTERN @@ -541,77 +522,56 @@ export class TerminalMarketService { throw invalid(field); } } - const markPrice = Number(market.markPrice); + if (market.price !== market.markPrice) { + throw invalid('deprecated price alias'); + } + const price = Number(market.markPrice); const change24h = Number(market.change24h); - const change24hPercent = Number(market.change24hPercent); - const previousPrice = markPrice - change24h; - if ( - markPrice <= 0 || - previousPrice <= 0 || - !Number.isFinite(previousPrice) - ) { + 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) || - Math.abs(change24hPercent - derivedPercent) > + !Number.isFinite(market.changePercent24h) || + Math.abs(market.changePercent24h - derivedPercent) > GLOBAL_SNAPSHOT_PERCENT_TOLERANCE ) { - throw invalid('change24hPercent coherence'); - } - - const openInterestBase = Number(market.openInterestBase); - const openInterestUsd = Number(market.openInterestUsd); - const derivedOpenInterestUsd = openInterestBase * markPrice; - const openInterestRelativeError = - Math.abs(openInterestUsd - derivedOpenInterestUsd) / - Math.max(1, derivedOpenInterestUsd); - if ( - !Number.isFinite(derivedOpenInterestUsd) || - !Number.isFinite(openInterestRelativeError) || - openInterestRelativeError > - GLOBAL_SNAPSHOT_OPEN_INTEREST_RELATIVE_TOLERANCE - ) { - throw invalid('openInterestUsd coherence'); + throw invalid('changePercent24h coherence'); } for (const [field, values] of [ - ['categories', market.categories], ['keywords', market.keywords], ['tags', market.tags], ] as const) { if ( - values.some((value) => value.length === 0) || - new Set(values).size !== values.length + values !== null && + (values.some((value) => value.length === 0) || + new Set(values).size !== values.length) ) { throw invalid(field); } } - if ( - market.marketType !== null && - !VALID_MARKET_TYPES.has(market.marketType) - ) { - throw invalid('marketType'); - } 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, price] of market.trend) { + for (const [timestamp, trendPrice] of market.trend) { if ( !this.#isNonNegativeSafeInteger(timestamp) || timestamp < MINIMUM_EPOCH_MILLISECONDS || timestamp > generatedAt || timestamp <= previousTrendTimestamp || - !NON_NEGATIVE_DECIMAL_PATTERN.test(price) || - !Number.isFinite(Number(price)) || - Number(price) <= 0 + !NON_NEGATIVE_DECIMAL_PATTERN.test(trendPrice) || + !Number.isFinite(Number(trendPrice)) || + Number(trendPrice) <= 0 ) { throw invalid('trend'); } @@ -624,19 +584,14 @@ export class TerminalMarketService { sourceExpiresAt: number, ): PerpsMarketData { const formatters = this.#deps.marketDataFormatters; - // The current Terminal monitor derives both change fields from markPx. - // Use markPrice for the summary row so price and change share one source - // semantic. midPrice remains validated for future live-price consumers. + // 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 change24hPercent = Number(market.change24hPercent); - const volume = Number(market.volume24hUsd); - const openInterestUsd = Number(market.openInterestUsd); + const volume = Number(market.volume24h); + const openInterest = Number(market.openInterest); const isHip3 = market.dex !== 'main'; - const marketType = - market.marketType === null - ? undefined - : (market.marketType as TerminalAssetMetadata['marketType']); + const marketType = this.#marketTypeFor(market.dex, market.category); return { symbol: market.symbol, @@ -649,17 +604,17 @@ export class TerminalMarketService { ranges: formatters.priceRangesUniversal, }), change24h: formatChange(change24h, formatters), - change24hPercent: formatters.formatPercentage(change24hPercent), + change24hPercent: formatters.formatPercentage(market.changePercent24h), volume: formatters.formatVolume(volume), - openInterest: formatters.formatVolume(openInterestUsd), - fundingRate: Number(market.fundingRate), + openInterest: formatters.formatVolume(openInterest), + fundingRate: Number(market.funding), marketSource: isHip3 ? market.dex : undefined, marketType, isHip3, isNewMarket: isHip3 && marketType === undefined, - ...(market.keywords.length > 0 && { keywords: market.keywords }), - ...(market.tags.length > 0 && { tags: market.tags }), - ...(market.categories.length > 0 && { categories: market.categories }), + ...(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', @@ -667,6 +622,25 @@ export class TerminalMarketService { }; } + #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 { 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 6ea5ace5685..02151ddbf8b 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -994,7 +994,7 @@ describe('PerpsController', () => { it('passes only an exact static Hyperliquid snapshot identity and guards config races', async () => { mockInfrastructure.terminalApi = { ...mockInfrastructure.terminalApi, - globalSnapshotUrl: 'https://terminal.test/v2/perpetuals/snapshot', + globalSnapshotUrl: 'https://terminal.test/v2/perpetuals', }; controller = new TestablePerpsController({ messenger: createMockMessenger(), @@ -1059,7 +1059,7 @@ describe('PerpsController', () => { it('disables snapshot adoption for ambiguous bare allowlist identity', async () => { mockInfrastructure.terminalApi = { ...mockInfrastructure.terminalApi, - globalSnapshotUrl: 'https://terminal.test/v2/perpetuals/snapshot', + globalSnapshotUrl: 'https://terminal.test/v2/perpetuals', }; controller = new TestablePerpsController({ messenger: createMockMessenger(), @@ -1088,7 +1088,7 @@ describe('PerpsController', () => { it('keeps main first in an exact static snapshot identity', async () => { mockInfrastructure.terminalApi = { - globalSnapshotUrl: 'https://terminal.test/v2/perpetuals/snapshot', + globalSnapshotUrl: 'https://terminal.test/v2/perpetuals', }; controller = new TestablePerpsController({ messenger: createMockMessenger(), diff --git a/packages/perps-controller/tests/src/services/MarketDataService.test.ts b/packages/perps-controller/tests/src/services/MarketDataService.test.ts index 9fc3cfa8088..068975d1136 100644 --- a/packages/perps-controller/tests/src/services/MarketDataService.test.ts +++ b/packages/perps-controller/tests/src/services/MarketDataService.test.ts @@ -1162,7 +1162,7 @@ describe('MarketDataService', () => { beforeEach(() => { mockDeps.terminalApi = { ...mockDeps.terminalApi, - globalSnapshotUrl: 'https://terminal.test/v2/perpetuals/snapshot', + globalSnapshotUrl: 'https://terminal.test/v2/perpetuals', }; mockTerminalService = { fetchMarkets: jest.fn(), diff --git a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts index b14d1f659ef..d3337863d8b 100644 --- a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts +++ b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts @@ -10,27 +10,23 @@ 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, - marginTableId: 1, - onlyIsolated: false, - isDelisted: false, - minimumOrderSize: '10', markPrice: '50000', + price: '50000', midPrice: '50001', oraclePrice: '49999', change24h: '125', - change24hPercent: '0.25', - volume24hUsd: '1000000', - openInterestBase: '20', - openInterestUsd: '1000000', - fundingRate: '0.0001', - categories: ['crypto'], - marketType: 'crypto', + changePercent24h: 0.25, + funding: '0.0001', + volume24h: '1000000', + openInterest: '1000000', + category: 'crypto', keywords: ['bitcoin'], tags: ['top-10'], listedAt: 1_600_000_000_000, @@ -535,7 +531,7 @@ describe('TerminalMarketService', () => { mockDeps.terminalApi = { ...mockDeps.terminalApi, globalSnapshotUrl: - 'https://terminal.test-api.cx.metamask.io/v2/perpetuals/snapshot', + 'https://terminal.test-api.cx.metamask.io/v2/perpetuals', }; }); @@ -582,7 +578,7 @@ describe('TerminalMarketService', () => { expiresAt: SNAPSHOT_NOW + 28_000, }); expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://terminal.test-api.cx.metamask.io/v2/perpetuals/snapshot?provider=hyperliquid&network=mainnet&dexes=main', + 'https://terminal.test-api.cx.metamask.io/v2/perpetuals?provider=hyperliquid&network=mainnet&dexes=main', expect.objectContaining({ method: 'GET' }), ); }); @@ -598,13 +594,13 @@ describe('TerminalMarketService', () => { [ 'incoherent mark-based percent', createGlobalSnapshot({ - markets: [createSnapshotMarket({ change24hPercent: '9' })], + markets: [createSnapshotMarket({ changePercent24h: 9 })], }), ], [ - 'incoherent USD open interest', + 'incoherent deprecated price alias', createGlobalSnapshot({ - markets: [createSnapshotMarket({ openInterestUsd: '1' })], + markets: [createSnapshotMarket({ price: '50001' })], }), ], [ @@ -614,23 +610,7 @@ describe('TerminalMarketService', () => { createSnapshotMarket({ markPrice: HUGE_FINITE_DECIMAL, change24h: `-${HUGE_FINITE_DECIMAL}`, - change24hPercent: '0', - openInterestBase: '0', - openInterestUsd: '0', - }), - ], - }), - ], - [ - 'overflowing USD open-interest derivation', - createGlobalSnapshot({ - markets: [ - createSnapshotMarket({ - markPrice: HUGE_FINITE_DECIMAL, - change24h: '0', - change24hPercent: '0', - openInterestBase: '10', - openInterestUsd: HUGE_FINITE_DECIMAL, + changePercent24h: 0, }), ], }), @@ -826,27 +806,10 @@ describe('TerminalMarketService', () => { }), ], ], - [ - 'invalid raw open-interest units', - [createSnapshotMarket({ openInterestBase: '-1' })], - ], + ['invalid open interest', [createSnapshotMarket({ openInterest: '-1' })]], ['empty non-null name', [createSnapshotMarket({ name: '' })]], - ['delisted-only data', [createSnapshotMarket({ isDelisted: true })]], - [ - 'enabled DEX with only delisted data', - [ - createSnapshotMarket(), - createSnapshotMarket({ - symbol: 'xyz:TSLA', - dex: 'xyz', - isDelisted: true, - }), - ], - ], ])('rejects %s', async (_name, markets) => { - const needsXyz = - _name === 'missing requested DEX' || - _name === 'enabled DEX with only delisted data'; + const needsXyz = _name === 'missing requested DEX'; jest.spyOn(globalThis, 'fetch').mockResolvedValue( okJsonResponse( createGlobalSnapshot({ From bd97fb637212517f41859158f2975839523fc321 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 13 Aug 2026 00:25:41 +0800 Subject: [PATCH 06/11] chore(perps): minimize bootstrap snapshot changes --- eslint-suppressions.json | 15 +++++ packages/perps-controller/CHANGELOG.md | 1 - .../perps-controller/src/PerpsController.ts | 56 ++++++++++--------- packages/perps-controller/src/index.ts | 2 + .../perps-controller/src/types/messenger.ts | 10 +--- .../tests/defer-eligibility.test.ts | 15 ++--- .../src/PerpsController.lifecycle.test.ts | 20 +++---- 7 files changed, 63 insertions(+), 56 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 078a2de980a..d63a487fe33 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1517,6 +1517,16 @@ "count": 1 } }, + "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 + } + }, "packages/perps-controller/src/utils/myxAdapter.ts": { "@typescript-eslint/no-base-to-string": { "count": 2 @@ -1527,6 +1537,11 @@ "count": 1 } }, + "packages/perps-controller/tests/defer-eligibility.test.ts": { + "no-restricted-syntax": { + "count": 1 + } + }, "packages/phishing-controller/src/BulkTokenScan.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 2 diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 25505621022..58e15499ef5 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -58,7 +58,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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)). -- **BREAKING:** Subscribe to `RemoteFeatureFlagController:stateChanged` and read initial remote flags from `init()` instead of the constructor. Consumers must delegate the modern event before initializing `PerpsController` ([#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.ts b/packages/perps-controller/src/PerpsController.ts index b6e5b9ac734..fab301073be 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -5,7 +5,6 @@ import type { import { BaseController, ControllerGetStateAction, - ControllerStateChangedEvent, ControllerStateChangeEvent, StateMetadata, } from '@metamask/base-controller'; @@ -787,9 +786,10 @@ const metadata: StateMetadata = { /** * PerpsController events */ -export type PerpsControllerEvents = - | ControllerStateChangeEvent<'PerpsController', PerpsControllerState> - | ControllerStateChangedEvent<'PerpsController', PerpsControllerState>; +export type PerpsControllerEvents = ControllerStateChangeEvent< + 'PerpsController', + PerpsControllerState +>; /** * The action which can be used to retrieve the state of the @@ -1190,11 +1190,36 @@ export class PerpsController extends BaseController< 'fallback', ); + /** + * Immediately read current state to catch any flags already loaded + * This is necessary to avoid race conditions where the RemoteFeatureFlagController fetches flags + * before the PerpsController initializes its RemoteFeatureFlagController subscription. + * + * We still subscribe in case the RemoteFeatureFlagController is not yet populated and updates later. + */ + try { + const currentRemoteFeatureFlagState = this.messenger.call( + 'RemoteFeatureFlagController:getState', + ); + + this.refreshEligibilityOnFeatureFlagChange(currentRemoteFeatureFlagState); + } catch (error) { + // If we can't read the remote feature flags at construction time, we'll rely on: + // 1. The fallback blocked regions already set above + // 2. The subscription to catch updates when RemoteFeatureFlagController is ready + this.#logError( + ensureError(error, 'PerpsController.constructor'), + this.#getErrorContext('constructor', { + operation: 'readRemoteFeatureFlags', + }), + ); + } + // Subscribe for the full controller lifetime — intentionally not stored; // geo-blocking and HIP-3 flag propagation must remain active across // disconnect → reconnect cycles and must never be torn down. this.messenger.subscribe( - 'RemoteFeatureFlagController:stateChanged', + 'RemoteFeatureFlagController:stateChange', this.refreshEligibilityOnFeatureFlagChange.bind(this), ); @@ -2008,27 +2033,6 @@ export class PerpsController extends BaseController< this, MESSENGER_EXPOSED_METHODS, ); - - // Read the current state after all controllers have been constructed so - // flags loaded before this controller's subscription are not missed. - try { - const currentRemoteFeatureFlagState = this.messenger.call( - 'RemoteFeatureFlagController:getState', - ); - this.refreshEligibilityOnFeatureFlagChange( - currentRemoteFeatureFlagState, - ); - } catch (error) { - // Keep the configured fallback; the lifetime subscription can still - // apply a later RemoteFeatureFlagController update. - this.#logError( - ensureError(error, 'PerpsController.init'), - this.#getErrorContext('init', { - operation: 'readRemoteFeatureFlags', - }), - ); - } - this.#handlersRegistered = true; } diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index fff291152d3..635231ec6b0 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -271,6 +271,8 @@ export type { PerpsRemoteFeatureFlagState, PerpsPlatformDependencies, PerpsTerminalMarketService, + PerpsGlobalSnapshotRequest, + PerpsGlobalSnapshotResult, TerminalAssetMetadata, PerpsCacheType, InvalidateCacheParams, diff --git a/packages/perps-controller/src/types/messenger.ts b/packages/perps-controller/src/types/messenger.ts index 5ce2903d9c0..96d3a1b6f63 100644 --- a/packages/perps-controller/src/types/messenger.ts +++ b/packages/perps-controller/src/types/messenger.ts @@ -10,7 +10,6 @@ import type { AuthenticatedUserStorageServiceGetNotificationPreferencesAction, AuthenticatedUserStorageServicePutNotificationPreferencesAction, } from '@metamask/authenticated-user-storage'; -import type { ControllerStateChangedEvent } from '@metamask/base-controller'; import type { GeolocationControllerGetGeolocationAction } from '@metamask/geolocation-controller'; import type { KeyringControllerGetStateAction, @@ -25,15 +24,10 @@ import type { import type { AuthenticationController } from '@metamask/profile-sync-controller'; import type { RemoteFeatureFlagControllerGetStateAction, - RemoteFeatureFlagControllerState, + RemoteFeatureFlagControllerStateChangeEvent, } from '@metamask/remote-feature-flag-controller'; import type { TransactionControllerAddTransactionAction } from '@metamask/transaction-controller'; -type RemoteFeatureFlagControllerStateChangedEvent = ControllerStateChangedEvent< - 'RemoteFeatureFlagController', - RemoteFeatureFlagControllerState ->; - /** * Actions from other controllers that PerpsController is allowed to call. */ @@ -56,7 +50,7 @@ export type PerpsControllerAllowedActions = * Events from other controllers that PerpsController is allowed to subscribe to. */ export type PerpsControllerAllowedEvents = - | RemoteFeatureFlagControllerStateChangedEvent + | RemoteFeatureFlagControllerStateChangeEvent | AccountsControllerSelectedAccountChangeEvent | AccountTreeControllerSelectedAccountGroupChangeEvent; diff --git a/packages/perps-controller/tests/defer-eligibility.test.ts b/packages/perps-controller/tests/defer-eligibility.test.ts index 12722c27821..9e987b305bc 100644 --- a/packages/perps-controller/tests/defer-eligibility.test.ts +++ b/packages/perps-controller/tests/defer-eligibility.test.ts @@ -98,7 +98,7 @@ function getControllerMessenger( 'AuthenticationController:getBearerToken', ], events: [ - 'RemoteFeatureFlagController:stateChanged', + 'RemoteFeatureFlagController:stateChange', 'AccountTreeController:selectedAccountGroupChange', ], messenger, @@ -162,7 +162,7 @@ describe('PerpsController - deferEligibilityCheck', () => { }); rootMessenger.publish( - 'RemoteFeatureFlagController:stateChanged', + 'RemoteFeatureFlagController:stateChange', { ...MOCK_REMOTE_FEATURE_FLAG_STATE }, [], ); @@ -236,7 +236,7 @@ describe('PerpsController - deferEligibilityCheck', () => { expect(callCountAfterStart).toBe(callCountAfterConstruction + 1); rootMessenger.publish( - 'RemoteFeatureFlagController:stateChanged', + 'RemoteFeatureFlagController:stateChange', { ...MOCK_REMOTE_FEATURE_FLAG_STATE }, [], ); @@ -260,7 +260,7 @@ describe('PerpsController - deferEligibilityCheck', () => { controller.stopEligibilityMonitoring(); rootMessenger.publish( - 'RemoteFeatureFlagController:stateChanged', + 'RemoteFeatureFlagController:stateChange', { ...MOCK_REMOTE_FEATURE_FLAG_STATE }, [], ); @@ -313,7 +313,7 @@ describe('PerpsController - deferEligibilityCheck', () => { }); describe('when deferEligibilityCheck is false (default)', () => { - it('defers eligibility processing until init', async () => { + it('triggers eligibility processing during construction', () => { const refreshSpy = jest.spyOn( PerpsController.prototype as unknown as { refreshEligibilityOnFeatureFlagChange: (...args: unknown[]) => void; @@ -321,11 +321,8 @@ describe('PerpsController - deferEligibilityCheck', () => { 'refreshEligibilityOnFeatureFlagChange', ); - const { controller } = buildController({ deferEligibilityCheck: false }); + buildController({ deferEligibilityCheck: false }); - expect(refreshSpy).not.toHaveBeenCalled(); - - await controller.init(); expect(refreshSpy).toHaveBeenCalled(); refreshSpy.mockRestore(); }); diff --git a/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts b/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts index 33b548ba5c9..d781f4b623e 100644 --- a/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts @@ -579,7 +579,7 @@ describe('PerpsController', () => { expect(controller.state.isTestnet).toBe(false); // Default to mainnet }); - it('reads current RemoteFeatureFlagController state during initialization', async () => { + it('reads current RemoteFeatureFlagController state during construction', () => { // Given: A messenger that returns remote feature flags state const testMockCall = jest.fn().mockImplementation((action: string) => { if (action === 'RemoteFeatureFlagController:getState') { @@ -595,13 +595,12 @@ describe('PerpsController', () => { }); const testMessenger = createMockMessenger({ call: testMockCall }); - // When: Controller is constructed and initialized + // When: Controller is constructed const testController = new TestablePerpsController({ messenger: testMessenger, state: getDefaultPerpsControllerState(), infrastructure: createMockInfrastructure(), }); - await testController.init(); // Then: Should have called to get RemoteFeatureFlagController state via messenger expect(testController).toBeDefined(); @@ -610,7 +609,7 @@ describe('PerpsController', () => { ); }); - it('applies remote blocked regions when available during initialization', async () => { + it('applies remote blocked regions when available during construction', () => { // Given: Messenger that returns remote feature flags with blocked regions const testMockCall = jest.fn().mockImplementation((action: string) => { if (action === 'RemoteFeatureFlagController:getState') { @@ -625,7 +624,7 @@ describe('PerpsController', () => { return undefined; }); - // When: Controller is constructed and initialized + // When: Controller is constructed const testController = new TestablePerpsController({ messenger: createMockMessenger({ call: testMockCall }), state: getDefaultPerpsControllerState(), @@ -634,7 +633,6 @@ describe('PerpsController', () => { fallbackBlockedRegions: ['FALLBACK-REGION'], }, }); - await testController.init(); // Then: Should have used remote regions (not fallback) // Verify by checking the internal blockedRegionList @@ -670,7 +668,7 @@ describe('PerpsController', () => { expect(blockedRegionList.list).toEqual(['FALLBACK-US', 'FALLBACK-CA']); }); - it('never downgrade from remote to fallback regions', async () => { + it('never downgrade from remote to fallback regions', () => { // Given: Messenger that returns remote feature flags with blocked regions const testMockCall = jest.fn().mockImplementation((action: string) => { if (action === 'RemoteFeatureFlagController:getState') { @@ -685,7 +683,7 @@ describe('PerpsController', () => { return undefined; }); - // When: Controller is constructed and initialized with both remote and fallback + // When: Controller is constructed with both remote and fallback const testController = new TestablePerpsController({ messenger: createMockMessenger({ call: testMockCall }), state: getDefaultPerpsControllerState(), @@ -694,7 +692,6 @@ describe('PerpsController', () => { fallbackBlockedRegions: ['FALLBACK-US'], }, }); - await testController.init(); // Then: Should use remote (set after fallback) let blockedRegionList = testController.testGetBlockedRegionList(); @@ -710,7 +707,7 @@ describe('PerpsController', () => { expect(blockedRegionList.list).toEqual(['REMOTE-US']); }); - it('continues initialization when RemoteFeatureFlagController state call throws error', async () => { + it('continues initialization when RemoteFeatureFlagController state call throws error', () => { const testInfrastructure = createMockInfrastructure(); const testMockCall = jest.fn().mockImplementation((action: string) => { if (action === 'RemoteFeatureFlagController:getState') { @@ -727,7 +724,6 @@ describe('PerpsController', () => { fallbackBlockedRegions: ['FALLBACK-US', 'FALLBACK-CA'], }, }); - await testController.init(); expect(testController).toBeDefined(); const blockedRegionList = testController.testGetBlockedRegionList(); @@ -742,7 +738,7 @@ describe('PerpsController', () => { context: expect.objectContaining({ name: 'PerpsController', data: expect.objectContaining({ - method: 'init', + method: 'constructor', operation: 'readRemoteFeatureFlags', }), }), From be19d1f2425aa01872e64bbf30a441a86a601d90 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 13 Aug 2026 00:48:25 +0800 Subject: [PATCH 07/11] fix(perps): expose state changed event type --- packages/perps-controller/src/PerpsController.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index fab301073be..40816a6a773 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'; @@ -786,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 From 86aa8c4ee0209acc4724109668501ea005685782 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 13 Aug 2026 01:22:04 +0800 Subject: [PATCH 08/11] fix: align perps snapshot client contracts --- eslint-suppressions.json | 7 +- .../perps-controller/src/PerpsController.ts | 10 +-- .../src/providers/HyperLiquidProvider.ts | 27 +++++++- .../src/services/TerminalMarketService.ts | 17 ++++- .../PerpsController.providers-cache.test.ts | 29 +++++++++ .../HyperLiquidProvider.standalone.test.ts | 40 +++++++++++- .../services/TerminalMarketService.test.ts | 65 ++++++++++++++++++- 7 files changed, 174 insertions(+), 21 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index d63a487fe33..89b575d7868 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/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 40816a6a773..56d53b5a642 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -1574,16 +1574,16 @@ export class PerpsController extends BaseController< ...expectedDexes, ].join('|'); const existingRequest = this.#userSnapshotRequests.get(requestKey); - if (existingRequest) { - return existingRequest; + if (existingRequest?.provider === provider) { + return existingRequest.promise; } const request = this.#fetchAndCacheUserDataSnapshot(context); - this.#userSnapshotRequests.set(requestKey, request); + this.#userSnapshotRequests.set(requestKey, { provider, promise: request }); try { return await request; } finally { - if (this.#userSnapshotRequests.get(requestKey) === request) { + if (this.#userSnapshotRequests.get(requestKey)?.promise === request) { this.#userSnapshotRequests.delete(requestKey); } } @@ -3601,7 +3601,7 @@ export class PerpsController extends BaseController< readonly #userSnapshotRequests = new Map< string, - Promise + { provider: PerpsProvider; promise: Promise } >(); #preloadStateUnsubscribe: (() => void) | null = null; diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 20bfaa6de34..9780706d2af 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -7256,13 +7256,34 @@ export class HyperLiquidProvider implements PerpsProvider { ), ]); + 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) => adaptPositionFromSDK(assetPosition)), + .map((assetPosition) => { + const position = adaptPositionFromSDK(assetPosition); + const { takeProfitOrders, stopLossOrders } = + collectPositionTriggerOrders({ + orders: ordersBySymbol.get(position.symbol) ?? [], + position, + childOrderIds, + }); + return { + ...position, + takeProfitCount: takeProfitOrders.length, + stopLossCount: stopLossOrders.length, + takeProfitOrders, + stopLossOrders, + }; + }), + ); + const positionsBySymbol = new Map( + positions.map((position) => [position.symbol, position]), ); - const orders = openOrdersByDex.flatMap((dexOrders) => - dexOrders.map((order) => adaptOrderFromSDK(order, undefined)), + const orders = rawOrders.map((order) => + adaptOrderFromSDK(order, positionsBySymbol.get(order.coin)), ); const dexAccountStates = clearinghouseStates.map((state) => adaptAccountStateFromSDK(state), diff --git a/packages/perps-controller/src/services/TerminalMarketService.ts b/packages/perps-controller/src/services/TerminalMarketService.ts index a414bd29619..62c9ce85a55 100644 --- a/packages/perps-controller/src/services/TerminalMarketService.ts +++ b/packages/perps-controller/src/services/TerminalMarketService.ts @@ -14,6 +14,7 @@ import { } from '@metamask/superstruct'; import { bytesToHex, sha256, stringToBytes } from '@metamask/utils'; +import { canonicalizeHyperLiquidDexes } from '../constants/hyperLiquidConfig.js'; import { PERPS_CONSTANTS, TERMINAL_API_CONFIG, @@ -36,6 +37,7 @@ 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; @@ -362,8 +364,8 @@ export class TerminalMarketService { !this.#isNonNegativeSafeInteger(snapshot.receivedAt) || !this.#isPositiveSafeInteger(snapshot.maxAgeMs) || snapshot.receivedAt > snapshot.generatedAt || - snapshot.generatedAt > now || - snapshot.receivedAt > now + 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'); } @@ -450,7 +452,10 @@ export class TerminalMarketService { if (new Set(normalized).size !== normalized.length) { throw new Error('Terminal global snapshot contains duplicate DEXes'); } - return normalized.sort(); + if (!normalized.includes('main')) { + throw new Error('Terminal global snapshot requires the main DEX'); + } + return canonicalizeHyperLiquidDexes(normalized); } async #createFingerprint( @@ -525,6 +530,12 @@ export class TerminalMarketService { 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; 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 02151ddbf8b..6a7f2cd2c96 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -2367,6 +2367,35 @@ describe('PerpsController', () => { expect(preloadInfrastructure.diskCache.setItem).toHaveBeenCalledTimes(1); }); + 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) => { 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 82df7c27e12..30c4b3e12f5 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts @@ -797,7 +797,24 @@ describe('HyperLiquidProvider', () => { mockStandaloneInfoClient.clearinghouseState.mockResolvedValue( clearinghouseState, ); - mockStandaloneInfoClient.frontendOpenOrders.mockResolvedValue([]); + 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, @@ -823,7 +840,26 @@ describe('HyperLiquidProvider', () => { 1, ); expect(result.positions).toHaveLength(1); - expect(result.orders).toEqual([]); + expect(result.positions[0]).toEqual( + expect.objectContaining({ + takeProfitCount: 1, + stopLossCount: 0, + 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', diff --git a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts index d3337863d8b..a0341d04a24 100644 --- a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts +++ b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts @@ -583,6 +583,27 @@ describe('TerminalMarketService', () => { ); }); + 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 })], [ @@ -771,8 +792,8 @@ describe('TerminalMarketService', () => { ['empty markets', { markets: [] }], ['incomplete', { complete: false }], ['per-DEX error', { perDexErrors: [{ dex: 'main', error: 'TIMEOUT' }] }], - ['future generatedAt', { generatedAt: SNAPSHOT_NOW + 1 }], - ['future receivedAt', { receivedAt: SNAPSHOT_NOW + 1 }], + ['future generatedAt', { generatedAt: SNAPSHOT_NOW + 5_001 }], + ['future receivedAt', { receivedAt: SNAPSHOT_NOW + 5_001 }], [ 'stale source age', { @@ -795,6 +816,46 @@ describe('TerminalMarketService', () => { ).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()]], [ From 3aafb966df6355bcc7a5b2ae026bb1bbdf3b4a27 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 13 Aug 2026 02:02:45 +0800 Subject: [PATCH 09/11] fix(perps): preserve bare DEX preload identity --- .../perps-controller/src/PerpsController.ts | 6 ++- .../PerpsController.providers-cache.test.ts | 50 +++++++++++++++++-- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 56d53b5a642..b5d6538813e 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -3565,8 +3565,10 @@ export class PerpsController extends BaseController< for (const pattern of this.#hip3AllowlistMarkets) { const colonIndex = pattern.indexOf(':'); if (colonIndex <= 0) { - // A bare token can mean either a market symbol or DEX shorthand. - // That identity is not exact enough for snapshot adoption. + if (/^[a-z][a-z0-9]*$/iu.test(pattern)) { + dexes.add(pattern.toLowerCase()); + continue; + } return undefined; } const dex = pattern.slice(0, colonIndex); 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 6a7f2cd2c96..61e6588f808 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -1056,7 +1056,7 @@ describe('PerpsController', () => { expect(priceCallback).toHaveBeenCalledWith([liveUpdate]); }); - it('disables snapshot adoption for ambiguous bare allowlist identity', async () => { + it('treats a bare allowlist entry as a DEX shorthand', async () => { mockInfrastructure.terminalApi = { ...mockInfrastructure.terminalApi, globalSnapshotUrl: 'https://terminal.test/v2/perpetuals', @@ -1081,9 +1081,8 @@ describe('PerpsController', () => { expect( mockMarketDataServiceInstance.getMarketDataWithPrices.mock.calls[0]?.[0] - .context.globalSnapshot, - ).toBeUndefined(); - expect(mockProvider.getMarketDataWithPrices).toHaveBeenCalledTimes(1); + .context.globalSnapshot?.request.enabledDexes, + ).toStrictEqual(['main', 'xyz']); }); it('keeps main first in an exact static snapshot identity', async () => { @@ -2367,6 +2366,49 @@ describe('PerpsController', () => { 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 From c6c4aa8c964b828e684d926b401f526296207ea9 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 13 Aug 2026 02:05:27 +0800 Subject: [PATCH 10/11] fix(perps): preserve snapshot trigger prices --- .../src/providers/HyperLiquidProvider.ts | 47 ++++++++++++++++--- .../HyperLiquidProvider.standalone.test.ts | 1 + 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 9780706d2af..a8fc41b1a75 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -612,12 +612,39 @@ 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) { + 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 || @@ -647,6 +674,8 @@ function collectPositionTriggerOrders(params: { stopLossOrders: triggerOrders.filter( (order) => order.direction !== 'take_profit', ), + ...(takeProfitPrice && { takeProfitPrice }), + ...(stopLossPrice && { stopLossPrice }), }; } @@ -7264,18 +7293,24 @@ export class HyperLiquidProvider implements PerpsProvider { .filter(({ position }) => position.szi !== '0') .map((assetPosition) => { const position = adaptPositionFromSDK(assetPosition); - const { takeProfitOrders, stopLossOrders } = - collectPositionTriggerOrders({ - orders: ordersBySymbol.get(position.symbol) ?? [], - position, - childOrderIds, - }); + 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 }), }; }), ); 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 30c4b3e12f5..3760daa6c6e 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts @@ -844,6 +844,7 @@ describe('HyperLiquidProvider', () => { expect.objectContaining({ takeProfitCount: 1, stopLossCount: 0, + takeProfitPrice: '55000', takeProfitOrders: [ expect.objectContaining({ orderId: '101', From f66b4a9bb6f1cd79707a839344fb85e16295dcb6 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 13 Aug 2026 06:52:12 +0800 Subject: [PATCH 11/11] fix(perps): address snapshot cache review findings --- .../perps-controller/src/PerpsController.ts | 2 +- .../src/providers/HyperLiquidProvider.ts | 6 +- .../PerpsController.providers-cache.test.ts | 4 +- .../HyperLiquidProvider.standalone.test.ts | 96 +++++++++++++++++++ 4 files changed, 104 insertions(+), 4 deletions(-) diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index b5d6538813e..84d0d20ca75 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -1328,7 +1328,7 @@ export class PerpsController extends BaseController< continue; } if (!this.#isMarketCacheEntryCurrent(providerId, entry, options)) { - return null; + continue; } assembled.push(...clonePerpsMarketData(entry.data)); } diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index a8fc41b1a75..08be87d6e63 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -635,7 +635,11 @@ function collectPositionTriggerOrders(params: { } rawOrder.children?.forEach((childOrder) => { - if (!childOrder.isTrigger || !childOrder.reduceOnly) { + if ( + !childOrder.isTrigger || + !childOrder.reduceOnly || + childOrder.isPositionTpsl !== Boolean(TP_SL_CONFIG.UsePositionBoundTpsl) + ) { return; } if (childOrder.orderType.includes('Take Profit')) { 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 61e6588f808..4e1d258757e 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -3414,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( @@ -3437,7 +3437,7 @@ describe('PerpsController', () => { const result = controller.getCachedMarketDataForActiveProvider(); - expect(result).toBeNull(); + expect(result).toEqual([expect.objectContaining({ symbol: 'MYX' })]); }); }); 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 3760daa6c6e..29e3832fc6b 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts @@ -871,6 +871,102 @@ describe('HyperLiquidProvider', () => { }); }); + 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: [],