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
21 changes: 21 additions & 0 deletions packages/fixed-points/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,27 @@ decimalFixedPointToString(usdc('42.5'), { padTrailingZeros: true }); // "42.5000
decimalFixedPointToNumber(usdc('42.5')); // 42.5
```

For locale-aware output, pass any `Intl.NumberFormat` instance to `formatDecimalFixedPoint` or `formatBinaryFixedPoint`. The helpers route the raw bigint through string scientific notation so precision is preserved beyond JavaScript's `number` mantissa.

```ts
const eurc = decimalFixedPoint('unsigned', 64, 6);
const eurFormatter = new Intl.NumberFormat('de-DE', { currency: 'EUR', style: 'currency' });
formatDecimalFixedPoint(eurFormatter, eurc('1234.5')); // "1.234,50 €"
```

The same helper exists for binary fixed-points.

```ts
const fr = new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 4 });
formatBinaryFixedPoint(fr, audioSample('0.1')); // "0,1"
```

To plug a binary fixed-point into a custom formatter, convert it to its exact base-10 representation with `binaryFixedPointToBase10` first. Decimal fixed-points already carry `raw` and `decimals` directly on the value object, so no equivalent helper is needed for them.

```ts
binaryFixedPointToBase10(audioSample('0.5')); // { raw: 500000000000000n, decimals: 15 }
```

## Type guards

The `is*` and `assertIs*` guards narrow an unknown value to a fixed-point. All shape arguments are optional — pass `undefined` for any dimension you don't care about.
Expand Down
44 changes: 44 additions & 0 deletions packages/fixed-points/src/__tests__/binary-conversions-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,56 @@ import {

import {
binaryFixedPoint,
binaryFixedPointToBase10,
rawBinaryFixedPoint,
rescaleBinaryFixedPoint,
toSignedBinaryFixedPoint,
toUnsignedBinaryFixedPoint,
} from '../binary';

describe('binaryFixedPointToBase10', () => {
it('returns a zero raw and the value fractionalBits as decimals for zero', () => {
const value = rawBinaryFixedPoint('signed', 16, 15)(0n);
expect(binaryFixedPointToBase10(value)).toStrictEqual({ decimals: 15, raw: 0n });
});

it('returns the input raw unchanged when fractionalBits is zero', () => {
const value = rawBinaryFixedPoint('signed', 8, 0)(-42n);
expect(binaryFixedPointToBase10(value)).toStrictEqual({ decimals: 0, raw: -42n });
});

it('scales the raw by 5 ** fractionalBits to reach base 10', () => {
// raw 1 at QX.1 represents 0.5 → (1 * 5) / 10 = 0.5.
const value = rawBinaryFixedPoint('unsigned', 8, 1)(1n);
expect(binaryFixedPointToBase10(value)).toStrictEqual({ decimals: 1, raw: 5n });
});

it('preserves the sign of negative values', () => {
// raw -16384 at Q1.15 represents -0.5 → (-16384 * 5 ** 15) / 10 ** 15.
const value = rawBinaryFixedPoint('signed', 16, 15)(-16384n);
const base10 = binaryFixedPointToBase10(value);
expect(base10.decimals).toBe(15);
expect(base10.raw).toBe(-500000000000000n);
});

it('produces the full exact decimal expansion for a smallest-unit value', () => {
// 1 / 2 ** 15 = 0.000030517578125 = 30517578125 / 10 ** 15.
const value = rawBinaryFixedPoint('unsigned', 16, 15)(1n);
expect(binaryFixedPointToBase10(value)).toStrictEqual({ decimals: 15, raw: 30517578125n });
});

it('handles raw values that exceed Number.MAX_SAFE_INTEGER', () => {
// raw 2 ** 60 at QX.20 represents 2 ** 40 → (2 ** 60) * 5 ** 20 / 10 ** 20.
const raw = 1n << 60n;
const value = rawBinaryFixedPoint('unsigned', 128, 20)(raw);
const base10 = binaryFixedPointToBase10(value);
expect(base10.decimals).toBe(20);
expect(base10.raw).toBe(raw * 5n ** 20n);
// Sanity-check the round trip: dividing by 10 ** 20 yields 2 ** 40.
expect(base10.raw / 10n ** 20n).toBe(1n << 40n);
});
});

describe('toUnsignedBinaryFixedPoint', () => {
it('converts a signed non-negative value to unsigned', () => {
const signed = rawBinaryFixedPoint('signed', 8, 4)(100n);
Expand Down
73 changes: 73 additions & 0 deletions packages/fixed-points/src/__tests__/binary-formatting-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
binaryFixedPoint,
binaryFixedPointToNumber,
binaryFixedPointToString,
formatBinaryFixedPoint,
ratioBinaryFixedPoint,
rawBinaryFixedPoint,
} from '../binary';
Expand Down Expand Up @@ -91,6 +92,78 @@ describe('binaryFixedPointToString', () => {
});
});

describe('formatBinaryFixedPoint', () => {
it('formats zero with a default formatter', () => {
const formatter = new Intl.NumberFormat('en-US');
const value = rawBinaryFixedPoint('signed', 16, 15)(0n);
expect(formatBinaryFixedPoint(formatter, value)).toBe('0');
});

it('formats a simple fraction with a default formatter', () => {
const formatter = new Intl.NumberFormat('en-US');
const value = binaryFixedPoint('signed', 16, 15)('0.5');
expect(formatBinaryFixedPoint(formatter, value)).toBe('0.5');
});

it('formats negative values with the formatter sign convention', () => {
const formatter = new Intl.NumberFormat('en-US');
const value = rawBinaryFixedPoint('signed', 16, 15)(-16384n);
expect(formatBinaryFixedPoint(formatter, value)).toBe('-0.5');
});

it('respects a non-default locale', () => {
const formatter = new Intl.NumberFormat('fr-FR');
const value = binaryFixedPoint('signed', 16, 15)('0.5');
// French uses ',' as the decimal separator.
expect(formatBinaryFixedPoint(formatter, value)).toBe('0,5');
});

it('honours the formatter rounding via maximumFractionDigits', () => {
// 1 / 2 ** 15 = 0.000030517578125 — capped to 4 fractional digits → '0'.
const formatter = new Intl.NumberFormat('en-US', { maximumFractionDigits: 4 });
const value = rawBinaryFixedPoint('unsigned', 16, 15)(1n);
expect(formatBinaryFixedPoint(formatter, value)).toBe('0');
});

it('pads trailing zeros via minimumFractionDigits', () => {
const formatter = new Intl.NumberFormat('en-US', { minimumFractionDigits: 4 });
const value = binaryFixedPoint('signed', 16, 15)('0.5');
expect(formatBinaryFixedPoint(formatter, value)).toBe('0.5000');
});

it('renders the full exact decimal expansion when minimumFractionDigits matches fractionalBits', () => {
const formatter = new Intl.NumberFormat('en-US', {
maximumFractionDigits: 15,
minimumFractionDigits: 15,
useGrouping: false,
});
const value = rawBinaryFixedPoint('unsigned', 16, 15)(1n);
expect(formatBinaryFixedPoint(formatter, value)).toBe('0.000030517578125');
});

it('preserves precision when the raw value exceeds Number.MAX_SAFE_INTEGER', () => {
// raw 2 ** 60 at QX.20 represents exactly 2 ** 40 = 1099511627776.
const formatter = new Intl.NumberFormat('en-US', { useGrouping: false });
const value = rawBinaryFixedPoint('unsigned', 128, 20)(1n << 60n);
expect(formatBinaryFixedPoint(formatter, value)).toBe('1099511627776');
});

it('combines locale, currency style, grouping, and fraction digits', () => {
const formatter = new Intl.NumberFormat('de-DE', {
currency: 'EUR',
maximumFractionDigits: 2,
minimumFractionDigits: 2,
style: 'currency',
useGrouping: true,
});
// raw 32_768_000 at QX.15 represents exactly 1000.0 (32_768_000 / 2 ** 15 = 1000).
const value = rawBinaryFixedPoint('unsigned', 32, 15)(32_768_000n);
// German uses '.' for grouping, ',' as the decimal separator, and '€' as the currency suffix.
// ICU emits a no-break space (U+00A0) between the number and the currency symbol.
expect(formatBinaryFixedPoint(formatter, value)).toBe('1.000,00\u00A0€');
});
});

describe('binaryFixedPointToNumber', () => {
it('returns zero for zero', () => {
expect(binaryFixedPointToNumber(rawBinaryFixedPoint('signed', 16, 15)(0n))).toBe(0);
Expand Down
75 changes: 74 additions & 1 deletion packages/fixed-points/src/__tests__/decimal-formatting-test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS, SolanaError } from '@solana/errors';

import { decimalFixedPointToNumber, decimalFixedPointToString, rawDecimalFixedPoint } from '../decimal';
import {
decimalFixedPoint,
decimalFixedPointToNumber,
decimalFixedPointToString,
formatDecimalFixedPoint,
rawDecimalFixedPoint,
} from '../decimal';

describe('decimalFixedPointToString', () => {
it('renders zero', () => {
Expand Down Expand Up @@ -80,6 +86,73 @@ describe('decimalFixedPointToString', () => {
});
});

describe('formatDecimalFixedPoint', () => {
it('formats zero with a default formatter', () => {
const formatter = new Intl.NumberFormat('en-US');
const value = rawDecimalFixedPoint('unsigned', 64, 6)(0n);
expect(formatDecimalFixedPoint(formatter, value)).toBe('0');
});

it('formats a simple value with a default formatter', () => {
const formatter = new Intl.NumberFormat('en-US');
const value = decimalFixedPoint('unsigned', 64, 6)('42.5');
expect(formatDecimalFixedPoint(formatter, value)).toBe('42.5');
});

it('formats negative values with the formatter sign convention', () => {
const formatter = new Intl.NumberFormat('en-US');
const value = decimalFixedPoint('signed', 64, 2)('-0.05');
expect(formatDecimalFixedPoint(formatter, value)).toBe('-0.05');
});

it('respects a non-default locale', () => {
const formatter = new Intl.NumberFormat('de-DE');
const value = decimalFixedPoint('unsigned', 64, 2)('1234.5');
// German uses '.' for grouping and ',' as the decimal separator.
expect(formatDecimalFixedPoint(formatter, value)).toBe('1.234,5');
});

it('renders currency formatting', () => {
const formatter = new Intl.NumberFormat('en-US', { currency: 'USD', style: 'currency' });
const value = decimalFixedPoint('unsigned', 64, 6)('1234.5');
expect(formatDecimalFixedPoint(formatter, value)).toBe('$1,234.50');
});

it('honours the formatter rounding via maximumFractionDigits', () => {
// raw 42678 at d3 represents 42.678; formatter caps to 2 fractional digits with default rounding → '42.68'.
const formatter = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2, useGrouping: false });
const value = rawDecimalFixedPoint('unsigned', 64, 3)(42_678n);
expect(formatDecimalFixedPoint(formatter, value)).toBe('42.68');
});

it('disables grouping separators when useGrouping is false', () => {
const formatter = new Intl.NumberFormat('en-US', { useGrouping: false });
const value = decimalFixedPoint('unsigned', 64, 2)('1234567.89');
expect(formatDecimalFixedPoint(formatter, value)).toBe('1234567.89');
});

it('preserves precision when the raw value exceeds Number.MAX_SAFE_INTEGER', () => {
// raw 10 ** 20 at d6 represents 10 ** 14 = 100000000000000.
const formatter = new Intl.NumberFormat('en-US', { useGrouping: false });
const value = rawDecimalFixedPoint('unsigned', 128, 6)(10n ** 20n);
expect(formatDecimalFixedPoint(formatter, value)).toBe('100000000000000');
});

it('combines locale, currency style, grouping, and fraction digits', () => {
const formatter = new Intl.NumberFormat('de-DE', {
currency: 'EUR',
maximumFractionDigits: 2,
minimumFractionDigits: 2,
style: 'currency',
useGrouping: true,
});
const value = decimalFixedPoint('unsigned', 64, 6)('1234567.891');
// German uses '.' for grouping, ',' as the decimal separator, and '€' as the currency suffix.
// ICU emits a no-break space (U+00A0) between the number and the currency symbol.
expect(formatDecimalFixedPoint(formatter, value)).toBe('1.234.567,89\u00A0€');
});
});

describe('decimalFixedPointToNumber', () => {
it('returns zero for zero', () => {
expect(decimalFixedPointToNumber(rawDecimalFixedPoint('unsigned', 64, 2)(0n))).toBe(0);
Expand Down
33 changes: 33 additions & 0 deletions packages/fixed-points/src/binary/conversions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,39 @@ import { roundDivision, type RoundingMode } from '../rounding';
import type { Signedness } from '../signedness';
import type { BinaryFixedPoint } from './core';

/**
* Converts a {@link BinaryFixedPoint} to its exact base-10 representation
* as a `(raw, decimals)` pair such that the mathematical value equals
* `raw / 10 ** decimals`.
*
* Because `1 / 2 ** F` has a finite decimal expansion of exactly `F`
* digits, the conversion is always lossless:
* `raw / 2 ** F === (raw * 5 ** F) / 10 ** F`. The transformed raw
* therefore carries exactly `fractionalBits` decimal digits of
* precision.
*
* Useful when you want to feed a binary fixed-point into a tool that
* understands base-10 scaled integers (such as `Intl.NumberFormat`'s
* string scientific notation).
*
* @example
* ```ts
* const q1_15 = binaryFixedPoint('signed', 16, 15);
* binaryFixedPointToBase10(q1_15('0.5'));
* // { raw: 500000000000000n, decimals: 15 }
* ```
*
* @see {@link BinaryFixedPoint}
*/
export function binaryFixedPointToBase10(value: BinaryFixedPoint<Signedness, number, number>): {
decimals: number;
raw: bigint;
} {
const decimals = value.fractionalBits;
const raw = decimals === 0 ? value.raw : value.raw * 5n ** BigInt(decimals);
return { decimals, raw };
}

/**
* Converts a {@link BinaryFixedPoint} to its unsigned equivalent at the
* same `totalBits` and `fractionalBits`.
Expand Down
46 changes: 40 additions & 6 deletions packages/fixed-points/src/binary/formatting.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { applyDecimalsOption, type FixedPointToStringOptions, formatScaledBigint } from '../formatting';
import type { Signedness } from '../signedness';
import { binaryFixedPointToBase10 } from './conversions';
import type { BinaryFixedPoint } from './core';

/**
Expand Down Expand Up @@ -32,15 +33,48 @@ 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);
const base10 = binaryFixedPointToBase10(value);
const { decimals, raw } = applyDecimalsOption('binaryFixedPoint', base10.raw, base10.decimals, options);
return formatScaledBigint(raw, decimals, options?.padTrailingZeros ?? false);
}

/**
* Formats a {@link BinaryFixedPoint} using a user-supplied
* `Intl.NumberFormat` instance, preserving full precision regardless of
* the value's magnitude.
*
* Internally calls {@link binaryFixedPointToBase10} and forwards the
* resulting integer to `formatter.format` using ES2023 string scientific
* notation (`"<raw>E-<decimals>"`). This preserves precision in
* fully-compliant runtimes and bypasses the JavaScript `number` mantissa
* limit.
*
* Use this when you want locale-aware output, currency formatting,
* grouping separators, or rounding modes from the rich
* `Intl.NumberFormat` API. Prefer {@link binaryFixedPointToString} when
* portability across older runtimes (older Hermes/React Native, etc.) is
* a concern.
*
* @example
* ```ts
* const q1_15 = binaryFixedPoint('signed', 16, 15);
* const formatter = new Intl.NumberFormat('fr-FR', {
* maximumFractionDigits: 4,
* });
* formatBinaryFixedPoint(formatter, q1_15('0.1')); // "0,1"
* ```
*
* @see {@link binaryFixedPointToString}
* @see {@link binaryFixedPointToBase10}
*/
export function formatBinaryFixedPoint(
formatter: Intl.NumberFormat,
value: BinaryFixedPoint<Signedness, number, number>,
): string {
const { decimals, raw } = binaryFixedPointToBase10(value);
return (formatter.format as unknown as (input: string) => string)(`${raw}E-${decimals}`);
}

/**
* Converts a {@link BinaryFixedPoint} to a JavaScript `number`.
*
Expand Down
Loading
Loading