UNPKG

@nimiq/network-client

Version:
711 lines (702 loc) 27.5 kB
class RandomUtils { static generateRandomId() { const array = new Uint32Array(1); crypto.getRandomValues(array); return array[0]; } } var ResponseStatus; (function (ResponseStatus) { ResponseStatus["OK"] = "ok"; ResponseStatus["ERROR"] = "error"; })(ResponseStatus || (ResponseStatus = {})); /* tslint:disable:no-bitwise */ class Base64 { // base64 is 4/3 + up to two characters of the original data static byteLength(b64) { const [validLength, placeHoldersLength] = Base64._getLengths(b64); return Base64._byteLength(validLength, placeHoldersLength); } static decode(b64) { Base64._initRevLookup(); const [validLength, placeHoldersLength] = Base64._getLengths(b64); const arr = new Uint8Array(Base64._byteLength(validLength, placeHoldersLength)); let curByte = 0; // if there are placeholders, only get up to the last complete 4 chars const len = placeHoldersLength > 0 ? validLength - 4 : validLength; let i = 0; for (; i < len; i += 4) { const tmp = (Base64._revLookup[b64.charCodeAt(i)] << 18) | (Base64._revLookup[b64.charCodeAt(i + 1)] << 12) | (Base64._revLookup[b64.charCodeAt(i + 2)] << 6) | Base64._revLookup[b64.charCodeAt(i + 3)]; arr[curByte++] = (tmp >> 16) & 0xFF; arr[curByte++] = (tmp >> 8) & 0xFF; arr[curByte++] = tmp & 0xFF; } if (placeHoldersLength === 2) { const tmp = (Base64._revLookup[b64.charCodeAt(i)] << 2) | (Base64._revLookup[b64.charCodeAt(i + 1)] >> 4); arr[curByte++] = tmp & 0xFF; } if (placeHoldersLength === 1) { const tmp = (Base64._revLookup[b64.charCodeAt(i)] << 10) | (Base64._revLookup[b64.charCodeAt(i + 1)] << 4) | (Base64._revLookup[b64.charCodeAt(i + 2)] >> 2); arr[curByte++] = (tmp >> 8) & 0xFF; arr[curByte /*++ not needed*/] = tmp & 0xFF; } return arr; } static encode(uint8) { const length = uint8.length; const extraBytes = length % 3; // if we have 1 byte left, pad 2 bytes const parts = []; const maxChunkLength = 16383; // must be multiple of 3 // go through the array every three bytes, we'll deal with trailing stuff later for (let i = 0, len2 = length - extraBytes; i < len2; i += maxChunkLength) { parts.push(Base64._encodeChunk(uint8, i, (i + maxChunkLength) > len2 ? len2 : (i + maxChunkLength))); } // pad the end with zeros, but make sure to not forget the extra bytes if (extraBytes === 1) { const tmp = uint8[length - 1]; parts.push(Base64._lookup[tmp >> 2] + Base64._lookup[(tmp << 4) & 0x3F] + '=='); } else if (extraBytes === 2) { const tmp = (uint8[length - 2] << 8) + uint8[length - 1]; parts.push(Base64._lookup[tmp >> 10] + Base64._lookup[(tmp >> 4) & 0x3F] + Base64._lookup[(tmp << 2) & 0x3F] + '='); } return parts.join(''); } static encodeUrl(buffer) { return Base64.encode(buffer).replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, '.'); } static decodeUrl(base64) { return Base64.decode(base64.replace(/_/g, '/').replace(/-/g, '+').replace(/\./g, '=')); } static _initRevLookup() { if (Base64._revLookup.length !== 0) return; Base64._revLookup = []; for (let i = 0, len = Base64._lookup.length; i < len; i++) { Base64._revLookup[Base64._lookup.charCodeAt(i)] = i; } // Support decoding URL-safe base64 strings, as Node.js does. // See: https://en.wikipedia.org/wiki/Base64#URL_applications Base64._revLookup['-'.charCodeAt(0)] = 62; Base64._revLookup['_'.charCodeAt(0)] = 63; } static _getLengths(b64) { const length = b64.length; if (length % 4 > 0) { throw new Error('Invalid string. Length must be a multiple of 4'); } // Trim off extra bytes after placeholder bytes are found // See: https://github.com/beatgammit/base64-js/issues/42 let validLength = b64.indexOf('='); if (validLength === -1) validLength = length; const placeHoldersLength = validLength === length ? 0 : 4 - (validLength % 4); return [validLength, placeHoldersLength]; } static _byteLength(validLength, placeHoldersLength) { return ((validLength + placeHoldersLength) * 3 / 4) - placeHoldersLength; } static _tripletToBase64(num) { return Base64._lookup[num >> 18 & 0x3F] + Base64._lookup[num >> 12 & 0x3F] + Base64._lookup[num >> 6 & 0x3F] + Base64._lookup[num & 0x3F]; } static _encodeChunk(uint8, start, end) { const output = []; for (let i = start; i < end; i += 3) { const tmp = ((uint8[i] << 16) & 0xFF0000) + ((uint8[i + 1] << 8) & 0xFF00) + (uint8[i + 2] & 0xFF); output.push(Base64._tripletToBase64(tmp)); } return output.join(''); } } Base64._lookup = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; Base64._revLookup = []; var ExtraJSONTypes; (function (ExtraJSONTypes) { ExtraJSONTypes[ExtraJSONTypes["UINT8_ARRAY"] = 0] = "UINT8_ARRAY"; })(ExtraJSONTypes || (ExtraJSONTypes = {})); class JSONUtils { static stringify(value) { return JSON.stringify(value, JSONUtils._jsonifyType); } static parse(value) { return JSON.parse(value, JSONUtils._parseType); } static _parseType(key, value) { if (value && value.hasOwnProperty && value.hasOwnProperty(JSONUtils.TYPE_SYMBOL) && value.hasOwnProperty(JSONUtils.VALUE_SYMBOL)) { switch (value[JSONUtils.TYPE_SYMBOL]) { case ExtraJSONTypes.UINT8_ARRAY: return Base64.decode(value[JSONUtils.VALUE_SYMBOL]); } } return value; } static _jsonifyType(key, value) { if (value instanceof Uint8Array) { return JSONUtils._typedObject(ExtraJSONTypes.UINT8_ARRAY, Base64.encode(value)); } return value; } static _typedObject(type, value) { const obj = {}; obj[JSONUtils.TYPE_SYMBOL] = type; obj[JSONUtils.VALUE_SYMBOL] = value; return obj; } } JSONUtils.TYPE_SYMBOL = '__'; JSONUtils.VALUE_SYMBOL = 'v'; class RequestIdStorage { /** * @param storeState Whether to store state in sessionStorage */ constructor(storeState = true) { this._store = storeState ? window.sessionStorage : null; this._validIds = new Map(); if (storeState) { this._restoreIds(); } } static _decodeIds(ids) { const obj = JSONUtils.parse(ids); const validIds = new Map(); for (const key of Object.keys(obj)) { const integerKey = parseInt(key, 10); validIds.set(isNaN(integerKey) ? key : integerKey, obj[key]); } return validIds; } has(id) { return this._validIds.has(id); } getCommand(id) { const result = this._validIds.get(id); return result ? result[0] : null; } getState(id) { const result = this._validIds.get(id); return result ? result[1] : null; } add(id, command, state = null) { this._validIds.set(id, [command, state]); this._storeIds(); } remove(id) { this._validIds.delete(id); this._storeIds(); } clear() { this._validIds.clear(); if (this._store) { this._store.removeItem(RequestIdStorage.KEY); } } _encodeIds() { const obj = Object.create(null); for (const [key, value] of this._validIds) { obj[key] = value; } return JSONUtils.stringify(obj); } _restoreIds() { const requests = this._store.getItem(RequestIdStorage.KEY); if (requests) { this._validIds = RequestIdStorage._decodeIds(requests); } } _storeIds() { if (this._store) { this._store.setItem(RequestIdStorage.KEY, this._encodeIds()); } } } RequestIdStorage.KEY = 'rpcRequests'; class RpcClient { constructor(allowedOrigin, storeState = false) { this._allowedOrigin = allowedOrigin; this._waitingRequests = new RequestIdStorage(storeState); this._responseHandlers = new Map(); this._preserveRequests = false; } onResponse(command, resolve, reject) { this._responseHandlers.set(command, { resolve, reject }); } _receive(message) { // Discard all messages from unwanted sources // or which are not replies // or which are not from the correct origin if (!message.data || !message.data.status || !message.data.id || (this._allowedOrigin !== '*' && message.origin !== this._allowedOrigin)) return; const data = message.data; const callback = this._getCallback(data.id); const state = this._waitingRequests.getState(data.id); if (callback) { if (!this._preserveRequests) { this._waitingRequests.remove(data.id); this._responseHandlers.delete(data.id); } console.debug('RpcClient RECEIVE', data); if (data.status === ResponseStatus.OK) { callback.resolve(data.result, data.id, state); } else if (data.status === ResponseStatus.ERROR) { const error = new Error(data.result.message); if (data.result.stack) { error.stack = data.result.stack; } if (data.result.name) { error.name = data.result.name; } callback.reject(error, data.id, state); } } else { console.warn('Unknown RPC response:', data); } } _getCallback(id) { // Response handlers by id have priority to more general ones by command if (this._responseHandlers.has(id)) { return this._responseHandlers.get(id); } else { const command = this._waitingRequests.getCommand(id); if (command) { return this._responseHandlers.get(command); } } return undefined; } } class PostMessageRpcClient extends RpcClient { constructor(targetWindow, allowedOrigin) { super(allowedOrigin); this._serverCloseCheckInterval = -1; this._target = targetWindow; this._connectionState = 0 /* DISCONNECTED */; this._receiveListener = this._receive.bind(this); } async init() { if (this._connectionState === 2 /* CONNECTED */) { return; } await this._connect(); window.addEventListener('message', this._receiveListener); if (this._serverCloseCheckInterval !== -1) return; this._serverCloseCheckInterval = window.setInterval(() => this._checkIfServerClosed(), 300); } async call(command, ...args) { return this._call({ command, args, id: RandomUtils.generateRandomId(), }); } async callAndPersist(command, ...args) { return this._call({ command, args, id: RandomUtils.generateRandomId(), persistInUrl: true, }); } close() { // Clean up old requests and disconnect. Note that until the popup get's closed by the user // it's possible to connect again though by calling init. this._connectionState = 0 /* DISCONNECTED */; window.removeEventListener('message', this._receiveListener); window.clearInterval(this._serverCloseCheckInterval); this._serverCloseCheckInterval = -1; for (const [id, { reject }] of this._responseHandlers) { const state = this._waitingRequests.getState(id); reject('Connection was closed', typeof id === 'number' ? id : undefined, state); } this._waitingRequests.clear(); this._responseHandlers.clear(); if (this._target && this._target.closed) this._target = null; } _receive(message) { if (message.source !== this._target) { // ignore messages originating from another client's target window return; } super._receive(message); } async _call(request) { if (!this._target || this._target.closed) { throw new Error('Connection was closed.'); } if (this._connectionState !== 2 /* CONNECTED */) { throw new Error('Client is not connected, call init first'); } return new Promise((resolve, reject) => { // Store the request resolvers this._responseHandlers.set(request.id, { resolve, reject }); this._waitingRequests.add(request.id, request.command); console.debug('RpcClient REQUEST', request.command, request.args); this._target.postMessage(request, this._allowedOrigin); }); } _connect() { if (this._connectionState === 2 /* CONNECTED */) return; this._connectionState = 1 /* CONNECTING */; return new Promise((resolve, reject) => { const connectedListener = (message) => { const { source, origin, data } = message; if (source !== this._target || data.status !== ResponseStatus.OK || data.result !== 'pong' || data.id !== 1 || (this._allowedOrigin !== '*' && origin !== this._allowedOrigin)) return; // Debugging printouts if (data.result.stack) { const error = new Error(data.result.message); error.stack = data.result.stack; if (data.result.name) { error.name = data.result.name; } console.error(error); } window.removeEventListener('message', connectedListener); this._connectionState = 2 /* CONNECTED */; console.log('RpcClient: Connection established'); resolve(true); }; window.addEventListener('message', connectedListener); /** * Send 'ping' command every 100ms, until cancelled */ const tryToConnect = () => { if (this._connectionState === 2 /* CONNECTED */) return; if (this._connectionState === 0 /* DISCONNECTED */ || this._checkIfServerClosed()) { window.removeEventListener('message', connectedListener); reject(new Error('Connection was closed')); return; } try { this._target.postMessage({ command: 'ping', id: 1 }, this._allowedOrigin); } catch (e) { console.error(`postMessage failed: ${e}`); } window.setTimeout(tryToConnect, 100); }; window.setTimeout(tryToConnect, 100); }); } _checkIfServerClosed() { if (this._target && !this._target.closed) return false; this.close(); return true; } } class EventClient { static async create(targetWindow, targetOrigin = '*') { const client = new EventClient(targetWindow, targetOrigin); await client._init(); return client; } constructor(targetWindow, targetOrigin) { this._listeners = new Map(); this._targetWindow = targetWindow; this._targetOrigin = targetOrigin; this._rpcClient = new PostMessageRpcClient(targetWindow, targetOrigin); // We need our own event listener here. self.addEventListener('message', this._receive.bind(this)); } async on(event, callback) { if (!this._listeners.has(event)) { this._listeners.set(event, new Set()); await this._rpcClient.call('on', event); } this._listeners.get(event).add(callback); } async off(event, callback) { if (!this._listeners.has(event)) return; const listeners = this._listeners.get(event); listeners.delete(callback); if (listeners.size === 0) { this._listeners.delete(event); await this._rpcClient.call('off', event); } } call(command, ...args) { return this._rpcClient.call(command, ...args); } close() { this._rpcClient.close(); } _init() { return this._rpcClient.init(); } _receive(message) { const { origin, data: { event, value } } = message; // Discard all messages from unwanted origins or which are not events. if ((this._targetOrigin !== '*' && origin !== this._targetOrigin) || !event) return; const listeners = this._listeners.get(event); if (!listeners) return; for (const listener of listeners) { listener(value); } } } // tslint:enable:interface-over-type-literal class NetworkClient { constructor(endpoint = NetworkClient.DEFAULT_ENDPOINT) { this._apiLoadingState = 'not-started'; this._consensusState = 'syncing'; this._peerCount = 0; this._headInfo = { height: 0, globalHashrate: 0 }; this._balances = new Map(); this._pendingTransactions = new Map(); this._expiredTransactions = []; this._minedTransactions = new Map(); this._relayedTransactions = new Map(); this._endpoint = endpoint + '/v2/'; } static get DEFAULT_ENDPOINT() { return window.location.origin.endsWith('nimiq.com') ? 'https://network.nimiq.com' : 'https://network.nimiq-testnet.com'; } static createInstance(endPoint = NetworkClient.DEFAULT_ENDPOINT) { if (NetworkClient._instance) throw new Error('NetworkClient already instantiated.'); const networkClient = new NetworkClient(endPoint); NetworkClient._instance = networkClient; return networkClient; } static hasInstance() { return !!NetworkClient._instance; } static get Instance() { return NetworkClient._instance || (NetworkClient._instance = new NetworkClient()); } static getAllowedOrigin(endpoint) { const url = new URL(endpoint); return url.origin; } static async _createIframe(src) { const $iframe = document.createElement('iframe'); const promise = new Promise((resolve) => $iframe.addEventListener('load', () => resolve($iframe))); $iframe.src = src; $iframe.name = 'NimiqNetwork'; $iframe.style.display = 'none'; document.body.appendChild($iframe); return promise; } async init() { this._initializationPromise = this._initializationPromise || (async () => { this.$iframe = await NetworkClient._createIframe(this._endpoint); const targetWindow = this.$iframe.contentWindow; this._eventClient = await EventClient.create(targetWindow, NetworkClient.getAllowedOrigin(this._endpoint)); this.on(NetworkClient.Events.API_READY, () => this._apiLoadingState = 'ready'); this.on(NetworkClient.Events.API_FAIL, () => this._apiLoadingState = 'failed'); this.on(NetworkClient.Events.CONSENSUS_SYNCING, () => this._consensusState = 'syncing'); this.on(NetworkClient.Events.CONSENSUS_ESTABLISHED, () => this._consensusState = 'established'); this.on(NetworkClient.Events.CONSENSUS_LOST, () => this._consensusState = 'lost'); this.on(NetworkClient.Events.PEERS_CHANGED, (peerCount) => this._peerCount = peerCount); this.on(NetworkClient.Events.BALANCES_CHANGED, (balances) => this._balances = balances); this.on(NetworkClient.Events.TRANSACTION_PENDING, (tx) => this._pendingTransactions.set(tx.hash, tx)); this.on(NetworkClient.Events.TRANSACTION_EXPIRED, (txHash) => { this._expiredTransactions.push([this.headInfo.height, txHash]); this._pendingTransactions.delete(txHash); this._relayedTransactions.delete(txHash); }); this.on(NetworkClient.Events.TRANSACTION_MINED, (tx) => { this._minedTransactions.set(tx.hash, tx); this._pendingTransactions.delete(tx.hash); this._relayedTransactions.delete(tx.hash); }); this.on(NetworkClient.Events.TRANSACTION_RELAYED, (tx) => { tx.blockHeight = this.headInfo.height; this._relayedTransactions.set(tx.hash, tx); }); this.on(NetworkClient.Events.HEAD_CHANGE, (headInfo) => { this._headInfo = headInfo; this._evictCachedTransactions(); }); })(); try { await this._initializationPromise; } catch (e) { delete this._initializationPromise; throw e; } } async on(event, callback) { this._eventClient.on(event, callback); } async off(event, callback) { this._eventClient.off(event, callback); } async connect() { return this._eventClient.call('connect'); } async disconnect(reason) { return this._eventClient.call('disconnect', reason); } async relayTransaction(txObj) { return this._eventClient.call('relayTransaction', txObj); } async getTransactionSize(txObj) { return this._eventClient.call('getTransactionSize', txObj); } async subscribe(addresses) { return this._eventClient.call('subscribe', addresses); } async getBalance(addresses) { return this._eventClient.call('getBalance', addresses); } async forgetBalances(addresses) { return this._eventClient.call('forgetBalances', addresses); } async getAccounts(addresses) { return this._eventClient.call('getAccounts', addresses); } async getAccountTypeString(address) { return this._eventClient.call('getAccountTypeString', address); } async requestTransactionHistory(addresses, // userfriendly addresses knownReceipts, // Map<txhash (base64), blockhash (base64)> fromHeight) { return this._eventClient.call('requestTransactionHistory', addresses, knownReceipts, fromHeight); } async requestTransactionReceipts(addresses, limit) { return this._eventClient.call('requestTransactionReceipts', addresses, limit); } async getGenesisVestingContracts(modern) { return this._eventClient.call('getGenesisVestingContracts', modern); } async removeTxFromMempool(txObj) { return this._eventClient.call('removeTxFromMempool', txObj); } async getPeerAddresses() { return this._eventClient.call('getPeerAddresses'); } // MODERN async sendTransaction(tx) { return this._eventClient.call('sendTransaction', tx); } async getTransactionsByAddress(address, sinceHeight, knownDetails, limit) { return this._eventClient.call('getTransactionsByAddress', address, sinceHeight, knownDetails, limit); } async addTransactionListener(listener, addresses) { const eventName = `transaction-listener-${Math.round(Math.random() * 1e8)}`; this._eventClient.on(eventName, listener); return this._eventClient.call('addTransactionListener', eventName, addresses); } async addConsensusChangedListener(listener) { const eventName = `consensus-listener-${Math.round(Math.random() * 1e8)}`; this._eventClient.on(eventName, listener); return this._eventClient.call('addConsensusChangedListener', eventName); } async resetConsensus() { return this._eventClient.call('resetConsensus'); } async removeListener(handle) { return this._eventClient.call('removeListener', handle); } // Getter get apiLoadingState() { return this._apiLoadingState; } get consensusState() { return this._consensusState; } get peerCount() { return this._peerCount; } get headInfo() { return this._headInfo; } get balances() { return this._balances; } get pendingTransactions() { return this._pendingTransactions.values(); } get minedTransactions() { return this._minedTransactions.values(); } get relayedTransactions() { return this._relayedTransactions.values(); } /** @returns base64 transaction hashes */ get expiredTransactions() { return this._expiredTransactions.map(([height, txHash]) => txHash); } // Private methods _evictCachedTransactions() { const CACHE_DURATION = 30; // purge expired transactions for (let i = 0; i < this._expiredTransactions.length; ++i) { const [expiredAt] = this._expiredTransactions[i]; if (expiredAt + CACHE_DURATION <= this.headInfo.height) { this._expiredTransactions.splice(i, 1); --i; } } // purge mined transactions for (const tx of this._minedTransactions.values()) { if (tx.blockHeight + CACHE_DURATION <= this.headInfo.height) { this._minedTransactions.delete(tx.hash); } } } } NetworkClient._instance = null; (function (NetworkClient) { let Events; (function (Events) { Events["API_READY"] = "nimiq-api-ready"; Events["API_FAIL"] = "nimiq-api-fail"; Events["CONSENSUS_SYNCING"] = "nimiq-consensus-syncing"; Events["CONSENSUS_ESTABLISHED"] = "nimiq-consensus-established"; Events["CONSENSUS_LOST"] = "nimiq-consensus-lost"; Events["PEERS_CHANGED"] = "nimiq-peer-count"; Events["BALANCES_CHANGED"] = "nimiq-balances"; Events["TRANSACTION_PENDING"] = "nimiq-transaction-pending"; Events["TRANSACTION_EXPIRED"] = "nimiq-transaction-expired"; Events["TRANSACTION_MINED"] = "nimiq-transaction-mined"; Events["TRANSACTION_RELAYED"] = "nimiq-transaction-relayed"; Events["HEAD_CHANGE"] = "nimiq-head-change"; Events["HEAD_HEIGHT"] = "head-height"; Events["CONSENSUS"] = "consensus"; Events["BALANCES"] = "balances"; Events["TRANSACTION"] = "transaction"; Events["PEER_COUNT"] = "peer-count"; Events["PEER_ADDRESSES_ADDED"] = "peer-addresses-added"; })(Events = NetworkClient.Events || (NetworkClient.Events = {})); })(NetworkClient || (NetworkClient = {})); export { NetworkClient };