Skip to content
markpaper

src/rest/client.ts

v0.1.0 · 5.9 KB

Download file
import { isBase58Pubkey } from '../address/base58.js';

export const REST_URL = 'https://perp-api.phoenix.trade';
export class PhoenixHttpError extends Error {
  override name = 'PhoenixHttpError';
  constructor(
    message: string,
    readonly status?: number,
  ) {
    super(message);
  }
}
export interface RestOptions {
  baseUrl?: string;
  fetch?: typeof globalThis.fetch;
  minGapMs: number;
  tries: number;
  timeoutMs: number;
  retryDelayMs: number;
  retryAfterMaxMs: number;
  retryAfterFallbackMs: number;
  now?: () => number;
  sleep?: (ms: number) => Promise<void>;
}
export interface ExchangeStatus {
  active: boolean;
  runningState: string;
  gated: boolean;
  withdrawalsAvailable: boolean;
}
export function decodeExchangeStatus(payload: unknown): ExchangeStatus {
  if (!payload || typeof payload !== 'object' || Array.isArray(payload))
    throw new PhoenixHttpError('Invalid exchange status');
  const s = payload as Record<string, unknown>;
  if (typeof s.active !== 'boolean' || typeof s.gated !== 'boolean' || typeof s.withdrawalsAvailable !== 'boolean') {
    throw new PhoenixHttpError('Incomplete exchange status');
  }
  return {
    active: s.active,
    gated: s.gated,
    runningState: typeof s.runningState === 'string' ? s.runningState : 'unknown',
    withdrawalsAvailable: s.withdrawalsAvailable,
  };
}
export function decodeMids(payload: unknown): Record<string, string> {
  const arr = (payload as { markets?: unknown } | null)?.markets;
  if (!Array.isArray(arr)) throw new PhoenixHttpError('Missing markets in statistics');
  const out: Record<string, string> = {};
  for (const r of arr as Array<Record<string, unknown>>) {
    const sym = typeof r?.symbol === 'string' ? r.symbol : '';
    const mark = Number(r?.mark_price);
    if (sym && Number.isFinite(mark) && mark > 0) out[sym] = String(mark);
  }
  if (Object.keys(out).length === 0) throw new PhoenixHttpError('No readable mark prices');
  return out;
}
export function createRestClient(opts: RestOptions) {
  for (const k of [
    'minGapMs',
    'tries',
    'timeoutMs',
    'retryDelayMs',
    'retryAfterMaxMs',
    'retryAfterFallbackMs',
  ] as const) {
    if (!Number.isSafeInteger(opts[k]) || opts[k] < 0) throw new TypeError(`Explicit REST policy required: ${k}`);
  }
  if (opts.tries < 1 || opts.timeoutMs < 1) throw new TypeError('tries and timeoutMs must be positive');
  const fetchImpl = opts.fetch ?? globalThis.fetch;
  const now = opts.now ?? Date.now;
  const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
  const baseUrl = (opts.baseUrl ?? REST_URL).replace(/\/+$/, '');
  let nextAt = 0;
  let backoffUntil = 0;
  const retryAfter = (res: Response): number => {
    const raw = res.headers.get('retry-after');
    if (raw === null) return opts.retryAfterFallbackMs;
    const seconds = Number(raw);
    const delay = Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : Date.parse(raw) - now();
    return Number.isFinite(delay) ? Math.min(opts.retryAfterMaxMs, Math.max(0, delay)) : opts.retryAfterFallbackMs;
  };
  async function get<T = unknown>(path: string): Promise<T> {
    if (!path.startsWith('/') || path.startsWith('//')) throw new TypeError('Expected relative API path');
    let last = '';
    for (let i = 0; i < opts.tries; i++) {
      const at = Math.max(now(), nextAt, backoffUntil);
      nextAt = at + opts.minGapMs;
      if (at > now()) await sleep(at - now());
      // Another concurrent request may have established a 429 pause while this one waited.
      if (backoffUntil > now()) await sleep(backoffUntil - now());
      const ctrl = new AbortController();
      const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs);
      try {
        const res = await fetchImpl(`${baseUrl}${path}`, {
          signal: ctrl.signal,
          headers: { accept: 'application/json' },
        });
        if (res.status === 429) {
          backoffUntil = Math.max(backoffUntil, now() + retryAfter(res));
          last = 'HTTP 429';
          continue;
        }
        if (res.status >= 400 && res.status < 500)
          throw new PhoenixHttpError(`HTTP ${res.status} on ${path.split('?')[0]}`, res.status);
        if (!res.ok) last = `HTTP ${res.status}`;
        else return (await res.json()) as T;
      } catch (e) {
        if (e instanceof PhoenixHttpError && e.status !== undefined && e.status < 500) throw e;
        last = (e as Error).message;
      } finally {
        clearTimeout(timer);
      }
      if (i < opts.tries - 1) await sleep(opts.retryDelayMs * (i + 1));
    }
    throw new PhoenixHttpError(`Read failed after ${opts.tries} attempts: ${last}`);
  }
  const key = (s: string) => {
    if (!isBase58Pubkey(s)) throw new TypeError('Invalid canonical Solana public key');
    return encodeURIComponent(s);
  };
  const allMids = async () => decodeMids(await get('/v1/markets/stats/latest'));
  const orderbook = (symbol: string) => get(`/v1/view/orderbook/${encodeURIComponent(symbol)}`);
  return {
    get,
    allMids,
    orderbook,
    markets: () => get('/v1/view/exchange/markets'),
    exchange: () => get('/v1/view/exchange'),
    exchangeStatus: async () => decodeExchangeStatus(await get('/v1/view/exchange/status')),
    statsLatest: () => get('/v1/markets/stats/latest'),
    traderState: (authority: string, traderPdaIndex = 0) =>
      get(`/v1/trader/state/${key(authority)}?traderPdaIndex=${traderPdaIndex}`),
    traderView: (traderPda: string) => get(`/v1/view/trader/${key(traderPda)}`),
    async mid(symbol: string): Promise<number | null> {
      try {
        const m = Number(((await orderbook(symbol)) as { mid?: unknown })?.mid);
        if (Number.isFinite(m) && m > 0) return m;
      } catch {}
      try {
        const m = Number((await allMids())[symbol]);
        return Number.isFinite(m) && m > 0 ? m : null;
      } catch {
        return null;
      }
    },
  };
}
export type RestClient = ReturnType<typeof createRestClient>;
All files