/** * Copyright (c) 2019 Burst Apps Team * Modified (c) 2023 Tura Network */ import {Http, HttpError, HttpClientFactory, HttpResponse} from '@turajs/http'; import {asyncRetry} from '@turajs/util'; import {ChainServiceSettings} from './chainServiceSettings'; import {DefaultApiEndpoint} from '../constants'; import {verifyTransaction} from '../internal/verifyTransaction'; /** * The send arguments for [[ChainService.send]] * * @module core */ export interface SendArgs { /** * Setting this option to `true`, skips the additional security check, i.e. the verification of the * unsigned transaction bytes, which detects tampered node responses. By default, the option is `false`. * Usually, you won't use this option, but can be useful when a method cannot be verified, * because the verification is not implemented yet. * */ skipAdditionalSecurityCheck?: boolean; [key: string]: any; } // Old API is inconsistent in its error responses interface ApiError { readonly errorCode?: number; readonly errorDescription?: string; readonly error?: string; } class SettingsImpl implements ChainServiceSettings { constructor(settings: ChainServiceSettings) { this.apiRootUrl = settings.apiRootUrl || DefaultApiEndpoint; this.nodeHost = settings.nodeHost; this.httpClient = settings.httpClient || HttpClientFactory.createHttpClient(settings.nodeHost, settings.httpClientOptions); this.reliableNodeHosts = settings.reliableNodeHosts || []; } readonly apiRootUrl: string; readonly httpClient: Http; readonly nodeHost: string; readonly reliableNodeHosts: string[]; } /** * Generic Chain Service class. * * This class can be used to call the chain api directly, in case a function is * not supported yet by SignumJS. Usually, you won't need to do it. * * * * @module core */ export class ChainService { /** * Creates Service instance * @param settings The settings for the service */ constructor(settings: ChainServiceSettings) { this.settings = new SettingsImpl(settings); const {apiRootUrl} = this.settings; if (apiRootUrl) { this._relPath = apiRootUrl.endsWith('/') ? apiRootUrl.substr(0, apiRootUrl.length - 1) : apiRootUrl; } } public settings: ChainServiceSettings; private readonly _relPath: string = DefaultApiEndpoint; private static throwAsHttpError(url: string, apiError: ApiError): void { const errorCode = apiError.errorCode && ` (Code: ${apiError.errorCode})` || ''; throw new HttpError(url, 400, `${apiError.errorDescription || apiError.error}${errorCode}`, apiError); } /** * Mounts an API conformant endpoint of format `?requestType=getBlock&height=123` * * @see https://docs.signum.network/signum/node-http-api * * @param {string} method The method name for `requestType` * @param {any} data A JSON object which will be mapped to url params * @return {string} The mounted url (without host) */ public toApiEndpoint(method: string, data: object = {}): string { const request = `${this._relPath}?requestType=${method}`; const params = Object.keys(data) .filter(k => data[k] !== undefined && k !== 'skipAdditionalSecurityCheck') .map(k => `${k}=${encodeURIComponent(data[k])}`) .join('&'); return params ? `${request}&${params}` : request; } /** * Requests a query to the configured chain node * @param {string} method The method according https://europe.signum.network/api-doc/ * @param {any} args A JSON object which will be mapped to url params * @param {any} options The optional request configuration for the passed Http client * (default is [AxiosRequestConfig](https://axios-http.com/docs/req_config) ) * @return {Promise} The response data of success * @throws HttpError in case of failure */ public async query(method: string, args: any = {}, options?: any): Promise { const endpoint = this.toApiEndpoint(method, args); const {response} = await this.faultTolerantRequest(() => this.settings.httpClient.get(endpoint, options)); if (response.errorCode || response.error || response.errorDescription) { ChainService.throwAsHttpError(endpoint, response); } return response; } /** * Send data to chain node * @param {string} method The method according https://europe.signum.network/api-doc/. * Note that there are only a few POST methods * @param {SendArgs} args A JSON object which will be mapped to url params * @param {any} body An object with key value pairs to submit as post body * @param {any} options The optional request configuration for the passed Http client * (default is [AxiosRequestConfig](https://axios-http.com/docs/req_config) ) * @return {Promise} The response data of success * @throws HttpError in case of failure */ public async send(method: string, args: SendArgs = {}, body?: object , options?: any): Promise { const endpoint = this.toApiEndpoint(method, args); const {response} = await this.faultTolerantRequest(() => this.settings.httpClient.post(endpoint, body, options)); if (response.errorCode || response.error || response.errorDescription) { ChainService.throwAsHttpError(endpoint, response); } if (!args.skipAdditionalSecurityCheck) { verifyTransaction(method, args, response); } return response; } private async faultTolerantRequest(requestFn: () => Promise): Promise { const onFailureAsync = async (e, retrialCount): Promise => { const shouldRetry = this.settings.reliableNodeHosts.length && retrialCount < this.settings.reliableNodeHosts.length; if (shouldRetry) { await this.selectBestHost(true); } return shouldRetry; }; return asyncRetry({ asyncFn: requestFn, onFailureAsync }); } /** * Automatically selects the best host, according to its response time, * i.e. the fastest node host will be returned (and set as nodeHost internally) * @param reconfigure An optional flag to set automatic reconfiguration. Default is `false` * Attention: Reconfiguration works only, if you use the default http client. Otherwise, you need to reconfigure manually! * @param checkMethod The optional API method to be called. This applies only for GET methods. Default is `getBlockchainStatus` * @throws Error If `reliableNodeHosts` is empty, or if all requests to the reliableNodeHosts fail */ public async selectBestHost(reconfigure = false, checkMethod = 'getBlockchainStatus'): Promise { if (!this.settings.reliableNodeHosts.length) { throw new Error('No reliableNodeHosts configured'); } const checkEndpoint = this.toApiEndpoint(checkMethod); let timeout = null; const requests = this.settings.reliableNodeHosts.map(host => { const absoluteUrl = `${host}${checkEndpoint}`; return new Promise(async (resolve, reject) => { try { await this.settings.httpClient.get(absoluteUrl); resolve(host); } catch (e) { if (timeout) { // @ts-ignore clearTimeout(timeout); } // @ts-ignore timeout = setTimeout(() => { reject(null); }, 10 * 1000); } }); }); const bestHost = await Promise.race(requests); // @ts-ignore clearTimeout(timeout); if (!bestHost) { throw new Error('All reliableNodeHosts failed'); } if (reconfigure) { this.settings = new SettingsImpl({ ...this.settings, httpClient: HttpClientFactory.createHttpClient(bestHost, this.settings.httpClientOptions), nodeHost: bestHost, }); } return bestHost; } }