Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(deser-lib): add serialization/deserialization module #4095

Merged
merged 16 commits into from
Jan 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
/modules/sdk-api/ @BitGo/custody-experience @BitGo/wallet-platform
/modules/sdk-core/ @BitGo/wallet-platform @BitGo/hsm
/modules/sdk-lib-mpc/ @BitGo/wallet-platform @BitGo/hsm
/modules/deser-lib/ @BitGo/wallet-platform @BitGo/hsm
/modules/sdk-rpc-wrapper @BitGo/ethalt-team
/modules/sdk-test/ @BitGo/custody-experience @BitGo/wallet-platform
/modules/sdk-unified-wallet @BitGo/ethalt-team
Expand Down
5 changes: 5 additions & 0 deletions modules/deser-lib/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
.idea
public
dist

4 changes: 4 additions & 0 deletions modules/deser-lib/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
.idea/
dist/
yarn-error.log
9 changes: 9 additions & 0 deletions modules/deser-lib/.mocharc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"require": ["ts-node/register", "should"],
"timeout": "20000",
"reporter": "min",
"reporter-option": ["cdn=true", "json=false"],
"exit": true,
"spec": ["test/unit/**/*.ts"],
"extension": [".js", ".ts"]
}
2 changes: 2 additions & 0 deletions modules/deser-lib/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.nyc_output/
dist/
3 changes: 3 additions & 0 deletions modules/deser-lib/.prettierrc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
printWidth: 120
singleQuote: true
trailingComma: 'es5'
3 changes: 3 additions & 0 deletions modules/deser-lib/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Deser Lib

This library will be used to centralize all the serialization and de-serialization schemes used in the bitgojs modules.
45 changes: 45 additions & 0 deletions modules/deser-lib/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"name": "@bitgo/deser-lib",
"version": "1.0.0",
"description": "BitGo serialization and deseralization library",
"main": "./dist/src/index.js",
zahin-mohammad marked this conversation as resolved.
Show resolved Hide resolved
"types": "./dist/src/index.d.ts",
"scripts": {
"test": "yarn unit-test",
"unit-test": "nyc -- mocha --recursive test",
"build": "yarn tsc --build --incremental --verbose .",
"fmt": "prettier --write .",
"check-fmt": "prettier --check .",
"clean": "rm -r ./dist",
"lint": "eslint --quiet .",
"prepare": "npm run build"
},
"repository": {
"type": "git",
"url": "git+https://github.com/BitGo/BitGoJS.git",
"directory": "modules/deser-lib"
},
"author": "BitGo SDK Team <[email protected]>",
"license": "MIT",
"bugs": {
"url": "https://github.com/bitgo/bitgojs/issues"
},
"homepage": "https://github.com/bitgo/bitgojs#readme",
"nyc": {
"extension": [
".ts"
]
},
"lint-staged": {
"*.{js,ts}": [
"yarn prettier --write",
"yarn eslint --fix"
]
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"cbor": "^9.0.1"
}
}
216 changes: 216 additions & 0 deletions modules/deser-lib/src/cbor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { decodeFirstSync, encodeCanonical } from 'cbor';

/**
* Return a string describing value as a type.
* @param value - Any javascript value to type.
* @returns String describing value type.
*/
function getType(value: unknown): string {
if (value === null || value === undefined) {
return 'null';
}
if (value instanceof Array) {
zahin-mohammad marked this conversation as resolved.
Show resolved Hide resolved
const types = value.map(getType);
if (!types.slice(1).every((value) => value === types[0])) {
throw new Error('Array elements are not of the same type');
}
return JSON.stringify([types[0]]);
}
if (value instanceof Object) {
const properties = Object.getOwnPropertyNames(value);
properties.sort();
return JSON.stringify(
properties.reduce((acc, name) => {
acc[name] = getType(value[name]);
return acc;
}, {})
);
}
if (typeof value === 'string') {
if (value.startsWith('0x')) {
return 'bytes';
}
return 'string';
}
return JSON.stringify(typeof value);
}

/**
* Compare two buffers for sorting.
* @param a - left buffer to compare to right buffer.
* @param b - right buffer to compare to left buffer.
* @returns Negative if a < b, positive if b > a, 0 if equal.
*/
function bufferCompare(a: Buffer, b: Buffer): number {
let i = 0;
while (i < a.length && i < b.length && a[i] == b[i]) {
i++;
}
if (i === a.length && i === b.length) {
return 0;
}
if (i === a.length || i === b.length) {
return a.length - b.length;
}
return a[i] - b[i];
}

/** A sortable array element. */
type Sortable = {
weight: number;
value?: unknown;
};

/**
* Type check for sortable array element.
* @param value - Value to type check.
* @returns True if value is a sortable array element.
*/
function isSortable(value: unknown): value is Sortable {
return value instanceof Object && 'weight' in value && typeof (value as Sortable).weight === 'number';
}

/**
* Convert number to base 256 and return as a big-endian Buffer.
* @param value - Value to convert.
* @returns Buffer representation of the number.
*/
function numberToBufferBE(value: number): Buffer {
// Normalize value so that negative numbers aren't compared higher
// than positive numbers when accounting for two's complement.
value += Math.pow(2, 52);
const byteCount = Math.floor((value.toString(2).length + 7) / 8);
const buffer = Buffer.alloc(byteCount);
let i = 0;
while (value) {
buffer[i++] = value % 256;
value = Math.floor(value / 256);
}
return buffer.reverse();
}

/**
* Compare two array elements for sorting.
* @param a - left element to compare to right element.
* @param b - right element to compare to left element.
* @returns Negative if a < b, positive if b > a, 0 if equal.
*/
function elementCompare(a: unknown, b: unknown): number {
if (!isSortable(a) || !isSortable(b)) {
throw new Error('Array elements must be sortable');
}
if (a.weight === b.weight) {
if (a.value === undefined && b.value === undefined) {
throw new Error('Array elements must be sortable');
}
const aVal = transform(a.value);
const bVal = transform(b.value);
if (
(!Buffer.isBuffer(aVal) && typeof aVal !== 'string' && typeof aVal !== 'number') ||
(!Buffer.isBuffer(bVal) && typeof bVal !== 'string' && typeof bVal !== 'number')
) {
throw new Error('Array element value cannot be compared');
}
let aBuf, bBuf;
if (typeof aVal === 'number') {
aBuf = numberToBufferBE(aVal);
} else {
aBuf = Buffer.from(aVal);
}
if (typeof bVal === 'number') {
bBuf = numberToBufferBE(bVal);
} else {
bBuf = Buffer.from(bVal);
}
return bufferCompare(aBuf, bBuf);
}
return a.weight - b.weight;
}

/**
* Transform value into its canonical, serializable form.
* @param value - Value to transform.
* @returns Canonical, serializable form of value.
*/
export function transform<T>(value: T): T | Buffer {
if (value === null || value === undefined) {
return value;
}
if (typeof value === 'string') {
// Transform hex strings to buffers.
if (value.startsWith('0x')) {
if (!value.match(/^0x([0-9a-fA-F]{2})*$/)) {
throw new Error('0x prefixed string contains non-hex characters.');
}
return Buffer.from(value.slice(2), 'hex');
} else if (value.startsWith('\\0x')) {
return value.slice(1) as unknown as T;
}
} else if (value instanceof Array) {
// Enforce array elements are same type.
getType(value);
johnoliverdriscoll marked this conversation as resolved.
Show resolved Hide resolved
value = [...value] as unknown as T;
(value as unknown as Array<unknown>).sort(elementCompare);
return (value as unknown as Array<unknown>).map(transform) as unknown as T;
} else if (value instanceof Object) {
const properties = Object.getOwnPropertyNames(value);
properties.sort();
return properties.reduce((acc, name) => {
acc[name] = transform(value[name]);
return acc;
}, {}) as unknown as T;
}
return value;
}

/**
* Untransform value into its human readable form.
* @param value - Value to untransform.
* @returns Untransformed, human readable form of value.
*/
export function untransform<T>(value: T): T | string {
if (Buffer.isBuffer(value)) {
return '0x' + value.toString('hex');
} else if (typeof value === 'string') {
if (value.startsWith('0x')) {
return '\\' + value;
}
} else if (value instanceof Array && value.length > 1) {
for (let i = 1; i < value.length; i++) {
if (value[i - 1].weight > value[i].weight) {
throw new Error('Array elements are not in canonical order');
}
}
return value.map(untransform) as unknown as T;
} else if (value instanceof Object) {
const properties = Object.getOwnPropertyNames(value);
for (let i = 1; i < properties.length; i++) {
if (properties[i - 1].localeCompare(properties[i]) > 0) {
throw new Error('Object properties are not in caonical order');
}
}
return properties.reduce((acc, name) => {
acc[name] = untransform(value[name]);
return acc;
}, {}) as unknown as T;
}
return value;
}

/**
* Serialize a value.
* @param value - Value to serialize.
* @returns Buffer representing serialized value.
*/
export function serialize<T>(value: T): Buffer {
return encodeCanonical(transform(value));
}

/**
* Deserialize a value.
* @param value - Buffer to deserialize.
* @returns Deserialized value.
*/
export function deserialize(value: Buffer): unknown {
return untransform(decodeFirstSync(value));
}
1 change: 1 addition & 0 deletions modules/deser-lib/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * as Cbor from './cbor';
Loading
Loading