react-native-hotpocket-js-client
Version:
Javascript client library to interact with HotPocket smart contracts.
1,245 lines (1,018 loc) • 49.3 kB
JavaScript
/**
* HotPocket javascript client library.
* Version 0.5.0
* React Native: import HotPocket from 'react-native-hotpocket-js-client'
*/
import sodium from 'libsodium-wrappers';
import { TextEncoder, TextDecoder } from 'text-encoding';
import blake3 from 'blake3-js';
import bson from 'bson';
import { Buffer } from 'buffer';
(() => {
const supportedHpVersion = "0.6.";
const serverChallengeSize = 16;
const outputValidationPassThreshold = 0.8;
const connectionCheckIntervalMs = 1000;
const recentActivityThresholdMs = 3000;
const edKeyType = 237;
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
// External dependency references.
let logLevel = 0; // 0=info, 1=error
/*--- Included in public interface. ---*/
const protocols = {
json: "json",
bson: "bson"
};
Object.freeze(protocols);
/*--- Included in public interface. ---*/
const events = {
disconnect: "disconnect",
contractOutput: "contract_output",
connectionChange: "connection_change",
unlChange: "unl_change",
ledgerEvent: "ledger_event",
healthEvent: "health_event"
};
Object.freeze(events);
/*--- Included in public interface. ---*/
const notificationChannels = {
unlChange: "unl_change",
ledgerEvent: "ledger_event",
healthEvent: "health_event"
};
Object.freeze(notificationChannels);
/*--- Included in public interface. ---*/
// privateKeyHex: Hex private key with prefix ('ed').
// Returns 'ed' (237) prefixed binary public/private keys.
const generateKeys = async (privateKeyHex = null) => {
if (!privateKeyHex) {
const keys = sodium.crypto_sign_keypair();
const binPrivateKey = new Uint8Array(65);
binPrivateKey[0] = edKeyType;
binPrivateKey.set(keys.privateKey, 1);
const binPublicKey = new Uint8Array(33);
binPublicKey[0] = edKeyType;
binPublicKey.set(keys.publicKey, 1);
return {
privateKey: binPrivateKey,
publicKey: binPublicKey
};
}
else {
const binPrivateKey = hexToUint8Array(privateKeyHex);
if (binPrivateKey[0] != edKeyType)
throw "Invaid key type. 'ed' expected.";
const binPublicKey = new Uint8Array(33);
binPublicKey[0] = edKeyType;
binPublicKey.set(binPrivateKey.slice(33), 1);
return {
privateKey: binPrivateKey,
publicKey: binPublicKey
};
}
};
/*--- Included in public interface. ---*/
const createClient = async (servers, clientKeys, options) => {
const defaultOptions = {
contractId: null,
contractVersion: null,
trustedServerKeys: null,
protocol: protocols.json,
requiredConnectionCount: 1,
connectionTimeoutMs: 5000
};
const opt = options ? { ...defaultOptions, ...options } : defaultOptions;
if (!clientKeys)
throw "clientKeys not specified.";
if (opt.contractId == "")
throw "contractId not specified. Specify null to bypass contract id validation.";
if (opt.contractVersion == "")
throw "contractVersion not specified. Specify null to bypass contract version validation.";
if (!opt.protocol || (opt.protocol != protocols.json && opt.protocol != protocols.bson))
throw "Valid protocol not specified.";
if (!opt.requiredConnectionCount || opt.requiredConnectionCount == 0)
throw "requiredConnectionCount must be greater than 0.";
if (!opt.connectionTimeoutMs || opt.connectionTimeoutMs == 0)
throw "Connection timeout must be greater than 0.";
// Load servers and serverKeys to object keys to avoid duplicates.
const serversLookup = {};
servers && servers.forEach(s => {
const url = s.trim();
if (url.length > 0)
serversLookup[url] = true;
});
if (Object.keys(serversLookup).length == 0)
throw "servers not specified.";
if (opt.requiredConnectionCount > Object.keys(serversLookup).length)
throw "requiredConnectionCount is higher than no. of servers.";
let trustedKeysLookup = {};
opt.trustedServerKeys && opt.trustedServerKeys.sort().forEach(k => {
const key = k.trim();
if (key.length > 0)
trustedKeysLookup[key] = true;
});
// If there are no keys specified, mark the lookup as null, indicating that we are not intersted in
// checking for trusted servers.
if (Object.keys(trustedKeysLookup).length == 0)
trustedKeysLookup = null;
return new HotPocketClient(opt.contractId, opt.contractVersion, clientKeys, serversLookup, trustedKeysLookup, opt.protocol, opt.requiredConnectionCount, opt.connectionTimeoutMs);
};
function HotPocketClient(contractId, contractVersion, clientKeys, serversLookup, trustedKeysLookup, protocol, requiredConnectionCount, connectionTimeoutMs) {
let emitter = new EventEmitter();
// The accessor functions passed into connections to query and set latest trusted key list.
const getTrustedKeys = () => trustedKeysLookup;
const setTrustedKeys = (newKeys) => (trustedKeysLookup = newKeys);
const nodes = Object.keys(serversLookup).map(s => {
return {
server: s, // Server address.
connection: null, // HotPocket connection (if any).
lastActivity: 0 // Last connection activity timestamp.
};
});
// Default subscriptions.
const subscriptions = {};
// Subscribe for unl changes if we have to maintain the trusted server key checks.
subscriptions[notificationChannels.unlChange] = trustedKeysLookup ? true : false;
subscriptions[notificationChannels.ledgerEvent] = false;
subscriptions[notificationChannels.healthEvent] = false;
let status = 0; //0:none, 1:connected, 2:closed
// This will get fired whenever the required connection count gets fullfilled.
let initialConnectSuccess = null;
// Tracks when was the earliest time that we were missing some required connections.
// 0 indicates we are not missing any connections. This will be initially set when connect() is called.
let connectionsMissingFrom = 0;
let reviewConnectionsTimer = null;
// Checks for missing connections and attempts to establish them.
const reviewConnections = () => {
if (status == 2)
return;
// Check for connection changes periodically.
reviewConnectionsTimer = setTimeout(() => {
reviewConnections();
}, connectionCheckIntervalMs);
// Check whether we have fullfilled all required connections.
if (nodes.filter(n => n.connection && n.connection.isConnected()).length == requiredConnectionCount) {
connectionsMissingFrom = 0;
initialConnectSuccess && initialConnectSuccess(true);
initialConnectSuccess = null;
status = 1;
return;
}
if (connectionsMissingFrom == 0) {
// Reaching here means we moved from connections-fullfilled state to missing-connections state.
connectionsMissingFrom = new Date().getTime();
}
else if ((new Date().getTime() - connectionsMissingFrom) > connectionTimeoutMs) {
// This means we were not able to maintain required connection count for the entire timeout period.
liblog(1, "Missing-connections timeout reached.");
// Close and cleanup all connections if we hit the timeout.
this.close().then(() => {
if (initialConnectSuccess) {
initialConnectSuccess(false);
initialConnectSuccess = null;
}
else {
emitter && emitter.emit(events.disconnect);
}
});
return;
}
// Reaching here means we should attempt to establish more connections if we have available slots.
let currentConnectionCount = nodes.filter(n => n.connection).length;
if (currentConnectionCount == requiredConnectionCount)
return;
// Find out available slots.
// Skip nodes that are already connected or is currently establishing connection.
// Skip nodes that have recently shown some connection activity.
// Give priority to nodes that have not shown any activity recently.
const freeNodes = nodes.filter(n => !n.connection && (new Date().getTime() - n.lastActivity) > recentActivityThresholdMs);
freeNodes.sort((a, b) => a.lastActivity - b.lastActivity); // Oldest activity comes first.
while (currentConnectionCount < requiredConnectionCount && freeNodes.length > 0) {
// Get the next available node.
const n = freeNodes.shift();
n.connection = new HotPocketConnection(
contractId, contractVersion, clientKeys, n.server,
getTrustedKeys, setTrustedKeys, protocol, connectionTimeoutMs, emitter);
n.lastActivity = new Date().getTime();
n.connection.connect().then(success => {
if (!success) {
n.connection = null;
}
else {
emitter && emitter.emit(events.connectionChange, n.server, "add");
// Issue subscription request for any subscriptions we have to maintain.
// We don't wait for completion because we just need to issue the request to the server.
for (const [channel, enabled] of Object.entries(subscriptions)) {
if (enabled) n.connection.subscribe(channel);
}
}
});
n.connection.onClose = () => {
n.connection = null;
emitter && emitter.emit(events.connectionChange, n.server, "remove");
};
currentConnectionCount++;
}
};
/**
* Executes the provided func on all connections and returns the collected results.
* @param func The function taking a HP Connection as a parameter. This will get executed on all connections.
* @param maxConnections Maximum no. of connections to use. Uses all available connections if null.
*/
const getMultiConnectionResult = async (func, maxConnections) => {
if (status == 2)
return await Promise.resolve();
if (maxConnections == null)
maxConnections = requiredConnectionCount;
const connections = nodes.filter(n => n.connection && n.connection.isConnected()).map(n => n.connection).slice(0, maxConnections);
const results = await Promise.all(connections.map(c => func(c)));
// If we are expecting only 1 connection, then return null or single result.
// Otherwise return the array of results.
if (maxConnections == 1 && results.length <= 1)
return results.length == 0 ? null : results[0];
else
return results;
};
/**
* Executes the provided func on all connections.
* @param func The function taking a HP Connection as a parameter. This will get executed on all connections.
* @param maxConnections Maximum no. of connections to use. Uses all available connections if null.
*/
const executeMultiConnectionFunc = (func, maxConnections) => {
if (status == 2)
return Promise.resolve();
if (maxConnections == null)
maxConnections = requiredConnectionCount;
const connections = nodes.filter(n => n.connection && n.connection.isConnected()).map(n => n.connection).slice(0, maxConnections);
return Promise.all(connections.map(c => func(c)));
};
this.connect = () => {
if (status > 0)
return;
// Start the missing-connections timer tracking from this point onwards.
connectionsMissingFrom = new Date().getTime();
reviewConnections();
return new Promise(resolve => {
initialConnectSuccess = resolve;
});
};
this.close = async () => {
if (status == 2)
return;
status = 2;
if (reviewConnectionsTimer) {
clearTimeout(reviewConnectionsTimer);
reviewConnectionsTimer = null;
}
emitter.clear(events.connectionChange);
emitter.clear(events.contractOutput);
// Close all nodes connections.
await Promise.all(nodes.filter(n => n.connection).map(n => n.connection.close()));
nodes.forEach(n => n.connection = null);
};
this.on = (event, listener) => {
emitter.on(event, listener);
};
this.clear = (event) => {
emitter.clear(event);
};
this.submitContractInput = (input, nonce = null, maxLedger = null, isOffset = true) => {
// We always only submit contract input to a single node (even if we are connected to multiple nodes).
return getMultiConnectionResult(con => con.submitContractInput(input, nonce, maxLedger, isOffset), 1);
};
this.submitContractReadRequest = (request, id = null, timeout = 15000) => {
id = id ? id.toString() : new Date().getTime().toString(); // Generate request id if not specified.
return getMultiConnectionResult(con => con.submitContractReadRequest(request, id, timeout));
};
this.getStatus = () => {
return getMultiConnectionResult(con => con.getStatus());
};
this.getLcl = () => {
return getMultiConnectionResult(con => con.getLcl());
};
this.subscribe = (channel) => {
subscriptions[channel] = true;
return executeMultiConnectionFunc(con => con.subscribe(channel));
};
this.unsubscribe = (channel) => {
subscriptions[channel] = false;
return executeMultiConnectionFunc(con => con.unsubscribe(channel));
};
this.getLedgerBySeqNo = (seqNo, includeInputs, includeOutputs) => {
return getMultiConnectionResult(con => con.getLedgerBySeqNo(seqNo, includeInputs, includeOutputs));
};
}
function HotPocketConnection(
contractId, contractVersion, clientKeys, server,
getTrustedKeys, setTrustedKeys, protocol, connectionTimeoutMs, emitter) {
// Create message helper with JSON protocol initially.
// After challenge handshake, we will change this to use the protocol specified by user.
const msgHelper = new MessageHelper(clientKeys, protocols.json);
let connectionStatus = 0; // 0:none, 1:server challenge sent, 2:handshake complete.
let serverChallenge = null; // The hex challenge we have issued to the server.
let reportedContractId = null;
let reportedContractVersion = null;
let pubkey = false; // Pubkey hex (with prefix) of this connection.
let ws = null;
let handshakeTimer = null; // Timer to track connection handshake timeout.
let handshakeResolver = null;
let closeResolver = null;
let statResponseResolvers = [];
let lclResponseResolvers = [];
let contractInputResolvers = {}; // Contract input status-awaiting resolvers (keyed by input hash).
let readRequestResolvers = {}; // Contract read request reply-awaiting resolvers (keyed by request id).
let ledgerQueryResolvers = {}; // Message resolvers that uses request/reply associations.
// Calcualtes the blake3 hash of all array items.
const getHash = (arr) => {
const hasher = blake3.newRegular();
arr.forEach(item => hasher.update(item));
const hash = Buffer.from(hasher.finalize(), 'hex');
return new Uint8Array(hash);
};
// Get root hash of the given merkle hash tree. (called recursively)
// Merkle hash tree indicates the collapsed output hashes of this round for all users.
// This user's output hash is indicated in the tree as null.
// selfHash: specifies the output hash of this user.
const getMerkleHash = (tree, selfHash) => {
const listToHash = []; // Collects elements to hash.
let selfHashFound = false;
for (let elem of tree) {
if (Array.isArray(elem)) {
// If the 'elem' is an array we should find the root hash of the array.
// Call this func recursively. If self hash already found, pass null.
const result = getMerkleHash(elem, selfHashFound ? null : selfHash);
if (result[0] == true)
selfHashFound = true;
listToHash.push(result[1]);
}
else { // elem' is a single hash value
// We get the hash bytes depending on the data type. (json encoding will use hex string
// and bson will use buffer). If the elem contains null, that means it represents the
// self hash. So we should substitute the self hash to null.
// If we have already found self hash (indicated by selfHash=null), we cannot encounter
// null element again.
if (!selfHash && !elem) {
liblog(1, "Self hash encountered more than once in output hash tree.");
return [false, null];
}
if (!elem)
selfHashFound = true;
const hashBytes = elem ? msgHelper.binaryDecode(elem) : selfHash; // If element is null, use self hash.
listToHash.push(hashBytes);
}
}
// Return a tuple of whether self hash was found and the root hash of the provided merkle tree.
return [selfHashFound, getHash(listToHash)];
};
// Verifies whether the provided root hash has enough signatures from unl.
const validateHashSignatures = (rootHash, signatures, unlKeysLookup) => {
const totalUnl = Object.keys(unlKeysLookup).length;
if (totalUnl == 0) {
liblog(1, "Cannot validate outputs with empty unl.");
return false;
}
const passedKeys = {};
// 'signatures' is an array of pairs of [pubkey, signature]
for (const pair of signatures) {
const pubkeyHex = msgHelper.stringifyValue(pair[0]); // Gets the pubkey hex to use for unl lookup key.
// Get the signature and issuer pubkey bytes based on the data type.
// (json encoding will use hex string and bson will use buffer)
const binPubkey = msgHelper.binaryDecode(pair[0]);
const sig = msgHelper.binaryDecode(pair[1]);
// Check whether the pubkey is in unl and whether signature is valid.
if (!passedKeys[pubkeyHex] && unlKeysLookup[pubkeyHex] && sodium.crypto_sign_verify_detached(sig, rootHash, binPubkey.slice(1)))
passedKeys[pubkeyHex] = true;
}
// Check the percentage of unl keys that passed the signature check.
const passed = Object.keys(passedKeys).length;
return ((passed / totalUnl) >= outputValidationPassThreshold);
};
const verifyContractOutputTrust = (msg, trustedKeys) => {
// Calculate combined output hash with user's pubkey.
const outputHash = getHash([clientKeys.publicKey, ...msgHelper.spreadArrayField(msg.outputs)]);
// Check whether calculated output hash is same as output hash indicated in the message.
if (!arraysEqual(outputHash, msgHelper.binaryDecode(msg.output_hash))) {
liblog(1, "Contract output hash mismatch.");
return false;
}
const hashResult = getMerkleHash(msg.hash_tree, outputHash);
// hashResult is a tuple containing whether self hash was found and the calculated root hash of the merkle hash tree.
if (hashResult[0] == true) {
const rootHash = hashResult[1];
// Verify the issued signatures against the root hash.
return validateHashSignatures(rootHash, msg.unl_sig, trustedKeys);
}
return false;
};
const processUnlUpdate = (unl, isHandshake) => {
unl = unl.map(k => msgHelper.deserializeValue(k)).sort();
// If this is currently a trusted connection, update the trusted key set with the received unl.
let trustedKeys = getTrustedKeys();
if (trustedKeys && trustedKeys[pubkey]) {
trustedKeys = {}; // reset the object and reinitialize the list.
// Convert unl pubkeys to hex string so we can use them as lookup object keys.
const hexUnl = unl.map(k => msgHelper.stringifyValue(k));
hexUnl.forEach(k => trustedKeys[k] = true);
setTrustedKeys(trustedKeys);
liblog(0, "Updated trusted keys.");
}
if (!isHandshake)
emitter && emitter.emit(events.unlChange, unl);
};
const handshakeMessageHandler = (m) => {
if (connectionStatus == 0 && m.type == "user_challenge" && m.hp_version && m.contract_id) {
if (!m.hp_version.startsWith(supportedHpVersion)) {
liblog(1, `Incompatible HotPocket server version. Expected:${supportedHpVersion}* Got:${m.hp_version}`);
return false;
}
else if (!m.contract_id) {
liblog(1, "Server did not specify contract id.");
return false;
}
else if (contractId && m.contract_id != contractId) {
liblog(1, `Contract id mismatch. Expected:${contractId} Got:${m.contract_id}`);
return false;
}
else if (!m.contract_version) {
liblog(1, "Server did not specify contract version.");
return false;
}
else if (contractVersion && m.contract_version != contractVersion) {
liblog(1, `Contract version mismatch. Expected:${contractVersion} Got:${m.contract_version}`);
return false;
}
reportedContractId = m.contract_id;
reportedContractVersion = m.contract_version;
// Generate the challenge we are sending to server.
serverChallenge = uint8ArrayToHex(sodium.randombytes_buf(serverChallengeSize));
// Sign the challenge and send back the response
const response = msgHelper.createUserChallengeResponse(m.challenge, serverChallenge, protocol);
wsSend(msgHelper.serializeObject(response));
connectionStatus = 1;
return true;
}
else if (connectionStatus == 1 && serverChallenge && m.type == "server_challenge_response" && m.sig && m.pubkey) {
// If trustedKeys has been specified and server key is not among them, fail the connection.
const trustedKeys = getTrustedKeys();
if (trustedKeys && !trustedKeys[m.pubkey]) {
liblog(1, `${server} is not among the trusted servers.`);
return false;
}
// Verify server challenge response.
const stringToVerify = serverChallenge + reportedContractId + reportedContractVersion;
const serverPubkeyHex = m.pubkey.substring(2); // Skip 'ed' prefix;
if (!sodium.crypto_sign_verify_detached(hexToUint8Array(m.sig), stringToVerify, hexToUint8Array(serverPubkeyHex))) {
liblog(1, `${server} challenge response verification failed.`);
return false;
}
clearTimeout(handshakeTimer); // Cancel the handshake timeout monitor.
handshakeTimer = null;
serverChallenge = null; // Clear the sent challenge as we no longer need it.
pubkey = m.pubkey; // Set this connection's public key.
connectionStatus = 2; // Handshake complete.
processUnlUpdate(m.unl, true);
msgHelper.useProtocol(protocol); // Here onwards, use the message protocol specified by user.
// If we are still connected, report handshaking as successful.
// (If websocket disconnects, handshakeResolver will be already null)
handshakeResolver && handshakeResolver(true);
liblog(0, `Connected to ${server}`);
return true;
}
liblog(1, `${server} invalid message during handshake. Connection status:${connectionStatus}`);
liblog(0, m);
return false;
};
const contractMessageHandler = (m) => {
if (m.type == "contract_read_response") {
const requestId = m.reply_for;
const resolver = readRequestResolvers[requestId];
if (resolver) {
clearTimeout(resolver.timer);
resolver.resolver(msgHelper.deserializeValue(m.content));
delete readRequestResolvers[requestId];
}
}
else if (m.type == "contract_input_status") {
const inputHashHex = msgHelper.stringifyValue(m.input_hash);
const resolver = contractInputResolvers[inputHashHex];
if (resolver) {
const result = { status: m.status };
if (m.status == "accepted") {
result.ledgerSeqNo = m.ledger_seq_no;
result.ledgerHash = msgHelper.deserializeValue(m.ledger_hash);
}
else {
result.reason = m.reason;
}
resolver(result);
delete contractInputResolvers[inputHashHex];
}
}
else if (m.type == "contract_output") {
if (emitter) {
// Validate outputs if trusted keys is not null. (null means bypass validation)
const trustedKeys = getTrustedKeys();
if (!trustedKeys || verifyContractOutputTrust(m, trustedKeys)) {
emitter.emit(events.contractOutput, {
ledgerSeqNo: m.ledger_seq_no,
ledgerHash: msgHelper.deserializeValue(m.ledger_hash),
outputHash: msgHelper.deserializeValue(m.output_hash),
outputs: m.outputs.map(o => msgHelper.deserializeValue(o))
});
}
else
liblog(1, "Output validation failed.");
}
}
else if (m.type == "stat_response") {
statResponseResolvers.forEach(resolver => {
resolver({
hpVersion: m.hp_version,
ledgerSeqNo: m.ledger_seq_no,
ledgerHash: msgHelper.deserializeValue(m.ledger_hash),
voteStatus: m.vote_status,
roundTime: m.round_time,
contractExecutionEnabled: m.contract_execution_enabled,
readRequestsEnabled: m.read_requests_enabled,
isFullHistoryNode: m.is_full_history_node,
weaklyConnected: m.weakly_connected,
currentUnl: m.current_unl.map(u => msgHelper.deserializeValue(u)),
peers: m.peers
});
});
statResponseResolvers = [];
}
else if (m.type == "lcl_response") {
lclResponseResolvers.forEach(resolver => {
resolver({
ledgerSeqNo: m.ledger_seq_no,
ledgerHash: msgHelper.deserializeValue(m.ledger_hash)
});
});
lclResponseResolvers = [];
}
else if (m.type == "unl_change") {
processUnlUpdate(m.unl, false);
}
else if (m.type == "ledger_event") {
const ev = { event: m.event };
if (ev.event == "ledger_created")
ev.ledger = msgHelper.deserializeLedger(m.ledger);
else if (ev.event == "vote_status")
ev.voteStatus = m.vote_status;
emitter.emit(events.ledgerEvent, ev);
}
else if (m.type == "health_event") {
const ev = msgHelper.deserializeHealthEvent(m);
emitter.emit(events.healthEvent, ev);
}
else if (m.type == "ledger_query_result") {
const resolver = ledgerQueryResolvers[m.reply_for];
if (resolver) {
const results = m.results.map(r => {
const result = msgHelper.deserializeLedger(r);
if (r.inputs) {
result.inputs = r.inputs.map(i => {
return {
pubkey: msgHelper.deserializeValue(i.pubkey),
hash: msgHelper.deserializeValue(i.hash),
nonce: i.nonce,
blob: msgHelper.deserializeValue(i.blob)
};
});
}
if (r.outputs) {
result.outputs = r.outputs.map(o => {
return {
pubkey: msgHelper.deserializeValue(o.pubkey),
hash: msgHelper.deserializeValue(o.hash),
blobs: o.blobs.map(b => msgHelper.deserializeValue(b))
};
});
}
return result;
});
if (resolver.type == "seq_no")
resolver.resolver(results.length > 0 ? results[0] : null); // Return as a single object rather than an array.
delete ledgerQueryResolvers[m.reply_for];
}
}
else {
liblog(1, "Received unrecognized contract message: type:" + m.type);
return false;
}
return true;
};
const messageHandler = async (rcvd) => {
// Decode the received data buffer.
// In browser, text(json) mode requires the buffer to be "decoded" to text before JSON parsing.
const isTextMode = (connectionStatus < 2 || protocol == protocols.json);
const data = isTextMode ? textDecoder.decode(rcvd.data) : rcvd.data;
// Deserialized message.
let m;
try {
m = msgHelper.deserializeMessage(data);
}
catch (e) {
liblog(1, e);
liblog(0, "Exception deserializing: ");
liblog(0, data || rcvd);
// If we get invalid message during handshake, close the socket.
if (connectionStatus < 2)
this.close();
return;
}
let isValid = false;
if (connectionStatus < 2)
isValid = handshakeMessageHandler(m);
else if (connectionStatus == 2)
isValid = contractMessageHandler(m);
if (!isValid) {
// If we get invalid message during handshake, close the socket.
if (connectionStatus < 2)
this.close();
}
};
const openHandler = () => {
ws.addEventListener("message", messageHandler);
ws.addEventListener("close", closeHandler);
handshakeTimer = setTimeout(() => {
// If handshake does not complete within timeout, close the connection.
this.close();
handshakeTimer = null;
}, connectionTimeoutMs);
};
const closeHandler = () => {
if (closeResolver)
liblog(0, "Closing connection to " + server);
else
liblog(0, "Disconnected from " + server);
emitter = null;
if (handshakeTimer) {
clearTimeout(handshakeTimer);
handshakeTimer = null;
}
// If there are any ongoing resolvers resolve them with error output.
handshakeResolver && handshakeResolver(false);
handshakeResolver = null;
statResponseResolvers.forEach(resolver => resolver(null));
statResponseResolvers = [];
Object.values(contractInputResolvers).forEach(resolver => resolver({
status: "failed",
reason: "connection_error"
}));
contractInputResolvers = {};
Object.values(readRequestResolvers).forEach(resolver => {
clearTimeout(resolver.timer);
resolver.resolver(null);
});
readRequestResolvers = {};
this.onClose && this.onClose();
closeResolver && closeResolver();
};
const errorHandler = (e) => {
handshakeResolver && handshakeResolver(false);
};
const wsSend = (msg) => {
if (isString(msg))
ws.send(textEncoder.encode(msg));
else
ws.send(msg);
};
this.isConnected = () => {
return connectionStatus == 2;
};
this.connect = () => {
liblog(0, "Connecting to " + server);
return new Promise(resolve => {
ws = new WebSocket(server);
handshakeResolver = resolve;
ws.addEventListener("error", errorHandler);
ws.addEventListener("open", openHandler);
});
};
this.close = () => {
if (ws.readyState == WebSocket.OPEN) {
return new Promise(resolve => {
closeResolver = resolve;
ws.close();
});
}
else {
ws.close();
return Promise.resolve();
}
};
this.getStatus = () => {
if (connectionStatus != 2)
return Promise.resolve(null);
const p = new Promise(resolve => {
statResponseResolvers.push(resolve);
});
// If this is the only awaiting stat request, then send an actual stat request.
// Otherwise simply wait for the previously sent request.
if (statResponseResolvers.length == 1) {
const msg = msgHelper.createStatusRequest();
wsSend(msgHelper.serializeObject(msg));
}
return p;
};
this.getLcl = () => {
if (connectionStatus != 2)
return Promise.resolve(null);
const p = new Promise(resolve => {
lclResponseResolvers.push(resolve);
});
// If this is the only awaiting lcl request, then send an actual lcl request.
// Otherwise simply wait for the previously sent request.
if (lclResponseResolvers.length == 1) {
const msg = msgHelper.createLclRequest();
wsSend(msgHelper.serializeObject(msg));
}
return p;
};
this.submitContractInput = async (input, nonce, maxLedger, isOffset) => {
if (connectionStatus != 2)
throw "Connection error.";
if (maxLedger == 0)
throw "Max ledger seq no. or offset cannot be 0.";
if (!isOffset && !maxLedger)
throw "Max ledger seq. no not specified.";
if (nonce && (!Number.isInteger(nonce) || nonce <= 0))
throw "Input nonce must be a positive integer.";
// Use epoch-based auto incrementing nonce if nonce is not specified.
if (!nonce)
nonce = new Date().getTime();
// If max ledger is specified as offset, we need to get current ledger status and add the offset to it.
if (isOffset) {
if (!maxLedger)
maxLedger = 10; // Default offset applied if not specified.
// Acquire the last ledger information and add the specified offset.
const lcl = await this.getLcl();
if (!lcl)
throw "Error retrieving last closed ledger.";
maxLedger += lcl.ledgerSeqNo;
}
const inp = msgHelper.createContractInputComponents(input, nonce, maxLedger);
const inputHashHex = msgHelper.stringifyValue(inp.hash);
// Start waiting for this input's accept/rejected status response.
const p = new Promise(resolve => {
contractInputResolvers[inputHashHex] = resolve;
});
const msg = msgHelper.createContractInputMessage(inp.container, inp.sig);
wsSend(msgHelper.serializeObject(msg));
// We return the input hash and a promise which can be resolved to get the input submission status.
return {
hash: msgHelper.binaryEncode(inp.hash),
submissionStatus: p
};
};
this.submitContractReadRequest = (request, id, timeout) => {
if (connectionStatus != 2)
return Promise.resolve();
// Start waiting for this request's reply.
return new Promise(resolve => {
const timer = setTimeout(() => {
resolve(null);
delete readRequestResolvers[id];
}, timeout);
readRequestResolvers[id] = { resolver: resolve, timer: timer };
const msg = msgHelper.createReadRequest(request, id);
wsSend(msgHelper.serializeObject(msg));
});
};
this.subscribe = (channel) => {
if (connectionStatus != 2)
return Promise.resolve();
const msg = msgHelper.createSubscriptionRequest(channel, true);
wsSend(msgHelper.serializeObject(msg));
return Promise.resolve();
};
this.unsubscribe = (channel) => {
if (connectionStatus != 2)
return Promise.resolve();
const msg = msgHelper.createSubscriptionRequest(channel, false);
wsSend(msgHelper.serializeObject(msg));
return Promise.resolve();
};
this.getLedgerBySeqNo = (seqNo, includeInputs, includeOutputs) => {
if (connectionStatus != 2)
return Promise.resolve(null);
const queryParams = { "seq_no": msgHelper.serializeNumber(seqNo) };
const msg = msgHelper.createLedgerQuery("seq_no", queryParams, includeInputs, includeOutputs);
const p = new Promise(resolve => {
ledgerQueryResolvers[msg.id] = {
type: "seq_no",
resolver: resolve
};
});
wsSend(msgHelper.serializeObject(msg));
return p;
};
}
function MessageHelper(keys, protocol) {
this.useProtocol = (p) => {
protocol = p;
};
this.binaryEncode = (data) => {
return protocol == protocols.json ?
uint8ArrayToHex(data) :
(Buffer.isBuffer(data) ? data : Buffer.from(data));
};
this.binaryDecode = (data) => {
return protocol == protocols.json ? hexToUint8Array(data) : new Uint8Array(data.buffer);
};
this.serializeObject = (obj) => {
return protocol == protocols.json ? JSON.stringify(obj) : bson.serialize(obj);
};
this.deserializeMessage = (m) => {
return protocol == protocols.json ? JSON.parse(m) : bson.deserialize(m);
};
this.serializeInput = (input) => {
return protocol == protocols.json ?
(isString(input) ? input : input.toString()) :
(Buffer.isBuffer(input) ? input : Buffer.from(input));
};
this.deserializeValue = (val) => {
return protocol == protocols.json ? val : val.buffer;
};
this.serializeNumber = (num) => {
// For standard javascript numbers, Bson library does not support serializing large numbers correctly.
// Hence we have to encode as special bson long type when using bson protocol.
return (protocol == protocols.json) ? num : bson.Long.fromNumber(num);
};
// Used for generating strings to hold values as js object keys.
this.stringifyValue = (val) => {
if (isString(val))
return val;
else if (val instanceof Uint8Array)
return uint8ArrayToHex(val);
else if (val.buffer) // BSON binary.
return uint8ArrayToHex(new Uint8Array(val.buffer));
else
throw "Cannot stringify signature.";
};
// Spreads hex/binary item array.
this.spreadArrayField = (outputs) => {
return protocol == protocols.json ? outputs : outputs.map(o => o.buffer);
};
this.createUserChallengeResponse = (userChallenge, serverChallenge, msgProtocol) => {
// For challenge response encoding HotPocket always uses json.
// Challenge response will specify the protocol to use for contract messages.
const sigBytes = sodium.crypto_sign_detached(userChallenge, keys.privateKey.slice(1));
return {
type: "user_challenge_response",
sig: this.binaryEncode(sigBytes),
pubkey: this.binaryEncode(keys.publicKey),
server_challenge: serverChallenge,
protocol: msgProtocol
};
};
// Creates a signed contract input components
this.createContractInputComponents = (input, nonce, maxLedgerSeqNo) => {
if (input.length == 0)
return null;
const inpContainer = {
input: this.serializeInput(input),
nonce: this.serializeNumber(nonce),
max_ledger_seq_no: this.serializeNumber(maxLedgerSeqNo)
};
const serlializedInpContainer = this.serializeObject(inpContainer);
const sigBytes = sodium.crypto_sign_detached(serlializedInpContainer, keys.privateKey.slice(1));
// Input hash is the blake3 hash of the input signature.
// The input hash can later be used to query input details from the ledger.
const hasher = blake3.newRegular();
hasher.update(sigBytes);
const hash = Buffer.from(hasher.finalize(), 'hex');
const inputHash = new Uint8Array(hash);
return {
hash: inputHash,
container: serlializedInpContainer,
sig: sigBytes
};
};
this.createContractInputMessage = (container, sig) => {
return {
type: "contract_input",
input_container: container,
sig: this.binaryEncode(sig)
};
};
this.createReadRequest = (request, id) => {
if (request.length == 0)
return null;
return {
type: "contract_read_request",
id: id,
content: this.serializeInput(request)
};
};
this.createStatusRequest = () => {
return { type: "stat" };
};
this.createLclRequest = () => {
return { type: "lcl" };
};
this.createSubscriptionRequest = (channel, enabled) => {
return {
type: "subscription",
channel: channel,
enabled: enabled
};
};
this.createLedgerQuery = (filterBy, params, includeInputs, includeOutputs) => {
const includes = [];
if (includeInputs) includes.push("inputs");
if (includeOutputs) includes.push("outputs");
return {
type: "ledger_query",
id: "query_" + filterBy + "_" + (new Date()).getTime().toString(),
filter_by: filterBy,
params: params,
include: includes
};
};
this.deserializeLedger = (l) => {
return {
seqNo: l.seq_no,
timestamp: l.timestamp,
hash: this.deserializeValue(l.hash),
prevHash: this.deserializeValue(l.prev_hash),
stateHash: this.deserializeValue(l.state_hash),
configHash: this.deserializeValue(l.config_hash),
userHash: this.deserializeValue(l.user_hash),
inputHash: this.deserializeValue(l.input_hash),
outputHash: this.deserializeValue(l.output_hash)
};
};
this.deserializeHealthEvent = (m) => {
if (m.event === "proposal") {
return {
event: m.event,
commLatency: m.comm_latency,
readLatency: m.read_latency,
batchSize: m.batch_size
};
}
else if (m.event === "connectivity") {
return {
event: m.event,
peerCount: m.peer_count,
weaklyConnected: m.weakly_connected
};
}
};
}
function hexToUint8Array(hexString) {
return new Uint8Array(hexString.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
}
function uint8ArrayToHex(bytes) {
return bytes.reduce((str, byte) => str + byte.toString(16).padStart(2, "0"), "");
}
function isString(obj) {
return (typeof obj === "string" || obj instanceof String);
}
function arraysEqual(a, b) {
if (a.length != b.length)
return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i])
return false;
}
return true;
}
function EventEmitter() {
const registrations = {};
this.on = (eventName, listener) => {
if (!registrations[eventName])
registrations[eventName] = [];
registrations[eventName].push(listener);
};
this.emit = (eventName, ...value) => {
if (registrations[eventName])
registrations[eventName].forEach(listener => listener(...value));
};
this.clear = (eventName) => {
if (eventName)
delete registrations[eventName];
else
Object.keys(registrations).forEach(k => delete registrations[k]);
};
}
function setLogLevel(level) {
logLevel = level;
}
function liblog(level, msg) {
if (level >= logLevel)
console.log(msg);
}
module.exports = {
generateKeys,
createClient,
events,
notificationChannels,
protocols,
setLogLevel
};
})();