Skip to content
markpaper

src/assets/registry.ts

v0.3.0 · 31.8 KB

Download file
// Process-wide asset registry: meta per dex + perpDexs + spotMeta behind one cache with single-flight,
// stale-on-error and a failure cooldown.

import type { InfoCallOptions, InfoRequest, InfoRequester } from '../transport/types.js';
import { decodeAssetId, hip3AssetId, perpAssetId, spotAssetId } from './assetId.js';
import { isValidDexName, parseCoin, type ParsedCoin } from './names.js';
import {
  checkAppendOnly,
  checkRegistryCoverage,
  crossCheckUniverses,
  MetaValidationError,
  parseMetaAndAssetCtxs,
  parsePerpDexs,
  parsePerpUniverse,
  parseSpotMeta,
  type PerpAssetMeta,
  type PerpDexInfo,
  type PerpDexTopology,
} from './parse.js';
import type {
  AssetInfo,
  AssetLookup,
  AssetRegistryEvent,
  SpotTokenInfo,
  UnknownReason,
  UnlistedReason,
} from './types.js';

/** Default meta TTL. Meta changes only on listings/delistings; 5 min bounds how late a new listing shows up. */
export const DEFAULT_META_TTL_MS = 5 * 60_000;
/** Default pause after a failed load before the endpoint is hit again (stale data is served meanwhile). */
export const DEFAULT_FAIL_COOLDOWN_MS = 30_000;
/** `meta`, `metaAndAssetCtxs`, `perpDexs`, `spotMeta` are heavy /info requests. */
export const META_REQUEST_WEIGHT = 20;

export interface AssetRegistryOptions {
  /** Cache TTL per source. Default {@link DEFAULT_META_TTL_MS}. */
  ttlMs?: number;
  /** Cooldown after a failed load. Default {@link DEFAULT_FAIL_COOLDOWN_MS}. */
  failCooldownMs?: number;
  /**
   * HIP-3 dexes this registry may load. `'all'` (default) loads any dex listed in `perpDexs` lazily, on first use.
   * The main dex (`''`) and spot are always available. Never rely on a hardcoded dex list for discovery —
   * use {@link AssetRegistry.listDexes}: new dexes (`mkts`, `io`) appeared during 2026.
   */
  dexes?: readonly string[] | 'all';
  /**
   * Double-read: fetch `meta` and `metaAndAssetCtxs` in parallel and require them to agree (costs 20 extra weight
   * per dex refresh). Protects against a truncated-but-valid 200 response shifting asset ids on a cold start.
   * Default `true`.
   */
  crossCheck?: boolean;
  /**
   * Refuse a HIP-3 dex whose `assetToStreamingOiCap` registry is empty: the registry is what proves the meta
   * universe is complete on a cold start. Set to `false` for a freshly deployed dex. Default `true`.
   */
  requireDexRegistry?: boolean;
  /** Per-request timeout forwarded to the InfoRequester. */
  requestTimeoutMs?: number;
  /** Diagnostic events (load failures, topology changes, new listings…). Exceptions from the handler are swallowed. */
  onEvent?: (event: AssetRegistryEvent) => void;
  /** Monotonic clock in ms. Default `performance.now()`. Injected in tests. */
  now?: () => number;
}

/** Thrown by the throwing accessors (`resolve`, `listAssets`, …). */
export class AssetRegistryError extends Error {
  readonly coin: string;
  readonly status: 'unlisted' | 'unknown' | 'delisted';
  readonly reason: UnknownReason | UnlistedReason | 'delisted';
  constructor(
    coin: string,
    status: AssetRegistryError['status'],
    reason: AssetRegistryError['reason'],
    options?: { cause?: unknown },
  ) {
    const text =
      status === 'delisted'
        ? `${coin} is delisted`
        : status === 'unlisted'
          ? `${coin} is not listed on Hyperliquid (${reason})`
          : `listing state of ${coin} is unknown (${reason})`;
    super(text, options);
    this.name = 'AssetRegistryError';
    this.coin = coin;
    this.status = status;
    this.reason = reason;
  }
}

export interface RegistrySourceStatus {
  /** `meta`, `meta:<dex>`, `perpDexs` or `spotMeta`. */
  readonly source: string;
  readonly loaded: boolean;
  readonly fresh: boolean;
  readonly healthy: boolean;
  readonly ageMs: number | null;
  readonly lastFailAgoMs: number | null;
  readonly lastErrorReason: UnknownReason | UnlistedReason | null;
  readonly count: number;
}

export interface MarketSpec {
  readonly coin: string;
  /** Leverage the strategy intends to use; checked against `maxLeverage`. */
  readonly leverage?: number;
}

export interface MarketCheck {
  readonly coin: string;
  readonly ok: boolean;
  readonly lookup: AssetLookup;
  readonly problem?: 'unlisted' | 'unknown' | 'delisted' | 'invalid_leverage' | 'leverage_above_max';
}

export interface AssetRegistry {
  /**
   * Resolves a coin (`BTC`, `xyz:TSLA`, `PURR/USDC`, `@107`) to validated asset metadata.
   * @throws AssetRegistryError when the coin is unlisted, its state is unknown, or (with `rejectDelisted`) it is delisted.
   */
  resolve(coin: string, opts?: { rejectDelisted?: boolean }): Promise<AssetInfo>;
  /** Three-valued listing check that never throws: `listed | unlisted | unknown`. */
  lookup(coin: string): Promise<AssetLookup>;
  /**
   * Synchronous cache-only lookup for hot paths (e.g. a WS message parser). When the source is missing or stale it
   * starts ONE background refresh (single-flight, cooldown-aware) — calling `peek` per message cannot stampede.
   */
  peek(coin: string): AssetLookup;
  /** Reverse lookup for payloads that carry a numeric `asset` but no `coin`. */
  lookupByAssetId(assetId: number): Promise<AssetLookup>;
  /** All validated perp assets of a dex (`''` = main). */
  listAssets(dex?: string): Promise<readonly AssetInfo[]>;
  /** All validated spot pairs. @experimental see {@link parseSpotMeta}. */
  listSpotAssets(): Promise<readonly AssetInfo[]>;
  /** Builder dexes from `perpDexs` with their indices. */
  listDexes(): Promise<readonly { name: string; index: number }[]>;
  /** Spot token by name, with the `NAME:0x…` wire id used by `sendAsset`. @experimental */
  spotToken(name: string): Promise<SpotTokenInfo>;
  /** Startup check: every configured market exists, is not delisted, and the intended leverage fits `maxLeverage`. */
  checkMarkets(specs: readonly MarketSpec[]): Promise<MarketCheck[]>;
  /** Loads main meta, the given (or configured) HIP-3 dexes and optionally spot. Never throws; returns `status()`. */
  preload(opts?: { dexes?: readonly string[]; spot?: boolean }): Promise<RegistrySourceStatus[]>;
  /** Forces a reload of every source used so far (ignores TTL and cooldown, keeps single-flight). */
  refresh(): Promise<RegistrySourceStatus[]>;
  status(): RegistrySourceStatus[];
}

interface DexSnapshot {
  readonly dex: string;
  readonly dexIndex: number;
  readonly assets: readonly AssetInfo[];
  readonly byCoin: ReadonlyMap<string, AssetInfo>;
}

interface SpotSnapshot {
  readonly assets: readonly AssetInfo[];
  readonly byCoin: ReadonlyMap<string, AssetInfo>;
  readonly byIndex: ReadonlyMap<number, AssetInfo>;
  readonly byTokenPair: ReadonlyMap<string, readonly AssetInfo[]>;
  readonly tokensByName: ReadonlyMap<string, readonly SpotTokenInfo[]>;
}

type LoadReason = UnknownReason | 'dex_not_found';

class LoadError extends Error {
  readonly reason: LoadReason;
  constructor(reason: LoadReason, message: string, cause?: unknown) {
    super(message, cause === undefined ? undefined : { cause });
    this.name = 'AssetLoadError';
    this.reason = reason;
  }
}

interface Source<T> {
  readonly name: string;
  value: T | null;
  ts: number;
  /** Time of the last failed attempt; `null` when the last attempt succeeded. Kept apart from `ts`. */
  lastFailAt: number | null;
  lastError: unknown;
  healthy: boolean;
  inflight: Promise<void> | null;
  /** Bumped when cached data is invalidated (topology change); in-flight loads of an older generation are discarded. */
  generation: number;
  count(v: T): number;
}

function newSource<T>(name: string, count: (v: T) => number): Source<T> {
  return { name, value: null, ts: 0, lastFailAt: null, lastError: undefined, healthy: false, inflight: null, generation: 0, count: (v: T) => count(v) };
}

const withDex = (body: InfoRequest, dex: string): InfoRequest => (dex ? { ...body, dex } : body);

const listed = (coin: string, asset: AssetInfo): AssetLookup => ({ status: 'listed', coin, asset });
const unlisted = (coin: string, reason: UnlistedReason): AssetLookup => ({ status: 'unlisted', coin, reason });
const unknown = (coin: string, reason: UnknownReason, error?: unknown): AssetLookup =>
  error === undefined ? { status: 'unknown', coin, reason } : { status: 'unknown', coin, reason, error };

/**
 * Creates a process-wide asset registry. Share ONE instance between all subsystems: separate caches of the same
 * global meta double heavy requests, and a per-message `getMeta()` without single-flight turns every TTL expiry
 * into a stampede of weight-20 requests that can eat most of the IP weight budget.
 *
 * Caching rules implemented:
 * - TTL per source (`meta` per dex, `perpDexs`, `spotMeta`) with single-flight: concurrent callers share one request;
 * - stale-on-error: a failed refresh keeps serving the last verified data (asset index and szDecimals practically
 *   never change, and a missing meta would block even protective closes);
 * - failure cooldown kept separately from the data timestamp, so a recovered endpoint is used right after the
 *   cooldown instead of after a full TTL; the ATTEMPT is memoized, so an outage does not rebuild per call;
 * - HIP-3 failures are isolated from main; a HIP-3 dex index is only reused if this process verified it through
 *   `perpDexs`, otherwise the dex is closed (fail-closed);
 * - topology change (dex index changed, duplicated, or vanished) drops that dex's ids and emits `topology_changed`.
 *
 * @example
 * const assets = createAssetRegistry(info, { ttlMs: 300_000 });
 * const tsla = await assets.resolve('xyz:TSLA'); // { assetId: 110001, onlyIsolated: true, … }
 */
export function createAssetRegistry(info: InfoRequester, options: AssetRegistryOptions = {}): AssetRegistry {
  const ttlMs = options.ttlMs ?? DEFAULT_META_TTL_MS;
  const failCooldownMs = options.failCooldownMs ?? DEFAULT_FAIL_COOLDOWN_MS;
  if (!(ttlMs > 0) || !Number.isFinite(ttlMs)) throw new RangeError(`ttlMs must be a positive finite number, got ${ttlMs}`);
  if (!(failCooldownMs >= 0) || !Number.isFinite(failCooldownMs)) {
    throw new RangeError(`failCooldownMs must be a non-negative finite number, got ${failCooldownMs}`);
  }
  const crossCheck = options.crossCheck ?? true;
  const requireDexRegistry = options.requireDexRegistry ?? true;
  const now = options.now ?? (() => performance.now());
  const onEvent = options.onEvent;

  let allowedDexes: ReadonlySet<string> | 'all' = 'all';
  if (options.dexes !== undefined && options.dexes !== 'all') {
    const set = new Set<string>();
    for (const d of options.dexes) {
      if (d === '') continue;
      if (!isValidDexName(d)) throw new RangeError(`invalid dex name ${JSON.stringify(d)}`);
      set.add(d);
    }
    allowedDexes = set;
  }
  const dexAllowed = (dex: string) => dex === '' || allowedDexes === 'all' || allowedDexes.has(dex);

  const callOpts: InfoCallOptions =
    options.requestTimeoutMs === undefined
      ? { weight: META_REQUEST_WEIGHT }
      : { weight: META_REQUEST_WEIGHT, timeoutMs: options.requestTimeoutMs };
  const call = (body: InfoRequest) => info<unknown>(body, callOpts);

  const emit = (e: AssetRegistryEvent) => {
    if (!onEvent) return;
    try {
      onEvent(e);
    } catch {
      /* a faulty handler must not break asset resolution */
    }
  };

  const perpDexsSrc = newSource<PerpDexTopology>('perpDexs', (v) => v.dexes.length);
  const spotSrc = newSource<SpotSnapshot>('spotMeta', (v) => v.assets.length);
  const dexSources = new Map<string, Source<DexSnapshot>>();
  /** Dex indices this process has verified and committed; basis for topology-change detection. */
  const verifiedDexIndex = new Map<string, { index: number; broken: 'missing' | 'duplicated' | null }>();
  const lastExtras = new Map<string, string>();
  const registryEmptyNotified = new Set<string>();

  const isFresh = (s: Source<unknown>) => s.value !== null && s.healthy && now() - s.ts < ttlMs;
  // `null` sentinel, not `0`: an injected clock may legitimately read 0, which silently disabled the cooldown.
  const inCooldown = (s: Source<unknown>) => s.lastFailAt !== null && now() - s.lastFailAt < failCooldownMs;
  const reasonOf = (err: unknown): LoadReason => (err instanceof LoadError ? err.reason : 'load_failed');

  function ensure<T>(
    s: Source<T>,
    load: (prev: T | null) => Promise<T>,
    hooks: { force?: boolean; accept?: (v: T) => LoadError | null; commit?: (v: T) => void } = {},
  ): Promise<void> {
    if (s.inflight) return s.inflight;
    if (!hooks.force && (isFresh(s) || inCooldown(s))) return Promise.resolve();
    const gen = s.generation;
    const run = async () => {
      try {
        const v = await load(s.value);
        if (s.generation !== gen) return; // invalidated while loading
        // Acceptance check and commit run in one synchronous block: nothing can interleave between them.
        const rejected = hooks.accept?.(v) ?? null;
        if (rejected) throw rejected;
        s.value = v;
        s.ts = now();
        s.healthy = true;
        s.lastError = undefined;
        s.lastFailAt = null;
        hooks.commit?.(v);
        emit({ type: 'loaded', source: s.name, count: s.count(v) });
      } catch (err) {
        if (s.generation !== gen) return;
        s.healthy = false;
        s.lastError = err;
        s.lastFailAt = now();
        emit({ type: 'load_failed', source: s.name, reason: reasonOf(err), error: err, servingStale: s.value !== null });
      }
    };
    const p: Promise<void> = run().finally(() => {
      if (s.inflight === p) s.inflight = null;
    });
    s.inflight = p;
    return p;
  }

  // ---------- perpDexs ----------

  async function loadPerpDexs(): Promise<PerpDexTopology> {
    const raw = await call({ type: 'perpDexs' });
    try {
      return parsePerpDexs(raw);
    } catch (e) {
      throw new LoadError('invalid_response', e instanceof Error ? e.message : 'invalid perpDexs', e);
    }
  }

  function dropDex(dex: string, reasonMessage: string) {
    const s = dexSources.get(dex);
    if (!s) return;
    s.generation++;
    s.value = null;
    s.ts = 0;
    s.healthy = false;
    s.inflight = null;
    s.lastFailAt = null;
    s.lastError = new LoadError('topology_unsafe', reasonMessage);
  }

  /** Called on every committed perpDexs load: drops HIP-3 ids whose dex index can no longer be trusted. */
  function onTopology(topo: PerpDexTopology) {
    for (const [dex, known] of verifiedDexIndex) {
      const duplicated = topo.duplicates.has(dex);
      const cur = topo.byName.get(dex);
      const broken = duplicated ? 'duplicated' : cur ? null : 'missing';
      if (!broken && cur && cur.index === known.index) {
        known.broken = null;
        continue;
      }
      if (broken && known.broken === broken) continue; // already reported and dropped
      emit({
        type: 'topology_changed',
        dex,
        previousIndex: known.index,
        currentIndex: broken ? null : cur!.index,
        duplicated,
      });
      dropDex(dex, `perpDexs topology changed for '${dex}'`);
      if (broken) known.broken = broken;
      else verifiedDexIndex.set(dex, { index: cur!.index, broken: null });
    }
  }

  const ensurePerpDexs = (force = false) => ensure(perpDexsSrc, loadPerpDexs, { force, commit: onTopology });

  async function dexEntry(dex: string): Promise<PerpDexInfo> {
    await ensurePerpDexs();
    const topo = perpDexsSrc.value;
    if (!topo) {
      throw new LoadError('dex_unverified', `perpDexs unavailable: index of dex '${dex}' is not verified`, perpDexsSrc.lastError);
    }
    if (topo.duplicates.has(dex)) throw new LoadError('topology_unsafe', `dex '${dex}' appears more than once in perpDexs`);
    const entry = topo.byName.get(dex);
    if (!entry) {
      if (verifiedDexIndex.has(dex)) throw new LoadError('topology_unsafe', `verified dex '${dex}' vanished from perpDexs`);
      if (!isFresh(perpDexsSrc)) throw new LoadError('dex_unverified', `dex '${dex}' not in stale perpDexs`);
      throw new LoadError('dex_not_found', `dex '${dex}' is not listed in perpDexs`);
    }
    return entry;
  }

  // ---------- perp dex meta ----------

  function buildDexSnapshot(dex: string, dexIndex: number, metas: readonly PerpAssetMeta[]): DexSnapshot {
    const assets = metas.map((m) =>
      Object.freeze<AssetInfo>({
        coin: m.coin,
        assetId: dex ? hip3AssetId(dexIndex, m.index) : perpAssetId(m.index),
        index: m.index,
        szDecimals: m.szDecimals,
        maxPxDecimals: Math.max(0, 6 - m.szDecimals),
        maxLeverage: m.maxLeverage,
        onlyIsolated: m.onlyIsolated,
        isDelisted: m.isDelisted,
        dex,
        dexIndex,
        market: 'perp',
      }),
    );
    return { dex, dexIndex, assets: Object.freeze(assets), byCoin: new Map(assets.map((a) => [a.coin, a] as const)) };
  }

  async function loadDex(dex: string, prev: DexSnapshot | null): Promise<DexSnapshot> {
    let dexIndex = 0;
    let registry: readonly string[] | null = null;
    if (dex) {
      const entry = await dexEntry(dex);
      dexIndex = entry.index;
      registry = entry.registry;
      if (registry.length === 0) {
        if (!registryEmptyNotified.has(dex)) {
          registryEmptyNotified.add(dex);
          emit({ type: 'registry_empty', dex });
        }
        if (requireDexRegistry) {
          throw new LoadError('registry_empty', `dex '${dex}' has an empty assetToStreamingOiCap registry`);
        }
      } else {
        registryEmptyNotified.delete(dex);
      }
    }

    const [metaRaw, ctxRaw] = await Promise.all([
      call(withDex({ type: 'meta' }, dex)),
      crossCheck ? call(withDex({ type: 'metaAndAssetCtxs' }, dex)) : Promise.resolve(undefined),
    ]);

    let metas: PerpAssetMeta[];
    try {
      metas = parsePerpUniverse(metaRaw, dex);
    } catch (e) {
      throw new LoadError('invalid_response', e instanceof Error ? e.message : 'invalid meta', e);
    }
    if (crossCheck) {
      let second: PerpAssetMeta[];
      try {
        second = parseMetaAndAssetCtxs(ctxRaw, dex);
      } catch (e) {
        throw new LoadError('cross_check_failed', e instanceof Error ? e.message : 'invalid metaAndAssetCtxs', e);
      }
      const issues = crossCheckUniverses(metas, second);
      if (issues.length > 0) {
        throw new LoadError('cross_check_failed', `meta vs metaAndAssetCtxs mismatch: ${issues.slice(0, 3).join('; ')}`,
          new MetaValidationError('cross-check', issues));
      }
    }
    if (registry && registry.length > 0) {
      const { missing, extra } = checkRegistryCoverage(
        metas.map((m) => m.coin),
        registry,
      );
      if (missing.length > 0) {
        emit({ type: 'coverage_missing', dex, missing });
        throw new LoadError('coverage_failed', `meta(dex=${dex}) lacks registry assets: ${missing.slice(0, 5).join(', ')}`);
      }
      // Live data (2026-09): every extra on flx/hyna/km/cash/para/mkts/io was a DELISTED asset that HL had dropped
      // from the OI-cap registry, not a new listing. Only extras that are still tradable are reported as listings.
      const delisted = new Set(metas.filter((m) => m.isDelisted).map((m) => m.coin));
      const listings = extra.filter((c) => !delisted.has(c));
      const key = listings.join(',');
      if (listings.length > 0 && lastExtras.get(dex) !== key) emit({ type: 'new_listings', dex, coins: listings });
      lastExtras.set(dex, key);
    }
    if (prev && prev.dexIndex === dexIndex) {
      const issues = checkAppendOnly(prev.assets, metas);
      if (issues.length > 0) {
        emit({ type: 'append_only_violation', source: dexSourceName(dex), issues });
        throw new LoadError('append_only_violation', `meta update rejected: ${issues.slice(0, 3).join('; ')}`);
      }
    }
    return buildDexSnapshot(dex, dexIndex, metas);
  }

  const dexSourceName = (dex: string) => (dex ? `meta:${dex}` : 'meta');

  function dexSource(dex: string): Source<DexSnapshot> {
    let s = dexSources.get(dex);
    if (!s) dexSources.set(dex, (s = newSource<DexSnapshot>(dexSourceName(dex), (v) => v.assets.length)));
    return s;
  }

  function ensureDex(dex: string, force = false): Promise<void> {
    const s = dexSource(dex);
    return ensure(s, (prev) => loadDex(dex, prev), {
      force,
      accept: (snap) => {
        if (!snap.dex) return null;
        // perpDexs may have been refreshed while meta was in flight: the index must still match.
        const topo = perpDexsSrc.value;
        const entry = topo?.byName.get(snap.dex);
        if (!topo || topo.duplicates.has(snap.dex) || !entry || entry.index !== snap.dexIndex) {
          return new LoadError('topology_unsafe', `perpDexs changed while loading dex '${snap.dex}'`);
        }
        return null;
      },
      commit: (snap) => {
        if (snap.dex) verifiedDexIndex.set(snap.dex, { index: snap.dexIndex, broken: null });
      },
    });
  }

  // ---------- spot ----------

  async function loadSpot(): Promise<SpotSnapshot> {
    const raw = await call({ type: 'spotMeta' });
    let parsed;
    try {
      parsed = parseSpotMeta(raw);
    } catch (e) {
      throw new LoadError('invalid_response', e instanceof Error ? e.message : 'invalid spotMeta', e);
    }
    const assets = parsed.pairs.map((p) =>
      Object.freeze<AssetInfo>({
        coin: p.coin,
        assetId: spotAssetId(p.index),
        index: p.index,
        szDecimals: p.base.szDecimals,
        maxPxDecimals: Math.max(0, 8 - p.base.szDecimals),
        maxLeverage: 1,
        onlyIsolated: false,
        isDelisted: false,
        dex: '',
        dexIndex: null,
        market: 'spot',
        base: p.base.name,
        quote: p.quote.name,
      }),
    );
    const byTokenPair = new Map<string, AssetInfo[]>();
    for (const a of assets) {
      const key = `${a.base}/${a.quote}`;
      const list = byTokenPair.get(key);
      if (list) list.push(a);
      else byTokenPair.set(key, [a]);
    }
    const tokensByName = new Map<string, SpotTokenInfo[]>();
    for (const t of parsed.tokens) {
      const list = tokensByName.get(t.name);
      if (list) list.push(t);
      else tokensByName.set(t.name, [t]);
    }
    return {
      assets: Object.freeze(assets),
      byCoin: new Map(assets.map((a) => [a.coin, a] as const)),
      byIndex: new Map(assets.map((a) => [a.index, a] as const)),
      byTokenPair,
      tokensByName,
    };
  }

  const ensureSpot = (force = false) => ensure(spotSrc, loadSpot, { force });

  // ---------- classification ----------

  /** Classifies a miss on a source: fresh+healthy → unlisted; otherwise unknown with the underlying reason. */
  function classifyMiss(coin: string, s: Source<unknown>): AssetLookup {
    if (isFresh(s)) return unlisted(coin, 'not_in_universe');
    const err = s.lastError;
    if (err !== undefined) {
      const reason = reasonOf(err);
      if (reason === 'dex_not_found') return s.value === null ? unlisted(coin, 'dex_not_found') : unknown(coin, 'stale', err);
      return unknown(coin, reason, err);
    }
    return unknown(coin, s.value === null ? 'not_loaded' : 'stale');
  }

  function classifyPerp(p: Extract<ParsedCoin, { market: 'perp' }>): AssetLookup {
    const s = dexSources.get(p.dex);
    if (!s) return unknown(p.coin, 'not_loaded');
    const asset = s.value?.byCoin.get(p.coin);
    if (asset) return listed(p.coin, asset);
    return classifyMiss(p.coin, s);
  }

  function classifySpot(p: Extract<ParsedCoin, { market: 'spot' }>): AssetLookup {
    const snap = spotSrc.value;
    if (snap) {
      if (p.form === 'index') {
        const a = snap.byIndex.get(p.index);
        if (a) return listed(p.coin, a);
      } else {
        const a = snap.byCoin.get(p.coin);
        if (a) return listed(p.coin, a);
        // Non-canonical pairs are named `@index`; map `BASE/QUOTE` through token names when unambiguous.
        const matches = snap.byTokenPair.get(p.coin);
        if (matches && matches.length === 1) return listed(p.coin, matches[0]!);
        if (matches && matches.length > 1) return unknown(p.coin, 'ambiguous');
      }
    }
    return classifyMiss(p.coin, spotSrc);
  }

  /** Pre-network filter for HIP-3 coins: invalid, not configured, or a dex a fresh perpDexs does not know. */
  function precheckPerp(p: Extract<ParsedCoin, { market: 'perp' }>): AssetLookup | null {
    if (p.dex === '') return null;
    if (!isValidDexName(p.dex)) return unlisted(p.coin, 'invalid');
    if (!dexAllowed(p.dex)) return unknown(p.coin, 'dex_not_configured');
    return null;
  }

  /** `unlisted(dex_not_found)` when a fresh perpDexs proves the dex does not exist and this process never verified it. */
  function provenUnknownDex(p: Extract<ParsedCoin, { market: 'perp' }>): AssetLookup | null {
    if (p.dex === '') return null;
    const topo = perpDexsSrc.value;
    if (topo && isFresh(perpDexsSrc) && !topo.byName.has(p.dex) && !topo.duplicates.has(p.dex) && !verifiedDexIndex.has(p.dex)) {
      return unlisted(p.coin, 'dex_not_found');
    }
    return null;
  }

  async function lookupParsed(p: ParsedCoin): Promise<AssetLookup> {
    if (p.market === 'spot') {
      await ensureSpot();
      return classifySpot(p);
    }
    const pre = precheckPerp(p);
    if (pre) return pre;
    if (p.dex && !dexSources.has(p.dex)) {
      // Avoid creating a cache entry per arbitrary dex name when a fresh perpDexs proves the dex does not exist.
      await ensurePerpDexs();
      const miss = provenUnknownDex(p);
      if (miss) return miss;
    }
    await ensureDex(p.dex);
    return classifyPerp(p);
  }

  async function lookup(coin: string): Promise<AssetLookup> {
    const p = parseCoin(coin);
    if (!p) return unlisted(String(coin), 'invalid');
    return lookupParsed(p);
  }

  function peek(coin: string): AssetLookup {
    const p = parseCoin(coin);
    if (!p) return unlisted(String(coin), 'invalid');
    if (p.market === 'spot') {
      if (!isFresh(spotSrc)) void ensureSpot();
      return classifySpot(p);
    }
    const pre = precheckPerp(p);
    if (pre) return pre;
    const s = dexSources.get(p.dex);
    if (!s) {
      // Same guard as lookup(): hot-path payloads must not grow one cache entry per arbitrary dex name.
      const miss = provenUnknownDex(p);
      if (miss) return miss;
    }
    if (!s || !isFresh(s)) void ensureDex(p.dex);
    return classifyPerp(p);
  }

  async function resolve(coin: string, opts: { rejectDelisted?: boolean } = {}): Promise<AssetInfo> {
    const r = await lookup(coin);
    if (r.status === 'listed') {
      if (opts.rejectDelisted && r.asset.isDelisted) throw new AssetRegistryError(r.asset.coin, 'delisted', 'delisted');
      return r.asset;
    }
    throw new AssetRegistryError(r.coin, r.status, r.reason, r.status === 'unknown' && r.error !== undefined ? { cause: r.error } : undefined);
  }

  async function lookupByAssetId(assetId: number): Promise<AssetLookup> {
    const label = String(assetId);
    let decoded;
    try {
      decoded = decodeAssetId(assetId);
    } catch {
      return unlisted(label, 'invalid');
    }
    if (decoded.market === 'spot') {
      await ensureSpot();
      const a = spotSrc.value?.byIndex.get(decoded.index);
      return a ? listed(label, a) : classifyMiss(label, spotSrc);
    }
    let dex = '';
    if (decoded.dexIndex > 0) {
      await ensurePerpDexs();
      const topo = perpDexsSrc.value;
      if (!topo) return unknown(label, 'dex_unverified', perpDexsSrc.lastError);
      const entry = topo.byIndex.get(decoded.dexIndex);
      if (!entry) return isFresh(perpDexsSrc) ? unlisted(label, 'dex_not_found') : unknown(label, 'dex_unverified');
      if (topo.duplicates.has(entry.name)) return unknown(label, 'topology_unsafe');
      if (!dexAllowed(entry.name)) return unknown(label, 'dex_not_configured');
      dex = entry.name;
    }
    await ensureDex(dex);
    const s = dexSource(dex);
    const snap = s.value;
    if (snap && snap.dexIndex === decoded.dexIndex) {
      const a = snap.assets[decoded.index];
      if (a) return listed(label, a);
    }
    return classifyMiss(label, s);
  }

  function throwFromMiss(label: string, s: Source<unknown>): never {
    const r = classifyMiss(label, s);
    if (r.status === 'listed') throw new Error('unreachable');
    throw new AssetRegistryError(label, r.status, r.reason, r.status === 'unknown' && r.error !== undefined ? { cause: r.error } : undefined);
  }

  async function listAssets(dex = ''): Promise<readonly AssetInfo[]> {
    if (dex !== '' && !isValidDexName(dex)) throw new AssetRegistryError(dex, 'unlisted', 'invalid');
    if (!dexAllowed(dex)) throw new AssetRegistryError(dex, 'unknown', 'dex_not_configured');
    await ensureDex(dex);
    const s = dexSource(dex);
    if (s.value) return s.value.assets;
    return throwFromMiss(dexSourceName(dex), s);
  }

  async function listSpotAssets(): Promise<readonly AssetInfo[]> {
    await ensureSpot();
    if (spotSrc.value) return spotSrc.value.assets;
    return throwFromMiss('spotMeta', spotSrc);
  }

  async function listDexes(): Promise<readonly { name: string; index: number }[]> {
    await ensurePerpDexs();
    const topo = perpDexsSrc.value;
    if (topo) return topo.dexes.map((d) => ({ name: d.name, index: d.index }));
    return throwFromMiss('perpDexs', perpDexsSrc);
  }

  async function spotToken(name: string): Promise<SpotTokenInfo> {
    await ensureSpot();
    const found = spotSrc.value?.tokensByName.get(name);
    if (found && found.length === 1) return found[0]!;
    if (found && found.length > 1) throw new AssetRegistryError(name, 'unknown', 'ambiguous');
    return throwFromMiss(name, spotSrc);
  }

  async function checkMarkets(specs: readonly MarketSpec[]): Promise<MarketCheck[]> {
    return Promise.all(
      specs.map(async (spec): Promise<MarketCheck> => {
        const r = await lookup(spec.coin);
        if (r.status !== 'listed') return { coin: spec.coin, ok: false, lookup: r, problem: r.status };
        if (r.asset.isDelisted) return { coin: spec.coin, ok: false, lookup: r, problem: 'delisted' };
        if (spec.leverage !== undefined) {
          if (!Number.isInteger(spec.leverage) || spec.leverage < 1) {
            return { coin: spec.coin, ok: false, lookup: r, problem: 'invalid_leverage' };
          }
          if (spec.leverage > r.asset.maxLeverage) {
            return { coin: spec.coin, ok: false, lookup: r, problem: 'leverage_above_max' };
          }
        }
        return { coin: spec.coin, ok: true, lookup: r };
      }),
    );
  }

  function status(): RegistrySourceStatus[] {
    const t = now();
    const row = (s: Source<unknown>): RegistrySourceStatus => ({
      source: s.name,
      loaded: s.value !== null,
      fresh: isFresh(s),
      healthy: s.healthy,
      ageMs: s.value !== null ? t - s.ts : null,
      lastFailAgoMs: s.lastFailAt !== null ? t - s.lastFailAt : null,
      lastErrorReason: s.lastError !== undefined ? reasonOf(s.lastError) : null,
      count: s.value !== null ? s.count(s.value) : 0,
    });
    const rows: RegistrySourceStatus[] = [];
    for (const s of dexSources.values()) rows.push(row(s as Source<unknown>));
    if (perpDexsSrc.value !== null || perpDexsSrc.lastError !== undefined) rows.push(row(perpDexsSrc as Source<unknown>));
    if (spotSrc.value !== null || spotSrc.lastError !== undefined) rows.push(row(spotSrc as Source<unknown>));
    return rows;
  }

  async function preload(opts: { dexes?: readonly string[]; spot?: boolean } = {}): Promise<RegistrySourceStatus[]> {
    const dexes = new Set<string>(['']);
    const wanted = opts.dexes ?? (allowedDexes === 'all' ? [] : [...allowedDexes]);
    for (const d of wanted) if (d === '' || (isValidDexName(d) && dexAllowed(d))) dexes.add(d);
    await Promise.all([...[...dexes].map((d) => ensureDex(d)), opts.spot ? ensureSpot() : Promise.resolve()]);
    return status();
  }

  async function refresh(): Promise<RegistrySourceStatus[]> {
    if (perpDexsSrc.value !== null || perpDexsSrc.lastError !== undefined) await ensurePerpDexs(true);
    const jobs: Promise<void>[] = [...dexSources.keys()].map((d) => ensureDex(d, true));
    if (spotSrc.value !== null || spotSrc.lastError !== undefined) jobs.push(ensureSpot(true));
    await Promise.all(jobs);
    return status();
  }

  return {
    resolve,
    lookup,
    peek,
    lookupByAssetId,
    listAssets,
    listSpotAssets,
    listDexes,
    spotToken,
    checkMarkets,
    preload,
    refresh,
    status,
  };
}
All files