if(typeof globalThis.Buffer === "undefined") { globalThis.Buffer = require("buffer/").Buffer; }
const bs58 = require("bs58");
const BigNumber = require("bignumber.js").default;
const VarInt = require("varint");
const URI = require("urijs");
const Pako = require("pako");
const {
keccak256,
getAddress
} = require("ethers").utils;
/**
* @namespace
* @description This is a utility namespace mostly containing functions for managing
* multiformat type conversions.
*
* Utils can be imported separately from the client:
*
* `const Utils = require("@eluvio/elv-client-js/src/Utils)`
*
* or
*
* `import Utils from "@eluvio/elv-client-js/src/Utils"`
*
*
* It can be accessed from ElvClient and FrameClient as `client.utils`
*/
const Utils = {
name: "Utils",
nullAddress: "0x0000000000000000000000000000000000000000",
weiPerEther: new BigNumber("1000000000000000000"),
/**
* Convert number or string to BigNumber
*
* @param {string | number} value - Value to convert to BigNumber
*
* @see https://github.com/MikeMcl/bignumber.js
*
* @returns {BigNumber} - Given value as a BigNumber
*/
ToBigNumber: (value) => {
return new BigNumber(value);
},
/**
* Convert wei to ether
*
* @param {string | BigNumber} wei - Wei value to convert to ether
*
* @see https://github.com/MikeMcl/bignumber.js
*
* @returns {BigNumber} - Given value in ether
*/
WeiToEther: (wei) => {
return Utils.ToBigNumber(wei).div(Utils.weiPerEther);
},
/**
* Convert ether to wei
*
* @param {number | string | BigNumber} ether - Ether value to convert to wei
*
* @see https://github.com/indutny/bn.js/
*
* @returns {BigNumber} - Given value in wei
*/
EtherToWei: (ether) => {
return Utils.ToBigNumber(ether).times(Utils.weiPerEther);
},
/**
* Convert address to normalized form - lower case with "0x" prefix
*
* @param {string} address - Address to format
*
* @returns {string} - Formatted address
*/
FormatAddress: (address) => {
if(!address || typeof address !== "string") {
return "";
}
address = address.trim();
if(!address.startsWith("0x")) {
address = "0x" + address;
}
return address.toLowerCase();
},
/**
* Formats a signature into multi-sig
*
* @param {string} sig - Hex representation of signature
*
* @returns {string} - Multi-sig string representation of signature
*/
FormatSignature: (sig) => {
sig = sig.replace("0x", "");
return "ES256K_" + bs58.encode(Buffer.from(sig, "hex"));
},
/**
* Decode the specified version hash into its component parts
*
* @param versionHash
*
* @returns {Object} - Components of the version hash.
*/
DecodeVersionHash: (versionHash) => {
if(!(versionHash.startsWith("hq__") || versionHash.startsWith("tq__"))) {
throw new Error(`Invalid version hash: "${versionHash}"`);
}
versionHash = versionHash.slice(4);
// Decode base58 payload
let bytes = Utils.FromB58(versionHash);
// Remove 32 byte SHA256 digest
const digestBytes = bytes.slice(0, 32);
const digest = digestBytes.toString("hex");
bytes = bytes.slice(32);
// Determine size of varint content size
let sizeLength = 0;
while(bytes[sizeLength] >= 128) {
sizeLength++;
}
sizeLength++;
// Remove size
const sizeBytes = bytes.slice(0, sizeLength);
const size = VarInt.decode(sizeBytes);
bytes = bytes.slice(sizeLength);
// Remaining bytes is object ID
const objectId = "iq__" + Utils.B58(bytes);
// Part hash is B58 encoded version hash without the ID
const partHash = "hqp_" + Utils.B58(Buffer.concat([digestBytes, sizeBytes]));
return {
digest,
size,
objectId,
partHash
};
},
/**
* Decode the specified signed token into its component parts
*
* @param {string} token - The token to decode
*
* @return {Object} - Components of the signed token
*/
DecodeSignedToken: (token) => {
const decodedToken = Utils.FromB58(token.slice(6));
const signature = `0x${decodedToken.slice(0, 65).toString("hex")}`;
let payload = JSON.parse(Buffer.from(Pako.inflateRaw(decodedToken.slice(65))).toString("utf-8"));
payload.adr = Utils.FormatAddress(`0x${Buffer.from(payload.adr, "base64").toString("hex")}`);
return {
payload,
signature
};
},
/**
* Decode the specified write token into its component parts
*
* @param writeToken
*
* @returns {Object} - Components of the write token.
*/
DecodeWriteToken: (writeToken) => {
/*
Format:
- content write token, LRO:
- prefix: "tq__", "tqw__", "tlro"
- format:
prefix + base58(uvarint(len(QID) | QID |
uvarint(len(NID) | NID |
uvarint(len(RAND_BYTES) | RAND_BYTES)
- content part write token:
- prefix: "tqp_"
- format:
prefix + base58(scheme | flags | uvarint(len(RAND_BYTES) | RAND_BYTES)
- content write token v1, content part write token v1:
- prefix: "tqw_", "tqpw"
- format:
prefix + base58(RAND_BYTES)
*/
if(writeToken.length<4){
throw new Error(`Invalid write token: ["${writeToken}"] (unknown prefix)`);
}
let tokenType;
if(writeToken.startsWith("tqw__")) {
tokenType = "tq__";
writeToken = writeToken.slice(5);
} else {
tokenType = writeToken.slice(0, 4);
writeToken = writeToken.slice(4);
}
if(writeToken.length===0){
throw new Error(`Invalid write token: ["${writeToken}"] (too short)`);
}
switch(tokenType) {
case "tqw_":
case "tq__":
case "tqpw":
case "tqp_":
case "tlro":
break;
default:
throw new Error(`Invalid write token: ["${writeToken}"] (unknown prefix)`);
}
// decode base58 payload
let bytes = Utils.FromB58(writeToken);
function decodeBytes(isID, prefix) {
let bsize = VarInt.decode(bytes,0); // decode: count of bytes to read
let offset = VarInt.decode.bytes; // offset in buffer to start read after decode
let theBytes;
let ret;
if(isID) {
theBytes = bytes.slice(offset+1, bsize+offset); // skip 1st byte (code id) at offset 0
if(theBytes.length===0){
ret = "";
} else {
ret = prefix + Utils.B58(theBytes);
}
} else {
theBytes = bytes.slice(offset, bsize+offset);
ret = "0x" + theBytes.toString("hex");
}
bytes = bytes.slice(bsize+offset);
return ret;
}
let tokenId;
let qid;
let nid;
let scheme;
let flags;
switch(tokenType) {
case "tqw_": // content write token v1
case "tqpw": // content part write token v1
tokenId = "0x" + bytes.toString("hex");
break;
case "tlro": // LRO,
case "tq__": // content write token
qid = decodeBytes(true, "iq__");
nid = decodeBytes(true, "inod");
tokenId = decodeBytes(false, "");
break;
case "tqp_": // content part write token
if(bytes.length<3) {
throw new Error(`Invalid write token: ["${writeToken}"] (token truncated)`);
}
scheme=bytes[0];
flags=bytes[1];
bytes = bytes.slice(2);
tokenId = decodeBytes(false, "");
break;
default:
// already raised
throw new Error(`Invalid write token: ["${writeToken}"] (unknown prefix)`);
}
return {
tokenType: tokenType, // type of token
tokenId: tokenId, // random bytes generated by the fabric node
objectId: qid, // content id for content write token (tq__) or LRO (tlro)
nodeId: nid, // node id where the content write token is valid (tq__)
scheme, // encryption scheme for part write token - (tqp_)
flags // flags for part write token (tqp_)
};
},
/**
* Convert contract address to multiformat hash
*
* @param {string} address - Address of contract
* @param {boolean} key - Whether or not the first param is a public key. Defaults to address type
*
* @returns {string} - Hash of contract address
*/
AddressToHash: (address, key=false) => {
address = address.replace(key ? "0x04" : "0x", "");
return bs58.encode(Buffer.from(address, "hex"));
},
/**
* Convert contract address to content space ID
*
* @param {string} address - Address of contract
*
* @returns {string} - Content space ID from contract address
*/
AddressToSpaceId: (address) => {
return "ispc" + Utils.AddressToHash(address);
},
/**
* Convert contract address to node ID
*
* @param {string} address - Address of contract
*
* @returns {string} - Node ID from contract address
*/
AddressToNodeId: (address) => {
return "inod" + Utils.AddressToHash(address);
},
/**
* Convert contract address to content library ID
*
* @param {string} address - Address of contract
*
* @returns {string} - Content library ID from contract address
*/
AddressToLibraryId: (address) => {
return "ilib" + Utils.AddressToHash(address);
},
/**
* Convert contract address to content object ID
*
* @param {string} address - Address of contract
*
* @returns {string} - Content object ID from contract address
*/
AddressToObjectId: (address) => {
return "iq__" + Utils.AddressToHash(address);
},
/**
* Convert any content fabric ID to the corresponding contract address
*
* @param {string} hash - Hash to convert to address
* @param {boolean} key - Whether or not the first param is a key. Defaults to address type
*
* @returns {string} - Contract address of item
*/
HashToAddress: (hash, key=false) => {
hash = key ? hash : hash.substr(4);
return Utils.FormatAddress((key ? "0x04" : "0x") + bs58.decode(hash).toString("hex"));
},
/**
* Compare two addresses to determine if they are the same, regardless of format/capitalization
*
* @param firstAddress
* @param secondAddress
*
* @returns {boolean} - Whether or not the addresses match
*/
EqualAddress(firstAddress, secondAddress) {
if(!firstAddress || !secondAddress) {
return false;
}
return (Utils.FormatAddress(firstAddress) === Utils.FormatAddress(secondAddress));
},
/**
* Compare two IDs to determine if the hashes are the same
* by comparing the contract address they resolve to
*
* @param firstHash
* @param secondHash
*
* @returns {boolean} - Whether or not the hashes of the IDs match
*/
EqualHash(firstHash, secondHash) {
if(!firstHash || !secondHash) {
return false;
}
if(firstHash.length <= 4 || secondHash.length <= 4) {
return false;
}
return (Utils.HashToAddress(firstHash) === Utils.HashToAddress(secondHash));
},
/**
* Determine whether the address is valid
*
* @param {string} address - Address to validate
*
* @returns {boolean} - Whether or not the address is valid
*/
ValidAddress: (address) => {
try {
getAddress(address);
return true;
} catch(error) {
return false;
}
},
/**
* Determine whether the hash is valid
*
* @param {string} hash - Hash to validate
*
* @returns {boolean} - Whether or not the hash is valid
*/
ValidHash: (hash) => {
return Utils.ValidAddress(Utils.HashToAddress(hash));
},
/**
* Convert the specified string to a bytes32 string
*
* @param {string} string - String to format as a bytes32 string
*
* @returns {string} - The given string in bytes32 format
*/
ToBytes32: (string) => {
const bytes32 = string.split("").map(char => {
return char.charCodeAt(0).toString(16);
}).join("");
return "0x" + bytes32.slice(0, 64).padEnd(64, "0");
},
BufferToArrayBuffer: (buffer) => {
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
},
FromHex: str => {
str = str.replace(/^0x/, "");
return Buffer.from(str, "hex").toString();
},
B64: (str, encoding="utf-8") => {
return Buffer.from(str, encoding).toString("base64");
},
FromB64: str => {
return Buffer.from(str, "base64").toString("utf-8");
},
FromB64URL: str => {
str = str.replace(/-/g, "+").replace(/_/g, "/");
const pad = str.length % 4;
if(pad) {
if(pad === 1) {
throw new Error("InvalidLengthError: Input base64url string is the wrong length to determine padding");
}
str += new Array(5-pad).join("=");
}
return Utils.FromB64(str);
},
B58: arr => {
return bs58.encode(Buffer.from(arr));
},
FromB58: str => {
return bs58.decode(str);
},
FromB58ToStr: str => {
return new TextDecoder().decode(Utils.FromB58(str));
},
/**
* Decode the given fabric authorization token
*
* @param {string} token - The authorization token to decode
* @return {Object} - Token Info: {qspace_id, qlib_id*, addr, tx_id*, afgh_pk*, signature}
*/
DecodeAuthorizationToken: token => {
token = decodeURIComponent(token);
let [info, signature] = token.split(".");
info = JSON.parse(Utils.FromB64(info));
return {
...info,
signature
};
},
LimitedMap: async (limit, array, f) => {
let index = 0;
let locked = false;
const nextIndex = async () => {
while(locked) {
await new Promise(resolve => setTimeout(resolve, 10));
}
locked = true;
const thisIndex = index;
index += 1;
locked = false;
return thisIndex;
};
let results = [];
let active = 0;
return new Promise((resolve, reject) => {
[...Array(limit || 1)].forEach(async () => {
active += 1;
let index = await nextIndex();
while(index < array.length) {
try {
results[index] = await f(array[index], index);
} catch(error) {
reject(error);
}
index = await nextIndex();
}
// When finished and no more workers are active, resolve
active -= 1;
if(active === 0) {
resolve(results);
}
});
});
},
/**
* Interprets an http response body obtained from an http call as JSON and returns result of parsing it.
*
* @param {Promise} response - An http response from node-fetch
* @param {boolean=} debug - Whether or not to log the body
* @param {Function} logFn - Log function to use if debug is truthy
* @return {*} - Result of parsing response body as JSON
*/
ResponseToJson: async (response, debug = false, logFn) => {
return await Utils.ResponseToFormat("json", response, debug, logFn);
},
/**
* Interprets an http response body obtained from an http call as a requested format and returns result of converting/formatting.
*
* @param {string} format - The format to use when interpreting response body (e.g. "json", "text" et. al.)
* @param {Promise} response - An http response from node-fetch
* @param {boolean=} debug - Whether or not to log a debug statement containing the body (ignored for formats other than "json" and "text")
* @param {Function} logFn - Log function to use if debug is truthy
* @return {*} - Result of converting response body into the requested format
*/
ResponseToFormat: async (format, response, debug = false, logFn) => {
response = await response;
let formattedBody;
switch(format.toLowerCase()) {
case "json":
formattedBody = await response.json();
if(debug) logFn(`response body: ${JSON.stringify(formattedBody, null, 2)}`);
return formattedBody;
case "text":
formattedBody = await response.text();
if(debug) logFn(`response body: ${formattedBody}`);
return formattedBody;
case "blob":
return await response.blob();
case "arraybuffer":
return await response.arrayBuffer();
case "formdata":
return await response.formData();
case "buffer":
return await response.buffer();
default:
return response;
}
},
/**
* Resize the image file or link URL to the specified maximum height. Can also be used to remove
* max height parameter(s) from a url if height is not specified.
*
* @param imageUrl - Url to an image file or link in the Fabric
* @param {number=} height - The maximum height for the image to be scaled to.
*
* @returns {string} - The modified URL with the height parameter
*/
ResizeImage({imageUrl, height}) {
if(!imageUrl || (imageUrl && !imageUrl.startsWith("http"))) {
return imageUrl;
}
imageUrl = URI(imageUrl)
.removeSearch("height")
.removeSearch("header-x_image_height");
if(height && !isNaN(parseInt(height))) {
imageUrl.addSearch("height", parseInt(height));
}
return imageUrl.toString();
},
SafeTraverse(object, ...keys) {
if(!object) { return object; }
if(keys.length === 1 && Array.isArray(keys[0])) {
keys = keys[0];
}
let result = object;
for(let i = 0; i < keys.length; i++){
result = result[keys[i]];
if(result === undefined) { return undefined; }
}
return result;
},
/**
* Determine if the given value is cloneable - Data passed in messages must be cloneable
*
* @param {*} value - Value to check
* @returns {boolean} - Whether or not the value is cloneable
*/
IsCloneable: (value) => {
if(Object(value) !== value) {
// Primitive value
return true;
}
switch({}.toString.call(value).slice(8, -1)) { // Class
case "Boolean":
case "Number":
case "String":
case "Date":
case "RegExp":
case "Blob":
case "FileList":
case "ImageData":
case "ImageBitmap":
case "ArrayBuffer":
return true;
case "Array":
case "Object":
return Object.keys(value).every(prop => Utils.IsCloneable(value[prop]));
case "Map":
return [...value.keys()].every(Utils.IsCloneable)
&& [...value.values()].every(Utils.IsCloneable);
case "Set":
return [...value.keys()].every(Utils.IsCloneable);
default:
return false;
}
},
/**
* Make the given value cloneable if it is not already.
*
* Note: this will remove or transform any attributes of the object that are not cloneable (e.g. functions)
*
* Transformations:
* - Buffer: Converted to ArrayBuffer
* - Error: Converted to string (error.message)
*
* @param {*} value - Value to check
* @returns {*} - Cloneable value
*/
MakeClonable: (value) => {
if(Utils.IsCloneable(value)) { return value; }
if(Buffer.isBuffer(value)) {
return Utils.BufferToArrayBuffer(value);
}
switch({}.toString.call(value).slice(8, -1)) { // Class
case "Response":
case "Function":
return undefined;
case "Boolean":
case "Number":
case "String":
case "Date":
case "RegExp":
case "Blob":
case "FileList":
case "ImageData":
case "ImageBitmap":
case "ArrayBuffer":
return value;
case "Array":
return value.map(element => Utils.MakeClonable(element));
case "Set":
return new Set(Array.from(value.keys()).map(entry => Utils.MakeClonable(entry)));
case "Map":
let cloneableMap = new Map();
Array.from(value.keys()).forEach(key => {
const cloneable = Utils.MakeClonable(value.get(key));
if(cloneable) {
cloneableMap.set(key, cloneable);
}
});
return cloneableMap;
case "Error":
return value.message;
case "Object":
let cloneableObject = {};
Object.keys(value).map(key => {
const cloneable = Utils.MakeClonable(value[key]);
if(cloneable) {
cloneableObject[key] = cloneable;
}
});
return cloneableObject;
default:
return JSON.parse(JSON.stringify(value));
}
},
/**
* Converts the given string to a public address
*
* @param key - Public key to convert to a public address
*
* @returns {string} - the public address
*/
PublicKeyToAddress: (key) => {
const keyData = new Uint8Array(Buffer.from(key.replace("0x04", ""), "hex"));
const keccakHash = keccak256(keyData);
const address = "0x" + keccakHash.slice(26);
return Utils.FormatAddress(address);
},
PLATFORM_NODE: "node",
PLATFORM_WEB: "web",
PLATFORM_REACT_NATIVE: "react-native",
Platform: () => {
if(typeof navigator !== "undefined" && navigator.product === "ReactNative") {
return Utils.PLATFORM_REACT_NATIVE;
} else if(
(typeof process !== "undefined") &&
(typeof process.versions !== "undefined") &&
(typeof process.versions.node !== "undefined")
) {
return Utils.PLATFORM_NODE;
} else {
return Utils.PLATFORM_WEB;
}
},
HLSJSSettings({profile="default"}={}) {
const isSafari =
typeof window !== "undefined" &&
typeof window.navigator !== "undefined" &&
/^((?!chrome|android).)*safari/i.test(window.navigator.userAgent);
const defaultSettings = {
"maxBufferHole": 2.2,
"nudgeOffset": 0.2,
"nudgeMaxRetry": 12,
"highBufferWatchdogPeriod": 1
};
if(!isSafari && ["ull", "ultraLowLatency"].includes(profile)) {
return {
"lowLatencyMode": true,
"liveSyncDuration": 4,
"liveMaxLatencyDuration": 5,
"liveDurationInfinity": false,
"maxBufferLength": 8,
"backBufferLength": 4,
"highBufferWatchdogPeriod": 1
};
} else if(["ll", "lowLatency", "ull", "ultraLowLatency"].includes(profile)) {
return {
"lowLatencyMode": true,
"liveSyncDuration": 5,
"liveMaxLatencyDuration": isSafari ? 15 : 10,
"liveDurationInfinity": false,
"maxBufferLength": 5,
"backBufferLength": 5,
...defaultSettings
};
} else {
return defaultSettings;
}
},
// Alias for HLSJSSettings
LiveHLSJSSettings({lowLatency=false, ultraLowLatency=false}) {
return Utils.HLSJSSettings({profile: ultraLowLatency ? "ull" : lowLatency ? "ll" : "default"});
}
};
module.exports = Utils;