Skip to content
markpaper

scripts/smoke.ts

v0.3.0 · 6.4 KB

Download file
// Read-only live smoke test against PUBLIC mainnet data. No addresses, no keys, no orders.
// Run: npm run smoke
//
// Exercises the package entry the way a user would: throttled /info client, asset registry
// (perp, spot, HIP-3), price/size formatting on real szDecimals, candles and one short WS subscription.

import WebSocket from 'ws';
import {
  NODE_WS_SOCKET_OPTIONS,
  createAssetRegistry,
  createInfoClient,
  createWeightLimiter,
  createWsClient,
  fetchCandles,
  fetchServerTimeMs,
  formatPrice,
  formatSize,
  isValidPrice,
  isValidSize,
  meetsMinNotional,
  sizeFromNotional,
  type AssetInfo,
  type WebSocketConstructorLike,
} from '../src/index.js';

let failures = 0;
function requiredPositiveEnv(name: string): number {
  const value = Number(process.env[name]);
  if (!Number.isFinite(value) || value <= 0) throw new Error(`${name} must be a positive number`);
  return value;
}

function check(label: string, ok: boolean, detail = ''): void {
  if (!ok) failures++;
  console.log(`${ok ? 'ok  ' : 'FAIL'} ${label}${detail ? ` - ${detail}` : ''}`);
}

async function main(): Promise<void> {
  const limiter = createWeightLimiter({
    weightPerMinute: requiredPositiveEnv('HL_WEIGHT_PER_MINUTE'),
    burstCapacity: requiredPositiveEnv('HL_BURST_CAPACITY'),
    maxConcurrent: requiredPositiveEnv('HL_MAX_CONCURRENT'),
  });
  const info = createInfoClient({ network: 'mainnet', limiter, timeoutMs: 15_000 });
  check('info client url', info.url.startsWith('https://api.hyperliquid.xyz'), info.url);

  // --- Asset registry --------------------------------------------------------------------------
  const events: string[] = [];
  const registry = createAssetRegistry(info, { onEvent: (e) => events.push(e.type) });

  const btc = await registry.resolve('BTC');
  check('perp BTC resolved', btc.market === 'perp' && btc.dex === '' && btc.assetId === btc.index, `assetId=${btc.assetId} szDecimals=${btc.szDecimals}`);

  const spot = await registry.resolve('PURR/USDC');
  check('spot PURR/USDC resolved', spot.market === 'spot' && spot.assetId >= 10_000, `assetId=${spot.assetId}`);

  const dexes = await registry.listDexes();
  check('perpDexs listed', dexes.length > 0, dexes.map((d) => d.name || '(main)').slice(0, 8).join(','));

  let hip3: AssetInfo | undefined;
  if (dexes.some((d) => d.name === 'xyz')) {
    const xyz = await registry.listAssets('xyz');
    hip3 = xyz.find((a) => !a.isDelisted);
    check('HIP-3 xyz assets', hip3 !== undefined && hip3.coin.startsWith('xyz:') && hip3.assetId >= 100_000, hip3 ? `${hip3.coin} assetId=${hip3.assetId}` : 'none');
  } else {
    check('HIP-3 dex xyz present', false, 'xyz not in perpDexs');
  }

  const lookup = await registry.lookup('NOT_A_REAL_COIN_ZZZ');
  check('unlisted coin is unlisted, not unknown', lookup.status === 'unlisted', lookup.status);

  // --- Formatting on real szDecimals -----------------------------------------------------------
  const mids = await info<Record<string, string>>({ type: 'allMids' });
  for (const asset of [btc, hip3].filter((a): a is AssetInfo => a !== undefined)) {
    const midRaw = asset.dex ? (await info<Record<string, string>>({ type: 'allMids', dex: asset.dex }))[asset.coin] : mids[asset.coin];
    if (!midRaw) {
      check(`${asset.coin} mid available`, false);
      continue;
    }
    const grid = { szDecimals: asset.szDecimals, market: asset.market };
    const px = formatPrice(Number(midRaw) * 0.97, { ...grid, mode: 'passive', side: 'buy' });
    const sz = sizeFromNotional(12, px, asset.szDecimals, 'ceil');
    check(`${asset.coin} price on grid`, isValidPrice(px, grid), `mid=${midRaw} px=${px}`);
    check(`${asset.coin} size on lot`, isValidSize(sz, asset.szDecimals) && formatSize(sz, asset.szDecimals) === sz, `sz=${sz}`);
    check(`${asset.coin} notional >= $10`, meetsMinNotional(px, sz), `${px} x ${sz}`);
  }
  const spotMid = mids[`@${spot.index}`] ?? mids[spot.coin];
  if (spotMid) {
    const grid = { szDecimals: spot.szDecimals, market: 'spot' as const };
    const px = formatPrice(spotMid, grid);
    check('spot price on grid', isValidPrice(px, grid), `mid=${spotMid} px=${px}`);
  }

  // --- Candles ---------------------------------------------------------------------------------
  const serverTimeMs = await fetchServerTimeMs(info, 'BTC');
  check('server time sane', Math.abs(serverTimeMs - Date.now()) < 5 * 60_000, new Date(serverTimeMs).toISOString());
  const candles = await fetchCandles(info, {
    coin: 'BTC',
    interval: '1h',
    startTime: serverTimeMs - 48 * 3_600_000,
    serverTimeMs,
    maxPages: 2,
  });
  const ordered = candles.candles.every((c, i, arr) => i === 0 || c.t > arr[i - 1]!.t);
  check('BTC 1h candles', candles.candles.length >= 40 && candles.candles.length <= 49 && ordered, `bars=${candles.candles.length} pages=${candles.pages} complete=${candles.complete} droppedUnclosed=${candles.droppedUnclosed}`);
  if (hip3) {
    const hc = await fetchCandles(info, { coin: hip3.coin, interval: '1h', startTime: serverTimeMs - 6 * 3_600_000, serverTimeMs });
    check(`${hip3.coin} candles (prefixed coin, no dex)`, hc.candles.length > 0, `bars=${hc.candles.length}`);
  }

  // --- WebSocket -------------------------------------------------------------------------------
  const client = createWsClient({
    network: 'mainnet',
    WebSocket: WebSocket as unknown as WebSocketConstructorLike,
    socketOptions: NODE_WS_SOCKET_OPTIONS,
    reconnect: false,
    name: 'smoke',
  });
  const wsErrors: string[] = [];
  client.onError((e) => wsErrors.push(String((e as { message?: string }).message ?? e)));
  let frames = 0;
  let trades = 0;
  const unsubscribe = client.subscribe({ type: 'trades', coin: 'BTC' }, (data) => {
    frames++;
    trades += Array.isArray(data) ? data.length : 0;
  });
  await new Promise((r) => setTimeout(r, 5_000));
  const stats = client.stats();
  unsubscribe();
  client.close();
  check('WS trades BTC', frames > 0 && stats.pendingAcks === 0 && wsErrors.length === 0, `frames=${frames} trades=${trades} state=${stats.state} errors=${wsErrors.join('; ')}`);

  const ls = limiter.stats();
  console.log(`limiter: ${JSON.stringify(ls)}`);
  console.log(`registry events: ${[...new Set(events)].join(',')}`);
}

main()
  .catch((err) => {
    failures++;
    console.error('FAIL smoke crashed:', err);
  })
  .finally(() => {
    console.log(failures === 0 ? 'SMOKE PASSED' : `SMOKE FAILED (${failures})`);
    process.exit(failures === 0 ? 0 : 1);
  });
All files