Skip to content
markpaper

src/assets/names.ts

v0.3.0 · 4.4 KB

Download file
// Coin-name rules: main perps (`BTC`), HIP-3 perps (`xyz:TSLA`), spot (`PURR/USDC`, `@107`).

/** Defensive upper bound for coin names (a sanity bound, not an HL contract). */
export const MAX_COIN_NAME_LENGTH = 128;

// eslint-disable-next-line no-control-regex
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;

export type ParsedCoin =
  | { readonly market: 'perp'; readonly coin: string; readonly dex: string; readonly base: string }
  | { readonly market: 'spot'; readonly coin: string; readonly form: 'pair'; readonly base: string; readonly quote: string }
  | { readonly market: 'spot'; readonly coin: string; readonly form: 'index'; readonly index: number };

/**
 * Spot detector used across HL responses (`frontendOpenOrders`, `userFills`): spot coins are pairs
 * (`PURR/USDC`) or index forms (`@85`). Perp engines must skip them, otherwise a "cancel everything not in
 * my config" rule wipes spot orders living on the same account.
 */
export function isSpotCoin(coin: string): boolean {
  return coin.includes('/') || coin.startsWith('@');
}

/**
 * Parses a coin name. Returns `null` for malformed names, including a doubled dex prefix such as
 * `xyz:xyz:TSLA`: that name is always the product of a normalization bug upstream, never a real market.
 *
 * The dex is the part before the single `:`; no colon means the main perp dex (`''`).
 * Never parse composite keys like `${wallet}:${coin}` with `split(':')` — HIP-3 coins contain a colon.
 */
export function parseCoin(coin: string): ParsedCoin | null {
  if (typeof coin !== 'string' || coin.length === 0 || coin.length > MAX_COIN_NAME_LENGTH) return null;
  if (CONTROL_CHARS.test(coin)) return null;

  if (coin.startsWith('@')) {
    if (!/^@(0|[1-9]\d*)$/.test(coin)) return null;
    const index = Number(coin.slice(1));
    if (!Number.isSafeInteger(index)) return null;
    return { market: 'spot', coin, form: 'index', index };
  }

  if (coin.includes('/')) {
    if (coin.includes(':')) return null;
    const parts = coin.split('/');
    if (parts.length !== 2) return null;
    const [base, quote] = parts as [string, string];
    if (!base || !quote) return null;
    return { market: 'spot', coin, form: 'pair', base, quote };
  }

  const colon = coin.indexOf(':');
  if (colon === -1) return { market: 'perp', coin, dex: '', base: coin };
  if (coin.indexOf(':', colon + 1) !== -1) return null;
  const dex = coin.slice(0, colon);
  const base = coin.slice(colon + 1);
  if (!dex || !base) return null;
  return { market: 'perp', coin, dex, base };
}

/**
 * Qualifies a perp name with its dex prefix, idempotently.
 *
 * Why: HL changed `meta({dex:'xyz'}).universe[].name` from bare (`TSLA`) to prefixed (`xyz:TSLA`). Code that
 * always prepends the prefix produces `xyz:xyz:TSLA`: every meta lookup misses and all xyz orders silently
 * stop. HIP-3 `position.coin` can also arrive without the prefix.
 * Repeated leading prefixes are collapsed, so both formats map to `xyz:TSLA`.
 *
 * @param dex `''` for the main dex (name returned unchanged).
 */
export function qualifyCoin(dex: string, name: string): string {
  if (!dex) return name;
  const prefix = `${dex}:`;
  let base = name;
  while (base.startsWith(prefix)) base = base.slice(prefix.length);
  return prefix + base;
}

/** Dex of a perp coin (`''` for main); `''` for spot; `null` for malformed names. */
export function dexOfCoin(coin: string): string | null {
  const p = parseCoin(coin);
  if (!p) return null;
  return p.market === 'perp' ? p.dex : '';
}

/**
 * Strips the dex prefix (`xyz:INTC` → `INTC`). The result is not a unique market id: `xyz:SPCX` and main `SPCX`
 * share a base name although they are different HL markets, so never key orders or positions by it.
 * Spot and malformed names are returned unchanged.
 */
export function stripDexPrefix(coin: string): string {
  const p = parseCoin(coin);
  return p && p.market === 'perp' ? p.base : coin;
}

/** A string usable as a perp dex name / prefix. */
export function isValidDexName(dex: string): boolean {
  return (
    typeof dex === 'string' &&
    dex.length > 0 &&
    dex.length <= MAX_COIN_NAME_LENGTH &&
    !CONTROL_CHARS.test(dex) &&
    !/[:/@\s]/.test(dex)
  );
}

/** Internal: non-empty string without control characters and within the length bound. */
export function isSaneName(name: unknown): name is string {
  return typeof name === 'string' && name.length > 0 && name.length <= MAX_COIN_NAME_LENGTH && !CONTROL_CHARS.test(name);
}
All files