import { errorCodeFromStatus, fetchWithRetry, isAbortError, PerpsError, type PerpsSDKClient, type ResolvedRetryPolicy, resolveRetryPolicy, type SDKRequestOptions, } from '@lifi/perps-sdk' import { PerpsErrorCode } from '@lifi/perps-types' import { isAddress } from 'viem' import { PROVIDER_KEY } from '../constants.js' const normalizeInfoValue = (value: unknown): unknown => { if (typeof value === 'string') { return isAddress(value, { strict: false }) ? value.toLowerCase() : value } if (Array.isArray(value)) { return value.map(normalizeInfoValue) } if (value !== null && typeof value === 'object') { return Object.fromEntries( Object.entries(value).map(([key, nestedValue]) => [ key, normalizeInfoValue(nestedValue), ]) ) } return value } const HYPERLIQUID_STATUS_ERROR_CODES = { 401: PerpsErrorCode.Unauthorized, 403: PerpsErrorCode.AgentUnauthorized, } as const /** @internal */ export const HYPERLIQUID_RETRY_DEFAULTS: ResolvedRetryPolicy = { enabled: true, maxAttempts: 3, baseDelayMs: 2_000, maxDelayMs: 15_000, respectRetryAfter: true, classify: ({ response }) => { if (response.status === 429) { return 'retry-rate-limit' } if ( response.status === 502 || response.status === 503 || response.status === 504 ) { return 'retry-server' } return 'fail' }, } /** * Transport options for a direct Hyperliquid REST request. `policy` * controls retries, `signal` cancels the request, and `fetchImpl` overrides * the runtime's global `fetch` implementation. * @public */ export interface InfoRequestOptions { signal?: AbortSignal policy?: ResolvedRetryPolicy fetchImpl?: typeof fetch } /** * Resolve the client's retry/fetch config into options for {@link infoRequest}. * * @public */ export const hlInfoOptions = ( client: PerpsSDKClient, options?: SDKRequestOptions ): InfoRequestOptions => ({ signal: options?.signal, policy: resolveRetryPolicy( HYPERLIQUID_RETRY_DEFAULTS, client.config.retry, PROVIDER_KEY ), fetchImpl: client.config.fetch, }) /** * POST a JSON body to a Hyperliquid REST surface and return the parsed body. * * Direct-to-venue: no proxy, no AJV validation, no cache. The caller's type * parameter is trusted; consumers should treat the response shape as * upstream-controlled and normalise into `@lifi/perps-types` shapes before * surfacing. * * Non-2xx responses raise a {@link PerpsError} tagged with the Hyperliquid * provider key, carrying the code the status resolves to: `RateLimitExceeded` * for a 429, `Unauthorized` for a 401, `AgentUnauthorized` for a 403, and * `ThirdPartyError` for every other status. A transport failure and a 2xx body * that is not JSON both raise `ServerError`, so every failure but a caller * abort reaches the caller as a `PerpsError`. An abort rejects untouched. * * @param label - Names the surface in the error message, e.g. `info request`. * @internal */ export async function hlPostJson( url: string, label: string, body: Record, options?: InfoRequestOptions ): Promise { const policy = options?.policy ?? HYPERLIQUID_RETRY_DEFAULTS try { const response = await fetchWithRetry( url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(normalizeInfoValue(body)), }, { policy, fetchImpl: options?.fetchImpl, signal: options?.signal, } ) if (!response.ok) { const err = new PerpsError( errorCodeFromStatus( response.status, PerpsErrorCode.ThirdPartyError, HYPERLIQUID_STATUS_ERROR_CODES ), `Hyperliquid ${label} failed: ${response.status}` ) err.tool = PROVIDER_KEY throw err } return (await response.json()) as T } catch (error) { if (error instanceof PerpsError || isAbortError(error)) { throw error } const err = new PerpsError( PerpsErrorCode.ServerError, error instanceof Error ? error.message : `Hyperliquid ${label} failed` ) err.tool = PROVIDER_KEY throw err } } /** * POST to the Hyperliquid `/info` endpoint and return the parsed JSON body. * See {@link hlPostJson} for the transport and error contract. * * @public */ export function infoRequest( apiUrl: string, body: Record, options?: InfoRequestOptions ): Promise { return hlPostJson(`${apiUrl}/info`, 'info request', body, options) }