@toruslabs/metadata-helpers
Version:
Helper methods for metadata
390 lines (364 loc) • 13.4 kB
JavaScript
import { base64 } from '@scure/base';
import { assert } from './assert.js';
import { add0x, assertIsHexString, remove0x } from './hex.js';
// '0'.charCodeAt(0) === 48
const HEX_MINIMUM_NUMBER_CHARACTER = 48;
// '9'.charCodeAt(0) === 57
const HEX_MAXIMUM_NUMBER_CHARACTER = 58;
const HEX_CHARACTER_OFFSET = 87;
/**
* Memoized function that returns an array to be used as a lookup table for
* converting bytes to hexadecimal values.
*
* The array is created lazily and then cached for future use. The benefit of
* this approach is that the performance of converting bytes to hex is much
* better than if we were to call `toString(16)` on each byte.
*
* The downside is that the array is created once and then never garbage
* collected. This is not a problem in practice because the array is only 256
* elements long.
*
* @returns A function that returns the lookup table.
*/
function getPrecomputedHexValuesBuilder() {
// To avoid issues with tree shaking, we need to use a function to return the
// array. This is because the array is only used in the `bytesToHexPrefixedString` function
// and if we were to use a global variable, the array might be removed by the
// tree shaker.
const lookupTable = [];
return () => {
if (lookupTable.length === 0) {
for (let i = 0; i < 256; i++) {
lookupTable.push(i.toString(16).padStart(2, "0"));
}
}
return lookupTable;
};
}
/**
* Function implementation of the {@link getPrecomputedHexValuesBuilder}
* function.
*/
const getPrecomputedHexValues = getPrecomputedHexValuesBuilder();
/**
* Check if a value is a `Uint8Array`.
*
* @param value - The value to check.
* @returns Whether the value is a `Uint8Array`.
*/
function isBytes(value) {
return value instanceof Uint8Array;
}
/**
* Assert that a value is a `Uint8Array`.
*
* @param value - The value to check.
* @throws If the value is not a `Uint8Array`.
*/
function assertIsBytes(value) {
assert(isBytes(value), "Value must be a Uint8Array.");
}
/**
* Convert a `Uint8Array` to a hexadecimal string.
*
* @param bytes - The bytes to convert to a hexadecimal string.
* @returns The hexadecimal string.
*/
function bytesToHexPrefixedString(bytes) {
assertIsBytes(bytes);
if (bytes.length === 0) {
return "0x";
}
const lookupTable = getPrecomputedHexValues();
const hexadecimal = new Array(bytes.length);
for (let i = 0; i < bytes.length; i++) {
hexadecimal[i] = lookupTable[bytes[i]];
}
return add0x(hexadecimal.join(""));
}
/**
* Convert a `Uint8Array` to a `bigint`.
*
* To convert a `Uint8Array` to a `number` instead, use {@link bytesToNumber}.
* To convert a two's complement encoded `Uint8Array` to a `bigint`, use
* {@link bytesToSignedBigInt}.
*
* @param bytes - The bytes to convert to a `bigint`.
* @returns The `bigint`.
*/
function bytesToBigInt(bytes) {
assertIsBytes(bytes);
const hexadecimal = bytesToHexPrefixedString(bytes);
return BigInt(hexadecimal);
}
/**
* Convert a `Uint8Array` to a signed `bigint`. This assumes that the bytes are
* encoded in two's complement.
*
* To convert a `Uint8Array` to an unsigned `bigint` instead, use
* {@link bytesToBigInt}.
*
* @see https://en.wikipedia.org/wiki/Two%27s_complement
* @param bytes - The bytes to convert to a signed `bigint`.
* @returns The signed `bigint`.
*/
function bytesToSignedBigInt(bytes) {
assertIsBytes(bytes);
let value = BigInt(0);
for (const byte of bytes) {
value = (value << BigInt(8)) + BigInt(byte);
}
return BigInt.asIntN(bytes.length * 8, value);
}
/**
* Convert a `Uint8Array` to a `number`.
*
* To convert a `Uint8Array` to a `bigint` instead, use {@link bytesToBigInt}.
*
* @param bytes - The bytes to convert to a number.
* @returns The number.
* @throws If the resulting number is not a safe integer.
*/
function bytesToNumber(bytes) {
assertIsBytes(bytes);
const bigint = bytesToBigInt(bytes);
assert(bigint <= BigInt(Number.MAX_SAFE_INTEGER), "Number is not a safe integer. Use `bytesToBigInt` instead.");
return Number(bigint);
}
/**
* Convert a UTF-8 encoded `Uint8Array` to a `string`.
*
* @param bytes - The bytes to convert to a string.
* @returns The string.
*/
function bytesToUtf8(bytes) {
return new TextDecoder().decode(bytes);
}
/**
* Convert a `string` to a UTF-8 encoded `Uint8Array`.
*
* @param value - The string to convert to bytes.
* @returns The bytes as `Uint8Array`.
*/
function utf8ToBytes(value) {
return new TextEncoder().encode(value);
}
/**
* Convert a `Uint8Array` to a base64 encoded string.
*
* @param bytes - The bytes to convert to a base64 encoded string.
* @returns The base64 encoded string.
*/
function bytesToBase64(bytes) {
assertIsBytes(bytes);
return base64.encode(bytes);
}
/**
* Convert a hexadecimal string to a `Uint8Array`. The string can optionally be
* prefixed with `0x`. It accepts even and odd length strings.
*
* If the value is "0x", an empty `Uint8Array` is returned.
*
* @param value - The hexadecimal string to convert to bytes.
* @returns The bytes as `Uint8Array`.
*/
function hexToBytes(value) {
var _value$toLowerCase;
// "0x" is often used as empty byte array.
if ((value === null || value === void 0 || (_value$toLowerCase = value.toLowerCase) === null || _value$toLowerCase === void 0 ? void 0 : _value$toLowerCase.call(value)) === "0x") {
return new Uint8Array();
}
assertIsHexString(value);
// Remove the `0x` prefix if it exists, and pad the string to have an even
// number of characters.
const strippedValue = remove0x(value).toLowerCase();
const normalizedValue = strippedValue.length % 2 === 0 ? strippedValue : `0${strippedValue}`;
const bytes = new Uint8Array(normalizedValue.length / 2);
for (let i = 0; i < bytes.length; i++) {
// While this is not the prettiest way to convert a hexadecimal string to a
// `Uint8Array`, it is a lot faster than using `parseInt` to convert each
// character.
const c1 = normalizedValue.charCodeAt(i * 2);
const c2 = normalizedValue.charCodeAt(i * 2 + 1);
const n1 = c1 - (c1 < HEX_MAXIMUM_NUMBER_CHARACTER ? HEX_MINIMUM_NUMBER_CHARACTER : HEX_CHARACTER_OFFSET);
const n2 = c2 - (c2 < HEX_MAXIMUM_NUMBER_CHARACTER ? HEX_MINIMUM_NUMBER_CHARACTER : HEX_CHARACTER_OFFSET);
bytes[i] = n1 * 16 + n2;
}
return bytes;
}
/**
* Convert a `bigint` to a `Uint8Array`.
*
* This assumes that the `bigint` is an unsigned integer. To convert a signed
* `bigint` instead, use {@link signedBigIntToBytes}.
*
* @param value - The bigint to convert to bytes.
* @returns The bytes as `Uint8Array`.
*/
function bigIntToBytes(value) {
assert(typeof value === "bigint", "Value must be a bigint.");
assert(value >= BigInt(0), "Value must be a non-negative bigint.");
const hexadecimal = value.toString(16);
return hexToBytes(hexadecimal);
}
/**
* Check if a `bigint` fits in a certain number of bytes.
*
* @param value - The `bigint` to check.
* @param bytes - The number of bytes.
* @returns Whether the `bigint` fits in the number of bytes.
*/
function bigIntFits(value, bytes) {
assert(bytes > 0);
const mask = value >> BigInt(31);
return !((~value & mask) + (value & ~mask) >> BigInt(bytes * 8 + -1));
}
/**
* Convert a signed `bigint` to a `Uint8Array`. This uses two's complement
* encoding to represent negative numbers.
*
* To convert an unsigned `bigint` to a `Uint8Array` instead, use
* {@link bigIntToBytes}.
*
* @see https://en.wikipedia.org/wiki/Two%27s_complement
* @param value - The number to convert to bytes.
* @param byteLength - The length of the resulting `Uint8Array`. If the number
* is larger than the maximum value that can be represented by the given length,
* an error is thrown.
* @returns The bytes as `Uint8Array`.
*/
function signedBigIntToBytes(value, byteLength) {
assert(typeof value === "bigint", "Value must be a bigint.");
assert(typeof byteLength === "number", "Byte length must be a number.");
assert(byteLength > 0, "Byte length must be greater than 0.");
assert(bigIntFits(value, byteLength), "Byte length is too small to represent the given value.");
// ESLint doesn't like mutating function parameters, so to avoid having to
// disable the rule, we create a new variable.
let numberValue = value;
const bytes = new Uint8Array(byteLength);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = Number(BigInt.asUintN(8, numberValue));
numberValue >>= BigInt(8);
}
return bytes.reverse();
}
/**
* Convert a `number` to a `Uint8Array`.
*
* @param value - The number to convert to bytes.
* @returns The bytes as `Uint8Array`.
* @throws If the number is not a safe integer.
*/
function numberToBytes(value) {
assert(typeof value === "number", "Value must be a number.");
assert(value >= 0, "Value must be a non-negative number.");
assert(Number.isSafeInteger(value), "Value is not a safe integer. Use `bigIntToBytes` instead.");
const hexadecimal = value.toString(16);
return hexToBytes(hexadecimal);
}
/**
* Convert a base64 encoded string to a `Uint8Array`.
*
* @param value - The base64 encoded string to convert to bytes.
* @returns The bytes as `Uint8Array`.
*/
function base64ToBytes(value) {
assert(typeof value === "string", "Value must be a string.");
// Normalize base64url to standard base64 (pad + replace url-safe chars)
const segmentLength = 4;
const diff = value.length % segmentLength;
const padded = diff ? value + "=".repeat(segmentLength - diff) : value;
const normalized = padded.replace(/-/g, "+").replace(/_/g, "/");
return base64.decode(normalized);
}
/**
* Convert a byte-like value to a `Uint8Array`. The value can be a `Uint8Array`,
* a `bigint`, a `number`, or a `string`.
*
* This will attempt to guess the type of the value based on its type and
* contents. For more control over the conversion, use the more specific
* conversion functions, such as {@link hexToBytes} or {@link utf8ToBytes}.
*
* If the value is a `string`, and it is prefixed with `0x`, it will be
* interpreted as a hexadecimal string. Otherwise, it will be interpreted as a
* UTF-8 string. To convert a hexadecimal string to bytes without interpreting
* it as a UTF-8 string, use {@link hexToBytes} instead.
*
* If the value is a `bigint`, it is assumed to be unsigned. To convert a signed
* `bigint` to bytes, use {@link signedBigIntToBytes} instead.
*
* If the value is a `Uint8Array`, it will be returned as-is.
*
* @param value - The value to convert to bytes.
* @returns The bytes as `Uint8Array`.
*/
function valueToBytes(value) {
if (typeof value === "bigint") {
return bigIntToBytes(value);
}
if (typeof value === "number") {
return numberToBytes(value);
}
if (typeof value === "string") {
if (value.startsWith("0x")) {
return hexToBytes(value);
}
return utf8ToBytes(value);
}
if (isBytes(value)) {
return value;
}
throw new TypeError(`Unsupported value type: "${typeof value}".`);
}
/**
* Concatenate multiple byte-like values into a single `Uint8Array`. The values
* can be `Uint8Array`, `bigint`, `number`, or `string`. This uses
* {@link valueToBytes} under the hood to convert each value to bytes. Refer to
* the documentation of that function for more information.
*
* @param values - The values to concatenate.
* @returns The concatenated bytes as `Uint8Array`.
*/
function concatBytes(values) {
const normalizedValues = new Array(values.length);
let byteLength = 0;
for (let i = 0; i < values.length; i++) {
const value = valueToBytes(values[i]);
normalizedValues[i] = value;
byteLength += value.length;
}
const bytes = new Uint8Array(byteLength);
for (let i = 0, offset = 0; i < normalizedValues.length; i++) {
// While we could simply spread the values into an array and use
// `Uint8Array.from`, that is a lot slower than using `Uint8Array.set`.
bytes.set(normalizedValues[i], offset);
offset += normalizedValues[i].length;
}
return bytes;
}
/**
* Compare two Uint8Arrays using a constant-time style loop to reduce timing
* side-channels when comparing sensitive data (e.g., mnemonic bytes, keys,
* authentication tags). Does not early-return on the first difference:
* work done depends only on the input lengths, so byte content does not affect timing.
*
* When to use:
* - Use for secret or security-sensitive byte comparisons to avoid content-based timing leaks.
* - Prefer when inputs are fixed-length (or validated to equal length) at the API boundary.
*
* @param a - The first Uint8Array to compare.
* @param b - The second Uint8Array to compare.
* @returns Whether the Uint8Arrays are equal.
*/
function areUint8ArraysEqual(a, b) {
let diff = a.byteLength ^ b.byteLength;
const len = Math.max(a.byteLength, b.byteLength);
for (let i = 0; i < len; i++) {
var _a$i, _b$i;
const aByte = (_a$i = a[i]) !== null && _a$i !== void 0 ? _a$i : 0;
const bByte = (_b$i = b[i]) !== null && _b$i !== void 0 ? _b$i : 0;
diff |= aByte ^ bByte;
}
return diff === 0;
}
export { areUint8ArraysEqual, assertIsBytes, base64ToBytes, bigIntToBytes, bytesToBase64, bytesToBigInt, bytesToHexPrefixedString, bytesToNumber, bytesToSignedBigInt, bytesToUtf8, concatBytes, hexToBytes, isBytes, numberToBytes, signedBigIntToBytes, utf8ToBytes, valueToBytes };