diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 016c8cf5d4..94780ffe4b 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -10,6 +10,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/account-tree-controller` from `^7.5.3` to `7.5.4` ([#9429](https://github.com/MetaMask/core/pull/9429)) +- Gate HIP-3 markets to USDC collateral only, following HyperLiquid's USDH sunset (TAT-3304) ([#9530](https://github.com/MetaMask/core/pull/9530)) + - Market discovery (`getMarkets`) now filters a HIP-3 DEX out entirely when its collateral token positively resolves to something other than USDC, so such a market can never be surfaced to trade, even via an allowlist entry naming the DEX. + - Placing an order on a non-USDC-collateral HIP-3 DEX now fails immediately with a new `UNSUPPORTED_COLLATERAL` error code instead of attempting the previous USDC→USDH auto-swap path. + - The collateral check fails open (treats the DEX as USDC-collateral) when the collateral token can't be resolved against spot metadata, so incomplete metadata doesn't spuriously block an otherwise-valid market. + - Removed the now-unreachable USDH auto-swap machinery this replaces (spot USDH/USDC balance lookups, the USDC→USDH spot swap, and the auto-swap orchestration). +- Subscribe to HyperLiquid's `fastAssetCtxs` WebSocket feed for mark/mid price updates, replacing `assetCtxs` as the latency-sensitive price source now that HyperLiquid has slowed the public `assetCtxs` feed cadence ([#9530](https://github.com/MetaMask/core/pull/9530)) + - `assetCtxs` continues to populate funding, open interest, volume, and oracle price data, and no longer writes prices for any symbol `fastAssetCtxs` covers, so a slower `assetCtxs` batch tick can't overwrite a fresher `fastAssetCtxs` price; it remains the price source only for symbols outside `fastAssetCtxs`' coverage (e.g. HIP-3 DEX markets). + - `fastAssetCtxs` is a single global subscription (the HyperLiquid SDK exposes no per-DEX variant): the first message is a full snapshot keyed by coin, and later messages contain diffs for only the coins that changed. Coins without an active price subscriber are ignored. + - Established alongside the global `allMids` subscription, restored together on WebSocket reconnect, and torn down on `clearAll()`. Subscribe attempts use the same 3-attempt/500ms-backoff retry as `assetCtxs` for transient SDK errors. + +### Removed + +- **BREAKING:** Remove the `USDH_CONFIG` export, following HyperLiquid's USDH sunset (TAT-3304) ([#9530](https://github.com/MetaMask/core/pull/9530)) + - This constant configured the now-removed USDC→USDH auto-swap path; consumers importing it should remove the reference, as USDH-collateral HIP-3 DEXs are no longer supported (see the collateral gating change above). + +### Fixed + +- Scope `#notifyAllPriceSubscribers` to the symbols that actually changed, instead of always fanning out to every price subscriber ([#9530](https://github.com/MetaMask/core/pull/9530)) + - The `allMids` handler now tracks a per-symbol `changedSymbols` set (replacing the previous all-or-nothing `hasUpdates` boolean) and only notifies subscribers of symbols whose price changed. + - The `activeAssetCtx` handler now notifies only the subscribers of the symbol it just updated, instead of re-notifying every subscribed symbol on each tick. + - This eliminates redundant reference-equal `PriceUpdate` deliveries to list-view subscribers (e.g. market overview, watchlist) whenever an unrelated symbol's fast-stream price ticks. ## [9.2.1] diff --git a/packages/perps-controller/src/constants/hyperLiquidConfig.ts b/packages/perps-controller/src/constants/hyperLiquidConfig.ts index 667598b354..54fa74dde4 100644 --- a/packages/perps-controller/src/constants/hyperLiquidConfig.ts +++ b/packages/perps-controller/src/constants/hyperLiquidConfig.ts @@ -649,24 +649,6 @@ export const HIP3_MARGIN_CONFIG = { RebalanceMinThreshold: 0.1, } as const; -/** - * Configuration for USDH collateral handling on HIP-3 DEXs - * Per HyperLiquid docs: USDH DEXs pull collateral from spot balance automatically - * - * USDH is HyperLiquid's native stablecoin pegged 1:1 to USDC - */ -export const USDH_CONFIG = { - /** Token name for USDH collateral */ - TokenName: 'USDH', - - /** - * Maximum slippage for USDC→USDH spot swap in basis points - * USDH is pegged 1:1 to USDC so slippage should be minimal - * 10 bps (0.1%) provides small buffer for spread - */ - SwapSlippageBps: 10, -} as const; - // Progress bar constants export const INITIAL_AMOUNT_UI_PROGRESS = 10; export const WITHDRAWAL_PROGRESS_STAGES = [ diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 6e3e15352a..04fd95d974 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -385,7 +385,6 @@ export { TESTNET_HIP3_CONFIG, MAINNET_HIP3_CONFIG, HIP3_MARGIN_CONFIG, - USDH_CONFIG, INITIAL_AMOUNT_UI_PROGRESS, WITHDRAWAL_PROGRESS_STAGES, PROGRESS_BAR_COMPLETION_DELAY_MS, diff --git a/packages/perps-controller/src/perpsErrorCodes.ts b/packages/perps-controller/src/perpsErrorCodes.ts index 9ae2874825..01aa30df0c 100644 --- a/packages/perps-controller/src/perpsErrorCodes.ts +++ b/packages/perps-controller/src/perpsErrorCodes.ts @@ -56,6 +56,8 @@ export const PERPS_ERROR_CODES = { SWAP_FAILED: 'SWAP_FAILED', SPOT_PAIR_NOT_FOUND: 'SPOT_PAIR_NOT_FOUND', PRICE_UNAVAILABLE: 'PRICE_UNAVAILABLE', + // Market/collateral errors + UNSUPPORTED_COLLATERAL: 'UNSUPPORTED_COLLATERAL', // DEX collateral token is not USDC (TAT-3304) // Batch operation errors BATCH_CANCEL_FAILED: 'BATCH_CANCEL_FAILED', BATCH_CLOSE_FAILED: 'BATCH_CLOSE_FAILED', diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index ff84ab9cd0..0491abd071 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -24,10 +24,9 @@ import { HYPERLIQUID_CONFIG, HYPERLIQUID_WITHDRAWAL_MINUTES, REFERRAL_CONFIG, - SPOT_ASSET_ID_OFFSET, TRADING_DEFAULTS, USDC_DECIMALS, - USDH_CONFIG, + USDC_SYMBOL, } from '../constants/hyperLiquidConfig'; import { ORDER_SLIPPAGE_CONFIG, @@ -325,7 +324,7 @@ export class HyperLiquidProvider implements PerpsProvider { // Last known-good market list for stale fallback when every enabled DEX fails in one fetch window. #cachedMarketDataWithPrices: CachedMarketDataSnapshot | null = null; - // Session cache for spot metadata (contains USDC/USDH token info for HIP-3 collateral checks) + // Session cache for spot metadata (contains token info for HIP-3 collateral checks) // Pre-fetched in ensureReadyForTrading() to avoid API failures during order placement #cachedSpotMeta: SpotMetaResponse | null = null; @@ -1153,7 +1152,7 @@ export class HyperLiquidProvider implements PerpsProvider { this.#tradingSetupPromise = (async (): Promise => { // Pre-fetch spotMeta for HIP-3 operations (non-blocking if it fails) - // This ensures token info (USDC/USDH indices) is available during order placement + // This ensures token info (e.g. USDC token index) is available during order placement if (this.#hip3Enabled) { try { await this.#getCachedSpotMeta(); @@ -1647,7 +1646,7 @@ export class HyperLiquidProvider implements PerpsProvider { /** * Fetch spot metadata with session-based caching - * Contains token info (USDC, USDH indices) needed for HIP-3 collateral checks + * Contains token info (e.g. USDC token index) needed for HIP-3 collateral checks * Pre-fetched in ensureReadyForTrading() to ensure availability during order placement * * @returns SpotMetaResponse with tokens and universe data @@ -1843,6 +1842,18 @@ export class HyperLiquidProvider implements PerpsProvider { return []; } + // TAT-3304: Following the USDH sunset, only USDC-collateral HIP-3 DEXs + // are supported for trading. Gate non-USDC-collateral DEXs out of market + // discovery entirely (regardless of skipFilters) so their markets can + // never be surfaced to trade, even via an allowlist entry naming the DEX. + if (dex !== null && !(await this.#isUsdcCollateralDex(dex))) { + this.#deps.debugLogger.log( + 'HyperLiquidProvider: Filtering out non-USDC-collateral HIP-3 DEX from market discovery', + { dex }, + ); + return []; + } + // Transform to MarketInfo format const markets = meta.universe.map((asset) => adaptMarketFromSDK(asset)); @@ -1899,13 +1910,23 @@ export class HyperLiquidProvider implements PerpsProvider { } /** - * Check if a HIP-3 DEX uses USDH as collateral (vs USDC) - * Per HyperLiquid docs: USDH DEXs pull collateral from spot balance automatically + * Check if a HIP-3 DEX uses USDC as its collateral token + * + * TAT-3304: Following the USDH sunset, only USDC-collateral DEXs are + * supported for trading. This gate filters non-USDC-collateral DEXs + * (e.g. the now-sunset USDH) out of market discovery and blocks order + * placement, replacing the previous USDH-specific auto-swap path. + * + * Fails open (treated as USDC) when the collateral token index can't be + * resolved against spot metadata, so an unexpected/incomplete metadata + * shape doesn't spuriously block an otherwise-valid market — only a DEX + * whose collateral token positively resolves to something other than + * USDC is gated. * * @param dexName - The DEX identifier (empty string for main DEX). * @returns A promise that resolves to the boolean result. */ - async #isUsdhCollateralDex(dexName: string): Promise { + async #isUsdcCollateralDex(dexName: string): Promise { const meta = await this.#getCachedMeta({ dexName }); const spotMeta = await this.#getCachedSpotMeta(); @@ -1913,7 +1934,7 @@ export class HyperLiquidProvider implements PerpsProvider { (tok: { index: number }) => tok.index === meta.collateralToken, ); - const isUsdh = collateralToken?.name === USDH_CONFIG.TokenName; + const isUsdc = !collateralToken || collateralToken.name === USDC_SYMBOL; this.#deps.debugLogger.log( 'HyperLiquidProvider: Checked DEX collateral type', @@ -1921,388 +1942,11 @@ export class HyperLiquidProvider implements PerpsProvider { dexName, collateralTokenIndex: meta.collateralToken, collateralTokenName: collateralToken?.name, - isUsdh, - }, - ); - - return isUsdh; - } - - /** - * Get user's USDH balance in spot wallet - * - * @returns A promise that resolves to the numeric result. - */ - async #getSpotUsdhBalance(): Promise { - const infoClient = this.#clientService.getInfoClient(); - const userAddress = await this.#walletService.getUserAddressWithDefault(); - - const spotState = await infoClient.spotClearinghouseState({ - user: userAddress, - }); - - const usdhBalance = spotState.balances.find( - (b: { coin: string }) => b.coin === USDH_CONFIG.TokenName, - ); - - const balance = usdhBalance ? parseFloat(usdhBalance.total) : 0; - - this.#deps.debugLogger.log('HyperLiquidProvider: Spot USDH balance', { - balance, - userAddress, - }); - - return balance; - } - - /** - * Get user's USDC balance in spot wallet - * Required for USDH DEX orders - need USDC in spot to swap to USDH - * - * @returns A promise that resolves to the numeric result. - */ - async #getSpotUsdcBalance(): Promise { - const infoClient = this.#clientService.getInfoClient(); - const userAddress = await this.#walletService.getUserAddressWithDefault(); - - const spotState = await infoClient.spotClearinghouseState({ - user: userAddress, - }); - - const usdcBalance = spotState.balances.find( - (b: { coin: string }) => b.coin === 'USDC', - ); - - const balance = usdcBalance ? parseFloat(usdcBalance.total) : 0; - - this.#deps.debugLogger.log('HyperLiquidProvider: Spot USDC balance', { - balance, - userAddress, - }); - - return balance; - } - - /** - * Transfer USDC from main perps wallet to spot wallet - * Required before swapping USDC→USDH for USDH DEX orders - * - * @param amount - The amount value. - * @returns A promise that resolves to the result. - */ - async #transferUsdcToSpot( - amount: number, - ): Promise<{ success: boolean; error?: string }> { - const exchangeClient = this.#clientService.getExchangeClient(); - const userAddress = await this.#walletService.getUserAddressWithDefault(); - - this.#deps.debugLogger.log( - 'HyperLiquidProvider: Transferring USDC to spot', - { - amount, - userAddress, - }, - ); - - try { - const result = await exchangeClient.sendAsset({ - destination: userAddress, - sourceDex: '', // Main perps DEX (empty string) - destinationDex: 'spot', - token: await this.#getUsdcTokenId(), - amount: amount.toString(), - }); - - if (result.status === 'ok') { - this.#deps.debugLogger.log( - 'HyperLiquidProvider: USDC transferred to spot', - { - amount, - }, - ); - return { success: true }; - } - - return { success: false, error: PERPS_ERROR_CODES.TRANSFER_FAILED }; - } catch (error) { - const errorMsg = ensureError( - error, - 'HyperLiquidProvider.transferUSDCToPerps', - ).message; - this.#deps.debugLogger.log( - 'HyperLiquidProvider: USDC transfer to spot failed', - { - error: errorMsg, - }, - ); - return { success: false, error: errorMsg }; - } - } - - /** - * Swap USDC to USDH on spot market - * Returns the result of the swap including filled size - * - * @param amount - The amount value. - * @returns A promise that resolves to the result. - */ - async #swapUsdcToUsdh( - amount: number, - ): Promise<{ success: boolean; filledSize?: number; error?: string }> { - const spotMeta = await this.#getCachedSpotMeta(); - - // Find USDH and USDC tokens by name - const usdhToken = spotMeta.tokens.find( - (tok: { name: string }) => tok.name === USDH_CONFIG.TokenName, - ); - const usdcToken = spotMeta.tokens.find( - (tok: { name: string }) => tok.name === 'USDC', - ); - - if (!usdhToken || !usdcToken) { - return { - success: false, - error: PERPS_ERROR_CODES.SPOT_PAIR_NOT_FOUND, - }; - } - - // Find USDH/USDC pair by token indices (NOT by name - name is @230) - const usdhUsdcPair = spotMeta.universe.find( - (univ: { tokens: number[] }) => - univ.tokens.includes(usdhToken.index) && - univ.tokens.includes(usdcToken.index), - ); - - if (!usdhUsdcPair) { - return { success: false, error: PERPS_ERROR_CODES.SPOT_PAIR_NOT_FOUND }; - } - - const spotAssetId = SPOT_ASSET_ID_OFFSET + usdhUsdcPair.index; - - this.#deps.debugLogger.log( - 'HyperLiquidProvider: Found USDH/USDC spot pair', - { - pairIndex: usdhUsdcPair.index, - pairName: usdhUsdcPair.name, - spotAssetId, - usdhTokenIndex: usdhToken.index, - usdcTokenIndex: usdcToken.index, - }, - ); - - // Get current mid price - const infoClient = this.#clientService.getInfoClient(); - const allMids = await infoClient.allMids(); - const pairKey = `@${usdhUsdcPair.index}`; - const usdhPrice = parseFloat(allMids[pairKey] || '1'); - - if (usdhPrice === 0) { - return { - success: false, - error: PERPS_ERROR_CODES.PRICE_UNAVAILABLE, - }; - } - - // Calculate order parameters - // USDH is pegged 1:1 to USDC, add small slippage buffer - const slippageMultiplier = - 1 + USDH_CONFIG.SwapSlippageBps / BASIS_POINTS_DIVISOR; - const maxPrice = usdhPrice * slippageMultiplier; - - // Size in USDH = amount / price (since we're buying USDH with USDC) - let sizeInUsdh = amount / usdhPrice; - - // Format size according to HyperLiquid requirements - let formattedSize = sizeInUsdh.toFixed(usdhToken.szDecimals); - - // CRITICAL: Ensure USDC cost meets $10 minimum after rounding - // At price ~0.999995, buying 10.00 USDH costs 9.99995 USDC (under minimum) - // Bump up by one increment if needed to meet minimum - const minSpotOrderValue = TRADING_DEFAULTS.amount.mainnet; - const estimatedCost = parseFloat(formattedSize) * usdhPrice; - if (estimatedCost < minSpotOrderValue) { - const increment = Math.pow(10, -usdhToken.szDecimals); // 0.01 for szDecimals=2 - sizeInUsdh = parseFloat(formattedSize) + increment; - formattedSize = sizeInUsdh.toFixed(usdhToken.szDecimals); - } - - // Format price according to HyperLiquid requirements - const formattedPrice = formatHyperLiquidPrice({ - price: maxPrice, - szDecimals: usdhToken.szDecimals, - }); - - this.#deps.debugLogger.log( - 'HyperLiquidProvider: Placing USDC→USDH swap order', - { - usdcAmount: amount, - usdhPrice, - maxPrice: formattedPrice, - size: formattedSize, - szDecimals: usdhToken.szDecimals, - }, - ); - - try { - const exchangeClient = this.#clientService.getExchangeClient(); - const result = await exchangeClient.order({ - orders: [ - { - a: spotAssetId, - b: true, // Buy USDH - p: formattedPrice, - s: formattedSize, - r: false, // Not reduce-only - t: { limit: { tif: 'Ioc' } }, // Immediate-or-cancel - }, - ], - grouping: 'na', - }); - - if (result.status !== 'ok') { - return { - success: false, - error: PERPS_ERROR_CODES.SWAP_FAILED, - }; - } - - // Check order status - const status = result.response?.data?.statuses?.[0]; - if (isStatusObject(status) && hasProperty(status, 'error')) { - return { success: false, error: String(status.error) }; - } - - // Note: `in` narrows the HyperLiquid SDK discriminated union to the - // branch that has `filled`; `hasProperty` only narrows the key and - // types `status.filled` as `unknown`, which loses access to `.totalSz`. - /* eslint-disable no-restricted-syntax */ - const filledSize = - isStatusObject(status) && 'filled' in status - ? parseFloat(status.filled?.totalSz ?? '0') - : 0; - /* eslint-enable no-restricted-syntax */ - - this.#deps.debugLogger.log( - 'HyperLiquidProvider: USDC→USDH swap completed', - { - success: true, - filledSize, - requestedSize: formattedSize, - }, - ); - - return { success: true, filledSize }; - } catch (error) { - const errorMsg = ensureError( - error, - 'HyperLiquidProvider.swapUSDCToUSDH', - ).message; - this.#deps.debugLogger.log('HyperLiquidProvider: USDC→USDH swap error', { - error: errorMsg, - }); - return { success: false, error: errorMsg }; - } - } - - /** - * Ensure sufficient USDH collateral in spot for HIP-3 DEX order - * If user lacks USDH, auto-swap from USDC - * - * @param dexName - The DEX identifier (empty string for main DEX). - * @param requiredMargin - The required margin amount. - */ - async #ensureUsdhCollateralForOrder( - dexName: string, - requiredMargin: number, - ): Promise { - const spotUsdhBalance = await this.#getSpotUsdhBalance(); - - this.#deps.debugLogger.log( - 'HyperLiquidProvider: Checking USDH collateral', - { - dexName, - requiredMargin, - spotUsdhBalance, - }, - ); - - if (spotUsdhBalance >= requiredMargin) { - this.#deps.debugLogger.log( - 'HyperLiquidProvider: Sufficient USDH in spot', - ); - return; - } - - const shortfall = requiredMargin - spotUsdhBalance; - // HyperLiquid spot has $10 minimum order value - const minSpotOrderValue = TRADING_DEFAULTS.amount.mainnet; - - // If user has some USDH already, we can swap just the shortfall (if >= $10) - // If user has zero USDH, they need at least $10 for first swap - const swapAmount = - spotUsdhBalance > 0 && shortfall >= minSpotOrderValue - ? shortfall - : Math.max(shortfall, minSpotOrderValue); - - // Step 1: Check spot USDC balance - const spotUsdcBalance = await this.#getSpotUsdcBalance(); - - // Calculate total available USDC (spot + what we can transfer from perps) - // For now, check if we have enough in spot first - const totalUsdcNeeded = swapAmount - spotUsdcBalance; - - // Step 2: If insufficient USDC in spot, transfer from main perps - if (spotUsdcBalance < swapAmount) { - const transferAmount = totalUsdcNeeded; - - this.#deps.debugLogger.log( - 'HyperLiquidProvider: Transferring USDC to spot for swap', - { - spotUsdcBalance, - swapAmount, - transferAmount, - }, - ); - - const transferResult = await this.#transferUsdcToSpot(transferAmount); - if (!transferResult.success) { - // Provide user-friendly error for insufficient funds - if (transferResult.error?.includes('Insufficient balance')) { - throw new Error( - `Insufficient USDC balance. Need $${swapAmount.toFixed(2)} for USDH swap but transfer failed. Please deposit more USDC to your HyperLiquid account.`, - ); - } - throw new Error( - `Failed to transfer USDC to spot: ${transferResult.error}`, - ); - } - } - - // Step 3: Swap USDC → USDH - this.#deps.debugLogger.log( - 'HyperLiquidProvider: Swapping USDC→USDH for collateral', - { - shortfall, - swapAmount, - minOrderValue: minSpotOrderValue, + isUsdc, }, ); - const swapResult = await this.#swapUsdcToUsdh(swapAmount); - - if (!swapResult.success) { - throw new Error( - `Failed to acquire USDH collateral for ${dexName}: ${swapResult.error}`, - ); - } - - this.#deps.debugLogger.log( - 'HyperLiquidProvider: USDH collateral acquired', - { - dexName, - filledSize: swapResult.filledSize, - }, - ); + return isUsdc; } /** @@ -3510,32 +3154,23 @@ export class HyperLiquidProvider implements PerpsProvider { const { dexName, symbol, orderPrice, positionSize, leverage, isBuy } = params; - // Check if this DEX uses USDH collateral (vs USDC) - // For USDH DEXs, HyperLiquid automatically pulls from spot balance - const isUsdhDex = await this.#isUsdhCollateralDex(dexName); - if (isUsdhDex) { + // TAT-3304: Only USDC-collateral HIP-3 DEXs are supported for trading. + // Following the USDH sunset, reject orders on any non-USDC-collateral + // DEX here instead of attempting the (now-removed) USDH auto-swap path. + // Market discovery (#fetchMarketsForDex) already filters such DEXs out, + // so this is a defense-in-depth check against stale caches or a + // misconfigured allowlist entry. + const isUsdcDex = await this.#isUsdcCollateralDex(dexName); + if (!isUsdcDex) { this.#deps.debugLogger.log( - 'HyperLiquidProvider: USDH-collateralized DEX detected', + 'HyperLiquidProvider: Rejecting order for non-USDC-collateral DEX', { dexName, symbol, }, ); - // Calculate required margin and ensure USDH is in spot - const requiredMargin = await this.#calculateHip3RequiredMargin({ - symbol, - dexName, - positionSize, - orderPrice, - leverage, - isBuy, - }); - - await this.#ensureUsdhCollateralForOrder(dexName, requiredMargin); - - // Unified Account will pull USDH from spot automatically - return { transferInfo: null }; + throw new Error(PERPS_ERROR_CODES.UNSUPPORTED_COLLATERAL); } if (this.#useUnifiedAccount) { diff --git a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts index 533ce74f1e..eddbd8c683 100644 --- a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts +++ b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts @@ -10,6 +10,7 @@ import type { BboWsEvent, L2BookResponse, AssetCtxsWsEvent, + FastAssetCtxsWsEvent, FrontendOpenOrdersResponse, ClearinghouseStateWsEvent, OpenOrdersWsEvent, @@ -137,6 +138,21 @@ export class HyperLiquidSubscriptionService { #globalAllMidsPromise?: Promise; // Track in-progress subscription + // fastAssetCtxs (TAT-3387): single global feed (no per-DEX param) that owns + // the latency-sensitive mark/mid price path at HyperLiquid's fast (~5s) + // cadence, now that the public assetCtxs feed has been slowed down. + #globalFastAssetCtxsSubscription?: ISubscription; + + #globalFastAssetCtxsPromise?: Promise; // Track in-progress subscription + + // Coins seen in any fastAssetCtxs event (snapshot or diff). Once a coin + // appears here, the per-DEX assetCtxs handler stops writing its price into + // #cachedPriceData, since fastAssetCtxs is the fresher/authoritative source + // for that coin going forward. Cleared on clearAll() and when the + // fastAssetCtxs subscription is re-established after a reconnect, so + // assetCtxs can serve prices again until a fresh snapshot arrives. + readonly #fastAssetCtxsCoins = new Set(); + readonly #globalActiveAssetSubscriptions = new Map(); // Track in-progress activeAssetCtx subscription promises to prevent leaks @@ -1559,6 +1575,7 @@ export class HyperLiquidSubscriptionService { // Ensure global subscriptions are established this.#ensureGlobalAllMidsSubscription(); + this.#ensureGlobalFastAssetCtxsSubscription(); // Extract unique DEXs from requested symbols const dexsNeeded = new Set(); @@ -3029,8 +3046,9 @@ export class HyperLiquidSubscriptionService { } } - // Track if any subscribed symbol was updated - let hasUpdates = false; + // Track which subscribed symbols actually changed price, so + // notification can be scoped to just those symbols + const changedSymbols = new Set(); // Only process symbols that are actually subscribed to for (const symbol in data.mids) { @@ -3050,13 +3068,13 @@ export class HyperLiquidSubscriptionService { // Price changed or new symbol - update cache const priceUpdate = this.#createPriceUpdate(symbol, price); this.#cachedPriceData.set(symbol, priceUpdate); - hasUpdates = true; + changedSymbols.add(symbol); } - // Only notify subscribers if we actually have updates + // Only notify subscribers of symbols whose price actually changed // This prevents unnecessary React re-renders when prices haven't changed - if (hasUpdates) { - this.#notifyAllPriceSubscribers(); + if (changedSymbols.size > 0) { + this.#notifyAllPriceSubscribers(changedSymbols); } }) .then((sub) => { @@ -3085,6 +3103,142 @@ export class HyperLiquidSubscriptionService { }); } + /** + * Ensure global fastAssetCtxs subscription is active (singleton pattern) + * + * TAT-3387: Hyperliquid slowed the public assetCtxs feed cadence and + * introduced fastAssetCtxs to preserve a fast (~5 s) cadence specifically + * for mark/mid price diffs. This subscription owns the #cachedPriceData + * price path going forward; assetCtxs continues to populate + * #marketDataCache (funding/OI/volume/oracle price) unchanged, and remains + * the price source for any symbol fastAssetCtxs does not cover. + * + * The SDK exposes fastAssetCtxs as a single global feed with no `dex` + * parameter (unlike assetCtxs, which is per-DEX). The first message after + * subscribing is a full snapshot keyed by coin; later messages contain + * diffs for only the coins that changed. Coins without an active price + * subscriber are ignored, matching the allMids handler's filtering. + */ + #ensureGlobalFastAssetCtxsSubscription(): void { + // Check both the subscription AND the promise to prevent race conditions + if ( + this.#globalFastAssetCtxsSubscription ?? + this.#globalFastAssetCtxsPromise + ) { + return; + } + + const subscriptionClient = this.#clientService.getSubscriptionClient(); + if (!subscriptionClient) { + return; + } + + const handleFastAssetCtxsUpdate = (data: FastAssetCtxsWsEvent): void => { + this.#cachedPriceData ??= new Map(); + + // Track which subscribed symbols actually changed price, so + // notification can be scoped to just those symbols + const changedSymbols = new Set(); + + for (const coin in data) { + if (!hasProperty(data, coin)) { + continue; + } + + // Mark this coin as covered by fastAssetCtxs regardless of whether + // there's currently a subscriber, so the slower per-DEX assetCtxs + // handler knows to defer to this feed for the coin's price once + // there is one. + this.#fastAssetCtxsCoins.add(coin); + + // Skip coins nobody is subscribed to (snapshot messages include + // every asset on the exchange, most of which have no subscriber) + if (!this.#priceSubscribers.get(coin)?.size) { + continue; + } + + const ctx = data[coin]; + const priceRaw = ctx.midPx ?? ctx.markPx; + if (priceRaw === undefined || priceRaw === null) { + continue; + } + + const price = priceRaw.toString(); + const cachedPrice = this.#cachedPriceData.get(coin); + + // Skip if price hasn't changed + if (cachedPrice?.price === price) { + continue; + } + + const priceUpdate = this.#createPriceUpdate(coin, price); + this.#cachedPriceData.set(coin, priceUpdate); + changedSymbols.add(coin); + } + + // Only notify subscribers of symbols whose price actually changed + if (changedSymbols.size > 0) { + this.#notifyAllPriceSubscribers(changedSymbols); + } + }; + + const subscribeWithRetry = async (): Promise => { + const maxAttempts = 3; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await subscriptionClient.fastAssetCtxs( + handleFastAssetCtxsUpdate, + ); + } catch (error) { + const ensuredError = ensureError( + error, + 'HyperLiquidSubscriptionService.ensureGlobalFastAssetCtxsSubscription', + ); + const isLastAttempt = attempt === maxAttempts; + if (isLastAttempt || !this.#isTransientSdkError(ensuredError)) { + throw ensuredError; + } + + const retryDelayMs = attempt * 500; + this.#deps.debugLogger.log( + 'Transient fastAssetCtxs subscription failure during reconnect, retrying', + { + attempt, + retryDelayMs, + error: ensuredError.message, + }, + ); + await new Promise((_resolve) => setTimeout(_resolve, retryDelayMs)); + } + } + + throw new Error('Failed to establish fastAssetCtxs subscription'); + }; + + // Store the promise immediately to prevent duplicate calls + this.#globalFastAssetCtxsPromise = subscribeWithRetry() + .then((sub) => { + this.#globalFastAssetCtxsSubscription = sub; + this.#deps.debugLogger.log( + 'HyperLiquid: Global fastAssetCtxs subscription established', + ); + return undefined; + }) + .catch((error) => { + // Clear the promise on error so it can be retried + this.#globalFastAssetCtxsPromise = undefined; + + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.ensureGlobalFastAssetCtxsSubscription', + ), + this.#getErrorContext('ensureGlobalFastAssetCtxsSubscription'), + ); + }); + } + /** * Ensure activeAssetCtx subscription for specific symbol (with reference counting) * @@ -3181,12 +3335,14 @@ export class HyperLiquidSubscriptionService { ); } - // Notify subscribers. #notifyAllPriceSubscribers projects the - // fast-stream price (now stored in #marketDataCache) for focused - // (includeMarketData: true) subscribers, while list subscribers + // Notify subscribers of this symbol only. #notifyAllPriceSubscribers + // projects the fast-stream price (now stored in #marketDataCache) for + // focused (includeMarketData: true) subscribers, while list subscribers // continue to receive only the allMids baseline from #cachedPriceData. - // List subscribers are skipped until an allMids tick has arrived. - this.#notifyAllPriceSubscribers(); + // Scoping to this symbol avoids redundant reference-equal allMids + // updates to list subscribers watching other symbols, since their + // allMids baseline hasn't changed on this tick. + this.#notifyAllPriceSubscribers(new Set([symbol])); } }, ) @@ -3496,12 +3652,31 @@ export class HyperLiquidSubscriptionService { this.#marketDataCache.set(asset.name, marketData); - // HIP-3: Extract price from assetCtx and update cached prices + // HIP-3: Extract price from assetCtx and update cached prices. + // For HIP-3 DEXs, meta() returns asset.name already containing the + // DEX prefix (e.g., "xyz:XYZ100"), so use it directly. + const symbol = asset.name; const price = ctx.midPx?.toString() ?? ctx.markPx?.toString(); - if (price) { - // For HIP-3 DEXs, meta() returns asset.name already containing the DEX prefix - // (e.g., "xyz:XYZ100"), so use it directly - const symbol = asset.name; + if (this.#fastAssetCtxsCoins.has(symbol)) { + // fastAssetCtxs (TAT-3387) owns the price string for this coin + // with fresher, ~5s-cadence data, so don't overwrite it with + // this batch's price. Still rebuild the baseline (keeping the + // existing price) so derived fields just refreshed above in + // #marketDataCache (funding, openInterest, volume24h, + // oraclePrice, percentChange24h/isTradable via markPrice) reach + // list subscribers instead of going stale until the next + // fastAssetCtxs/allMids price change. Only rebuild an existing + // baseline to preserve the startup zero-price guard: we never + // want to synthesize a baseline from a '0' / absent allMids + // price. + const existingBaseline = this.#cachedPriceData?.get(symbol); + if (this.#cachedPriceData && existingBaseline) { + this.#cachedPriceData.set( + symbol, + this.#createPriceUpdate(symbol, existingBaseline.price), + ); + } + } else if (price) { const priceUpdate = this.#createPriceUpdate(symbol, price); this.#cachedPriceData ??= new Map(); this.#cachedPriceData.set(symbol, priceUpdate); @@ -3951,14 +4126,26 @@ export class HyperLiquidSubscriptionService { * - When no allMids baseline exists yet but a fresh `activeAssetCtxPrice` is * available, focused callbacks still receive an update so detail screens * stay responsive on first render. + * + * @param changedSymbols - When provided, only subscribers for symbols in + * this set are notified. This avoids redundant reference-equal updates to + * list subscribers whose symbols were untouched by the triggering event + * (e.g. a per-symbol `activeAssetCtx` tick for a different symbol). When + * omitted, all symbols with subscribers are notified (fan-out-all), which + * is the correct behavior for callers whose event isn't scoped to specific + * symbols (e.g. subscription-established replays, per-DEX `assetCtxs`). */ - #notifyAllPriceSubscribers(): void { + #notifyAllPriceSubscribers(changedSymbols?: Set): void { const subscriberUpdates = new Map< (prices: PriceUpdate[]) => void, PriceUpdate[] >(); this.#priceSubscribers.forEach((subscriberSet, symbol) => { + if (changedSymbols && !changedSymbols.has(symbol)) { + return; + } + const allMidsBase = this.#cachedPriceData?.get(symbol); const fastPrice = this.#getFreshActiveAssetCtxPrice(symbol); const now = Date.now(); @@ -4025,6 +4212,14 @@ export class HyperLiquidSubscriptionService { // Re-establish the subscription this.#ensureGlobalAllMidsSubscription(); + + // Re-establish the fastAssetCtxs subscription alongside allMids (TAT-3387). + // Clear fastAssetCtxsCoins so assetCtxs can serve prices in the gap + // until the fresh post-reconnect snapshot re-establishes coverage. + this.#globalFastAssetCtxsSubscription = undefined; + this.#globalFastAssetCtxsPromise = undefined; + this.#fastAssetCtxsCoins.clear(); + this.#ensureGlobalFastAssetCtxsSubscription(); } // Re-establish order fill subscriptions if there are fill subscribers @@ -4299,6 +4494,20 @@ export class HyperLiquidSubscriptionService { this.#globalAllMidsSubscription = undefined; this.#globalAllMidsPromise = undefined; + if (this.#globalFastAssetCtxsSubscription) { + this.#globalFastAssetCtxsSubscription + .unsubscribe() + .catch((error: Error) => { + this.#logErrorUnlessClearing( + ensureError(error, 'HyperLiquidSubscriptionService.clearAll'), + this.#getErrorContext('clearAll.globalFastAssetCtxs'), + ); + }); + } + this.#globalFastAssetCtxsSubscription = undefined; + this.#globalFastAssetCtxsPromise = undefined; + this.#fastAssetCtxsCoins.clear(); + this.#globalActiveAssetSubscriptions.forEach((sub, symbol) => { sub.unsubscribe().catch((error: Error) => { this.#logErrorUnlessClearing( diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.data.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.data.test.ts index abc073ebec..46d116f01c 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.data.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.data.test.ts @@ -538,11 +538,11 @@ describe('HyperLiquidProvider', () => { ).toHaveBeenCalled(); }); - it('does not count USDH-only spot balance in funded-state totals', async () => { + it('does not count non-USDC-only spot balance in funded-state totals', async () => { mockClientService.getInfoClient = jest.fn().mockReturnValue( createMockInfoClient({ spotClearinghouseState: jest.fn().mockResolvedValue({ - balances: [{ coin: 'USDH', hold: '1000', total: '10000' }], + balances: [{ coin: 'DAI', hold: '1000', total: '10000' }], }), }), ); @@ -690,6 +690,104 @@ describe('HyperLiquidProvider', () => { ).toHaveBeenCalled(); }); + it('filters out a HIP-3 DEX from market discovery when its collateral token is not USDC (TAT-3304)', async () => { + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + }); + const xyzMeta = { + universe: [{ name: 'xyz:STOCK1', szDecimals: 2, maxLeverage: 20 }], + collateralToken: 5, + }; + const mockInfoClient = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + meta: jest.fn().mockImplementation((params?: { dex?: string }) => + params?.dex === 'xyz' + ? Promise.resolve(xyzMeta) + : Promise.resolve({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + ), + metaAndAssetCtxs: jest + .fn() + .mockImplementation((params?: { dex?: string }) => + params?.dex === 'xyz' + ? Promise.resolve([xyzMeta, []]) + : Promise.resolve([ + { + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }, + [], + ]), + ), + spotMeta: jest.fn().mockResolvedValue({ + tokens: [ + { name: 'USDC', tokenId: '0xdef456', index: 0 }, + { name: 'USDH', tokenId: '0xabc123', index: 5 }, + ], + universe: [], + }), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + const markets = await hip3Provider.getMarkets({ dex: 'xyz' }); + + expect(markets).toEqual([]); + }); + + it('does not filter a HIP-3 DEX whose collateral token resolves to USDC', async () => { + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + }); + const xyzMeta = { + universe: [{ name: 'xyz:STOCK1', szDecimals: 2, maxLeverage: 20 }], + collateralToken: 0, + }; + const mockInfoClient = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + meta: jest.fn().mockImplementation((params?: { dex?: string }) => + params?.dex === 'xyz' + ? Promise.resolve(xyzMeta) + : Promise.resolve({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + ), + metaAndAssetCtxs: jest + .fn() + .mockImplementation((params?: { dex?: string }) => + params?.dex === 'xyz' + ? Promise.resolve([xyzMeta, []]) + : Promise.resolve([ + { + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }, + [], + ]), + ), + spotMeta: jest.fn().mockResolvedValue({ + tokens: [{ name: 'USDC', tokenId: '0xdef456', index: 0 }], + universe: [], + }), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + + const markets = await hip3Provider.getMarkets({ dex: 'xyz' }); + + expect(markets.length).toBe(1); + expect(markets[0].name).toBe('xyz:STOCK1'); + }); + it('handles data retrieval errors gracefully', async () => { ( mockClientService.getInfoClient().clearinghouseState as jest.Mock diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts index 28e0eea6c7..44f46f0c60 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts @@ -1274,6 +1274,100 @@ describe('HyperLiquidProvider', () => { }), ); }); + + it('rejects placeOrder for a HIP-3 DEX whose collateral token is not USDC (TAT-3304)', async () => { + const hip3Provider = createTestProvider({ + hip3Enabled: true, + allowlistMarkets: ['xyz:*'], + useUnifiedAccount: true, + }); + const mockOrder = jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses: [{ resting: { oid: 123 } }] } }, + }); + const xyzMeta = { + universe: [{ name: 'xyz:STOCK1', szDecimals: 2, maxLeverage: 20 }], + collateralToken: 5, + }; + const xyzAssetCtxs = [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '95', + dayNtlVlm: '100000', + markPx: '100', + midPx: '100', + oraclePx: '100', + }, + ]; + const mockInfoClient = createMockInfoClient({ + perpDexs: jest + .fn() + .mockResolvedValue([null, { name: 'xyz', url: 'https://xyz.com' }]), + meta: jest.fn().mockImplementation((params?: { dex?: string }) => + params?.dex === 'xyz' + ? Promise.resolve(xyzMeta) + : Promise.resolve({ + universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }], + }), + ), + metaAndAssetCtxs: jest + .fn() + .mockImplementation((params?: { dex?: string }) => { + if (params?.dex === 'xyz') { + return Promise.resolve([xyzMeta, xyzAssetCtxs]); + } + + return Promise.resolve([ + { universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }] }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + ], + ]); + }), + spotMeta: jest.fn().mockResolvedValue({ + tokens: [ + { name: 'USDC', tokenId: '0xdef456', index: 0 }, + { name: 'USDH', tokenId: '0xabc123', index: 5 }, + ], + universe: [], + }), + allMids: jest.fn().mockImplementation((params?: { dex?: string }) => { + if (params?.dex === 'xyz') { + return Promise.resolve({ 'xyz:STOCK1': '100' }); + } + + return Promise.resolve({ BTC: '50000' }); + }), + }); + + mockClientService.getInfoClient = jest + .fn() + .mockReturnValue(mockInfoClient); + mockClientService.getExchangeClient = jest + .fn() + .mockReturnValue(createMockExchangeClient({ order: mockOrder })); + + const result = await hip3Provider.placeOrder({ + symbol: 'xyz:STOCK1', + isBuy: true, + size: '10', + orderType: 'market', + currentPrice: 100, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe(PERPS_ERROR_CODES.UNSUPPORTED_COLLATERAL); + expect(mockOrder).not.toHaveBeenCalled(); + }); }); describe('closePosition with TP/SL handling', () => { diff --git a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts index 699b5fa7a0..6c39767211 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts @@ -431,6 +431,9 @@ describe('HyperLiquidSubscriptionService', () => { return Promise.resolve(mockSubscription); }), assetCtxs: jest.fn(() => Promise.resolve(mockSubscription)), + fastAssetCtxs: jest.fn((_callback: any) => + Promise.resolve(mockSubscription), + ), spotState: jest.fn((_params: any, _callback: any) => Promise.resolve(mockSubscription), ), @@ -1593,6 +1596,35 @@ describe('HyperLiquidSubscriptionService', () => { expect(mockSubscriptionClient.allMids).not.toHaveBeenCalled(); }); + it('restores fastAssetCtxs subscription when price subscribers exist (TAT-3387)', async () => { + const callback = jest.fn(); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback, + }); + + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.fastAssetCtxs).toHaveBeenCalledTimes(1); + + // Restore subscriptions (simulates reconnection) + await service.restoreSubscriptions(); + + // Verify fastAssetCtxs subscription was re-established alongside allMids + expect(mockSubscriptionClient.fastAssetCtxs).toHaveBeenCalledTimes(2); + + unsubscribe(); + }); + + it('does not restore fastAssetCtxs subscription when no price subscribers exist', async () => { + // No subscriptions created + + await service.restoreSubscriptions(); + + expect(mockSubscriptionClient.fastAssetCtxs).not.toHaveBeenCalled(); + }); + // TODO: Refactor to test restoreSubscriptions through public disconnect/reconnect API it.skip('restores webData3 subscription when user data subscribers exist', async () => { 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 4b88a23515..f4b8e1ab4a 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.lifecycle.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.lifecycle.test.ts @@ -431,6 +431,9 @@ describe('HyperLiquidSubscriptionService', () => { return Promise.resolve(mockSubscription); }), assetCtxs: jest.fn(() => Promise.resolve(mockSubscription)), + fastAssetCtxs: jest.fn((_callback: any) => + Promise.resolve(mockSubscription), + ), spotState: jest.fn((_params: any, _callback: any) => Promise.resolve(mockSubscription), ), @@ -642,6 +645,51 @@ describe('HyperLiquidSubscriptionService', () => { expect(typeof unsubscribe).toBe('function'); }); + it('retries the fastAssetCtxs subscription on a transient SDK error and succeeds (TAT-3387)', async () => { + const mockUnsubscribe = jest.fn().mockResolvedValue(undefined); + const transientError = new Error( + 'Unknown error while making a WebSocket request', + ); + transientError.name = 'WebSocketRequestError'; + + // First attempt fails with a transient error; second attempt succeeds + mockSubscriptionClient.fastAssetCtxs + .mockRejectedValueOnce(transientError) + .mockResolvedValueOnce({ unsubscribe: mockUnsubscribe }); + + const mockCallback = jest.fn(); + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + }); + + // Let the first attempt fail and the 500ms backoff elapse + await jest.advanceTimersByTimeAsync(500); + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.fastAssetCtxs).toHaveBeenCalledTimes(2); + + unsubscribe(); + }); + + it('does not retry the fastAssetCtxs subscription on a non-transient error', async () => { + mockSubscriptionClient.fastAssetCtxs.mockRejectedValue( + new Error('Non-transient failure'), + ); + + const mockCallback = jest.fn(); + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + // Non-transient errors should not be retried (single attempt only) + expect(mockSubscriptionClient.fastAssetCtxs).toHaveBeenCalledTimes(1); + expect(typeof unsubscribe).toBe('function'); + }); + it('should handle missing subscription client in position subscription', async () => { mockClientService.getSubscriptionClient.mockReturnValue(undefined); diff --git a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.market-data.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.market-data.test.ts index 384462e609..2c3c3c4a8b 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.market-data.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.market-data.test.ts @@ -432,6 +432,9 @@ describe('HyperLiquidSubscriptionService', () => { return Promise.resolve(mockSubscription); }), assetCtxs: jest.fn(() => Promise.resolve(mockSubscription)), + fastAssetCtxs: jest.fn((_callback: any) => + Promise.resolve(mockSubscription), + ), spotState: jest.fn((_params: any, _callback: any) => Promise.resolve(mockSubscription), ), @@ -3100,6 +3103,119 @@ describe('HyperLiquidSubscriptionService', () => { unsubscribe(); }); + + it('does not let a slower assetCtxs batch update overwrite a price already covered by fastAssetCtxs', async () => { + let fastAssetCtxsCallback: ((data: any) => void) | undefined; + let assetCtxsCallback: ((data: any) => void) | undefined; + + service.setDexMetaCache('', { universe: [{ name: 'BTC' }] } as any); + + mockSubscriptionClient.fastAssetCtxs.mockImplementation( + (callback: any) => { + fastAssetCtxsCallback = callback; + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.assetCtxs.mockImplementation( + (_params: any, callback: any) => { + assetCtxsCallback = callback; + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const listCallback = jest.fn(); + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: listCallback, + }); + + await jest.runAllTimersAsync(); + + // fastAssetCtxs establishes the authoritative price for BTC + fastAssetCtxsCallback?.({ BTC: { midPx: '52000' } }); + await jest.runAllTimersAsync(); + + listCallback.mockClear(); + + // A slower assetCtxs batch tick fires for BTC with a different price. + // It should not overwrite the fresher fastAssetCtxs price. + assetCtxsCallback?.({ + ctxs: [ + { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', + dayNtlVlm: '50000000', + oraclePx: '50100', + midPx: '50200', + }, + ], + }); + await jest.runAllTimersAsync(); + + expect(listCallback).not.toHaveBeenCalledWith([ + expect.objectContaining({ symbol: 'BTC', price: '50200' }), + ]); + + unsubscribe(); + }); + + it('lets assetCtxs update the price for a symbol not covered by fastAssetCtxs (e.g. a HIP-3 dex:symbol asset)', async () => { + let assetCtxsCallback: ((data: any) => void) | undefined; + + jest.mocked(parseAssetName).mockImplementation((symbol: string) => ({ + symbol, + dex: symbol === 'xyz:STOCK1' ? 'xyz' : null, + })); + + service.setDexMetaCache('xyz', { + universe: [{ name: 'xyz:STOCK1' }], + } as any); + + mockSubscriptionClient.assetCtxs.mockImplementation( + (_params: any, callback: any) => { + assetCtxsCallback = callback; + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const listCallback = jest.fn(); + const unsubscribe = await service.subscribeToPrices({ + symbols: ['xyz:STOCK1'], + callback: listCallback, + }); + + await jest.runAllTimersAsync(); + + listCallback.mockClear(); + + assetCtxsCallback?.({ + ctxs: [ + { + prevDayPx: '9', + funding: '0.01', + openInterest: '1000', + dayNtlVlm: '5000', + oraclePx: '10', + midPx: '10.5', + }, + ], + }); + await jest.runAllTimersAsync(); + + expect(listCallback).toHaveBeenCalledWith([ + expect.objectContaining({ symbol: 'xyz:STOCK1', price: '10.5' }), + ]); + + unsubscribe(); + }); }); describe('Market tradability (isTradable)', () => { diff --git a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.streams.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.streams.test.ts index 439114ff47..8b59181702 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.streams.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.streams.test.ts @@ -431,6 +431,9 @@ describe('HyperLiquidSubscriptionService', () => { return Promise.resolve(mockSubscription); }), assetCtxs: jest.fn(() => Promise.resolve(mockSubscription)), + fastAssetCtxs: jest.fn((_callback: any) => + Promise.resolve(mockSubscription), + ), spotState: jest.fn((_params: any, _callback: any) => Promise.resolve(mockSubscription), ), @@ -574,6 +577,260 @@ describe('HyperLiquidSubscriptionService', () => { expect(typeof unsubscribe1).toBe('function'); expect(typeof unsubscribe2).toBe('function'); }); + + it('does not notify a list subscriber for symbol A when only symbol B activeAssetCtx fires', async () => { + const listCallback = jest.fn(); + const focusedCallback = jest.fn(); + + // List subscriber watching BTC (no market data -> no activeAssetCtx subscription) + const unsubscribeList = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: listCallback, + includeMarketData: false, + }); + + // Focused subscriber watching ETH (market data -> activeAssetCtx subscription) + const unsubscribeFocused = await service.subscribeToPrices({ + symbols: ['ETH'], + callback: focusedCallback, + includeMarketData: true, + }); + + // Let initial allMids + activeAssetCtx ticks settle + await jest.runAllTimersAsync(); + + listCallback.mockClear(); + focusedCallback.mockClear(); + + // Fire a fresh activeAssetCtx tick for ETH only (simulates the fast-stream + // price cadence for a focused symbol while BTC's allMids baseline is untouched) + const ethCall = mockSubscriptionClient.activeAssetCtx.mock.calls.find( + ([params]: [{ coin: string }]) => params.coin === 'ETH', + ); + expect(ethCall).toBeDefined(); + const ethCallback = ethCall[1]; + + ethCallback({ + coin: 'ETH', + ctx: { + prevDayPx: '2900', + funding: '0.02', + openInterest: '2000000', + dayNtlVlm: '60000000', + oraclePx: '3010', + midPx: '3010', + }, + }); + + expect(focusedCallback).toHaveBeenCalled(); + expect(listCallback).not.toHaveBeenCalled(); + + unsubscribeList(); + unsubscribeFocused(); + }); + + it('only notifies subscribers of symbols whose allMids price actually changed', async () => { + const btcCallback = jest.fn(); + const ethCallback = jest.fn(); + + const unsubscribeBtc = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: btcCallback, + }); + const unsubscribeEth = await service.subscribeToPrices({ + symbols: ['ETH'], + callback: ethCallback, + }); + + // Let the initial allMids snapshot settle + await jest.runAllTimersAsync(); + + btcCallback.mockClear(); + ethCallback.mockClear(); + + // Re-invoke the allMids handler directly with only BTC's price changed + const allMidsCallback = mockSubscriptionClient.allMids.mock.calls[0][0]; + allMidsCallback({ + mids: { + BTC: 51000, // changed + ETH: 3000, // unchanged from initial snapshot + }, + }); + + expect(btcCallback).toHaveBeenCalled(); + expect(ethCallback).not.toHaveBeenCalled(); + + unsubscribeBtc(); + unsubscribeEth(); + }); + + it('establishes a global fastAssetCtxs subscription when subscribing to prices', async () => { + const mockCallback = jest.fn(); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + }); + + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.fastAssetCtxs).toHaveBeenCalledTimes(1); + expect(mockSubscriptionClient.fastAssetCtxs).toHaveBeenCalledWith( + expect.any(Function), + ); + + unsubscribe(); + }); + + it('only creates a single global fastAssetCtxs subscription for multiple subscribers', async () => { + const unsubscribeBtc = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: jest.fn(), + }); + const unsubscribeEth = await service.subscribeToPrices({ + symbols: ['ETH'], + callback: jest.fn(), + }); + + await jest.runAllTimersAsync(); + + expect(mockSubscriptionClient.fastAssetCtxs).toHaveBeenCalledTimes(1); + + unsubscribeBtc(); + unsubscribeEth(); + }); + + it('applies a fastAssetCtxs snapshot to cached price data and notifies subscribers', async () => { + const btcCallback = jest.fn(); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: btcCallback, + }); + + await jest.runAllTimersAsync(); + btcCallback.mockClear(); + + const fastAssetCtxsCallback = + mockSubscriptionClient.fastAssetCtxs.mock.calls[0][0]; + + // First message after subscribing is a full snapshot keyed by coin + fastAssetCtxsCallback({ + BTC: { midPx: '52000' }, + SOL: { midPx: '150' }, // no subscriber for SOL; should be ignored + }); + + expect(btcCallback).toHaveBeenCalledWith([ + expect.objectContaining({ symbol: 'BTC', price: '52000' }), + ]); + + unsubscribe(); + }); + + it('only notifies subscribers of symbols present in a fastAssetCtxs diff message', async () => { + const btcCallback = jest.fn(); + const ethCallback = jest.fn(); + + const unsubscribeBtc = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: btcCallback, + }); + const unsubscribeEth = await service.subscribeToPrices({ + symbols: ['ETH'], + callback: ethCallback, + }); + + await jest.runAllTimersAsync(); + + const fastAssetCtxsCallback = + mockSubscriptionClient.fastAssetCtxs.mock.calls[0][0]; + + // Snapshot establishes a baseline for both symbols + fastAssetCtxsCallback({ + BTC: { midPx: '52000' }, + ETH: { midPx: '3000' }, + }); + + btcCallback.mockClear(); + ethCallback.mockClear(); + + // Later messages are diffs containing only the coins that changed + fastAssetCtxsCallback({ + BTC: { midPx: '52500' }, + }); + + expect(btcCallback).toHaveBeenCalledWith([ + expect.objectContaining({ symbol: 'BTC', price: '52500' }), + ]); + expect(ethCallback).not.toHaveBeenCalled(); + + unsubscribeBtc(); + unsubscribeEth(); + }); + + it('falls back to markPx when midPx is absent in a fastAssetCtxs update', async () => { + const btcCallback = jest.fn(); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: btcCallback, + }); + + await jest.runAllTimersAsync(); + btcCallback.mockClear(); + + const fastAssetCtxsCallback = + mockSubscriptionClient.fastAssetCtxs.mock.calls[0][0]; + + fastAssetCtxsCallback({ + BTC: { markPx: '53000' }, + }); + + expect(btcCallback).toHaveBeenCalledWith([ + expect.objectContaining({ symbol: 'BTC', price: '53000' }), + ]); + + unsubscribe(); + }); + + it('skips a coin with a null midPx and no markPx in a fastAssetCtxs update without throwing', async () => { + const btcCallback = jest.fn(); + const ethCallback = jest.fn(); + + const unsubscribeBtc = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: btcCallback, + }); + const unsubscribeEth = await service.subscribeToPrices({ + symbols: ['ETH'], + callback: ethCallback, + }); + + await jest.runAllTimersAsync(); + btcCallback.mockClear(); + ethCallback.mockClear(); + + const fastAssetCtxsCallback = + mockSubscriptionClient.fastAssetCtxs.mock.calls[0][0]; + + // BTC has a null midPx (and no markPx), which the SDK types allow at + // runtime; ETH is a valid update (with a price change from the allMids + // baseline of 3000) in the same payload. + expect(() => + fastAssetCtxsCallback({ + BTC: { midPx: null }, + ETH: { midPx: '3100' }, + }), + ).not.toThrow(); + + expect(btcCallback).not.toHaveBeenCalled(); + expect(ethCallback).toHaveBeenCalledWith([ + expect.objectContaining({ symbol: 'ETH', price: '3100' }), + ]); + + unsubscribeBtc(); + unsubscribeEth(); + }); }); describe('Position Subscriptions', () => { diff --git a/packages/perps-controller/tests/src/utils/accountUtils.test.ts b/packages/perps-controller/tests/src/utils/accountUtils.test.ts index 79cfbbb5f9..5c7fd83c9f 100644 --- a/packages/perps-controller/tests/src/utils/accountUtils.test.ts +++ b/packages/perps-controller/tests/src/utils/accountUtils.test.ts @@ -239,7 +239,7 @@ describe('spot balance helpers', () => { expect(result).toBe(accountState); }); - it('excludes USDH-only spot balance from funded-state totals', () => { + it('excludes non-USDC-only spot balance from funded-state totals', () => { const accountState: AccountState = { spendableBalance: '0', withdrawableBalance: '0', @@ -251,7 +251,7 @@ describe('spot balance helpers', () => { const result = addSpotBalanceToAccountState(accountState, { balances: [ - { coin: 'USDH', total: '75.25' }, + { coin: 'DAI', total: '75.25' }, { coin: 'HYPE', total: '999' }, ], } as never); @@ -259,7 +259,7 @@ describe('spot balance helpers', () => { expect(result).toBe(accountState); }); - it('adds only the USDC portion when USDC and USDH are both present', () => { + it('adds only the USDC portion when USDC and a non-USDC token are both present', () => { const accountState: AccountState = { spendableBalance: '0', withdrawableBalance: '0', @@ -274,7 +274,7 @@ describe('spot balance helpers', () => { { balances: [ { coin: 'USDC', total: '20' }, - { coin: 'USDH', total: '30' }, + { coin: 'DAI', total: '30' }, { coin: 'HYPE', total: '9999' }, ], } as never,