Skip to content

Commit

Permalink
refactor(experimental): add getConstantCodec to @solana/codecs-data-s…
Browse files Browse the repository at this point in the history
…tructures (#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<void>`. 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.
```
  • Loading branch information
lorisleiva committed Apr 2, 2024
1 parent e77a9b4 commit ebb03cd
Show file tree
Hide file tree
Showing 12 changed files with 214 additions and 0 deletions.
24 changes: 24 additions & 0 deletions .changeset/honest-rivers-deny.md
Original file line number Diff line number Diff line change
@@ -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<void>`. 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.
```
5 changes: 5 additions & 0 deletions packages/codecs-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
```

---
Expand Down
14 changes: 14 additions & 0 deletions packages/codecs-core/src/bytes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
19 changes: 19 additions & 0 deletions packages/codecs-data-structures/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>`. 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<void>` 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.
Expand Down
46 changes: 46 additions & 0 deletions packages/codecs-data-structures/src/__tests__/constant-test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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<void>;
getConstantEncoder(constant3bytes) satisfies FixedSizeEncoder<void, 3>;
}

{
// [getConstantDecoder]: It returns a fixed size decoder.
getConstantDecoder(constant) satisfies FixedSizeDecoder<void>;
getConstantDecoder(constant3bytes) satisfies FixedSizeDecoder<void, 3>;
}

{
// [getConstantCodec]: It returns a fixed size codec.
getConstantCodec(constant) satisfies FixedSizeCodec<void>;
getConstantCodec(constant3bytes) satisfies FixedSizeCodec<void, void, 3>;
}
62 changes: 62 additions & 0 deletions packages/codecs-data-structures/src/constant.ts
Original file line number Diff line number Diff line change
@@ -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<TConstant extends ReadonlyUint8Array>(
constant: TConstant,
): FixedSizeEncoder<void, TConstant['length']> {
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<TConstant extends ReadonlyUint8Array>(
constant: TConstant,
): FixedSizeDecoder<void, TConstant['length']> {
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<TConstant extends ReadonlyUint8Array>(
constant: TConstant,
): FixedSizeCodec<void, void, TConstant['length']> {
return combineCodec(getConstantEncoder(constant), getConstantDecoder(constant));
}
1 change: 1 addition & 0 deletions packages/codecs-data-structures/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions packages/codecs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 2 additions & 0 deletions packages/errors/src/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions packages/errors/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -150,6 +151,11 @@ type DefaultUnspecifiedErrorContextToUndefined<T> = {
[P in SolanaErrorCode]: P extends keyof T ? T[P] : undefined;
};

type TypedArrayMutableProperties = 'copyWithin' | 'fill' | 'reverse' | 'set' | 'sort';
interface ReadonlyUint8Array extends Omit<Uint8Array, TypedArrayMutableProperties> {
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
Expand Down Expand Up @@ -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)[];
Expand Down
3 changes: 3 additions & 0 deletions packages/errors/src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]:
Expand Down

0 comments on commit ebb03cd

Please sign in to comment.