src/rest/client.ts
v0.1.0 · 5.9 KB
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>;