src/numbers/x18.ts
v0.2.0 · 6.6 KB
// 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);
}