Skip to content
markpaper

src/orders/cloid.ts

v0.3.0 · 3.7 KB

Download file
// Client order ids (cloid): `0x` + exactly 32 hex characters (16 bytes).

import { invalidArgument } from './errors.js';

/** A normalized (lowercase) client order id. */
export type Cloid = `0x${string}`;

/** Number of hex characters after `0x`. */
export const CLOID_HEX_LENGTH = 32;

/**
 * Longest ownership prefix accepted by {@link createCloid}, in hex characters. The rest (at least
 * 16 hex characters, 64 bits) stays random so ids do not collide within a process lifetime.
 */
export const MAX_CLOID_PREFIX_LENGTH = 16;

const CLOID_RE = /^0x[0-9a-fA-F]{32}$/;
const HEX_RE = /^[0-9a-fA-F]*$/;

/** `true` for `0x` + 32 hex characters (either case). */
export function isCloid(value: unknown): value is Cloid {
  return typeof value === 'string' && CLOID_RE.test(value);
}

/**
 * Validates and lowercases a cloid. The SDK lowercases cloids before signing and the exchange
 * echoes them lowercase, so stored ids must be lowercase too or a lookup by cloid misses.
 * @throws HlOrderError `INVALID_ARGUMENT` unless the value is `0x` + 32 hex characters
 * (SDK 0.33.3 rejects any other length with a `ValidationError`).
 */
export function normalizeCloid(value: unknown): Cloid {
  if (!isCloid(value)) {
    invalidArgument(`Invalid cloid ${JSON.stringify(value)}: expected 0x + ${CLOID_HEX_LENGTH} hex characters`);
  }
  return value.toLowerCase() as Cloid;
}

function normalizePrefix(prefix: string): string {
  const p = typeof prefix === 'string' && /^0x/i.test(prefix) ? prefix.slice(2) : prefix;
  if (typeof p !== 'string' || !HEX_RE.test(p) || p.length > MAX_CLOID_PREFIX_LENGTH) {
    invalidArgument(`Invalid cloid prefix ${JSON.stringify(prefix)}: expected up to ${MAX_CLOID_PREFIX_LENGTH} hex characters`);
  }
  return p.toLowerCase();
}

export interface CreateCloidOptions {
  /**
   * Hex ownership marker placed at the start of the id (up to {@link MAX_CLOID_PREFIX_LENGTH}
   * characters, `0x` optional). Lets a bot tell its own orders from manual ones in
   * `frontendOpenOrders`.
   *
   * Why: without a marker a bot cannot tell its orders from the user's and cancels everything that
   * is not in its plan. The marker scheme is part of the contract with the live book: changing it
   * between deploys orphans every resting order (they stop being recognized as own and get
   * duplicated), so change it only with a migration that adopts the existing orders first.
   */
  prefix?: string;
  /** Random source filling the given array (tests). Default `crypto.getRandomValues`. */
  random?: (bytes: Uint8Array) => Uint8Array;
}

/** Generates a random cloid, optionally starting with an ownership prefix. */
export function createCloid(opts: CreateCloidOptions = {}): Cloid {
  const prefix = opts.prefix === undefined ? '' : normalizePrefix(opts.prefix);
  const random = opts.random ?? defaultRandom;
  const bytes = random(new Uint8Array(CLOID_HEX_LENGTH / 2));
  let hex = '';
  for (const b of bytes) hex += b.toString(16).padStart(2, '0');
  if (hex.length !== CLOID_HEX_LENGTH) throw new RangeError('random source returned the wrong number of bytes');
  return `0x${prefix}${hex.slice(prefix.length)}` as Cloid;
}

/** `true` when `cloid` is a valid cloid starting with the ownership `prefix` (case-insensitive). */
export function cloidHasPrefix(cloid: unknown, prefix: string): boolean {
  if (!isCloid(cloid)) return false;
  return cloid.slice(2).toLowerCase().startsWith(normalizePrefix(prefix));
}

function defaultRandom(bytes: Uint8Array): Uint8Array {
  const c = (globalThis as { crypto?: { getRandomValues?: (a: Uint8Array) => Uint8Array } }).crypto;
  if (!c || typeof c.getRandomValues !== 'function') {
    throw new Error('crypto.getRandomValues is not available; pass CreateCloidOptions.random');
  }
  return c.getRandomValues(bytes);
}
All files