SPB Git

Repository Public

record.mts 6.7 KB 94 lines typescript

1// Records real answers of the PUBLIC, READ-ONLY Hyperliquid endpoints (POST /info and websocket subscriptions) as fixtures.2// Nothing is signed, nothing is sent to /exchange. Account data is public on-chain state of an address picked from recent trades.3// Run from the repository root:  node packages/derivatives/test/fixtures/hyperliquid/record.mts4import { writeFileSync } from 'node:fs';56const API = { mainnet: 'https://api.hyperliquid.xyz', testnet: 'https://api.hyperliquid-testnet.xyz' } as const;7const WS = { mainnet: 'wss://api.hyperliquid.xyz/ws', testnet: 'wss://api.hyperliquid-testnet.xyz/ws' } as const;8type Net = keyof typeof API;9const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));10const out = (name: string, v: unknown) => { writeFileSync(new URL(`./${name}.json`, import.meta.url), `${JSON.stringify(v, null, 1)}\n`); console.log(`  ${name}.json`); };1112async function info(network: Net, request: Record<string, unknown>): Promise<{ status: number; body: any }> {13  const r = await fetch(`${API[network]}/info`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(request) });14  const text = await r.text();15  let body: unknown;16  try { body = JSON.parse(text); } catch { body = text; }17  await sleep(250);18  return { status: r.status, body };19}20async function record(network: Net, name: string, request: Record<string, unknown>, trim?: (body: any) => unknown) {21  const { status, body } = await info(network, request);22  out(`${network}-${name}`, { recordedAt: new Date().toISOString(), network, request, status, body: trim && status === 200 ? trim(body) : body });23  return body;24}2526/** an address with open orders (trigger orders if possible), positions and fills — public state, found through recent trades */27async function pickAccount(network: Net): Promise<string> {28  const users = new Set<string>();29  for (const coin of ['BTC', 'ETH', 'SOL']) for (const t of (await info(network, { type: 'recentTrades', coin })).body) for (const u of t.users) users.add(u);30  let best: { user: string; score: number } | null = null;31  for (const user of [...users].slice(0, 30)) {32    const orders = (await info(network, { type: 'frontendOpenOrders', user })).body, state = (await info(network, { type: 'clearinghouseState', user })).body;33    if (!Array.isArray(orders) || !state?.assetPositions) continue;34    const score = (orders.some((o: any) => o.isTrigger) ? 8 : 0) + (orders.some((o: any) => o.cloid) ? 2 : 0) + (orders.some((o: any) => !o.isTrigger) ? 3 : 0)35      + (state.assetPositions.length ? 3 : 0) + (state.assetPositions.some((p: any) => p.position.leverage.type === 'isolated') ? 2 : 0) + (state.assetPositions.some((p: any) => p.position.leverage.type === 'cross') ? 2 : 0)36      + (orders.length > 0 && orders.length <= 40 ? 2 : 0);37    if (!best || score > best.score) best = { user, score };38    if (score >= 20) break;39  }40  if (!best) throw new Error('no account found');41  return best.user;42}4344async function recordWs(network: Net, user: string) {45  const messages: unknown[] = [], perChannel = new Map<string, number>();46  const ws = new WebSocket(WS[network]);47  await new Promise<void>((resolve, reject) => { ws.onopen = () => resolve(); ws.onerror = () => reject(new Error('websocket error')); });48  ws.onmessage = (ev) => {49    const m = JSON.parse(String(ev.data)), n = perChannel.get(m.channel) ?? 0;50    perChannel.set(m.channel, n + 1);51    if (n >= (m.channel === 'subscriptionResponse' ? 20 : 3)) return;52    if (m.channel === 'trades' && Array.isArray(m.data)) m.data = m.data.slice(0, 12);53    if (m.channel === 'userFills' && m.data?.fills) m.data.fills = m.data.fills.slice(0, 12);54    if (m.channel === 'allMids' && m.data?.mids) m.data.mids = Object.fromEntries(Object.entries(m.data.mids).filter(([k]) => !/^[#@+]/.test(k)).slice(0, 40));55    if (m.channel === 'orderUpdates' && Array.isArray(m.data)) m.data = m.data.slice(0, 12);56    messages.push(m);57  };58  const subs = [{ type: 'l2Book', coin: 'BTC' }, { type: 'trades', coin: 'BTC' }, { type: 'activeAssetCtx', coin: 'BTC' }, { type: 'allMids' },59    { type: 'userFills', user }, { type: 'orderUpdates', user }, { type: 'userEvents', user }];60  for (const subscription of subs) ws.send(JSON.stringify({ method: 'subscribe', subscription }));61  ws.send(JSON.stringify({ method: 'ping' }));62  // answered by an `error` channel message. NOT sent here: subscribing to an unknown coin, which makes the server drop the63  // connection without a message (close 1006, observed 2026-11-10) — the adapter checks symbols before subscribing.64  ws.send('not json');65  ws.send(JSON.stringify({ method: 'subscribe', subscription: { type: 'l2Book', coin: 'BTC' } }));66  await sleep(9000);67  ws.close();68  out(`${network}-ws`, { recordedAt: new Date().toISOString(), network, subscriptions: subs, counts: Object.fromEntries(perChannel), messages });69}7071for (const network of ['mainnet', 'testnet'] as Net[]) {72  console.log(network);73  const now = Date.now();74  await record(network, 'meta', { type: 'meta' }, (b) => ({ ...b, marginTables: b.marginTables?.slice(0, 2) }));75  await record(network, 'metaAndAssetCtxs', { type: 'metaAndAssetCtxs' }, (b) => [{ ...b[0], marginTables: b[0].marginTables?.slice(0, 2) }, b[1]]);76  await record(network, 'l2Book-BTC', { type: 'l2Book', coin: 'BTC' });77  await record(network, 'recentTrades-BTC', { type: 'recentTrades', coin: 'BTC' });78  await record(network, 'candleSnapshot-BTC-1h', { type: 'candleSnapshot', req: { coin: 'BTC', interval: '1h', startTime: now - 24 * 3_600_000, endTime: now } });79  await record(network, 'fundingHistory-BTC', { type: 'fundingHistory', coin: 'BTC', startTime: now - 12 * 3_600_000 });80  await record(network, 'error-unknown-coin', { type: 'l2Book', coin: 'NOT-A-COIN' });81  await record(network, 'error-bad-type', { type: 'notAnInfoType' });82  const user = await pickAccount(network);83  console.log(`  account ${user}`);84  await record(network, 'clearinghouseState', { type: 'clearinghouseState', user });85  await record(network, 'frontendOpenOrders', { type: 'frontendOpenOrders', user }, (b) => b.slice(0, 40));86  await record(network, 'historicalOrders', { type: 'historicalOrders', user }, (b) => b.slice(0, 80));87  await record(network, 'userFills', { type: 'userFills', user }, (b) => b.slice(0, 40));88  await record(network, 'userFillsByTime', { type: 'userFillsByTime', user, startTime: now - 24 * 3_600_000 }, (b) => b.slice(0, 40));89  await record(network, 'userFunding', { type: 'userFunding', user, startTime: now - 24 * 3_600_000 }, (b) => b.slice(0, 40));90  await record(network, 'clearinghouseState-empty', { type: 'clearinghouseState', user: '0x00000000000000000000000000000000000000ff' });91  await record(network, 'error-bad-user', { type: 'clearinghouseState', user: 'not-an-address' });92  await recordWs(network, user);93}94
Files main
Menu

Theme