From da94b9b685ed89b4d4b0ecdd424f0c4c30505f45 Mon Sep 17 00:00:00 2001 From: Loris Leiva Date: Fri, 24 Apr 2026 14:36:50 +0100 Subject: [PATCH] Add fixed-point string and number conversions This PR adds `binaryFixedPointToString`, `decimalFixedPointToString`, `binaryFixedPointToNumber`, and `decimalFixedPointToNumber` to `@solana/fixed-points`. The string variants accept an optional `FixedPointToStringOptions` bag (`decimals`, `padTrailingZeros`, `rounding`) and throw `SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS` (with `operation: 'toString'`) when capping requires a lossy rescale under the default `'strict'` mode. The number variants are lossy escape hatches bounded by IEEE 754's ~53-bit mantissa. `binaryFixedPointToNumber` splits the raw value into integer and fractional parts before coercing, preserving exactness whenever the result magnitude fits `Number.MAX_SAFE_INTEGER` even if the raw bigint does not. --- .../src/__tests__/binary-formatting-test.ts | 134 ++++++++++++++++++ .../src/__tests__/decimal-formatting-test.ts | 105 ++++++++++++++ .../fixed-points/src/binary/formatting.ts | 74 ++++++++++ packages/fixed-points/src/binary/index.ts | 1 + .../fixed-points/src/decimal/formatting.ts | 59 ++++++++ packages/fixed-points/src/decimal/index.ts | 1 + packages/fixed-points/src/formatting.ts | 81 +++++++++++ packages/fixed-points/src/index.ts | 1 + 8 files changed, 456 insertions(+) create mode 100644 packages/fixed-points/src/__tests__/binary-formatting-test.ts create mode 100644 packages/fixed-points/src/__tests__/decimal-formatting-test.ts create mode 100644 packages/fixed-points/src/binary/formatting.ts create mode 100644 packages/fixed-points/src/decimal/formatting.ts create mode 100644 packages/fixed-points/src/formatting.ts diff --git a/packages/fixed-points/src/__tests__/binary-formatting-test.ts b/packages/fixed-points/src/__tests__/binary-formatting-test.ts new file mode 100644 index 000000000..6b5516460 --- /dev/null +++ b/packages/fixed-points/src/__tests__/binary-formatting-test.ts @@ -0,0 +1,134 @@ +import { SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS, SolanaError } from '@solana/errors'; + +import { + binaryFixedPoint, + binaryFixedPointToNumber, + binaryFixedPointToString, + ratioBinaryFixedPoint, + rawBinaryFixedPoint, +} from '../binary'; + +describe('binaryFixedPointToString', () => { + it('renders zero', () => { + expect(binaryFixedPointToString(rawBinaryFixedPoint('signed', 16, 15)(0n))).toBe('0'); + }); + + it('renders a simple one-half fraction', () => { + expect(binaryFixedPointToString(rawBinaryFixedPoint('unsigned', 8, 1)(1n))).toBe('0.5'); + }); + + it('renders a simple one-quarter fraction', () => { + expect(binaryFixedPointToString(rawBinaryFixedPoint('unsigned', 8, 2)(1n))).toBe('0.25'); + }); + + it('renders a negative half', () => { + expect(binaryFixedPointToString(rawBinaryFixedPoint('signed', 16, 15)(-16384n))).toBe('-0.5'); + }); + + it('renders an integer when fractionalBits is zero', () => { + expect(binaryFixedPointToString(rawBinaryFixedPoint('unsigned', 8, 0)(42n))).toBe('42'); + }); + + it('renders a negative integer when fractionalBits is zero', () => { + expect(binaryFixedPointToString(rawBinaryFixedPoint('signed', 8, 0)(-42n))).toBe('-42'); + }); + + it('emits the full exact decimal expansion by default', () => { + // 1 / 2 ** 15 = 0.000030517578125 exactly. + expect(binaryFixedPointToString(rawBinaryFixedPoint('unsigned', 16, 15)(1n))).toBe('0.000030517578125'); + }); + + it('renders a ratio-built value cleanly', () => { + const probability = ratioBinaryFixedPoint('signed', 16, 15); + expect(binaryFixedPointToString(probability(1n, 4n))).toBe('0.25'); + }); + + it('caps the fractional output at the requested decimals using the given rounding mode', () => { + // The raw value represents 0.480010986328125 exactly; rounded to 2 decimals → 0.48. + const ugly = rawBinaryFixedPoint('signed', 16, 15)(15729n); + expect(binaryFixedPointToString(ugly, { decimals: 2, rounding: 'round' })).toBe('0.48'); + }); + + it('trims trailing zeros even when the requested decimals is larger than necessary', () => { + const value = rawBinaryFixedPoint('unsigned', 8, 1)(1n); // 0.5 + expect(binaryFixedPointToString(value, { decimals: 6 })).toBe('0.5'); + }); + + it('pads trailing zeros up to the requested decimals when padTrailingZeros is true', () => { + const value = rawBinaryFixedPoint('unsigned', 8, 1)(1n); // 0.5 + expect(binaryFixedPointToString(value, { decimals: 6, padTrailingZeros: true })).toBe('0.500000'); + }); + + it('pads trailing zeros up to the native fractionalBits when padTrailingZeros is true and decimals is omitted', () => { + const value = rawBinaryFixedPoint('unsigned', 8, 1)(1n); // 0.5 with a single fractional bit. + expect(binaryFixedPointToString(value, { padTrailingZeros: true })).toBe('0.5'); + }); + + it('pads trailing zeros up to fractionalBits for a longer native scale', () => { + const value = binaryFixedPoint('signed', 16, 15)('0.5'); + expect(binaryFixedPointToString(value, { padTrailingZeros: true })).toBe('0.500000000000000'); + }); + + it('pads whole numbers with trailing zeros when padTrailingZeros is true', () => { + const value = rawBinaryFixedPoint('unsigned', 8, 1)(0n); + expect(binaryFixedPointToString(value, { decimals: 3, padTrailingZeros: true })).toBe('0.000'); + }); + + it('throws STRICT_MODE_PRECISION_LOSS when a lossy cap is requested without a rounding mode', () => { + // 1 / 2 ** 15 cannot be represented at 2 decimals without loss. + const value = rawBinaryFixedPoint('unsigned', 16, 15)(1n); + expect(() => binaryFixedPointToString(value, { decimals: 2 })).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS, { + kind: 'binaryFixedPoint', + operation: 'toString', + }), + ); + }); + + it('does not throw when capping at the same number of decimals as the native expansion', () => { + const value = rawBinaryFixedPoint('unsigned', 16, 15)(1n); + expect(binaryFixedPointToString(value, { decimals: 15 })).toBe('0.000030517578125'); + }); +}); + +describe('binaryFixedPointToNumber', () => { + it('returns zero for zero', () => { + expect(binaryFixedPointToNumber(rawBinaryFixedPoint('signed', 16, 15)(0n))).toBe(0); + }); + + it('returns one half for a raw value halfway to one', () => { + expect(binaryFixedPointToNumber(rawBinaryFixedPoint('unsigned', 8, 1)(1n))).toBe(0.5); + }); + + it('returns one quarter for a raw value a quarter of the way to one', () => { + expect(binaryFixedPointToNumber(rawBinaryFixedPoint('unsigned', 8, 2)(1n))).toBe(0.25); + }); + + it('returns negative one half for a negative half raw value', () => { + expect(binaryFixedPointToNumber(rawBinaryFixedPoint('signed', 16, 15)(-16384n))).toBe(-0.5); + }); + + it('returns the unscaled raw as a number when fractionalBits is zero', () => { + expect(binaryFixedPointToNumber(rawBinaryFixedPoint('signed', 8, 0)(-42n))).toBe(-42); + }); + + it('returns a very small but finite number for large fractionalBits', () => { + expect(binaryFixedPointToNumber(rawBinaryFixedPoint('unsigned', 64, 53)(1n))).toBe(2 ** -53); + }); + + it('preserves low-order bits when the raw value exceeds Number.MAX_SAFE_INTEGER but the result fits', () => { + // raw = 2 ** 60 + (2 ** 20 - 1) at fractionalBits = 20 represents + // 2 ** 40 + (2 ** 20 - 1) / 2 ** 20, which fits within 53 bits of mantissa. + // A naive `Number(raw) / 2 ** 20` would round `raw` at bit level 8 and + // return 2 ** 40 + 1; the split preserves the fractional part. + const raw = (1n << 60n) + ((1n << 20n) - 1n); + const value = rawBinaryFixedPoint('unsigned', 128, 20)(raw); + expect(binaryFixedPointToNumber(value)).toBe(2 ** 40 + (2 ** 20 - 1) / 2 ** 20); + }); + + it('preserves low-order bits for negative values that exceed Number.MAX_SAFE_INTEGER', () => { + const raw = -((1n << 60n) + ((1n << 20n) - 1n)); + const value = rawBinaryFixedPoint('signed', 128, 20)(raw); + expect(binaryFixedPointToNumber(value)).toBe(-(2 ** 40 + (2 ** 20 - 1) / 2 ** 20)); + }); +}); diff --git a/packages/fixed-points/src/__tests__/decimal-formatting-test.ts b/packages/fixed-points/src/__tests__/decimal-formatting-test.ts new file mode 100644 index 000000000..2836d816a --- /dev/null +++ b/packages/fixed-points/src/__tests__/decimal-formatting-test.ts @@ -0,0 +1,105 @@ +import { SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS, SolanaError } from '@solana/errors'; + +import { decimalFixedPointToNumber, decimalFixedPointToString, rawDecimalFixedPoint } from '../decimal'; + +describe('decimalFixedPointToString', () => { + it('renders zero', () => { + expect(decimalFixedPointToString(rawDecimalFixedPoint('unsigned', 64, 2)(0n))).toBe('0'); + }); + + it('renders a whole number with trailing zeros trimmed', () => { + expect(decimalFixedPointToString(rawDecimalFixedPoint('unsigned', 64, 6)(42_000_000n))).toBe('42'); + }); + + it('renders a clean fractional value with trailing zeros trimmed', () => { + expect(decimalFixedPointToString(rawDecimalFixedPoint('unsigned', 64, 6)(42_500_000n))).toBe('42.5'); + }); + + it('renders a sub-unit fraction with a leading zero padding', () => { + expect(decimalFixedPointToString(rawDecimalFixedPoint('unsigned', 16, 2)(5n))).toBe('0.05'); + }); + + it('renders a negative value with a leading sign', () => { + expect(decimalFixedPointToString(rawDecimalFixedPoint('signed', 16, 2)(-5n))).toBe('-0.05'); + }); + + it('renders an integer when decimals is zero', () => { + expect(decimalFixedPointToString(rawDecimalFixedPoint('unsigned', 8, 0)(42n))).toBe('42'); + }); + + it('renders values whose raw exceeds Number.MAX_SAFE_INTEGER correctly', () => { + // 10 ** 20 / 10 ** 6 = 10 ** 14 = 100000000000000. + const value = rawDecimalFixedPoint('unsigned', 128, 6)(10n ** 20n); + expect(decimalFixedPointToString(value)).toBe('100000000000000'); + }); + + it('caps the fractional output at the requested decimals using the given rounding mode', () => { + // 42.678 at d3 → 2 decimals with floor → 42.67. + const value = rawDecimalFixedPoint('unsigned', 64, 3)(42_678n); + expect(decimalFixedPointToString(value, { decimals: 2, rounding: 'floor' })).toBe('42.67'); + }); + + it('rounds half values away from zero under the round mode', () => { + const value = rawDecimalFixedPoint('unsigned', 64, 1)(425n); // 42.5 + expect(decimalFixedPointToString(value, { decimals: 0, rounding: 'round' })).toBe('43'); + }); + + it('trims trailing zeros even when the requested decimals is larger than the native scale', () => { + const value = rawDecimalFixedPoint('unsigned', 64, 2)(4250n); // 42.5 + expect(decimalFixedPointToString(value, { decimals: 10 })).toBe('42.5'); + }); + + it('pads trailing zeros up to the requested decimals when padTrailingZeros is true', () => { + const value = rawDecimalFixedPoint('unsigned', 64, 2)(4250n); // 42.5 + expect(decimalFixedPointToString(value, { decimals: 6, padTrailingZeros: true })).toBe('42.500000'); + }); + + it('pads trailing zeros up to the native decimals when padTrailingZeros is true and decimals is omitted', () => { + const value = rawDecimalFixedPoint('unsigned', 64, 6)(42_500_000n); // 42.5 at d6 + expect(decimalFixedPointToString(value, { padTrailingZeros: true })).toBe('42.500000'); + }); + + it('pads whole numbers with trailing zeros when padTrailingZeros is true', () => { + const value = rawDecimalFixedPoint('unsigned', 64, 6)(0n); + expect(decimalFixedPointToString(value, { padTrailingZeros: true })).toBe('0.000000'); + }); + + it('throws STRICT_MODE_PRECISION_LOSS when a lossy cap is requested without a rounding mode', () => { + const value = rawDecimalFixedPoint('unsigned', 64, 1)(425n); // 42.5 + expect(() => decimalFixedPointToString(value, { decimals: 0 })).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS, { + kind: 'decimalFixedPoint', + operation: 'toString', + }), + ); + }); + + it('does not throw when capping at the same number of decimals as the native scale', () => { + const value = rawDecimalFixedPoint('unsigned', 64, 2)(4250n); + expect(decimalFixedPointToString(value, { decimals: 2 })).toBe('42.5'); + }); +}); + +describe('decimalFixedPointToNumber', () => { + it('returns zero for zero', () => { + expect(decimalFixedPointToNumber(rawDecimalFixedPoint('unsigned', 64, 2)(0n))).toBe(0); + }); + + it('returns 42.5 for raw 4250 at d2', () => { + expect(decimalFixedPointToNumber(rawDecimalFixedPoint('unsigned', 64, 2)(4250n))).toBe(42.5); + }); + + it('returns -0.05 for raw -5 at d2', () => { + expect(decimalFixedPointToNumber(rawDecimalFixedPoint('signed', 16, 2)(-5n))).toBe(-0.05); + }); + + it('returns the unscaled raw as a number when decimals is zero', () => { + expect(decimalFixedPointToNumber(rawDecimalFixedPoint('signed', 8, 0)(-42n))).toBe(-42); + }); + + it('returns an approximate finite number when the raw exceeds Number.MAX_SAFE_INTEGER', () => { + // 10 ** 20 / 10 ** 6 = 10 ** 14 = 100000000000000; the exact value fits Number cleanly. + const value = rawDecimalFixedPoint('unsigned', 128, 6)(10n ** 20n); + expect(decimalFixedPointToNumber(value)).toBeCloseTo(1e14, -2); + }); +}); diff --git a/packages/fixed-points/src/binary/formatting.ts b/packages/fixed-points/src/binary/formatting.ts new file mode 100644 index 000000000..7bd7ea277 --- /dev/null +++ b/packages/fixed-points/src/binary/formatting.ts @@ -0,0 +1,74 @@ +import { applyDecimalsOption, type FixedPointToStringOptions, formatScaledBigint } from '../formatting'; +import type { Signedness } from '../signedness'; +import type { BinaryFixedPoint } from './core'; + +/** + * Returns the canonical decimal string representation of a + * {@link BinaryFixedPoint}. + * + * Because `1 / 2 ** fractionalBits` has a finite decimal expansion, the + * default output is always exact. This means that values with many + * `fractionalBits` can produce long strings — pass `options.decimals` to + * cap the output at a desired precision, optionally with a + * {@link RoundingMode}. Use `options.padTrailingZeros` to emit exactly as + * many fractional digits as requested; when `decimals` is omitted, this + * pads to `value.fractionalBits` (the full exact expansion length). + * + * Throws `SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS` when + * `options.decimals` forces a lossy rescale under the default `'strict'` + * rounding mode. + * + * @example + * ```ts + * const q1_15 = binaryFixedPoint('signed', 16, 15); + * binaryFixedPointToString(q1_15('0.5')); // "0.5" + * binaryFixedPointToString(q1_15('0.5'), { padTrailingZeros: true }); // "0.500000000000000" + * binaryFixedPointToString(ugly, { decimals: 2, rounding: 'round' }); // "0.48" + * ``` + * + * @see {@link binaryFixedPointToNumber} + */ +export function binaryFixedPointToString( + value: BinaryFixedPoint, + options?: FixedPointToStringOptions, +): string { + // Convert the base-2 representation to an exact base-10 representation: + // raw / 2 ** F === (raw * 5 ** F) / 10 ** F, which terminates cleanly. + // The transformed raw carries exactly F decimal digits of precision. + const base10Decimals = value.fractionalBits; + const base10Raw = base10Decimals === 0 ? value.raw : value.raw * 5n ** BigInt(base10Decimals); + const { decimals, raw } = applyDecimalsOption('binaryFixedPoint', base10Raw, base10Decimals, options); + return formatScaledBigint(raw, decimals, options?.padTrailingZeros ?? false); +} + +/** + * Converts a {@link BinaryFixedPoint} to a JavaScript `number`. + * + * Precision loss occurs only when `|value.raw / 2 ** fractionalBits|` + * exceeds `Number.MAX_SAFE_INTEGER`, since JavaScript numbers have only + * ~53 bits of mantissa. For values whose magnitude fits that budget the + * result is exact, regardless of the raw value's magnitude. + * + * For exact representations prefer {@link binaryFixedPointToString}. + * + * @example + * ```ts + * const q1_15 = binaryFixedPoint('signed', 16, 15); + * binaryFixedPointToNumber(q1_15('0.5')); // 0.5 + * ``` + * + * @see {@link binaryFixedPointToString} + */ +export function binaryFixedPointToNumber(value: BinaryFixedPoint): number { + const { fractionalBits, raw } = value; + if (fractionalBits === 0) { + return Number(raw); + } + // Split `raw` into an integer and a fractional residue before coercing to + // Number. This preserves exactness for values whose final magnitude fits + // ~53 bits of mantissa even when `|raw|` itself exceeds MAX_SAFE_INTEGER. + const scale = 1n << BigInt(fractionalBits); + const integerPart = raw / scale; + const fractionalPart = Number(raw - integerPart * scale) / 2 ** fractionalBits; + return Number(integerPart) + fractionalPart; +} diff --git a/packages/fixed-points/src/binary/index.ts b/packages/fixed-points/src/binary/index.ts index 409a55ebf..15ea0b33d 100644 --- a/packages/fixed-points/src/binary/index.ts +++ b/packages/fixed-points/src/binary/index.ts @@ -2,4 +2,5 @@ export * from './arithmetics'; export * from './comparisons'; export * from './conversions'; export * from './core'; +export * from './formatting'; export * from './guards'; diff --git a/packages/fixed-points/src/decimal/formatting.ts b/packages/fixed-points/src/decimal/formatting.ts new file mode 100644 index 000000000..bb8875399 --- /dev/null +++ b/packages/fixed-points/src/decimal/formatting.ts @@ -0,0 +1,59 @@ +import { applyDecimalsOption, type FixedPointToStringOptions, formatScaledBigint } from '../formatting'; +import type { Signedness } from '../signedness'; +import type { DecimalFixedPoint } from './core'; + +/** + * Returns the canonical decimal string representation of a + * {@link DecimalFixedPoint}. + * + * By default, trailing zeros are trimmed and the decimal point is + * dropped for whole numbers. Pass `options.decimals` to emit a different + * number of fractional digits (with {@link RoundingMode} control when + * scale-down is lossy), and `options.padTrailingZeros` to emit exactly + * that many digits. When `padTrailingZeros` is set without `decimals`, + * the output is padded to `value.decimals`. + * + * Throws `SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS` when + * `options.decimals` forces a lossy rescale under the default `'strict'` + * rounding mode. + * + * @example + * ```ts + * const usdc = decimalFixedPoint('unsigned', 64, 6); + * decimalFixedPointToString(usdc('42.5')); // "42.5" + * decimalFixedPointToString(usdc('42.5'), { padTrailingZeros: true }); // "42.500000" + * decimalFixedPointToString(usdc('42.678'), { decimals: 2, rounding: 'floor' }); // "42.67" + * ``` + * + * @see {@link decimalFixedPointToNumber} + */ +export function decimalFixedPointToString( + value: DecimalFixedPoint, + options?: FixedPointToStringOptions, +): string { + const { decimals, raw } = applyDecimalsOption('decimalFixedPoint', value.raw, value.decimals, options); + return formatScaledBigint(raw, decimals, options?.padTrailingZeros ?? false); +} + +/** + * Converts a {@link DecimalFixedPoint} to a JavaScript `number`. + * + * This conversion is inherently lossy: `1 / 10 ** decimals` is not + * representable exactly in IEEE 754 for any positive `decimals`, and + * additional precision is lost when `|value.raw|` exceeds + * `Number.MAX_SAFE_INTEGER`, since JavaScript numbers have only ~53 + * bits of mantissa. + * + * For exact representations prefer {@link decimalFixedPointToString}. + * + * @example + * ```ts + * const usdc = decimalFixedPoint('unsigned', 64, 6); + * decimalFixedPointToNumber(usdc('42.5')); // 42.5 + * ``` + * + * @see {@link decimalFixedPointToString} + */ +export function decimalFixedPointToNumber(value: DecimalFixedPoint): number { + return Number(value.raw) / 10 ** value.decimals; +} diff --git a/packages/fixed-points/src/decimal/index.ts b/packages/fixed-points/src/decimal/index.ts index 409a55ebf..15ea0b33d 100644 --- a/packages/fixed-points/src/decimal/index.ts +++ b/packages/fixed-points/src/decimal/index.ts @@ -2,4 +2,5 @@ export * from './arithmetics'; export * from './comparisons'; export * from './conversions'; export * from './core'; +export * from './formatting'; export * from './guards'; diff --git a/packages/fixed-points/src/formatting.ts b/packages/fixed-points/src/formatting.ts new file mode 100644 index 000000000..5ca1286c6 --- /dev/null +++ b/packages/fixed-points/src/formatting.ts @@ -0,0 +1,81 @@ +import { roundDivision, type RoundingMode } from './rounding'; + +/** + * Options accepted by `binaryFixedPointToString` and + * `decimalFixedPointToString` to control the emitted representation. + * + * - `decimals`: caps the number of fractional digits in the output. When + * this is lower than the value's native precision the raw value is + * rescaled using `rounding` (defaults to `'strict'`, which throws + * `SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS` on inexact + * results). When higher, the extra precision is zero-padded only if + * `padTrailingZeros` is also set. + * - `padTrailingZeros`: emits exactly as many fractional digits as + * requested by `decimals`. When `decimals` is omitted, pads to the + * value's native scale (`decimals` for decimal values, + * `fractionalBits` for binary values — the length of the exact + * base-10 expansion). Defaults to `false`, which trims trailing zeros + * (and drops the decimal point altogether for whole numbers). + * - `rounding`: only consulted when `decimals` forces a scale-down. + * Defaults to `'strict'`. + */ +export type FixedPointToStringOptions = { + decimals?: number; + padTrailingZeros?: boolean; + rounding?: RoundingMode; +}; + +/** + * Rescales `raw` from `currentDecimals` decimal digits to `options.decimals` + * decimal digits (when set), respecting `options.rounding`. Returns the + * raw value to format and the number of fractional digits implied by it. + * + * @internal + */ +export function applyDecimalsOption( + kind: 'binaryFixedPoint' | 'decimalFixedPoint', + raw: bigint, + currentDecimals: number, + options: FixedPointToStringOptions | undefined, +): { decimals: number; raw: bigint } { + const targetDecimals = options?.decimals; + if (targetDecimals === undefined || targetDecimals === currentDecimals) { + return { decimals: currentDecimals, raw }; + } + if (targetDecimals > currentDecimals) { + return { + decimals: targetDecimals, + raw: raw * 10n ** BigInt(targetDecimals - currentDecimals), + }; + } + const divisor = 10n ** BigInt(currentDecimals - targetDecimals); + const rescaled = roundDivision(kind, 'toString', raw, divisor, options?.rounding ?? 'strict'); + return { decimals: targetDecimals, raw: rescaled }; +} + +/** + * Formats a scaled bigint `(raw, decimals)` as a canonical decimal + * string. When `padTrailingZeros` is `true`, the output emits exactly + * `decimals` fractional digits; otherwise trailing zeros are trimmed and + * the decimal point is dropped if the fractional part becomes empty. + * + * @internal + */ +export function formatScaledBigint(raw: bigint, decimals: number, padTrailingZeros: boolean): string { + if (decimals === 0) { + return raw.toString(); + } + const isNegative = raw < 0n; + const absDigits = (isNegative ? -raw : raw).toString(); + const padded = absDigits.padStart(decimals + 1, '0'); + const integerPart = padded.slice(0, -decimals); + let fractionalPart = padded.slice(-decimals); + if (!padTrailingZeros) { + fractionalPart = fractionalPart.replace(/0+$/, ''); + } + const sign = isNegative ? '-' : ''; + if (fractionalPart.length === 0) { + return `${sign}${integerPart}`; + } + return `${sign}${integerPart}.${fractionalPart}`; +} diff --git a/packages/fixed-points/src/index.ts b/packages/fixed-points/src/index.ts index fc6f91533..76cad6e4b 100644 --- a/packages/fixed-points/src/index.ts +++ b/packages/fixed-points/src/index.ts @@ -12,5 +12,6 @@ */ export * from './binary'; export * from './decimal'; +export type { FixedPointToStringOptions } from './formatting'; export type { RoundingMode } from './rounding'; export * from './signedness';