Skip to content
markpaper

src/assets/parse.ts

v0.3.0 · 16.6 KB

Download file
// Pure parsers and invariant checks for `meta`, `metaAndAssetCtxs`, `perpDexs` and `spotMeta` responses.
// Everything here is network-free; the registry composes these checks.

import { isSaneName, isValidDexName, parseCoin, qualifyCoin } from './names.js';
import type { SpotTokenInfo } from './types.js';

/** `szDecimals` bound: HL perp sizes currently use 0..8 decimals. */
export const MAX_PERP_SZ_DECIMALS = 8;
/** Defensive `maxLeverage` bound (a sanity bound, not an HL contract). */
export const MAX_LEVERAGE_BOUND = 1000;
/**
 * Universe length bound, the same for main and builder dexes. Main must stay below 10000: a main index of
 * 10000 or more would collide with the spot asset-id range.
 */
export const MAX_UNIVERSE_LENGTH = 10_000;
/** Spot pair index bound: `10000 + index` must stay below the HIP-3 range (100000). */
export const MAX_SPOT_PAIR_INDEX = 90_000;

const MAX_ISSUES = 20;

/** Thrown when a response violates the expected shape or invariants. `issues` lists the first problems found. */
export class MetaValidationError extends Error {
  readonly issues: readonly string[];
  constructor(what: string, issues: readonly string[]) {
    super(`${what}: ${issues.slice(0, 5).join('; ')}${issues.length > 5 ? ` (+${issues.length - 5} more)` : ''}`);
    this.name = 'MetaValidationError';
    this.issues = issues;
  }
}

/** One validated perp universe element. `coin` is dex-qualified (`xyz:TSLA`). */
export interface PerpAssetMeta {
  readonly coin: string;
  /** Position in `universe` — this is the asset index. */
  readonly index: number;
  readonly szDecimals: number;
  readonly maxLeverage: number;
  readonly onlyIsolated: boolean;
  readonly isDelisted: boolean;
}

function toFiniteNumber(v: unknown): number | null {
  if (typeof v === 'number') return Number.isFinite(v) ? v : null;
  if (typeof v === 'string' && v.trim() !== '') {
    const n = Number(v);
    return Number.isFinite(n) ? n : null;
  }
  return null;
}

function dexLabel(dex: string): string {
  return dex ? `meta(dex=${dex})` : 'meta';
}

/**
 * Isolated-only flag.
 * - main dex: `onlyIsolated === true`;
 * - HIP-3: `onlyIsolated !== false` — almost all xyz pairs are isolated-only and positions there are reported as
 *   isolated, so a missing field is treated as isolated (sending `isCross: true` gets rejected).
 * - `marginMode` (`strictIsolated` / `noCross`) is the SDK-documented successor of the deprecated flag. It is
 *   not verified live and is only allowed to make a market MORE restrictive.
 */
function isolatedFlag(dex: string, u: Record<string, unknown>): boolean {
  if (u.marginMode === 'strictIsolated' || u.marginMode === 'noCross') return true;
  return dex === '' ? u.onlyIsolated === true : u.onlyIsolated !== false;
}

/**
 * Parses and validates `meta` (optionally for a HIP-3 dex).
 *
 * One bad element invalidates the WHOLE universe: skipping it would shift the index (= asset id) of every
 * following element, and an order for one market would land on another. Checks: `name` non-empty, at most
 * 128 chars, no control characters, belongs to `dex`, unique; `szDecimals` integer 0..8; `maxLeverage` 1..1000;
 * `onlyIsolated` / `isDelisted` boolean when present. HIP-3 names are normalized idempotently (bare or prefixed).
 *
 * @throws MetaValidationError
 */
export function parsePerpUniverse(meta: unknown, dex: string): PerpAssetMeta[] {
  const what = dexLabel(dex);
  if (dex !== '' && !isValidDexName(dex)) throw new MetaValidationError(what, [`invalid dex name ${JSON.stringify(dex)}`]);
  const universe = meta && typeof meta === 'object' ? (meta as { universe?: unknown }).universe : undefined;
  if (!Array.isArray(universe)) throw new MetaValidationError(what, ['universe is not an array']);
  if (universe.length > MAX_UNIVERSE_LENGTH) {
    throw new MetaValidationError(what, [`universe length ${universe.length} exceeds ${MAX_UNIVERSE_LENGTH}`]);
  }

  const issues: string[] = [];
  const seen = new Set<string>();
  const out: PerpAssetMeta[] = [];
  for (let i = 0; i < universe.length && issues.length < MAX_ISSUES; i++) {
    const raw: unknown = universe[i];
    const fail = (msg: string) => issues.push(`[${i}] ${msg}`);
    if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
      fail('element is not an object');
      continue;
    }
    const u = raw as Record<string, unknown>;
    if (!isSaneName(u.name)) {
      fail('invalid name');
      continue;
    }
    const coin = qualifyCoin(dex, u.name);
    const parsed = parseCoin(coin);
    if (!parsed || parsed.market !== 'perp' || parsed.dex !== dex) {
      fail(`name ${JSON.stringify(u.name)} is not a perp of dex '${dex}'`);
      continue;
    }
    if (seen.has(coin)) {
      fail(`duplicate name ${coin}`);
      continue;
    }
    seen.add(coin);
    const sz = toFiniteNumber(u.szDecimals);
    if (sz === null || !Number.isSafeInteger(sz) || sz < 0 || sz > MAX_PERP_SZ_DECIMALS) {
      fail(`${coin}: szDecimals ${JSON.stringify(u.szDecimals)} not an integer in 0..${MAX_PERP_SZ_DECIMALS}`);
      continue;
    }
    const lev = toFiniteNumber(u.maxLeverage);
    if (lev === null || lev < 1 || lev > MAX_LEVERAGE_BOUND) {
      fail(`${coin}: maxLeverage ${JSON.stringify(u.maxLeverage)} not in 1..${MAX_LEVERAGE_BOUND}`);
      continue;
    }
    if (u.onlyIsolated !== undefined && typeof u.onlyIsolated !== 'boolean') {
      fail(`${coin}: onlyIsolated is not a boolean`);
      continue;
    }
    if (u.isDelisted !== undefined && typeof u.isDelisted !== 'boolean') {
      fail(`${coin}: isDelisted is not a boolean`);
      continue;
    }
    out.push(
      Object.freeze({
        coin,
        index: i,
        szDecimals: sz,
        maxLeverage: lev,
        onlyIsolated: isolatedFlag(dex, u),
        isDelisted: u.isDelisted === true,
      }),
    );
  }
  if (issues.length > 0) throw new MetaValidationError(what, issues);
  return out;
}

/**
 * Parses `metaAndAssetCtxs` (`[meta, assetCtxs]`) and enforces `universe.length === assetCtxs.length`.
 * @throws MetaValidationError
 */
export function parseMetaAndAssetCtxs(raw: unknown, dex: string): PerpAssetMeta[] {
  const what = dex ? `metaAndAssetCtxs(dex=${dex})` : 'metaAndAssetCtxs';
  if (!Array.isArray(raw) || raw.length < 2) throw new MetaValidationError(what, ['response is not a [meta, assetCtxs] tuple']);
  const assets = parsePerpUniverse(raw[0], dex);
  const ctxs: unknown = raw[1];
  if (!Array.isArray(ctxs)) throw new MetaValidationError(what, ['assetCtxs is not an array']);
  if (ctxs.length !== assets.length) {
    throw new MetaValidationError(what, [`universe length ${assets.length} != assetCtxs length ${ctxs.length}`]);
  }
  return assets;
}

/**
 * Double-read check: `meta` and `metaAndAssetCtxs` fetched in parallel must agree element by element on
 * `name`, `szDecimals`, `maxLeverage`, `onlyIsolated`. A truncated-but-valid 200 response would otherwise shift
 * ids with nothing to compare against after a cold start.
 *
 * @returns list of mismatches (empty = consistent).
 */
export function crossCheckUniverses(a: readonly PerpAssetMeta[], b: readonly PerpAssetMeta[]): string[] {
  const issues: string[] = [];
  if (a.length !== b.length) issues.push(`universe length ${a.length} != ${b.length}`);
  const n = Math.min(a.length, b.length);
  for (let i = 0; i < n && issues.length < MAX_ISSUES; i++) {
    const x = a[i]!;
    const y = b[i]!;
    if (x.coin !== y.coin) issues.push(`[${i}] name ${x.coin} != ${y.coin}`);
    else if (x.szDecimals !== y.szDecimals) issues.push(`[${i}] ${x.coin} szDecimals ${x.szDecimals} != ${y.szDecimals}`);
    else if (x.maxLeverage !== y.maxLeverage) issues.push(`[${i}] ${x.coin} maxLeverage ${x.maxLeverage} != ${y.maxLeverage}`);
    else if (x.onlyIsolated !== y.onlyIsolated) issues.push(`[${i}] ${x.coin} onlyIsolated mismatch`);
  }
  return issues;
}

/**
 * Completeness of a HIP-3 universe against the dex registry (`perpDexs[i].assetToStreamingOiCap`).
 *
 * Requires INCLUSION, not equality. On a new listing HL can update `meta` before the registry (seen on xyz on
 * 2026-08-22: meta was one asset ahead of the registry); an equality check then rejects the whole dex
 * universe, which stops trading on that dex and blocks every restart until the registry catches up.
 * - `missing` (in registry, not in meta) → truncated response or delisting: reject the universe.
 * - `extra` (in meta, not in registry) → new listing: informational only.
 */
export function checkRegistryCoverage(
  universeCoins: Iterable<string>,
  registryCoins: Iterable<string>,
): { missing: string[]; extra: string[] } {
  const uni = new Set(universeCoins);
  const reg = new Set(registryCoins);
  const missing = [...reg].filter((c) => !uni.has(c)).sort();
  const extra = [...uni].filter((c) => !reg.has(c)).sort();
  return { missing, extra };
}

/**
 * Append-only invariant for refreshes: every previously verified asset must still exist with the same index and
 * `szDecimals`. For an executor assets may only be added, never changed; on violation keep the last verified map.
 *
 * @returns list of violations (empty = the update is acceptable).
 */
export function checkAppendOnly(
  prev: readonly { coin: string; index: number; szDecimals: number }[],
  next: readonly { coin: string; index: number; szDecimals: number }[],
): string[] {
  const byCoin = new Map(next.map((a) => [a.coin, a] as const));
  const issues: string[] = [];
  for (const p of prev) {
    const n = byCoin.get(p.coin);
    if (!n) issues.push(`${p.coin} disappeared`);
    else if (n.index !== p.index) issues.push(`${p.coin} index ${p.index} -> ${n.index}`);
    else if (n.szDecimals !== p.szDecimals) issues.push(`${p.coin} szDecimals ${p.szDecimals} -> ${n.szDecimals}`);
    if (issues.length >= MAX_ISSUES) break;
  }
  return issues;
}

/** One builder dex from `perpDexs`. */
export interface PerpDexInfo {
  readonly name: string;
  /** Array position — the `perpDexIndex` of the asset-id formula. */
  readonly index: number;
  /** Dex-qualified coins from `assetToStreamingOiCap` (may be empty for a new / not-yet-live dex). */
  readonly registry: readonly string[];
}

export interface PerpDexTopology {
  readonly dexes: readonly PerpDexInfo[];
  /** First occurrence per name. Names in `duplicates` must not be used. */
  readonly byName: ReadonlyMap<string, PerpDexInfo>;
  readonly byIndex: ReadonlyMap<number, PerpDexInfo>;
  /** Names that occur more than once: unsafe topology, fail closed. */
  readonly duplicates: ReadonlySet<string>;
}

/**
 * Parses `perpDexs`: `[null, { name, assetToStreamingOiCap, … }, …]`.
 * - element 0 is the main dex and must be `null`; the array has at least 2 elements;
 * - `null` gaps inside are part of the schema and preserve indices; entries without a valid string `name` are skipped;
 * - registry caps may be strings or numbers and are ignored; malformed registry rows are skipped.
 *
 * @throws MetaValidationError on a structurally invalid response.
 */
export function parsePerpDexs(raw: unknown): PerpDexTopology {
  if (!Array.isArray(raw)) throw new MetaValidationError('perpDexs', ['response is not an array']);
  if (raw.length < 2) throw new MetaValidationError('perpDexs', [`expected at least 2 elements, got ${raw.length}`]);
  if (raw[0] !== null) throw new MetaValidationError('perpDexs', ['element 0 (main dex) is not null']);

  const dexes: PerpDexInfo[] = [];
  const byName = new Map<string, PerpDexInfo>();
  const byIndex = new Map<number, PerpDexInfo>();
  const duplicates = new Set<string>();
  for (let i = 1; i < raw.length; i++) {
    const d: unknown = raw[i];
    if (!d || typeof d !== 'object') continue;
    const rec = d as { name?: unknown; assetToStreamingOiCap?: unknown };
    if (typeof rec.name !== 'string' || !isValidDexName(rec.name)) continue;
    const name = rec.name;
    const registry: string[] = [];
    if (Array.isArray(rec.assetToStreamingOiCap)) {
      for (const row of rec.assetToStreamingOiCap as unknown[]) {
        const asset = Array.isArray(row) ? (row[0] as unknown) : undefined;
        if (!isSaneName(asset)) continue;
        const coin = qualifyCoin(name, asset);
        const p = parseCoin(coin);
        if (p && p.market === 'perp' && p.dex === name) registry.push(coin);
      }
    }
    const info: PerpDexInfo = Object.freeze({ name, index: i, registry: Object.freeze(registry) });
    dexes.push(info);
    byIndex.set(i, info);
    if (byName.has(name)) duplicates.add(name);
    else byName.set(name, info);
  }
  return { dexes: Object.freeze(dexes), byName, byIndex, duplicates };
}

/** One spot pair from `spotMeta.universe`. */
export interface SpotPairMeta {
  /** Universe name: `PURR/USDC` for canonical pairs, `@<index>` otherwise. */
  readonly coin: string;
  readonly index: number;
  readonly base: SpotTokenInfo;
  readonly quote: SpotTokenInfo;
  readonly isCanonical: boolean;
}

export interface ParsedSpotMeta {
  readonly pairs: readonly SpotPairMeta[];
  readonly tokens: readonly SpotTokenInfo[];
  /** Elements skipped as invalid (spot elements carry an explicit `index`, so skipping does not shift ids). */
  readonly skipped: readonly string[];
}

/**
 * Parses `spotMeta` (`{ tokens: [...], universe: [{ tokens: [base, quote], name, index, isCanonical }] }`).
 *
 * @experimental The spotMeta shape is taken from the SDK schema; the knowledge base only verified pair names
 * (`@index`, `BASE/QUOTE`), asset id `10000 + index` and the USDC token id. Unlike perps, invalid elements are
 * skipped individually because ids come from the explicit `index` field; duplicate indices or names still
 * invalidate the whole response.
 *
 * @throws MetaValidationError
 */
export function parseSpotMeta(raw: unknown): ParsedSpotMeta {
  const obj = raw && typeof raw === 'object' ? (raw as { tokens?: unknown; universe?: unknown }) : null;
  if (!obj || !Array.isArray(obj.tokens) || !Array.isArray(obj.universe)) {
    throw new MetaValidationError('spotMeta', ['tokens/universe are not arrays']);
  }
  const skipped: string[] = [];
  const tokens: SpotTokenInfo[] = [];
  const tokenByIndex = new Map<number, SpotTokenInfo>();
  for (const [i, t] of (obj.tokens as unknown[]).entries()) {
    const r = t && typeof t === 'object' ? (t as Record<string, unknown>) : null;
    const name = r?.name;
    const index = r?.index;
    const tokenId = r?.tokenId;
    const sz = r?.szDecimals;
    const wei = r?.weiDecimals;
    if (
      !r ||
      !isSaneName(name) ||
      /[:/]/.test(name) ||
      typeof index !== 'number' ||
      !Number.isSafeInteger(index) ||
      index < 0 ||
      typeof tokenId !== 'string' ||
      !/^0x[0-9a-fA-F]{32}$/.test(tokenId) ||
      typeof sz !== 'number' ||
      !Number.isSafeInteger(sz) ||
      sz < 0 ||
      sz > 18 ||
      typeof wei !== 'number' ||
      !Number.isSafeInteger(wei) ||
      wei < 0 ||
      wei > 36
    ) {
      skipped.push(`tokens[${i}]`);
      continue;
    }
    if (tokenByIndex.has(index)) throw new MetaValidationError('spotMeta', [`duplicate token index ${index}`]);
    const info: SpotTokenInfo = Object.freeze({
      name,
      index,
      tokenId,
      szDecimals: sz,
      weiDecimals: wei,
      isCanonical: r.isCanonical === true,
      wireId: `${name}:${tokenId}`,
    });
    tokenByIndex.set(index, info);
    tokens.push(info);
  }

  const pairs: SpotPairMeta[] = [];
  const seenIndex = new Set<number>();
  const seenName = new Set<string>();
  for (const [i, u] of (obj.universe as unknown[]).entries()) {
    const r = u && typeof u === 'object' ? (u as Record<string, unknown>) : null;
    const index = r?.index;
    const name = r?.name;
    const pair = r?.tokens;
    if (!r || typeof index !== 'number' || !Number.isSafeInteger(index) || index < 0 || index >= MAX_SPOT_PAIR_INDEX) {
      skipped.push(`universe[${i}]`);
      continue;
    }
    if (seenIndex.has(index)) throw new MetaValidationError('spotMeta', [`duplicate pair index ${index}`]);
    seenIndex.add(index);
    const parsed = isSaneName(name) ? parseCoin(name) : null;
    const base = Array.isArray(pair) && typeof pair[0] === 'number' ? tokenByIndex.get(pair[0]) : undefined;
    const quote = Array.isArray(pair) && typeof pair[1] === 'number' ? tokenByIndex.get(pair[1]) : undefined;
    if (
      !parsed ||
      parsed.market !== 'spot' ||
      (parsed.form === 'index' && parsed.index !== index) ||
      !base ||
      !quote ||
      (pair as unknown[]).length !== 2
    ) {
      skipped.push(`universe[${i}]`);
      continue;
    }
    if (seenName.has(parsed.coin)) throw new MetaValidationError('spotMeta', [`duplicate pair name ${parsed.coin}`]);
    seenName.add(parsed.coin);
    pairs.push(Object.freeze({ coin: parsed.coin, index, base, quote, isCanonical: r.isCanonical === true }));
  }
  return { pairs: Object.freeze(pairs), tokens: Object.freeze(tokens), skipped: Object.freeze(skipped) };
}
All files