src/assets/registry.ts
v0.3.0 · 31.8 KB
// 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,
};
}