From ebb03cd8270027db957d4cecc7d2374d468d4ccb Mon Sep 17 00:00:00 2001 From: Loris Leiva Date: Wed, 3 Apr 2024 00:01:44 +0100 Subject: [PATCH] refactor(experimental): add getConstantCodec to @solana/codecs-data-structures (#2400) This PR adds two new helpers: `containsBytes` and `getConstantCodec`. The `containsBytes` helper checks if a `Uint8Array` contains another `Uint8Array` at a given offset. ```ts containsBytes(new Uint8Array([1, 2, 3, 4]), new Uint8Array([2, 3]), 1); // true containsBytes(new Uint8Array([1, 2, 3, 4]), new Uint8Array([2, 3]), 2); // false ``` The `getConstantCodec` function accepts any `Uint8Array` and returns a `Codec`. When encoding, it will set the provided `Uint8Array` as-is. When decoding, it will assert that the next bytes contain the provided `Uint8Array` and move the offset forward. ```ts const codec = getConstantCodec(new Uint8Array([1, 2, 3])); codec.encode(undefined); // 0x010203 codec.decode(new Uint8Array([1, 2, 3])); // undefined codec.decode(new Uint8Array([1, 2, 4])); // Throws an error. ``` --- .changeset/honest-rivers-deny.md | 24 +++++++ packages/codecs-core/README.md | 5 ++ packages/codecs-core/src/bytes.ts | 14 +++++ packages/codecs-data-structures/README.md | 19 ++++++ .../src/__tests__/constant-test.ts | 46 ++++++++++++++ .../src/__typetests__/constant-typetest.ts | 24 +++++++ .../codecs-data-structures/src/constant.ts | 62 +++++++++++++++++++ packages/codecs-data-structures/src/index.ts | 1 + packages/codecs/README.md | 1 + packages/errors/src/codes.ts | 2 + packages/errors/src/context.ts | 13 ++++ packages/errors/src/messages.ts | 3 + 12 files changed, 214 insertions(+) create mode 100644 .changeset/honest-rivers-deny.md create mode 100644 packages/codecs-data-structures/src/__tests__/constant-test.ts create mode 100644 packages/codecs-data-structures/src/__typetests__/constant-typetest.ts create mode 100644 packages/codecs-data-structures/src/constant.ts diff --git a/.changeset/honest-rivers-deny.md b/.changeset/honest-rivers-deny.md new file mode 100644 index 000000000000..680b8cde0160 --- /dev/null +++ b/.changeset/honest-rivers-deny.md @@ -0,0 +1,24 @@ +--- +'@solana/codecs-data-structures': patch +'@solana/codecs-core': patch +'@solana/errors': patch +--- + +Added new `containsBytes` and `getConstantCodec` helpers + +The `containsBytes` helper checks if a `Uint8Array` contains another `Uint8Array` at a given offset. + +```ts +containsBytes(new Uint8Array([1, 2, 3, 4]), new Uint8Array([2, 3]), 1); // true +containsBytes(new Uint8Array([1, 2, 3, 4]), new Uint8Array([2, 3]), 2); // false +``` + +The `getConstantCodec` function accepts any `Uint8Array` and returns a `Codec`. When encoding, it will set the provided `Uint8Array` as-is. When decoding, it will assert that the next bytes contain the provided `Uint8Array` and move the offset forward. + +```ts +const codec = getConstantCodec(new Uint8Array([1, 2, 3])); + +codec.encode(undefined); // 0x010203 +codec.decode(new Uint8Array([1, 2, 3])); // undefined +codec.decode(new Uint8Array([1, 2, 4])); // Throws an error. +``` \ No newline at end of file diff --git a/packages/codecs-core/README.md b/packages/codecs-core/README.md index dff9102abdee..d3e05446a466 100644 --- a/packages/codecs-core/README.md +++ b/packages/codecs-core/README.md @@ -614,6 +614,7 @@ This package also provides utility functions for managing bytes such as: - `mergeBytes`: Concatenates an array of `Uint8Arrays` into a single `Uint8Array`. - `padBytes`: Pads a `Uint8Array` with zeroes (to the right) to the specified length. - `fixBytes`: Pads or truncates a `Uint8Array` so it has the specified length. +- `containsBytes`: Checks if a `Uint8Array` contains another `Uint8Array` at a given offset. ```ts // Merge multiple Uint8Array buffers into one. @@ -626,6 +627,10 @@ padBytes(new Uint8Array([1, 2, 3, 4]), 2); // Uint8Array([1, 2, 3, 4]) // Pad and truncate a Uint8Array buffer to the given size. fixBytes(new Uint8Array([1, 2]), 4); // Uint8Array([1, 2, 0, 0]) fixBytes(new Uint8Array([1, 2, 3, 4]), 2); // Uint8Array([1, 2]) + +// Check if a Uint8Array contains another Uint8Array at a given offset. +containsBytes(new Uint8Array([1, 2, 3, 4]), new Uint8Array([2, 3]), 1); // true +containsBytes(new Uint8Array([1, 2, 3, 4]), new Uint8Array([2, 3]), 2); // false ``` --- diff --git a/packages/codecs-core/src/bytes.ts b/packages/codecs-core/src/bytes.ts index 1e6ec9ab1e02..4a77ab229f60 100644 --- a/packages/codecs-core/src/bytes.ts +++ b/packages/codecs-core/src/bytes.ts @@ -42,3 +42,17 @@ export const padBytes = (bytes: ReadonlyUint8Array | Uint8Array, length: number) */ export const fixBytes = (bytes: ReadonlyUint8Array | Uint8Array, length: number): ReadonlyUint8Array | Uint8Array => padBytes(bytes.length <= length ? bytes : bytes.slice(0, length), length); + +/** + * Returns true if and only if the provided `data` byte array contains + * the provided `bytes` byte array at the specified `offset`. + */ +export function containsBytes( + data: ReadonlyUint8Array | Uint8Array, + bytes: ReadonlyUint8Array | Uint8Array, + offset: number, +): boolean { + const slice = offset === 0 && data.length === bytes.length ? data : data.slice(offset, offset + bytes.length); + if (slice.length !== bytes.length) return false; + return bytes.every((b, i) => b === slice[i]); +} diff --git a/packages/codecs-data-structures/README.md b/packages/codecs-data-structures/README.md index 63c1c20d8b7a..4fe773c0a782 100644 --- a/packages/codecs-data-structures/README.md +++ b/packages/codecs-data-structures/README.md @@ -574,6 +574,25 @@ const bytes = getBitArrayEncoder(1).encode(booleans); const decodedBooleans = getBitArrayDecoder(1).decode(bytes); ``` +## Constant codec + +The `getConstantCodec` function accepts any `Uint8Array` and returns a `Codec`. When encoding, it will set the provided `Uint8Array` as-is. When decoding, it will assert that the next bytes contain the provided `Uint8Array` and move the offset forward. + +```ts +const codec = getConstantCodec(new Uint8Array([1, 2, 3])); + +codec.encode(undefined); // 0x010203 +codec.decode(new Uint8Array([1, 2, 3])); // undefined +codec.decode(new Uint8Array([1, 2, 4])); // Throws an error. +``` + +Separate `getConstantEncoder` and `getConstantDecoder` functions are also available. + +```ts +getConstantEncoder(new Uint8Array([1, 2, 3])).encode(undefined); +getConstantDecoder(new Uint8Array([1, 2, 3])).decode(new Uint8Array([1, 2, 3])); +``` + ## Unit codec The `getUnitCodec` function returns a `Codec` that encodes `undefined` into an empty `Uint8Array` and returns `undefined` without consuming any bytes when decoding. This is more of a low-level codec that can be used internally by other codecs. For instance, this is how discriminated union codecs describe the codecs of empty variants. diff --git a/packages/codecs-data-structures/src/__tests__/constant-test.ts b/packages/codecs-data-structures/src/__tests__/constant-test.ts new file mode 100644 index 000000000000..884f49e46e51 --- /dev/null +++ b/packages/codecs-data-structures/src/__tests__/constant-test.ts @@ -0,0 +1,46 @@ +import { assertIsFixedSize } from '@solana/codecs-core'; +import { SOLANA_ERROR__CODECS__INVALID_CONSTANT, SolanaError } from '@solana/errors'; + +import { getConstantCodec } from '../constant'; +import { b } from './__setup__'; + +describe('getConstantCodec', () => { + it('encodes the provided constant', () => { + const codec = getConstantCodec(b('010203')); + expect(codec.encode(undefined)).toStrictEqual(b('010203')); + }); + + it('decodes undefined when the constant is present', () => { + const codec = getConstantCodec(b('010203')); + expect(codec.decode(b('010203'))).toBeUndefined(); + }); + + it('pushes the offset forward when writing', () => { + const codec = getConstantCodec(b('010203')); + expect(codec.write(undefined, new Uint8Array(10), 3)).toBe(6); + }); + + it('pushes the offset forward when reading', () => { + const codec = getConstantCodec(b('010203')); + expect(codec.read(b('ffff01020300'), 2)).toStrictEqual([undefined, 5]); + }); + + it('throws when the decoded bytes do no contain the constant bytes', () => { + const codec = getConstantCodec(b('010203')); + expect(() => codec.decode(b('0102ff'))).toThrow( + new SolanaError(SOLANA_ERROR__CODECS__INVALID_CONSTANT, { + constant: b('010203'), + data: b('0102ff'), + hexConstant: '010203', + hexData: '0102ff', + offset: 0, + }), + ); + }); + + it('returns a fixed size codec of the size of the provided byte array', () => { + const codec = getConstantCodec(b('010203')); + assertIsFixedSize(codec); + expect(codec.fixedSize).toBe(3); + }); +}); diff --git a/packages/codecs-data-structures/src/__typetests__/constant-typetest.ts b/packages/codecs-data-structures/src/__typetests__/constant-typetest.ts new file mode 100644 index 000000000000..4abf41195230 --- /dev/null +++ b/packages/codecs-data-structures/src/__typetests__/constant-typetest.ts @@ -0,0 +1,24 @@ +import { FixedSizeCodec, FixedSizeDecoder, FixedSizeEncoder } from '@solana/codecs-core'; + +import { getConstantCodec, getConstantDecoder, getConstantEncoder } from '../constant'; + +const constant = {} as Uint8Array; +const constant3bytes = {} as Uint8Array & { length: 3 }; + +{ + // [getConstantEncoder]: It returns a fixed size encoder. + getConstantEncoder(constant) satisfies FixedSizeEncoder; + getConstantEncoder(constant3bytes) satisfies FixedSizeEncoder; +} + +{ + // [getConstantDecoder]: It returns a fixed size decoder. + getConstantDecoder(constant) satisfies FixedSizeDecoder; + getConstantDecoder(constant3bytes) satisfies FixedSizeDecoder; +} + +{ + // [getConstantCodec]: It returns a fixed size codec. + getConstantCodec(constant) satisfies FixedSizeCodec; + getConstantCodec(constant3bytes) satisfies FixedSizeCodec; +} diff --git a/packages/codecs-data-structures/src/constant.ts b/packages/codecs-data-structures/src/constant.ts new file mode 100644 index 000000000000..101cf021ad23 --- /dev/null +++ b/packages/codecs-data-structures/src/constant.ts @@ -0,0 +1,62 @@ +import { + combineCodec, + containsBytes, + createDecoder, + createEncoder, + FixedSizeCodec, + FixedSizeDecoder, + FixedSizeEncoder, + ReadonlyUint8Array, +} from '@solana/codecs-core'; +import { getBase16Decoder } from '@solana/codecs-strings'; +import { SOLANA_ERROR__CODECS__INVALID_CONSTANT, SolanaError } from '@solana/errors'; + +/** + * Creates a void encoder that always sets the provided byte array when encoding. + */ +export function getConstantEncoder( + constant: TConstant, +): FixedSizeEncoder { + return createEncoder({ + fixedSize: constant.length, + write: (_, bytes, offset) => { + bytes.set(constant, offset); + return offset + constant.length; + }, + }); +} + +/** + * Creates a void decoder that reads the next bytes and fails if they do not match the provided constant. + */ +export function getConstantDecoder( + constant: TConstant, +): FixedSizeDecoder { + return createDecoder({ + fixedSize: constant.length, + read: (bytes, offset) => { + const base16 = getBase16Decoder(); + if (!containsBytes(bytes, constant, offset)) { + throw new SolanaError(SOLANA_ERROR__CODECS__INVALID_CONSTANT, { + constant, + data: bytes, + hexConstant: base16.decode(constant), + hexData: base16.decode(bytes), + offset, + }); + } + return [undefined, offset + constant.length]; + }, + }); +} + +/** + * Creates a void codec that always sets the provided byte array + * when encoding and, when decoding, asserts that the next + * bytes match the provided byte array. + */ +export function getConstantCodec( + constant: TConstant, +): FixedSizeCodec { + return combineCodec(getConstantEncoder(constant), getConstantDecoder(constant)); +} diff --git a/packages/codecs-data-structures/src/index.ts b/packages/codecs-data-structures/src/index.ts index ecd207bf8ff1..76076ce4c910 100644 --- a/packages/codecs-data-structures/src/index.ts +++ b/packages/codecs-data-structures/src/index.ts @@ -3,6 +3,7 @@ export * from './assertions'; export * from './bit-array'; export * from './boolean'; export * from './bytes'; +export * from './constant'; export * from './discriminated-union'; export * from './enum'; export * from './map'; diff --git a/packages/codecs/README.md b/packages/codecs/README.md index dfb9f8e35eca..aaa1232cf950 100644 --- a/packages/codecs/README.md +++ b/packages/codecs/README.md @@ -90,6 +90,7 @@ The `@solana/codecs` package is composed of several smaller packages, each with - [Nullable codec](https://github.com/solana-labs/solana-web3.js/tree/master/packages/codecs-data-structures#nullable-codec). - [Bytes codec](https://github.com/solana-labs/solana-web3.js/tree/master/packages/codecs-data-structures#bytes-codec). - [Bit array codec](https://github.com/solana-labs/solana-web3.js/tree/master/packages/codecs-data-structures#bit-array-codec). + - [Constant codec](https://github.com/solana-labs/solana-web3.js/tree/master/packages/codecs-data-structures#constant-codec). - [Unit codec](https://github.com/solana-labs/solana-web3.js/tree/master/packages/codecs-data-structures#unit-codec). - [`@solana/options`](https://github.com/solana-labs/solana-web3.js/tree/master/packages/options) This package adds Rust-like `Options` to JavaScript and offers codecs and helpers to manage them. - [Creating options](https://github.com/solana-labs/solana-web3.js/tree/master/packages/options#creating-options). diff --git a/packages/errors/src/codes.ts b/packages/errors/src/codes.ts index 3cd8df1fd0ed..0240e93e45f3 100644 --- a/packages/errors/src/codes.ts +++ b/packages/errors/src/codes.ts @@ -262,6 +262,7 @@ export const SOLANA_ERROR__CODECS__OFFSET_OUT_OF_RANGE = 8078014 as const; export const SOLANA_ERROR__CODECS__INVALID_LITERAL_UNION_VARIANT = 8078015 as const; export const SOLANA_ERROR__CODECS__LITERAL_UNION_DISCRIMINATOR_OUT_OF_RANGE = 8078016 as const; export const SOLANA_ERROR__CODECS__UNION_VARIANT_OUT_OF_RANGE = 8078017 as const; +export const SOLANA_ERROR__CODECS__INVALID_CONSTANT = 8078018 as const; // RPC-related errors. // Reserve error codes in the range [8100000-8100999]. @@ -330,6 +331,7 @@ export type SolanaErrorCode = | typeof SOLANA_ERROR__CODECS__EXPECTED_POSITIVE_BYTE_LENGTH | typeof SOLANA_ERROR__CODECS__EXPECTED_VARIABLE_LENGTH | typeof SOLANA_ERROR__CODECS__INVALID_BYTE_LENGTH + | typeof SOLANA_ERROR__CODECS__INVALID_CONSTANT | typeof SOLANA_ERROR__CODECS__INVALID_DISCRIMINATED_UNION_VARIANT | typeof SOLANA_ERROR__CODECS__INVALID_ENUM_VARIANT | typeof SOLANA_ERROR__CODECS__INVALID_LITERAL_UNION_VARIANT diff --git a/packages/errors/src/context.ts b/packages/errors/src/context.ts index da68ce53bb08..7585478b3d9f 100644 --- a/packages/errors/src/context.ts +++ b/packages/errors/src/context.ts @@ -18,6 +18,7 @@ import { SOLANA_ERROR__CODECS__ENUM_DISCRIMINATOR_OUT_OF_RANGE, SOLANA_ERROR__CODECS__EXPECTED_POSITIVE_BYTE_LENGTH, SOLANA_ERROR__CODECS__INVALID_BYTE_LENGTH, + SOLANA_ERROR__CODECS__INVALID_CONSTANT, SOLANA_ERROR__CODECS__INVALID_DISCRIMINATED_UNION_VARIANT, SOLANA_ERROR__CODECS__INVALID_ENUM_VARIANT, SOLANA_ERROR__CODECS__INVALID_LITERAL_UNION_VARIANT, @@ -150,6 +151,11 @@ type DefaultUnspecifiedErrorContextToUndefined = { [P in SolanaErrorCode]: P extends keyof T ? T[P] : undefined; }; +type TypedArrayMutableProperties = 'copyWithin' | 'fill' | 'reverse' | 'set' | 'sort'; +interface ReadonlyUint8Array extends Omit { + readonly [n: number]: number; +} + /** * To add a new error, follow the instructions at * https://github.com/solana-labs/solana-web3.js/tree/master/packages/errors/#adding-a-new-error @@ -283,6 +289,13 @@ export type SolanaErrorContext = DefaultUnspecifiedErrorContextToUndefined< codecDescription: string; expected: number; }; + [SOLANA_ERROR__CODECS__INVALID_CONSTANT]: { + constant: ReadonlyUint8Array; + data: ReadonlyUint8Array; + hexConstant: string; + hexData: string; + offset: number; + }; [SOLANA_ERROR__CODECS__INVALID_DISCRIMINATED_UNION_VARIANT]: { value: bigint | boolean | number | string | null | undefined; variants: readonly (bigint | boolean | number | string | null | undefined)[]; diff --git a/packages/errors/src/messages.ts b/packages/errors/src/messages.ts index a4bdd1055c0a..02b7a115a8ed 100644 --- a/packages/errors/src/messages.ts +++ b/packages/errors/src/messages.ts @@ -26,6 +26,7 @@ import { SOLANA_ERROR__CODECS__EXPECTED_POSITIVE_BYTE_LENGTH, SOLANA_ERROR__CODECS__EXPECTED_VARIABLE_LENGTH, SOLANA_ERROR__CODECS__INVALID_BYTE_LENGTH, + SOLANA_ERROR__CODECS__INVALID_CONSTANT, SOLANA_ERROR__CODECS__INVALID_DISCRIMINATED_UNION_VARIANT, SOLANA_ERROR__CODECS__INVALID_ENUM_VARIANT, SOLANA_ERROR__CODECS__INVALID_LITERAL_UNION_VARIANT, @@ -268,6 +269,8 @@ export const SolanaErrorMessages: Readonly<{ [SOLANA_ERROR__CODECS__EXPECTED_VARIABLE_LENGTH]: 'Expected a variable-size codec, got a fixed-size one.', [SOLANA_ERROR__CODECS__INVALID_BYTE_LENGTH]: 'Codec [$codecDescription] expected $expected bytes, got $bytesLength.', + [SOLANA_ERROR__CODECS__INVALID_CONSTANT]: + 'Expected byte array constant [$hexConstant] to be present in data [$hexData] at offset [$offset].', [SOLANA_ERROR__CODECS__INVALID_DISCRIMINATED_UNION_VARIANT]: 'Invalid discriminated union variant. Expected one of [$variants], got $value.', [SOLANA_ERROR__CODECS__INVALID_ENUM_VARIANT]: