From 42010c9425b09dcfc20cb512f6bf4c13460daf5d Mon Sep 17 00:00:00 2001 From: Loris Leiva Date: Fri, 24 Apr 2026 12:20:10 +0100 Subject: [PATCH] Add fixed-point signedness conversions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds four helpers to `@solana/fixed-points` for converting between signed and unsigned variants at the same `totalBits` and scale: `toSignedBinaryFixedPoint`, `toUnsignedBinaryFixedPoint`, `toSignedDecimalFixedPoint`, and `toUnsignedDecimalFixedPoint`. Each helper accepts input of either signedness. If the input already has the target signedness, the same reference is returned — no new object is allocated. Otherwise the raw value is checked against the target range and a new frozen value is returned with the updated `signedness` field. Values that do not fit the target range (for example a negative number being converted to unsigned, or an unsigned value above `2 ** (totalBits - 1) - 1` being converted to signed) throw the existing `SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE` error. No new error codes were introduced. --- .../src/__tests__/binary-conversions-test.ts | 85 +++++++++++++++++++ .../src/__tests__/decimal-conversions-test.ts | 85 +++++++++++++++++++ .../binary-conversions-typetest.ts | 35 ++++++++ .../decimal-conversions-typetest.ts | 35 ++++++++ .../fixed-points/src/binary/conversions.ts | 63 ++++++++++++++ packages/fixed-points/src/binary/index.ts | 1 + .../fixed-points/src/decimal/conversions.ts | 63 ++++++++++++++ packages/fixed-points/src/decimal/index.ts | 1 + 8 files changed, 368 insertions(+) create mode 100644 packages/fixed-points/src/__tests__/binary-conversions-test.ts create mode 100644 packages/fixed-points/src/__tests__/decimal-conversions-test.ts create mode 100644 packages/fixed-points/src/__typetests__/binary-conversions-typetest.ts create mode 100644 packages/fixed-points/src/__typetests__/decimal-conversions-typetest.ts create mode 100644 packages/fixed-points/src/binary/conversions.ts create mode 100644 packages/fixed-points/src/decimal/conversions.ts diff --git a/packages/fixed-points/src/__tests__/binary-conversions-test.ts b/packages/fixed-points/src/__tests__/binary-conversions-test.ts new file mode 100644 index 000000000..d0efab8a4 --- /dev/null +++ b/packages/fixed-points/src/__tests__/binary-conversions-test.ts @@ -0,0 +1,85 @@ +import '@solana/test-matchers/toBeFrozenObject'; + +import { SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE, SolanaError } from '@solana/errors'; + +import { rawBinaryFixedPoint, toSignedBinaryFixedPoint, toUnsignedBinaryFixedPoint } from '../binary'; + +describe('toUnsignedBinaryFixedPoint', () => { + it('converts a signed non-negative value to unsigned', () => { + const signed = rawBinaryFixedPoint('signed', 8, 4)(100n); + const unsigned = toUnsignedBinaryFixedPoint(signed); + expect(unsigned.signedness).toBe('unsigned'); + expect(unsigned.raw).toBe(100n); + }); + + it('returns the same reference when the input is already unsigned', () => { + const unsigned = rawBinaryFixedPoint('unsigned', 8, 4)(100n); + expect(toUnsignedBinaryFixedPoint(unsigned)).toBe(unsigned); + }); + + it('preserves totalBits and fractionalBits', () => { + const signed = rawBinaryFixedPoint('signed', 16, 15)(1n); + const unsigned = toUnsignedBinaryFixedPoint(signed); + expect(unsigned.totalBits).toBe(16); + expect(unsigned.fractionalBits).toBe(15); + }); + + it('returns a frozen value', () => { + const signed = rawBinaryFixedPoint('signed', 8, 4)(100n); + expect(toUnsignedBinaryFixedPoint(signed)).toBeFrozenObject(); + }); + + it('throws VALUE_OUT_OF_RANGE when converting a negative value', () => { + const signed = rawBinaryFixedPoint('signed', 8, 4)(-1n); + expect(() => toUnsignedBinaryFixedPoint(signed)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE, { + kind: 'binaryFixedPoint', + max: 255n, + min: 0n, + raw: -1n, + signedness: 'unsigned', + totalBits: 8, + }), + ); + }); +}); + +describe('toSignedBinaryFixedPoint', () => { + it('converts an unsigned value that fits the signed range', () => { + const unsigned = rawBinaryFixedPoint('unsigned', 8, 4)(100n); + const signed = toSignedBinaryFixedPoint(unsigned); + expect(signed.signedness).toBe('signed'); + expect(signed.raw).toBe(100n); + }); + + it('returns the same reference when the input is already signed', () => { + const signed = rawBinaryFixedPoint('signed', 8, 4)(-50n); + expect(toSignedBinaryFixedPoint(signed)).toBe(signed); + }); + + it('preserves totalBits and fractionalBits', () => { + const unsigned = rawBinaryFixedPoint('unsigned', 16, 15)(1n); + const signed = toSignedBinaryFixedPoint(unsigned); + expect(signed.totalBits).toBe(16); + expect(signed.fractionalBits).toBe(15); + }); + + it('returns a frozen value', () => { + const unsigned = rawBinaryFixedPoint('unsigned', 8, 4)(100n); + expect(toSignedBinaryFixedPoint(unsigned)).toBeFrozenObject(); + }); + + it('throws VALUE_OUT_OF_RANGE when the unsigned value exceeds the signed upper bound', () => { + const unsigned = rawBinaryFixedPoint('unsigned', 8, 4)(200n); + expect(() => toSignedBinaryFixedPoint(unsigned)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE, { + kind: 'binaryFixedPoint', + max: 127n, + min: -128n, + raw: 200n, + signedness: 'signed', + totalBits: 8, + }), + ); + }); +}); diff --git a/packages/fixed-points/src/__tests__/decimal-conversions-test.ts b/packages/fixed-points/src/__tests__/decimal-conversions-test.ts new file mode 100644 index 000000000..28164daff --- /dev/null +++ b/packages/fixed-points/src/__tests__/decimal-conversions-test.ts @@ -0,0 +1,85 @@ +import '@solana/test-matchers/toBeFrozenObject'; + +import { SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE, SolanaError } from '@solana/errors'; + +import { rawDecimalFixedPoint, toSignedDecimalFixedPoint, toUnsignedDecimalFixedPoint } from '../decimal'; + +describe('toUnsignedDecimalFixedPoint', () => { + it('converts a signed non-negative value to unsigned', () => { + const signed = rawDecimalFixedPoint('signed', 8, 2)(100n); + const unsigned = toUnsignedDecimalFixedPoint(signed); + expect(unsigned.signedness).toBe('unsigned'); + expect(unsigned.raw).toBe(100n); + }); + + it('returns the same reference when the input is already unsigned', () => { + const unsigned = rawDecimalFixedPoint('unsigned', 8, 2)(100n); + expect(toUnsignedDecimalFixedPoint(unsigned)).toBe(unsigned); + }); + + it('preserves totalBits and decimals', () => { + const signed = rawDecimalFixedPoint('signed', 64, 6)(1n); + const unsigned = toUnsignedDecimalFixedPoint(signed); + expect(unsigned.totalBits).toBe(64); + expect(unsigned.decimals).toBe(6); + }); + + it('returns a frozen value', () => { + const signed = rawDecimalFixedPoint('signed', 8, 2)(100n); + expect(toUnsignedDecimalFixedPoint(signed)).toBeFrozenObject(); + }); + + it('throws VALUE_OUT_OF_RANGE when converting a negative value', () => { + const signed = rawDecimalFixedPoint('signed', 8, 2)(-1n); + expect(() => toUnsignedDecimalFixedPoint(signed)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE, { + kind: 'decimalFixedPoint', + max: 255n, + min: 0n, + raw: -1n, + signedness: 'unsigned', + totalBits: 8, + }), + ); + }); +}); + +describe('toSignedDecimalFixedPoint', () => { + it('converts an unsigned value that fits the signed range', () => { + const unsigned = rawDecimalFixedPoint('unsigned', 8, 2)(100n); + const signed = toSignedDecimalFixedPoint(unsigned); + expect(signed.signedness).toBe('signed'); + expect(signed.raw).toBe(100n); + }); + + it('returns the same reference when the input is already signed', () => { + const signed = rawDecimalFixedPoint('signed', 8, 2)(-50n); + expect(toSignedDecimalFixedPoint(signed)).toBe(signed); + }); + + it('preserves totalBits and decimals', () => { + const unsigned = rawDecimalFixedPoint('unsigned', 64, 6)(1n); + const signed = toSignedDecimalFixedPoint(unsigned); + expect(signed.totalBits).toBe(64); + expect(signed.decimals).toBe(6); + }); + + it('returns a frozen value', () => { + const unsigned = rawDecimalFixedPoint('unsigned', 8, 2)(100n); + expect(toSignedDecimalFixedPoint(unsigned)).toBeFrozenObject(); + }); + + it('throws VALUE_OUT_OF_RANGE when the unsigned value exceeds the signed upper bound', () => { + const unsigned = rawDecimalFixedPoint('unsigned', 8, 2)(200n); + expect(() => toSignedDecimalFixedPoint(unsigned)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE, { + kind: 'decimalFixedPoint', + max: 127n, + min: -128n, + raw: 200n, + signedness: 'signed', + totalBits: 8, + }), + ); + }); +}); diff --git a/packages/fixed-points/src/__typetests__/binary-conversions-typetest.ts b/packages/fixed-points/src/__typetests__/binary-conversions-typetest.ts new file mode 100644 index 000000000..6a849a053 --- /dev/null +++ b/packages/fixed-points/src/__typetests__/binary-conversions-typetest.ts @@ -0,0 +1,35 @@ +import { type BinaryFixedPoint, toSignedBinaryFixedPoint, toUnsignedBinaryFixedPoint } from '../binary'; + +// [DESCRIBE] toUnsignedBinaryFixedPoint. +{ + // It returns an unsigned value regardless of the input's signedness. + { + const fromSigned = {} as BinaryFixedPoint<'signed', 16, 15>; + toUnsignedBinaryFixedPoint(fromSigned) satisfies BinaryFixedPoint<'unsigned', 16, 15>; + const fromUnsigned = {} as BinaryFixedPoint<'unsigned', 16, 15>; + toUnsignedBinaryFixedPoint(fromUnsigned) satisfies BinaryFixedPoint<'unsigned', 16, 15>; + } + + // It preserves totalBits and fractionalBits in the return type. + { + const value = {} as BinaryFixedPoint<'signed', 8, 4>; + toUnsignedBinaryFixedPoint(value) satisfies BinaryFixedPoint<'unsigned', 8, 4>; + } +} + +// [DESCRIBE] toSignedBinaryFixedPoint. +{ + // It returns a signed value regardless of the input's signedness. + { + const fromSigned = {} as BinaryFixedPoint<'signed', 16, 15>; + toSignedBinaryFixedPoint(fromSigned) satisfies BinaryFixedPoint<'signed', 16, 15>; + const fromUnsigned = {} as BinaryFixedPoint<'unsigned', 16, 15>; + toSignedBinaryFixedPoint(fromUnsigned) satisfies BinaryFixedPoint<'signed', 16, 15>; + } + + // It preserves totalBits and fractionalBits in the return type. + { + const value = {} as BinaryFixedPoint<'unsigned', 8, 4>; + toSignedBinaryFixedPoint(value) satisfies BinaryFixedPoint<'signed', 8, 4>; + } +} diff --git a/packages/fixed-points/src/__typetests__/decimal-conversions-typetest.ts b/packages/fixed-points/src/__typetests__/decimal-conversions-typetest.ts new file mode 100644 index 000000000..8153968e2 --- /dev/null +++ b/packages/fixed-points/src/__typetests__/decimal-conversions-typetest.ts @@ -0,0 +1,35 @@ +import { type DecimalFixedPoint, toSignedDecimalFixedPoint, toUnsignedDecimalFixedPoint } from '../decimal'; + +// [DESCRIBE] toUnsignedDecimalFixedPoint. +{ + // It returns an unsigned value regardless of the input's signedness. + { + const fromSigned = {} as DecimalFixedPoint<'signed', 64, 2>; + toUnsignedDecimalFixedPoint(fromSigned) satisfies DecimalFixedPoint<'unsigned', 64, 2>; + const fromUnsigned = {} as DecimalFixedPoint<'unsigned', 64, 2>; + toUnsignedDecimalFixedPoint(fromUnsigned) satisfies DecimalFixedPoint<'unsigned', 64, 2>; + } + + // It preserves totalBits and decimals in the return type. + { + const value = {} as DecimalFixedPoint<'signed', 8, 2>; + toUnsignedDecimalFixedPoint(value) satisfies DecimalFixedPoint<'unsigned', 8, 2>; + } +} + +// [DESCRIBE] toSignedDecimalFixedPoint. +{ + // It returns a signed value regardless of the input's signedness. + { + const fromSigned = {} as DecimalFixedPoint<'signed', 64, 2>; + toSignedDecimalFixedPoint(fromSigned) satisfies DecimalFixedPoint<'signed', 64, 2>; + const fromUnsigned = {} as DecimalFixedPoint<'unsigned', 64, 2>; + toSignedDecimalFixedPoint(fromUnsigned) satisfies DecimalFixedPoint<'signed', 64, 2>; + } + + // It preserves totalBits and decimals in the return type. + { + const value = {} as DecimalFixedPoint<'unsigned', 8, 2>; + toSignedDecimalFixedPoint(value) satisfies DecimalFixedPoint<'signed', 8, 2>; + } +} diff --git a/packages/fixed-points/src/binary/conversions.ts b/packages/fixed-points/src/binary/conversions.ts new file mode 100644 index 000000000..6bfe16caa --- /dev/null +++ b/packages/fixed-points/src/binary/conversions.ts @@ -0,0 +1,63 @@ +import { assertRawFitsInRange } from '../assertions'; +import type { Signedness } from '../signedness'; +import type { BinaryFixedPoint } from './core'; + +/** + * Converts a {@link BinaryFixedPoint} to its unsigned equivalent at the + * same `totalBits` and `fractionalBits`. + * + * Unsigned inputs are returned by reference unchanged; signed inputs are + * accepted as long as their raw value is non-negative. + * + * Throws `SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE` when the input + * represents a negative value that cannot be stored as unsigned. + * + * @example + * ```ts + * const signedUsd = binaryFixedPoint('signed', 16, 8); + * toUnsignedBinaryFixedPoint(signedUsd('1.5')); // unsigned, raw unchanged + * toUnsignedBinaryFixedPoint(signedUsd('-1')); // throws + * ``` + * + * @see {@link toSignedBinaryFixedPoint} + */ +export function toUnsignedBinaryFixedPoint( + value: BinaryFixedPoint, +): BinaryFixedPoint<'unsigned', TTotalBits, TFractionalBits> { + if (value.signedness === 'unsigned') { + return value as BinaryFixedPoint<'unsigned', TTotalBits, TFractionalBits>; + } + assertRawFitsInRange('binaryFixedPoint', 'unsigned', value.totalBits, value.raw); + return Object.freeze({ ...value, signedness: 'unsigned' }); +} + +/** + * Converts a {@link BinaryFixedPoint} to its signed equivalent at the same + * `totalBits` and `fractionalBits`. + * + * Signed inputs are returned by reference unchanged; unsigned inputs are + * accepted as long as their raw value fits the signed range, i.e. + * `raw <= 2 ** (totalBits - 1) - 1`. + * + * Throws `SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE` when the input's + * raw value exceeds the maximum representable signed value at its + * `totalBits`. + * + * @example + * ```ts + * const unsigned = rawBinaryFixedPoint('unsigned', 8, 0); + * toSignedBinaryFixedPoint(unsigned(100n)); // signed, raw === 100n + * toSignedBinaryFixedPoint(unsigned(200n)); // throws (200 > 127) + * ``` + * + * @see {@link toUnsignedBinaryFixedPoint} + */ +export function toSignedBinaryFixedPoint( + value: BinaryFixedPoint, +): BinaryFixedPoint<'signed', TTotalBits, TFractionalBits> { + if (value.signedness === 'signed') { + return value as BinaryFixedPoint<'signed', TTotalBits, TFractionalBits>; + } + assertRawFitsInRange('binaryFixedPoint', 'signed', value.totalBits, value.raw); + return Object.freeze({ ...value, signedness: 'signed' }); +} diff --git a/packages/fixed-points/src/binary/index.ts b/packages/fixed-points/src/binary/index.ts index 67c1763e7..409a55ebf 100644 --- a/packages/fixed-points/src/binary/index.ts +++ b/packages/fixed-points/src/binary/index.ts @@ -1,4 +1,5 @@ export * from './arithmetics'; export * from './comparisons'; +export * from './conversions'; export * from './core'; export * from './guards'; diff --git a/packages/fixed-points/src/decimal/conversions.ts b/packages/fixed-points/src/decimal/conversions.ts new file mode 100644 index 000000000..af3f5682c --- /dev/null +++ b/packages/fixed-points/src/decimal/conversions.ts @@ -0,0 +1,63 @@ +import { assertRawFitsInRange } from '../assertions'; +import type { Signedness } from '../signedness'; +import type { DecimalFixedPoint } from './core'; + +/** + * Converts a {@link DecimalFixedPoint} to its unsigned equivalent at the + * same `totalBits` and `decimals`. + * + * Unsigned inputs are returned by reference unchanged; signed inputs are + * accepted as long as their raw value is non-negative. + * + * Throws `SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE` when the input + * represents a negative value that cannot be stored as unsigned. + * + * @example + * ```ts + * const signedUsd = decimalFixedPoint('signed', 64, 2); + * toUnsignedDecimalFixedPoint(signedUsd('1.50')); // unsigned, raw unchanged + * toUnsignedDecimalFixedPoint(signedUsd('-1')); // throws + * ``` + * + * @see {@link toSignedDecimalFixedPoint} + */ +export function toUnsignedDecimalFixedPoint( + value: DecimalFixedPoint, +): DecimalFixedPoint<'unsigned', TTotalBits, TDecimals> { + if (value.signedness === 'unsigned') { + return value as DecimalFixedPoint<'unsigned', TTotalBits, TDecimals>; + } + assertRawFitsInRange('decimalFixedPoint', 'unsigned', value.totalBits, value.raw); + return Object.freeze({ ...value, signedness: 'unsigned' }); +} + +/** + * Converts a {@link DecimalFixedPoint} to its signed equivalent at the same + * `totalBits` and `decimals`. + * + * Signed inputs are returned by reference unchanged; unsigned inputs are + * accepted as long as their raw value fits the signed range, i.e. + * `raw <= 2 ** (totalBits - 1) - 1`. + * + * Throws `SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE` when the input's + * raw value exceeds the maximum representable signed value at its + * `totalBits`. + * + * @example + * ```ts + * const unsigned = rawDecimalFixedPoint('unsigned', 8, 0); + * toSignedDecimalFixedPoint(unsigned(100n)); // signed, raw === 100n + * toSignedDecimalFixedPoint(unsigned(200n)); // throws (200 > 127) + * ``` + * + * @see {@link toUnsignedDecimalFixedPoint} + */ +export function toSignedDecimalFixedPoint( + value: DecimalFixedPoint, +): DecimalFixedPoint<'signed', TTotalBits, TDecimals> { + if (value.signedness === 'signed') { + return value as DecimalFixedPoint<'signed', TTotalBits, TDecimals>; + } + assertRawFitsInRange('decimalFixedPoint', 'signed', value.totalBits, value.raw); + return Object.freeze({ ...value, signedness: 'signed' }); +} diff --git a/packages/fixed-points/src/decimal/index.ts b/packages/fixed-points/src/decimal/index.ts index 67c1763e7..409a55ebf 100644 --- a/packages/fixed-points/src/decimal/index.ts +++ b/packages/fixed-points/src/decimal/index.ts @@ -1,4 +1,5 @@ export * from './arithmetics'; export * from './comparisons'; +export * from './conversions'; export * from './core'; export * from './guards';