scripts/smoke.ts
v0.3.0 · 6.4 KB
// 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);
});