diff --git a/ecosystem/typescript/sdk_v2/package.json b/ecosystem/typescript/sdk_v2/package.json index 59b9a1144066c..9e621a3cfe851 100644 --- a/ecosystem/typescript/sdk_v2/package.json +++ b/ecosystem/typescript/sdk_v2/package.json @@ -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" }, diff --git a/ecosystem/typescript/sdk_v2/src/core/common.ts b/ecosystem/typescript/sdk_v2/src/core/common.ts new file mode 100644 index 0000000000000..19e1e3b68db80 --- /dev/null +++ b/ecosystem/typescript/sdk_v2/src/core/common.ts @@ -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 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 = { + /** + * 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; +}; diff --git a/ecosystem/typescript/sdk_v2/src/core/hex.ts b/ecosystem/typescript/sdk_v2/src/core/hex.ts new file mode 100644 index 0000000000000..2e0afaf6ad2d2 --- /dev/null +++ b/ecosystem/typescript/sdk_v2/src/core/hex.ts @@ -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 { + * 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 { + try { + Hex.fromString(args); + return { valid: true }; + } catch (e) { + const error = e as ParsingError; + return { + valid: false, + invalidReason: error.invalidReason, + invalidReasonMessage: error.message, + }; + } + } +} diff --git a/ecosystem/typescript/sdk_v2/src/core/index.ts b/ecosystem/typescript/sdk_v2/src/core/index.ts new file mode 100644 index 0000000000000..070dded028b7b --- /dev/null +++ b/ecosystem/typescript/sdk_v2/src/core/index.ts @@ -0,0 +1,5 @@ +// Copyright © Aptos Foundation +// SPDX-License-Identifier: Apache-2.0 + +export * from "./common"; +export * from "./hex"; diff --git a/ecosystem/typescript/sdk_v2/src/types/index.ts b/ecosystem/typescript/sdk_v2/src/types/index.ts index 9b7e79f0a6e24..f1704bfd23d61 100644 --- a/ecosystem/typescript/sdk_v2/src/types/index.ts +++ b/ecosystem/typescript/sdk_v2/src/types/index.ts @@ -1 +1,2 @@ export type AnyNumber = number | bigint; +export type HexInput = string | Uint8Array; diff --git a/ecosystem/typescript/sdk_v2/tests/unit/hex.test.ts b/ecosystem/typescript/sdk_v2/tests/unit/hex.test.ts new file mode 100644 index 0000000000000..75c6b9af06bd9 --- /dev/null +++ b/ecosystem/typescript/sdk_v2/tests/unit/hex.test.ts @@ -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."); +});