import { fetchWithRetry, PerpsError, type ResolvedRetryPolicy, } from '@lifi/perps-sdk' import { PerpsErrorCode } from '@lifi/perps-types' import { LIGHTER_INVALID_AUTH_CODE, LIGHTER_SUCCESS_CODES, } from '../constants.js' /** @internal */ export type ApiParams = Record /** * Lighter signals errors on two channels: a non-2xx HTTP status, or an HTTP 200 * carrying an error `code` in the JSON body. Returns the body error `code` when * the body advertises one that is not a success code, else `undefined`. */ const lighterBodyErrorCode = (data: unknown): number | undefined => { const code = (data as { code?: number } | undefined)?.code if (code === undefined || LIGHTER_SUCCESS_CODES.has(code)) { return undefined } return code } /** * Auth-gated read whose token Lighter rejected. Distinct from a generic * {@link PerpsError} so callers can evict the stored read-only token and retry * with a freshly-created one. Lighter signals this on either channel: an HTTP * 401/403, or an HTTP 200 carrying an error `code` in the body. * @internal */ export class LighterAuthRejectedError extends PerpsError {} const isLighterAuthRejection = (status: number, data: unknown): boolean => status === 401 || status === 403 || lighterBodyErrorCode(data) === LIGHTER_INVALID_AUTH_CODE /** @internal */ export const LIGHTER_RETRY_DEFAULTS: ResolvedRetryPolicy = { enabled: true, maxAttempts: 2, baseDelayMs: 10_000, maxDelayMs: 60_000, respectRetryAfter: true, classify: ({ response }) => { if (response.status === 429 || response.status === 405) { return 'retry-rate-limit' } if ( response.status === 502 || response.status === 503 || response.status === 504 ) { return 'retry-server' } return 'fail' }, } /** @internal */ export interface LighterApiClientOptions { signal?: AbortSignal policy?: ResolvedRetryPolicy fetchImpl?: typeof fetch } /** * HTTP boundary against Lighter's REST API. * * Browser-direct by design: no LI.FI backend hop, no caching shim — caller * supplies the REST base URL, the path, and any query params, and we return * the parsed JSON body. Lighter advertises CORS headers on every public * endpoint so a vanilla `fetch` from the widget works. * * Auth-gated endpoints (accountLimits, accountActiveOrders, deposit/history, * withdraw/history, positionFunding, liquidations, transfer/history) take the * Lighter read-only token as the `auth` query parameter — NOT as an * `Authorization` header. This matches Lighter's OpenAPI spec and lets the * same call work browser-direct and from server-side proxies. * * Lighter signals rate limiting via 429 OR 405 (documented behaviour) with a * documented 60s firewall cooldown. The default {@link ResolvedRetryPolicy} * waits long enough to avoid hammering through the cooldown. * @public */ export class LighterApiClient { private readonly baseUrl: string private readonly signal: AbortSignal | undefined private readonly policy: ResolvedRetryPolicy private readonly fetchImpl: typeof fetch | undefined constructor(baseUrl: string, options?: LighterApiClientOptions) { this.baseUrl = baseUrl.replace(/\/$/, '') this.signal = options?.signal this.policy = options?.policy ?? LIGHTER_RETRY_DEFAULTS this.fetchImpl = options?.fetchImpl } private buildUrl(path: string, params?: ApiParams): string { const url = `${this.baseUrl}${path}` if (!params || Object.keys(params).length === 0) { return url } const qs = Object.entries(params) .map( ([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}` ) .join('&') return `${url}?${qs}` } async get(path: string, params?: ApiParams): Promise { return this.getChecked(path, params) } /** * Auth-gated GET. The token is appended as the `auth` query parameter (per * Lighter's OpenAPI spec); the `Authorization` header is intentionally NOT * used — Lighter rejects it. */ async getAuthed( path: string, authToken: string, params: ApiParams = {} ): Promise { const { status, data } = await this.getWithStatus(path, { ...params, auth: authToken, }) if (isLighterAuthRejection(status, data)) { throw new LighterAuthRejectedError( PerpsErrorCode.ThirdPartyError, `Lighter rejected the auth token for ${path}` ) } this.assertOk(path, status, data) return data as T } /** * GET that surfaces the raw `{status, body}` pair without throwing on * non-2xx — used for endpoints (account lookup by L1 address) where the * caller distinguishes specific Lighter error codes from generic failures. */ async getWithStatus( path: string, params?: ApiParams ): Promise<{ status: number; data: T }> { const url = this.buildUrl(path, params) const response = await fetchWithRetry( url, {}, { policy: this.policy, fetchImpl: this.fetchImpl, signal: this.signal } ) const data = (await response.json().catch(() => undefined)) as T return { status: response.status, data } } /** * Form-encoded POST to a Lighter mutation endpoint. Single-shot — never * retried, since these are money/state writes whose outcome is unknown on a * transport failure. Surfaces the raw `{status, body}` pair so the caller can * map Lighter's per-endpoint business-rule `code` to a domain error verbatim. */ async postForm( path: string, params: ApiParams ): Promise<{ status: number; data: T }> { const body = new URLSearchParams() for (const [k, v] of Object.entries(params)) { body.set(k, String(v)) } const fetchImpl = this.fetchImpl ?? fetch const response = await fetchImpl(`${this.baseUrl}${path}`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: body.toString(), signal: this.signal, }) const data = (await response.json().catch(() => undefined)) as T return { status: response.status, data } } private async getChecked(path: string, params?: ApiParams): Promise { const { status, data } = await this.getWithStatus(path, params) this.assertOk(path, status, data) return data as T } /** * Post-parse validation shared by every checked read: rejects a non-2xx HTTP * status and a 200 body carrying a non-success `code`. Callers that surface a * distinct auth-rejection error must run that check before this one. */ private assertOk(path: string, status: number, data: unknown): void { if (status < 200 || status >= 300) { throw new PerpsError( PerpsErrorCode.ThirdPartyError, `Lighter API request failed: ${status} — ${JSON.stringify(data).slice(0, 200)}` ) } const errorCode = lighterBodyErrorCode(data) if (errorCode !== undefined) { throw new PerpsError( PerpsErrorCode.ThirdPartyError, `Lighter API error for ${path}: code ${errorCode} — ${JSON.stringify(data).slice(0, 200)}` ) } } }