diff --git a/packages/perps-controller/src/constants/hyperLiquidConfig.ts b/packages/perps-controller/src/constants/hyperLiquidConfig.ts index facad5e621..199bfcf28a 100644 --- a/packages/perps-controller/src/constants/hyperLiquidConfig.ts +++ b/packages/perps-controller/src/constants/hyperLiquidConfig.ts @@ -642,23 +642,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; diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index c82cc7d4e1..4e9e532d9d 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -377,7 +377,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/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 92380b16f4..f88f91f4e0 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -23,10 +23,8 @@ import { HYPERLIQUID_ASSET_NAMES, HYPERLIQUID_WITHDRAWAL_MINUTES, REFERRAL_CONFIG, - SPOT_ASSET_ID_OFFSET, TRADING_DEFAULTS, USDC_DECIMALS, - USDH_CONFIG, } from '../constants/hyperLiquidConfig'; import { ORDER_SLIPPAGE_CONFIG, @@ -116,7 +114,6 @@ import type { MetaResponse, PerpsAssetCtx, FrontendOrder, - SpotMetaResponse, } from '../types/hyperliquid-types'; import { HL_ABSTRACTION_WIRE, @@ -325,10 +322,6 @@ 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) - // Pre-fetched in ensureReadyForTrading() to avoid API failures during order placement - #cachedSpotMeta: SpotMetaResponse | null = null; - // Unified DEX discovery cache — single source of truth for all perpDexs() derivatives. // Replaces three separate caches to eliminate desync bugs by construction. // All writes go through #dexDiscoveryCache.update(); readers use .state. @@ -1145,20 +1138,6 @@ 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 - if (this.#hip3Enabled) { - try { - await this.#getCachedSpotMeta(); - } catch (error) { - this.#deps.debugLogger.log( - '[ensureReadyForTrading] spotMeta pre-fetch failed, will retry when needed', - error, - ); - // Don't throw - spotMeta will be fetched on-demand if needed - } - } - // Set up builder fee approval try { await this.#ensureBuilderFeeApproval(); @@ -1638,37 +1617,6 @@ export class HyperLiquidProvider implements PerpsProvider { throw new Error(`Asset ID not found for ${symbol}`); } - /** - * Fetch spot metadata with session-based caching - * Contains token info (USDC, USDH indices) needed for HIP-3 collateral checks - * Pre-fetched in ensureReadyForTrading() to ensure availability during order placement - * - * @returns SpotMetaResponse with tokens and universe data - */ - async #getCachedSpotMeta(): Promise { - if (this.#cachedSpotMeta) { - this.#deps.debugLogger.log('[getCachedSpotMeta] Using cached spotMeta', { - tokensCount: this.#cachedSpotMeta.tokens.length, - universeCount: this.#cachedSpotMeta.universe.length, - }); - return this.#cachedSpotMeta; - } - - const infoClient = this.#clientService.getInfoClient(); - const spotMeta = await infoClient.spotMeta(); - - this.#cachedSpotMeta = spotMeta; - this.#deps.debugLogger.log( - '[getCachedSpotMeta] Fetched and cached spotMeta', - { - tokensCount: spotMeta.tokens.length, - universeCount: spotMeta.universe.length, - }, - ); - - return spotMeta; - } - /** * Fetch perpDexs data with TTL-based caching * Returns deployerFeeScale info needed for dynamic fee calculation @@ -1876,9 +1824,12 @@ export class HyperLiquidProvider implements PerpsProvider { return this.#cachedUsdcTokenId; } - const spotMeta = await this.#getCachedSpotMeta(); + const infoClient = this.#clientService.getInfoClient(); + const spotMeta = await infoClient.spotMeta(); - const usdcToken = spotMeta.tokens.find((tok) => tok.name === 'USDC'); + const usdcToken = spotMeta.tokens.find( + (tok: { name: string }) => tok.name === 'USDC', + ); if (!usdcToken) { throw new Error('USDC token not found in spot metadata'); } @@ -1891,413 +1842,6 @@ export class HyperLiquidProvider implements PerpsProvider { return this.#cachedUsdcTokenId; } - /** - * Check if a HIP-3 DEX uses USDH as collateral (vs USDC) - * Per HyperLiquid docs: USDH DEXs pull collateral from spot balance automatically - * - * @param dexName - The DEX identifier (empty string for main DEX). - * @returns A promise that resolves to the boolean result. - */ - async #isUsdhCollateralDex(dexName: string): Promise { - const meta = await this.#getCachedMeta({ dexName }); - const spotMeta = await this.#getCachedSpotMeta(); - - const collateralToken = spotMeta.tokens.find( - (tok: { index: number }) => tok.index === meta.collateralToken, - ); - - const isUsdh = collateralToken?.name === USDH_CONFIG.TokenName; - - this.#deps.debugLogger.log( - 'HyperLiquidProvider: Checked DEX collateral type', - { - 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, - }, - ); - - 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, - }, - ); - } - /** * Build asset ID mapping from market metadata * Fetches metadata for feature-flag-enabled DEXs and builds a unified mapping @@ -3503,34 +3047,6 @@ 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) { - this.#deps.debugLogger.log( - 'HyperLiquidProvider: USDH-collateralized DEX detected', - { - 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 }; - } - if (this.#useUnifiedAccount) { this.#deps.debugLogger.log('Using Unified Account (no manual transfer)', { symbol, @@ -8240,7 +7756,6 @@ export class HyperLiquidProvider implements PerpsProvider { // NOTE: UnifiedAccountCache is global and NOT cleared on disconnect // to prevent repeated signing requests across reconnections this.#cachedMetaByDex.clear(); - this.#cachedSpotMeta = null; this.#dexDiscoveryCache.reset(); this.#dexDiscoveryComplete = false; 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..913c8db323 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 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: 'HYPE', hold: '1000', total: '10000' }], }), }), ); diff --git a/packages/perps-controller/tests/src/utils/accountUtils.test.ts b/packages/perps-controller/tests/src/utils/accountUtils.test.ts index 79cfbbb5f9..60992a65d1 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 spot balances from funded-state totals', () => { const accountState: AccountState = { spendableBalance: '0', withdrawableBalance: '0', @@ -251,15 +251,15 @@ describe('spot balance helpers', () => { const result = addSpotBalanceToAccountState(accountState, { balances: [ - { coin: 'USDH', total: '75.25' }, - { coin: 'HYPE', total: '999' }, + { coin: 'HYPE', total: '75.25' }, + { coin: 'PURR', total: '999' }, ], } as never); expect(result).toBe(accountState); }); - it('adds only the USDC portion when USDC and USDH are both present', () => { + it('adds only the USDC portion when multiple coins are present', () => { const accountState: AccountState = { spendableBalance: '0', withdrawableBalance: '0', @@ -274,8 +274,8 @@ describe('spot balance helpers', () => { { balances: [ { coin: 'USDC', total: '20' }, - { coin: 'USDH', total: '30' }, - { coin: 'HYPE', total: '9999' }, + { coin: 'HYPE', total: '30' }, + { coin: 'PURR', total: '9999' }, ], } as never, { foldIntoCollateral: true },