From 0b4fe283e15ac7e7e872efb359332a6f8ba85574 Mon Sep 17 00:00:00 2001 From: bludnic Date: Fri, 27 Oct 2023 19:44:00 +0100 Subject: [PATCH 01/50] refactor(): rewrite healthcheck `AdmNode` --- src/lib/adamant-api-client.js | 287 +--------------------------------- src/lib/nodes/AdmNode.ts | 233 +++++++++++++++++++++++++++ 2 files changed, 240 insertions(+), 280 deletions(-) create mode 100644 src/lib/nodes/AdmNode.ts diff --git a/src/lib/adamant-api-client.js b/src/lib/adamant-api-client.js index b5c558554..ffb9b5eb7 100644 --- a/src/lib/adamant-api-client.js +++ b/src/lib/adamant-api-client.js @@ -1,6 +1,5 @@ -import axios from 'axios' +import { AdmNode } from "@/lib/nodes/AdmNode"; -import utils from './adamant' import config from '../config' import semver from 'semver' @@ -17,278 +16,6 @@ const HEIGHT_EPSILON = 10 */ const REVISE_CONNECTION_TIMEOUT = 5000 -/** - * Protocol on host where app is running, f. e., http: or https: - */ -const appProtocol = location.protocol - -/** - * @typedef {Object} RequestConfig - * @property {String} url request relative URL - * @property {string} method request method (defaults to 'get') - * @property {any} payload request payload - */ - -/** - * Custom error to indicate that the endpoint is not available - */ -class NodeOfflineError extends Error { - constructor (...args) { - super('Node is offline', ...args) - this.code = 'NODE_OFFLINE' - - if (Error.captureStackTrace) { - Error.captureStackTrace(this, NodeOfflineError) - } - } -} - -/** - * Encapsulates an ADAMANT node. Provides methods to send API-requests - * to the node and verify is status (online/offline, version, ping, etc.) - */ -class ApiNode { - constructor (baseUrl) { - /** - * Indicates whether node is active (i.e. user allows the application - * to interact with this node). - * @type {Boolean} - */ - this.active = true - - /** - * Indicates whether node is out of sync (i.e. its block height is - * either too big or too small compared to the other nodes) - * @type {Boolean} - */ - this.outOfSync = false - - this._baseUrl = baseUrl - this._protocol = new URL(baseUrl).protocol - this._port = new URL(baseUrl).port - this._hostname = new URL(baseUrl).hostname - this._wsPort = '36668' // default wsPort - this._wsProtocol = this._protocol === 'https:' ? 'wss:' : 'ws:' - this._wsPortNeeded = this._wsProtocol === 'ws:' && !this._hostname.includes('.onion') - this._hasSupportedProtocol = !(this._protocol === 'http:' && appProtocol === 'https:') - - this._online = false - this._ping = Infinity - this._timeDelta = 0 - this._version = '' - this._height = 0 - this._socketSupport = false - - this._client = axios.create({ - baseURL: this._baseUrl - }) - } - - /** - * Node base URL - * @type {String} - */ - get url () { - return this._baseUrl - } - - /** - * Node port like 36666 for http nodes (default) - * @type {String} - */ - get port () { - return this._port - } - - /** - * Node socket port like 36668 (default) - * @type {String} - */ - get wsPort () { - return this._wsPort - } - - /** - * Node hostname like bid.adamant.im or 23.226.231.225 - * @type {String} - */ - get hostname () { - return this._hostname - } - - /** - * Node protocol, like http: or https: - * @type {String} - */ - get protocol () { - return this._protocol - } - - /** - * Socket protocol, ws: or wss: - * @type {String} - */ - get wsProtocol () { - return this._wsProtocol - } - - /** - * If Socket port like :36668 needed for connection - * @type {String} - */ - get wsPortNeeded () { - return this._wsPortNeeded - } - - /** - * Node API version. - * @type {String} - */ - get version () { - return this._version - } - - /** - * Indicates whether node is available. - * @type {Boolean} - */ - get online () { - return this._online - } - - /** - * Node ping estimation - * @type {Number} - */ - get ping () { - return this._ping - } - - /** - * Delta between local time and the node time - * @type {Number} - */ - get timeDelta () { - return this._timeDelta - } - - /** - * Current block height - * @type {Number} - */ - get height () { - return this._height - } - - get socketSupport () { - return this._socketSupport - } - - /** - * Performs an API request. - * - * The `payload` of the `cfg` can be either an object or a function that - * accepts `ApiNode` as a first argument and returns an object. - * @param {RequestConfig} cfg config - * @returns {Promise} - */ - request (cfg) { - let { url, method, payload } = cfg - - method = (method || 'get').toLowerCase() - - if (typeof payload === 'function') { - payload = payload(this) - } - - const config = { - url, - method, - [method === 'get' ? 'params' : 'data']: payload - } - - return this._client.request(config).then( - response => { - const body = response.data - // Refresh time delta on each request - if (body && isFinite(body.nodeTimestamp)) { - this._timeDelta = utils.epochTime() - body.nodeTimestamp - } - - return body - }, - error => { - // According to https://github.com/axios/axios#handling-errors this means, that request was sent, - // but server could not respond. - if (!error.response && error.request) { - this._online = false - throw new NodeOfflineError() - } - throw error - } - ) - } - - /** - * Initiates node status update: version, ping, online/offline. - * @returns {PromiseLike} - */ - updateStatus () { - return this._getNodeStatus() - .then(status => { - if (status.version) { - this._version = status.version - } - if (status.height) { - this._height = status.height - } - if (status.ping) { - this._ping = status.ping - } - if (status.wsPort) { - this._wsPort = status.wsPort - } - this._online = status.online - this._socketSupport = status.socketSupport - }) - .catch(() => { - this._online = false - this._socketSupport = false - }) - } - - /** - * Gets node version, block height and ping. - * @returns {Promise<{version: string, height: number, ping: number}>} - */ - _getNodeStatus () { - if (!this._hasSupportedProtocol) { - return new Promise((resolve) => { - resolve({ - online: false, - socketSupport: false - }) - }) - } else { - const time = Date.now() - return this.request({ url: '/api/node/status' }) - .then(res => { - if (res.success) { - return { - online: true, - version: res.version.version, - height: Number(res.network.height), - ping: Date.now() - time, - socketSupport: res.wsClient && res.wsClient.enabled, - wsPort: res.wsClient ? res.wsClient.port : false - } - } - throw new Error('Request to /api/node/status was unsuccessful') - }) - } - } -} - /** * Provides methods for calling the ADAMANT API. * @@ -306,7 +33,7 @@ class ApiClient { * List of the available nodes * @type {Array} */ - this._nodes = endpoints.map(x => new ApiNode(x)) + this._nodes = endpoints.map(x => new AdmNode(x)) /** * Minimum API version a node is required to have @@ -329,17 +56,17 @@ class ApiClient { url: node.url, port: node.port, hostname: node.hostname, - protocol: node._protocol, - wsProtocol: node._wsProtocol, - wsPort: node._wsPort, - wsPortNeeded: node._wsPortNeeded, + protocol: node.protocol, + wsProtocol: node.wsProtocol, + wsPort: node.wsPort, + wsPortNeeded: node.wsPortNeeded, online: node.online, ping: node.ping, version: node.version, active: node.active, outOfSync: node.outOfSync, hasMinNodeVersion: node.version >= this._minNodeVersion, - hasSupportedProtocol: node._hasSupportedProtocol, + hasSupportedProtocol: node.hasSupportedProtocol, socketSupport: node.socketSupport }) diff --git a/src/lib/nodes/AdmNode.ts b/src/lib/nodes/AdmNode.ts new file mode 100644 index 000000000..b15668425 --- /dev/null +++ b/src/lib/nodes/AdmNode.ts @@ -0,0 +1,233 @@ +import utils from '@/lib/adamant' +import { GetNodeStatusResponseDto } from '@/lib/schema/client' +import axios, { AxiosInstance, AxiosRequestConfig } from 'axios' + +/** + * Protocol on host where app is running, f. e., http: or https: + */ +const appProtocol = location.protocol + +/** + * Custom error to indicate that the endpoint is not available + */ +class NodeOfflineError extends Error { + code = 'NODE_OFFLINE' + + constructor() { + super('Node is offline') + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, NodeOfflineError) + } + } +} + +type NodeStatus = { + online: boolean + socketSupport: boolean + version?: string + height?: number + ping?: number + wsPort?: string +} + +type Payload = + | Record + | { + (ctx: AdmNode): Record + } +type RequestConfig

= { + url: string + method?: string + payload?: P +} + +/** + * Encapsulates a node. Provides methods to send API-requests + * to the node and verify is status (online/offline, version, ping, etc.) + */ +export class AdmNode { + /** + * Indicates whether node is active (i.e. user allows the application + * to interact with this node). + */ + active = true + + /** + * Indicates whether node is out of sync (i.e. its block height is + * either too big or too small compared to the other nodes) + */ + outOfSync = false + + /** + * Default `wsPort`. Will be updated after `GET /api/node/status` + */ + wsPort = '36668' + + /** + * Node base URL + */ + baseUrl: string + /** + * Node protocol, like http: or https: + */ + protocol: string + /** + * Node port like 36666 for http nodes (default) + */ + port: string + /** + * Node hostname like bid.adamant.im or 23.226.231.225 + */ + hostname: string + /** + * WebSocket protocol + */ + wsProtocol: 'ws:' | 'wss:' + /** + * If Socket port like :36668 needed for connection + */ + wsPortNeeded: boolean + hasSupportedProtocol: boolean + + // Healthcheck related params + /** + * Indicates whether node is available. + */ + online = false + /** + * Node ping estimation + */ + ping = Infinity + /** + * Delta between local time and the node time + */ + timeDelta = 0 + /** + * Node API version. + */ + version = '' + /** + * Current block height + */ + height = 0 + /** + * Will be updated after `GET /api/node/status` + */ + socketSupport = false + + client: AxiosInstance + + constructor(baseUrl: string) { + this.baseUrl = baseUrl + this.protocol = new URL(baseUrl).protocol + this.port = new URL(baseUrl).port + this.hostname = new URL(baseUrl).hostname + this.wsPort = '36668' // default wsPort + this.wsProtocol = this.protocol === 'https:' ? 'wss:' : 'ws:' + this.wsPortNeeded = this.wsProtocol === 'ws:' && !this.hostname.includes('.onion') + this.hasSupportedProtocol = !(this.protocol === 'http:' && appProtocol === 'https:') + + this.client = axios.create({ + baseURL: this.baseUrl + }) + } + + get url() { + return this.baseUrl + } + + /** + * Performs an API request. + * + * The `payload` of the `cfg` can be either an object or a function that + * accepts `ApiNode` as a first argument and returns an object. + */ + request

(cfg: RequestConfig

) { + const { url, method = 'get', payload } = cfg + + const config: AxiosRequestConfig = { + url, + method: method.toLowerCase(), + [method === 'get' ? 'params' : 'data']: + typeof payload === 'function' ? payload(this) : payload + } + + return this.client.request(config).then( + (response) => { + const body = response.data + // Refresh time delta on each request + if (body && isFinite(body.nodeTimestamp)) { + this.timeDelta = utils.epochTime() - body.nodeTimestamp + } + + return body + }, + (error) => { + // According to https://github.com/axios/axios#handling-errors this means, that request was sent, + // but server could not respond. + if (!error.response && error.request) { + this.online = false + throw new NodeOfflineError() + } + throw error + } + ) + } + + /** + * Initiates node status update: version, ping, online/offline. + * @returns {PromiseLike} + */ + async updateStatus() { + try { + const status = await this.getNodeStatus() + + if (status.version) { + this.version = status.version + } + if (status.height) { + this.height = status.height + } + if (status.ping) { + this.ping = status.ping + } + if (status.wsPort) { + this.wsPort = status.wsPort + } + this.online = status.online + this.socketSupport = status.socketSupport + } catch (err) { + this.online = false + this.socketSupport = false + } + } + + /** + * Gets node version, block height and ping. + * @returns {Promise<{version: string, height: number, ping: number}>} + */ + private async getNodeStatus(): Promise { + if (!this.hasSupportedProtocol) { + return Promise.reject({ + online: false, + socketSupport: false + }) + } + + const time = Date.now() + const response: GetNodeStatusResponseDto = await this.request({ url: '/api/node/status' }) + if (response.success) { + return { + online: true, + version: response.version.version, + height: Number(response.network.height), + ping: Date.now() - time, + socketSupport: response.wsClient ? response.wsClient.enabled : false, + wsPort: response.wsClient ? String(response.wsClient.port) : undefined + } + } + + throw new Error('Request to /api/node/status was unsuccessful') + } +} From cf43568f9a3f5db719040365ede71bebbb54d3ba Mon Sep 17 00:00:00 2001 From: bludnic Date: Sun, 29 Oct 2023 01:58:21 +0100 Subject: [PATCH 02/50] feat(Web3): add custom HttpProvider --- package-lock.json | 23 +++-- package.json | 3 + src/lib/nodes/EthNode/HttpProvider.ts | 130 ++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 10 deletions(-) create mode 100644 src/lib/nodes/EthNode/HttpProvider.ts diff --git a/package-lock.json b/package-lock.json index 2e5b9b5a5..81cad3348 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "bytebuffer": "^5.0.1", "coininfo": "^5.2.1", "copy-to-clipboard": "^3.3.3", + "cross-fetch": "^4.0.0", "crypto-browserify": "^3.12.0", "dayjs": "^1.11.10", "deepmerge": "^4.3.1", @@ -71,10 +72,12 @@ "vuetify": "^3.3.17", "vuex": "^4.1.0", "vuex-persist": "^3.1.3", + "web3-errors": "^1.1.3", "web3-eth": "^4.2.0", "web3-eth-abi": "^4.1.3", "web3-eth-accounts": "^4.0.6", "web3-eth-contract": "^4.1.0", + "web3-providers-http": "^4.1.0", "web3-utils": "^4.0.6" }, "devDependencies": { @@ -6738,9 +6741,9 @@ } }, "node_modules/cross-fetch": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", - "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", "dependencies": { "node-fetch": "^2.6.12" } @@ -15087,14 +15090,14 @@ } }, "node_modules/web3-providers-http": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/web3-providers-http/-/web3-providers-http-4.0.6.tgz", - "integrity": "sha512-FnBw0X25Xu0FejOgY2Ra7WY4p3fSrHxZuQ5a4j0ytDCE+0wxKQN0BaLRC7+uigbVvwEziQwzrhe+tn8bYAQKXQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/web3-providers-http/-/web3-providers-http-4.1.0.tgz", + "integrity": "sha512-6qRUGAhJfVQM41E5t+re5IHYmb5hSaLc02BE2MaRQsz2xKA6RjmHpOA5h/+ojJxEpI9NI2CrfDKOAgtJfoUJQg==", "dependencies": { - "cross-fetch": "^3.1.5", - "web3-errors": "^1.1.2", - "web3-types": "^1.2.0", - "web3-utils": "^4.0.6" + "cross-fetch": "^4.0.0", + "web3-errors": "^1.1.3", + "web3-types": "^1.3.0", + "web3-utils": "^4.0.7" }, "engines": { "node": ">=14", diff --git a/package.json b/package.json index d648a7139..002a9d660 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "bytebuffer": "^5.0.1", "coininfo": "^5.2.1", "copy-to-clipboard": "^3.3.3", + "cross-fetch": "^4.0.0", "crypto-browserify": "^3.12.0", "dayjs": "^1.11.10", "deepmerge": "^4.3.1", @@ -86,10 +87,12 @@ "vuetify": "^3.3.17", "vuex": "^4.1.0", "vuex-persist": "^3.1.3", + "web3-errors": "^1.1.3", "web3-eth": "^4.2.0", "web3-eth-abi": "^4.1.3", "web3-eth-accounts": "^4.0.6", "web3-eth-contract": "^4.1.0", + "web3-providers-http": "^4.1.0", "web3-utils": "^4.0.6" }, "devDependencies": { diff --git a/src/lib/nodes/EthNode/HttpProvider.ts b/src/lib/nodes/EthNode/HttpProvider.ts new file mode 100644 index 000000000..7775c587c --- /dev/null +++ b/src/lib/nodes/EthNode/HttpProvider.ts @@ -0,0 +1,130 @@ +/* +This file is part of web3.js. + +web3.js is free software: you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +web3.js is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License +along with web3.js. If not, see . +*/ + +import fetch from 'cross-fetch' +import { + EthExecutionAPI, + JsonRpcResponseWithResult, + Web3APIMethod, + Web3APIPayload, + Web3APIReturnType, + Web3APISpec, + Web3BaseProvider, + Web3ProviderStatus +} from 'web3-types' +import { InvalidClientError, MethodNotImplementedError, ResponseError } from 'web3-errors' +import type { HttpProviderOptions } from 'web3-providers-http' + +/** + * @source https://github.com/web3/web3.js/blob/4.x/packages/web3-providers-http/src/index.ts + */ +export default class HttpProvider< + API extends Web3APISpec = EthExecutionAPI, +> extends Web3BaseProvider { + private readonly clientUrl: string; + private readonly httpProviderOptions: HttpProviderOptions | undefined; + + public constructor(clientUrl: string, httpProviderOptions?: HttpProviderOptions) { + super(); + if (!HttpProvider.validateClientUrl(clientUrl)) throw new InvalidClientError(clientUrl); + this.clientUrl = clientUrl; + this.httpProviderOptions = httpProviderOptions; + } + + private static validateClientUrl(clientUrl: string): boolean { + return typeof clientUrl === 'string' ? /^http(s)?:\/\//i.test(clientUrl) : false; + } + + /* eslint-disable class-methods-use-this */ + public getStatus(): Web3ProviderStatus { + throw new MethodNotImplementedError(); + } + + /* eslint-disable class-methods-use-this */ + public supportsSubscriptions() { + return false; + } + + public async request< + Method extends Web3APIMethod, + ResultType = Web3APIReturnType, + >( + payload: Web3APIPayload, + requestOptions?: RequestInit, + ): Promise> { + const providerOptionsCombined = { + ...this.httpProviderOptions?.providerOptions, + ...requestOptions, + }; + const response = await fetch(this.clientUrl, { + ...providerOptionsCombined, + method: 'POST', + headers: { + ...providerOptionsCombined.headers, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + if (!response.ok) throw new ResponseError(await response.json()); + + return (await response.json()) as JsonRpcResponseWithResult; + } + + /* eslint-disable class-methods-use-this */ + public on() { + throw new MethodNotImplementedError(); + } + + /* eslint-disable class-methods-use-this */ + public removeListener() { + throw new MethodNotImplementedError(); + } + + /* eslint-disable class-methods-use-this */ + public once() { + throw new MethodNotImplementedError(); + } + + /* eslint-disable class-methods-use-this */ + public removeAllListeners() { + throw new MethodNotImplementedError(); + } + + /* eslint-disable class-methods-use-this */ + public connect() { + throw new MethodNotImplementedError(); + } + + /* eslint-disable class-methods-use-this */ + public disconnect() { + throw new MethodNotImplementedError(); + } + + /* eslint-disable class-methods-use-this */ + public reset() { + throw new MethodNotImplementedError(); + } + + /* eslint-disable class-methods-use-this */ + public reconnect() { + throw new MethodNotImplementedError(); + } +} + +export { HttpProvider }; From bfa496f9c80f2588549e91c87c4a7c249cec1730 Mon Sep 17 00:00:00 2001 From: bludnic Date: Sun, 29 Oct 2023 18:00:25 +0000 Subject: [PATCH 03/50] refactor(): rewrite AdmApiClient to TS --- src/lib/adamant-api/index.js | 2 +- .../adm/AdmClient.ts} | 198 ++++++++---------- src/lib/nodes/{ => adm}/AdmNode.ts | 57 ++++- src/store/modules/nodes/nodes-actions.js | 6 +- src/store/modules/nodes/nodes-plugin.js | 12 +- 5 files changed, 150 insertions(+), 125 deletions(-) rename src/lib/{adamant-api-client.js => nodes/adm/AdmClient.ts} (54%) rename src/lib/nodes/{ => adm}/AdmNode.ts (79%) diff --git a/src/lib/adamant-api/index.js b/src/lib/adamant-api/index.js index 6f591dac1..a58db27a4 100644 --- a/src/lib/adamant-api/index.js +++ b/src/lib/adamant-api/index.js @@ -3,7 +3,7 @@ import { Base64 } from 'js-base64' import { Transactions, Delegates, MessageType } from '@/lib/constants' import utils from '@/lib/adamant' -import client from '@/lib/adamant-api-client' +import client from '@/lib/nodes/adm/AdmClient' import { encryptPassword } from '@/lib/idb/crypto' import { restoreState } from '@/lib/idb/state' import { i18n } from '@/i18n' diff --git a/src/lib/adamant-api-client.js b/src/lib/nodes/adm/AdmClient.ts similarity index 54% rename from src/lib/adamant-api-client.js rename to src/lib/nodes/adm/AdmClient.ts index ffb9b5eb7..fd5cafbd7 100644 --- a/src/lib/adamant-api-client.js +++ b/src/lib/nodes/adm/AdmClient.ts @@ -1,6 +1,7 @@ -import { AdmNode } from "@/lib/nodes/AdmNode"; +import { NodeInfo } from '@/types/wallets' +import { AdmNode, Payload, RequestConfig } from './AdmNode' -import config from '../config' +import config from '@/config' import semver from 'semver' /** @@ -23,68 +24,44 @@ const REVISE_CONNECTION_TIMEOUT = 5000 * send the API-requests to and switches to another node if the current one * is not available at the moment. */ -class ApiClient { +class AdmClient { /** - * Creates new client instance - * @param {Array} endpoints endpoints URLs + * List of the available nodes */ - constructor (endpoints = [], minNodeVersion = '0.0.0') { - /** - * List of the available nodes - * @type {Array} - */ - this._nodes = endpoints.map(x => new AdmNode(x)) + nodes: AdmNode[] + /** + * Minimum API version a node is required to have + */ + minNodeVersion: string + /** + * A callback that is called every time a node status is updated + */ + statusUpdateCallback?: (status: ReturnType) => void + /** + * Indicates wether `ApiClient` should prefer the fastest node available. + */ + useFastest: boolean + /** + * This promise is resolved whenever we get at least one compatible online node + * after the status update. + */ + statusPromise: Promise + onInit?: (error?: Error) => void - /** - * Minimum API version a node is required to have - * @type {String} - */ - this._minNodeVersion = minNodeVersion - /** - * A callback that is called every time a node status is updated - * @type {function({url: string, ping: number, online: boolean}): void} - */ - this._onStatusUpdate = null + constructor(endpoints: string[] = [], minNodeVersion = '0.0.0') { + this.nodes = endpoints.map((endpoint) => new AdmNode(endpoint)) + this.minNodeVersion = minNodeVersion - /** - * Indicates wether `ApiClient` should prefer the fastest node available. - * @type {Boolean} - */ this.useFastest = false - this._nodeStatus = node => ({ - url: node.url, - port: node.port, - hostname: node.hostname, - protocol: node.protocol, - wsProtocol: node.wsProtocol, - wsPort: node.wsPort, - wsPortNeeded: node.wsPortNeeded, - online: node.online, - ping: node.ping, - version: node.version, - active: node.active, - outOfSync: node.outOfSync, - hasMinNodeVersion: node.version >= this._minNodeVersion, - hasSupportedProtocol: node.hasSupportedProtocol, - socketSupport: node.socketSupport - }) - - this._onInit = null - - /** - * This promise is resolved whenever we get at least one compatible online node - * after the status update. - * @type {Promise} - */ - this._statusPromise = new Promise((resolve, reject) => { - this._onInit = error => { + this.statusPromise = new Promise((resolve, reject) => { + this.onInit = (error) => { if (error) { reject(error) } else { resolve() } - this._onInit = null + this.onInit = undefined } }) } @@ -93,27 +70,29 @@ class ApiClient { * Returns endpoint statuses * @returns {Array<{ url: string, online: boolean, ping: number }>} */ - getNodes () { - return this._nodes.map(this._nodeStatus) + getNodes() { + return this.nodes.map((node) => node.getNodeStatus()) } /** * Initiates the status update for each of the known nodes. */ - updateStatus () { - this._statusPromise = new Promise((resolve, reject) => { + updateStatus() { + this.statusPromise = new Promise((resolve, reject) => { let done = false - const promises = this._nodes.filter(x => x.active).map(x => { - return x.updateStatus().then(() => { - this._fireStatusUpdate(x) - // Resolve the `_statusPromise` if it's a good node. - if (!done && x.online && this._isCompatible(x.version)) { - done = true - resolve() - } + const promises = this.nodes + .filter((x) => x.active) + .map((x) => { + return x.updateStatus().then(() => { + this.fireStatusUpdate(x) + // Resolve the `_statusPromise` if it's a good node. + if (!done && x.online && this.isCompatible(x.version)) { + done = true + resolve() + } + }) }) - }) Promise.all(promises).then(() => { // If all nodes have been checked and none of them is online or @@ -124,13 +103,15 @@ class ApiClient { // Schedule a status update after a while setTimeout(() => this.updateStatus(), REVISE_CONNECTION_TIMEOUT) } else { - this._updateSyncStatuses() + this.updateSyncStatuses() } }) }).then( - () => { if (this._onInit) this._onInit() }, - error => { - if (this._onInit) this._onInit(error) + () => { + if (this.onInit) this.onInit() + }, + (error) => { + if (this.onInit) this.onInit(error) return Promise.reject(error) } ) @@ -141,8 +122,8 @@ class ApiClient { * @param {String} url node URL * @param {Boolean} active set node active or not */ - toggleNode (url, active) { - const node = this._nodes.find(x => x.url === url) + toggleNode(url: string, active: boolean) { + const node = this.nodes.find((x) => x.url === url) if (node) { node.active = active } @@ -153,7 +134,7 @@ class ApiClient { * @param {String} url relative API url * @param {any} params request params (an object) or a function that accepts `ApiNode` and returns the request params */ - get (url, params) { + get

(url: string, params: P) { return this.request({ method: 'get', url, payload: params }) } @@ -162,7 +143,7 @@ class ApiClient { * @param {String} url relative API url * @param {any} payload request payload (an object) or a function that accepts `ApiNode` and returns the request payload */ - post (url, payload) { + post

(url: string, payload: P) { return this.request({ method: 'post', url, payload }) } @@ -170,12 +151,10 @@ class ApiClient { * Performs an API request. * @param {RequestConfig} config request config */ - request (config) { + request

(config: RequestConfig

): Promise { // First wait until we get at least one compatible node - return this._statusPromise.then(() => { - const node = this.useFastest - ? this._getFastestNode() - : this._getRandomNode() + return this.statusPromise.then(() => { + const node = this.useFastest ? this.getFastestNode() : this.getRandomNode() if (!node) { // All nodes seem to be offline: let's refresh the statuses @@ -184,10 +163,10 @@ class ApiClient { return Promise.reject(new Error('No online nodes at the moment')) } - return node.request(config).catch(error => { + return node.request(config).catch((error) => { if (error.code === 'NODE_OFFLINE') { // Notify the world that the node is down - this._fireStatusUpdate(node) + this.fireStatusUpdate(node) // Initiate nodes status check this.updateStatus() // If the selected node is not available, repeat the request with another one. @@ -202,20 +181,17 @@ class ApiClient { * Registers a status update callback. * @param {function({url: string, ping: number, online: boolean}): void} callback callback function */ - onStatusUpdate (callback) { - this._onStatusUpdate = callback + onStatusUpdate(callback: typeof this.statusUpdateCallback) { + this.statusUpdateCallback = callback } /** * Returns a random node. * @returns {ApiNode} */ - _getRandomNode () { - const onlineNodes = this._nodes.filter(x => - x.online && - x.active && - !x.outOfSync && - this._isCompatible(x.version) + private getRandomNode() { + const onlineNodes = this.nodes.filter( + (x) => x.online && x.active && !x.outOfSync && this.isCompatible(x.version) ) const node = onlineNodes[Math.floor(Math.random() * onlineNodes.length)] return node @@ -223,20 +199,24 @@ class ApiClient { /** * Returns the fastest node. - * @returns {ApiNode} */ - _getFastestNode () { - return this._nodes.reduce((fastest, current) => { - if (!current.online || !current.active || current.outOfSync || !this._isCompatible(current.version)) { + private getFastestNode() { + return this.nodes.reduce((fastest, current) => { + if ( + !current.online || + !current.active || + current.outOfSync || + !this.isCompatible(current.version) + ) { return fastest } - return (!fastest || fastest.ping > current.ping) ? current : fastest + return !fastest || fastest.ping > current.ping ? current : fastest }) } - _fireStatusUpdate (node) { - if (typeof this._onStatusUpdate === 'function') { - this._onStatusUpdate(this._nodeStatus(node)) + private fireStatusUpdate(node: AdmNode) { + if (typeof this.statusUpdateCallback === 'function') { + this.statusUpdateCallback(node.getNodeStatus()) } } @@ -245,8 +225,8 @@ class ApiClient { * @param {string} version version to check * @returns {boolean} */ - _isCompatible (version) { - return !!(version && semver.gte(version, this._minNodeVersion)) + private isCompatible(version: string) { + return !!(version && semver.gte(version, this.minNodeVersion)) } /** @@ -256,22 +236,22 @@ class ApiClient { * height (considering HEIGHT_EPSILON). These nodes are considered to be in sync with the network, * all the others are not. */ - _updateSyncStatuses () { - const nodes = this._nodes.filter(x => x.online && x.active) + private updateSyncStatuses() { + const nodes = this.nodes.filter((x) => x.online && x.active) // For each node we take its height and list of nodes that have the same height ± epsilon - const grouped = nodes.map(node => { + const grouped = nodes.map((node) => { return { /** In case of "win" this height will be considered to be real height of the network */ height: node.height, /** List of nodes with the same (or close) height, including current one */ - nodes: nodes.filter(x => Math.abs(node.height - x.height) <= HEIGHT_EPSILON) + nodes: nodes.filter((x) => Math.abs(node.height - x.height) <= HEIGHT_EPSILON) } }) // A group with the longest same-height nodes list wins. // If two groups have the same number of nodes, the one with the biggest height wins. - const winner = grouped.reduce((out, x) => { + const winner = grouped.reduce<{ height: number; nodes: AdmNode[] } | null>((out, x) => { if (!out) return x if (out.nodes.length < x.nodes.length || out.height < x.height) return x return out @@ -279,14 +259,16 @@ class ApiClient { // Finally, all the nodes from the winner list are considered to be in sync, all the // others are not - nodes.forEach(node => { + nodes.forEach((node) => { + if (!winner) return + node.outOfSync = !winner.nodes.includes(node) - this._fireStatusUpdate(node) + this.fireStatusUpdate(node) }) } } -const endpoints = config.adm.nodes.map(endpoint => endpoint.url) -const apiClient = new ApiClient(endpoints, config.adm.minNodeVersion) +const endpoints = (config.adm.nodes as NodeInfo[]).map((endpoint) => endpoint.url) +const apiClient = new AdmClient(endpoints, config.adm.minNodeVersion) export default apiClient diff --git a/src/lib/nodes/AdmNode.ts b/src/lib/nodes/adm/AdmNode.ts similarity index 79% rename from src/lib/nodes/AdmNode.ts rename to src/lib/nodes/adm/AdmNode.ts index b15668425..555cef808 100644 --- a/src/lib/nodes/AdmNode.ts +++ b/src/lib/nodes/adm/AdmNode.ts @@ -22,7 +22,7 @@ class NodeOfflineError extends Error { } } -type NodeStatus = { +type FetchNodeStatusResult = { online: boolean socketSupport: boolean version?: string @@ -31,12 +31,30 @@ type NodeStatus = { wsPort?: string } -type Payload = +type GetNodeStatusResult = { + url: string + port: string + hostname: string + protocol: string + wsProtocol: 'ws:' | 'wss:' + wsPort: string + wsPortNeeded: boolean + online: boolean + ping: number + version: string + active: boolean + outOfSync: boolean + hasMinNodeVersion: boolean + hasSupportedProtocol: boolean + socketSupport: boolean +} + +export type Payload = | Record | { (ctx: AdmNode): Record } -type RequestConfig

= { +export type RequestConfig

= { url: string method?: string payload?: P @@ -107,6 +125,10 @@ export class AdmNode { * Node API version. */ version = '' + /** + * Minimal required Node API version. + */ + minNodeVersion: string /** * Current block height */ @@ -118,7 +140,7 @@ export class AdmNode { client: AxiosInstance - constructor(baseUrl: string) { + constructor(baseUrl: string, minNodeVersion = '0.0.0') { this.baseUrl = baseUrl this.protocol = new URL(baseUrl).protocol this.port = new URL(baseUrl).port @@ -127,6 +149,7 @@ export class AdmNode { this.wsProtocol = this.protocol === 'https:' ? 'wss:' : 'ws:' this.wsPortNeeded = this.wsProtocol === 'ws:' && !this.hostname.includes('.onion') this.hasSupportedProtocol = !(this.protocol === 'http:' && appProtocol === 'https:') + this.minNodeVersion = minNodeVersion this.client = axios.create({ baseURL: this.baseUrl @@ -181,7 +204,7 @@ export class AdmNode { */ async updateStatus() { try { - const status = await this.getNodeStatus() + const status = await this.fetchNodeStatus() if (status.version) { this.version = status.version @@ -204,10 +227,10 @@ export class AdmNode { } /** - * Gets node version, block height and ping. + * Fetch node version, block height and ping. * @returns {Promise<{version: string, height: number, ping: number}>} */ - private async getNodeStatus(): Promise { + private async fetchNodeStatus(): Promise { if (!this.hasSupportedProtocol) { return Promise.reject({ online: false, @@ -230,4 +253,24 @@ export class AdmNode { throw new Error('Request to /api/node/status was unsuccessful') } + + getNodeStatus(): GetNodeStatusResult { + return { + url: this.url, + port: this.port, + hostname: this.hostname, + protocol: this.protocol, + wsProtocol: this.wsProtocol, + wsPort: this.wsPort, + wsPortNeeded: this.wsPortNeeded, + online: this.online, + ping: this.ping, + version: this.version, + active: this.active, + outOfSync: this.outOfSync, + hasMinNodeVersion: this.version >= this.minNodeVersion, + hasSupportedProtocol: this.hasSupportedProtocol, + socketSupport: this.socketSupport + } + } } diff --git a/src/store/modules/nodes/nodes-actions.js b/src/store/modules/nodes/nodes-actions.js index 793c21c0d..3da94f092 100644 --- a/src/store/modules/nodes/nodes-actions.js +++ b/src/store/modules/nodes/nodes-actions.js @@ -1,14 +1,14 @@ -import apiClient from '../../../lib/adamant-api-client' +import admClient from '@/lib/nodes/adm/AdmClient' export default { restore ({ state }) { const nodes = Object.values(state.list) - nodes.forEach(node => apiClient.toggleNode(node.url, node.active)) + nodes.forEach(node => admClient.toggleNode(node.url, node.active)) }, updateStatus () { - apiClient.updateStatus() + admClient.updateStatus() }, toggle (context, payload) { diff --git a/src/store/modules/nodes/nodes-plugin.js b/src/store/modules/nodes/nodes-plugin.js index 14b48ce56..98d4feabf 100644 --- a/src/store/modules/nodes/nodes-plugin.js +++ b/src/store/modules/nodes/nodes-plugin.js @@ -1,22 +1,22 @@ -import apiClient from '../../../lib/adamant-api-client' +import admClient from '@/lib/nodes/adm/AdmClient' export default store => { // initial nodes state - apiClient.getNodes().forEach(node => store.commit('nodes/status', node)) + admClient.getNodes().forEach(node => store.commit('nodes/status', node)) - apiClient.updateStatus() + admClient.updateStatus() store.subscribe(mutation => { if (mutation.type === 'nodes/useFastest') { - apiClient.useFastest = !!mutation.payload + admClient.useFastest = !!mutation.payload } if (mutation.type === 'nodes/toggle') { - apiClient.toggleNode(mutation.payload.url, mutation.payload.active) + admClient.toggleNode(mutation.payload.url, mutation.payload.active) } }) - apiClient.onStatusUpdate(status => { + admClient.onStatusUpdate(status => { store.commit('nodes/status', status) }) } From 4f01cfac316c9a6e5e46024aa1bfb30a71f14ca4 Mon Sep 17 00:00:00 2001 From: bludnic Date: Sun, 29 Oct 2023 19:35:45 +0000 Subject: [PATCH 04/50] refactor(AdmClient): refactor promises --- src/lib/nodes/adm/AdmClient.ts | 77 +++++++++++++++++----------------- 1 file changed, 39 insertions(+), 38 deletions(-) diff --git a/src/lib/nodes/adm/AdmClient.ts b/src/lib/nodes/adm/AdmClient.ts index fd5cafbd7..7cd9dba5b 100644 --- a/src/lib/nodes/adm/AdmClient.ts +++ b/src/lib/nodes/adm/AdmClient.ts @@ -81,18 +81,18 @@ class AdmClient { this.statusPromise = new Promise((resolve, reject) => { let done = false - const promises = this.nodes - .filter((x) => x.active) - .map((x) => { - return x.updateStatus().then(() => { - this.fireStatusUpdate(x) - // Resolve the `_statusPromise` if it's a good node. - if (!done && x.online && this.isCompatible(x.version)) { - done = true - resolve() - } - }) + const activeNodes = this.nodes.filter((node) => node.active) + + const promises = activeNodes.map((node) => { + return node.updateStatus().then(() => { + this.fireStatusUpdate(node) + // Resolve the `statusPromise` if it's a good node. + if (!done && node.online && this.isCompatible(node.version)) { + done = true + resolve() + } }) + }) Promise.all(promises).then(() => { // If all nodes have been checked and none of them is online or @@ -106,15 +106,14 @@ class AdmClient { this.updateSyncStatuses() } }) - }).then( - () => { + }) + .then(() => { if (this.onInit) this.onInit() - }, - (error) => { + }) + .catch((error) => { if (this.onInit) this.onInit(error) return Promise.reject(error) - } - ) + }) } /** @@ -151,29 +150,28 @@ class AdmClient { * Performs an API request. * @param {RequestConfig} config request config */ - request

(config: RequestConfig

): Promise { + async request

(config: RequestConfig

): Promise { // First wait until we get at least one compatible node - return this.statusPromise.then(() => { - const node = this.useFastest ? this.getFastestNode() : this.getRandomNode() + await this.statusPromise - if (!node) { - // All nodes seem to be offline: let's refresh the statuses + const node = this.useFastest ? this.getFastestNode() : this.getRandomNode() + if (!node) { + // All nodes seem to be offline: let's refresh the statuses + this.updateStatus() + // But there's nothing we can do right now + return Promise.reject(new Error('No online nodes at the moment')) + } + + return node.request(config).catch((error) => { + if (error.code === 'NODE_OFFLINE') { + // Notify the world that the node is down + this.fireStatusUpdate(node) + // Initiate nodes status check this.updateStatus() - // But there's nothing we can do right now - return Promise.reject(new Error('No online nodes at the moment')) + // If the selected node is not available, repeat the request with another one. + return this.request(config) } - - return node.request(config).catch((error) => { - if (error.code === 'NODE_OFFLINE') { - // Notify the world that the node is down - this.fireStatusUpdate(node) - // Initiate nodes status check - this.updateStatus() - // If the selected node is not available, repeat the request with another one. - return this.request(config) - } - throw error - }) + throw error }) } @@ -257,11 +255,14 @@ class AdmClient { return out }, null) + if (!winner) { + console.log('AdmClient: updateSyncStatuses: No winner found') + return + } + // Finally, all the nodes from the winner list are considered to be in sync, all the // others are not nodes.forEach((node) => { - if (!winner) return - node.outOfSync = !winner.nodes.includes(node) this.fireStatusUpdate(node) }) From 2293af5b586dde860929f21f0627ceeb99c24a36 Mon Sep 17 00:00:00 2001 From: bludnic Date: Mon, 30 Oct 2023 16:34:55 +0000 Subject: [PATCH 05/50] refactor(AdmClient): add `filterSyncedNodes` helper --- src/lib/nodes/adm/AdmClient.ts | 38 +++--------------- src/lib/nodes/utils/filterSyncedNodes.ts | 49 ++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 33 deletions(-) create mode 100644 src/lib/nodes/utils/filterSyncedNodes.ts diff --git a/src/lib/nodes/adm/AdmClient.ts b/src/lib/nodes/adm/AdmClient.ts index 7cd9dba5b..5912f2f28 100644 --- a/src/lib/nodes/adm/AdmClient.ts +++ b/src/lib/nodes/adm/AdmClient.ts @@ -1,17 +1,10 @@ import { NodeInfo } from '@/types/wallets' import { AdmNode, Payload, RequestConfig } from './AdmNode' +import { filterSyncedNodes } from '../utils/filterSyncedNodes' import config from '@/config' import semver from 'semver' -/** - * Allowed height delta for the nodes. - * - * If two nodes' heights differ by no more than this value, - * they are considered to be in sync with each other. - */ -const HEIGHT_EPSILON = 10 - /** * Interval how often to update node statuses */ @@ -237,35 +230,14 @@ class AdmClient { private updateSyncStatuses() { const nodes = this.nodes.filter((x) => x.online && x.active) - // For each node we take its height and list of nodes that have the same height ± epsilon - const grouped = nodes.map((node) => { - return { - /** In case of "win" this height will be considered to be real height of the network */ - height: node.height, - /** List of nodes with the same (or close) height, including current one */ - nodes: nodes.filter((x) => Math.abs(node.height - x.height) <= HEIGHT_EPSILON) - } - }) - - // A group with the longest same-height nodes list wins. - // If two groups have the same number of nodes, the one with the biggest height wins. - const winner = grouped.reduce<{ height: number; nodes: AdmNode[] } | null>((out, x) => { - if (!out) return x - if (out.nodes.length < x.nodes.length || out.height < x.height) return x - return out - }, null) - - if (!winner) { - console.log('AdmClient: updateSyncStatuses: No winner found') - return - } + const nodesInSync = filterSyncedNodes(nodes) // Finally, all the nodes from the winner list are considered to be in sync, all the // others are not - nodes.forEach((node) => { - node.outOfSync = !winner.nodes.includes(node) + for (const node of nodes) { + node.outOfSync = !nodesInSync.nodes.includes(node) this.fireStatusUpdate(node) - }) + } } } diff --git a/src/lib/nodes/utils/filterSyncedNodes.ts b/src/lib/nodes/utils/filterSyncedNodes.ts new file mode 100644 index 000000000..a01564e33 --- /dev/null +++ b/src/lib/nodes/utils/filterSyncedNodes.ts @@ -0,0 +1,49 @@ +/** + * Allowed height delta for the nodes. + * + * If two nodes' heights differ by no more than this value, + * they are considered to be in sync with each other. + */ +const HEIGHT_EPSILON = 10 + +interface Node { + height: number +} + +type GroupNodes = { + height: number + nodes: N[] +} + +/** + * Basic idea is the following: we look for the biggest group of nodes that have the same + * height (considering HEIGHT_EPSILON). These nodes are considered to be in sync with the network, + * all the others are not. + */ +export function filterSyncedNodes(nodes: N[]): GroupNodes { + if (nodes.length === 0) { + throw new Error('filterSyncedNodes: No nodes provided') + } + + // For each node we take its height and list of nodes that have the same height ± epsilon + const groups = nodes.map((node) => { + return { + /** In case of "win" this height will be considered to be real height of the network */ + height: node.height, + /** List of nodes with the same (or close) height, including current one */ + nodes: nodes.filter((x) => Math.abs(node.height - x.height) <= HEIGHT_EPSILON) + } + }) + + // A group with the longest same-height nodes list wins. + // If two groups have the same number of nodes, the one with the biggest height wins. + const winner = groups.reduce((acc, curr) => { + if (curr.height > acc.height || curr.nodes.length > acc.nodes.length) { + return curr + } + + return acc + }) + + return winner +} From 26e188fca0f052d1a7d3d99e3050e3a704c7c1c8 Mon Sep 17 00:00:00 2001 From: bludnic Date: Mon, 30 Oct 2023 16:45:51 +0000 Subject: [PATCH 06/50] feat(AdmClient): add `isNodeOfflineError` helper --- src/lib/nodes/adm/AdmClient.ts | 3 ++- src/lib/nodes/adm/AdmNode.ts | 16 +--------------- src/lib/nodes/utils/errors.ts | 18 ++++++++++++++++++ 3 files changed, 21 insertions(+), 16 deletions(-) create mode 100644 src/lib/nodes/utils/errors.ts diff --git a/src/lib/nodes/adm/AdmClient.ts b/src/lib/nodes/adm/AdmClient.ts index 5912f2f28..0e42e9a46 100644 --- a/src/lib/nodes/adm/AdmClient.ts +++ b/src/lib/nodes/adm/AdmClient.ts @@ -1,3 +1,4 @@ +import { isNodeOfflineError } from '@/lib/nodes/utils/errors' import { NodeInfo } from '@/types/wallets' import { AdmNode, Payload, RequestConfig } from './AdmNode' import { filterSyncedNodes } from '../utils/filterSyncedNodes' @@ -156,7 +157,7 @@ class AdmClient { } return node.request(config).catch((error) => { - if (error.code === 'NODE_OFFLINE') { + if (isNodeOfflineError(error)) { // Notify the world that the node is down this.fireStatusUpdate(node) // Initiate nodes status check diff --git a/src/lib/nodes/adm/AdmNode.ts b/src/lib/nodes/adm/AdmNode.ts index 555cef808..5a0652cf3 100644 --- a/src/lib/nodes/adm/AdmNode.ts +++ b/src/lib/nodes/adm/AdmNode.ts @@ -1,4 +1,5 @@ import utils from '@/lib/adamant' +import { NodeOfflineError } from '@/lib/nodes/utils/errors' import { GetNodeStatusResponseDto } from '@/lib/schema/client' import axios, { AxiosInstance, AxiosRequestConfig } from 'axios' @@ -7,21 +8,6 @@ import axios, { AxiosInstance, AxiosRequestConfig } from 'axios' */ const appProtocol = location.protocol -/** - * Custom error to indicate that the endpoint is not available - */ -class NodeOfflineError extends Error { - code = 'NODE_OFFLINE' - - constructor() { - super('Node is offline') - - if (Error.captureStackTrace) { - Error.captureStackTrace(this, NodeOfflineError) - } - } -} - type FetchNodeStatusResult = { online: boolean socketSupport: boolean diff --git a/src/lib/nodes/utils/errors.ts b/src/lib/nodes/utils/errors.ts new file mode 100644 index 000000000..0c4684e1a --- /dev/null +++ b/src/lib/nodes/utils/errors.ts @@ -0,0 +1,18 @@ +/** + * Custom error to indicate that the endpoint is not available + */ +export class NodeOfflineError extends Error { + code = 'NODE_OFFLINE' + + constructor() { + super('Node is offline') + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, NodeOfflineError) + } + } +} + +export function isNodeOfflineError(error: Error): error is NodeOfflineError { + return (error as NodeOfflineError).code === 'NODE_OFFLINE' +} From 085588a10192d53461356f03128ec63741a9ddf2 Mon Sep 17 00:00:00 2001 From: bludnic Date: Tue, 31 Oct 2023 22:15:24 +0000 Subject: [PATCH 07/50] feat: add healthcheck for ETH nodes --- src/components/NodesTable/NodesTable.vue | 11 +- src/components/NodesTable/NodesTableItem.vue | 4 + src/lib/adamant-api/index.js | 2 +- src/lib/nodes/abstract.client.ts | 119 ++++++++++ src/lib/nodes/abstract.node.ts | 150 +++++++++++++ src/lib/nodes/adm/AdmClient.ts | 186 +--------------- src/lib/nodes/adm/AdmNode.ts | 205 +++--------------- src/lib/nodes/adm/index.ts | 8 + src/lib/nodes/eth/EthClient.ts | 20 ++ src/lib/nodes/eth/EthNode.ts | 34 +++ .../nodes/{EthNode => eth}/HttpProvider.ts | 0 src/lib/nodes/eth/index.ts | 8 + src/store/modules/erc20/erc20-actions.js | 21 +- .../modules/eth-base/eth-base-actions.js | 16 +- src/store/modules/eth/actions.js | 10 +- src/store/modules/nodes/nodes-actions.js | 44 ++-- src/store/modules/nodes/nodes-getters.js | 7 +- src/store/modules/nodes/nodes-mutations.js | 6 +- src/store/modules/nodes/nodes-plugin.js | 47 ++-- src/store/modules/nodes/nodes-state.js | 9 +- src/store/plugins/socketsPlugin.js | 2 +- 21 files changed, 474 insertions(+), 435 deletions(-) create mode 100644 src/lib/nodes/abstract.client.ts create mode 100644 src/lib/nodes/abstract.node.ts create mode 100644 src/lib/nodes/adm/index.ts create mode 100644 src/lib/nodes/eth/EthClient.ts create mode 100644 src/lib/nodes/eth/EthNode.ts rename src/lib/nodes/{EthNode => eth}/HttpProvider.ts (100%) create mode 100644 src/lib/nodes/eth/index.ts diff --git a/src/components/NodesTable/NodesTable.vue b/src/components/NodesTable/NodesTable.vue index 15939cf0b..f3948993c 100644 --- a/src/components/NodesTable/NodesTable.vue +++ b/src/components/NodesTable/NodesTable.vue @@ -3,7 +3,8 @@ - + + @@ -21,8 +22,8 @@ export default defineComponent({ }, setup() { const store = useStore() - const nodes = computed(() => { - const arr = store.getters['nodes/list'] + const admNodes = computed(() => { + const arr = store.getters['nodes/adm'] return [...arr].sort((a, b) => { if (/^http:\/\//.test(a.url) || /^http:\/\//.test(b.url)) { @@ -32,6 +33,7 @@ export default defineComponent({ return a.url > b.url ? 1 : b.url > a.url ? -1 : 0 }) }) + const ethNodes = computed(() => store.getters['nodes/eth']) const className = 'nodes-table' const classes = { @@ -39,7 +41,8 @@ export default defineComponent({ } return { - nodes, + admNodes, + ethNodes, classes } } diff --git a/src/components/NodesTable/NodesTableItem.vue b/src/components/NodesTable/NodesTableItem.vue index 25293fe12..a21aa26f3 100644 --- a/src/components/NodesTable/NodesTableItem.vue +++ b/src/components/NodesTable/NodesTableItem.vue @@ -11,6 +11,7 @@ :model-value="active" :class="[classes.checkbox, classes.statusIconGrey]" @input="toggleActiveStatus" + :disabled="disableCheckbox" /> @@ -88,6 +89,9 @@ export default { node: { type: Object, required: true + }, + disableCheckbox: { + type: Boolean } }, setup(props) { diff --git a/src/lib/adamant-api/index.js b/src/lib/adamant-api/index.js index a58db27a4..d16b7fa9b 100644 --- a/src/lib/adamant-api/index.js +++ b/src/lib/adamant-api/index.js @@ -3,7 +3,7 @@ import { Base64 } from 'js-base64' import { Transactions, Delegates, MessageType } from '@/lib/constants' import utils from '@/lib/adamant' -import client from '@/lib/nodes/adm/AdmClient' +import client from '@/lib/nodes/adm' import { encryptPassword } from '@/lib/idb/crypto' import { restoreState } from '@/lib/idb/state' import { i18n } from '@/i18n' diff --git a/src/lib/nodes/abstract.client.ts b/src/lib/nodes/abstract.client.ts new file mode 100644 index 000000000..4b9bfede4 --- /dev/null +++ b/src/lib/nodes/abstract.client.ts @@ -0,0 +1,119 @@ +import { filterSyncedNodes } from './utils/filterSyncedNodes.ts' +import { Node } from './abstract.node' + +export abstract class Client { + /** + * List of the available nodes + */ + nodes: N[] = [] + /** + * Minimum API version a node is required to have + */ + minNodeVersion = '' + /** + * Indicates wether `ApiClient` should prefer the fastest node available. + */ + useFastest = false + /** + * A callback that is called every time a node status is updated + */ + statusUpdateCallback?: (status: ReturnType) => void + + protected async watchNodeStatusChange() { + for (const node of this.nodes) { + node.onStatusChange((node) => { + this.updateSyncStatuses() + + this.statusUpdateCallback?.(node) + }) + } + } + + /** + * Initiates healthcheck for each node. + */ + checkHealth() { + for (const node of this.nodes) { + void node.startHealthcheck() + } + } + + getClient(): N['client'] { + const node = this.useFastest ? this.getFastestNode() : this.getRandomNode() + + if (!node) { + throw new Error('No available nodes at the moment') + } + + return node.client + } + + /** + * Returns endpoint statuses + * @returns {Array<{ url: string, online: boolean, ping: number }>} + */ + getNodes() { + return this.nodes.map((node) => node.getStatus()) + } + + /** + * Enables/disables an node. + * @param {String} url node URL + * @param {Boolean} active set node active or not + */ + toggleNode(url: string, active: boolean) { + const node = this.nodes.find((x) => x.url === url) + if (node) { + node.active = active + } + } + + /** + * Registers a status update callback. + * @param {function({url: string, ping: number, online: boolean}): void} callback callback function + */ + onStatusUpdate(callback: typeof this.statusUpdateCallback) { + this.statusUpdateCallback = callback + } + + /** + * Returns a random node. + * @returns {ApiNode} + */ + protected getRandomNode() { + const onlineNodes = this.nodes.filter((x) => x.online && x.active && !x.outOfSync) + const node = onlineNodes[Math.floor(Math.random() * onlineNodes.length)] + return node + } + + /** + * Returns the fastest node. + */ + protected getFastestNode() { + return this.nodes.reduce((fastest, current) => { + if (!current.online || !current.active || current.outOfSync) { + return fastest + } + return !fastest || fastest.ping > current.ping ? current : fastest + }) + } + + /** + * Updates `outOfSync` status of the nodes. + * + * Basic idea is the following: we look for the biggest group of nodes that have the same + * height (considering HEIGHT_EPSILON). These nodes are considered to be in sync with the network, + * all the others are not. + */ + protected updateSyncStatuses() { + const nodes = this.nodes.filter((x) => x.online && x.active) + + const nodesInSync = filterSyncedNodes(nodes) + + // Finally, all the nodes from the winner list are considered to be in sync, all the + // others are not + for (const node of nodes) { + node.outOfSync = !nodesInSync.nodes.includes(node) + } + } +} diff --git a/src/lib/nodes/abstract.node.ts b/src/lib/nodes/abstract.node.ts new file mode 100644 index 000000000..346991ad0 --- /dev/null +++ b/src/lib/nodes/abstract.node.ts @@ -0,0 +1,150 @@ +type HealthcheckResult = { + height: number + ping: number +} + +/** + * Protocol on host where app is running, f. e., http: or https: + */ +const appProtocol = location.protocol + +/** + * Interval how often to update node statuses + */ +const REVISE_CONNECTION_TIMEOUT = 5000 + +export abstract class Node { + /** + * Indicates whether node is active (i.e. user allows the application + * to interact with this node). + */ + active = true + + /** + * Indicates whether node is out of sync (i.e. its block height is + * either too big or too small compared to the other nodes) + */ + outOfSync = false + + /** + * Default `wsPort`. Will be updated after `GET /api/node/status` + */ + wsPort = '36668' + + /** + * Node base URL + */ + url: string + /** + * Node protocol, like http: or https: + */ + protocol: string + /** + * Node port like 36666 for http nodes (default) + */ + port: string + /** + * Node hostname like bid.adamant.im or 23.226.231.225 + */ + hostname: string + /** + * WebSocket protocol + */ + wsProtocol: 'ws:' | 'wss:' = 'wss:' + /** + * If Socket port like :36668 needed for connection + */ + wsPortNeeded = false + hasSupportedProtocol = false + + // Healthcheck related params + /** + * Indicates whether node is available. + */ + online = true + /** + * Node ping estimation + */ + ping = Infinity + /** + * Delta between local time and the node time + */ + timeDelta = 0 + /** + * Node API version. + */ + version = '0.0.0' + /** + * Minimal required Node API version. + */ + minNodeVersion = '' + /** + * Current block height + */ + height = 0 + /** + * Will be updated after `GET /api/node/status` + */ + socketSupport = false + + onStatusChangeCallback?: (nodeStatus: ReturnType) => void + + timer?: NodeJS.Timeout + abstract client: C + + constructor(url: string, minNodeVersion = '', version = '0.0.0') { + this.url = url + this.protocol = new URL(url).protocol + this.port = new URL(url).port + this.hostname = new URL(url).hostname + this.minNodeVersion = minNodeVersion + this.version = version + this.hasSupportedProtocol = !(this.protocol === 'http:' && appProtocol === 'https:') + } + + async startHealthcheck() { + clearInterval(this.timer) + try { + const health = await this.checkHealth() + + this.height = health.height + this.ping = health.ping + this.online = true + } catch (err) { + this.online = false + } + + this.fireStatusChange() + this.timer = setTimeout(() => this.startHealthcheck(), REVISE_CONNECTION_TIMEOUT) + } + + private fireStatusChange() { + this.onStatusChangeCallback?.(this.getStatus()) + } + + onStatusChange(callback: typeof this.onStatusChangeCallback) { + this.onStatusChangeCallback = callback + } + + getStatus() { + return { + url: this.url, + port: this.port, + hostname: this.hostname, + protocol: this.protocol, + wsProtocol: this.wsProtocol, + wsPort: this.wsPort, + wsPortNeeded: this.wsPortNeeded, + online: this.online, + ping: this.ping, + version: this.version, + active: this.active, + outOfSync: this.outOfSync, + hasMinNodeVersion: this.version >= this.minNodeVersion, + hasSupportedProtocol: this.hasSupportedProtocol, + socketSupport: this.socketSupport + } + } + + protected abstract checkHealth(): Promise +} diff --git a/src/lib/nodes/adm/AdmClient.ts b/src/lib/nodes/adm/AdmClient.ts index 0e42e9a46..bf72ad9c8 100644 --- a/src/lib/nodes/adm/AdmClient.ts +++ b/src/lib/nodes/adm/AdmClient.ts @@ -1,16 +1,9 @@ import { isNodeOfflineError } from '@/lib/nodes/utils/errors' -import { NodeInfo } from '@/types/wallets' import { AdmNode, Payload, RequestConfig } from './AdmNode' -import { filterSyncedNodes } from '../utils/filterSyncedNodes' +import { Client } from '../abstract.client' -import config from '@/config' import semver from 'semver' -/** - * Interval how often to update node statuses - */ -const REVISE_CONNECTION_TIMEOUT = 5000 - /** * Provides methods for calling the ADAMANT API. * @@ -18,108 +11,14 @@ const REVISE_CONNECTION_TIMEOUT = 5000 * send the API-requests to and switches to another node if the current one * is not available at the moment. */ -class AdmClient { - /** - * List of the available nodes - */ - nodes: AdmNode[] - /** - * Minimum API version a node is required to have - */ - minNodeVersion: string - /** - * A callback that is called every time a node status is updated - */ - statusUpdateCallback?: (status: ReturnType) => void - /** - * Indicates wether `ApiClient` should prefer the fastest node available. - */ - useFastest: boolean - /** - * This promise is resolved whenever we get at least one compatible online node - * after the status update. - */ - statusPromise: Promise - onInit?: (error?: Error) => void - +export class AdmClient extends Client { constructor(endpoints: string[] = [], minNodeVersion = '0.0.0') { + super() this.nodes = endpoints.map((endpoint) => new AdmNode(endpoint)) this.minNodeVersion = minNodeVersion - this.useFastest = false - this.statusPromise = new Promise((resolve, reject) => { - this.onInit = (error) => { - if (error) { - reject(error) - } else { - resolve() - } - this.onInit = undefined - } - }) - } - - /** - * Returns endpoint statuses - * @returns {Array<{ url: string, online: boolean, ping: number }>} - */ - getNodes() { - return this.nodes.map((node) => node.getNodeStatus()) - } - - /** - * Initiates the status update for each of the known nodes. - */ - updateStatus() { - this.statusPromise = new Promise((resolve, reject) => { - let done = false - - const activeNodes = this.nodes.filter((node) => node.active) - - const promises = activeNodes.map((node) => { - return node.updateStatus().then(() => { - this.fireStatusUpdate(node) - // Resolve the `statusPromise` if it's a good node. - if (!done && node.online && this.isCompatible(node.version)) { - done = true - resolve() - } - }) - }) - - Promise.all(promises).then(() => { - // If all nodes have been checked and none of them is online or - // compatible, throw an error to indicate that we're unable to send - // requests at the moment. - if (!done) { - reject(new Error('No compatible nodes at the moment')) - // Schedule a status update after a while - setTimeout(() => this.updateStatus(), REVISE_CONNECTION_TIMEOUT) - } else { - this.updateSyncStatuses() - } - }) - }) - .then(() => { - if (this.onInit) this.onInit() - }) - .catch((error) => { - if (this.onInit) this.onInit(error) - return Promise.reject(error) - }) - } - - /** - * Enables/disables an node. - * @param {String} url node URL - * @param {Boolean} active set node active or not - */ - toggleNode(url: string, active: boolean) { - const node = this.nodes.find((x) => x.url === url) - if (node) { - node.active = active - } + void this.watchNodeStatusChange() } /** @@ -145,23 +44,18 @@ class AdmClient { * @param {RequestConfig} config request config */ async request

(config: RequestConfig

): Promise { - // First wait until we get at least one compatible node - await this.statusPromise - const node = this.useFastest ? this.getFastestNode() : this.getRandomNode() if (!node) { // All nodes seem to be offline: let's refresh the statuses - this.updateStatus() + this.checkHealth() // But there's nothing we can do right now return Promise.reject(new Error('No online nodes at the moment')) } return node.request(config).catch((error) => { if (isNodeOfflineError(error)) { - // Notify the world that the node is down - this.fireStatusUpdate(node) // Initiate nodes status check - this.updateStatus() + this.checkHealth() // If the selected node is not available, repeat the request with another one. return this.request(config) } @@ -169,49 +63,6 @@ class AdmClient { }) } - /** - * Registers a status update callback. - * @param {function({url: string, ping: number, online: boolean}): void} callback callback function - */ - onStatusUpdate(callback: typeof this.statusUpdateCallback) { - this.statusUpdateCallback = callback - } - - /** - * Returns a random node. - * @returns {ApiNode} - */ - private getRandomNode() { - const onlineNodes = this.nodes.filter( - (x) => x.online && x.active && !x.outOfSync && this.isCompatible(x.version) - ) - const node = onlineNodes[Math.floor(Math.random() * onlineNodes.length)] - return node - } - - /** - * Returns the fastest node. - */ - private getFastestNode() { - return this.nodes.reduce((fastest, current) => { - if ( - !current.online || - !current.active || - current.outOfSync || - !this.isCompatible(current.version) - ) { - return fastest - } - return !fastest || fastest.ping > current.ping ? current : fastest - }) - } - - private fireStatusUpdate(node: AdmNode) { - if (typeof this.statusUpdateCallback === 'function') { - this.statusUpdateCallback(node.getNodeStatus()) - } - } - /** * Checks if the supplied version is compatible with the minimal allowed one. * @param {string} version version to check @@ -220,29 +71,4 @@ class AdmClient { private isCompatible(version: string) { return !!(version && semver.gte(version, this.minNodeVersion)) } - - /** - * Updates `outOfSync` status of the nodes. - * - * Basic idea is the following: we look for the biggest group of nodes that have the same - * height (considering HEIGHT_EPSILON). These nodes are considered to be in sync with the network, - * all the others are not. - */ - private updateSyncStatuses() { - const nodes = this.nodes.filter((x) => x.online && x.active) - - const nodesInSync = filterSyncedNodes(nodes) - - // Finally, all the nodes from the winner list are considered to be in sync, all the - // others are not - for (const node of nodes) { - node.outOfSync = !nodesInSync.nodes.includes(node) - this.fireStatusUpdate(node) - } - } } - -const endpoints = (config.adm.nodes as NodeInfo[]).map((endpoint) => endpoint.url) -const apiClient = new AdmClient(endpoints, config.adm.minNodeVersion) - -export default apiClient diff --git a/src/lib/nodes/adm/AdmNode.ts b/src/lib/nodes/adm/AdmNode.ts index 5a0652cf3..160da4c76 100644 --- a/src/lib/nodes/adm/AdmNode.ts +++ b/src/lib/nodes/adm/AdmNode.ts @@ -2,37 +2,13 @@ import utils from '@/lib/adamant' import { NodeOfflineError } from '@/lib/nodes/utils/errors' import { GetNodeStatusResponseDto } from '@/lib/schema/client' import axios, { AxiosInstance, AxiosRequestConfig } from 'axios' +import { Node } from '@/lib/nodes/abstract.node' -/** - * Protocol on host where app is running, f. e., http: or https: - */ -const appProtocol = location.protocol - -type FetchNodeStatusResult = { - online: boolean +type FetchNodeInfoResult = { socketSupport: boolean - version?: string - height?: number - ping?: number - wsPort?: string -} - -type GetNodeStatusResult = { - url: string - port: string - hostname: string - protocol: string - wsProtocol: 'ws:' | 'wss:' - wsPort: string - wsPortNeeded: boolean - online: boolean - ping: number version: string - active: boolean - outOfSync: boolean - hasMinNodeVersion: boolean - hasSupportedProtocol: boolean - socketSupport: boolean + height: number + wsPort?: string } export type Payload = @@ -50,100 +26,22 @@ export type RequestConfig

= { * Encapsulates a node. Provides methods to send API-requests * to the node and verify is status (online/offline, version, ping, etc.) */ -export class AdmNode { - /** - * Indicates whether node is active (i.e. user allows the application - * to interact with this node). - */ - active = true - - /** - * Indicates whether node is out of sync (i.e. its block height is - * either too big or too small compared to the other nodes) - */ - outOfSync = false - - /** - * Default `wsPort`. Will be updated after `GET /api/node/status` - */ - wsPort = '36668' - - /** - * Node base URL - */ - baseUrl: string - /** - * Node protocol, like http: or https: - */ - protocol: string - /** - * Node port like 36666 for http nodes (default) - */ - port: string - /** - * Node hostname like bid.adamant.im or 23.226.231.225 - */ - hostname: string - /** - * WebSocket protocol - */ - wsProtocol: 'ws:' | 'wss:' - /** - * If Socket port like :36668 needed for connection - */ - wsPortNeeded: boolean - hasSupportedProtocol: boolean - - // Healthcheck related params - /** - * Indicates whether node is available. - */ - online = false - /** - * Node ping estimation - */ - ping = Infinity - /** - * Delta between local time and the node time - */ - timeDelta = 0 - /** - * Node API version. - */ - version = '' - /** - * Minimal required Node API version. - */ - minNodeVersion: string - /** - * Current block height - */ - height = 0 - /** - * Will be updated after `GET /api/node/status` - */ - socketSupport = false - +export class AdmNode extends Node { client: AxiosInstance - constructor(baseUrl: string, minNodeVersion = '0.0.0') { - this.baseUrl = baseUrl - this.protocol = new URL(baseUrl).protocol - this.port = new URL(baseUrl).port - this.hostname = new URL(baseUrl).hostname + constructor(url: string, minNodeVersion = '0.0.0') { + super(url, minNodeVersion) + this.wsPort = '36668' // default wsPort this.wsProtocol = this.protocol === 'https:' ? 'wss:' : 'ws:' this.wsPortNeeded = this.wsProtocol === 'ws:' && !this.hostname.includes('.onion') - this.hasSupportedProtocol = !(this.protocol === 'http:' && appProtocol === 'https:') - this.minNodeVersion = minNodeVersion this.client = axios.create({ - baseURL: this.baseUrl + baseURL: this.url }) - } - get url() { - return this.baseUrl + void this.fetchNodeInfo() + void this.startHealthcheck() } /** @@ -184,79 +82,42 @@ export class AdmNode { ) } - /** - * Initiates node status update: version, ping, online/offline. - * @returns {PromiseLike} - */ - async updateStatus() { - try { - const status = await this.fetchNodeStatus() - - if (status.version) { - this.version = status.version - } - if (status.height) { - this.height = status.height - } - if (status.ping) { - this.ping = status.ping - } - if (status.wsPort) { - this.wsPort = status.wsPort - } - this.online = status.online - this.socketSupport = status.socketSupport - } catch (err) { - this.online = false - this.socketSupport = false - } - } - /** * Fetch node version, block height and ping. * @returns {Promise<{version: string, height: number, ping: number}>} */ - private async fetchNodeStatus(): Promise { - if (!this.hasSupportedProtocol) { - return Promise.reject({ - online: false, - socketSupport: false - }) - } - - const time = Date.now() + private async fetchNodeInfo(): Promise { const response: GetNodeStatusResponseDto = await this.request({ url: '/api/node/status' }) + if (response.success) { + const version = response.version.version + const height = Number(response.network.height) + const socketSupport = response.wsClient ? response.wsClient.enabled : false + const wsPort = response.wsClient ? String(response.wsClient.port) : '' + + this.version = version + this.height = height + this.socketSupport = socketSupport + this.wsPort = wsPort + return { - online: true, - version: response.version.version, - height: Number(response.network.height), - ping: Date.now() - time, - socketSupport: response.wsClient ? response.wsClient.enabled : false, - wsPort: response.wsClient ? String(response.wsClient.port) : undefined + version, + height, + socketSupport, + wsPort } } throw new Error('Request to /api/node/status was unsuccessful') } - getNodeStatus(): GetNodeStatusResult { + protected async checkHealth() { + const time = Date.now() + const nodeInfo = await this.fetchNodeInfo() + return { - url: this.url, - port: this.port, - hostname: this.hostname, - protocol: this.protocol, - wsProtocol: this.wsProtocol, - wsPort: this.wsPort, - wsPortNeeded: this.wsPortNeeded, - online: this.online, - ping: this.ping, - version: this.version, - active: this.active, - outOfSync: this.outOfSync, - hasMinNodeVersion: this.version >= this.minNodeVersion, - hasSupportedProtocol: this.hasSupportedProtocol, - socketSupport: this.socketSupport + height: nodeInfo.height, + ping: Date.now() - time } } } diff --git a/src/lib/nodes/adm/index.ts b/src/lib/nodes/adm/index.ts new file mode 100644 index 000000000..9b8d5573a --- /dev/null +++ b/src/lib/nodes/adm/index.ts @@ -0,0 +1,8 @@ +import config from '@/config' +import { NodeInfo } from '@/types/wallets' +import { AdmClient } from './AdmClient' + +const endpoints = (config.adm.nodes as NodeInfo[]).map((endpoint) => endpoint.url) +export const adm = new AdmClient(endpoints, config.adm.minNodeVersion) + +export default adm diff --git a/src/lib/nodes/eth/EthClient.ts b/src/lib/nodes/eth/EthClient.ts new file mode 100644 index 000000000..52da86094 --- /dev/null +++ b/src/lib/nodes/eth/EthClient.ts @@ -0,0 +1,20 @@ +import { EthNode } from './EthNode' +import { Client } from '../abstract.client' + +/** + * Provides methods for calling the ADAMANT API. + * + * The `ApiClient` instance automatically selects an ADAMANT node to + * send the API-requests to and switches to another node if the current one + * is not available at the moment. + */ +export class EthClient extends Client { + constructor(endpoints: string[] = [], minNodeVersion = '0.0.0') { + super() + this.nodes = endpoints.map((endpoint) => new EthNode(endpoint)) + this.minNodeVersion = minNodeVersion + this.useFastest = false + + void this.watchNodeStatusChange() + } +} diff --git a/src/lib/nodes/eth/EthNode.ts b/src/lib/nodes/eth/EthNode.ts new file mode 100644 index 000000000..332e4eb46 --- /dev/null +++ b/src/lib/nodes/eth/EthNode.ts @@ -0,0 +1,34 @@ +import Web3Eth from 'web3-eth' +import { HttpProvider } from './HttpProvider' +import { Node } from '@/lib/nodes/abstract.node' + +/** + * Encapsulates a node. Provides methods to send API-requests + * to the node and verify is status (online/offline, version, ping, etc.) + */ +export class EthNode extends Node { + /** + * @custom + */ + provider: HttpProvider + client: Web3Eth + + constructor(url: string) { + super(url) + + this.provider = new HttpProvider(this.url) + this.client = new Web3Eth(this.provider) + + void this.startHealthcheck() + } + + protected async checkHealth() { + const time = Date.now() + const blockNumber = await this.client.getBlockNumber() + + return { + height: Number(blockNumber), + ping: Date.now() - time + } + } +} diff --git a/src/lib/nodes/EthNode/HttpProvider.ts b/src/lib/nodes/eth/HttpProvider.ts similarity index 100% rename from src/lib/nodes/EthNode/HttpProvider.ts rename to src/lib/nodes/eth/HttpProvider.ts diff --git a/src/lib/nodes/eth/index.ts b/src/lib/nodes/eth/index.ts new file mode 100644 index 000000000..cfcdc500e --- /dev/null +++ b/src/lib/nodes/eth/index.ts @@ -0,0 +1,8 @@ +import config from '@/config' +import { NodeInfo } from '@/types/wallets' +import { EthClient } from './EthClient' + +const endpoints = (config.eth.nodes as NodeInfo[]).map((endpoint) => endpoint.url) +export const eth = new EthClient(endpoints) + +export default eth diff --git a/src/store/modules/erc20/erc20-actions.js b/src/store/modules/erc20/erc20-actions.js index 4bf2a60e7..481579496 100644 --- a/src/store/modules/erc20/erc20-actions.js +++ b/src/store/modules/erc20/erc20-actions.js @@ -3,7 +3,6 @@ import { FetchStatus, INCREASE_FEE_MULTIPLIER } from '@/lib/constants' import EthContract from 'web3-eth-contract' import Erc20 from './erc20.abi.json' import createActions from '../eth-base/eth-base-actions' -import { getRandomNodeUrl } from '@/config/utils' import { AbiDecoder } from '@/lib/abi/abi-decoder' /** Timestamp of the most recent status update */ @@ -29,11 +28,14 @@ const initTransaction = (api, context, ethAddress, amount, increaseFee) => { .encodeABI() } - return api.estimateGas(transaction).then((gasLimit) => { - gasLimit = increaseFee ? gasLimit * INCREASE_FEE_MULTIPLIER : gasLimit - transaction.gas = gasLimit - return transaction - }) + return api + .getClient() + .estimateGas(transaction) + .then((gasLimit) => { + gasLimit = increaseFee ? gasLimit * INCREASE_FEE_MULTIPLIER : gasLimit + transaction.gas = gasLimit + return transaction + }) } const parseTransaction = (context, tx) => { @@ -73,8 +75,8 @@ const createSpecificActions = (api) => ({ try { const contract = new EthContract(Erc20, state.contractAddress) - const endpoint = getRandomNodeUrl('eth') - contract.setProvider(endpoint) + contract.setProvider(api.getClient().provider) + const rawBalance = await contract.methods.balanceOf(state.address).call() const balance = Number(ethUtils.toFraction(rawBalance, state.decimals)) @@ -92,8 +94,7 @@ const createSpecificActions = (api) => ({ if (!context.state.address) return const contract = new EthContract(Erc20, context.state.contractAddress) - const endpoint = getRandomNodeUrl('eth') - contract.setProvider(endpoint) + contract.setProvider(api.getClient().provider) contract.methods .balanceOf(context.state.address) diff --git a/src/store/modules/eth-base/eth-base-actions.js b/src/store/modules/eth-base/eth-base-actions.js index 5f86306a0..3f4334a97 100644 --- a/src/store/modules/eth-base/eth-base-actions.js +++ b/src/store/modules/eth-base/eth-base-actions.js @@ -1,20 +1,16 @@ -import Web3Eth from 'web3-eth' import BigNumber from 'bignumber.js' -import { getRandomNodeUrl } from '@/config/utils' import * as utils from '../../../lib/eth-utils' import { getTransactions } from '../../../lib/eth-index' import * as tf from '../../../lib/transactionsFetching' import { isStringEqualCI } from '@/lib/textHelpers' +import api from '@/lib/nodes/eth' /** Interval between attempts to fetch the registered tx details */ const RETRY_TIMEOUT = 20 * 1000 const CHUNK_SIZE = 25 export default function createActions(config) { - const endpoint = getRandomNodeUrl('eth') - const api = new Web3Eth(endpoint) - const { onInit = () => {}, initTransaction, parseTransaction, createSpecificActions } = config return { @@ -65,7 +61,7 @@ export default function createActions(config) { return initTransaction(api, context, address, amount, increaseFee) .then((ethTx) => { - return api.accounts.signTransaction(ethTx, context.state.privateKey).then((signedTx) => { + return api.getClient().accounts.signTransaction(ethTx, context.state.privateKey).then((signedTx) => { const txInfo = { signedTx, ethTx @@ -89,7 +85,7 @@ export default function createActions(config) { }) }) .then((txInfo) => { - return api.sendSignedTransaction(txInfo.signedTx.rawTransaction).then( + return api.getClient().sendSignedTransaction(txInfo.signedTx.rawTransaction).then( (hash) => ({ txInfo, hash }), (error) => { // Known bug that after Tx sent successfully, this error occurred anyway https://github.com/ethereum/web3.js/issues/3145 @@ -166,7 +162,7 @@ export default function createActions(config) { const transaction = context.state.transactions[payload.hash] if (!transaction) return - void api.getBlock(payload.blockNumber).then((block) => { + void api.getClient().getBlock(payload.blockNumber).then((block) => { // Converting from BigInt into Number must be safe const timestamp = BigNumber(block.timestamp.toString()).multipliedBy(1000).toNumber() @@ -201,7 +197,7 @@ export default function createActions(config) { ]) } - void api.getTransaction(payload.hash).then((tx) => { + void api.getClient().getTransaction(payload.hash).then((tx) => { if (tx?.input) { const transaction = parseTransaction(context, tx) const status = existing ? existing.status : 'REGISTERED' @@ -264,7 +260,7 @@ export default function createActions(config) { const gasPrice = transaction.gasPrice - void api.getTransactionReceipt(payload.hash).then((tx) => { + void api.getClient().getTransactionReceipt(payload.hash).then((tx) => { let replay = true if (tx) { diff --git a/src/store/modules/eth/actions.js b/src/store/modules/eth/actions.js index 1909f45bb..7b61680f0 100644 --- a/src/store/modules/eth/actions.js +++ b/src/store/modules/eth/actions.js @@ -28,7 +28,7 @@ const initTransaction = (api, context, ethAddress, amount, increaseFee) => { // nonce // Let sendTransaction choose it } - return api.estimateGas(transaction).then((gasLimit) => { + return api.getClient().estimateGas(transaction).then((gasLimit) => { gasLimit = increaseFee ? BigNumber(gasLimit).times(INCREASE_FEE_MULTIPLIER).toNumber() : gasLimit @@ -60,7 +60,7 @@ const createSpecificActions = (api) => ({ } try { - const rawBalance = await api.getBalance(state.address, 'latest') + const rawBalance = await api.getClient().getBalance(state.address, 'latest') const balance = Number(utils.toEther(rawBalance.toString())) commit('balance', balance) @@ -80,13 +80,13 @@ const createSpecificActions = (api) => ({ if (!context.state.address) return // Balance - void api.getBalance(context.state.address, 'latest').then((balance) => { + void api.getClient().getBalance(context.state.address, 'latest').then((balance) => { context.commit('balance', Number(utils.toEther(balance.toString()))) context.commit('setBalanceStatus', FetchStatus.Success) }) // Current gas price - void api.getGasPrice().then((price) => { + void api.getClient().getGasPrice().then((price) => { // It is OK with London hardfork context.commit('gasPrice', { gasPrice: price, // string type @@ -95,7 +95,7 @@ const createSpecificActions = (api) => ({ }) // Current block number - void api.getBlockNumber().then((number) => { + void api.getClient().getBlockNumber().then((number) => { context.commit('blockNumber', Number(number)) }) diff --git a/src/store/modules/nodes/nodes-actions.js b/src/store/modules/nodes/nodes-actions.js index 3da94f092..03e24b708 100644 --- a/src/store/modules/nodes/nodes-actions.js +++ b/src/store/modules/nodes/nodes-actions.js @@ -1,21 +1,23 @@ -import admClient from '@/lib/nodes/adm/AdmClient' - -export default { - restore ({ state }) { - const nodes = Object.values(state.list) - - nodes.forEach(node => admClient.toggleNode(node.url, node.active)) - }, - - updateStatus () { - admClient.updateStatus() - }, - - toggle (context, payload) { - context.commit('toggle', payload) - }, - - setUseFastest (context, payload) { - context.commit('useFastest', payload) - } -} +import { adm } from '@/lib/nodes/adm' +import { eth } from '@/lib/nodes/eth' + +export default { + restore({ state }) { + const nodes = Object.values(state.adm) + + nodes.forEach((node) => adm.toggleNode(node.url, node.active)) + }, + + updateStatus() { + adm.checkHealth() + eth.checkHealth() + }, + + toggle(context, payload) { + context.commit('toggle', payload) + }, + + setUseFastest(context, payload) { + context.commit('useFastest', payload) + } +} diff --git a/src/store/modules/nodes/nodes-getters.js b/src/store/modules/nodes/nodes-getters.js index db8e9e3df..030fea047 100644 --- a/src/store/modules/nodes/nodes-getters.js +++ b/src/store/modules/nodes/nodes-getters.js @@ -1,5 +1,8 @@ export default { - list (state) { - return Object.values(state.list) + adm(state) { + return Object.values(state.adm) + }, + eth(state) { + return Object.values(state.eth) } } diff --git a/src/store/modules/nodes/nodes-mutations.js b/src/store/modules/nodes/nodes-mutations.js index a4fb2c6a1..0b33d7468 100644 --- a/src/store/modules/nodes/nodes-mutations.js +++ b/src/store/modules/nodes/nodes-mutations.js @@ -4,13 +4,13 @@ export default { }, toggle (state, payload) { - const node = state.list[payload.url] + const node = state.adm[payload.url] if (node) { node.active = payload.active } }, - status (state, status) { - state.list[status.url] = status + status (state, { status, nodeType }) { + state[nodeType][status.url] = status } } diff --git a/src/store/modules/nodes/nodes-plugin.js b/src/store/modules/nodes/nodes-plugin.js index 98d4feabf..9dbb74d6e 100644 --- a/src/store/modules/nodes/nodes-plugin.js +++ b/src/store/modules/nodes/nodes-plugin.js @@ -1,22 +1,25 @@ -import admClient from '@/lib/nodes/adm/AdmClient' - -export default store => { - // initial nodes state - admClient.getNodes().forEach(node => store.commit('nodes/status', node)) - - admClient.updateStatus() - - store.subscribe(mutation => { - if (mutation.type === 'nodes/useFastest') { - admClient.useFastest = !!mutation.payload - } - - if (mutation.type === 'nodes/toggle') { - admClient.toggleNode(mutation.payload.url, mutation.payload.active) - } - }) - - admClient.onStatusUpdate(status => { - store.commit('nodes/status', status) - }) -} +import { adm } from '@/lib/nodes/adm' +import { eth } from '@/lib/nodes/eth' + +export default (store) => { + // initial nodes state + adm.getNodes().forEach((status) => store.commit('nodes/status', { status, nodeType: 'adm' })) + eth.getNodes().forEach((status) => store.commit('nodes/status', { status, nodeType: 'eth' })) + + store.subscribe((mutation) => { + if (mutation.type === 'nodes/useFastest') { + adm.useFastest = !!mutation.payload + } + + if (mutation.type === 'nodes/toggle') { + adm.toggleNode(mutation.payload.url, mutation.payload.active) + } + }) + + adm.onStatusUpdate((status) => { + store.commit('nodes/status', { status, nodeType: 'adm' }) + }) + eth.onStatusUpdate((status) => { + store.commit('nodes/status', { status, nodeType: 'eth' }) + }) +} diff --git a/src/store/modules/nodes/nodes-state.js b/src/store/modules/nodes/nodes-state.js index 0f747c9db..df0a39971 100644 --- a/src/store/modules/nodes/nodes-state.js +++ b/src/store/modules/nodes/nodes-state.js @@ -1,4 +1,5 @@ -export default { - list: { }, - useFastest: false -} +export default { + adm: {}, + eth: {}, + useFastest: false +} diff --git a/src/store/plugins/socketsPlugin.js b/src/store/plugins/socketsPlugin.js index 92d718468..7ee58469a 100644 --- a/src/store/plugins/socketsPlugin.js +++ b/src/store/plugins/socketsPlugin.js @@ -49,7 +49,7 @@ export default store => { mutation.type === 'nodes/status' || mutation.type === 'nodes/toggle' ) { - socketClient.setNodes(store.getters['nodes/list']) + socketClient.setNodes(store.getters['nodes/adm']) } if (mutation.type === 'nodes/useFastest') { From a6bf360c7d6940e960058c3d14f2c68fb0b00b2c Mon Sep 17 00:00:00 2001 From: bludnic Date: Wed, 1 Nov 2023 17:46:08 +0000 Subject: [PATCH 08/50] feat: add healthcheck for BTC nodes --- src/components/NodesTable/NodesTable.vue | 3 ++ src/lib/nodes/btc/BtcClient.ts | 20 +++++++++++++ src/lib/nodes/btc/BtcNode.ts | 36 ++++++++++++++++++++++++ src/lib/nodes/btc/index.ts | 8 ++++++ src/store/modules/nodes/nodes-actions.js | 2 ++ src/store/modules/nodes/nodes-getters.js | 3 ++ src/store/modules/nodes/nodes-plugin.js | 5 ++++ src/store/modules/nodes/nodes-state.js | 1 + 8 files changed, 78 insertions(+) create mode 100644 src/lib/nodes/btc/BtcClient.ts create mode 100644 src/lib/nodes/btc/BtcNode.ts create mode 100644 src/lib/nodes/btc/index.ts diff --git a/src/components/NodesTable/NodesTable.vue b/src/components/NodesTable/NodesTable.vue index f3948993c..e12b37cf9 100644 --- a/src/components/NodesTable/NodesTable.vue +++ b/src/components/NodesTable/NodesTable.vue @@ -5,6 +5,7 @@ + @@ -34,6 +35,7 @@ export default defineComponent({ }) }) const ethNodes = computed(() => store.getters['nodes/eth']) + const btcNodes = computed(() => store.getters['nodes/btc']) const className = 'nodes-table' const classes = { @@ -43,6 +45,7 @@ export default defineComponent({ return { admNodes, ethNodes, + btcNodes, classes } } diff --git a/src/lib/nodes/btc/BtcClient.ts b/src/lib/nodes/btc/BtcClient.ts new file mode 100644 index 000000000..0d99e0c2f --- /dev/null +++ b/src/lib/nodes/btc/BtcClient.ts @@ -0,0 +1,20 @@ +import { BtcNode } from './BtcNode' +import { Client } from '../abstract.client' + +/** + * Provides methods for calling the ADAMANT API. + * + * The `ApiClient` instance automatically selects an ADAMANT node to + * send the API-requests to and switches to another node if the current one + * is not available at the moment. + */ +export class BtcClient extends Client { + constructor(endpoints: string[] = [], minNodeVersion = '0.0.0') { + super() + this.nodes = endpoints.map((endpoint) => new BtcNode(endpoint)) + this.minNodeVersion = minNodeVersion + this.useFastest = false + + void this.watchNodeStatusChange() + } +} diff --git a/src/lib/nodes/btc/BtcNode.ts b/src/lib/nodes/btc/BtcNode.ts new file mode 100644 index 000000000..d591505bd --- /dev/null +++ b/src/lib/nodes/btc/BtcNode.ts @@ -0,0 +1,36 @@ +import axios, { AxiosInstance } from 'axios' +import { Node } from '@/lib/nodes/abstract.node' + +/** + * Encapsulates a node. Provides methods to send API-requests + * to the node and verify is status (online/offline, version, ping, etc.) + */ +export class BtcNode extends Node { + client: AxiosInstance + + constructor(url: string) { + super(url) + + this.client = axios.create({ baseURL: url }) + this.client.interceptors.response.use(null, (error) => { + if (error.response && Number(error.response.status) >= 500) { + console.error('Request failed', error) + } + return Promise.reject(error) + }) + + void this.startHealthcheck() + } + + protected async checkHealth() { + const time = Date.now() + const blockNumber = await this.client + .get('/blocks/tip/height') + .then((data) => Number(data) || 0) + + return { + height: Number(blockNumber), + ping: Date.now() - time + } + } +} diff --git a/src/lib/nodes/btc/index.ts b/src/lib/nodes/btc/index.ts new file mode 100644 index 000000000..f82c8b174 --- /dev/null +++ b/src/lib/nodes/btc/index.ts @@ -0,0 +1,8 @@ +import config from '@/config' +import { NodeInfo } from '@/types/wallets' +import { BtcClient } from './BtcClient' + +const endpoints = (config.btc.nodes as NodeInfo[]).map((endpoint) => endpoint.url) +export const btc = new BtcClient(endpoints, config.adm.minNodeVersion) + +export default btc diff --git a/src/store/modules/nodes/nodes-actions.js b/src/store/modules/nodes/nodes-actions.js index 03e24b708..a6a61bf6d 100644 --- a/src/store/modules/nodes/nodes-actions.js +++ b/src/store/modules/nodes/nodes-actions.js @@ -1,5 +1,6 @@ import { adm } from '@/lib/nodes/adm' import { eth } from '@/lib/nodes/eth' +import { btc } from '@/lib/nodes/btc' export default { restore({ state }) { @@ -11,6 +12,7 @@ export default { updateStatus() { adm.checkHealth() eth.checkHealth() + btc.checkHealth() }, toggle(context, payload) { diff --git a/src/store/modules/nodes/nodes-getters.js b/src/store/modules/nodes/nodes-getters.js index 030fea047..728ed66db 100644 --- a/src/store/modules/nodes/nodes-getters.js +++ b/src/store/modules/nodes/nodes-getters.js @@ -4,5 +4,8 @@ export default { }, eth(state) { return Object.values(state.eth) + }, + btc(state) { + return Object.values(state.btc) } } diff --git a/src/store/modules/nodes/nodes-plugin.js b/src/store/modules/nodes/nodes-plugin.js index 9dbb74d6e..a9972f613 100644 --- a/src/store/modules/nodes/nodes-plugin.js +++ b/src/store/modules/nodes/nodes-plugin.js @@ -1,10 +1,12 @@ import { adm } from '@/lib/nodes/adm' import { eth } from '@/lib/nodes/eth' +import { btc } from '@/lib/nodes/btc' export default (store) => { // initial nodes state adm.getNodes().forEach((status) => store.commit('nodes/status', { status, nodeType: 'adm' })) eth.getNodes().forEach((status) => store.commit('nodes/status', { status, nodeType: 'eth' })) + btc.getNodes().forEach((status) => store.commit('nodes/status', { status, nodeType: 'btc' })) store.subscribe((mutation) => { if (mutation.type === 'nodes/useFastest') { @@ -22,4 +24,7 @@ export default (store) => { eth.onStatusUpdate((status) => { store.commit('nodes/status', { status, nodeType: 'eth' }) }) + btc.onStatusUpdate((status) => { + store.commit('nodes/status', { status, nodeType: 'btc' }) + }) } diff --git a/src/store/modules/nodes/nodes-state.js b/src/store/modules/nodes/nodes-state.js index df0a39971..5311bdd66 100644 --- a/src/store/modules/nodes/nodes-state.js +++ b/src/store/modules/nodes/nodes-state.js @@ -1,5 +1,6 @@ export default { adm: {}, eth: {}, + btc: {}, useFastest: false } From c97f916ba495218dc193b79f966965da9bda1507 Mon Sep 17 00:00:00 2001 From: bludnic Date: Wed, 1 Nov 2023 18:19:45 +0000 Subject: [PATCH 09/50] feat: add healthcheck for DOGE and DASH nodes --- src/components/NodesTable/NodesTable.vue | 6 ++++ src/lib/bitcoin/bitcoin-api.js | 5 ++-- src/lib/bitcoin/btc-base-api.js | 10 ------- src/lib/bitcoin/dash-api.js | 5 ++-- src/lib/bitcoin/doge-api.js | 5 ++-- src/lib/nodes/dash/DashClient.ts | 13 ++++++++ src/lib/nodes/dash/DashNode.ts | 38 ++++++++++++++++++++++++ src/lib/nodes/dash/index.ts | 8 +++++ src/lib/nodes/doge/DogeClient.ts | 13 ++++++++ src/lib/nodes/doge/DogeNode.ts | 36 ++++++++++++++++++++++ src/lib/nodes/doge/index.ts | 8 +++++ src/store/modules/nodes/nodes-actions.js | 4 +++ src/store/modules/nodes/nodes-getters.js | 6 ++++ src/store/modules/nodes/nodes-plugin.js | 10 +++++++ src/store/modules/nodes/nodes-state.js | 2 ++ 15 files changed, 153 insertions(+), 16 deletions(-) create mode 100644 src/lib/nodes/dash/DashClient.ts create mode 100644 src/lib/nodes/dash/DashNode.ts create mode 100644 src/lib/nodes/dash/index.ts create mode 100644 src/lib/nodes/doge/DogeClient.ts create mode 100644 src/lib/nodes/doge/DogeNode.ts create mode 100644 src/lib/nodes/doge/index.ts diff --git a/src/components/NodesTable/NodesTable.vue b/src/components/NodesTable/NodesTable.vue index e12b37cf9..2184f41c1 100644 --- a/src/components/NodesTable/NodesTable.vue +++ b/src/components/NodesTable/NodesTable.vue @@ -6,6 +6,8 @@ + + @@ -36,6 +38,8 @@ export default defineComponent({ }) const ethNodes = computed(() => store.getters['nodes/eth']) const btcNodes = computed(() => store.getters['nodes/btc']) + const dogeNodes = computed(() => store.getters['nodes/doge']) + const dashNodes = computed(() => store.getters['nodes/dash']) const className = 'nodes-table' const classes = { @@ -46,6 +50,8 @@ export default defineComponent({ admNodes, ethNodes, btcNodes, + dogeNodes, + dashNodes, classes } } diff --git a/src/lib/bitcoin/bitcoin-api.js b/src/lib/bitcoin/bitcoin-api.js index f2cfe03ad..7798d4ed0 100644 --- a/src/lib/bitcoin/bitcoin-api.js +++ b/src/lib/bitcoin/bitcoin-api.js @@ -1,5 +1,6 @@ import BtcBaseApi from './btc-base-api' import { Cryptos } from '../constants' +import { btc } from '@/lib/nodes/btc' export default class BitcoinApi extends BtcBaseApi { constructor (passphrase) { @@ -27,7 +28,7 @@ export default class BitcoinApi extends BtcBaseApi { /** @override */ sendTransaction (txHex) { - return this._getClient().post('/tx', txHex).then(response => response.data) + return btc.getClient().post('/tx', txHex).then(response => response.data) } /** @override */ @@ -78,6 +79,6 @@ export default class BitcoinApi extends BtcBaseApi { /** Executes a GET request to the API */ _get (url, params) { - return this._getClient().get(url, { params }).then(response => response.data) + return btc.getClient().get(url, { params }).then(response => response.data) } } diff --git a/src/lib/bitcoin/btc-base-api.js b/src/lib/bitcoin/btc-base-api.js index 200a629ed..23b912cd4 100644 --- a/src/lib/bitcoin/btc-base-api.js +++ b/src/lib/bitcoin/btc-base-api.js @@ -50,7 +50,6 @@ export default class BtcBaseApi { this._network = account.network this._keyPair = account.keyPair this._address = account.address - this._clients = {} this._crypto = crypto } @@ -197,15 +196,6 @@ export default class BtcBaseApi { return tx.toHex() } - /** Picks a client for a random API endpoint */ - _getClient () { - const url = getRandomNodeUrl(this._crypto.toLowerCase()) - if (!this._clients[url]) { - this._clients[url] = createClient(url) - } - return this._clients[url] - } - _mapTransaction(tx) { // Remove курьи txs like "possibleDoubleSpend" and txs without info if (tx.possibleDoubleSpend || (!tx.txid && !tx.time && !tx.valueIn && !tx.vin)) return diff --git a/src/lib/bitcoin/dash-api.js b/src/lib/bitcoin/dash-api.js index 630a5c489..0fa50219c 100644 --- a/src/lib/bitcoin/dash-api.js +++ b/src/lib/bitcoin/dash-api.js @@ -1,5 +1,6 @@ import BtcBaseApi from './btc-base-api' import { Cryptos } from '../constants' +import { dash } from '@/lib/nodes/dash' class DashApiError extends Error { constructor (method, error) { @@ -87,7 +88,7 @@ export default class DashApi extends BtcBaseApi { * @returns {Promise} method result */ _invoke (method, params) { - return this._getClient().post('/', { method, params }) + return dash.getClient().post('/', { method, params }) .then(({ data }) => { if (data.error) throw new DashApiError(method, data.error) return data.result @@ -95,7 +96,7 @@ export default class DashApi extends BtcBaseApi { } _invokeMany (calls) { - return this._getClient().post('/', calls) + return dash.getClient().post('/', calls) .then(response => response.data) } } diff --git a/src/lib/bitcoin/doge-api.js b/src/lib/bitcoin/doge-api.js index 882e7ac1d..25805c4b0 100644 --- a/src/lib/bitcoin/doge-api.js +++ b/src/lib/bitcoin/doge-api.js @@ -3,6 +3,7 @@ import qs from 'qs' import BtcBaseApi from './btc-base-api' import { Cryptos } from '../constants' import BigNumber from '../bignumber' +import { doge } from '@/lib/nodes/doge' const POST_CONFIG = { headers: { @@ -60,11 +61,11 @@ export default class DogeApi extends BtcBaseApi { /** Executes a GET request to the DOGE API */ _get (url, params) { - return this._getClient().get(url, { params }).then(response => response.data) + return doge.getClient().get(url, { params }).then(response => response.data) } /** Executes a POST request to the DOGE API */ _post (url, data) { - return this._getClient().post(url, qs.stringify(data), POST_CONFIG).then(response => response.data) + return doge.getClient().post(url, qs.stringify(data), POST_CONFIG).then(response => response.data) } } diff --git a/src/lib/nodes/dash/DashClient.ts b/src/lib/nodes/dash/DashClient.ts new file mode 100644 index 000000000..5512edad5 --- /dev/null +++ b/src/lib/nodes/dash/DashClient.ts @@ -0,0 +1,13 @@ +import { DashNode } from './DashNode' +import { Client } from '../abstract.client' + +export class DashClient extends Client { + constructor(endpoints: string[] = [], minNodeVersion = '0.0.0') { + super() + this.nodes = endpoints.map((endpoint) => new DashNode(endpoint)) + this.minNodeVersion = minNodeVersion + this.useFastest = false + + void this.watchNodeStatusChange() + } +} diff --git a/src/lib/nodes/dash/DashNode.ts b/src/lib/nodes/dash/DashNode.ts new file mode 100644 index 000000000..bbae6974e --- /dev/null +++ b/src/lib/nodes/dash/DashNode.ts @@ -0,0 +1,38 @@ +import axios, { AxiosInstance } from 'axios' +import { Node } from '@/lib/nodes/abstract.node' + +/** + * Encapsulates a node. Provides methods to send API-requests + * to the node and verify is status (online/offline, version, ping, etc.) + */ +export class DashNode extends Node { + client: AxiosInstance + + constructor(url: string) { + super(url) + + this.client = axios.create({ baseURL: url }) + this.client.interceptors.response.use(null, (error) => { + if (error.response && Number(error.response.status) >= 500) { + console.error('Request failed', error) + } + return Promise.reject(error) + }) + + void this.startHealthcheck() + } + + protected async checkHealth() { + const time = Date.now() + const height = await this.client + .post('/', { + method: 'getblockchaininfo' + }) + .then((res) => res.data.result.blocks) + + return { + height, + ping: Date.now() - time + } + } +} diff --git a/src/lib/nodes/dash/index.ts b/src/lib/nodes/dash/index.ts new file mode 100644 index 000000000..7f5a516bb --- /dev/null +++ b/src/lib/nodes/dash/index.ts @@ -0,0 +1,8 @@ +import config from '@/config' +import { NodeInfo } from '@/types/wallets' +import { DashClient } from './DashClient' + +const endpoints = (config.dash.nodes as NodeInfo[]).map((endpoint) => endpoint.url) +export const dash = new DashClient(endpoints, config.adm.minNodeVersion) + +export default dash diff --git a/src/lib/nodes/doge/DogeClient.ts b/src/lib/nodes/doge/DogeClient.ts new file mode 100644 index 000000000..3d24d2c81 --- /dev/null +++ b/src/lib/nodes/doge/DogeClient.ts @@ -0,0 +1,13 @@ +import { DogeNode } from './DogeNode' +import { Client } from '../abstract.client' + +export class DogeClient extends Client { + constructor(endpoints: string[] = [], minNodeVersion = '0.0.0') { + super() + this.nodes = endpoints.map((endpoint) => new DogeNode(endpoint)) + this.minNodeVersion = minNodeVersion + this.useFastest = false + + void this.watchNodeStatusChange() + } +} diff --git a/src/lib/nodes/doge/DogeNode.ts b/src/lib/nodes/doge/DogeNode.ts new file mode 100644 index 000000000..b78aa3579 --- /dev/null +++ b/src/lib/nodes/doge/DogeNode.ts @@ -0,0 +1,36 @@ +import axios, { AxiosInstance } from 'axios' +import { Node } from '@/lib/nodes/abstract.node' + +/** + * Encapsulates a node. Provides methods to send API-requests + * to the node and verify is status (online/offline, version, ping, etc.) + */ +export class DogeNode extends Node { + client: AxiosInstance + + constructor(url: string) { + super(url) + + this.client = axios.create({ baseURL: url }) + this.client.interceptors.response.use(null, (error) => { + if (error.response && Number(error.response.status) >= 500) { + console.error('Request failed', error) + } + return Promise.reject(error) + }) + + void this.startHealthcheck() + } + + protected async checkHealth() { + const time = Date.now() + const height = await this.client + .get('/api/blocks?limit=0') + .then((res) => res.data.blocks[0].height) + + return { + height, + ping: Date.now() - time + } + } +} diff --git a/src/lib/nodes/doge/index.ts b/src/lib/nodes/doge/index.ts new file mode 100644 index 000000000..0662be488 --- /dev/null +++ b/src/lib/nodes/doge/index.ts @@ -0,0 +1,8 @@ +import config from '@/config' +import { NodeInfo } from '@/types/wallets' +import { DogeClient } from './DogeClient' + +const endpoints = (config.doge.nodes as NodeInfo[]).map((endpoint) => endpoint.url) +export const doge = new DogeClient(endpoints, config.adm.minNodeVersion) + +export default doge diff --git a/src/store/modules/nodes/nodes-actions.js b/src/store/modules/nodes/nodes-actions.js index a6a61bf6d..e43141c8c 100644 --- a/src/store/modules/nodes/nodes-actions.js +++ b/src/store/modules/nodes/nodes-actions.js @@ -1,6 +1,8 @@ import { adm } from '@/lib/nodes/adm' import { eth } from '@/lib/nodes/eth' import { btc } from '@/lib/nodes/btc' +import { doge } from '@/lib/nodes/doge' +import { dash } from '@/lib/nodes/dash' export default { restore({ state }) { @@ -13,6 +15,8 @@ export default { adm.checkHealth() eth.checkHealth() btc.checkHealth() + doge.checkHealth() + dash.checkHealth() }, toggle(context, payload) { diff --git a/src/store/modules/nodes/nodes-getters.js b/src/store/modules/nodes/nodes-getters.js index 728ed66db..af3e15ba4 100644 --- a/src/store/modules/nodes/nodes-getters.js +++ b/src/store/modules/nodes/nodes-getters.js @@ -7,5 +7,11 @@ export default { }, btc(state) { return Object.values(state.btc) + }, + doge(state) { + return Object.values(state.doge) + }, + dash(state) { + return Object.values(state.dash) } } diff --git a/src/store/modules/nodes/nodes-plugin.js b/src/store/modules/nodes/nodes-plugin.js index a9972f613..08e4abe04 100644 --- a/src/store/modules/nodes/nodes-plugin.js +++ b/src/store/modules/nodes/nodes-plugin.js @@ -1,12 +1,16 @@ import { adm } from '@/lib/nodes/adm' import { eth } from '@/lib/nodes/eth' import { btc } from '@/lib/nodes/btc' +import { doge } from '@/lib/nodes/doge' +import { dash } from '@/lib/nodes/dash' export default (store) => { // initial nodes state adm.getNodes().forEach((status) => store.commit('nodes/status', { status, nodeType: 'adm' })) eth.getNodes().forEach((status) => store.commit('nodes/status', { status, nodeType: 'eth' })) btc.getNodes().forEach((status) => store.commit('nodes/status', { status, nodeType: 'btc' })) + doge.getNodes().forEach((status) => store.commit('nodes/status', { status, nodeType: 'doge' })) + dash.getNodes().forEach((status) => store.commit('nodes/status', { status, nodeType: 'dash' })) store.subscribe((mutation) => { if (mutation.type === 'nodes/useFastest') { @@ -27,4 +31,10 @@ export default (store) => { btc.onStatusUpdate((status) => { store.commit('nodes/status', { status, nodeType: 'btc' }) }) + doge.onStatusUpdate((status) => { + store.commit('nodes/status', { status, nodeType: 'doge' }) + }) + dash.onStatusUpdate((status) => { + store.commit('nodes/status', { status, nodeType: 'dash' }) + }) } diff --git a/src/store/modules/nodes/nodes-state.js b/src/store/modules/nodes/nodes-state.js index 5311bdd66..dd8be7ff2 100644 --- a/src/store/modules/nodes/nodes-state.js +++ b/src/store/modules/nodes/nodes-state.js @@ -2,5 +2,7 @@ export default { adm: {}, eth: {}, btc: {}, + doge: {}, + dash: {}, useFastest: false } From 71869e3fcacbd7ea32a7125a67b8cb246e3b3113 Mon Sep 17 00:00:00 2001 From: bludnic Date: Wed, 1 Nov 2023 18:20:15 +0000 Subject: [PATCH 10/50] feat(abstract.node): provider current `height` --- src/lib/nodes/abstract.node.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/nodes/abstract.node.ts b/src/lib/nodes/abstract.node.ts index 346991ad0..829885550 100644 --- a/src/lib/nodes/abstract.node.ts +++ b/src/lib/nodes/abstract.node.ts @@ -142,7 +142,8 @@ export abstract class Node { outOfSync: this.outOfSync, hasMinNodeVersion: this.version >= this.minNodeVersion, hasSupportedProtocol: this.hasSupportedProtocol, - socketSupport: this.socketSupport + socketSupport: this.socketSupport, + height: this.height } } From 56ad0b0aa76031c98ff3dcd7cceeebae6d090818 Mon Sep 17 00:00:00 2001 From: bludnic Date: Wed, 1 Nov 2023 18:30:53 +0000 Subject: [PATCH 11/50] feat: add healthcheck for LSK nodes --- src/components/NodesTable/NodesTable.vue | 3 ++ src/lib/lisk/lisk-api.js | 5 ++- src/lib/lisk/lsk-base-api.js | 33 ----------------- src/lib/nodes/lsk/LskClient.ts | 13 +++++++ src/lib/nodes/lsk/LskNode.ts | 47 ++++++++++++++++++++++++ src/lib/nodes/lsk/index.ts | 8 ++++ src/store/modules/nodes/nodes-actions.js | 2 + src/store/modules/nodes/nodes-getters.js | 3 ++ src/store/modules/nodes/nodes-plugin.js | 5 +++ src/store/modules/nodes/nodes-state.js | 1 + 10 files changed, 85 insertions(+), 35 deletions(-) create mode 100644 src/lib/nodes/lsk/LskClient.ts create mode 100644 src/lib/nodes/lsk/LskNode.ts create mode 100644 src/lib/nodes/lsk/index.ts diff --git a/src/components/NodesTable/NodesTable.vue b/src/components/NodesTable/NodesTable.vue index 2184f41c1..bd37aef64 100644 --- a/src/components/NodesTable/NodesTable.vue +++ b/src/components/NodesTable/NodesTable.vue @@ -8,6 +8,7 @@ + @@ -40,6 +41,7 @@ export default defineComponent({ const btcNodes = computed(() => store.getters['nodes/btc']) const dogeNodes = computed(() => store.getters['nodes/doge']) const dashNodes = computed(() => store.getters['nodes/dash']) + const lskNodes = computed(() => store.getters['nodes/lsk']) const className = 'nodes-table' const classes = { @@ -52,6 +54,7 @@ export default defineComponent({ btcNodes, dogeNodes, dashNodes, + lskNodes, classes } } diff --git a/src/lib/lisk/lisk-api.js b/src/lib/lisk/lisk-api.js index 866b2fd7d..70f011102 100644 --- a/src/lib/lisk/lisk-api.js +++ b/src/lib/lisk/lisk-api.js @@ -7,6 +7,7 @@ import * as transactions from '@liskhq/lisk-transactions' import pbkdf2 from 'pbkdf2' import sodium from 'sodium-browserify-tweetnacl' import networks from './networks' +import { lsk } from '@/lib/nodes/lsk' export const LiskHashSettings = { SALT: 'adm', @@ -147,7 +148,7 @@ export default class LiskApi extends LskBaseApi { /** @override */ sendTransaction (signedTx) { - return this._getClient().post('/api/transactions', signedTx).then(response => { + return lsk.getClient().post('/api/transactions', signedTx).then(response => { return response.data.data.transactionId }) } @@ -203,7 +204,7 @@ export default class LiskApi extends LskBaseApi { /** Executes a GET request to the node's core API */ _get (url, params) { - return this._getClient().get(url, { params }).then(response => response.data) + return lsk.getClient().get(url, { params }).then(response => response.data) } /** Executes a GET request to the Lisk Service API */ diff --git a/src/lib/lisk/lsk-base-api.js b/src/lib/lisk/lsk-base-api.js index 01d666eae..ab11622bc 100644 --- a/src/lib/lisk/lsk-base-api.js +++ b/src/lib/lisk/lsk-base-api.js @@ -7,30 +7,6 @@ import { CryptosInfo } from '../constants' export const TX_DEFAULT_FEE = 0.0016 -const createClient = (url) => { - const client = axios.create({ baseURL: url }) - client.interceptors.response.use(null, (error) => { - if (error.response && Number(error.response.status) >= 500) { - console.error(`Request to ${url} failed.`, error) - } - // Lisk is spamming with 404 in console, when there is no LSK account - // There is no way to disable 404 logging for Chrome - if (error.response && Number(error.response.status) === 404) { - if ( - error.response.data && - error.response.data.errors && - error.response.data.errors[0] && - error.response.data.errors[0].message && - error.response.data.errors[0].message.includes('was not found') - ) { - return error.response - } - } - return Promise.reject(error) - }) - return client -} - const createServiceClient = (url) => { const client = axios.create({ baseURL: url }) client.interceptors.response.use(null, (error) => { @@ -203,15 +179,6 @@ export default class LskBaseApi { return Promise.resolve({ hasMore: false, items: [] }) } - /** Picks a LSK node's (core) client for a random API endpoint */ - _getClient() { - const url = getRandomNodeUrl(this._crypto.toLowerCase()) - if (!this._clients[url]) { - this._clients[url] = createClient(url) - } - return this._clients[url] - } - /** Picks a Lisk Service client for a random API endpoint */ _getServiceClient() { const url = getRandomServiceUrl(this._crypto.toLowerCase(), 'lskService') diff --git a/src/lib/nodes/lsk/LskClient.ts b/src/lib/nodes/lsk/LskClient.ts new file mode 100644 index 000000000..a8bdd5eb1 --- /dev/null +++ b/src/lib/nodes/lsk/LskClient.ts @@ -0,0 +1,13 @@ +import { LskNode } from './LskNode' +import { Client } from '../abstract.client' + +export class LskClient extends Client { + constructor(endpoints: string[] = [], minNodeVersion = '0.0.0') { + super() + this.nodes = endpoints.map((endpoint) => new LskNode(endpoint)) + this.minNodeVersion = minNodeVersion + this.useFastest = false + + void this.watchNodeStatusChange() + } +} diff --git a/src/lib/nodes/lsk/LskNode.ts b/src/lib/nodes/lsk/LskNode.ts new file mode 100644 index 000000000..2b40717a8 --- /dev/null +++ b/src/lib/nodes/lsk/LskNode.ts @@ -0,0 +1,47 @@ +import axios, { AxiosInstance } from 'axios' +import { Node } from '@/lib/nodes/abstract.node' + +/** + * Encapsulates a node. Provides methods to send API-requests + * to the node and verify is status (online/offline, version, ping, etc.) + */ +export class LskNode extends Node { + client: AxiosInstance + + constructor(url: string) { + super(url) + + this.client = axios.create({ baseURL: url }) + this.client.interceptors.response.use(null, (error) => { + if (error.response && Number(error.response.status) >= 500) { + console.error(`Request to ${url} failed.`, error) + } + // Lisk is spamming with 404 in console, when there is no LSK account + // There is no way to disable 404 logging for Chrome + if (error.response && Number(error.response.status) === 404) { + if ( + error.response.data && + error.response.data.errors && + error.response.data.errors[0] && + error.response.data.errors[0].message && + error.response.data.errors[0].message.includes('was not found') + ) { + return error.response + } + } + return Promise.reject(error) + }) + + void this.startHealthcheck() + } + + protected async checkHealth() { + const time = Date.now() + const height = await this.client.get('/api/node/info').then((res) => res.data.data.height) + + return { + height, + ping: Date.now() - time + } + } +} diff --git a/src/lib/nodes/lsk/index.ts b/src/lib/nodes/lsk/index.ts new file mode 100644 index 000000000..f80da62f6 --- /dev/null +++ b/src/lib/nodes/lsk/index.ts @@ -0,0 +1,8 @@ +import config from '@/config' +import { NodeInfo } from '@/types/wallets' +import { LskClient } from './LskClient' + +const endpoints = (config.lsk.nodes as NodeInfo[]).map((endpoint) => endpoint.url) +export const lsk = new LskClient(endpoints) + +export default lsk diff --git a/src/store/modules/nodes/nodes-actions.js b/src/store/modules/nodes/nodes-actions.js index e43141c8c..74f311cfc 100644 --- a/src/store/modules/nodes/nodes-actions.js +++ b/src/store/modules/nodes/nodes-actions.js @@ -3,6 +3,7 @@ import { eth } from '@/lib/nodes/eth' import { btc } from '@/lib/nodes/btc' import { doge } from '@/lib/nodes/doge' import { dash } from '@/lib/nodes/dash' +import { lsk } from '@/lib/nodes/lsk' export default { restore({ state }) { @@ -17,6 +18,7 @@ export default { btc.checkHealth() doge.checkHealth() dash.checkHealth() + lsk.checkHealth() }, toggle(context, payload) { diff --git a/src/store/modules/nodes/nodes-getters.js b/src/store/modules/nodes/nodes-getters.js index af3e15ba4..1b34c022c 100644 --- a/src/store/modules/nodes/nodes-getters.js +++ b/src/store/modules/nodes/nodes-getters.js @@ -13,5 +13,8 @@ export default { }, dash(state) { return Object.values(state.dash) + }, + lsk(state) { + return Object.values(state.lsk) } } diff --git a/src/store/modules/nodes/nodes-plugin.js b/src/store/modules/nodes/nodes-plugin.js index 08e4abe04..16dbca3e0 100644 --- a/src/store/modules/nodes/nodes-plugin.js +++ b/src/store/modules/nodes/nodes-plugin.js @@ -3,6 +3,7 @@ import { eth } from '@/lib/nodes/eth' import { btc } from '@/lib/nodes/btc' import { doge } from '@/lib/nodes/doge' import { dash } from '@/lib/nodes/dash' +import { lsk } from '@/lib/nodes/lsk' export default (store) => { // initial nodes state @@ -11,6 +12,7 @@ export default (store) => { btc.getNodes().forEach((status) => store.commit('nodes/status', { status, nodeType: 'btc' })) doge.getNodes().forEach((status) => store.commit('nodes/status', { status, nodeType: 'doge' })) dash.getNodes().forEach((status) => store.commit('nodes/status', { status, nodeType: 'dash' })) + lsk.getNodes().forEach((status) => store.commit('nodes/status', { status, nodeType: 'lsk' })) store.subscribe((mutation) => { if (mutation.type === 'nodes/useFastest') { @@ -37,4 +39,7 @@ export default (store) => { dash.onStatusUpdate((status) => { store.commit('nodes/status', { status, nodeType: 'dash' }) }) + lsk.onStatusUpdate((status) => { + store.commit('nodes/status', { status, nodeType: 'lsk' }) + }) } diff --git a/src/store/modules/nodes/nodes-state.js b/src/store/modules/nodes/nodes-state.js index dd8be7ff2..f4b7249b0 100644 --- a/src/store/modules/nodes/nodes-state.js +++ b/src/store/modules/nodes/nodes-state.js @@ -4,5 +4,6 @@ export default { btc: {}, doge: {}, dash: {}, + lsk: {}, useFastest: false } From 3f530d6d5273e8abb597eeea7c4dc5d1aac67493 Mon Sep 17 00:00:00 2001 From: bludnic Date: Wed, 1 Nov 2023 18:32:16 +0000 Subject: [PATCH 12/50] fix(Nodes): use default minNodeVersion --- src/lib/nodes/btc/index.ts | 2 +- src/lib/nodes/dash/index.ts | 2 +- src/lib/nodes/doge/index.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/nodes/btc/index.ts b/src/lib/nodes/btc/index.ts index f82c8b174..6c52544f3 100644 --- a/src/lib/nodes/btc/index.ts +++ b/src/lib/nodes/btc/index.ts @@ -3,6 +3,6 @@ import { NodeInfo } from '@/types/wallets' import { BtcClient } from './BtcClient' const endpoints = (config.btc.nodes as NodeInfo[]).map((endpoint) => endpoint.url) -export const btc = new BtcClient(endpoints, config.adm.minNodeVersion) +export const btc = new BtcClient(endpoints) export default btc diff --git a/src/lib/nodes/dash/index.ts b/src/lib/nodes/dash/index.ts index 7f5a516bb..640953e48 100644 --- a/src/lib/nodes/dash/index.ts +++ b/src/lib/nodes/dash/index.ts @@ -3,6 +3,6 @@ import { NodeInfo } from '@/types/wallets' import { DashClient } from './DashClient' const endpoints = (config.dash.nodes as NodeInfo[]).map((endpoint) => endpoint.url) -export const dash = new DashClient(endpoints, config.adm.minNodeVersion) +export const dash = new DashClient(endpoints) export default dash diff --git a/src/lib/nodes/doge/index.ts b/src/lib/nodes/doge/index.ts index 0662be488..5143c9155 100644 --- a/src/lib/nodes/doge/index.ts +++ b/src/lib/nodes/doge/index.ts @@ -3,6 +3,6 @@ import { NodeInfo } from '@/types/wallets' import { DogeClient } from './DogeClient' const endpoints = (config.doge.nodes as NodeInfo[]).map((endpoint) => endpoint.url) -export const doge = new DogeClient(endpoints, config.adm.minNodeVersion) +export const doge = new DogeClient(endpoints) export default doge From 7f56cfe70e9fd85a000c016a26f9bc04d76a82a7 Mon Sep 17 00:00:00 2001 From: bludnic Date: Wed, 1 Nov 2023 23:39:05 +0000 Subject: [PATCH 13/50] feat(Nodes): increase healthcheck interval --- src/lib/nodes/abstract.node.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/nodes/abstract.node.ts b/src/lib/nodes/abstract.node.ts index 829885550..41a2104fa 100644 --- a/src/lib/nodes/abstract.node.ts +++ b/src/lib/nodes/abstract.node.ts @@ -11,7 +11,7 @@ const appProtocol = location.protocol /** * Interval how often to update node statuses */ -const REVISE_CONNECTION_TIMEOUT = 5000 +const REVISE_CONNECTION_TIMEOUT = 60000 export abstract class Node { /** From fbbd257d0501f75c3c03839f59dc55d07984f1be Mon Sep 17 00:00:00 2001 From: bludnic Date: Wed, 1 Nov 2023 23:47:37 +0000 Subject: [PATCH 14/50] fix(Nodes): check health immediately when navigate to Nodes page --- src/views/Nodes.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/src/views/Nodes.vue b/src/views/Nodes.vue index b66a39869..92704fdb1 100644 --- a/src/views/Nodes.vue +++ b/src/views/Nodes.vue @@ -96,6 +96,7 @@ export default { mounted () { this.$store.dispatch('nodes/restore') + this.$store.dispatch('nodes/updateStatus') this.timer = setInterval(() => { this.$store.dispatch('nodes/updateStatus') }, 10000) From 96f6eabe7615fb93a74f839c5264117cd95eac78 Mon Sep 17 00:00:00 2001 From: bludnic Date: Thu, 2 Nov 2023 00:07:06 +0000 Subject: [PATCH 15/50] feat(NodeTable): add blockchain label --- src/components/NodesTable/BlockchainLabel.vue | 30 +++++++++++++++++++ src/components/NodesTable/NodesTable.vue | 12 ++++---- src/components/NodesTable/NodesTableItem.vue | 27 +++++++++++------ src/lib/nodes/abstract.node.ts | 2 ++ 4 files changed, 56 insertions(+), 15 deletions(-) create mode 100644 src/components/NodesTable/BlockchainLabel.vue diff --git a/src/components/NodesTable/BlockchainLabel.vue b/src/components/NodesTable/BlockchainLabel.vue new file mode 100644 index 000000000..19d33a8f1 --- /dev/null +++ b/src/components/NodesTable/BlockchainLabel.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/src/components/NodesTable/NodesTable.vue b/src/components/NodesTable/NodesTable.vue index bd37aef64..adb5dcc37 100644 --- a/src/components/NodesTable/NodesTable.vue +++ b/src/components/NodesTable/NodesTable.vue @@ -3,12 +3,12 @@ - - - - - - + + + + + + diff --git a/src/components/NodesTable/NodesTableItem.vue b/src/components/NodesTable/NodesTableItem.vue index a21aa26f3..23d816008 100644 --- a/src/components/NodesTable/NodesTableItem.vue +++ b/src/components/NodesTable/NodesTableItem.vue @@ -11,12 +11,13 @@ :model-value="active" :class="[classes.checkbox, classes.statusIconGrey]" @input="toggleActiveStatus" - :disabled="disableCheckbox" + :disabled="blockchain !== 'adm'" /> {{ url }} +

{{ 'v' + version }}
@@ -49,12 +50,15 @@ - - - diff --git a/src/components/NodesTable/BlockchainLabel.vue b/src/components/nodes/adm/NodesTable/BlockchainLabel.vue similarity index 100% rename from src/components/NodesTable/BlockchainLabel.vue rename to src/components/nodes/adm/NodesTable/BlockchainLabel.vue diff --git a/src/components/NodesTable/NodesTable.vue b/src/components/nodes/adm/NodesTable/NodesTable.vue similarity index 94% rename from src/components/NodesTable/NodesTable.vue rename to src/components/nodes/adm/NodesTable/NodesTable.vue index adb5dcc37..43370607c 100644 --- a/src/components/NodesTable/NodesTable.vue +++ b/src/components/nodes/adm/NodesTable/NodesTable.vue @@ -16,8 +16,8 @@ + + diff --git a/src/components/nodes/adm/index.ts b/src/components/nodes/adm/index.ts new file mode 100644 index 000000000..2fa474855 --- /dev/null +++ b/src/components/nodes/adm/index.ts @@ -0,0 +1,3 @@ +import NodesTable from './NodesTable/NodesTable.vue' + +export { NodesTable } diff --git a/src/components/nodes/components/NodeStatus.vue b/src/components/nodes/components/NodeStatus.vue new file mode 100644 index 000000000..008c94e67 --- /dev/null +++ b/src/components/nodes/components/NodeStatus.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/src/components/nodes/components/SocketSupport.vue b/src/components/nodes/components/SocketSupport.vue new file mode 100644 index 000000000..8251f9f63 --- /dev/null +++ b/src/components/nodes/components/SocketSupport.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/src/components/nodes/hooks/index.ts b/src/components/nodes/hooks/index.ts new file mode 100644 index 000000000..cb4e771c9 --- /dev/null +++ b/src/components/nodes/hooks/index.ts @@ -0,0 +1 @@ +export * from './useNodeStatus.ts' diff --git a/src/components/nodes/hooks/useNodeStatus.ts b/src/components/nodes/hooks/useNodeStatus.ts new file mode 100644 index 000000000..913fecfa7 --- /dev/null +++ b/src/components/nodes/hooks/useNodeStatus.ts @@ -0,0 +1,62 @@ +import { computed, Ref } from 'vue' +import { useI18n, VueI18nTranslation } from 'vue-i18n' + +import { NodeStatusResult } from '@/lib/nodes/abstract.node' +import { NodeStatus } from '@/lib/nodes/types' + +type StatusColor = 'green' | 'red' | 'grey' | 'orange' + +function getNodeStatusTitle(node: NodeStatusResult, t: VueI18nTranslation) { + const i18n: Record = { + online: node.ping + ' ' + t('nodes.ms'), + offline: 'nodes.offline', + disabled: 'nodes.inactive', + sync: 'nodes.sync', + unsupported_version: 'nodes.unsupported' + } + const i18nKey = i18n[node.status] + + return t(i18nKey) +} + +function getNodeStatusText(node: NodeStatusResult, t: VueI18nTranslation) { + if (!node.hasMinNodeVersion) { + return t('nodes.unsupported_reason_api_version') + } else if (!node.hasSupportedProtocol) { + return t('nodes.unsupported_reason_protocol') + } + + return '' +} + +function getNodeStatusColor(node: NodeStatusResult) { + const statusColorMap: Record = { + online: 'green', + unsupported_version: 'red', + disabled: 'grey', + offline: 'red', + sync: 'orange' + } + + return statusColorMap[node.status] +} + +type UseNodeStatusResult = { + nodeStatusTitle: Ref + nodeStatusText: Ref + nodeStatusColor: Ref +} + +export function useNodeStatus(node: Ref): UseNodeStatusResult { + const { t } = useI18n() + + const nodeStatusTitle = computed(() => getNodeStatusTitle(node.value, t)) + const nodeStatusText = computed(() => getNodeStatusText(node.value, t)) + const nodeStatusColor = computed(() => getNodeStatusColor(node.value)) + + return { + nodeStatusTitle, + nodeStatusText, + nodeStatusColor + } +} diff --git a/src/lib/nodes/abstract.node.ts b/src/lib/nodes/abstract.node.ts index 0fa3e7b9a..8e322427a 100644 --- a/src/lib/nodes/abstract.node.ts +++ b/src/lib/nodes/abstract.node.ts @@ -1,3 +1,5 @@ +import { NodeStatus } from '@/lib/nodes/types.ts' + type HealthcheckResult = { height: number ping: number @@ -146,10 +148,25 @@ export abstract class Node { hasMinNodeVersion: this.hasMinNodeVersion(), hasSupportedProtocol: this.hasSupportedProtocol, socketSupport: this.socketSupport, - height: this.height + height: this.height, + status: this.getNodeStatus() } } + getNodeStatus(): NodeStatus { + if (!this.hasMinNodeVersion() || !this.hasSupportedProtocol) { + return 'unsupported_version' + } else if (!this.active) { + return 'disabled' + } else if (!this.online) { + return 'offline' + } else if (this.outOfSync) { + return 'sync' + } + + return 'online' + } + hasMinNodeVersion() { // if not provided then it doesn't require min version check if (!this.minNodeVersion) { diff --git a/src/lib/nodes/types.ts b/src/lib/nodes/types.ts index 8e22bdcf7..bcb505979 100644 --- a/src/lib/nodes/types.ts +++ b/src/lib/nodes/types.ts @@ -1,2 +1,8 @@ -export type NodeType = 'adm' | 'eth' | 'btc' | 'doge' | 'dash' | 'lsk' +export type NodeStatus = + | 'online' // node is online and fully functional + | 'offline' // node is offline + | 'disabled' // user disabled the node + | 'sync' // node is out of sync (too low block height) + | 'unsupported_version' // node version is too low +export type NodeType = 'adm' | 'eth' | 'btc' | 'doge' | 'dash' | 'lsk' diff --git a/src/views/Nodes.vue b/src/views/Nodes.vue index 92704fdb1..8e3e09d64 100644 --- a/src/views/Nodes.vue +++ b/src/views/Nodes.vue @@ -58,7 +58,7 @@ + + From 083d3a7fe09bb137a481b84e701cb075beebc230 Mon Sep 17 00:00:00 2001 From: bludnic Date: Wed, 8 Nov 2023 23:42:35 +0000 Subject: [PATCH 36/50] feat(NodesTable): display node height --- src/components/nodes/adm/NodesTable/NodesTableItem.vue | 2 +- src/components/nodes/hooks/useNodeStatus.ts | 4 ++++ src/locales/en.json | 1 + src/locales/ru.json | 1 + 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/nodes/adm/NodesTable/NodesTableItem.vue b/src/components/nodes/adm/NodesTable/NodesTableItem.vue index ec6e63cfe..84f921e10 100644 --- a/src/components/nodes/adm/NodesTable/NodesTableItem.vue +++ b/src/components/nodes/adm/NodesTable/NodesTableItem.vue @@ -60,7 +60,7 @@ export default { root: className, column: `${className}__column`, columnCheckbox: `${className}__column--checkbox`, - checkbox: `${className}__checkbox`, + checkbox: `${className}__checkbox` } const url = computed(() => props.node.url) diff --git a/src/components/nodes/hooks/useNodeStatus.ts b/src/components/nodes/hooks/useNodeStatus.ts index 913fecfa7..4751e8ea8 100644 --- a/src/components/nodes/hooks/useNodeStatus.ts +++ b/src/components/nodes/hooks/useNodeStatus.ts @@ -20,6 +20,10 @@ function getNodeStatusTitle(node: NodeStatusResult, t: VueI18nTranslation) { } function getNodeStatusText(node: NodeStatusResult, t: VueI18nTranslation) { + if (node.online) { + return t('nodes.height') + ': ' + node.height + } + if (!node.hasMinNodeVersion) { return t('nodes.unsupported_reason_api_version') } else if (!node.hasSupportedProtocol) { diff --git a/src/locales/en.json b/src/locales/en.json index 072b4b701..329f92c62 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -165,6 +165,7 @@ "ms": "ms", "nodeLabelDescription": "Support decentralization and enhance privacy level—run your own ADAMANT node. To add your nodes to the list, you need to deploy web application on separate domain. This limitation refers to Content Security Policy (CSP).", "offline": "Offline", + "height": "Height", "ping": "Ping", "socket": "Socket", "unsupported": "Unsupported", diff --git a/src/locales/ru.json b/src/locales/ru.json index e35485cf6..7ca01f263 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -166,6 +166,7 @@ "ms": "мс", "nodeLabelDescription": "Поддержите децентрализацию и повысьте уровень приватности — установите свой узел (ноду) АДАМАНТа. Чтобы добавить свои узлы в список для подключения, вам нужно развернуть свое веб-приложение на отдельном домене. Это ограничение связано с политикой безопасности Content Security Policy (CSP).", "offline": "Недоступна", + "height": "Высота", "ping": "Пинг", "socket": "Сокеты", "unsupported": "Не поддерживается", From 0d117582a6c6471c124261e1d429ae83dd158b26 Mon Sep 17 00:00:00 2001 From: bludnic Date: Thu, 9 Nov 2023 19:06:57 +0000 Subject: [PATCH 37/50] refactor(nodes): rename `BlockchainLabel` -> `NodeLabel` --- .../adm/NodesTable/{BlockchainLabel.vue => NodeLabel.vue} | 0 src/components/nodes/adm/NodesTable/NodesTableItem.vue | 6 +++--- 2 files changed, 3 insertions(+), 3 deletions(-) rename src/components/nodes/adm/NodesTable/{BlockchainLabel.vue => NodeLabel.vue} (100%) diff --git a/src/components/nodes/adm/NodesTable/BlockchainLabel.vue b/src/components/nodes/adm/NodesTable/NodeLabel.vue similarity index 100% rename from src/components/nodes/adm/NodesTable/BlockchainLabel.vue rename to src/components/nodes/adm/NodesTable/NodeLabel.vue diff --git a/src/components/nodes/adm/NodesTable/NodesTableItem.vue b/src/components/nodes/adm/NodesTable/NodesTableItem.vue index 84f921e10..fb9124253 100644 --- a/src/components/nodes/adm/NodesTable/NodesTableItem.vue +++ b/src/components/nodes/adm/NodesTable/NodesTableItem.vue @@ -11,7 +11,7 @@ {{ url }} - + @@ -30,7 +30,7 @@ import { computed, PropType } from 'vue' import { useStore } from 'vuex' import type { NodeStatusResult } from '@/lib/nodes/abstract.node' import type { NodeType } from '@/lib/nodes/types' -import BlockchainLabel from './BlockchainLabel.vue' +import NodeLabel from './NodeLabel.vue' import NodeStatus from '@/components/nodes/components/NodeStatus.vue' import NodeVersion from '@/components/nodes/components/NodeVersion.vue' import SocketSupport from '@/components/nodes/components/SocketSupport.vue' @@ -40,7 +40,7 @@ export default { NodeStatus, NodeVersion, SocketSupport, - BlockchainLabel + NodeLabel }, props: { node: { From 2b92f82b258df0f31659d20d3584754771384688 Mon Sep 17 00:00:00 2001 From: bludnic Date: Thu, 9 Nov 2023 19:22:09 +0000 Subject: [PATCH 38/50] refactor(nodes): reuse `NodesTable` and `NodesTableHead` --- .../nodes/adm/NodesTable/NodesTable.vue | 10 +++-- .../nodes/adm/NodesTable/NodesTableItem.vue | 2 +- .../CoindNodesTable/CoinNodesTable.vue | 0 .../NodesTable => components}/NodeLabel.vue | 0 .../nodes/components/NodesTable.vue | 45 +++++++++++++++++++ .../NodesTableHead.vue | 22 +++++++-- 6 files changed, 70 insertions(+), 9 deletions(-) create mode 100644 src/components/nodes/coin-nodes/CoindNodesTable/CoinNodesTable.vue rename src/components/nodes/{adm/NodesTable => components}/NodeLabel.vue (100%) create mode 100644 src/components/nodes/components/NodesTable.vue rename src/components/nodes/{adm/NodesTable => components}/NodesTableHead.vue (62%) diff --git a/src/components/nodes/adm/NodesTable/NodesTable.vue b/src/components/nodes/adm/NodesTable/NodesTable.vue index 43370607c..d26f6e41b 100644 --- a/src/components/nodes/adm/NodesTable/NodesTable.vue +++ b/src/components/nodes/adm/NodesTable/NodesTable.vue @@ -1,6 +1,6 @@ + + diff --git a/src/components/nodes/adm/NodesTable/NodesTableHead.vue b/src/components/nodes/components/NodesTableHead.vue similarity index 62% rename from src/components/nodes/adm/NodesTable/NodesTableHead.vue rename to src/components/nodes/components/NodesTableHead.vue index 28a8e8aa7..b5d48a272 100644 --- a/src/components/nodes/adm/NodesTable/NodesTableHead.vue +++ b/src/components/nodes/components/NodesTableHead.vue @@ -1,14 +1,14 @@