From 78d2ec526453acc51437707b9fb9e38bcd3aafea Mon Sep 17 00:00:00 2001 From: Daniel Porteous Date: Fri, 11 Aug 2023 08:50:11 -0700 Subject: [PATCH] [TS SDK v2] Add AccountAddress --- .../sdk_v2/src/core/account_address.ts | 330 ++++++++++++++++++ ecosystem/typescript/sdk_v2/src/core/index.ts | 1 + .../sdk_v2/tests/unit/account_address.test.ts | 217 ++++++++++++ 3 files changed, 548 insertions(+) create mode 100644 ecosystem/typescript/sdk_v2/src/core/account_address.ts create mode 100644 ecosystem/typescript/sdk_v2/tests/unit/account_address.test.ts diff --git a/ecosystem/typescript/sdk_v2/src/core/account_address.ts b/ecosystem/typescript/sdk_v2/src/core/account_address.ts new file mode 100644 index 00000000000000..6430131e1205e9 --- /dev/null +++ b/ecosystem/typescript/sdk_v2/src/core/account_address.ts @@ -0,0 +1,330 @@ +// 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 an address was invalid. + */ +export enum AddressInvalidReason { + INCORRECT_NUMBER_OF_BYTES = "incorrect_number_of_bytes", + INVALID_HEX_CHARS = "invalid_hex_chars", + TOO_SHORT = "too_short", + TOO_LONG = "too_long", + LEADING_ZERO_X_REQUIRED = "leading_zero_x_required", + LONG_FORM_REQUIRED_UNLESS_SPECIAL = "long_form_required_unless_special", +} + +/** + * NOTE: Only use this class for account addresses. For other hex data, e.g. transaction + * hashes, use the Hex class. + * + * AccountAddress is used for working with account addresses. Account addresses, when + * represented as a string, generally look like these examples: + * - 0x1 + * - 0xaa86fe99004361f747f91342ca13c426ca0cccb0c1217677180c9493bad6ef0c + * + * Proper formatting and parsing of account addresses is defined by AIP-40. + * To learn more about the standard, read the AIP here: + * https://github.com/aptos-foundation/AIPs/blob/main/aips/aip-40.md. + * + * The comments in this class make frequent reference to the LONG and SHORT formats, + * as well as "special" addresses. To learn what these refer to see AIP-40. + */ +export class AccountAddress { + /* + * This is the internal representation of an account address. + */ + readonly data: Uint8Array; + + /* + * The number of bytes that make up an account address. + */ + static readonly LENGTH: number = 32; + + /* + * The length of an address string in LONG form without a leading 0x. + */ + static readonly LONG_STRING_LENGTH: number = 64; + + static ONE: AccountAddress = AccountAddress.fromString({ str: "0x1" }); + + static TWO: AccountAddress = AccountAddress.fromString({ str: "0x2" }); + + static THREE: AccountAddress = AccountAddress.fromString({ str: "0x3" }); + + static FOUR: AccountAddress = AccountAddress.fromString({ str: "0x4" }); + + /** + * Creates an instance of AccountAddress from a Uint8Array. + * + * @param args.data A Uint8Array representing an account address. + */ + constructor(args: { data: Uint8Array }) { + if (args.data.length !== AccountAddress.LENGTH) { + throw new ParsingError( + "AccountAddress data should be exactly 32 bytes long", + AddressInvalidReason.INCORRECT_NUMBER_OF_BYTES, + ); + } + this.data = args.data; + } + + /** + * Returns whether an address is special, where special is defined as 0x0 to 0xf + * inclusive. In other words, the last byte of the address must be < 0b10000 (16) + * and every other byte must be zero. + * + * For more information on how special addresses are defined see AIP-40: + * https://github.com/aptos-foundation/AIPs/blob/main/aips/aip-40.md. + * + * @returns true if the address is special, false if not. + */ + isSpecial(): boolean { + return ( + this.data.slice(0, this.data.length - 1).every((byte) => byte === 0) && this.data[this.data.length - 1] < 0b10000 + ); + } + + // === + // Methods for representing an instance of AccountAddress as other types. + // === + + /** + * Return the AccountAddress as a string as per AIP-40. + * https://github.com/aptos-foundation/AIPs/blob/main/aips/aip-40.md. + * + * In short, it means that special addresses are represented in SHORT form, meaning + * 0x0 through to 0xf inclusive, and every other address is represented in LONG form, + * meaning 0x + 64 hex characters. + * + * @returns AccountAddress as a string conforming to AIP-40. + */ + toString(): string { + return `0x${this.toStringWithoutPrefix()}`; + } + + /** + * NOTE: Prefer to use `toString` where possible. + * + * Return the AccountAddress as a string as per AIP-40 but without the leading 0x. + * + * Learn more by reading the docstring of `toString`. + * + * @returns AccountAddress as a string conforming to AIP-40 but without the leading 0x. + */ + toStringWithoutPrefix(): string { + let hex = bytesToHex(this.data); + if (this.isSpecial()) { + hex = hex[hex.length - 1]; + } + return hex; + } + + /** + * NOTE: Prefer to use `toString` where possible. + * + * Whereas toString will format special addresses (as defined by isSpecial) using the + * SHORT form (no leading 0s), this format the address in the LONG format + * unconditionally. + * + * This means it will be 0x + 64 hex characters. + * + * @returns AccountAddress as a string in LONG form. + */ + toStringLong(): string { + return `0x${this.toStringLongWithoutPrefix()}`; + } + + /* + * NOTE: Prefer to use `toString` where possible. + * + * Whereas toString will format special addresses (as defined by isSpecial) using the + * SHORT form (no leading 0s), this function will include leading zeroes. The string + * will not have a leading zero. + * + * This means it will be 64 hex characters without a leading 0x. + * + * @returns AccountAddress as a string in LONG form without a leading 0x. + */ + toStringLongWithoutPrefix(): string { + return bytesToHex(this.data); + } + + // === + // Methods for creating an instance of Hex from other types. + // === + + /** + * NOTE: This function has strict parsing behavior. For relaxed behavior, please use + * the `fromStringRelaxed` function. + * + * Creates an instance of AccountAddress from a hex string. + * + * This function allows only the strictest formats defined by AIP-40. In short this + * means only the following formats are accepted: + * + * - LONG + * - SHORT for special addresses + * + * Where: + * - LONG is defined as 0x + 64 hex characters. + * - SHORT for special addresses is 0x0 to 0xf inclusive. + * + * This means the following are not accepted: + * - SHORT for non-special addresses. + * - Any address without a leading 0x. + * + * Learn more about the different address formats by reading AIP-40: + * https://github.com/aptos-foundation/AIPs/blob/main/aips/aip-40.md. + * + * @param args.str A hex string representing an account address. + * + * @returns An instance of AccountAddress. + */ + static fromString(args: { str: string }): AccountAddress { + // Assert the string starts with 0x. + if (!args.str.startsWith("0x")) { + throw new ParsingError("Hex string must start with a leading 0x.", AddressInvalidReason.LEADING_ZERO_X_REQUIRED); + } + + const address = AccountAddress.fromStringRelaxed(args); + + // Assert that only special addresses can use short form. + if (args.str.slice(2).length !== this.LONG_STRING_LENGTH && !address.isSpecial()) { + throw new ParsingError( + "Hex string is not a special address, it must be represented as 0x + 64 chars.", + AddressInvalidReason.LONG_FORM_REQUIRED_UNLESS_SPECIAL, + ); + } + + return address; + } + + /** + * NOTE: This function has relaxed parsing behavior. For strict behavior, please use + * the `fromString` function. Where possible use `fromString` rather than this + * function, `fromStringRelaxed` is only provided for backwards compatibility. + * + * Creates an instance of AccountAddress from a hex string. + * + * This function allows all formats defined by AIP-40. In short this means the + * following formats are accepted: + * + * - LONG, with or without leading 0x + * - SHORT, with or without leading 0x + * + * Where: + * - LONG is 64 hex characters. + * - SHORT is 1 to 63 hex characters inclusive. + * + * Learn more about the different address formats by reading AIP-40: + * https://github.com/aptos-foundation/AIPs/blob/main/aips/aip-40.md. + * + * @param args.str A hex string representing an account address. + * + * @returns An instance of AccountAddress. + */ + static fromStringRelaxed(args: { str: string }): AccountAddress { + let input = args.str; + + // Remove leading 0x for parsing. + if (input.startsWith("0x")) { + input = input.slice(2); + } + + // Ensure the address string is at least 1 character long. + if (input.length === 0) { + throw new ParsingError( + "Hex string is too short, must be 1 to 64 chars long, excluding the leading 0x.", + AddressInvalidReason.TOO_SHORT, + ); + } + + // Ensure the address string is not longer than 64 characters. + if (input.length > 64) { + throw new ParsingError( + "Hex string is too long, must be 1 to 64 chars long, excluding the leading 0x.", + AddressInvalidReason.TOO_LONG, + ); + } + + let addressBytes: Uint8Array; + try { + // Pad the address with leading zeroes so it is 64 chars long and then convert + // the hex string to bytes. Every two characters in a hex string constitutes a + // single byte. So a 64 length hex string becomes a 32 byte array. + addressBytes = hexToBytes(input.padStart(64, "0")); + } catch (e) { + const error = e as Error; + // At this point the only way this can fail is if the hex string contains + // invalid characters. + throw new ParsingError(`Hex characters are invalid: ${error.message}`, AddressInvalidReason.INVALID_HEX_CHARS); + } + + return new AccountAddress({ data: addressBytes }); + } + + /** + * Convenience method for creating an AccountAddress from HexInput. For more + * more information on how this works, see the constructor and fromString. + * + * @param args.hexInput A hex string or Uint8Array representing an account address. + * + * @returns An instance of AccountAddress. + */ + static fromHexInput(args: { hexInput: HexInput }): AccountAddress { + if (args.hexInput instanceof Uint8Array) { + return new AccountAddress({ data: args.hexInput }); + } + return AccountAddress.fromString({ str: args.hexInput }); + } + + /** + * Convenience method for creating an AccountAddress from HexInput. For more + * more information on how this works, see the constructor and fromStringRelaxed. + * + * @param args.hexInput A hex string or Uint8Array representing an account address. + * + * @returns An instance of AccountAddress. + */ + static fromHexInputRelaxed(args: { hexInput: HexInput }): AccountAddress { + if (args.hexInput instanceof Uint8Array) { + return new AccountAddress({ data: args.hexInput }); + } + return AccountAddress.fromStringRelaxed({ str: args.hexInput }); + } + + // === + // Methods for checking validity. + // === + + /** + * Check if the string is a valid AccountAddress. + * + * @param str A hex string representing an account address. + * @param relaxed If true, use relaxed parsing behavior. If false, use strict parsing behavior. + * + * @returns valid = true if the string is valid, valid = false if not. If the string + * is not valid, invalidReason will be set explaining why it is invalid. + */ + static isValid(args: { str: string; relaxed?: boolean }): ParsingResult { + try { + if (args.relaxed) { + AccountAddress.fromStringRelaxed({ str: args.str }); + } else { + AccountAddress.fromString({ str: args.str }); + } + 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 index 070dded028b7b1..4328f607f82dae 100644 --- a/ecosystem/typescript/sdk_v2/src/core/index.ts +++ b/ecosystem/typescript/sdk_v2/src/core/index.ts @@ -1,5 +1,6 @@ // Copyright © Aptos Foundation // SPDX-License-Identifier: Apache-2.0 +export * from "./account_address"; export * from "./common"; export * from "./hex"; diff --git a/ecosystem/typescript/sdk_v2/tests/unit/account_address.test.ts b/ecosystem/typescript/sdk_v2/tests/unit/account_address.test.ts new file mode 100644 index 00000000000000..f0c5d478a6dc42 --- /dev/null +++ b/ecosystem/typescript/sdk_v2/tests/unit/account_address.test.ts @@ -0,0 +1,217 @@ +// Copyright © Aptos Foundation +// SPDX-License-Identifier: Apache-2.0 + +import { AccountAddress, AddressInvalidReason } from "../../src/core/account_address"; + +type Addresses = { + shortWith0x: string; + shortWithout0x: string; + longWith0x: string; + longWithout0x: string; +}; + +// Special addresses. + +const ADDRESS_ZERO: Addresses = { + shortWith0x: "0x0", + shortWithout0x: "0", + longWith0x: "0x0000000000000000000000000000000000000000000000000000000000000000", + longWithout0x: "0000000000000000000000000000000000000000000000000000000000000000", +}; + +const ADDRESS_ONE: Addresses = { + shortWith0x: "0x1", + shortWithout0x: "1", + longWith0x: "0x0000000000000000000000000000000000000000000000000000000000000001", + longWithout0x: "0000000000000000000000000000000000000000000000000000000000000001", +}; + +const ADDRESS_F: Addresses = { + shortWith0x: "0xf", + shortWithout0x: "f", + longWith0x: "0x000000000000000000000000000000000000000000000000000000000000000f", + longWithout0x: "000000000000000000000000000000000000000000000000000000000000000f", +}; + +// Non-special addresses. + +const ADDRESS_TEN: Addresses = { + shortWith0x: "0x10", + shortWithout0x: "10", + longWith0x: "0x0000000000000000000000000000000000000000000000000000000000000010", + longWithout0x: "0000000000000000000000000000000000000000000000000000000000000010", +}; + +const ADDRESS_OTHER: Addresses = { + shortWith0x: "0xca843279e3427144cead5e4d5999a3d0ca843279e3427144cead5e4d5999a3d0", + shortWithout0x: "ca843279e3427144cead5e4d5999a3d0ca843279e3427144cead5e4d5999a3d0", + // These are the same as the short variants. + longWith0x: "0xca843279e3427144cead5e4d5999a3d0ca843279e3427144cead5e4d5999a3d0", + longWithout0x: "ca843279e3427144cead5e4d5999a3d0ca843279e3427144cead5e4d5999a3d0", +}; + +// These tests show that fromStringRelaxed works happily parses all formats. +describe("AccountAddress fromStringRelaxed", () => { + it("parses special address: 0x0", async () => { + expect(AccountAddress.fromStringRelaxed({ str: ADDRESS_ZERO.longWith0x }).toString()).toBe( + ADDRESS_ZERO.shortWith0x, + ); + expect(AccountAddress.fromStringRelaxed({ str: ADDRESS_ZERO.longWithout0x }).toString()).toBe( + ADDRESS_ZERO.shortWith0x, + ); + expect(AccountAddress.fromStringRelaxed({ str: ADDRESS_ZERO.shortWith0x }).toString()).toBe( + ADDRESS_ZERO.shortWith0x, + ); + expect(AccountAddress.fromStringRelaxed({ str: ADDRESS_ZERO.shortWithout0x }).toString()).toBe( + ADDRESS_ZERO.shortWith0x, + ); + }); + + it("parses special address: 0x1", async () => { + expect(AccountAddress.fromStringRelaxed({ str: ADDRESS_ONE.longWith0x }).toString()).toBe(ADDRESS_ONE.shortWith0x); + expect(AccountAddress.fromStringRelaxed({ str: ADDRESS_ONE.longWithout0x }).toString()).toBe( + ADDRESS_ONE.shortWith0x, + ); + expect(AccountAddress.fromStringRelaxed({ str: ADDRESS_ONE.shortWith0x }).toString()).toBe(ADDRESS_ONE.shortWith0x); + expect(AccountAddress.fromStringRelaxed({ str: ADDRESS_ONE.shortWithout0x }).toString()).toBe( + ADDRESS_ONE.shortWith0x, + ); + }); + + it("parses special address: 0xf", async () => { + expect(AccountAddress.fromStringRelaxed({ str: ADDRESS_F.longWith0x }).toString()).toBe(ADDRESS_F.shortWith0x); + expect(AccountAddress.fromStringRelaxed({ str: ADDRESS_F.longWithout0x }).toString()).toBe(ADDRESS_F.shortWith0x); + expect(AccountAddress.fromStringRelaxed({ str: ADDRESS_F.shortWith0x }).toString()).toBe(ADDRESS_F.shortWith0x); + expect(AccountAddress.fromStringRelaxed({ str: ADDRESS_F.shortWithout0x }).toString()).toBe(ADDRESS_F.shortWith0x); + }); + + it("parses non-special address: 0x10", async () => { + expect(AccountAddress.fromStringRelaxed({ str: ADDRESS_TEN.longWith0x }).toString()).toBe(ADDRESS_TEN.longWith0x); + expect(AccountAddress.fromStringRelaxed({ str: ADDRESS_TEN.longWithout0x }).toString()).toBe( + ADDRESS_TEN.longWith0x, + ); + expect(AccountAddress.fromStringRelaxed({ str: ADDRESS_TEN.shortWith0x }).toString()).toBe(ADDRESS_TEN.longWith0x); + expect(AccountAddress.fromStringRelaxed({ str: ADDRESS_TEN.shortWithout0x }).toString()).toBe( + ADDRESS_TEN.longWith0x, + ); + }); + + it("parses non-special address: 0xca843279e3427144cead5e4d5999a3d0ca843279e3427144cead5e4d5999a3d0", async () => { + expect(AccountAddress.fromStringRelaxed({ str: ADDRESS_OTHER.longWith0x }).toString()).toBe( + ADDRESS_OTHER.longWith0x, + ); + expect(AccountAddress.fromStringRelaxed({ str: ADDRESS_OTHER.longWithout0x }).toString()).toBe( + ADDRESS_OTHER.longWith0x, + ); + }); +}); + +// These tests show that fromString only parses addresses with a leading 0x and only +// SHORT if it is a special address. +describe("AccountAddress fromString", () => { + it("parses special address: 0x0", async () => { + expect(AccountAddress.fromString({ str: ADDRESS_ZERO.longWith0x }).toString()).toBe(ADDRESS_ZERO.shortWith0x); + expect(() => AccountAddress.fromString({ str: ADDRESS_ZERO.longWithout0x })).toThrow(); + expect(AccountAddress.fromString({ str: ADDRESS_ZERO.shortWith0x }).toString()).toBe(ADDRESS_ZERO.shortWith0x); + expect(() => AccountAddress.fromString({ str: ADDRESS_ZERO.shortWithout0x })).toThrow(); + }); + + it("parses special address: 0x1", async () => { + expect(AccountAddress.fromString({ str: ADDRESS_ONE.longWith0x }).toString()).toBe(ADDRESS_ONE.shortWith0x); + expect(() => AccountAddress.fromString({ str: ADDRESS_ONE.longWithout0x })).toThrow(); + expect(AccountAddress.fromString({ str: ADDRESS_ONE.shortWith0x }).toString()).toBe(ADDRESS_ONE.shortWith0x); + expect(() => AccountAddress.fromString({ str: ADDRESS_ONE.shortWithout0x })).toThrow(); + }); + + it("parses special address: 0xf", async () => { + expect(AccountAddress.fromString({ str: ADDRESS_F.longWith0x }).toString()).toBe(ADDRESS_F.shortWith0x); + expect(() => AccountAddress.fromString({ str: ADDRESS_F.longWithout0x })).toThrow(); + expect(AccountAddress.fromString({ str: ADDRESS_F.shortWith0x }).toString()).toBe(ADDRESS_F.shortWith0x); + expect(() => AccountAddress.fromString({ str: ADDRESS_F.shortWithout0x })).toThrow(); + }); + + it("parses non-special address: 0x10", async () => { + expect(AccountAddress.fromString({ str: ADDRESS_TEN.longWith0x }).toString()).toBe(ADDRESS_TEN.longWith0x); + expect(() => AccountAddress.fromString({ str: ADDRESS_TEN.longWithout0x })).toThrow(); + expect(() => AccountAddress.fromString({ str: ADDRESS_TEN.shortWith0x })).toThrow(); + expect(() => AccountAddress.fromString({ str: ADDRESS_TEN.shortWithout0x })).toThrow(); + }); + + it("parses non-special address: 0xca843279e3427144cead5e4d5999a3d0ca843279e3427144cead5e4d5999a3d0", async () => { + expect(AccountAddress.fromString({ str: ADDRESS_OTHER.longWith0x }).toString()).toBe(ADDRESS_OTHER.longWith0x); + expect(() => AccountAddress.fromString({ str: ADDRESS_OTHER.longWithout0x })).toThrow(); + }); +}); + +describe("AccountAddress toStringWithoutPrefix", () => { + it("formats special address correctly: 0x0", async () => { + const addr = AccountAddress.fromString({ str: ADDRESS_ZERO.shortWith0x }); + expect(addr.toStringWithoutPrefix()).toBe(ADDRESS_ZERO.shortWithout0x); + }); + + it("formats non-special address correctly: 0x10", async () => { + const addr = AccountAddress.fromString({ str: ADDRESS_TEN.longWith0x }); + expect(addr.toStringWithoutPrefix()).toBe(ADDRESS_TEN.longWithout0x); + }); +}); + +describe("AccountAddress toStringLong", () => { + it("formats special address correctly: 0x0", async () => { + const addr = AccountAddress.fromString({ str: ADDRESS_ZERO.shortWith0x }); + expect(addr.toStringLong()).toBe(ADDRESS_ZERO.longWith0x); + }); + + it("formats non-special address correctly: 0x10", async () => { + const addr = AccountAddress.fromString({ str: ADDRESS_TEN.longWith0x }); + expect(addr.toStringLong()).toBe(ADDRESS_TEN.longWith0x); + }); +}); + +describe("AccountAddress toStringLongWithoutPrefix", () => { + it("formats special address correctly: 0x0", async () => { + const addr = AccountAddress.fromString({ str: ADDRESS_ZERO.shortWith0x }); + expect(addr.toStringLongWithoutPrefix()).toBe(ADDRESS_ZERO.longWithout0x); + }); + + it("formats non-special address correctly: 0x10", async () => { + const addr = AccountAddress.fromString({ str: ADDRESS_TEN.longWith0x }); + expect(addr.toStringLongWithoutPrefix()).toBe(ADDRESS_TEN.longWithout0x); + }); +}); + +describe("AccountAddress other parsing", () => { + it("throws exception when initiating from too long hex string", async () => { + expect(() => { + AccountAddress.fromString({ str: `${ADDRESS_ONE.longWith0x}1` }); + }).toThrow("Hex string is too long, must be 1 to 64 chars long, excluding the leading 0x."); + }); + + test("throws when parsing invalid hex char", () => { + expect(() => AccountAddress.fromString({ str: "0xxyz" })).toThrow(); + }); + + test("throws when parsing hex of length zero", () => { + expect(() => AccountAddress.fromString({ str: "0x" })).toThrow(); + expect(() => AccountAddress.fromString({ str: "" })).toThrow(); + }); + + test("throws when parsing invalid prefix", () => { + expect(() => AccountAddress.fromString({ str: "0za" })).toThrow(); + }); + + it("isValid is false if too long with 0xf", async () => { + const { valid, invalidReason, invalidReasonMessage } = AccountAddress.isValid({ + str: `0x00${ADDRESS_F.longWithout0x}`, + }); + expect(valid).toBe(false); + expect(invalidReason).toBe(AddressInvalidReason.TOO_LONG); + expect(invalidReasonMessage).toBe("Hex string is too long, must be 1 to 64 chars long, excluding the leading 0x."); + }); + + it("isValid is true if account address string is valid", async () => { + const { valid, invalidReason, invalidReasonMessage } = AccountAddress.isValid({ str: ADDRESS_F.longWith0x }); + expect(valid).toBe(true); + expect(invalidReason).toBeUndefined(); + expect(invalidReasonMessage).toBeUndefined(); + }); +});