diff --git a/packages/fixed-points/package.json b/packages/fixed-points/package.json index 990b7066a..a9e7642b4 100644 --- a/packages/fixed-points/package.json +++ b/packages/fixed-points/package.json @@ -74,6 +74,7 @@ "maintained node versions" ], "dependencies": { + "@solana/codecs-core": "workspace:*", "@solana/errors": "workspace:*" }, "peerDependencies": { diff --git a/packages/fixed-points/src/__tests__/binary-codec-test.ts b/packages/fixed-points/src/__tests__/binary-codec-test.ts new file mode 100644 index 000000000..06f6b3fb5 --- /dev/null +++ b/packages/fixed-points/src/__tests__/binary-codec-test.ts @@ -0,0 +1,359 @@ +import '@solana/test-matchers/toBeFrozenObject'; + +import { + SOLANA_ERROR__CODECS__CANNOT_DECODE_EMPTY_BYTE_ARRAY, + SOLANA_ERROR__CODECS__INVALID_BYTE_LENGTH, + SOLANA_ERROR__FIXED_POINTS__FRACTIONAL_BITS_EXCEED_TOTAL_BITS, + SOLANA_ERROR__FIXED_POINTS__INVALID_FRACTIONAL_BITS, + SOLANA_ERROR__FIXED_POINTS__INVALID_TOTAL_BITS, + SOLANA_ERROR__FIXED_POINTS__SHAPE_MISMATCH, + SOLANA_ERROR__FIXED_POINTS__TOTAL_BITS_NOT_BYTE_ALIGNED, + SolanaError, +} from '@solana/errors'; + +import { + binaryFixedPoint, + getBinaryFixedPointCodec, + getBinaryFixedPointDecoder, + getBinaryFixedPointEncoder, + rawBinaryFixedPoint, +} from '../binary'; + +describe('getBinaryFixedPointEncoder', () => { + it('encodes an unsigned 8-bit value', () => { + const encoder = getBinaryFixedPointEncoder('unsigned', 8, 0); + expect(encoder.encode(rawBinaryFixedPoint('unsigned', 8, 0)(42n))).toEqual(new Uint8Array([0x2a])); + }); + + it("encodes a signed 8-bit negative value using two's-complement", () => { + const encoder = getBinaryFixedPointEncoder('signed', 8, 0); + expect(encoder.encode(rawBinaryFixedPoint('signed', 8, 0)(-1n))).toEqual(new Uint8Array([0xff])); + }); + + it('encodes a signed 16-bit value at 15 fractional bits in little-endian by default', () => { + const encoder = getBinaryFixedPointEncoder('signed', 16, 15); + expect(encoder.encode(binaryFixedPoint('signed', 16, 15)('0.5'))).toEqual(new Uint8Array([0x00, 0x40])); + }); + + it('encodes a signed 16-bit negative value at 15 fractional bits in little-endian', () => { + const encoder = getBinaryFixedPointEncoder('signed', 16, 15); + expect(encoder.encode(binaryFixedPoint('signed', 16, 15)('-0.5'))).toEqual(new Uint8Array([0x00, 0xc0])); + }); + + it('encodes in big-endian when configured', () => { + const encoder = getBinaryFixedPointEncoder('unsigned', 16, 0, { endian: 'be' }); + expect(encoder.encode(rawBinaryFixedPoint('unsigned', 16, 0)(0x1234n))).toEqual(new Uint8Array([0x12, 0x34])); + }); + + it('encodes an unsigned 24-bit value (byte-aligned width without a matching number codec)', () => { + const encoder = getBinaryFixedPointEncoder('unsigned', 24, 0); + expect(encoder.encode(rawBinaryFixedPoint('unsigned', 24, 0)(0xabcdefn))).toEqual( + new Uint8Array([0xef, 0xcd, 0xab]), + ); + }); + + it('encodes an unsigned 24-bit value in big-endian (exercises residual positioning)', () => { + const encoder = getBinaryFixedPointEncoder('unsigned', 24, 0, { endian: 'be' }); + expect(encoder.encode(rawBinaryFixedPoint('unsigned', 24, 0)(0xabcdefn))).toEqual( + new Uint8Array([0xab, 0xcd, 0xef]), + ); + }); + + it('encodes an unsigned 72-bit value in little-endian (exercises one full chunk + one residual byte)', () => { + const encoder = getBinaryFixedPointEncoder('unsigned', 72, 0); + expect(encoder.encode(rawBinaryFixedPoint('unsigned', 72, 0)(0x112233445566778899n))).toEqual( + new Uint8Array([0x99, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11]), + ); + }); + + it('encodes an unsigned 72-bit value in big-endian (exercises one full chunk + one residual byte)', () => { + const encoder = getBinaryFixedPointEncoder('unsigned', 72, 0, { endian: 'be' }); + expect(encoder.encode(rawBinaryFixedPoint('unsigned', 72, 0)(0x112233445566778899n))).toEqual( + new Uint8Array([0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99]), + ); + }); + + it('encodes an unsigned 40-bit value in little-endian (residual = 32-bit + 8-bit)', () => { + const encoder = getBinaryFixedPointEncoder('unsigned', 40, 0); + expect(encoder.encode(rawBinaryFixedPoint('unsigned', 40, 0)(0x1122334455n))).toEqual( + new Uint8Array([0x55, 0x44, 0x33, 0x22, 0x11]), + ); + }); + + it('encodes an unsigned 40-bit value in big-endian (residual = 32-bit + 8-bit)', () => { + const encoder = getBinaryFixedPointEncoder('unsigned', 40, 0, { endian: 'be' }); + expect(encoder.encode(rawBinaryFixedPoint('unsigned', 40, 0)(0x1122334455n))).toEqual( + new Uint8Array([0x11, 0x22, 0x33, 0x44, 0x55]), + ); + }); + + it('encodes an unsigned 48-bit value in little-endian (residual = 32-bit + 16-bit)', () => { + const encoder = getBinaryFixedPointEncoder('unsigned', 48, 0); + expect(encoder.encode(rawBinaryFixedPoint('unsigned', 48, 0)(0x112233445566n))).toEqual( + new Uint8Array([0x66, 0x55, 0x44, 0x33, 0x22, 0x11]), + ); + }); + + it('encodes an unsigned 48-bit value in big-endian (residual = 32-bit + 16-bit)', () => { + const encoder = getBinaryFixedPointEncoder('unsigned', 48, 0, { endian: 'be' }); + expect(encoder.encode(rawBinaryFixedPoint('unsigned', 48, 0)(0x112233445566n))).toEqual( + new Uint8Array([0x11, 0x22, 0x33, 0x44, 0x55, 0x66]), + ); + }); + + it('encodes an unsigned 56-bit value in little-endian (residual = 32-bit + 16-bit + 8-bit)', () => { + const encoder = getBinaryFixedPointEncoder('unsigned', 56, 0); + expect(encoder.encode(rawBinaryFixedPoint('unsigned', 56, 0)(0x11223344556677n))).toEqual( + new Uint8Array([0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11]), + ); + }); + + it('encodes an unsigned 56-bit value in big-endian (residual = 32-bit + 16-bit + 8-bit)', () => { + const encoder = getBinaryFixedPointEncoder('unsigned', 56, 0, { endian: 'be' }); + expect(encoder.encode(rawBinaryFixedPoint('unsigned', 56, 0)(0x11223344556677n))).toEqual( + new Uint8Array([0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77]), + ); + }); + + it('encodes an unsigned 128-bit value in little-endian', () => { + const encoder = getBinaryFixedPointEncoder('unsigned', 128, 0); + const bytes = encoder.encode(rawBinaryFixedPoint('unsigned', 128, 0)(0x0102030405060708090a0b0c0d0e0f10n)); + expect(bytes).toEqual( + new Uint8Array([ + 0x10, 0x0f, 0x0e, 0x0d, 0x0c, 0x0b, 0x0a, 0x09, 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01, + ]), + ); + }); + + it('reports the correct fixed size', () => { + expect(getBinaryFixedPointEncoder('signed', 16, 15).fixedSize).toBe(2); + expect(getBinaryFixedPointEncoder('unsigned', 128, 0).fixedSize).toBe(16); + }); + + it('throws TOTAL_BITS_NOT_BYTE_ALIGNED for a non-byte-aligned total bits', () => { + expect(() => getBinaryFixedPointEncoder('unsigned', 12, 4)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__TOTAL_BITS_NOT_BYTE_ALIGNED, { + kind: 'binaryFixedPoint', + totalBits: 12, + }), + ); + }); + + it('throws INVALID_TOTAL_BITS for a non-positive total bits', () => { + expect(() => getBinaryFixedPointEncoder('unsigned', 0, 0)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__INVALID_TOTAL_BITS, { + kind: 'binaryFixedPoint', + totalBits: 0, + }), + ); + }); + + it('throws INVALID_FRACTIONAL_BITS for a negative fractional bits', () => { + expect(() => getBinaryFixedPointEncoder('signed', 16, -1)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__INVALID_FRACTIONAL_BITS, { + fractionalBits: -1, + }), + ); + }); + + it('throws FRACTIONAL_BITS_EXCEED_TOTAL_BITS when fractional bits exceed total bits', () => { + expect(() => getBinaryFixedPointEncoder('signed', 8, 16)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__FRACTIONAL_BITS_EXCEED_TOTAL_BITS, { + fractionalBits: 16, + totalBits: 8, + }), + ); + }); + + it('throws SHAPE_MISMATCH when encoding a value whose shape does not match the codec', () => { + const encoder = getBinaryFixedPointEncoder('signed', 16, 15); + const mismatched = rawBinaryFixedPoint('signed', 16, 8)(1n); + expect(() => + // @ts-expect-error The value's shape does not match the codec's shape. + encoder.encode(mismatched), + ).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__SHAPE_MISMATCH, { + actualKind: 'binaryFixedPoint', + actualScale: 8, + actualScaleLabel: 'fractional bits', + actualSignedness: 'signed', + actualTotalBits: 16, + expectedKind: 'binaryFixedPoint', + expectedScale: 15, + expectedScaleLabel: 'fractional bits', + expectedSignedness: 'signed', + expectedTotalBits: 16, + operation: 'getBinaryFixedPointEncoder', + }), + ); + }); +}); + +describe('getBinaryFixedPointDecoder', () => { + it('decodes an unsigned 8-bit value', () => { + const decoder = getBinaryFixedPointDecoder('unsigned', 8, 0); + expect(decoder.decode(new Uint8Array([0x2a]))).toEqual({ + fractionalBits: 0, + kind: 'binaryFixedPoint', + raw: 42n, + signedness: 'unsigned', + totalBits: 8, + }); + }); + + it("decodes a signed 8-bit negative value via two's-complement", () => { + const decoder = getBinaryFixedPointDecoder('signed', 8, 0); + expect(decoder.decode(new Uint8Array([0xff])).raw).toBe(-1n); + }); + + it('decodes a signed 16-bit value at 15 fractional bits in little-endian', () => { + const decoder = getBinaryFixedPointDecoder('signed', 16, 15); + expect(decoder.decode(new Uint8Array([0x00, 0x40])).raw).toBe(16384n); + }); + + it('decodes a signed 16-bit negative value at 15 fractional bits in little-endian', () => { + const decoder = getBinaryFixedPointDecoder('signed', 16, 15); + expect(decoder.decode(new Uint8Array([0x00, 0xc0])).raw).toBe(-16384n); + }); + + it('decodes in big-endian when configured', () => { + const decoder = getBinaryFixedPointDecoder('unsigned', 16, 0, { endian: 'be' }); + expect(decoder.decode(new Uint8Array([0x12, 0x34])).raw).toBe(0x1234n); + }); + + it('decodes an unsigned 24-bit value', () => { + const decoder = getBinaryFixedPointDecoder('unsigned', 24, 0); + expect(decoder.decode(new Uint8Array([0xef, 0xcd, 0xab])).raw).toBe(0xabcdefn); + }); + + it('decodes an unsigned 72-bit value in little-endian (exercises one full chunk + one residual byte)', () => { + const decoder = getBinaryFixedPointDecoder('unsigned', 72, 0); + expect(decoder.decode(new Uint8Array([0x99, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11])).raw).toBe( + 0x112233445566778899n, + ); + }); + + it('decodes an unsigned 72-bit value in big-endian (exercises one full chunk + one residual byte)', () => { + const decoder = getBinaryFixedPointDecoder('unsigned', 72, 0, { endian: 'be' }); + expect(decoder.decode(new Uint8Array([0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99])).raw).toBe( + 0x112233445566778899n, + ); + }); + + it('decodes an unsigned 40-bit value in little-endian (residual = 32-bit + 8-bit)', () => { + const decoder = getBinaryFixedPointDecoder('unsigned', 40, 0); + expect(decoder.decode(new Uint8Array([0x55, 0x44, 0x33, 0x22, 0x11])).raw).toBe(0x1122334455n); + }); + + it('decodes an unsigned 40-bit value in big-endian (residual = 32-bit + 8-bit)', () => { + const decoder = getBinaryFixedPointDecoder('unsigned', 40, 0, { endian: 'be' }); + expect(decoder.decode(new Uint8Array([0x11, 0x22, 0x33, 0x44, 0x55])).raw).toBe(0x1122334455n); + }); + + it('decodes an unsigned 48-bit value in little-endian (residual = 32-bit + 16-bit)', () => { + const decoder = getBinaryFixedPointDecoder('unsigned', 48, 0); + expect(decoder.decode(new Uint8Array([0x66, 0x55, 0x44, 0x33, 0x22, 0x11])).raw).toBe(0x112233445566n); + }); + + it('decodes an unsigned 48-bit value in big-endian (residual = 32-bit + 16-bit)', () => { + const decoder = getBinaryFixedPointDecoder('unsigned', 48, 0, { endian: 'be' }); + expect(decoder.decode(new Uint8Array([0x11, 0x22, 0x33, 0x44, 0x55, 0x66])).raw).toBe(0x112233445566n); + }); + + it('decodes an unsigned 56-bit value in little-endian (residual = 32-bit + 16-bit + 8-bit)', () => { + const decoder = getBinaryFixedPointDecoder('unsigned', 56, 0); + expect(decoder.decode(new Uint8Array([0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11])).raw).toBe(0x11223344556677n); + }); + + it('decodes an unsigned 56-bit value in big-endian (residual = 32-bit + 16-bit + 8-bit)', () => { + const decoder = getBinaryFixedPointDecoder('unsigned', 56, 0, { endian: 'be' }); + expect(decoder.decode(new Uint8Array([0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77])).raw).toBe(0x11223344556677n); + }); + + it('returns a frozen value', () => { + const decoder = getBinaryFixedPointDecoder('unsigned', 8, 0); + expect(decoder.decode(new Uint8Array([0x2a]))).toBeFrozenObject(); + }); + + it('throws TOTAL_BITS_NOT_BYTE_ALIGNED for a non-byte-aligned total bits', () => { + expect(() => getBinaryFixedPointDecoder('unsigned', 12, 4)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__TOTAL_BITS_NOT_BYTE_ALIGNED, { + kind: 'binaryFixedPoint', + totalBits: 12, + }), + ); + }); + + it('throws CANNOT_DECODE_EMPTY_BYTE_ARRAY when decoding from an empty buffer', () => { + const decoder = getBinaryFixedPointDecoder('unsigned', 16, 0); + expect(() => decoder.decode(new Uint8Array([]))).toThrow( + new SolanaError(SOLANA_ERROR__CODECS__CANNOT_DECODE_EMPTY_BYTE_ARRAY, { + codecDescription: 'getBinaryFixedPointDecoder', + }), + ); + }); + + it('throws INVALID_BYTE_LENGTH when decoding from a too-short buffer', () => { + const decoder = getBinaryFixedPointDecoder('unsigned', 32, 0); + expect(() => decoder.decode(new Uint8Array([0x01, 0x02]))).toThrow( + new SolanaError(SOLANA_ERROR__CODECS__INVALID_BYTE_LENGTH, { + bytesLength: 2, + codecDescription: 'getBinaryFixedPointDecoder', + expected: 4, + }), + ); + }); +}); + +describe('getBinaryFixedPointCodec', () => { + describe.each([{ endian: 'le' as const }, { endian: 'be' as const }])('under $endian endianness', ({ endian }) => { + it.each([ + { fractionalBits: 0, raw: 42n, signedness: 'signed' as const, totalBits: 8 }, + { fractionalBits: 0, raw: -42n, signedness: 'signed' as const, totalBits: 8 }, + { fractionalBits: 15, raw: 16384n, signedness: 'signed' as const, totalBits: 16 }, + { fractionalBits: 15, raw: -16384n, signedness: 'signed' as const, totalBits: 16 }, + // 24 bits exercises the all-residual path (no full 8-byte chunks). + { fractionalBits: 0, raw: 0xabcdefn, signedness: 'unsigned' as const, totalBits: 24 }, + { fractionalBits: 0, raw: -0xabcden, signedness: 'signed' as const, totalBits: 24 }, + { fractionalBits: 0, raw: 0xffffffffn, signedness: 'unsigned' as const, totalBits: 32 }, + // 40/48/56 bits exercise the greedy residual (32+8, 32+16, 32+16+8). + { fractionalBits: 0, raw: 0x1122334455n, signedness: 'unsigned' as const, totalBits: 40 }, + { fractionalBits: 0, raw: 0x112233445566n, signedness: 'unsigned' as const, totalBits: 48 }, + { fractionalBits: 0, raw: 0x11223344556677n, signedness: 'unsigned' as const, totalBits: 56 }, + { fractionalBits: 0, raw: 0x0123456789abcdefn, signedness: 'signed' as const, totalBits: 64 }, + // 72 bits exercises one full chunk + one residual byte. + { fractionalBits: 0, raw: 0x112233445566778899n, signedness: 'unsigned' as const, totalBits: 72 }, + { + fractionalBits: 0, + raw: 0x00112233445566778899aabbccddeeffn, + signedness: 'unsigned' as const, + totalBits: 128, + }, + // 136 bits exercises two full chunks + one residual byte. + { + fractionalBits: 0, + raw: 0x0102030405060708091011121314151617n, + signedness: 'unsigned' as const, + totalBits: 136, + }, + ])( + 'round-trips $signedness $totalBits-bit values with $fractionalBits fractional bits (raw $raw)', + ({ signedness, totalBits, fractionalBits, raw }) => { + const codec = getBinaryFixedPointCodec(signedness, totalBits, fractionalBits, { endian }); + const value = rawBinaryFixedPoint(signedness, totalBits, fractionalBits)(raw); + const decoded = codec.decode(codec.encode(value)); + expect(decoded.raw).toBe(raw); + expect(decoded.signedness).toBe(signedness); + expect(decoded.totalBits).toBe(totalBits); + expect(decoded.fractionalBits).toBe(fractionalBits); + }, + ); + }); + + it('produces the same bytes as a straightforward u16 serialization for unsigned 16-bit values', () => { + // This interop check ensures the codec is wire-compatible with standard u16 little-endian layouts. + const codec = getBinaryFixedPointCodec('unsigned', 16, 0); + const encoded = codec.encode(rawBinaryFixedPoint('unsigned', 16, 0)(0xbeefn)); + expect(encoded).toEqual(new Uint8Array([0xef, 0xbe])); + }); +}); diff --git a/packages/fixed-points/src/__tests__/decimal-codec-test.ts b/packages/fixed-points/src/__tests__/decimal-codec-test.ts new file mode 100644 index 000000000..6add61035 --- /dev/null +++ b/packages/fixed-points/src/__tests__/decimal-codec-test.ts @@ -0,0 +1,191 @@ +import '@solana/test-matchers/toBeFrozenObject'; + +import { + SOLANA_ERROR__CODECS__CANNOT_DECODE_EMPTY_BYTE_ARRAY, + SOLANA_ERROR__CODECS__INVALID_BYTE_LENGTH, + SOLANA_ERROR__FIXED_POINTS__INVALID_DECIMALS, + SOLANA_ERROR__FIXED_POINTS__INVALID_TOTAL_BITS, + SOLANA_ERROR__FIXED_POINTS__SHAPE_MISMATCH, + SOLANA_ERROR__FIXED_POINTS__TOTAL_BITS_NOT_BYTE_ALIGNED, + SolanaError, +} from '@solana/errors'; + +import { + decimalFixedPoint, + getDecimalFixedPointCodec, + getDecimalFixedPointDecoder, + getDecimalFixedPointEncoder, + rawDecimalFixedPoint, +} from '../decimal'; + +describe('getDecimalFixedPointEncoder', () => { + it('encodes an unsigned 8-bit value', () => { + const encoder = getDecimalFixedPointEncoder('unsigned', 8, 0); + expect(encoder.encode(rawDecimalFixedPoint('unsigned', 8, 0)(42n))).toEqual(new Uint8Array([0x2a])); + }); + + it("encodes a signed 8-bit negative value using two's-complement", () => { + const encoder = getDecimalFixedPointEncoder('signed', 8, 0); + expect(encoder.encode(rawDecimalFixedPoint('signed', 8, 0)(-1n))).toEqual(new Uint8Array([0xff])); + }); + + it('encodes an unsigned 64-bit value at 2 decimals in little-endian by default', () => { + const encoder = getDecimalFixedPointEncoder('unsigned', 64, 2); + // 42.50 has raw 4250n → 0x0000000000000019a in LE → [0x9a, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + expect(encoder.encode(decimalFixedPoint('unsigned', 64, 2)('42.50'))).toEqual( + new Uint8Array([0x9a, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), + ); + }); + + it('encodes in big-endian when configured', () => { + const encoder = getDecimalFixedPointEncoder('unsigned', 16, 0, { endian: 'be' }); + expect(encoder.encode(rawDecimalFixedPoint('unsigned', 16, 0)(0x1234n))).toEqual(new Uint8Array([0x12, 0x34])); + }); + + it('encodes an unsigned 24-bit value (byte-aligned width without a matching number codec)', () => { + const encoder = getDecimalFixedPointEncoder('unsigned', 24, 0); + expect(encoder.encode(rawDecimalFixedPoint('unsigned', 24, 0)(0xabcdefn))).toEqual( + new Uint8Array([0xef, 0xcd, 0xab]), + ); + }); + + it('reports the correct fixed size', () => { + expect(getDecimalFixedPointEncoder('unsigned', 64, 2).fixedSize).toBe(8); + expect(getDecimalFixedPointEncoder('unsigned', 128, 18).fixedSize).toBe(16); + }); + + it('throws TOTAL_BITS_NOT_BYTE_ALIGNED for a non-byte-aligned total bits', () => { + expect(() => getDecimalFixedPointEncoder('unsigned', 12, 2)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__TOTAL_BITS_NOT_BYTE_ALIGNED, { + kind: 'decimalFixedPoint', + totalBits: 12, + }), + ); + }); + + it('throws INVALID_TOTAL_BITS for a non-positive total bits', () => { + expect(() => getDecimalFixedPointEncoder('unsigned', 0, 0)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__INVALID_TOTAL_BITS, { + kind: 'decimalFixedPoint', + totalBits: 0, + }), + ); + }); + + it('throws INVALID_DECIMALS for a negative decimals', () => { + expect(() => getDecimalFixedPointEncoder('unsigned', 64, -1)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__INVALID_DECIMALS, { + decimals: -1, + }), + ); + }); + + it('throws SHAPE_MISMATCH when encoding a value whose shape does not match the codec', () => { + const encoder = getDecimalFixedPointEncoder('unsigned', 64, 6); + const mismatched = rawDecimalFixedPoint('unsigned', 64, 2)(1n); + expect(() => + // @ts-expect-error The value's shape does not match the codec's shape. + encoder.encode(mismatched), + ).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__SHAPE_MISMATCH, { + actualKind: 'decimalFixedPoint', + actualScale: 2, + actualScaleLabel: 'decimals', + actualSignedness: 'unsigned', + actualTotalBits: 64, + expectedKind: 'decimalFixedPoint', + expectedScale: 6, + expectedScaleLabel: 'decimals', + expectedSignedness: 'unsigned', + expectedTotalBits: 64, + operation: 'getDecimalFixedPointEncoder', + }), + ); + }); +}); + +describe('getDecimalFixedPointDecoder', () => { + it('decodes an unsigned 8-bit value', () => { + const decoder = getDecimalFixedPointDecoder('unsigned', 8, 0); + expect(decoder.decode(new Uint8Array([0x2a]))).toEqual({ + decimals: 0, + kind: 'decimalFixedPoint', + raw: 42n, + signedness: 'unsigned', + totalBits: 8, + }); + }); + + it("decodes a signed 8-bit negative value via two's-complement", () => { + const decoder = getDecimalFixedPointDecoder('signed', 8, 0); + expect(decoder.decode(new Uint8Array([0xff])).raw).toBe(-1n); + }); + + it('decodes an unsigned 64-bit value at 2 decimals in little-endian', () => { + const decoder = getDecimalFixedPointDecoder('unsigned', 64, 2); + expect(decoder.decode(new Uint8Array([0x9a, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])).raw).toBe(4250n); + }); + + it('decodes in big-endian when configured', () => { + const decoder = getDecimalFixedPointDecoder('unsigned', 16, 0, { endian: 'be' }); + expect(decoder.decode(new Uint8Array([0x12, 0x34])).raw).toBe(0x1234n); + }); + + it('returns a frozen value', () => { + const decoder = getDecimalFixedPointDecoder('unsigned', 8, 0); + expect(decoder.decode(new Uint8Array([0x2a]))).toBeFrozenObject(); + }); + + it('throws TOTAL_BITS_NOT_BYTE_ALIGNED for a non-byte-aligned total bits', () => { + expect(() => getDecimalFixedPointDecoder('unsigned', 12, 2)).toThrow( + new SolanaError(SOLANA_ERROR__FIXED_POINTS__TOTAL_BITS_NOT_BYTE_ALIGNED, { + kind: 'decimalFixedPoint', + totalBits: 12, + }), + ); + }); + + it('throws CANNOT_DECODE_EMPTY_BYTE_ARRAY when decoding from an empty buffer', () => { + const decoder = getDecimalFixedPointDecoder('unsigned', 16, 2); + expect(() => decoder.decode(new Uint8Array([]))).toThrow( + new SolanaError(SOLANA_ERROR__CODECS__CANNOT_DECODE_EMPTY_BYTE_ARRAY, { + codecDescription: 'getDecimalFixedPointDecoder', + }), + ); + }); + + it('throws INVALID_BYTE_LENGTH when decoding from a too-short buffer', () => { + const decoder = getDecimalFixedPointDecoder('unsigned', 64, 6); + expect(() => decoder.decode(new Uint8Array([0x01, 0x02]))).toThrow( + new SolanaError(SOLANA_ERROR__CODECS__INVALID_BYTE_LENGTH, { + bytesLength: 2, + codecDescription: 'getDecimalFixedPointDecoder', + expected: 8, + }), + ); + }); +}); + +describe('getDecimalFixedPointCodec', () => { + describe.each([{ endian: 'le' as const }, { endian: 'be' as const }])('under $endian endianness', ({ endian }) => { + it.each([ + { decimals: 0, raw: 42n, signedness: 'signed' as const, totalBits: 8 }, + { decimals: 0, raw: -42n, signedness: 'signed' as const, totalBits: 8 }, + { decimals: 2, raw: 4250n, signedness: 'unsigned' as const, totalBits: 64 }, + { decimals: 6, raw: 100_123_456n, signedness: 'unsigned' as const, totalBits: 64 }, + { decimals: 18, raw: 100_123_456_789_012_345_678n, signedness: 'unsigned' as const, totalBits: 128 }, + { decimals: 2, raw: -1234567n, signedness: 'signed' as const, totalBits: 64 }, + ])( + 'round-trips $signedness $totalBits-bit values with $decimals decimals (raw $raw)', + ({ signedness, totalBits, decimals, raw }) => { + const codec = getDecimalFixedPointCodec(signedness, totalBits, decimals, { endian }); + const value = rawDecimalFixedPoint(signedness, totalBits, decimals)(raw); + const decoded = codec.decode(codec.encode(value)); + expect(decoded.raw).toBe(raw); + expect(decoded.signedness).toBe(signedness); + expect(decoded.totalBits).toBe(totalBits); + expect(decoded.decimals).toBe(decimals); + }, + ); + }); +}); diff --git a/packages/fixed-points/src/__typetests__/binary-codec-typetest.ts b/packages/fixed-points/src/__typetests__/binary-codec-typetest.ts new file mode 100644 index 000000000..2b686d335 --- /dev/null +++ b/packages/fixed-points/src/__typetests__/binary-codec-typetest.ts @@ -0,0 +1,49 @@ +import type { FixedSizeCodec, FixedSizeDecoder, FixedSizeEncoder } from '@solana/codecs-core'; + +import { + type BinaryFixedPoint, + getBinaryFixedPointCodec, + getBinaryFixedPointDecoder, + getBinaryFixedPointEncoder, +} from '../binary'; + +// [DESCRIBE] getBinaryFixedPointEncoder. +{ + // It preserves the shape generics in the encoded payload type. + { + const encoder = getBinaryFixedPointEncoder('signed', 16, 15); + encoder satisfies FixedSizeEncoder, 2>; + } + + // It preserves the byte-size literal for all supported byte-aligned widths. + { + getBinaryFixedPointEncoder('unsigned', 8, 0) satisfies FixedSizeEncoder, 1>; + getBinaryFixedPointEncoder('signed', 32, 16) satisfies FixedSizeEncoder, 4>; + getBinaryFixedPointEncoder('unsigned', 128, 64) satisfies FixedSizeEncoder< + BinaryFixedPoint<'unsigned', 128, 64>, + 16 + >; + } +} + +// [DESCRIBE] getBinaryFixedPointDecoder. +{ + // It preserves the shape generics in the decoded payload type. + { + const decoder = getBinaryFixedPointDecoder('signed', 16, 15); + decoder satisfies FixedSizeDecoder, 2>; + } +} + +// [DESCRIBE] getBinaryFixedPointCodec. +{ + // It preserves the shape generics in both the encoded and decoded payload types. + { + const codec = getBinaryFixedPointCodec('unsigned', 128, 64); + codec satisfies FixedSizeCodec< + BinaryFixedPoint<'unsigned', 128, 64>, + BinaryFixedPoint<'unsigned', 128, 64>, + 16 + >; + } +} diff --git a/packages/fixed-points/src/__typetests__/decimal-codec-typetest.ts b/packages/fixed-points/src/__typetests__/decimal-codec-typetest.ts new file mode 100644 index 000000000..1fdfb3bb1 --- /dev/null +++ b/packages/fixed-points/src/__typetests__/decimal-codec-typetest.ts @@ -0,0 +1,52 @@ +import type { FixedSizeCodec, FixedSizeDecoder, FixedSizeEncoder } from '@solana/codecs-core'; + +import { + type DecimalFixedPoint, + getDecimalFixedPointCodec, + getDecimalFixedPointDecoder, + getDecimalFixedPointEncoder, +} from '../decimal'; + +// [DESCRIBE] getDecimalFixedPointEncoder. +{ + // It preserves the shape generics in the encoded payload type. + { + const encoder = getDecimalFixedPointEncoder('unsigned', 64, 6); + encoder satisfies FixedSizeEncoder, 8>; + } + + // It preserves the byte-size literal for all supported byte-aligned widths. + { + getDecimalFixedPointEncoder('unsigned', 8, 0) satisfies FixedSizeEncoder< + DecimalFixedPoint<'unsigned', 8, 0>, + 1 + >; + getDecimalFixedPointEncoder('signed', 32, 6) satisfies FixedSizeEncoder, 4>; + getDecimalFixedPointEncoder('unsigned', 128, 18) satisfies FixedSizeEncoder< + DecimalFixedPoint<'unsigned', 128, 18>, + 16 + >; + } +} + +// [DESCRIBE] getDecimalFixedPointDecoder. +{ + // It preserves the shape generics in the decoded payload type. + { + const decoder = getDecimalFixedPointDecoder('unsigned', 64, 6); + decoder satisfies FixedSizeDecoder, 8>; + } +} + +// [DESCRIBE] getDecimalFixedPointCodec. +{ + // It preserves the shape generics in both the encoded and decoded payload types. + { + const codec = getDecimalFixedPointCodec('unsigned', 128, 18); + codec satisfies FixedSizeCodec< + DecimalFixedPoint<'unsigned', 128, 18>, + DecimalFixedPoint<'unsigned', 128, 18>, + 16 + >; + } +} diff --git a/packages/fixed-points/src/assertions.ts b/packages/fixed-points/src/assertions.ts index 9e27e305a..a885b7d64 100644 --- a/packages/fixed-points/src/assertions.ts +++ b/packages/fixed-points/src/assertions.ts @@ -7,6 +7,7 @@ import { SOLANA_ERROR__FIXED_POINTS__INVALID_TOTAL_BITS, SOLANA_ERROR__FIXED_POINTS__MALFORMED_RAW_VALUE, SOLANA_ERROR__FIXED_POINTS__SHAPE_MISMATCH, + SOLANA_ERROR__FIXED_POINTS__TOTAL_BITS_NOT_BYTE_ALIGNED, SOLANA_ERROR__FIXED_POINTS__VALUE_OUT_OF_RANGE, SolanaError, } from '@solana/errors'; @@ -94,6 +95,25 @@ export function assertFractionalBitsFitInTotalBits(fractionalBits: number, total } } +/** + * Asserts that `totalBits` is a multiple of 8. Throws + * `SOLANA_ERROR__FIXED_POINTS__TOTAL_BITS_NOT_BYTE_ALIGNED` otherwise. + * + * This is a codec-only constraint: fixed-point values themselves accept + * any positive `totalBits`, but the byte-oriented codec can only serialize + * sizes that are exact multiples of 8 bits. + * + * @internal + */ +export function assertTotalBitsIsByteAligned(kind: FixedPointKind, totalBits: number): void { + if (totalBits % 8 !== 0) { + throw new SolanaError(SOLANA_ERROR__FIXED_POINTS__TOTAL_BITS_NOT_BYTE_ALIGNED, { + kind, + 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` diff --git a/packages/fixed-points/src/binary/codecs.ts b/packages/fixed-points/src/binary/codecs.ts new file mode 100644 index 000000000..b823fa32f --- /dev/null +++ b/packages/fixed-points/src/binary/codecs.ts @@ -0,0 +1,162 @@ +import { + assertByteArrayHasEnoughBytesForCodec, + assertByteArrayIsNotEmptyForCodec, + combineCodec, + createDecoder, + createEncoder, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, +} from '@solana/codecs-core'; + +import { + assertFractionalBitsFitInTotalBits, + assertShapeMatches, + assertTotalBitsIsByteAligned, + assertValidFractionalBits, + assertValidTotalBits, + describeShape, +} from '../assertions'; +import { type BytesForTotalBits, type FixedPointCodecConfig, readRawBigInt, writeRawBigInt } from '../codecs'; +import type { Signedness } from '../signedness'; +import type { BinaryFixedPoint } from './core'; + +/** + * Returns an encoder for {@link BinaryFixedPoint} values of a specific + * shape. The encoder serializes `value.raw` as a fixed-size integer using + * two's-complement for signed values and little-endian byte order by + * default. + * + * Throws `SOLANA_ERROR__FIXED_POINTS__TOTAL_BITS_NOT_BYTE_ALIGNED` when + * `totalBits` is not a multiple of 8. Encoding a value whose shape does + * not match the codec's shape throws + * `SOLANA_ERROR__FIXED_POINTS__SHAPE_MISMATCH`. + * + * @example + * ```ts + * const encoder = getBinaryFixedPointEncoder('signed', 16, 15); + * encoder.encode(binaryFixedPoint('signed', 16, 15)('0.5')); // 0x0040 + * ``` + * + * @see {@link getBinaryFixedPointDecoder} + * @see {@link getBinaryFixedPointCodec} + */ +export function getBinaryFixedPointEncoder< + TSignedness extends Signedness, + TTotalBits extends number, + TFractionalBits extends number, +>( + signedness: TSignedness, + totalBits: TTotalBits, + fractionalBits: TFractionalBits, + config: FixedPointCodecConfig = {}, +): FixedSizeEncoder, BytesForTotalBits> { + assertValidTotalBits('binaryFixedPoint', totalBits); + assertValidFractionalBits(fractionalBits); + assertFractionalBitsFitInTotalBits(fractionalBits, totalBits); + assertTotalBitsIsByteAligned('binaryFixedPoint', totalBits); + const byteSize = (totalBits / 8) as BytesForTotalBits; + const littleEndian = config.endian !== 'be'; + return createEncoder({ + fixedSize: byteSize, + write(value, bytes, offset) { + assertShapeMatches('getBinaryFixedPointEncoder', describeShape(value), { + kind: 'binaryFixedPoint', + scale: fractionalBits, + scaleLabel: 'fractional bits', + signedness, + totalBits, + }); + writeRawBigInt(bytes, offset, value.raw, byteSize, signedness, littleEndian); + return offset + byteSize; + }, + }); +} + +/** + * Returns a decoder for {@link BinaryFixedPoint} values of a specific + * shape. The decoder reads a fixed-size integer using two's-complement for + * signed values and little-endian byte order by default, and reconstructs + * a frozen {@link BinaryFixedPoint} from the bytes. + * + * Throws `SOLANA_ERROR__FIXED_POINTS__TOTAL_BITS_NOT_BYTE_ALIGNED` when + * `totalBits` is not a multiple of 8. + * + * @example + * ```ts + * const decoder = getBinaryFixedPointDecoder('signed', 16, 15); + * decoder.decode(new Uint8Array([0x00, 0x40])); // represents 0.5 + * ``` + * + * @see {@link getBinaryFixedPointEncoder} + * @see {@link getBinaryFixedPointCodec} + */ +export function getBinaryFixedPointDecoder< + TSignedness extends Signedness, + TTotalBits extends number, + TFractionalBits extends number, +>( + signedness: TSignedness, + totalBits: TTotalBits, + fractionalBits: TFractionalBits, + config: FixedPointCodecConfig = {}, +): FixedSizeDecoder, BytesForTotalBits> { + assertValidTotalBits('binaryFixedPoint', totalBits); + assertValidFractionalBits(fractionalBits); + assertFractionalBitsFitInTotalBits(fractionalBits, totalBits); + assertTotalBitsIsByteAligned('binaryFixedPoint', totalBits); + const byteSize = (totalBits / 8) as BytesForTotalBits; + const littleEndian = config.endian !== 'be'; + const codecDescription = 'getBinaryFixedPointDecoder'; + return createDecoder({ + fixedSize: byteSize, + read(bytes, offset) { + assertByteArrayIsNotEmptyForCodec(codecDescription, bytes, offset); + assertByteArrayHasEnoughBytesForCodec(codecDescription, byteSize, bytes, offset); + const raw = readRawBigInt(bytes, offset, byteSize, signedness, littleEndian); + const value = Object.freeze({ + fractionalBits, + kind: 'binaryFixedPoint', + raw, + signedness, + totalBits, + }) as BinaryFixedPoint; + return [value, offset + byteSize]; + }, + }); +} + +/** + * Returns a codec for {@link BinaryFixedPoint} values of a specific shape, + * combining {@link getBinaryFixedPointEncoder} and + * {@link getBinaryFixedPointDecoder}. + * + * @example + * ```ts + * const codec = getBinaryFixedPointCodec('signed', 16, 15); + * const bytes = codec.encode(binaryFixedPoint('signed', 16, 15)('0.5')); + * const value = codec.decode(bytes); // represents 0.5 + * ``` + * + * @see {@link getBinaryFixedPointEncoder} + * @see {@link getBinaryFixedPointDecoder} + */ +export function getBinaryFixedPointCodec< + TSignedness extends Signedness, + TTotalBits extends number, + TFractionalBits extends number, +>( + signedness: TSignedness, + totalBits: TTotalBits, + fractionalBits: TFractionalBits, + config: FixedPointCodecConfig = {}, +): FixedSizeCodec< + BinaryFixedPoint, + BinaryFixedPoint, + BytesForTotalBits +> { + return combineCodec( + getBinaryFixedPointEncoder(signedness, totalBits, fractionalBits, config), + getBinaryFixedPointDecoder(signedness, totalBits, fractionalBits, config), + ); +} diff --git a/packages/fixed-points/src/binary/index.ts b/packages/fixed-points/src/binary/index.ts index 15ea0b33d..4579a1c3e 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 './codecs'; export * from './comparisons'; export * from './conversions'; export * from './core'; diff --git a/packages/fixed-points/src/codecs.ts b/packages/fixed-points/src/codecs.ts new file mode 100644 index 000000000..03feff291 --- /dev/null +++ b/packages/fixed-points/src/codecs.ts @@ -0,0 +1,209 @@ +import { type ReadonlyUint8Array, toArrayBuffer } from '@solana/codecs-core'; + +import type { Signedness } from './signedness'; + +/** + * Configuration options for fixed-point codecs. + */ +export type FixedPointCodecConfig = { + /** + * Whether values are serialized in little- or big-endian byte order. + * + * @defaultValue `'le'` + */ + endian?: 'be' | 'le'; +}; + +/** + * Maps a byte-aligned `totalBits` literal to its byte count. Falls back to + * `number` when `totalBits` is not a known multiple of 8 between 8 and 256 + * inclusive — in which case the runtime still works, but the literal size + * generic is erased. + * + * @internal + */ +/* eslint-disable typescript-sort-keys/interface */ +type TotalBitsToBytesTable = { + 8: 1; + 16: 2; + 24: 3; + 32: 4; + 40: 5; + 48: 6; + 56: 7; + 64: 8; + 72: 9; + 80: 10; + 88: 11; + 96: 12; + 104: 13; + 112: 14; + 120: 15; + 128: 16; + 136: 17; + 144: 18; + 152: 19; + 160: 20; + 168: 21; + 176: 22; + 184: 23; + 192: 24; + 200: 25; + 208: 26; + 216: 27; + 224: 28; + 232: 29; + 240: 30; + 248: 31; + 256: 32; +}; +/* eslint-enable typescript-sort-keys/interface */ + +/** + * Byte count implied by a fixed-point codec's `totalBits`. Preserves the + * byte-size literal in the codec type for common widths (multiples of 8 + * from 8 to 256); widens to `number` for other widths. + * + * @internal + */ +export type BytesForTotalBits = TTotalBits extends keyof TotalBitsToBytesTable + ? TotalBitsToBytesTable[TTotalBits] + : number; + +const MASK_64 = 0xffffffffffffffffn; +const MASK_32 = 0xffffffffn; +const MASK_16 = 0xffffn; +const MASK_8 = 0xffn; + +/** + * Writes a raw bigint into `bytes` starting at `offset`, using `byteSize` + * bytes with the given `signedness` and endianness. + * + * Signed negative values are serialized using two's-complement semantics: + * `raw + 2 ** (byteSize * 8)` is written as if unsigned. The caller is + * expected to have validated that `raw` fits the claimed range. + * + * The implementation processes 64-bit chunks via `DataView.setBigUint64` + * and greedily consumes the remaining 0–7 bytes with at most one + * `setUint32`, one `setUint16`, and one direct byte write. For common + * widths this matches `@solana/codecs-numbers` performance exactly. + * + * @internal + */ +export function writeRawBigInt( + bytes: Uint8Array, + offset: number, + raw: bigint, + byteSize: number, + signedness: Signedness, + littleEndian: boolean, +): void { + // Normalize to an unsigned bit pattern so downstream code can treat + // signed negatives as their two's-complement counterparts. + const unsigned = signedness === 'signed' && raw < 0n ? raw + (1n << BigInt(byteSize * 8)) : raw; + + const fullChunks = byteSize >> 3; + const residual = byteSize & 7; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + + // Full 64-bit chunks. LE lays chunks low-to-high; BE lays higher-order + // chunks at lower memory addresses. + for (let c = 0; c < fullChunks; c++) { + const chunk = (unsigned >> BigInt(c * 64)) & MASK_64; + const position = littleEndian ? c * 8 : byteSize - (c + 1) * 8; + view.setBigUint64(offset + position, chunk, littleEndian); + } + + if (residual > 0) { + const residualChunk = unsigned >> BigInt(fullChunks * 64); + // LE: residual bytes follow the chunks. BE: residual occupies the + // highest-order bytes, which sit at the start of the memory region. + const residualBase = littleEndian ? fullChunks * 8 : 0; + let consumed = 0; + + if (residual - consumed >= 4) { + const chunk = Number((residualChunk >> BigInt(consumed * 8)) & MASK_32); + const position = littleEndian ? residualBase + consumed : residualBase + residual - consumed - 4; + view.setUint32(offset + position, chunk, littleEndian); + consumed += 4; + } + if (residual - consumed >= 2) { + const chunk = Number((residualChunk >> BigInt(consumed * 8)) & MASK_16); + const position = littleEndian ? residualBase + consumed : residualBase + residual - consumed - 2; + view.setUint16(offset + position, chunk, littleEndian); + consumed += 2; + } + if (residual - consumed >= 1) { + const chunk = Number((residualChunk >> BigInt(consumed * 8)) & MASK_8); + const position = littleEndian ? residualBase + consumed : residualBase + residual - consumed - 1; + bytes[offset + position] = chunk; + } + } +} + +/** + * Reads a raw bigint from `bytes` starting at `offset`, using `byteSize` + * bytes with the given `signedness` and endianness. + * + * Signed values use two's-complement semantics: if the top bit of the + * decoded unsigned value is set, `2 ** (byteSize * 8)` is subtracted to + * produce the negative result. + * + * The implementation processes 64-bit chunks via `DataView.getBigUint64` + * and greedily consumes the remaining 0–7 bytes with at most one + * `getUint32`, one `getUint16`, and one direct byte read. + * + * @internal + */ +export function readRawBigInt( + bytes: ReadonlyUint8Array | Uint8Array, + offset: number, + byteSize: number, + signedness: Signedness, + littleEndian: boolean, +): bigint { + const fullChunks = byteSize >> 3; + const residual = byteSize & 7; + // `toArrayBuffer` defensively copies when the backing is a + // SharedArrayBuffer and is safe to call where SharedArrayBuffer is + // undefined (React Native, non-isolated browser contexts). + const view = new DataView(toArrayBuffer(bytes, offset, byteSize)); + let unsigned = 0n; + + for (let c = 0; c < fullChunks; c++) { + const position = littleEndian ? c * 8 : byteSize - (c + 1) * 8; + const chunk = view.getBigUint64(position, littleEndian); + unsigned |= chunk << BigInt(c * 64); + } + + if (residual > 0) { + const residualBase = littleEndian ? fullChunks * 8 : 0; + let residualChunk = 0n; + let consumed = 0; + + if (residual - consumed >= 4) { + const position = littleEndian ? residualBase + consumed : residualBase + residual - consumed - 4; + residualChunk |= BigInt(view.getUint32(position, littleEndian)) << BigInt(consumed * 8); + consumed += 4; + } + if (residual - consumed >= 2) { + const position = littleEndian ? residualBase + consumed : residualBase + residual - consumed - 2; + residualChunk |= BigInt(view.getUint16(position, littleEndian)) << BigInt(consumed * 8); + consumed += 2; + } + if (residual - consumed >= 1) { + const position = littleEndian ? residualBase + consumed : residualBase + residual - consumed - 1; + residualChunk |= BigInt(bytes[offset + position]) << BigInt(consumed * 8); + } + + unsigned |= residualChunk << BigInt(fullChunks * 64); + } + + if (signedness === 'signed') { + const signBit = 1n << BigInt(byteSize * 8 - 1); + if ((unsigned & signBit) !== 0n) { + return unsigned - (1n << BigInt(byteSize * 8)); + } + } + return unsigned; +} diff --git a/packages/fixed-points/src/decimal/codecs.ts b/packages/fixed-points/src/decimal/codecs.ts new file mode 100644 index 000000000..41b85b129 --- /dev/null +++ b/packages/fixed-points/src/decimal/codecs.ts @@ -0,0 +1,159 @@ +import { + assertByteArrayHasEnoughBytesForCodec, + assertByteArrayIsNotEmptyForCodec, + combineCodec, + createDecoder, + createEncoder, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, +} from '@solana/codecs-core'; + +import { + assertShapeMatches, + assertTotalBitsIsByteAligned, + assertValidDecimals, + assertValidTotalBits, + describeShape, +} from '../assertions'; +import { type BytesForTotalBits, type FixedPointCodecConfig, readRawBigInt, writeRawBigInt } from '../codecs'; +import type { Signedness } from '../signedness'; +import type { DecimalFixedPoint } from './core'; + +/** + * Returns an encoder for {@link DecimalFixedPoint} values of a specific + * shape. The encoder serializes `value.raw` as a fixed-size integer using + * two's-complement for signed values and little-endian byte order by + * default. + * + * Throws `SOLANA_ERROR__FIXED_POINTS__TOTAL_BITS_NOT_BYTE_ALIGNED` when + * `totalBits` is not a multiple of 8. Encoding a value whose shape does + * not match the codec's shape throws + * `SOLANA_ERROR__FIXED_POINTS__SHAPE_MISMATCH`. + * + * @example + * ```ts + * const encoder = getDecimalFixedPointEncoder('unsigned', 64, 6); + * encoder.encode(decimalFixedPoint('unsigned', 64, 6)('42.5')); + * ``` + * + * @see {@link getDecimalFixedPointDecoder} + * @see {@link getDecimalFixedPointCodec} + */ +export function getDecimalFixedPointEncoder< + TSignedness extends Signedness, + TTotalBits extends number, + TDecimals extends number, +>( + signedness: TSignedness, + totalBits: TTotalBits, + decimals: TDecimals, + config: FixedPointCodecConfig = {}, +): FixedSizeEncoder, BytesForTotalBits> { + assertValidTotalBits('decimalFixedPoint', totalBits); + assertValidDecimals(decimals); + assertTotalBitsIsByteAligned('decimalFixedPoint', totalBits); + const byteSize = (totalBits / 8) as BytesForTotalBits; + const littleEndian = config.endian !== 'be'; + return createEncoder({ + fixedSize: byteSize, + write(value, bytes, offset) { + assertShapeMatches('getDecimalFixedPointEncoder', describeShape(value), { + kind: 'decimalFixedPoint', + scale: decimals, + scaleLabel: 'decimals', + signedness, + totalBits, + }); + writeRawBigInt(bytes, offset, value.raw, byteSize, signedness, littleEndian); + return offset + byteSize; + }, + }); +} + +/** + * Returns a decoder for {@link DecimalFixedPoint} values of a specific + * shape. The decoder reads a fixed-size integer using two's-complement for + * signed values and little-endian byte order by default, and reconstructs + * a frozen {@link DecimalFixedPoint} from the bytes. + * + * Throws `SOLANA_ERROR__FIXED_POINTS__TOTAL_BITS_NOT_BYTE_ALIGNED` when + * `totalBits` is not a multiple of 8. + * + * @example + * ```ts + * const decoder = getDecimalFixedPointDecoder('unsigned', 64, 6); + * decoder.decode(bytes); // represents 42.5 for appropriately encoded bytes + * ``` + * + * @see {@link getDecimalFixedPointEncoder} + * @see {@link getDecimalFixedPointCodec} + */ +export function getDecimalFixedPointDecoder< + TSignedness extends Signedness, + TTotalBits extends number, + TDecimals extends number, +>( + signedness: TSignedness, + totalBits: TTotalBits, + decimals: TDecimals, + config: FixedPointCodecConfig = {}, +): FixedSizeDecoder, BytesForTotalBits> { + assertValidTotalBits('decimalFixedPoint', totalBits); + assertValidDecimals(decimals); + assertTotalBitsIsByteAligned('decimalFixedPoint', totalBits); + const byteSize = (totalBits / 8) as BytesForTotalBits; + const littleEndian = config.endian !== 'be'; + const codecDescription = 'getDecimalFixedPointDecoder'; + return createDecoder({ + fixedSize: byteSize, + read(bytes, offset) { + assertByteArrayIsNotEmptyForCodec(codecDescription, bytes, offset); + assertByteArrayHasEnoughBytesForCodec(codecDescription, byteSize, bytes, offset); + const raw = readRawBigInt(bytes, offset, byteSize, signedness, littleEndian); + const value = Object.freeze({ + decimals, + kind: 'decimalFixedPoint', + raw, + signedness, + totalBits, + }) as DecimalFixedPoint; + return [value, offset + byteSize]; + }, + }); +} + +/** + * Returns a codec for {@link DecimalFixedPoint} values of a specific + * shape, combining {@link getDecimalFixedPointEncoder} and + * {@link getDecimalFixedPointDecoder}. + * + * @example + * ```ts + * const codec = getDecimalFixedPointCodec('unsigned', 64, 6); + * const bytes = codec.encode(decimalFixedPoint('unsigned', 64, 6)('42.5')); + * const value = codec.decode(bytes); // represents 42.5 + * ``` + * + * @see {@link getDecimalFixedPointEncoder} + * @see {@link getDecimalFixedPointDecoder} + */ +export function getDecimalFixedPointCodec< + TSignedness extends Signedness, + TTotalBits extends number, + TDecimals extends number, +>( + signedness: TSignedness, + totalBits: TTotalBits, + decimals: TDecimals, + config: FixedPointCodecConfig = {}, +): FixedSizeCodec< + DecimalFixedPoint, + DecimalFixedPoint, + BytesForTotalBits +> { + return combineCodec( + getDecimalFixedPointEncoder(signedness, totalBits, decimals, config), + getDecimalFixedPointDecoder(signedness, totalBits, decimals, config), + ); +} diff --git a/packages/fixed-points/src/decimal/index.ts b/packages/fixed-points/src/decimal/index.ts index 15ea0b33d..4579a1c3e 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 './codecs'; export * from './comparisons'; export * from './conversions'; export * from './core'; diff --git a/packages/fixed-points/src/index.ts b/packages/fixed-points/src/index.ts index 76cad6e4b..852ac8615 100644 --- a/packages/fixed-points/src/index.ts +++ b/packages/fixed-points/src/index.ts @@ -11,6 +11,7 @@ * @packageDocumentation */ export * from './binary'; +export type { FixedPointCodecConfig } from './codecs'; export * from './decimal'; export type { FixedPointToStringOptions } from './formatting'; export type { RoundingMode } from './rounding'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc1c63a90..ae162b91d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -603,6 +603,9 @@ importers: packages/fixed-points: dependencies: + '@solana/codecs-core': + specifier: workspace:* + version: link:../codecs-core '@solana/errors': specifier: workspace:* version: link:../errors