Skip to content

Commit

Permalink
[TS SDK v2] Add Hex and HexInput types (#9595)
Browse files Browse the repository at this point in the history
Co-authored-by: maayan <[email protected]>
  • Loading branch information
2 people authored and xbtmatt committed Aug 13, 2023
1 parent 76d3deb commit 27955f5
Show file tree
Hide file tree
Showing 6 changed files with 305 additions and 0 deletions.
2 changes: 2 additions & 0 deletions ecosystem/typescript/sdk_v2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
"_build:cjs": "tsup src/index.ts --format cjs --dts --out-dir dist/cjs",
"_build:types": "tsup src/types/index.ts --dts --out-dir dist/types",
"generate-openapi-response-types": "openapi -i ../../../../api/doc/spec.yaml -o ./src/types/generated --exportCore=false --exportServices=false",
"_fmt": "prettier 'src/**/*.ts' 'tests/**/*.ts' '.eslintrc.js'",
"fmt": "pnpm _fmt --write",
"lint": "eslint \"**/*.ts\"",
"test": "pnpm jest"
},
Expand Down
40 changes: 40 additions & 0 deletions ecosystem/typescript/sdk_v2/src/core/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright © Aptos Foundation
// SPDX-License-Identifier: Apache-2.0

/**
* This error is used to explain why parsing failed.
*/
export class ParsingError<T> extends Error {
/**
* This provides a programmatic way to access why parsing failed. Downstream devs
* might want to use this to build their own error messages if the default error
* messages are not suitable for their use case. This should be an enum.
*/
public invalidReason: T;

constructor(message: string, invalidReason: T) {
super(message);
this.invalidReason = invalidReason;
}
}

/**
* Whereas ParsingError is thrown when parsing fails, e.g. in a fromString function,
* this type is returned from "defensive" functions like isValid.
*/
export type ParsingResult<T> = {
/**
* True if valid, false otherwise.
*/
valid: boolean;

/*
* If valid is false, this will be a code explaining why parsing failed.
*/
invalidReason?: T;

/*
* If valid is false, this will be a string explaining why parsing failed.
*/
invalidReasonMessage?: string;
};
165 changes: 165 additions & 0 deletions ecosystem/typescript/sdk_v2/src/core/hex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Copyright © Aptos Foundation
// SPDX-License-Identifier: Apache-2.0

import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
import { HexInput } from "../types";
import { ParsingError, ParsingResult } from "./common";

/**
* This enum is used to explain why parsing might have failed.
*/
export enum HexInvalidReason {
TOO_SHORT = "too_short",
INVALID_LENGTH = "invalid_length",
INVALID_HEX_CHARS = "invalid_hex_chars",
}

/**
* NOTE: Do not use this class when working with account addresses, use AccountAddress.
*
* NOTE: When accepting hex data as input to a function, prefer to accept HexInput and
* then use the static helper methods of this class to convert it into the desired
* format. This enables the greatest flexibility for the developer.
*
* Hex is a helper class for working with hex data. Hex data, when represented as a
* string, generally looks like this, for example: 0xaabbcc, 45cd32, etc.
*
* You might use this class like this:
*
* ```ts
* getTransactionByHash(txnHash: HexInput): Promise<Transaction> {
* const txnHashString = Hex.fromHexInput({ hexInput: txnHash }).toString();
* return await getTransactionByHashInner(txnHashString);
* }
* ```
*
* This call to `Hex.fromHexInput().toString()` converts the HexInput to a hex string
* with a leading 0x prefix, regardless of what the input format was.
*
* These are some other ways to chain the functions together:
* - `Hex.fromString({ hexInput: "0x1f" }).toUint8Array()`
* - `new Hex({ data: [1, 3] }).toStringWithoutPrefix()`
*/
export class Hex {
private data: Uint8Array;

/**
* Create a new Hex instance from a Uint8Array.
*
* @param hex Uint8Array
*/
constructor(args: { data: Uint8Array }) {
this.data = args.data;
}

// ===
// Methods for representing an instance of Hex as other types.
// ===

/**
* Get the inner hex data. The inner data is already a Uint8Array so no conversion
* is taking place here, it just returns the inner data.
*
* @returns Hex data as Uint8Array
*/
toUint8Array(): Uint8Array {
return this.data;
}

/**
* Get the hex data as a string without the 0x prefix.
*
* @returns Hex string without 0x prefix
*/
toStringWithoutPrefix(): string {
return bytesToHex(this.data);
}

/**
* Get the hex data as a string with the 0x prefix.
*
* @returns Hex string with 0x prefix
*/
toString(): string {
return `0x${this.toStringWithoutPrefix()}`;
}

// ===
// Methods for creating an instance of Hex from other types.
// ===

/**
* Static method to convert a hex string to Hex
*
* @param str A hex string, with or without the 0x prefix
*
* @returns Hex
*/
static fromString(args: { str: string }): Hex {
let input = args.str;

if (input.startsWith("0x")) {
input = input.slice(2);
}

if (input.length === 0) {
throw new ParsingError(
"Hex string is too short, must be at least 1 char long, excluding the optional leading 0x.",
HexInvalidReason.TOO_SHORT,
);
}

if (input.length % 2 !== 0) {
throw new ParsingError("Hex string must be an even number of hex characters.", HexInvalidReason.INVALID_LENGTH);
}

try {
return new Hex({ data: hexToBytes(input) });
} catch (e) {
const error = e as Error;
throw new ParsingError(
`Hex string contains invalid hex characters: ${error.message}`,
HexInvalidReason.INVALID_HEX_CHARS,
);
}
}

/**
* Static method to convert an instance of HexInput to Hex
*
* @param str A HexInput (string or Uint8Array)
*
* @returns Hex
*/
static fromHexInput(args: { hexInput: HexInput }): Hex {
if (args.hexInput instanceof Uint8Array) return new Hex({ data: args.hexInput });
return Hex.fromString({ str: args.hexInput });
}

// ===
// Methods for checking validity.
// ===

/**
* Check if the string is valid hex.
*
* @param str A hex string representing byte data.
*
* @returns valid = true if the string is valid, false if not. If the string is not
* valid, invalidReason and invalidReasonMessage will be set explaining why it is
* invalid.
*/
static isValid(args: { str: string }): ParsingResult<HexInvalidReason> {
try {
Hex.fromString(args);
return { valid: true };
} catch (e) {
const error = e as ParsingError<HexInvalidReason>;
return {
valid: false,
invalidReason: error.invalidReason,
invalidReasonMessage: error.message,
};
}
}
}
5 changes: 5 additions & 0 deletions ecosystem/typescript/sdk_v2/src/core/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Copyright © Aptos Foundation
// SPDX-License-Identifier: Apache-2.0

export * from "./common";
export * from "./hex";
1 change: 1 addition & 0 deletions ecosystem/typescript/sdk_v2/src/types/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export type AnyNumber = number | bigint;
export type HexInput = string | Uint8Array;
92 changes: 92 additions & 0 deletions ecosystem/typescript/sdk_v2/tests/unit/hex.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { ParsingError } from "../../src/core";
import { Hex, HexInvalidReason } from "../../src/core/hex";

const mockHex = {
withoutPrefix: "007711b4d0",
withPrefix: "0x007711b4d0",
bytes: new Uint8Array([0, 119, 17, 180, 208]),
};

test("creates a new Hex instance from bytes", () => {
const hex = new Hex({ data: mockHex.bytes });
expect(hex.toUint8Array()).toEqual(mockHex.bytes);
});

test("creates a new Hex instance from string", () => {
const hex = new Hex({ data: mockHex.bytes });
expect(hex.toString()).toEqual(mockHex.withPrefix);
});

test("converts hex bytes input into hex data", () => {
const hex = new Hex({ data: mockHex.bytes });
expect(hex instanceof Hex).toBeTruthy();
expect(hex.toUint8Array()).toEqual(mockHex.bytes);
});

test("converts hex string input into hex data", () => {
const hex = Hex.fromString({ str: mockHex.withPrefix });
expect(hex instanceof Hex).toBeTruthy();
expect(hex.toUint8Array()).toEqual(mockHex.bytes);
});

test("accepts hex string input without prefix", () => {
const hex = Hex.fromString({ str: mockHex.withoutPrefix });
expect(hex instanceof Hex).toBeTruthy();
expect(hex.toUint8Array()).toEqual(mockHex.bytes);
});

test("accepts hex string with prefix", () => {
const hex = Hex.fromString({ str: mockHex.withPrefix });
expect(hex instanceof Hex).toBeTruthy();
expect(hex.toUint8Array()).toEqual(mockHex.bytes);
});

test("converts hex string to bytes", () => {
const hex = Hex.fromHexInput({ hexInput: mockHex.withPrefix }).toUint8Array();
expect(hex instanceof Uint8Array).toBeTruthy();
expect(hex).toEqual(mockHex.bytes);
});

test("converts hex bytes to string", () => {
const hex = Hex.fromHexInput({ hexInput: mockHex.bytes }).toString();
expect(typeof hex).toEqual("string");
expect(hex).toEqual(mockHex.withPrefix);
});

test("converts hex bytes to string without 0x prefix", () => {
const hex = Hex.fromHexInput({ hexInput: mockHex.withPrefix }).toStringWithoutPrefix();
expect(hex).toEqual(mockHex.withoutPrefix);
});

test("throws when parsing invalid hex char", () => {
expect(() => Hex.fromString({ str: "0xzyzz" })).toThrow(
"Hex string contains invalid hex characters: Invalid byte sequence",
);
});

test("throws when parsing hex of length zero", () => {
expect(() => Hex.fromString({ str: "0x" })).toThrow(
"Hex string is too short, must be at least 1 char long, excluding the optional leading 0x.",
);
expect(() => Hex.fromString({ str: "" })).toThrow(
"Hex string is too short, must be at least 1 char long, excluding the optional leading 0x.",
);
});

test("throws when parsing hex of invalid length", () => {
expect(() => Hex.fromString({ str: "0x1" })).toThrow("Hex string must be an even number of hex characters.");
});

test("isValid returns true when parsing valid string", () => {
const result = Hex.isValid({ str: "0x11aabb" });
expect(result.valid).toBe(true);
expect(result.invalidReason).toBeUndefined();
expect(result.invalidReasonMessage).toBeUndefined();
});

test("isValid returns false when parsing hex of invalid length", () => {
const result = Hex.isValid({ str: "0xa" });
expect(result.valid).toBe(false);
expect(result.invalidReason).toBe(HexInvalidReason.INVALID_LENGTH);
expect(result.invalidReasonMessage).toBe("Hex string must be an even number of hex characters.");
});

0 comments on commit 27955f5

Please sign in to comment.