src/orders/cloid.ts
v0.3.0 · 3.7 KB
// 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);
}