src/assets/names.ts
v0.3.0 · 4.4 KB
// 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);
}