diff --git a/packages/fixed-points/package.json b/packages/fixed-points/package.json index f643ab840..161598214 100644 --- a/packages/fixed-points/package.json +++ b/packages/fixed-points/package.json @@ -73,6 +73,9 @@ "supports bigint and not dead", "maintained node versions" ], + "dependencies": { + "@solana/errors": "workspace:*" + }, "peerDependencies": { "typescript": ">=5.0.0" }, diff --git a/packages/fixed-points/src/__tests__/assertions-test.ts b/packages/fixed-points/src/__tests__/assertions-test.ts new file mode 100644 index 000000000..d8ef59474 --- /dev/null +++ b/packages/fixed-points/src/__tests__/assertions-test.ts @@ -0,0 +1,32 @@ +import { getRawRange } from '../assertions'; + +describe('getRawRange', () => { + it('returns the range for signed widths', () => { + expect(getRawRange('signed', 8)).toEqual({ max: 127n, min: -128n }); + expect(getRawRange('signed', 16)).toEqual({ max: 32767n, min: -32768n }); + expect(getRawRange('signed', 64)).toEqual({ + max: 9223372036854775807n, + min: -9223372036854775808n, + }); + }); + + it('returns the range for unsigned widths', () => { + expect(getRawRange('unsigned', 8)).toEqual({ max: 255n, min: 0n }); + expect(getRawRange('unsigned', 16)).toEqual({ max: 65535n, min: 0n }); + expect(getRawRange('unsigned', 64)).toEqual({ + max: 18446744073709551615n, + min: 0n, + }); + }); + + it('supports any arbitrary bit widths', () => { + expect(getRawRange('unsigned', 123)).toEqual({ + max: (1n << 123n) - 1n, + min: 0n, + }); + expect(getRawRange('signed', 123)).toEqual({ + max: (1n << 122n) - 1n, + min: -(1n << 122n), + }); + }); +}); diff --git a/packages/fixed-points/src/__tests__/binary-core-test.ts b/packages/fixed-points/src/__tests__/binary-core-test.ts new file mode 100644 index 000000000..eea151ed0 --- /dev/null +++ b/packages/fixed-points/src/__tests__/binary-core-test.ts @@ -0,0 +1,229 @@ +import '@solana/test-matchers/toBeFrozenObject'; + +import { + SOLANA_ERROR__FIXED_POINTS__FRACTIONAL_BITS_EXCEED_TOTAL_BITS, + SOLANA_ERROR__FIXED_POINTS__INVALID_FRACTIONAL_BITS, + SOLANA_ERROR__FIXED_POINTS__INVALID_STRING, + SOLANA_ERROR__FIXED_POINTS__INVALID_TOTAL_BITS, + SOLANA_ERROR__FIXED_POINTS__INVALID_ZERO_DENOMINATOR_RATIO, + SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS, + SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE, + SolanaError, +} from '@solana/errors'; + +import { binaryFixedPoint, ratioBinaryFixedPoint, rawBinaryFixedPoint } from '../binary/core'; + +describe('binaryFixedPoint', () => { + it('constructs values from decimal strings that are exactly representable in binary', () => { + const q1_15 = binaryFixedPoint('signed', 16, 15); + expect(q1_15('0').raw).toBe(0n); + expect(q1_15('0.5').raw).toBe(2n ** 14n); + expect(q1_15('0.25').raw).toBe(2n ** 13n); + expect(q1_15('-0.5').raw).toBe(-(2n ** 14n)); + }); + + it('returns values whose fields match the shape and kind', () => { + const q1_15 = binaryFixedPoint('signed', 16, 15); + expect(q1_15('0.5')).toEqual({ + fractionalBits: 15, + kind: 'binaryFixedPoint', + raw: 2n ** 14n, + signedness: 'signed', + totalBits: 16, + }); + }); + + it('returns frozen values', () => { + const q1_15 = binaryFixedPoint('signed', 16, 15); + expect(q1_15('0.5')).toBeFrozenObject(); + }); + + it('throws STRICT_MODE_PRECISION_LOSS under the default rounding when the string cannot be represented exactly in binary', () => { + const q1_15 = binaryFixedPoint('signed', 16, 15); + expect(() => q1_15('0.1')).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS, { + kind: 'binaryFixedPoint', + operation: 'fromString', + }), + ); + }); + + it('rounds inexact strings when a non-strict rounding mode is supplied', () => { + const q1_15 = binaryFixedPoint('signed', 16, 15); + // 0.1 × 2^15 = 3276.8 + expect(q1_15('0.1', 'floor').raw).toBe(3276n); + expect(q1_15('0.1', 'ceil').raw).toBe(3277n); + expect(q1_15('0.1', 'round').raw).toBe(3277n); + expect(q1_15('0.1', 'trunc').raw).toBe(3276n); + }); + + it('throws VALUE_OUT_OF_RANGE when the result does not fit the target shape', () => { + // 1 × 2^7 = 128, which overflows a signed 8-bit range [-128, 127]. + const q1_7 = binaryFixedPoint('signed', 8, 7); + expect(() => q1_7('1')).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE, { + kind: 'binaryFixedPoint', + max: 127n, + min: -128n, + raw: 128n, + signedness: 'signed', + totalBits: 8, + }), + ); + }); + + it('accepts the largest representable value for a given shape', () => { + // 0.9921875 = 127/128, the largest value representable as signed Q1.7. + const q1_7 = binaryFixedPoint('signed', 8, 7); + expect(q1_7('0.9921875').raw).toBe(127n); + }); + + it('throws INVALID_STRING on malformed inputs', () => { + const q1_15 = binaryFixedPoint('signed', 16, 15); + expect(() => q1_15('abc')).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__INVALID_STRING, { + input: 'abc', + kind: 'binaryFixedPoint', + }), + ); + }); + + it('throws INVALID_TOTAL_BITS when totalBits is not a positive integer', () => { + expect(() => binaryFixedPoint('signed', 0, 0)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__INVALID_TOTAL_BITS, { + kind: 'binaryFixedPoint', + totalBits: 0, + }), + ); + }); + + it('throws INVALID_FRACTIONAL_BITS when fractionalBits is not a non-negative integer', () => { + expect(() => binaryFixedPoint('signed', 16, -1)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__INVALID_FRACTIONAL_BITS, { fractionalBits: -1 }), + ); + }); + + it('throws FRACTIONAL_BITS_EXCEED_TOTAL_BITS when fractionalBits exceeds totalBits', () => { + expect(() => binaryFixedPoint('signed', 16, 32)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__FRACTIONAL_BITS_EXCEED_TOTAL_BITS, { + fractionalBits: 32, + totalBits: 16, + }), + ); + }); + + it('allows fractionalBits equal to totalBits', () => { + // Q0.16 can represent values in [0, 1), so `0` fits and proves the + // factory was accepted even at the fractionalBits=totalBits boundary. + const factory = binaryFixedPoint('unsigned', 16, 16); + expect(factory('0').raw).toBe(0n); + }); +}); + +describe('rawBinaryFixedPoint', () => { + it('constructs values directly from a raw bigint', () => { + const q1_15 = rawBinaryFixedPoint('signed', 16, 15); + expect(q1_15(2n ** 14n)).toEqual({ + fractionalBits: 15, + kind: 'binaryFixedPoint', + raw: 2n ** 14n, + signedness: 'signed', + totalBits: 16, + }); + }); + + it('returns frozen values', () => { + const q1_15 = rawBinaryFixedPoint('signed', 16, 15); + expect(q1_15(2n ** 14n)).toBeFrozenObject(); + }); + + it('throws VALUE_OUT_OF_RANGE when the raw value does not fit the shape', () => { + const q1_7 = rawBinaryFixedPoint('signed', 8, 7); + expect(() => q1_7(128n)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE, { + kind: 'binaryFixedPoint', + max: 127n, + min: -128n, + raw: 128n, + signedness: 'signed', + totalBits: 8, + }), + ); + }); +}); + +describe('ratioBinaryFixedPoint', () => { + it('constructs values from exact ratios', () => { + const q1_15 = ratioBinaryFixedPoint('signed', 16, 15); + // 0.25 × 2^15 + expect(q1_15(1n, 4n).raw).toBe(2n ** 13n); + // 0.5 × 2^15 + expect(q1_15(1n, 2n).raw).toBe(2n ** 14n); + }); + + it('returns frozen values', () => { + const q1_15 = ratioBinaryFixedPoint('signed', 16, 15); + expect(q1_15(1n, 4n)).toBeFrozenObject(); + }); + + it('throws STRICT_MODE_PRECISION_LOSS under the default rounding when the ratio is inexact', () => { + const q1_15 = ratioBinaryFixedPoint('signed', 16, 15); + expect(() => q1_15(1n, 3n)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS, { + kind: 'binaryFixedPoint', + operation: 'fromRatio', + }), + ); + }); + + it('rounds inexact ratios when a non-strict rounding mode is supplied', () => { + const q1_15 = ratioBinaryFixedPoint('signed', 16, 15); + // 1/3 × 2^15 = 10922.666… + expect(q1_15(1n, 3n, 'floor').raw).toBe(10922n); + expect(q1_15(1n, 3n, 'ceil').raw).toBe(10923n); + expect(q1_15(1n, 3n, 'round').raw).toBe(10923n); + }); + + it('throws INVALID_ZERO_DENOMINATOR_RATIO when the denominator is zero', () => { + const q1_15 = ratioBinaryFixedPoint('signed', 16, 15); + expect(() => q1_15(1n, 0n)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__INVALID_ZERO_DENOMINATOR_RATIO, { + denominator: 0n, + kind: 'binaryFixedPoint', + numerator: 1n, + }), + ); + }); +}); + +describe('binary factory shape validation', () => { + it('rejects zero totalBits up front from every binary factory', () => { + for (const factory of [binaryFixedPoint, rawBinaryFixedPoint, ratioBinaryFixedPoint]) { + expect(() => factory('signed', 0, 0)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__INVALID_TOTAL_BITS, { + kind: 'binaryFixedPoint', + totalBits: 0, + }), + ); + } + }); + + it('rejects negative fractionalBits up front from every binary factory', () => { + for (const factory of [binaryFixedPoint, rawBinaryFixedPoint, ratioBinaryFixedPoint]) { + expect(() => factory('signed', 16, -1)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__INVALID_FRACTIONAL_BITS, { fractionalBits: -1 }), + ); + } + }); + + it('rejects fractionalBits that exceed totalBits up front from every binary factory', () => { + for (const factory of [binaryFixedPoint, rawBinaryFixedPoint, ratioBinaryFixedPoint]) { + expect(() => factory('signed', 16, 32)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__FRACTIONAL_BITS_EXCEED_TOTAL_BITS, { + fractionalBits: 32, + totalBits: 16, + }), + ); + } + }); +}); diff --git a/packages/fixed-points/src/__tests__/binary-guards-test.ts b/packages/fixed-points/src/__tests__/binary-guards-test.ts new file mode 100644 index 000000000..8d8d039ab --- /dev/null +++ b/packages/fixed-points/src/__tests__/binary-guards-test.ts @@ -0,0 +1,178 @@ +import { + SOLANA_ERROR__FIXED_POINTS__MALFORMED_RAW_VALUE, + SOLANA_ERROR__FIXED_POINTS__SHAPE_MISMATCH, + SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE, + SolanaError, +} from '@solana/errors'; + +import { binaryFixedPoint, rawBinaryFixedPoint } from '../binary/core'; +import { assertIsBinaryFixedPoint, isBinaryFixedPoint } from '../binary/guards'; + +describe('isBinaryFixedPoint', () => { + it('returns true for valid binary fixed-point values', () => { + const q1_15 = binaryFixedPoint('signed', 16, 15); + expect(isBinaryFixedPoint(q1_15('0.5'))).toBe(true); + const unsigned = rawBinaryFixedPoint('unsigned', 8, 4); + expect(isBinaryFixedPoint(unsigned(42n))).toBe(true); + }); + + it('returns false for non-objects and wrong kinds', () => { + expect(isBinaryFixedPoint(42)).toBe(false); + expect(isBinaryFixedPoint(null)).toBe(false); + expect(isBinaryFixedPoint(undefined)).toBe(false); + expect(isBinaryFixedPoint({})).toBe(false); + expect(isBinaryFixedPoint({ kind: 'decimalFixedPoint' })).toBe(false); + }); + + it('returns false when required fields are missing or malformed', () => { + const base = { + fractionalBits: 15, + kind: 'binaryFixedPoint', + raw: 0n, + signedness: 'signed', + totalBits: 16, + }; + expect(isBinaryFixedPoint({ ...base, signedness: 'weird' })).toBe(false); + expect(isBinaryFixedPoint({ ...base, totalBits: 0 })).toBe(false); + expect(isBinaryFixedPoint({ ...base, fractionalBits: -1 })).toBe(false); + expect(isBinaryFixedPoint({ ...base, fractionalBits: 32 })).toBe(false); // exceeds totalBits + expect(isBinaryFixedPoint({ ...base, raw: 1 })).toBe(false); + }); + + it('returns false when the raw value does not fit the claimed range', () => { + expect( + isBinaryFixedPoint({ + fractionalBits: 0, + kind: 'binaryFixedPoint', + raw: 128n, + signedness: 'signed', + totalBits: 8, + }), + ).toBe(false); + }); + + it('narrows to the specific shape when parameters are provided', () => { + const q1_15 = binaryFixedPoint('signed', 16, 15); + const value = q1_15('0.5'); + expect(isBinaryFixedPoint(value, 'signed', 16, 15)).toBe(true); + expect(isBinaryFixedPoint(value, 'unsigned', 16, 15)).toBe(false); + expect(isBinaryFixedPoint(value, 'signed', 32, 15)).toBe(false); + expect(isBinaryFixedPoint(value, 'signed', 16, 14)).toBe(false); + }); + + it('accepts partial positional arguments, constraining only the fields that are provided', () => { + const q1_15 = binaryFixedPoint('signed', 16, 15); + const value = q1_15('0.5'); + expect(isBinaryFixedPoint(value, 'signed')).toBe(true); + expect(isBinaryFixedPoint(value, 'unsigned')).toBe(false); + expect(isBinaryFixedPoint(value, 'signed', 16)).toBe(true); + expect(isBinaryFixedPoint(value, 'signed', 32)).toBe(false); + }); + + it('treats `undefined` as "don’t care" for any skipped field', () => { + const q1_15 = binaryFixedPoint('signed', 16, 15); + const value = q1_15('0.5'); + expect(isBinaryFixedPoint(value, undefined, 16)).toBe(true); + expect(isBinaryFixedPoint(value, undefined, undefined, 15)).toBe(true); + expect(isBinaryFixedPoint(value, undefined, 32)).toBe(false); + }); +}); + +describe('assertIsBinaryFixedPoint', () => { + it('passes silently for valid values', () => { + const q1_15 = binaryFixedPoint('signed', 16, 15); + expect(() => assertIsBinaryFixedPoint(q1_15('0.5'))).not.toThrow(); + expect(() => assertIsBinaryFixedPoint(q1_15('0.5'), 'signed', 16, 15)).not.toThrow(); + }); + + it('throws SHAPE_MISMATCH for non-object inputs', () => { + expect(() => assertIsBinaryFixedPoint(42)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__SHAPE_MISMATCH, { + actualKind: 'unknown', + actualScale: 0, + actualScaleLabel: 'unknown', + actualSignedness: 'unknown', + actualTotalBits: 0, + expectedKind: 'binaryFixedPoint', + expectedScale: 0, + expectedScaleLabel: 'fractional bits', + expectedSignedness: 'unknown', + expectedTotalBits: 0, + operation: 'assertIsBinaryFixedPoint', + }), + ); + }); + + it('throws SHAPE_MISMATCH when the value is a decimal fixed-point', () => { + expect(() => assertIsBinaryFixedPoint({ kind: 'decimalFixedPoint' })).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__SHAPE_MISMATCH, { + actualKind: 'decimalFixedPoint', + actualScale: 0, + actualScaleLabel: 'decimals', + actualSignedness: 'unknown', + actualTotalBits: 0, + expectedKind: 'binaryFixedPoint', + expectedScale: 0, + expectedScaleLabel: 'fractional bits', + expectedSignedness: 'unknown', + expectedTotalBits: 0, + operation: 'assertIsBinaryFixedPoint', + }), + ); + }); + + it('throws SHAPE_MISMATCH when a binary value has the wrong signedness', () => { + const q1_15 = binaryFixedPoint('signed', 16, 15); + expect(() => assertIsBinaryFixedPoint(q1_15('0.5'), 'unsigned', 16, 15)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__SHAPE_MISMATCH, { + actualKind: 'binaryFixedPoint', + actualScale: 15, + actualScaleLabel: 'fractional bits', + actualSignedness: 'signed', + actualTotalBits: 16, + expectedKind: 'binaryFixedPoint', + expectedScale: 15, + expectedScaleLabel: 'fractional bits', + expectedSignedness: 'unsigned', + expectedTotalBits: 16, + operation: 'assertIsBinaryFixedPoint', + }), + ); + }); + + it('throws VALUE_OUT_OF_RANGE when the raw value does not fit the claimed range', () => { + const malformed = { + fractionalBits: 0, + kind: 'binaryFixedPoint' as const, + raw: 128n, + signedness: 'signed' as const, + totalBits: 8, + }; + expect(() => assertIsBinaryFixedPoint(malformed)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE, { + kind: 'binaryFixedPoint', + max: 127n, + min: -128n, + raw: 128n, + signedness: 'signed', + totalBits: 8, + }), + ); + }); + + it('throws MALFORMED_RAW_VALUE when the raw field is not a bigint', () => { + const malformed = { + fractionalBits: 15, + kind: 'binaryFixedPoint' as const, + raw: 42, + signedness: 'signed' as const, + totalBits: 16, + }; + expect(() => assertIsBinaryFixedPoint(malformed)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__MALFORMED_RAW_VALUE, { + kind: 'binaryFixedPoint', + raw: 42, + }), + ); + }); +}); diff --git a/packages/fixed-points/src/__tests__/decimal-core-test.ts b/packages/fixed-points/src/__tests__/decimal-core-test.ts new file mode 100644 index 000000000..c43cad2fc --- /dev/null +++ b/packages/fixed-points/src/__tests__/decimal-core-test.ts @@ -0,0 +1,283 @@ +import '@solana/test-matchers/toBeFrozenObject'; + +import { + SOLANA_ERROR__FIXED_POINTS__INVALID_DECIMALS, + SOLANA_ERROR__FIXED_POINTS__INVALID_STRING, + SOLANA_ERROR__FIXED_POINTS__INVALID_TOTAL_BITS, + SOLANA_ERROR__FIXED_POINTS__INVALID_ZERO_DENOMINATOR_RATIO, + SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS, + SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE, + SolanaError, +} from '@solana/errors'; + +import { decimalFixedPoint, ratioDecimalFixedPoint, rawDecimalFixedPoint } from '../decimal/core'; + +describe('decimalFixedPoint', () => { + it('constructs values from decimal strings', () => { + const usdc = decimalFixedPoint('unsigned', 64, 6); + expect(usdc('0').raw).toBe(0n); + expect(usdc('1').raw).toBe(1000000n); + expect(usdc('42.5').raw).toBe(42500000n); + expect(usdc('0.000001').raw).toBe(1n); + expect(usdc('1234567890.123456').raw).toBe(1234567890123456n); + }); + + it('accepts negative values for signed shapes', () => { + const signed = decimalFixedPoint('signed', 32, 2); + expect(signed('-1.5').raw).toBe(-150n); + expect(signed('-0').raw).toBe(0n); + }); + + it('returns values whose fields match the shape and kind', () => { + const usdc = decimalFixedPoint('unsigned', 64, 6); + expect(usdc('1.5')).toEqual({ + decimals: 6, + kind: 'decimalFixedPoint', + raw: 1500000n, + signedness: 'unsigned', + totalBits: 64, + }); + }); + + it('returns frozen values', () => { + const usdc = decimalFixedPoint('unsigned', 64, 6); + expect(usdc('1.5')).toBeFrozenObject(); + }); + + it('throws STRICT_MODE_PRECISION_LOSS under the default rounding when the input has more precision than the target', () => { + const cents = decimalFixedPoint('unsigned', 16, 2); + expect(() => cents('1.234')).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS, { + kind: 'decimalFixedPoint', + operation: 'fromString', + }), + ); + }); + + it('rounds excess precision when a non-strict rounding mode is supplied', () => { + const cents = decimalFixedPoint('unsigned', 16, 2); + expect(cents('1.234', 'floor').raw).toBe(123n); + expect(cents('1.234', 'ceil').raw).toBe(124n); + expect(cents('1.235', 'round').raw).toBe(124n); // tie away from zero + expect(cents('1.234', 'trunc').raw).toBe(123n); + }); + + it('throws VALUE_OUT_OF_RANGE when the result exceeds the unsigned upper bound', () => { + const tiny = decimalFixedPoint('unsigned', 8, 0); + expect(() => tiny('256')).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE, { + kind: 'decimalFixedPoint', + max: 255n, + min: 0n, + raw: 256n, + signedness: 'unsigned', + totalBits: 8, + }), + ); + }); + + it('throws VALUE_OUT_OF_RANGE when the result exceeds the signed upper bound', () => { + const signed = decimalFixedPoint('signed', 8, 0); + expect(() => signed('128')).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE, { + kind: 'decimalFixedPoint', + max: 127n, + min: -128n, + raw: 128n, + signedness: 'signed', + totalBits: 8, + }), + ); + }); + + it('throws VALUE_OUT_OF_RANGE when the result is below the signed lower bound', () => { + const signed = decimalFixedPoint('signed', 8, 0); + expect(() => signed('-129')).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE, { + kind: 'decimalFixedPoint', + max: 127n, + min: -128n, + raw: -129n, + signedness: 'signed', + totalBits: 8, + }), + ); + }); + + it('throws INVALID_STRING on malformed inputs', () => { + const usdc = decimalFixedPoint('unsigned', 64, 6); + expect(() => usdc('abc')).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__INVALID_STRING, { + input: 'abc', + kind: 'decimalFixedPoint', + }), + ); + }); + + it('throws INVALID_TOTAL_BITS when totalBits is not a positive integer', () => { + for (const bad of [0, -1, 1.5, Number.NaN, '64' as unknown as number]) { + expect(() => decimalFixedPoint('unsigned', bad, 6)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__INVALID_TOTAL_BITS, { + kind: 'decimalFixedPoint', + totalBits: bad, + }), + ); + } + }); + + it('throws INVALID_DECIMALS when decimals is not a non-negative integer', () => { + for (const bad of [-1, 1.5, Number.NaN, '6' as unknown as number]) { + expect(() => decimalFixedPoint('unsigned', 64, bad)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__INVALID_DECIMALS, { decimals: bad }), + ); + } + }); + + it('allows decimals to exceed totalBits, since decimal shapes do not enforce that constraint', () => { + // decimal(unsigned, 8, 10) with raw=0n represents 0, which fits fine + // — decimal fixed-points with many decimals and few bits are valid. + const tiny = decimalFixedPoint('unsigned', 8, 10); + expect(tiny('0').raw).toBe(0n); + }); +}); + +describe('rawDecimalFixedPoint', () => { + it('constructs values directly from a raw bigint', () => { + const cents = rawDecimalFixedPoint('unsigned', 16, 2); + expect(cents(425n)).toEqual({ + decimals: 2, + kind: 'decimalFixedPoint', + raw: 425n, + signedness: 'unsigned', + totalBits: 16, + }); + }); + + it('returns frozen values', () => { + const cents = rawDecimalFixedPoint('unsigned', 16, 2); + expect(cents(425n)).toBeFrozenObject(); + }); + + it('accepts negative raw values for signed shapes', () => { + const signed = rawDecimalFixedPoint('signed', 8, 2); + expect(signed(-128n).raw).toBe(-128n); + }); + + it('throws VALUE_OUT_OF_RANGE when the raw value exceeds the unsigned upper bound', () => { + const tiny = rawDecimalFixedPoint('unsigned', 8, 0); + expect(() => tiny(256n)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE, { + kind: 'decimalFixedPoint', + max: 255n, + min: 0n, + raw: 256n, + signedness: 'unsigned', + totalBits: 8, + }), + ); + }); + + it('throws VALUE_OUT_OF_RANGE when the raw value exceeds the signed upper bound', () => { + const signed = rawDecimalFixedPoint('signed', 8, 0); + expect(() => signed(128n)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE, { + kind: 'decimalFixedPoint', + max: 127n, + min: -128n, + raw: 128n, + signedness: 'signed', + totalBits: 8, + }), + ); + }); + + it('throws VALUE_OUT_OF_RANGE when the raw value is below the signed lower bound', () => { + const signed = rawDecimalFixedPoint('signed', 8, 0); + expect(() => signed(-129n)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE, { + kind: 'decimalFixedPoint', + max: 127n, + min: -128n, + raw: -129n, + signedness: 'signed', + totalBits: 8, + }), + ); + }); +}); + +describe('ratioDecimalFixedPoint', () => { + it('constructs values from exact ratios', () => { + const prob = ratioDecimalFixedPoint('unsigned', 64, 4); + expect(prob(1n, 4n).raw).toBe(2500n); // 0.2500 + expect(prob(1n, 2n).raw).toBe(5000n); // 0.5000 + }); + + it('returns frozen values', () => { + const prob = ratioDecimalFixedPoint('unsigned', 64, 4); + expect(prob(1n, 4n)).toBeFrozenObject(); + }); + + it('throws STRICT_MODE_PRECISION_LOSS under the default rounding when the ratio is inexact', () => { + const prob = ratioDecimalFixedPoint('unsigned', 64, 4); + expect(() => prob(1n, 3n)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS, { + kind: 'decimalFixedPoint', + operation: 'fromRatio', + }), + ); + }); + + it('rounds inexact ratios when a non-strict rounding mode is supplied', () => { + const prob = ratioDecimalFixedPoint('unsigned', 64, 4); + expect(prob(1n, 3n, 'floor').raw).toBe(3333n); + expect(prob(1n, 3n, 'ceil').raw).toBe(3334n); + expect(prob(1n, 3n, 'round').raw).toBe(3333n); + }); + + it('throws INVALID_ZERO_DENOMINATOR_RATIO when the denominator is zero', () => { + const prob = ratioDecimalFixedPoint('unsigned', 64, 4); + expect(() => prob(1n, 0n)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__INVALID_ZERO_DENOMINATOR_RATIO, { + denominator: 0n, + kind: 'decimalFixedPoint', + numerator: 1n, + }), + ); + }); + + it('throws VALUE_OUT_OF_RANGE when the ratio overflows the target shape', () => { + const tiny = ratioDecimalFixedPoint('unsigned', 8, 0); + expect(() => tiny(256n, 1n)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE, { + kind: 'decimalFixedPoint', + max: 255n, + min: 0n, + raw: 256n, + signedness: 'unsigned', + totalBits: 8, + }), + ); + }); +}); + +describe('decimal factory shape validation', () => { + it('rejects zero totalBits up front from every decimal factory', () => { + for (const factory of [decimalFixedPoint, rawDecimalFixedPoint, ratioDecimalFixedPoint]) { + expect(() => factory('unsigned', 0, 6)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__INVALID_TOTAL_BITS, { + kind: 'decimalFixedPoint', + totalBits: 0, + }), + ); + } + }); + + it('rejects negative decimals up front from every decimal factory', () => { + for (const factory of [decimalFixedPoint, rawDecimalFixedPoint, ratioDecimalFixedPoint]) { + expect(() => factory('unsigned', 64, -1)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__INVALID_DECIMALS, { decimals: -1 }), + ); + } + }); +}); diff --git a/packages/fixed-points/src/__tests__/decimal-guards-test.ts b/packages/fixed-points/src/__tests__/decimal-guards-test.ts new file mode 100644 index 000000000..5abf703f5 --- /dev/null +++ b/packages/fixed-points/src/__tests__/decimal-guards-test.ts @@ -0,0 +1,180 @@ +import { + SOLANA_ERROR__FIXED_POINTS__MALFORMED_RAW_VALUE, + SOLANA_ERROR__FIXED_POINTS__SHAPE_MISMATCH, + SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE, + SolanaError, +} from '@solana/errors'; + +import { decimalFixedPoint, rawDecimalFixedPoint } from '../decimal/core'; +import { assertIsDecimalFixedPoint, isDecimalFixedPoint } from '../decimal/guards'; + +describe('isDecimalFixedPoint', () => { + it('returns true for valid decimal fixed-point values', () => { + const usdc = decimalFixedPoint('unsigned', 64, 6); + expect(isDecimalFixedPoint(usdc('1.5'))).toBe(true); + expect(isDecimalFixedPoint(usdc('0'))).toBe(true); + const signed = rawDecimalFixedPoint('signed', 8, 2); + expect(isDecimalFixedPoint(signed(-128n))).toBe(true); + }); + + it('returns false for non-objects and wrong kinds', () => { + expect(isDecimalFixedPoint(42)).toBe(false); + expect(isDecimalFixedPoint('1.5')).toBe(false); + expect(isDecimalFixedPoint(null)).toBe(false); + expect(isDecimalFixedPoint(undefined)).toBe(false); + expect(isDecimalFixedPoint({})).toBe(false); + expect(isDecimalFixedPoint({ kind: 'binaryFixedPoint' })).toBe(false); + }); + + it('returns false when required fields are missing or malformed', () => { + const base = { + decimals: 6, + kind: 'decimalFixedPoint', + raw: 0n, + signedness: 'unsigned', + totalBits: 64, + }; + expect(isDecimalFixedPoint({ ...base, signedness: 'weird' })).toBe(false); + expect(isDecimalFixedPoint({ ...base, totalBits: -1 })).toBe(false); + expect(isDecimalFixedPoint({ ...base, totalBits: 1.5 })).toBe(false); + expect(isDecimalFixedPoint({ ...base, decimals: -1 })).toBe(false); + expect(isDecimalFixedPoint({ ...base, raw: 1 })).toBe(false); // number instead of bigint + }); + + it('returns false when the raw value does not fit the claimed range', () => { + expect( + isDecimalFixedPoint({ + decimals: 0, + kind: 'decimalFixedPoint', + raw: 256n, + signedness: 'unsigned', + totalBits: 8, + }), + ).toBe(false); + }); + + it('narrows to the specific shape when parameters are provided', () => { + const usdc = decimalFixedPoint('unsigned', 64, 6); + const value = usdc('1.5'); + expect(isDecimalFixedPoint(value, 'unsigned', 64, 6)).toBe(true); + expect(isDecimalFixedPoint(value, 'signed', 64, 6)).toBe(false); + expect(isDecimalFixedPoint(value, 'unsigned', 32, 6)).toBe(false); + expect(isDecimalFixedPoint(value, 'unsigned', 64, 9)).toBe(false); + }); + + it('accepts partial positional arguments, constraining only the fields that are provided', () => { + const usdc = decimalFixedPoint('unsigned', 64, 6); + const value = usdc('1.5'); + expect(isDecimalFixedPoint(value, 'unsigned')).toBe(true); + expect(isDecimalFixedPoint(value, 'signed')).toBe(false); + expect(isDecimalFixedPoint(value, 'unsigned', 64)).toBe(true); + expect(isDecimalFixedPoint(value, 'unsigned', 32)).toBe(false); + }); + + it('treats `undefined` as "don’t care" for any skipped field', () => { + const usdc = decimalFixedPoint('unsigned', 64, 6); + const value = usdc('1.5'); + expect(isDecimalFixedPoint(value, undefined, 64)).toBe(true); + expect(isDecimalFixedPoint(value, undefined, undefined, 6)).toBe(true); + expect(isDecimalFixedPoint(value, undefined, 32)).toBe(false); + }); +}); + +describe('assertIsDecimalFixedPoint', () => { + it('passes silently for valid values', () => { + const usdc = decimalFixedPoint('unsigned', 64, 6); + expect(() => assertIsDecimalFixedPoint(usdc('1.5'))).not.toThrow(); + expect(() => assertIsDecimalFixedPoint(usdc('1.5'), 'unsigned', 64, 6)).not.toThrow(); + }); + + it('throws SHAPE_MISMATCH for non-object inputs', () => { + expect(() => assertIsDecimalFixedPoint(42)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__SHAPE_MISMATCH, { + actualKind: 'unknown', + actualScale: 0, + actualScaleLabel: 'unknown', + actualSignedness: 'unknown', + actualTotalBits: 0, + expectedKind: 'decimalFixedPoint', + expectedScale: 0, + expectedScaleLabel: 'decimals', + expectedSignedness: 'unknown', + expectedTotalBits: 0, + operation: 'assertIsDecimalFixedPoint', + }), + ); + }); + + it('throws SHAPE_MISMATCH when the value is a binary fixed-point', () => { + expect(() => assertIsDecimalFixedPoint({ kind: 'binaryFixedPoint' })).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__SHAPE_MISMATCH, { + actualKind: 'binaryFixedPoint', + actualScale: 0, + actualScaleLabel: 'fractional bits', + actualSignedness: 'unknown', + actualTotalBits: 0, + expectedKind: 'decimalFixedPoint', + expectedScale: 0, + expectedScaleLabel: 'decimals', + expectedSignedness: 'unknown', + expectedTotalBits: 0, + operation: 'assertIsDecimalFixedPoint', + }), + ); + }); + + it('throws SHAPE_MISMATCH when a decimal value has the wrong totalBits', () => { + const usdc = decimalFixedPoint('unsigned', 64, 6); + expect(() => assertIsDecimalFixedPoint(usdc('1.5'), 'unsigned', 32, 6)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__SHAPE_MISMATCH, { + actualKind: 'decimalFixedPoint', + actualScale: 6, + actualScaleLabel: 'decimals', + actualSignedness: 'unsigned', + actualTotalBits: 64, + expectedKind: 'decimalFixedPoint', + expectedScale: 6, + expectedScaleLabel: 'decimals', + expectedSignedness: 'unsigned', + expectedTotalBits: 32, + operation: 'assertIsDecimalFixedPoint', + }), + ); + }); + + it('throws VALUE_OUT_OF_RANGE when the raw value does not fit the claimed range', () => { + const malformed = { + decimals: 0, + kind: 'decimalFixedPoint' as const, + raw: 256n, + signedness: 'unsigned' as const, + totalBits: 8, + }; + expect(() => assertIsDecimalFixedPoint(malformed)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE, { + kind: 'decimalFixedPoint', + max: 255n, + min: 0n, + raw: 256n, + signedness: 'unsigned', + totalBits: 8, + }), + ); + }); + + it('throws MALFORMED_RAW_VALUE when the raw field is not a bigint', () => { + const malformed = { + decimals: 6, + kind: 'decimalFixedPoint' as const, + raw: 42, + signedness: 'unsigned' as const, + totalBits: 64, + }; + expect(() => assertIsDecimalFixedPoint(malformed)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__MALFORMED_RAW_VALUE, { + kind: 'decimalFixedPoint', + raw: 42, + }), + ); + }); +}); diff --git a/packages/fixed-points/src/__tests__/parsing-test.ts b/packages/fixed-points/src/__tests__/parsing-test.ts new file mode 100644 index 000000000..cd1d49809 --- /dev/null +++ b/packages/fixed-points/src/__tests__/parsing-test.ts @@ -0,0 +1,49 @@ +import { SOLANA_ERROR__FIXED_POINTS__INVALID_STRING, SolanaError } from '@solana/errors'; + +import { parseDecimalString } from '../parsing'; + +describe('parseDecimalString', () => { + it('parses positive integers', () => { + expect(parseDecimalString('decimalFixedPoint', '0')).toEqual({ decimals: 0, raw: 0n }); + expect(parseDecimalString('decimalFixedPoint', '42')).toEqual({ decimals: 0, raw: 42n }); + expect(parseDecimalString('decimalFixedPoint', '007')).toEqual({ decimals: 0, raw: 7n }); + }); + + it('parses negative integers', () => { + expect(parseDecimalString('decimalFixedPoint', '-42')).toEqual({ decimals: 0, raw: -42n }); + expect(parseDecimalString('decimalFixedPoint', '-0')).toEqual({ decimals: 0, raw: 0n }); + }); + + it('parses numbers with a fractional part', () => { + expect(parseDecimalString('decimalFixedPoint', '1.5')).toEqual({ decimals: 1, raw: 15n }); + expect(parseDecimalString('decimalFixedPoint', '42.500')).toEqual({ decimals: 3, raw: 42500n }); + expect(parseDecimalString('decimalFixedPoint', '-0.25')).toEqual({ decimals: 2, raw: -25n }); + }); + + it('parses numbers with an implicit leading or trailing zero', () => { + expect(parseDecimalString('decimalFixedPoint', '.5')).toEqual({ decimals: 1, raw: 5n }); + expect(parseDecimalString('decimalFixedPoint', '-.25')).toEqual({ decimals: 2, raw: -25n }); + expect(parseDecimalString('decimalFixedPoint', '5.')).toEqual({ decimals: 0, raw: 5n }); + }); + + it('rejects malformed inputs', () => { + const badInputs = ['', '-', '.', 'abc', '1e3', '+1', ' 1', '1 ', '1.2.3', '1,5', '0x10']; + for (const input of badInputs) { + expect(() => parseDecimalString('decimalFixedPoint', input)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__INVALID_STRING, { + input, + kind: 'decimalFixedPoint', + }), + ); + } + }); + + it('preserves the provided kind in the error context', () => { + expect(() => parseDecimalString('binaryFixedPoint', 'abc')).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__INVALID_STRING, { + input: 'abc', + kind: 'binaryFixedPoint', + }), + ); + }); +}); diff --git a/packages/fixed-points/src/__tests__/placeholder-test.ts b/packages/fixed-points/src/__tests__/placeholder-test.ts deleted file mode 100644 index 340e22f70..000000000 --- a/packages/fixed-points/src/__tests__/placeholder-test.ts +++ /dev/null @@ -1,5 +0,0 @@ -describe('@solana/fixed-points', () => { - it('has runtime tests that arrive with the upcoming factory PR', () => { - expect(true).toBe(true); - }); -}); diff --git a/packages/fixed-points/src/__tests__/rounding-test.ts b/packages/fixed-points/src/__tests__/rounding-test.ts new file mode 100644 index 000000000..c44bebdd7 --- /dev/null +++ b/packages/fixed-points/src/__tests__/rounding-test.ts @@ -0,0 +1,75 @@ +import { SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS, SolanaError } from '@solana/errors'; + +import { roundDivision, type RoundingMode } from '../rounding'; + +describe('roundDivision', () => { + const div = (numerator: bigint, denominator: bigint, mode: RoundingMode) => + roundDivision('decimalFixedPoint', 'test', numerator, denominator, mode); + + it('returns the exact quotient when the division has no remainder', () => { + for (const mode of ['ceil', 'floor', 'round', 'strict', 'trunc'] as const) { + expect(div(10n, 2n, mode)).toBe(5n); + expect(div(-10n, 2n, mode)).toBe(-5n); + expect(div(0n, 7n, mode)).toBe(0n); + } + }); + + it('throws under strict mode when division is inexact', () => { + expect(() => div(10n, 3n, 'strict')).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS, { + kind: 'decimalFixedPoint', + operation: 'test', + }), + ); + }); + + it('truncates toward zero under trunc', () => { + expect(div(10n, 3n, 'trunc')).toBe(3n); + expect(div(-10n, 3n, 'trunc')).toBe(-3n); + expect(div(10n, -3n, 'trunc')).toBe(-3n); + expect(div(-10n, -3n, 'trunc')).toBe(3n); + }); + + it('rounds toward negative infinity under floor', () => { + expect(div(10n, 3n, 'floor')).toBe(3n); + expect(div(-10n, 3n, 'floor')).toBe(-4n); + expect(div(10n, -3n, 'floor')).toBe(-4n); + expect(div(-10n, -3n, 'floor')).toBe(3n); + }); + + it('rounds toward positive infinity under ceil', () => { + expect(div(10n, 3n, 'ceil')).toBe(4n); + expect(div(-10n, 3n, 'ceil')).toBe(-3n); + expect(div(10n, -3n, 'ceil')).toBe(-3n); + expect(div(-10n, -3n, 'ceil')).toBe(4n); + }); + + it('rounds to nearest with ties away from zero under round', () => { + // Non-tie: closer to the upper integer. + expect(div(7n, 4n, 'round')).toBe(2n); // 1.75 -> 2 + expect(div(-7n, 4n, 'round')).toBe(-2n); // -1.75 -> -2 + + // Non-tie: closer to the lower integer. + expect(div(5n, 4n, 'round')).toBe(1n); // 1.25 -> 1 + expect(div(-5n, 4n, 'round')).toBe(-1n); // -1.25 -> -1 + + // Ties break away from zero. + expect(div(10n, 4n, 'round')).toBe(3n); // 2.5 -> 3 + expect(div(-10n, 4n, 'round')).toBe(-3n); // -2.5 -> -3 + expect(div(6n, 4n, 'round')).toBe(2n); // 1.5 -> 2 + expect(div(-6n, 4n, 'round')).toBe(-2n); // -1.5 -> -2 + }); + + it('handles very large values without losing precision', () => { + expect(div((1n << 200n) + 1n, 3n, 'floor')).toBe((1n << 200n) / 3n); + }); + + it('preserves the provided kind and operation in strict-mode errors', () => { + expect(() => roundDivision('binaryFixedPoint', 'divide', 1n, 3n, 'strict')).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS, { + kind: 'binaryFixedPoint', + operation: 'divide', + }), + ); + }); +}); diff --git a/packages/fixed-points/src/__typetests__/binary-guards-typetest.ts b/packages/fixed-points/src/__typetests__/binary-guards-typetest.ts new file mode 100644 index 000000000..20edf568c --- /dev/null +++ b/packages/fixed-points/src/__typetests__/binary-guards-typetest.ts @@ -0,0 +1,87 @@ +import type { BinaryFixedPoint } from '../binary/core'; +import { assertIsBinaryFixedPoint, isBinaryFixedPoint } from '../binary/guards'; +import type { Signedness } from '../signedness'; + +// [DESCRIBE] isBinaryFixedPoint. +{ + // It narrows to a fully-generic BinaryFixedPoint when no shape is provided. + { + const value = {} as unknown; + if (isBinaryFixedPoint(value)) { + value satisfies BinaryFixedPoint; + } + } + + // It narrows progressively as shape arguments are added. + { + const value = {} as unknown; + if (isBinaryFixedPoint(value, 'signed')) { + value satisfies BinaryFixedPoint<'signed', number, number>; + } + } + { + const value = {} as unknown; + if (isBinaryFixedPoint(value, 'signed', 16)) { + value satisfies BinaryFixedPoint<'signed', 16, number>; + } + } + { + const value = {} as unknown; + if (isBinaryFixedPoint(value, 'signed', 16, 15)) { + value satisfies BinaryFixedPoint<'signed', 16, 15>; + } + } + + // It preserves the generic default at a position when `undefined` is passed. + { + const value = {} as unknown; + if (isBinaryFixedPoint(value, undefined, 16)) { + value satisfies BinaryFixedPoint; + } + } + { + const value = {} as unknown; + if (isBinaryFixedPoint(value, undefined, undefined, 15)) { + value satisfies BinaryFixedPoint; + } + } +} + +// [DESCRIBE] assertIsBinaryFixedPoint. +{ + // It narrows to a fully-generic BinaryFixedPoint when no shape is provided. + { + const value = {} as unknown; + assertIsBinaryFixedPoint(value); + value satisfies BinaryFixedPoint; + } + + // It narrows progressively as shape arguments are added. + { + const value = {} as unknown; + assertIsBinaryFixedPoint(value, 'signed'); + value satisfies BinaryFixedPoint<'signed', number, number>; + } + { + const value = {} as unknown; + assertIsBinaryFixedPoint(value, 'signed', 16); + value satisfies BinaryFixedPoint<'signed', 16, number>; + } + { + const value = {} as unknown; + assertIsBinaryFixedPoint(value, 'signed', 16, 15); + value satisfies BinaryFixedPoint<'signed', 16, 15>; + } + + // It preserves the generic default at a position when `undefined` is passed. + { + const value = {} as unknown; + assertIsBinaryFixedPoint(value, undefined, 16); + value satisfies BinaryFixedPoint; + } + { + const value = {} as unknown; + assertIsBinaryFixedPoint(value, undefined, undefined, 15); + value satisfies BinaryFixedPoint; + } +} diff --git a/packages/fixed-points/src/__typetests__/decimal-guards-typetest.ts b/packages/fixed-points/src/__typetests__/decimal-guards-typetest.ts new file mode 100644 index 000000000..03025fee1 --- /dev/null +++ b/packages/fixed-points/src/__typetests__/decimal-guards-typetest.ts @@ -0,0 +1,87 @@ +import type { DecimalFixedPoint } from '../decimal/core'; +import { assertIsDecimalFixedPoint, isDecimalFixedPoint } from '../decimal/guards'; +import type { Signedness } from '../signedness'; + +// [DESCRIBE] isDecimalFixedPoint. +{ + // It narrows to a fully-generic DecimalFixedPoint when no shape is provided. + { + const value = {} as unknown; + if (isDecimalFixedPoint(value)) { + value satisfies DecimalFixedPoint; + } + } + + // It narrows progressively as shape arguments are added. + { + const value = {} as unknown; + if (isDecimalFixedPoint(value, 'unsigned')) { + value satisfies DecimalFixedPoint<'unsigned', number, number>; + } + } + { + const value = {} as unknown; + if (isDecimalFixedPoint(value, 'unsigned', 64)) { + value satisfies DecimalFixedPoint<'unsigned', 64, number>; + } + } + { + const value = {} as unknown; + if (isDecimalFixedPoint(value, 'unsigned', 64, 6)) { + value satisfies DecimalFixedPoint<'unsigned', 64, 6>; + } + } + + // It preserves the generic default at a position when `undefined` is passed. + { + const value = {} as unknown; + if (isDecimalFixedPoint(value, undefined, 64)) { + value satisfies DecimalFixedPoint; + } + } + { + const value = {} as unknown; + if (isDecimalFixedPoint(value, undefined, undefined, 6)) { + value satisfies DecimalFixedPoint; + } + } +} + +// [DESCRIBE] assertIsDecimalFixedPoint. +{ + // It narrows to a fully-generic DecimalFixedPoint when no shape is provided. + { + const value = {} as unknown; + assertIsDecimalFixedPoint(value); + value satisfies DecimalFixedPoint; + } + + // It narrows progressively as shape arguments are added. + { + const value = {} as unknown; + assertIsDecimalFixedPoint(value, 'unsigned'); + value satisfies DecimalFixedPoint<'unsigned', number, number>; + } + { + const value = {} as unknown; + assertIsDecimalFixedPoint(value, 'unsigned', 64); + value satisfies DecimalFixedPoint<'unsigned', 64, number>; + } + { + const value = {} as unknown; + assertIsDecimalFixedPoint(value, 'unsigned', 64, 6); + value satisfies DecimalFixedPoint<'unsigned', 64, 6>; + } + + // It preserves the generic default at a position when `undefined` is passed. + { + const value = {} as unknown; + assertIsDecimalFixedPoint(value, undefined, 64); + value satisfies DecimalFixedPoint; + } + { + const value = {} as unknown; + assertIsDecimalFixedPoint(value, undefined, undefined, 6); + value satisfies DecimalFixedPoint; + } +} diff --git a/packages/fixed-points/src/assertions.ts b/packages/fixed-points/src/assertions.ts new file mode 100644 index 000000000..8aa973b17 --- /dev/null +++ b/packages/fixed-points/src/assertions.ts @@ -0,0 +1,241 @@ +import { + SOLANA_ERROR__FIXED_POINTS__FRACTIONAL_BITS_EXCEED_TOTAL_BITS, + SOLANA_ERROR__FIXED_POINTS__INVALID_DECIMALS, + SOLANA_ERROR__FIXED_POINTS__INVALID_FRACTIONAL_BITS, + SOLANA_ERROR__FIXED_POINTS__INVALID_TOTAL_BITS, + SOLANA_ERROR__FIXED_POINTS__MALFORMED_RAW_VALUE, + SOLANA_ERROR__FIXED_POINTS__SHAPE_MISMATCH, + SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE, + SolanaError, +} from '@solana/errors'; + +import type { Signedness } from './signedness'; + +type FixedPointKind = 'binaryFixedPoint' | 'decimalFixedPoint'; + +/** + * Returns the inclusive raw bigint range for a fixed-point number with the + * given signedness and total bits. + * + * Signed ranges use two's-complement semantics, so an 8-bit signed value + * spans `[-128n, 127n]` and an 8-bit unsigned value spans `[0n, 255n]`. + * + * This helper trusts that `totalBits` has already been validated as a + * positive integer by the caller. + * + * @internal + */ +export function getRawRange(signedness: Signedness, totalBits: number): { max: bigint; min: bigint } { + if (signedness === 'signed') { + const half = 1n << BigInt(totalBits - 1); + return { max: half - 1n, min: -half }; + } + return { max: (1n << BigInt(totalBits)) - 1n, min: 0n }; +} + +/** + * Asserts that `totalBits` is a positive integer. Throws + * `SOLANA_ERROR__FIXED_POINTS__INVALID_TOTAL_BITS` otherwise. + * + * @internal + */ +export function assertValidTotalBits(kind: FixedPointKind, totalBits: unknown): asserts totalBits is number { + if (typeof totalBits !== 'number' || !Number.isInteger(totalBits) || totalBits <= 0) { + throw new SolanaError(SOLANA_ERROR__FIXED_POINTS__INVALID_TOTAL_BITS, { + kind, + totalBits, + }); + } +} + +/** + * Asserts that `fractionalBits` is a non-negative integer. Throws + * `SOLANA_ERROR__FIXED_POINTS__INVALID_FRACTIONAL_BITS` otherwise. + * + * @internal + */ +export function assertValidFractionalBits(fractionalBits: unknown): asserts fractionalBits is number { + if (typeof fractionalBits !== 'number' || !Number.isInteger(fractionalBits) || fractionalBits < 0) { + throw new SolanaError(SOLANA_ERROR__FIXED_POINTS__INVALID_FRACTIONAL_BITS, { + fractionalBits, + }); + } +} + +/** + * Asserts that `decimals` is a non-negative integer. Throws + * `SOLANA_ERROR__FIXED_POINTS__INVALID_DECIMALS` otherwise. + * + * @internal + */ +export function assertValidDecimals(decimals: unknown): asserts decimals is number { + if (typeof decimals !== 'number' || !Number.isInteger(decimals) || decimals < 0) { + throw new SolanaError(SOLANA_ERROR__FIXED_POINTS__INVALID_DECIMALS, { + decimals, + }); + } +} + +/** + * Asserts that `fractionalBits` does not exceed `totalBits` for a binary + * fixed-point shape. Throws + * `SOLANA_ERROR__FIXED_POINTS__FRACTIONAL_BITS_EXCEED_TOTAL_BITS` otherwise. + * + * @internal + */ +export function assertFractionalBitsFitInTotalBits(fractionalBits: number, totalBits: number): void { + if (fractionalBits > totalBits) { + throw new SolanaError(SOLANA_ERROR__FIXED_POINTS__FRACTIONAL_BITS_EXCEED_TOTAL_BITS, { + fractionalBits, + totalBits, + }); + } +} + +/** + * Asserts that a raw bigint fits the range claimed by the given signedness + * and total bits. Throws `SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE` + * otherwise. + * + * @internal + */ +export function assertRawFitsInRange( + kind: FixedPointKind, + signedness: Signedness, + totalBits: number, + raw: bigint, +): void { + const { max, min } = getRawRange(signedness, totalBits); + if (raw < min || raw > max) { + throw new SolanaError(SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE, { + kind, + max, + min, + raw, + signedness, + totalBits, + }); + } +} + +/** + * Describes the concrete shape of a fixed-point value for `SHAPE_MISMATCH` + * error context. The `scale` is `fractionalBits` for binary values and + * `decimals` for decimal values; `scaleLabel` is the matching + * human-readable label. + * + * @internal + */ +export type FixedPointShape = { + kind: string; + scale: number; + scaleLabel: string; + signedness: string; + totalBits: number; +}; + +/** + * Expected shape for {@link assertShapeMatches}. Each field except `kind` + * and `scaleLabel` is optional: `undefined` means "don't constrain this + * field". `kind` is always required because mismatched kinds are always + * mismatches; `scaleLabel` is always required because it appears in the + * expected side of the error message even when `scale` is not pinned. + * + * @internal + */ +export type ExpectedFixedPointShape = { + kind: string; + scale?: number; + scaleLabel: string; + signedness?: string; + totalBits?: number; +}; + +/** + * Best-effort {@link FixedPointShape} description for an unknown value. + * Used to populate the `actual*` half of `SHAPE_MISMATCH` contexts when + * the input may not be a valid fixed-point value at all. + * + * @internal + */ +export function describeShape(value: unknown): FixedPointShape { + const record = value && typeof value === 'object' ? (value as Record) : {}; + const kind = typeof record.kind === 'string' ? record.kind : 'unknown'; + const signedness = typeof record.signedness === 'string' ? record.signedness : 'unknown'; + const totalBits = typeof record.totalBits === 'number' ? record.totalBits : 0; + let scale: number; + let scaleLabel: string; + if (kind === 'decimalFixedPoint') { + scale = typeof record.decimals === 'number' ? record.decimals : 0; + scaleLabel = 'decimals'; + } else if (kind === 'binaryFixedPoint') { + scale = typeof record.fractionalBits === 'number' ? record.fractionalBits : 0; + scaleLabel = 'fractional bits'; + } else { + scale = 0; + scaleLabel = 'unknown'; + } + return { kind, scale, scaleLabel, signedness, totalBits }; +} + +/** + * Asserts that `actual` matches the `expected` shape. Throws + * `SOLANA_ERROR__FIXED_POINTS__SHAPE_MISMATCH` otherwise. + * + * Fields left `undefined` on `expected` are not constrained — the actual + * value may carry any value for that field. This is how partial shape + * checks (e.g. "any signed value" without pinning `totalBits`) are + * expressed. + * + * @internal + */ +export function assertShapeMatches( + operation: string, + actual: FixedPointShape, + expected: ExpectedFixedPointShape, +): void { + const actualIsStructurallyValid = + (actual.signedness === 'signed' || actual.signedness === 'unsigned') && + Number.isInteger(actual.totalBits) && + actual.totalBits > 0 && + Number.isInteger(actual.scale) && + actual.scale >= 0; + if ( + !actualIsStructurallyValid || + actual.kind !== expected.kind || + (expected.signedness !== undefined && actual.signedness !== expected.signedness) || + (expected.totalBits !== undefined && actual.totalBits !== expected.totalBits) || + (expected.scale !== undefined && actual.scale !== expected.scale) + ) { + throw new SolanaError(SOLANA_ERROR__FIXED_POINTS__SHAPE_MISMATCH, { + actualKind: actual.kind, + actualScale: actual.scale, + actualScaleLabel: actual.scaleLabel, + actualSignedness: actual.signedness, + actualTotalBits: actual.totalBits, + expectedKind: expected.kind, + expectedScale: expected.scale ?? actual.scale, + expectedScaleLabel: expected.scaleLabel, + expectedSignedness: expected.signedness ?? actual.signedness, + expectedTotalBits: expected.totalBits ?? actual.totalBits, + operation, + }); + } +} + +/** + * Asserts that `value.raw` is a bigint, so that downstream range checks + * can compare it against the claimed signedness and total bits. Throws + * `SOLANA_ERROR__FIXED_POINTS__MALFORMED_RAW_VALUE` otherwise. + * + * @internal + */ +export function assertRawIsBigint(kind: FixedPointKind, value: unknown): asserts value is { raw: bigint } { + const raw = value && typeof value === 'object' ? (value as { raw?: unknown }).raw : undefined; + if (typeof raw !== 'bigint') { + throw new SolanaError(SOLANA_ERROR__FIXED_POINTS__MALFORMED_RAW_VALUE, { + kind, + raw, + }); + } +} diff --git a/packages/fixed-points/src/binary/core.ts b/packages/fixed-points/src/binary/core.ts index 921c4c0ff..3a74b7301 100644 --- a/packages/fixed-points/src/binary/core.ts +++ b/packages/fixed-points/src/binary/core.ts @@ -1,3 +1,13 @@ +import { SOLANA_ERROR__FIXED_POINTS__INVALID_ZERO_DENOMINATOR_RATIO, SolanaError } from '@solana/errors'; + +import { + assertFractionalBitsFitInTotalBits, + assertRawFitsInRange, + assertValidFractionalBits, + assertValidTotalBits, +} from '../assertions'; +import { parseDecimalString } from '../parsing'; +import { roundDivision, type RoundingMode } from '../rounding'; import type { Signedness } from '../signedness'; /** @@ -34,3 +44,165 @@ export type BinaryFixedPoint< readonly signedness: TSignedness; readonly totalBits: TTotalBits; }; + +function createBinaryFixedPoint< + TSignedness extends Signedness, + TTotalBits extends number, + TFractionalBits extends number, +>( + signedness: TSignedness, + totalBits: TTotalBits, + fractionalBits: TFractionalBits, + raw: bigint, +): BinaryFixedPoint { + assertRawFitsInRange('binaryFixedPoint', signedness, totalBits, raw); + return Object.freeze({ fractionalBits, kind: 'binaryFixedPoint', raw, signedness, totalBits }); +} + +/** + * Returns a factory that constructs {@link BinaryFixedPoint} values from + * decimal strings. + * + * The outer call validates the shape parameters once and the returned + * factory can be called many times to construct values of that shape. + * + * The input string is parsed as a decimal number and scaled by + * `2 ** fractionalBits` to compute the raw bigint. Values that cannot be + * represented exactly in binary (such as `"0.1"`) trigger the rounding + * behaviour documented on {@link RoundingMode}, with `'strict'` throwing + * `SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS` by default. + * + * @example + * ```ts + * const audioSample = binaryFixedPoint('signed', 16, 15); + * audioSample('0.5'); // raw === 16384n (exact) + * audioSample('0.1'); // throws under the default 'strict' mode + * audioSample('0.1', 'round'); // raw === 3277n + * ``` + * + * @see {@link BinaryFixedPoint} + * @see {@link rawBinaryFixedPoint} + * @see {@link ratioBinaryFixedPoint} + */ +export function binaryFixedPoint< + TSignedness extends Signedness, + TTotalBits extends number, + TFractionalBits extends number, +>( + signedness: TSignedness, + totalBits: TTotalBits, + fractionalBits: TFractionalBits, +): (input: string, rounding?: RoundingMode) => BinaryFixedPoint { + assertValidTotalBits('binaryFixedPoint', totalBits); + assertValidFractionalBits(fractionalBits); + assertFractionalBitsFitInTotalBits(fractionalBits, totalBits); + return (input, rounding = 'strict') => { + const parsed = parseDecimalString('binaryFixedPoint', input); + // The parsed value is `parsed.raw / 10^parsed.decimals`. We need + // `raw = value * 2^fractionalBits`, i.e. + // `raw = parsed.raw * 2^fractionalBits / 10^parsed.decimals`. + const scaledRaw = parsed.raw * (1n << BigInt(fractionalBits)); + const raw = + parsed.decimals === 0 + ? scaledRaw + : roundDivision('binaryFixedPoint', 'fromString', scaledRaw, 10n ** BigInt(parsed.decimals), rounding); + return createBinaryFixedPoint(signedness, totalBits, fractionalBits, raw); + }; +} + +/** + * Returns a factory that constructs {@link BinaryFixedPoint} values from a + * raw bigint in the smallest representable unit (i.e. already scaled by + * `2 ** fractionalBits`). + * + * The outer call validates the shape parameters once and the returned + * factory can be called many times to construct values of that shape. + * + * The raw value is range-checked against the claimed `totalBits` and + * `signedness`; no rounding is ever required. + * + * @example + * ```ts + * const q1_15 = rawBinaryFixedPoint('signed', 16, 15); + * q1_15(16384n); // Represents 0.5 + * ``` + * + * @see {@link BinaryFixedPoint} + * @see {@link binaryFixedPoint} + * @see {@link ratioBinaryFixedPoint} + */ +export function rawBinaryFixedPoint< + TSignedness extends Signedness, + TTotalBits extends number, + TFractionalBits extends number, +>( + signedness: TSignedness, + totalBits: TTotalBits, + fractionalBits: TFractionalBits, +): (raw: bigint) => BinaryFixedPoint { + assertValidTotalBits('binaryFixedPoint', totalBits); + assertValidFractionalBits(fractionalBits); + assertFractionalBitsFitInTotalBits(fractionalBits, totalBits); + return raw => createBinaryFixedPoint(signedness, totalBits, fractionalBits, raw); +} + +/** + * Returns a factory that constructs {@link BinaryFixedPoint} values from a + * rational `numerator / denominator`. + * + * The outer call validates the shape parameters once and the returned + * factory can be called many times to construct values of that shape. + * + * If the ratio cannot be exactly represented at the target + * `fractionalBits`, the returned factory throws + * `SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS` under the + * default `'strict'` rounding mode. Pass a different {@link RoundingMode} + * to allow a rounded result. Zero denominators always throw + * `SOLANA_ERROR__FIXED_POINTS__INVALID_ZERO_DENOMINATOR_RATIO`. + * + * @example + * ```ts + * const probability = ratioBinaryFixedPoint('signed', 16, 15); + * probability(1n, 4n); // raw === 8192n (0.25, exact) + * probability(1n, 3n); // throws under 'strict' + * probability(1n, 3n, 'floor'); // raw === 10922n + * ``` + * + * @see {@link BinaryFixedPoint} + * @see {@link binaryFixedPoint} + * @see {@link rawBinaryFixedPoint} + */ +export function ratioBinaryFixedPoint< + TSignedness extends Signedness, + TTotalBits extends number, + TFractionalBits extends number, +>( + signedness: TSignedness, + totalBits: TTotalBits, + fractionalBits: TFractionalBits, +): ( + numerator: bigint, + denominator: bigint, + rounding?: RoundingMode, +) => BinaryFixedPoint { + assertValidTotalBits('binaryFixedPoint', totalBits); + assertValidFractionalBits(fractionalBits); + assertFractionalBitsFitInTotalBits(fractionalBits, totalBits); + return (numerator, denominator, rounding = 'strict') => { + if (denominator === 0n) { + throw new SolanaError(SOLANA_ERROR__FIXED_POINTS__INVALID_ZERO_DENOMINATOR_RATIO, { + denominator, + kind: 'binaryFixedPoint', + numerator, + }); + } + const raw = roundDivision( + 'binaryFixedPoint', + 'fromRatio', + numerator * (1n << BigInt(fractionalBits)), + denominator, + rounding, + ); + return createBinaryFixedPoint(signedness, totalBits, fractionalBits, raw); + }; +} diff --git a/packages/fixed-points/src/binary/guards.ts b/packages/fixed-points/src/binary/guards.ts new file mode 100644 index 000000000..5c5781d0d --- /dev/null +++ b/packages/fixed-points/src/binary/guards.ts @@ -0,0 +1,96 @@ +import { + assertFractionalBitsFitInTotalBits, + assertRawFitsInRange, + assertRawIsBigint, + assertShapeMatches, + describeShape, +} from '../assertions'; +import type { Signedness } from '../signedness'; +import type { BinaryFixedPoint } from './core'; + +/** + * Asserts that `value` is a {@link BinaryFixedPoint}. + * + * Every shape parameter is independently optional. Pass `undefined` (or + * simply omit trailing arguments) to leave a given field unconstrained. + * + * Throws `SOLANA_ERROR__FIXED_POINTS__SHAPE_MISMATCH` if the value does + * not match the expected shape, or + * `SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE` if the `raw` bigint + * does not fit the claimed signedness and total bits. + * + * @example + * ```ts + * assertIsBinaryFixedPoint(value); // any binary fixed-point + * assertIsBinaryFixedPoint(value, 'signed'); // any signed binary + * assertIsBinaryFixedPoint(value, 'signed', 16, 15); // fully pinned + * assertIsBinaryFixedPoint(value, undefined, 16); // any binary with totalBits=16 + * ``` + * + * @see {@link isBinaryFixedPoint} + * @see {@link BinaryFixedPoint} + */ +export function assertIsBinaryFixedPoint< + TSignedness extends Signedness = Signedness, + TTotalBits extends number = number, + TFractionalBits extends number = number, +>( + value: unknown, + signedness?: TSignedness, + totalBits?: TTotalBits, + fractionalBits?: TFractionalBits, +): asserts value is BinaryFixedPoint { + const actual = describeShape(value); + const expected = { + kind: 'binaryFixedPoint', + scale: fractionalBits, + scaleLabel: 'fractional bits', + signedness, + totalBits, + }; + assertShapeMatches('assertIsBinaryFixedPoint', actual, expected); + // Binary fixed-points carry an extra structural invariant beyond the + // generic shape check: `fractionalBits` (stored in `actual.scale`) + // must not exceed `totalBits`. + assertFractionalBitsFitInTotalBits(actual.scale, actual.totalBits); + assertRawIsBigint('binaryFixedPoint', value); + assertRawFitsInRange('binaryFixedPoint', actual.signedness as Signedness, actual.totalBits, value.raw); +} + +/** + * Type guard that refines an unknown value to a {@link BinaryFixedPoint}. + * + * Accepts the same partial-positional shape arguments as + * {@link assertIsBinaryFixedPoint} and returns `true` if the assertion + * would pass, `false` otherwise. + * + * @example + * ```ts + * if (isBinaryFixedPoint(value)) { + * value satisfies BinaryFixedPoint; + * } + * if (isBinaryFixedPoint(value, 'signed', 16, 15)) { + * value satisfies BinaryFixedPoint<'signed', 16, 15>; + * } + * ``` + * + * @see {@link assertIsBinaryFixedPoint} + * @see {@link BinaryFixedPoint} + */ +export function isBinaryFixedPoint< + TSignedness extends Signedness = Signedness, + TTotalBits extends number = number, + TFractionalBits extends number = number, +>( + value: unknown, + signedness?: TSignedness, + totalBits?: TTotalBits, + fractionalBits?: TFractionalBits, +): value is BinaryFixedPoint { + try { + assertIsBinaryFixedPoint(value, signedness, totalBits, fractionalBits); + return true; + } catch { + return false; + } +} diff --git a/packages/fixed-points/src/binary/index.ts b/packages/fixed-points/src/binary/index.ts index 4b0e04137..5cc9bdc45 100644 --- a/packages/fixed-points/src/binary/index.ts +++ b/packages/fixed-points/src/binary/index.ts @@ -1 +1,2 @@ export * from './core'; +export * from './guards'; diff --git a/packages/fixed-points/src/decimal/core.ts b/packages/fixed-points/src/decimal/core.ts index 48c724a74..ecefbdcc8 100644 --- a/packages/fixed-points/src/decimal/core.ts +++ b/packages/fixed-points/src/decimal/core.ts @@ -1,3 +1,8 @@ +import { SOLANA_ERROR__FIXED_POINTS__INVALID_ZERO_DENOMINATOR_RATIO, SolanaError } from '@solana/errors'; + +import { assertRawFitsInRange, assertValidDecimals, assertValidTotalBits } from '../assertions'; +import { parseDecimalString } from '../parsing'; +import { roundDivision, type RoundingMode } from '../rounding'; import type { Signedness } from '../signedness'; /** @@ -28,3 +33,156 @@ export type DecimalFixedPoint( + signedness: TSignedness, + totalBits: TTotalBits, + decimals: TDecimals, + raw: bigint, +): DecimalFixedPoint { + assertRawFitsInRange('decimalFixedPoint', signedness, totalBits, raw); + return Object.freeze({ decimals, kind: 'decimalFixedPoint', raw, signedness, totalBits }); +} + +/** + * Returns a factory that constructs {@link DecimalFixedPoint} values from + * decimal strings. + * + * The outer call validates the shape parameters once and the returned + * factory can be called many times to construct values of that shape. + * + * If the string carries more precision than the target `decimals` can + * represent exactly, the returned factory throws + * `SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS` under the + * default `'strict'` rounding mode. Pass a different {@link RoundingMode} + * to allow a rounded result. + * + * @example + * ```ts + * const usdc = decimalFixedPoint('unsigned', 64, 6); + * usdc('42.5'); // raw === 42500000n + * usdc('0.0000001'); // throws under the default 'strict' mode + * usdc('0.0000001', 'round'); // raw === 0n + * ``` + * + * @see {@link DecimalFixedPoint} + * @see {@link rawDecimalFixedPoint} + * @see {@link ratioDecimalFixedPoint} + */ +export function decimalFixedPoint( + signedness: TSignedness, + totalBits: TTotalBits, + decimals: TDecimals, +): (input: string, rounding?: RoundingMode) => DecimalFixedPoint { + assertValidTotalBits('decimalFixedPoint', totalBits); + assertValidDecimals(decimals); + return (input, rounding = 'strict') => { + const parsed = parseDecimalString('decimalFixedPoint', input); + const raw = + parsed.decimals <= decimals + ? parsed.raw * 10n ** BigInt(decimals - parsed.decimals) + : roundDivision( + 'decimalFixedPoint', + 'fromString', + parsed.raw, + 10n ** BigInt(parsed.decimals - decimals), + rounding, + ); + return createDecimalFixedPoint(signedness, totalBits, decimals, raw); + }; +} + +/** + * Returns a factory that constructs {@link DecimalFixedPoint} values from a + * raw bigint in the smallest representable unit (i.e. already scaled by + * `10 ** decimals`). + * + * The outer call validates the shape parameters once and the returned + * factory can be called many times to construct values of that shape. + * + * The raw value is range-checked against the claimed `totalBits` and + * `signedness`; no rounding is ever required. + * + * @example + * ```ts + * const cents = rawDecimalFixedPoint('unsigned', 16, 2); + * cents(425n); // Represents 4.25 + * ``` + * + * @see {@link DecimalFixedPoint} + * @see {@link decimalFixedPoint} + * @see {@link ratioDecimalFixedPoint} + */ +export function rawDecimalFixedPoint< + TSignedness extends Signedness, + TTotalBits extends number, + TDecimals extends number, +>( + signedness: TSignedness, + totalBits: TTotalBits, + decimals: TDecimals, +): (raw: bigint) => DecimalFixedPoint { + assertValidTotalBits('decimalFixedPoint', totalBits); + assertValidDecimals(decimals); + return raw => createDecimalFixedPoint(signedness, totalBits, decimals, raw); +} + +/** + * Returns a factory that constructs {@link DecimalFixedPoint} values from + * a rational `numerator / denominator`. + * + * The outer call validates the shape parameters once and the returned + * factory can be called many times to construct values of that shape. + * + * If the ratio cannot be exactly represented at the target `decimals`, + * the returned factory throws + * `SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS` under the + * default `'strict'` rounding mode. Pass a different {@link RoundingMode} + * to allow a rounded result. Zero denominators always throw + * `SOLANA_ERROR__FIXED_POINTS__INVALID_ZERO_DENOMINATOR_RATIO`. + * + * @example + * ```ts + * const probability = ratioDecimalFixedPoint('unsigned', 64, 4); + * probability(1n, 4n); // raw === 2500n (0.2500) + * probability(1n, 3n); // throws under 'strict' + * probability(1n, 3n, 'floor'); // raw === 3333n + * ``` + * + * @see {@link DecimalFixedPoint} + * @see {@link decimalFixedPoint} + * @see {@link rawDecimalFixedPoint} + */ +export function ratioDecimalFixedPoint< + TSignedness extends Signedness, + TTotalBits extends number, + TDecimals extends number, +>( + signedness: TSignedness, + totalBits: TTotalBits, + decimals: TDecimals, +): ( + numerator: bigint, + denominator: bigint, + rounding?: RoundingMode, +) => DecimalFixedPoint { + assertValidTotalBits('decimalFixedPoint', totalBits); + assertValidDecimals(decimals); + return (numerator, denominator, rounding = 'strict') => { + if (denominator === 0n) { + throw new SolanaError(SOLANA_ERROR__FIXED_POINTS__INVALID_ZERO_DENOMINATOR_RATIO, { + denominator, + kind: 'decimalFixedPoint', + numerator, + }); + } + const raw = roundDivision( + 'decimalFixedPoint', + 'fromRatio', + numerator * 10n ** BigInt(decimals), + denominator, + rounding, + ); + return createDecimalFixedPoint(signedness, totalBits, decimals, raw); + }; +} diff --git a/packages/fixed-points/src/decimal/guards.ts b/packages/fixed-points/src/decimal/guards.ts new file mode 100644 index 000000000..e1413d56d --- /dev/null +++ b/packages/fixed-points/src/decimal/guards.ts @@ -0,0 +1,86 @@ +import { assertRawFitsInRange, assertRawIsBigint, assertShapeMatches, describeShape } from '../assertions'; +import type { Signedness } from '../signedness'; +import type { DecimalFixedPoint } from './core'; + +/** + * Asserts that `value` is a {@link DecimalFixedPoint}. + * + * Every shape parameter is independently optional. Pass `undefined` (or + * simply omit trailing arguments) to leave a given field unconstrained. + * + * Throws `SOLANA_ERROR__FIXED_POINTS__SHAPE_MISMATCH` if the value does + * not match the expected shape, or + * `SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE` if the `raw` bigint + * does not fit the claimed signedness and total bits. + * + * @example + * ```ts + * assertIsDecimalFixedPoint(value); // any decimal fixed-point + * assertIsDecimalFixedPoint(value, 'unsigned'); // any unsigned decimal + * assertIsDecimalFixedPoint(value, 'unsigned', 64, 6); // fully pinned + * assertIsDecimalFixedPoint(value, undefined, 64); // any decimal with totalBits=64 + * ``` + * + * @see {@link isDecimalFixedPoint} + * @see {@link DecimalFixedPoint} + */ +export function assertIsDecimalFixedPoint< + TSignedness extends Signedness = Signedness, + TTotalBits extends number = number, + TDecimals extends number = number, +>( + value: unknown, + signedness?: TSignedness, + totalBits?: TTotalBits, + decimals?: TDecimals, +): asserts value is DecimalFixedPoint { + const actual = describeShape(value); + const expected = { + kind: 'decimalFixedPoint', + scale: decimals, + scaleLabel: 'decimals', + signedness, + totalBits, + }; + assertShapeMatches('assertIsDecimalFixedPoint', actual, expected); + assertRawIsBigint('decimalFixedPoint', value); + assertRawFitsInRange('decimalFixedPoint', actual.signedness as Signedness, actual.totalBits, value.raw); +} + +/** + * Type guard that refines an unknown value to a {@link DecimalFixedPoint}. + * + * Accepts the same partial-positional shape arguments as + * {@link assertIsDecimalFixedPoint} and returns `true` if the assertion + * would pass, `false` otherwise. + * + * @example + * ```ts + * if (isDecimalFixedPoint(value)) { + * value satisfies DecimalFixedPoint; + * } + * if (isDecimalFixedPoint(value, 'unsigned', 64, 6)) { + * value satisfies DecimalFixedPoint<'unsigned', 64, 6>; + * } + * ``` + * + * @see {@link assertIsDecimalFixedPoint} + * @see {@link DecimalFixedPoint} + */ +export function isDecimalFixedPoint< + TSignedness extends Signedness = Signedness, + TTotalBits extends number = number, + TDecimals extends number = number, +>( + value: unknown, + signedness?: TSignedness, + totalBits?: TTotalBits, + decimals?: TDecimals, +): value is DecimalFixedPoint { + try { + assertIsDecimalFixedPoint(value, signedness, totalBits, decimals); + return true; + } catch { + return false; + } +} diff --git a/packages/fixed-points/src/decimal/index.ts b/packages/fixed-points/src/decimal/index.ts index 4b0e04137..5cc9bdc45 100644 --- a/packages/fixed-points/src/decimal/index.ts +++ b/packages/fixed-points/src/decimal/index.ts @@ -1 +1,2 @@ export * from './core'; +export * from './guards'; diff --git a/packages/fixed-points/src/index.ts b/packages/fixed-points/src/index.ts index 456f32509..fc6f91533 100644 --- a/packages/fixed-points/src/index.ts +++ b/packages/fixed-points/src/index.ts @@ -12,5 +12,5 @@ */ export * from './binary'; export * from './decimal'; -export * from './rounding'; +export type { RoundingMode } from './rounding'; export * from './signedness'; diff --git a/packages/fixed-points/src/parsing.ts b/packages/fixed-points/src/parsing.ts new file mode 100644 index 000000000..d18d7b111 --- /dev/null +++ b/packages/fixed-points/src/parsing.ts @@ -0,0 +1,46 @@ +import { SOLANA_ERROR__FIXED_POINTS__INVALID_STRING, SolanaError } from '@solana/errors'; + +/** + * Parses a human-readable decimal string into a decimal fixed-point + * representation `{ raw, decimals }` such that the parsed value is exactly + * `raw / 10 ** decimals`. + * + * Accepts strings of the form: + * - `"123"`, `"-123"` + * - `"12.5"`, `"-0.25"` + * - `".5"`, `"-.5"`, `"5."` + * + * Rejects scientific notation, leading `+`, whitespace, and any other + * non-digit / non-sign / non-decimal-point characters. + * + * Throws `SOLANA_ERROR__FIXED_POINTS__INVALID_STRING` for malformed input. + * + * @internal + */ +export function parseDecimalString( + kind: 'binaryFixedPoint' | 'decimalFixedPoint', + input: string, +): { decimals: number; raw: bigint } { + if (typeof input !== 'string' || !/^-?(?:\d+\.?\d*|\.\d+)$/.test(input)) { + throw new SolanaError(SOLANA_ERROR__FIXED_POINTS__INVALID_STRING, { + input: String(input), + kind, + }); + } + const isNegative = input.startsWith('-'); + const unsigned = isNegative ? input.slice(1) : input; + const dotIndex = unsigned.indexOf('.'); + let integerPart: string; + let fractionalPart: string; + if (dotIndex === -1) { + integerPart = unsigned; + fractionalPart = ''; + } else { + integerPart = unsigned.slice(0, dotIndex); + fractionalPart = unsigned.slice(dotIndex + 1); + } + const digits = (integerPart || '0') + fractionalPart; + const rawAbs = BigInt(digits); + const raw = isNegative ? -rawAbs : rawAbs; + return { decimals: fractionalPart.length, raw }; +} diff --git a/packages/fixed-points/src/rounding.ts b/packages/fixed-points/src/rounding.ts index d01920c20..4ee376daf 100644 --- a/packages/fixed-points/src/rounding.ts +++ b/packages/fixed-points/src/rounding.ts @@ -1,3 +1,5 @@ +import { SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS, SolanaError } from '@solana/errors'; + /** * Rounding mode used by fixed-point operations that must coerce an exact * mathematical result into a value with fewer bits of precision. Applies to @@ -16,3 +18,55 @@ * coercing the result. */ export type RoundingMode = 'ceil' | 'floor' | 'round' | 'strict' | 'trunc'; + +/** + * Divides `numerator` by `denominator` and rounds the quotient according to + * the given {@link RoundingMode}. + * + * If the division is exact, the quotient is returned unchanged regardless + * of the rounding mode. Otherwise, `'strict'` throws + * `SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS` and the other + * modes round as documented on {@link RoundingMode}. + * + * The helper handles negative numerators and denominators correctly and + * assumes `denominator !== 0n` — callers must check for and report + * division-by-zero before invoking this function. + * + * @internal + */ +export function roundDivision( + kind: 'binaryFixedPoint' | 'decimalFixedPoint', + operation: string, + numerator: bigint, + denominator: bigint, + mode: RoundingMode, +): bigint { + const quotient = numerator / denominator; + const remainder = numerator - quotient * denominator; + if (remainder === 0n) { + return quotient; + } + if (mode === 'strict') { + throw new SolanaError(SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS, { + kind, + operation, + }); + } + const sameSign = numerator < 0n === denominator < 0n; + if (mode === 'trunc') { + return quotient; + } + if (mode === 'floor') { + return sameSign ? quotient : quotient - 1n; + } + if (mode === 'ceil') { + return sameSign ? quotient + 1n : quotient; + } + // 'round': ties away from zero. + const absRemainderDoubled = (remainder < 0n ? -remainder : remainder) * 2n; + const absDenominator = denominator < 0n ? -denominator : denominator; + if (absRemainderDoubled < absDenominator) { + return quotient; + } + return sameSign ? quotient + 1n : quotient - 1n; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a94a13c1..e5c662abe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -603,6 +603,9 @@ importers: packages/fixed-points: dependencies: + '@solana/errors': + specifier: workspace:* + version: link:../errors typescript: specifier: '>=5.0.0' version: 5.9.3