diff --git a/packages/options/src/__tests__/__setup__.ts b/packages/options/src/__tests__/__setup__.ts index 005f5438037f..c00caebf54e2 100644 --- a/packages/options/src/__tests__/__setup__.ts +++ b/packages/options/src/__tests__/__setup__.ts @@ -1,31 +1,34 @@ -import { Codec } from '@solana/codecs-core'; +import { Codec, createCodec } from "@solana/codecs-core"; export const b = (s: string) => base16.encode(s); -export const base16: Codec = { - decode(bytes, offset = 0) { +export const base16: Codec = createCodec({ + getSizeFromValue: (value: string) => Math.ceil(value.length / 2), + read(bytes, offset) { const value = bytes.slice(offset).reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), ''); return [value, bytes.length]; }, - description: 'base16', - encode(value: string) { + write(value: string, bytes, offset) { const matches = value.toLowerCase().match(/.{1,2}/g); - return Uint8Array.from(matches ? matches.map((byte: string) => parseInt(byte, 16)) : []); + const hexBytes = matches ? matches.map((byte: string) => parseInt(byte, 16)) : []; + bytes.set(hexBytes, offset); + return offset + hexBytes.length; }, - fixedSize: null, - maxSize: null, -}; +}); export const getMockCodec = ( config: { defaultValue?: string; description?: string; size?: number | null; - } = {}, -) => ({ - decode: jest.fn().mockReturnValue([config.defaultValue ?? '', 0]), - description: config.description ?? 'mock', - encode: jest.fn().mockReturnValue(new Uint8Array()), - fixedSize: config.size ?? null, - maxSize: config.size ?? null, -}); + } = {} +) => + createCodec({ + ...(config.size != null ? { fixedSize: config.size } : { getSizeFromValue: jest.fn().mockReturnValue(0) }), + read: jest.fn().mockReturnValue([config.defaultValue ?? '', 0]), + write: jest.fn().mockReturnValue(0), + }) as Codec & { + readonly read: jest.Mock; + readonly getSizeFromValue: jest.Mock; + readonly write: jest.Mock; + }; diff --git a/packages/options/src/__tests__/option-codec-test.ts b/packages/options/src/__tests__/option-codec-test.ts index 4fa73baad7d6..6c313d29414e 100644 --- a/packages/options/src/__tests__/option-codec-test.ts +++ b/packages/options/src/__tests__/option-codec-test.ts @@ -2,7 +2,7 @@ import { getU8Codec, getU16Codec, getU64Codec } from '@solana/codecs-numbers'; import { none, some } from '../option'; import { getOptionCodec } from '../option-codec'; -import { b, getMockCodec } from './__setup__'; +import { b, base16, getMockCodec } from './__setup__'; describe('getOptionCodec', () => { const option = getOptionCodec; @@ -14,35 +14,39 @@ describe('getOptionCodec', () => { // None. expect(option(u8()).encode(none())).toStrictEqual(b('00')); expect(option(u8()).encode(null)).toStrictEqual(b('00')); - expect(option(u8()).decode(b('00'))).toStrictEqual([none(), 1]); - expect(option(u8()).decode(b('ffff00'), 2)).toStrictEqual([none(), 3]); + expect(option(u8()).read(b('00'), 0)).toStrictEqual([none(), 1]); + expect(option(u8()).read(b('ffff00'), 2)).toStrictEqual([none(), 3]); // None with custom prefix. expect(option(u8(), { prefix: u16() }).encode(none())).toStrictEqual(b('0000')); expect(option(u8(), { prefix: u16() }).encode(null)).toStrictEqual(b('0000')); - expect(option(u8(), { prefix: u16() }).decode(b('0000'))).toStrictEqual([none(), 2]); + expect(option(u8(), { prefix: u16() }).read(b('0000'), 0)).toStrictEqual([none(), 2]); // Some. expect(option(u8()).encode(some(42))).toStrictEqual(b('012a')); expect(option(u8()).encode(42)).toStrictEqual(b('012a')); - expect(option(u8()).decode(b('012a'))).toStrictEqual([some(42), 2]); - expect(option(u8()).decode(b('ffff012a'), 2)).toStrictEqual([some(42), 4]); + expect(option(u8()).read(b('012a'), 0)).toStrictEqual([some(42), 2]); + expect(option(u8()).read(b('ffff012a'), 2)).toStrictEqual([some(42), 4]); // Some with custom prefix. expect(option(u8(), { prefix: u16() }).encode(some(42))).toStrictEqual(b('01002a')); expect(option(u8(), { prefix: u16() }).encode(42)).toStrictEqual(b('01002a')); - expect(option(u8(), { prefix: u16() }).decode(b('01002a'))).toStrictEqual([some(42), 3]); + expect(option(u8(), { prefix: u16() }).read(b('01002a'), 0)).toStrictEqual([some(42), 3]); // Some with variable-size codec. const variableSizeMock = getMockCodec({ size: null }); - variableSizeMock.encode.mockReturnValue(b('7777777777')); - variableSizeMock.decode.mockReturnValue(['Hello', 6]); + variableSizeMock.getSizeFromValue.mockReturnValue(5); + variableSizeMock.write.mockImplementation((_, bytes: Uint8Array, offset: number) => { + bytes.set(b('7777777777'), offset); + return offset + 5; + }); + variableSizeMock.read.mockReturnValue(['Hello', 6]); expect(option(variableSizeMock).encode(some('Hello'))).toStrictEqual(b('017777777777')); - expect(variableSizeMock.encode).toHaveBeenCalledWith('Hello'); + expect(variableSizeMock.write).toHaveBeenCalledWith('Hello', expect.any(Uint8Array), 1); expect(option(variableSizeMock).encode('Hello')).toStrictEqual(b('017777777777')); - expect(variableSizeMock.encode).toHaveBeenCalledWith('Hello'); - expect(option(variableSizeMock).decode(b('017777777777'))).toStrictEqual([some('Hello'), 6]); - expect(variableSizeMock.decode).toHaveBeenCalledWith(b('017777777777'), 1); + expect(variableSizeMock.write).toHaveBeenCalledWith('Hello', expect.any(Uint8Array), 1); + expect(option(variableSizeMock).read(b('017777777777'), 0)).toStrictEqual([some('Hello'), 6]); + expect(variableSizeMock.read).toHaveBeenCalledWith(b('017777777777'), 1); // Different From and To types. const optionU64 = option(u64()); @@ -50,20 +54,20 @@ describe('getOptionCodec', () => { expect(optionU64.encode(some(2n))).toStrictEqual(b('010200000000000000')); expect(optionU64.encode(2)).toStrictEqual(b('010200000000000000')); expect(optionU64.encode(2n)).toStrictEqual(b('010200000000000000')); - expect(optionU64.decode(b('010200000000000000'))).toStrictEqual([some(2n), 9]); + expect(optionU64.read(b('010200000000000000'), 0)).toStrictEqual([some(2n), 9]); // Nested options. const nested = option(option(u8())); expect(nested.encode(some(some(42)))).toStrictEqual(b('01012a')); expect(nested.encode(some(42))).toStrictEqual(b('01012a')); expect(nested.encode(42)).toStrictEqual(b('01012a')); - expect(nested.decode(b('01012a'))).toStrictEqual([some(some(42)), 3]); + expect(nested.read(b('01012a'), 0)).toStrictEqual([some(some(42)), 3]); expect(nested.encode(some(none()))).toStrictEqual(b('0100')); expect(nested.encode(some(null))).toStrictEqual(b('0100')); - expect(nested.decode(b('0100'))).toStrictEqual([some(none()), 2]); + expect(nested.read(b('0100'), 0)).toStrictEqual([some(none()), 2]); expect(nested.encode(none())).toStrictEqual(b('00')); expect(nested.encode(null)).toStrictEqual(b('00')); - expect(nested.decode(b('00'))).toStrictEqual([none(), 1]); + expect(nested.read(b('00'), 0)).toStrictEqual([none(), 1]); }); it('encodes fixed options', () => { @@ -73,23 +77,23 @@ describe('getOptionCodec', () => { // None. expect(fixedU8.encode(none())).toStrictEqual(b('0000')); expect(fixedU8.encode(null)).toStrictEqual(b('0000')); - expect(fixedU8.decode(b('0000'))).toStrictEqual([none(), 2]); - expect(fixedU8.decode(b('ffff0000'), 2)).toStrictEqual([none(), 4]); + expect(fixedU8.read(b('0000'), 0)).toStrictEqual([none(), 2]); + expect(fixedU8.read(b('ffff0000'), 2)).toStrictEqual([none(), 4]); // None with custom prefix. expect(fixedU8WithU16Prefix.encode(none())).toStrictEqual(b('000000')); expect(fixedU8WithU16Prefix.encode(null)).toStrictEqual(b('000000')); - expect(fixedU8WithU16Prefix.decode(b('000000'))).toStrictEqual([none(), 3]); + expect(fixedU8WithU16Prefix.read(b('000000'), 0)).toStrictEqual([none(), 3]); // Some. expect(fixedU8.encode(some(42))).toStrictEqual(b('012a')); expect(fixedU8.encode(42)).toStrictEqual(b('012a')); - expect(fixedU8.decode(b('012a'))).toStrictEqual([some(42), 2]); - expect(fixedU8.decode(b('ffff012a'), 2)).toStrictEqual([some(42), 4]); + expect(fixedU8.read(b('012a'), 0)).toStrictEqual([some(42), 2]); + expect(fixedU8.read(b('ffff012a'), 2)).toStrictEqual([some(42), 4]); // Some with custom prefix. expect(fixedU8WithU16Prefix.encode(42)).toStrictEqual(b('01002a')); - expect(fixedU8WithU16Prefix.decode(b('01002a'))).toStrictEqual([some(42), 3]); + expect(fixedU8WithU16Prefix.read(b('01002a'), 0)).toStrictEqual([some(42), 3]); // Different From and To types. const optionU64 = option(u64()); @@ -97,46 +101,26 @@ describe('getOptionCodec', () => { expect(optionU64.encode(some(2n))).toStrictEqual(b('010200000000000000')); expect(optionU64.encode(2)).toStrictEqual(b('010200000000000000')); expect(optionU64.encode(2n)).toStrictEqual(b('010200000000000000')); - expect(optionU64.decode(b('010200000000000000'))).toStrictEqual([some(2n), 9]); + expect(optionU64.read(b('010200000000000000'), 0)).toStrictEqual([some(2n), 9]); // Fixed options must wrap fixed-size items. + // @ts-expect-error Fixed options must wrap fixed-size items. expect(() => option(getMockCodec({ size: null }), { fixed: true })).toThrow( 'Fixed options can only be used with fixed-size codecs', ); }); - it('has the right description', () => { - const mock = getMockCodec({ description: 'mock', size: 5 }); - expect(option(u8()).description).toBe('option(u8; u8)'); - expect(option(mock).description).toBe('option(mock; u8)'); - expect(option(u8(), { prefix: u16() }).description).toBe('option(u8; u16(le))'); - - // Fixed. - expect(option(u8(), { fixed: true }).description).toBe('option(u8; u8; fixed)'); - expect(option(mock, { fixed: true }).description).toBe('option(mock; u8; fixed)'); - expect(option(u8(), { fixed: true, prefix: u16() }).description).toBe('option(u8; u16(le); fixed)'); - - // Custom description. - expect(option(u8(), { description: 'My option' }).description).toBe('My option'); - }); - it('has the right sizes', () => { - const fixMock = getMockCodec({ description: 'mock', size: 5 }); - const variableMock = getMockCodec({ description: 'mock', size: null }); - - expect(option(u8()).fixedSize).toBeNull(); + expect(option(u8()).getSizeFromValue(some(42))).toBe(1 + 1); expect(option(u8()).maxSize).toBe(2); - expect(option(variableMock).fixedSize).toBeNull(); - expect(option(variableMock).maxSize).toBeNull(); - expect(option(u8(), { prefix: u16() }).fixedSize).toBeNull(); + expect(option(base16).getSizeFromValue(some('010203'))).toBe(1 + 3); + expect(option(base16).maxSize).toBeUndefined(); + expect(option(u8(), { prefix: u16() }).getSizeFromValue(some(42))).toBe(2 + 1); expect(option(u8(), { prefix: u16() }).maxSize).toBe(3); // Fixed. expect(option(u8(), { fixed: true }).fixedSize).toBe(2); - expect(option(u8(), { fixed: true }).maxSize).toBe(2); - expect(option(fixMock, { fixed: true }).fixedSize).toBe(6); - expect(option(fixMock, { fixed: true }).maxSize).toBe(6); + expect(option(u64(), { fixed: true }).fixedSize).toBe(9); expect(option(u8(), { fixed: true, prefix: u16() }).fixedSize).toBe(3); - expect(option(u8(), { fixed: true, prefix: u16() }).maxSize).toBe(3); }); }); diff --git a/packages/options/src/__typetests__/option-codec-typetest.ts b/packages/options/src/__typetests__/option-codec-typetest.ts new file mode 100644 index 000000000000..ee6792c84381 --- /dev/null +++ b/packages/options/src/__typetests__/option-codec-typetest.ts @@ -0,0 +1,46 @@ +import { + FixedSizeCodec, + FixedSizeDecoder, + FixedSizeEncoder, + VariableSizeCodec, + VariableSizeDecoder, + VariableSizeEncoder, +} from '@solana/codecs-core'; + +import { Option, OptionOrNullable } from '../option'; +import { getOptionCodec, getOptionDecoder, getOptionEncoder } from '../option-codec'; + +{ + // [getOptionEncoder]: It knows if the encoder is fixed size or variable size. + getOptionEncoder({} as FixedSizeEncoder) satisfies FixedSizeEncoder>; + getOptionEncoder({} as FixedSizeEncoder, { fixed: true }) satisfies FixedSizeEncoder< + OptionOrNullable + >; + getOptionEncoder({} as FixedSizeEncoder) satisfies VariableSizeEncoder>; + + // @ts-expect-error It cannot be fixed when using a variable size item. + getOptionEncoder({} as VariableSizeEncoder, { fixed: true }); +} + +{ + // [getOptionDecoder]: It knows if the decoder is fixed size or variable size. + getOptionDecoder({} as FixedSizeDecoder) satisfies FixedSizeDecoder>; + getOptionDecoder({} as FixedSizeDecoder, { fixed: true }) satisfies FixedSizeDecoder>; + getOptionDecoder({} as FixedSizeDecoder) satisfies VariableSizeDecoder>; + + // @ts-expect-error It cannot be fixed when using a variable size item. + getOptionDecoder({} as VariableSizeDecoder, { fixed: true }); +} + +{ + // [getOptionCodec]: It knows if the codec is fixed size or variable size. + getOptionCodec({} as FixedSizeCodec) satisfies FixedSizeCodec, Option>; + getOptionCodec({} as FixedSizeCodec, { fixed: true }) satisfies FixedSizeCodec< + OptionOrNullable, + Option + >; + getOptionCodec({} as FixedSizeCodec) satisfies VariableSizeCodec, Option>; + + // @ts-expect-error It cannot be fixed when using a variable size item. + getOptionCodec({} as VariableSizeCodec, { fixed: true }); +} diff --git a/packages/options/src/option-codec.ts b/packages/options/src/option-codec.ts index 2186a516479d..210ec26b4c38 100644 --- a/packages/options/src/option-codec.ts +++ b/packages/options/src/option-codec.ts @@ -1,21 +1,36 @@ import { - assertFixedSizeCodec, - BaseCodecConfig, + assertIsFixedSize, Codec, - CodecData, combineCodec, + createDecoder, + createEncoder, Decoder, Encoder, - fixBytes, - mergeBytes, + FixedSizeCodec, + FixedSizeDecoder, + FixedSizeEncoder, + getEncodedSize, + isFixedSize, + VariableSizeCodec, + VariableSizeDecoder, + VariableSizeEncoder, } from '@solana/codecs-core'; -import { getU8Decoder, getU8Encoder, NumberCodec, NumberDecoder, NumberEncoder } from '@solana/codecs-numbers'; +import { + FixedSizeNumberCodec, + FixedSizeNumberDecoder, + FixedSizeNumberEncoder, + getU8Decoder, + getU8Encoder, + NumberCodec, + NumberDecoder, + NumberEncoder, +} from '@solana/codecs-numbers'; import { isOption, isSome, none, Option, OptionOrNullable, some } from './option'; import { wrapNullable } from './unwrap-option'; /** Defines the config for option codecs. */ -export type OptionCodecConfig = BaseCodecConfig & { +export type OptionCodecConfig = { /** * The codec to use for the boolean prefix. * @defaultValue u8 prefix. @@ -33,49 +48,67 @@ export type OptionCodecConfig (all === null || size === null ? null : all + size), 0 as number | null); -} - -function optionCodecHelper(item: CodecData, prefix: CodecData, fixed: boolean, description?: string): CodecData { - let descriptionSuffix = `; ${prefix.description}`; - let fixedSize = item.fixedSize === 0 ? prefix.fixedSize : null; - if (fixed) { - assertFixedSizeCodec(item, 'Fixed options can only be used with fixed-size codecs.'); - assertFixedSizeCodec(prefix, 'Fixed options can only be used with fixed-size prefix.'); - descriptionSuffix += '; fixed'; - fixedSize = prefix.fixedSize + item.fixedSize; - } - - return { - description: description ?? `option(${item.description + descriptionSuffix})`, - fixedSize, - maxSize: sumCodecSizes([prefix.maxSize, item.maxSize]), - }; -} - /** * Creates a encoder for an optional value using `null` as the `None` value. * * @param item - The encoder to use for the value that may be present. * @param config - A set of config for the encoder. */ -export function getOptionEncoder( - item: Encoder, +export function getOptionEncoder( + item: FixedSizeEncoder, + config: OptionCodecConfig & { fixed: true }, +): FixedSizeEncoder>; +export function getOptionEncoder( + item: FixedSizeEncoder, + config?: OptionCodecConfig, +): FixedSizeEncoder>; +export function getOptionEncoder( + item: Encoder, + config?: OptionCodecConfig & { fixed?: false }, +): VariableSizeEncoder>; +export function getOptionEncoder( + item: Encoder, config: OptionCodecConfig = {}, -): Encoder> { +): Encoder> { const prefix = config.prefix ?? getU8Encoder(); const fixed = config.fixed ?? false; - return { - ...optionCodecHelper(item, prefix, fixed, config.description), - encode: (optionOrNullable: OptionOrNullable) => { - const option = isOption(optionOrNullable) ? optionOrNullable : wrapNullable(optionOrNullable); - const prefixByte = prefix.encode(Number(isSome(option))); - let itemBytes = isSome(option) ? item.encode(option.value) : new Uint8Array(); - itemBytes = fixed ? fixBytes(itemBytes, item.fixedSize as number) : itemBytes; - return mergeBytes([prefixByte, itemBytes]); + + const isZeroSizeItem = isFixedSize(item) && isFixedSize(prefix) && item.fixedSize === 0; + if (fixed || isZeroSizeItem) { + assertIsFixedSize(item, 'Fixed options can only be used with fixed-size codecs.'); + assertIsFixedSize(prefix, 'Fixed options can only be used with fixed-size prefix.'); + const fixedSize = prefix.fixedSize + item.fixedSize; + return createEncoder({ + fixedSize, + write: (optionOrNullable: OptionOrNullable, bytes, offset) => { + const option = isOption(optionOrNullable) ? optionOrNullable : wrapNullable(optionOrNullable); + const prefixOffset = prefix.write(Number(isSome(option)), bytes, offset); + if (isSome(option)) { + item.write(option.value, bytes, prefixOffset); + } + return offset + fixedSize; + }, + }); + } + + return createEncoder({ + getSizeFromValue: (optionOrNullable: OptionOrNullable) => { + const option = isOption(optionOrNullable) ? optionOrNullable : wrapNullable(optionOrNullable); + return ( + getEncodedSize(Number(isSome(option)), prefix) + + (isSome(option) ? getEncodedSize(option.value, item) : 0) + ); }, - }; + maxSize: sumCodecSizes([prefix, item].map(getMaxSize)) ?? undefined, + write: (optionOrNullable: OptionOrNullable, bytes, offset) => { + const option = isOption(optionOrNullable) ? optionOrNullable : wrapNullable(optionOrNullable); + offset = prefix.write(Number(isSome(option)), bytes, offset); + if (isSome(option)) { + offset = item.write(option.value, bytes, offset); + } + return offset; + }, + }); } /** @@ -84,29 +117,49 @@ export function getOptionEncoder( * @param item - The decoder to use for the value that may be present. * @param config - A set of config for the decoder. */ -export function getOptionDecoder( - item: Decoder, +export function getOptionDecoder( + item: FixedSizeDecoder, + config: OptionCodecConfig & { fixed: true }, +): FixedSizeDecoder>; +export function getOptionDecoder( + item: FixedSizeDecoder, + config?: OptionCodecConfig, +): FixedSizeDecoder>; +export function getOptionDecoder( + item: Decoder, + config?: OptionCodecConfig & { fixed?: false }, +): VariableSizeDecoder>; +export function getOptionDecoder( + item: Decoder, config: OptionCodecConfig = {}, -): Decoder> { +): Decoder> { const prefix = config.prefix ?? getU8Decoder(); const fixed = config.fixed ?? false; - return { - ...optionCodecHelper(item, prefix, fixed, config.description), - decode: (bytes: Uint8Array, offset = 0) => { + + let fixedSize: number | null = null; + const isZeroSizeItem = isFixedSize(item) && isFixedSize(prefix) && item.fixedSize === 0; + if (fixed || isZeroSizeItem) { + assertIsFixedSize(item, 'Fixed options can only be used with fixed-size codecs.'); + assertIsFixedSize(prefix, 'Fixed options can only be used with fixed-size prefix.'); + fixedSize = prefix.fixedSize + item.fixedSize; + } + + return createDecoder({ + ...(fixedSize === null + ? { maxSize: sumCodecSizes([prefix, item].map(getMaxSize)) ?? undefined } + : { fixedSize }), + read: (bytes: Uint8Array, offset) => { if (bytes.length - offset <= 0) { return [none(), offset]; } - const fixedOffset = offset + (prefix.fixedSize ?? 0) + (item.fixedSize ?? 0); - const [isSome, prefixOffset] = prefix.decode(bytes, offset); - offset = prefixOffset; + const [isSome, prefixOffset] = prefix.read(bytes, offset); if (isSome === 0) { - return [none(), fixed ? fixedOffset : offset]; + return [none(), fixedSize !== null ? offset + fixedSize : prefixOffset]; } - const [value, newOffset] = item.decode(bytes, offset); - offset = newOffset; - return [some(value), fixed ? fixedOffset : offset]; + const [value, newOffset] = item.read(bytes, prefixOffset); + return [some(value), fixedSize !== null ? offset + fixedSize : newOffset]; }, - }; + }); } /** @@ -115,9 +168,29 @@ export function getOptionDecoder( * @param item - The codec to use for the value that may be present. * @param config - A set of config for the codec. */ -export function getOptionCodec( - item: Codec, +export function getOptionCodec( + item: FixedSizeCodec, + config: OptionCodecConfig & { fixed: true }, +): FixedSizeCodec, Option>; +export function getOptionCodec( + item: FixedSizeCodec, + config?: OptionCodecConfig, +): FixedSizeCodec, Option>; +export function getOptionCodec( + item: Codec, + config?: OptionCodecConfig & { fixed?: false }, +): VariableSizeCodec, Option>; +export function getOptionCodec( + item: Codec, config: OptionCodecConfig = {}, -): Codec, Option> { - return combineCodec(getOptionEncoder(item, config), getOptionDecoder(item, config)); +): Codec, Option> { + return combineCodec(getOptionEncoder(item, config as object), getOptionDecoder(item, config as object)); +} + +function sumCodecSizes(sizes: (number | null)[]): number | null { + return sizes.reduce((all, size) => (all === null || size === null ? null : all + size), 0 as number | null); +} + +function getMaxSize(codec: { fixedSize: number } | { maxSize?: number }): number | null { + return isFixedSize(codec) ? codec.fixedSize : codec.maxSize ?? null; }