Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions packages/fixed-points/src/__tests__/binary-formatting-test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
});
105 changes: 105 additions & 0 deletions packages/fixed-points/src/__tests__/decimal-formatting-test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
74 changes: 74 additions & 0 deletions packages/fixed-points/src/binary/formatting.ts
Original file line number Diff line number Diff line change
@@ -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<Signedness, number, number>,
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<Signedness, number, number>): 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;
}
1 change: 1 addition & 0 deletions packages/fixed-points/src/binary/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export * from './arithmetics';
export * from './comparisons';
export * from './conversions';
export * from './core';
export * from './formatting';
export * from './guards';
59 changes: 59 additions & 0 deletions packages/fixed-points/src/decimal/formatting.ts
Original file line number Diff line number Diff line change
@@ -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<Signedness, number, number>,
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<Signedness, number, number>): number {
return Number(value.raw) / 10 ** value.decimals;
}
1 change: 1 addition & 0 deletions packages/fixed-points/src/decimal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export * from './arithmetics';
export * from './comparisons';
export * from './conversions';
export * from './core';
export * from './formatting';
export * from './guards';
Loading
Loading