Skip to content
markpaper

src/signing/nonce.ts

v0.2.0 · 7.7 KB

Download file
// Order / cancel nonces: `(recv_time_ms << 20) | client_bits`.
//
// The high 44 bits are `recv_time`, a millisecond deadline by which the request must reach the engine
// (documentation: no further than +100 s from the server's now; this kit defaults to now + 60 s). The low
// 20 bits are the client's. The `orders` query returns each resting order's nonce, and Nado has no
// `cloid`, so those 20 bits are the ONLY round-trippable place to mark an order as OURS and to keep one
// caller-defined flag on it (for example "this resting order is meant to reduce the position"): a resting
// order cannot carry reduce-only on this venue, so such intent is otherwise unrecoverable after a restart.
//
// Layout used by this kit (bits of the 20 client bits):
//   19..8  tag (12 bits)   - "placed by this process"; orders without the tag are FOREIGN: never adopted,
//                            never cancelled, still counted as exposure
//    7     flag            - one caller-defined bit (exposed as `reduceIntent`); it does not select a TP strategy
//    6..0  random (7 bits) - distinguishes identical orders placed in the same millisecond
//
// KEEP THE TAG STABLE ACROSS DEPLOYS. Changing it orphans every resting order (the process would refuse
// to cancel its own book). The default tag below was chosen at random when the kit was written; pass
// your own `tag` and never change it afterwards. Migration = cancel the old book by hand first.
//
// `link_signer` uses a different nonce: the incrementing `tx_nonce` from the `nonces` query.

/** Number of client-controlled low bits. */
export const NONCE_CLIENT_BITS = 20;
/** Width of the tag field. */
export const NONCE_TAG_BITS = 12;
/** Width of the random field. */
export const NONCE_RANDOM_BITS = 7;
/** Largest tag value. */
export const NONCE_TAG_MAX = (1 << NONCE_TAG_BITS) - 1;
/** Largest random value. */
export const NONCE_RANDOM_MAX = (1 << NONCE_RANDOM_BITS) - 1;
/**
 * Default tag of this kit (12 bits), picked at random on 2026-09-16. Long-running processes should pass
 * their own tag explicitly and keep it forever.
 */
export const DEFAULT_NONCE_TAG = 0x07b;
/** Default recv_time window: now + 60 s, inside the documented maximum of 100 s. */
export const DEFAULT_RECV_WINDOW_MS = 60_000;
/** Documented upper bound of the recv_time window. */
export const MAX_RECV_WINDOW_MS = 100_000;

const CLIENT_MASK = (1n << BigInt(NONCE_CLIENT_BITS)) - 1n;
const TAG_SHIFT = BigInt(NONCE_RANDOM_BITS + 1);
const FLAG_BIT = 1n << BigInt(NONCE_RANDOM_BITS);
const RANDOM_MASK = BigInt(NONCE_RANDOM_MAX);
const MAX_RECV_TIME = (1n << 44n) - 1n;

/** Decoded nonce. */
export interface NonceParts {
  /** Millisecond deadline carried in the high bits. */
  readonly recvTimeMs: number;
  /** All 20 client bits. */
  readonly clientBits: number;
  readonly tag: number;
  readonly reduceIntent: boolean;
  readonly random: number;
}

/** Inputs of {@link buildNonce}. */
export interface BuildNonceInput {
  /** Deadline in ms since the epoch (see {@link recvTimeFor}). */
  recvTimeMs: number;
  /** 12-bit process tag. Default {@link DEFAULT_NONCE_TAG}. */
  tag?: number;
  /** Caller-defined flag bit (e.g. reduce intent of a resting order). Default false. */
  reduceIntent?: boolean;
  /** 7-bit random field. Default: `Math.random()`. */
  random?: number;
}

function assertField(name: string, value: number, max: number): void {
  if (!Number.isInteger(value) || value < 0 || value > max) {
    throw new RangeError(`${name} must be an integer in [0, ${max}], got ${String(value)}`);
  }
}

/** `recv_time` for a request built now: `nowMs + windowMs` (default 60 s, documented maximum 100 s). */
export function recvTimeFor(nowMs: number, windowMs: number = DEFAULT_RECV_WINDOW_MS): number {
  if (!Number.isFinite(nowMs) || nowMs < 0)
    throw new RangeError(`nowMs must be a non-negative number, got ${String(nowMs)}`);
  if (!Number.isFinite(windowMs) || windowMs <= 0 || windowMs > MAX_RECV_WINDOW_MS) {
    throw new RangeError(`windowMs must be in (0, ${MAX_RECV_WINDOW_MS}], got ${String(windowMs)}`);
  }
  return Math.floor(nowMs + windowMs);
}

/** Builds an order nonce from explicit parts (see the module header for the layout). */
export function buildNonce(input: BuildNonceInput): bigint {
  const tag = input.tag ?? DEFAULT_NONCE_TAG;
  const random = input.random ?? Math.floor(Math.random() * (NONCE_RANDOM_MAX + 1));
  assertField('tag', tag, NONCE_TAG_MAX);
  assertField('random', random, NONCE_RANDOM_MAX);
  if (!Number.isInteger(input.recvTimeMs) || input.recvTimeMs < 0) {
    throw new RangeError(`recvTimeMs must be a non-negative integer, got ${String(input.recvTimeMs)}`);
  }
  const recv = BigInt(input.recvTimeMs);
  if (recv > MAX_RECV_TIME) throw new RangeError(`recvTimeMs ${input.recvTimeMs} does not fit 44 bits`);
  const client = (BigInt(tag) << TAG_SHIFT) | (input.reduceIntent ? FLAG_BIT : 0n) | BigInt(random);
  return (recv << BigInt(NONCE_CLIENT_BITS)) | client;
}

/** Order nonce for a request built at `nowMs`: `recv_time = now + window`, tag, flag bit, random. */
export function buildOrderNonce(
  nowMs: number,
  opts: { windowMs?: number; tag?: number; reduceIntent?: boolean; random?: number } = {},
): bigint {
  return buildNonce({
    recvTimeMs: recvTimeFor(nowMs, opts.windowMs),
    tag: opts.tag,
    reduceIntent: opts.reduceIntent,
    random: opts.random,
  });
}

/**
 * Cancel nonce: `recv_time` + 20 random client bits. Cancel nonces never come back from the venue, so no
 * tag is needed; a tag is harmless if you prefer one builder ({@link buildOrderNonce} works too).
 */
export function buildCancelNonce(nowMs: number, opts: { windowMs?: number; random?: number } = {}): bigint {
  const random = opts.random ?? Math.floor(Math.random() * Number(CLIENT_MASK + 1n));
  assertField('random', random, Number(CLIENT_MASK));
  return (BigInt(recvTimeFor(nowMs, opts.windowMs)) << BigInt(NONCE_CLIENT_BITS)) | BigInt(random);
}

/** Splits a nonce into its parts. Accepts the bigint or the decimal string from `orders`. */
export function parseNonce(nonce: bigint | string): NonceParts {
  const n = typeof nonce === 'bigint' ? nonce : parseNonceString(nonce);
  if (n < 0n) throw new RangeError('nonce must not be negative');
  const client = n & CLIENT_MASK;
  return {
    recvTimeMs: Number(n >> BigInt(NONCE_CLIENT_BITS)),
    clientBits: Number(client),
    tag: Number(client >> TAG_SHIFT),
    reduceIntent: (client & FLAG_BIT) !== 0n,
    random: Number(client & RANDOM_MASK),
  };
}

function parseNonceString(s: string): bigint {
  const t = s.trim();
  if (!/^\d+$/.test(t)) throw new TypeError(`nonce "${s}" is not an unsigned integer string`);
  return BigInt(t);
}

/** True when the nonce carries `tag` (the order was placed by a process using that tag). */
export function isOurNonce(nonce: bigint | string, tag: number = DEFAULT_NONCE_TAG): boolean {
  assertField('tag', tag, NONCE_TAG_MAX);
  return parseNonce(nonce).tag === tag;
}

/** Flag bit of a nonce; meaningful only for our own nonces. */
export function nonceReduceIntent(nonce: bigint | string): boolean {
  return parseNonce(nonce).reduceIntent;
}

/** `recv_time` (ms) carried by a nonce. */
export function recvTimeOf(nonce: bigint | string): number {
  return parseNonce(nonce).recvTimeMs;
}

/**
 * Clock sanity check before sending: the deadline must be in the future and no further than the
 * documented +100 s. A clock that drifted forward yields nonces outside the window, one that drifted
 * back yields already-expired deadlines. The venue's exact refusal for that was never observed.
 */
export function isRecvTimeWithinWindow(
  nonce: bigint | string,
  nowMs: number,
  maxAheadMs: number = MAX_RECV_WINDOW_MS,
): boolean {
  const recv = recvTimeOf(nonce);
  return recv > nowMs && recv - nowMs <= maxAheadMs;
}
All files