Skip to content
markpaper

src/signing/nonce.test.ts

v0.2.0 · 5.5 KB

Download file
import { describe, expect, it } from 'vitest';
import {
  buildCancelNonce,
  buildNonce,
  buildOrderNonce,
  DEFAULT_NONCE_TAG,
  DEFAULT_RECV_WINDOW_MS,
  isOurNonce,
  isRecvTimeWithinWindow,
  MAX_RECV_WINDOW_MS,
  NONCE_CLIENT_BITS,
  NONCE_RANDOM_MAX,
  NONCE_TAG_MAX,
  nonceReduceIntent,
  parseNonce,
  recvTimeFor,
  recvTimeOf,
} from './nonce.js';

const NOW = 1_780_000_000_000; // ms, synthetic round clock (2026)

describe('nonce layout', () => {
  it('has 20 client bits: 12 tag + 1 flag + 7 random', () => {
    expect(NONCE_CLIENT_BITS).toBe(20);
    expect(NONCE_TAG_MAX).toBe(4095);
    expect(NONCE_RANDOM_MAX).toBe(127);
    expect(DEFAULT_NONCE_TAG).toBeGreaterThan(0);
    expect(DEFAULT_NONCE_TAG).toBeLessThanOrEqual(NONCE_TAG_MAX);
    expect(DEFAULT_RECV_WINDOW_MS).toBe(60_000);
    expect(MAX_RECV_WINDOW_MS).toBe(100_000);
  });

  it('places recv_time in the high 44 bits and round-trips every field', () => {
    const nonce = buildNonce({ recvTimeMs: NOW + 60_000, tag: 0x5a5, reduceIntent: true, random: 77 });
    expect(nonce >> 20n).toBe(BigInt(NOW + 60_000));
    const parts = parseNonce(nonce);
    expect(parts).toEqual({
      recvTimeMs: NOW + 60_000,
      clientBits: (0x5a5 << 8) | (1 << 7) | 77,
      tag: 0x5a5,
      reduceIntent: true,
      random: 77,
    });
    expect(parseNonce(nonce.toString())).toEqual(parts);
    expect(recvTimeOf(nonce)).toBe(NOW + 60_000);
  });

  it('live nonce magnitude is above 2^53 (must be parsed from a string)', () => {
    const nonce = buildNonce({ recvTimeMs: NOW + 60_000, random: 0 });
    expect(nonce > BigInt(Number.MAX_SAFE_INTEGER)).toBe(true);
    expect(nonce.toString()).toHaveLength(19); // ~1.87e18
  });

  it('uses the default tag and a random field when omitted', () => {
    const a = buildNonce({ recvTimeMs: NOW });
    const parts = parseNonce(a);
    expect(parts.tag).toBe(DEFAULT_NONCE_TAG);
    expect(parts.reduceIntent).toBe(false);
    expect(parts.random).toBeGreaterThanOrEqual(0);
    expect(parts.random).toBeLessThanOrEqual(NONCE_RANDOM_MAX);
    // 200 draws stay inside the 7-bit field and are not all equal
    const draws = new Set<number>();
    for (let i = 0; i < 200; i++) draws.add(parseNonce(buildNonce({ recvTimeMs: NOW })).random);
    expect(draws.size).toBeGreaterThan(1);
    for (const d of draws) expect(d).toBeLessThanOrEqual(NONCE_RANDOM_MAX);
  });

  it('validates fields', () => {
    expect(() => buildNonce({ recvTimeMs: NOW, tag: 4096 })).toThrow(RangeError);
    expect(() => buildNonce({ recvTimeMs: NOW, tag: -1 })).toThrow(RangeError);
    expect(() => buildNonce({ recvTimeMs: NOW, random: 128 })).toThrow(RangeError);
    expect(() => buildNonce({ recvTimeMs: -1 })).toThrow(RangeError);
    expect(() => buildNonce({ recvTimeMs: 1.5 })).toThrow(RangeError);
    expect(() => buildNonce({ recvTimeMs: 2 ** 44 })).toThrow(RangeError);
    expect(() => parseNonce(-1n)).toThrow(RangeError);
    expect(() => parseNonce('abc')).toThrow(TypeError);
  });
});

describe('recvTimeFor / buildOrderNonce / buildCancelNonce', () => {
  it('defaults to now + 60 s and refuses windows above the documented 100 s', () => {
    expect(recvTimeFor(NOW)).toBe(NOW + 60_000);
    expect(recvTimeFor(NOW, 30_000)).toBe(NOW + 30_000);
    expect(() => recvTimeFor(NOW, 100_001)).toThrow(RangeError);
    expect(() => recvTimeFor(NOW, 0)).toThrow(RangeError);
    expect(() => recvTimeFor(-5)).toThrow(RangeError);
  });

  it('buildOrderNonce carries tag and flag bit; buildCancelNonce carries 20 random bits', () => {
    const o = buildOrderNonce(NOW, { tag: 0x123, reduceIntent: true, random: 3 });
    expect(parseNonce(o)).toEqual({
      recvTimeMs: NOW + 60_000,
      clientBits: (0x123 << 8) | 128 | 3,
      tag: 0x123,
      reduceIntent: true,
      random: 3,
    });
    const c = buildCancelNonce(NOW, { random: 0xfffff });
    expect(c >> 20n).toBe(BigInt(NOW + 60_000));
    expect(c & 0xfffffn).toBe(0xfffffn);
    expect(() => buildCancelNonce(NOW, { random: 0x100000 })).toThrow(RangeError);
    const auto = buildCancelNonce(NOW);
    expect(auto >> 20n).toBe(BigInt(NOW + 60_000));
  });
});

describe('isOurNonce / nonceReduceIntent', () => {
  it('recognises the tag and reads the flag bit', () => {
    const mine = buildNonce({ recvTimeMs: NOW, tag: 0x321, reduceIntent: true, random: 5 });
    expect(isOurNonce(mine, 0x321)).toBe(true);
    expect(isOurNonce(mine, 0x322)).toBe(false);
    expect(isOurNonce(mine.toString(), 0x321)).toBe(true);
    expect(nonceReduceIntent(mine)).toBe(true);
    const entry = buildNonce({ recvTimeMs: NOW, tag: 0x321, reduceIntent: false, random: 5 });
    expect(nonceReduceIntent(entry)).toBe(false);
  });

  it('treats a foreign nonce with zero client bits as not ours', () => {
    const foreign = BigInt(NOW) << 20n;
    expect(isOurNonce(foreign)).toBe(false);
    expect(isOurNonce(foreign, 0)).toBe(true); // tag 0 is legal but matches untagged orders: do not use it
  });

  it('uses the default tag when none is passed', () => {
    expect(isOurNonce(buildNonce({ recvTimeMs: NOW }))).toBe(true);
    expect(() => isOurNonce(1n, 5000)).toThrow(RangeError);
  });
});

describe('isRecvTimeWithinWindow', () => {
  it('accepts a deadline in (now, now + 100 s]', () => {
    const n = buildOrderNonce(NOW);
    expect(isRecvTimeWithinWindow(n, NOW)).toBe(true);
    expect(isRecvTimeWithinWindow(n, NOW + 60_000)).toBe(false); // expired
    expect(isRecvTimeWithinWindow(n, NOW - 50_000)).toBe(false); // clock drifted: too far ahead
    expect(isRecvTimeWithinWindow(n, NOW - 40_000)).toBe(true);
  });
});
All files