Skip to content
markpaper

src/numbers/x18.ts

v0.2.0 · 6.6 KB

Download file
// x18 fixed-point numbers: Nado's wire format is decimal STRINGS of value * 1e18 (prices carry an
// `_x18` suffix, `size_increment` / `min_size` / `amount` do not, but the scale is the same).
//
// Every parser here is fail-closed: a malformed field throws instead of becoming NaN. A raw
// `Number('1e20')` of `min_size` reads as "minimum $100 quintillion"; nonces (~1.87e18) are above
// 2^53 and are destroyed by `JSON.parse` if the gateway ever sent them as numbers.

/** 10^18 as a bigint. */
export const X18 = 10n ** 18n;

const INT_RE = /^-?\d+$/;
const UINT_RE = /^\d+$/;
const DECIMAL_RE = /^(-?)(\d+)(?:\.(\d{1,18}))?$/;

/**
 * Strict x18 string -> bigint. Accepts an integer string (optionally negative) or an integer number that
 * is a safe integer; anything else throws with `label` in the message.
 *
 * @throws TypeError when the value is not a plain integer string.
 */
export function x18ToBigInt(value: unknown, label = 'value'): bigint {
  if (typeof value === 'bigint') return value;
  if (typeof value === 'number') {
    if (!Number.isSafeInteger(value)) throw new TypeError(`${label}=${String(value)} is not a safe integer`);
    return BigInt(value);
  }
  if (typeof value !== 'string') throw new TypeError(`${label} is not a numeric string`);
  const s = value.trim();
  if (!INT_RE.test(s)) throw new TypeError(`${label}="${s}" is not an integer x18 string`);
  return BigInt(s);
}

/**
 * Strict unsigned integer string -> bigint (nonces, ids, timestamps). Nonces in `orders` responses are
 * larger than 2^53: read them from the string, never through `Number`.
 *
 * @throws TypeError when the value is not a plain unsigned integer string.
 */
export function parseUint(value: unknown, label = 'value'): bigint {
  if (typeof value === 'bigint') {
    if (value < 0n) throw new TypeError(`${label}=${value} is negative`);
    return value;
  }
  if (typeof value === 'number') {
    if (!Number.isSafeInteger(value) || value < 0)
      throw new TypeError(`${label}=${String(value)} is not an unsigned safe integer`);
    return BigInt(value);
  }
  if (typeof value !== 'string') throw new TypeError(`${label} is not a numeric string`);
  const s = value.trim();
  if (!UINT_RE.test(s)) throw new TypeError(`${label}="${s}" is not an unsigned integer string`);
  return BigInt(s);
}

/** Exact decimal string of an x18 bigint (no float involved), trailing zeros trimmed: `-50000000000000n` -> `'-0.00005'`. */
export function x18ToDecimalString(v: bigint): string {
  const neg = v < 0n;
  const abs = neg ? -v : v;
  const whole = abs / X18;
  const frac = abs % X18;
  if (frac === 0n) return `${neg ? '-' : ''}${whole}`;
  const fracStr = frac.toString().padStart(18, '0').replace(/0+$/, '');
  return `${neg ? '-' : ''}${whole}.${fracStr}`;
}

/**
 * x18 -> Number through the exact decimal string. `Number(bigint)` directly rounds differently from
 * `Number(decimalString)` in edge cases; doubles are plenty for prices, sizes and notionals.
 *
 * @throws RangeError when the value does not fit a finite double.
 */
export function x18ToNumber(v: bigint): number {
  const n = Number(x18ToDecimalString(v));
  if (!Number.isFinite(n)) throw new RangeError(`x18 value ${v} does not fit a double`);
  return n;
}

/** Strict x18 string -> Number in one step (`x18ToNumber(x18ToBigInt(value))`). */
export function parseX18(value: unknown, label = 'value'): number {
  return x18ToNumber(x18ToBigInt(value, label));
}

/**
 * Exact plain-decimal string -> x18 bigint: `'0.00005'` -> `50000000000000n`. Exponent notation and more
 * than 18 decimal places are rejected (they cannot be represented on the wire).
 *
 * @throws TypeError when the string is not a plain decimal.
 */
export function decimalToX18(s: string): bigint {
  if (typeof s !== 'string') throw new TypeError(`decimalToX18: expected a string, got ${typeof s}`);
  const m = DECIMAL_RE.exec(s.trim());
  if (!m) throw new TypeError(`decimalToX18: "${s}" is not a plain decimal`);
  const sign = m[1] === '-' ? -1n : 1n;
  const whole = BigInt(m[2] as string);
  const frac = BigInt((m[3] ?? '').padEnd(18, '0') || '0');
  return sign * (whole * X18 + frac);
}

/**
 * JS number -> x18 bigint through the shortest round-trip decimal representation (`String(n)`), so
 * `0.1` becomes exactly `100000000000000000n` and not the binary expansion `0.1000000000000000055…`.
 * Numbers with an exponent (`1e-7`, `1e21`) are expanded first.
 *
 * Prefer {@link decimalToX18} for values that come as strings (config, wire); use this only for values
 * that already live as doubles (mid prices, computed sizes) and quantize the result before sending.
 *
 * @throws TypeError when the number is not finite or needs more than 18 decimal places.
 */
export function numberToX18(n: number): bigint {
  if (typeof n !== 'number' || !Number.isFinite(n)) throw new TypeError(`numberToX18: ${String(n)} is not finite`);
  return decimalToX18(expandExponent(String(n)));
}

/** `'1e-7'` -> `'0.0000001'`, `'1.5e21'` -> `'1500000000000000000000'`; plain decimals pass through. */
function expandExponent(s: string): string {
  const m = /^(-?)(\d+)(?:\.(\d+))?e([+-]?\d+)$/i.exec(s);
  if (!m) return s;
  const sign = m[1] as string;
  const intPart = m[2] as string;
  const fracPart = m[3] ?? '';
  const exp = Number(m[4]);
  const digits = intPart + fracPart;
  const pointAt = intPart.length + exp;
  if (pointAt <= 0) return `${sign}0.${'0'.repeat(-pointAt)}${digits}`;
  if (pointAt >= digits.length) return `${sign}${digits}${'0'.repeat(pointAt - digits.length)}`;
  return `${sign}${digits.slice(0, pointAt)}.${digits.slice(pointAt)}`;
}

/**
 * Decimal places of an x18 step (tick or lot): `1e18` (1) -> 0, `5e13` (0.00005) -> 5, `5e18` (5) -> 0.
 * Only for display: the real grid on Nado is the step itself, which is not a power of ten.
 *
 * @throws RangeError when the step is not positive.
 */
export function stepDecimals(stepX18: bigint): number {
  if (stepX18 <= 0n) throw new RangeError(`invalid step ${stepX18}`);
  let v = stepX18;
  let trailingZeros = 0;
  while (v % 10n === 0n && trailingZeros < 18) {
    v /= 10n;
    trailingZeros++;
  }
  return Math.max(0, 18 - trailingZeros);
}

/** Absolute value of a bigint. */
export function absX18(v: bigint): bigint {
  return v < 0n ? -v : v;
}

/** Notional (quote units, x18) of `|price * size|`: both inputs x18, the product is scaled back once. */
export function notionalX18(priceX18: bigint, sizeX18: bigint): bigint {
  return absX18(priceX18 * sizeX18) / X18;
}

/**
 * Wire representation of a bigint field (`priceX18`, `amount`, `expiration`, `nonce`, `appendix`):
 * a decimal integer string. Typed data keeps the bigint; the JSON body needs the string.
 */
export function toWire(v: bigint): string {
  return v.toString(10);
}
All files