@nimiq/network-client
Version:
711 lines (702 loc) • 27.5 kB
JavaScript
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 };