@mysten/bcs
Version:
BCS - Canonical Binary Serialization implementation for JavaScript
530 lines (478 loc) • 13.6 kB
text/typescript
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0
import { fromBase58, fromBase64, toBase58, toBase64, fromHex, toHex } from '@mysten/utils';
import { BcsReader } from './reader.js';
import { ulebEncode } from './uleb.js';
import type { BcsWriterOptions } from './writer.js';
import { BcsWriter } from './writer.js';
import type { EnumInputShape, EnumOutputShape, JoinString } from './types.js';
export interface BcsTypeOptions<T, Input = T, Name extends string = string> {
name?: Name;
validate?: (value: Input) => void;
}
export class BcsType<T, Input = T, const Name extends string = string> {
$inferType!: T;
$inferInput!: Input;
name: Name;
read: (reader: BcsReader) => T;
serializedSize: (value: Input, options?: BcsWriterOptions) => number | null;
validate: (value: Input) => void;
#write: (value: Input, writer: BcsWriter) => void;
#serialize: (value: Input, options?: BcsWriterOptions) => Uint8Array<ArrayBuffer>;
constructor(
options: {
name: Name;
read: (reader: BcsReader) => T;
write: (value: Input, writer: BcsWriter) => void;
serialize?: (value: Input, options?: BcsWriterOptions) => Uint8Array<ArrayBuffer>;
serializedSize?: (value: Input) => number | null;
validate?: (value: Input) => void;
} & BcsTypeOptions<T, Input, Name>,
) {
this.name = options.name;
this.read = options.read;
this.serializedSize = options.serializedSize ?? (() => null);
this.#write = options.write;
this.#serialize =
options.serialize ??
((value, options) => {
const writer = new BcsWriter({
initialSize: this.serializedSize(value) ?? undefined,
...options,
});
this.#write(value, writer);
return writer.toBytes();
});
this.validate = options.validate ?? (() => {});
}
write(value: Input, writer: BcsWriter) {
this.validate(value);
this.#write(value, writer);
}
serialize(value: Input, options?: BcsWriterOptions) {
this.validate(value);
return new SerializedBcs(this, this.#serialize(value, options));
}
parse(bytes: Uint8Array): T {
const reader = new BcsReader(bytes);
return this.read(reader);
}
fromHex(hex: string) {
return this.parse(fromHex(hex));
}
fromBase58(b64: string) {
return this.parse(fromBase58(b64));
}
fromBase64(b64: string) {
return this.parse(fromBase64(b64));
}
transform<T2 = T, Input2 = Input, NewName extends string = Name>({
name,
input,
output,
validate,
}: {
input?: (val: Input2) => Input;
output?: (value: T) => T2;
} & BcsTypeOptions<T2, Input2, NewName>) {
return new BcsType<T2, Input2, NewName>({
name: (name ?? this.name) as NewName,
read: (reader) => (output ? output(this.read(reader)) : (this.read(reader) as never)),
write: (value, writer) => this.#write(input ? input(value) : (value as never), writer),
serializedSize: (value) => this.serializedSize(input ? input(value) : (value as never)),
serialize: (value, options) =>
this.#serialize(input ? input(value) : (value as never), options),
validate: (value) => {
validate?.(value);
this.validate(input ? input(value) : (value as never));
},
});
}
}
const SERIALIZED_BCS_BRAND = Symbol.for('@mysten/serialized-bcs') as never;
export function isSerializedBcs(obj: unknown): obj is SerializedBcs<unknown> {
return !!obj && typeof obj === 'object' && (obj as any)[SERIALIZED_BCS_BRAND] === true;
}
export class SerializedBcs<T, Input = T> {
#schema: BcsType<T, Input>;
#bytes: Uint8Array<ArrayBuffer>;
// Used to brand SerializedBcs so that they can be identified, even between multiple copies
// of the @mysten/bcs package are installed
get [SERIALIZED_BCS_BRAND]() {
return true;
}
constructor(schema: BcsType<T, Input>, bytes: Uint8Array<ArrayBuffer>) {
this.#schema = schema;
this.#bytes = bytes;
}
toBytes() {
return this.#bytes;
}
toHex() {
return toHex(this.#bytes);
}
toBase64() {
return toBase64(this.#bytes);
}
toBase58() {
return toBase58(this.#bytes);
}
parse() {
return this.#schema.parse(this.#bytes);
}
}
export function fixedSizeBcsType<T, Input = T, const Name extends string = string>({
size,
...options
}: {
name: Name;
size: number;
read: (reader: BcsReader) => T;
write: (value: Input, writer: BcsWriter) => void;
} & BcsTypeOptions<T, Input, Name>) {
return new BcsType<T, Input, Name>({
...options,
serializedSize: () => size,
});
}
export function uIntBcsType<const Name extends string = string>({
readMethod,
writeMethod,
...options
}: {
name: Name;
size: number;
readMethod: `read${8 | 16 | 32}`;
writeMethod: `write${8 | 16 | 32}`;
maxValue: number;
} & BcsTypeOptions<number, number, Name>) {
return fixedSizeBcsType<number, number, Name>({
...options,
read: (reader) => reader[readMethod](),
write: (value, writer) => writer[writeMethod](value),
validate: (value) => {
if (value < 0 || value > options.maxValue) {
throw new TypeError(
`Invalid ${options.name} value: ${value}. Expected value in range 0-${options.maxValue}`,
);
}
options.validate?.(value);
},
});
}
export function bigUIntBcsType<const Name extends string = string>({
readMethod,
writeMethod,
...options
}: {
name: Name;
size: number;
readMethod: `read${64 | 128 | 256}`;
writeMethod: `write${64 | 128 | 256}`;
maxValue: bigint;
} & BcsTypeOptions<string, string | number | bigint>) {
return fixedSizeBcsType<string, string | number | bigint, Name>({
...options,
read: (reader) => reader[readMethod](),
write: (value, writer) => writer[writeMethod](BigInt(value)),
validate: (val) => {
const value = BigInt(val);
if (value < 0 || value > options.maxValue) {
throw new TypeError(
`Invalid ${options.name} value: ${value}. Expected value in range 0-${options.maxValue}`,
);
}
options.validate?.(value);
},
});
}
export function dynamicSizeBcsType<T, Input = T, const Name extends string = string>({
serialize,
...options
}: {
name: Name;
read: (reader: BcsReader) => T;
serialize: (value: Input, options?: BcsWriterOptions) => Uint8Array<ArrayBuffer>;
} & BcsTypeOptions<T, Input>) {
const type = new BcsType<T, Input>({
...options,
serialize,
write: (value, writer) => {
for (const byte of type.serialize(value).toBytes()) {
writer.write8(byte);
}
},
});
return type;
}
export function stringLikeBcsType<const Name extends string = string>({
toBytes,
fromBytes,
...options
}: {
name: Name;
toBytes: (value: string) => Uint8Array;
fromBytes: (bytes: Uint8Array) => string;
serializedSize?: (value: string) => number | null;
} & BcsTypeOptions<string, string, Name>) {
return new BcsType<string, string, Name>({
...options,
read: (reader) => {
const length = reader.readULEB();
const bytes = reader.readBytes(length);
return fromBytes(bytes);
},
write: (hex, writer) => {
const bytes = toBytes(hex);
writer.writeULEB(bytes.length);
for (let i = 0; i < bytes.length; i++) {
writer.write8(bytes[i]);
}
},
serialize: (value) => {
const bytes = toBytes(value);
const size = ulebEncode(bytes.length);
const result = new Uint8Array(size.length + bytes.length);
result.set(size, 0);
result.set(bytes, size.length);
return result;
},
validate: (value) => {
if (typeof value !== 'string') {
throw new TypeError(`Invalid ${options.name} value: ${value}. Expected string`);
}
options.validate?.(value);
},
});
}
export function lazyBcsType<T, Input>(cb: () => BcsType<T, Input>) {
let lazyType: BcsType<T, Input> | null = null;
function getType() {
if (!lazyType) {
lazyType = cb();
}
return lazyType;
}
return new BcsType<T, Input>({
name: 'lazy' as never,
read: (data) => getType().read(data),
serializedSize: (value) => getType().serializedSize(value),
write: (value, writer) => getType().write(value, writer),
serialize: (value, options) => getType().serialize(value, options).toBytes(),
});
}
export interface BcsStructOptions<
T extends Record<string, BcsType<any>>,
Name extends string = string,
> extends Omit<
BcsTypeOptions<
{
[K in keyof T]: T[K] extends BcsType<infer U, any> ? U : never;
},
{
[K in keyof T]: T[K] extends BcsType<any, infer U> ? U : never;
},
Name
>,
'name'
> {
name: Name;
fields: T;
}
export class BcsStruct<
T extends Record<string, BcsType<any>>,
const Name extends string = string,
> extends BcsType<
{
[K in keyof T]: T[K] extends BcsType<infer U, any> ? U : never;
},
{
[K in keyof T]: T[K] extends BcsType<any, infer U> ? U : never;
},
Name
> {
constructor({ name, fields, ...options }: BcsStructOptions<T, Name>) {
const canonicalOrder = Object.entries(fields);
super({
name,
serializedSize: (values) => {
let total = 0;
for (const [field, type] of canonicalOrder) {
const size = type.serializedSize(values[field]);
if (size == null) {
return null;
}
total += size;
}
return total;
},
read: (reader) => {
const result: Record<string, unknown> = {};
for (const [field, type] of canonicalOrder) {
result[field] = type.read(reader);
}
return result as never;
},
write: (value, writer) => {
for (const [field, type] of canonicalOrder) {
type.write(value[field], writer);
}
},
...options,
validate: (value) => {
options?.validate?.(value);
if (typeof value !== 'object' || value == null) {
throw new TypeError(`Expected object, found ${typeof value}`);
}
},
});
}
}
export interface BcsEnumOptions<
T extends Record<string, BcsType<any> | null>,
Name extends string = string,
> extends Omit<
BcsTypeOptions<
EnumOutputShape<{
[K in keyof T]: T[K] extends BcsType<infer U, any, any> ? U : true;
}>,
EnumInputShape<{
[K in keyof T]: T[K] extends BcsType<any, infer U, any> ? U : boolean | object | null;
}>,
Name
>,
'name'
> {
name: Name;
fields: T;
}
export class BcsEnum<
T extends Record<string, BcsType<any> | null>,
const Name extends string = string,
> extends BcsType<
EnumOutputShape<{
[K in keyof T]: T[K] extends BcsType<infer U, any> ? U : true;
}>,
EnumInputShape<{
[K in keyof T]: T[K] extends BcsType<any, infer U, any> ? U : boolean | object | null;
}>,
Name
> {
constructor({ fields, ...options }: BcsEnumOptions<T, Name>) {
const canonicalOrder = Object.entries(fields as object);
super({
read: (reader) => {
const index = reader.readULEB();
const enumEntry = canonicalOrder[index];
if (!enumEntry) {
throw new TypeError(`Unknown value ${index} for enum ${options.name}`);
}
const [kind, type] = enumEntry;
return {
[kind]: type?.read(reader) ?? true,
$kind: kind,
} as never;
},
write: (value, writer) => {
const [name, val] = Object.entries(value).filter(([name]) =>
Object.hasOwn(fields, name),
)[0];
for (let i = 0; i < canonicalOrder.length; i++) {
const [optionName, optionType] = canonicalOrder[i];
if (optionName === name) {
writer.writeULEB(i);
optionType?.write(val, writer);
return;
}
}
},
...options,
validate: (value) => {
options?.validate?.(value);
if (typeof value !== 'object' || value == null) {
throw new TypeError(`Expected object, found ${typeof value}`);
}
const keys = Object.keys(value).filter(
(k) => value[k] !== undefined && Object.hasOwn(fields, k),
);
if (keys.length !== 1) {
throw new TypeError(
`Expected object with one key, but found ${keys.length} for type ${options.name}}`,
);
}
const [variant] = keys;
if (!Object.hasOwn(fields, variant)) {
throw new TypeError(`Invalid enum variant ${variant}`);
}
},
});
}
}
export interface BcsTupleOptions<T extends readonly BcsType<any>[], Name extends string>
extends Omit<
BcsTypeOptions<
{
-readonly [K in keyof T]: T[K] extends BcsType<infer T, any> ? T : never;
},
{
[K in keyof T]: T[K] extends BcsType<any, infer T> ? T : never;
},
Name
>,
'name'
> {
name?: Name;
fields: T;
}
export class BcsTuple<
const T extends readonly BcsType<any>[],
const Name extends
string = `(${JoinString<{ [K in keyof T]: T[K] extends BcsType<any, any, infer T> ? T : never }, ', '>})`,
> extends BcsType<
{
-readonly [K in keyof T]: T[K] extends BcsType<infer T, any> ? T : never;
},
{
[K in keyof T]: T[K] extends BcsType<any, infer T> ? T : never;
},
Name
> {
constructor({ fields, name, ...options }: BcsTupleOptions<T, Name>) {
super({
name: name ?? (`(${fields.map((t) => t.name).join(', ')})` as never),
serializedSize: (values) => {
let total = 0;
for (let i = 0; i < fields.length; i++) {
const size = fields[i].serializedSize(values[i]);
if (size == null) {
return null;
}
total += size;
}
return total;
},
read: (reader) => {
const result: unknown[] = [];
for (const field of fields) {
result.push(field.read(reader));
}
return result as never;
},
write: (value, writer) => {
for (let i = 0; i < fields.length; i++) {
fields[i].write(value[i], writer);
}
},
...options,
validate: (value) => {
options?.validate?.(value);
if (!Array.isArray(value)) {
throw new TypeError(`Expected array, found ${typeof value}`);
}
if (value.length !== fields.length) {
throw new TypeError(`Expected array of length ${fields.length}, found ${value.length}`);
}
},
});
}
}