/* eslint-disable @typescript-eslint/no-explicit-any */ import axios, { AxiosRequestConfig, AxiosResponse, Method } from 'axios'; import https from 'https'; import { APIID, RestClientOptions, RestClientType, getRestBaseUrl, parseRateLimitHeaders, serializeParams, } from './requestUtils'; import { SignAlgorithm, SignEncodeMethod, signMessage } from './webCryptoAPI'; const ENABLE_HTTP_TRACE = typeof process === 'object' && typeof process.env === 'object' && process.env.BYBITTRACE; if (ENABLE_HTTP_TRACE) { // axios.interceptors.request.use((request) => { // console.log( // new Date(), // 'Starting Request', // JSON.stringify(request, null, 2) // ); // return request; // }); axios.interceptors.response.use((response) => { console.log(new Date(), 'Response:', { request: { url: response.config.url, method: response.config.method, data: response.config.data, headers: response.config.headers, params: response.config.params, }, response: { status: response.status, statusText: response.statusText, headers: response.headers, data: response.data, }, }); return response; }); } interface SignedRequestContext { timestamp?: number; api_key?: string; recv_window?: number; } interface SignedRequest { originalParams: (T & SignedRequestContext) | SignedRequestContext; paramsWithSign?: T & SignedRequestContext & { sign: string }; serializedParams: string; sign: string; timestamp: number; recvWindow: number; } interface UnsignedRequest { originalParams: T; paramsWithSign: T; sign?: string; timestamp?: number; recvWindow?: number; } type SignMethod = 'v5auth'; export default abstract class BaseRestClient { private timeOffset: number | null = null; private syncTimePromise: null | Promise = null; private options: RestClientOptions; private baseUrl: string; private globalRequestOptions: AxiosRequestConfig; private key: string | undefined; private secret: string | undefined; private clientType: RestClientType; /** * Function that calls exchange API to query & resolve server time, used by time sync, disabled by default. * * Note: this should always return server time in seconds */ abstract fetchServerTime(): Promise; /** Defines the client type (affecting how requests & signatures behave) */ abstract getClientType(): RestClientType; /** * Create an instance of the REST client. Pass API credentials in the object in the first parameter. * @param {RestClientOptions} [restOptions={}] options to configure REST API connectivity * @param {AxiosRequestConfig} [networkOptions={}] HTTP networking options for axios */ constructor( restOptions: RestClientOptions = {}, networkOptions: AxiosRequestConfig = {}, ) { this.clientType = this.getClientType(); this.options = { recv_window: 5000, /** Throw errors if any request params are empty */ strict_param_validation: false, /** Disable time sync by default */ enable_time_sync: false, /** How often to sync time drift with bybit servers (if time sync is enabled) */ sync_interval_ms: 3600000, /** Determines whether to perform time synchronization before sending private requests */ syncTimeBeforePrivateRequests: false, /** Request parameter values are now URI encoded by default during signing. Set to false to override this behaviour. */ encodeSerialisedValues: true, ...restOptions, }; this.globalRequestOptions = { // in ms == 5 minutes by default timeout: 1000 * 60 * 5, // custom request options based on axios specs - see: https://github.com/axios/axios#request-config ...networkOptions, headers: { ...networkOptions.headers, 'x-referer': APIID, }, }; // If enabled, configure a https agent with keepAlive enabled if (this.options.keepAlive) { // For more advanced configuration, raise an issue on GitHub or use the "networkOptions" // parameter to define a custom httpsAgent with the desired properties this.globalRequestOptions.httpsAgent = new https.Agent({ keepAlive: true, keepAliveMsecs: this.options.keepAliveMsecs, }); } this.baseUrl = getRestBaseUrl(!!this.options.testnet, restOptions); this.key = this.options.key; this.secret = this.options.secret; if (this.key && !this.secret) { throw new Error( 'API Key & Secret are both required for private endpoints', ); } if (this.options.enable_time_sync) { this.syncTime(); setInterval(this.syncTime.bind(this), +this.options.sync_interval_ms!); } } get(endpoint: string, params?: any) { return this._call('GET', endpoint, params, true); } getPrivate(endpoint: string, params?: any) { return this._call('GET', endpoint, params, false); } post(endpoint: string, params?: any) { return this._call('POST', endpoint, params, true); } postPrivate(endpoint: string, params?: any) { return this._call('POST', endpoint, params, false); } deletePrivate(endpoint: string, params?: any) { return this._call('DELETE', endpoint, params, false); } private async prepareSignParams( method: Method, signMethod: SignMethod, params?: TParams, isPublicApi?: true, ): Promise>; private async prepareSignParams( method: Method, signMethod: SignMethod, params?: TParams, isPublicApi?: false | undefined, ): Promise>; private async prepareSignParams( method: Method, signMethod: SignMethod, params?: TParams, isPublicApi?: boolean, ): Promise | UnsignedRequest>; private async prepareSignParams( method: Method, signMethod: SignMethod, params?: TParams, isPublicApi?: boolean, ) { if (isPublicApi) { return { originalParams: params, paramsWithSign: params, }; } if (!this.key || !this.secret) { throw new Error('Private endpoints require api and private keys set'); } if (this.timeOffset === null) { await this.syncTime(); } if (this.options.syncTimeBeforePrivateRequests) { this.timeOffset = await this.fetchTimeOffset(); } return this.signRequest(params || {}, method, signMethod); } /** Returns an axios request object. Handles signing process automatically if this is a private API call */ private async buildRequest( method: Method, url: string, params?: any, isPublicApi?: boolean, ): Promise { const options: AxiosRequestConfig = { ...this.globalRequestOptions, url: url, method: method, }; for (const key in params) { if (typeof params[key] === 'undefined') { delete params[key]; } } if (isPublicApi) { return { ...options, params: params, }; } const signResult = await this.prepareSignParams( method, 'v5auth', params, isPublicApi, ); const headers: AxiosRequestConfig['headers'] = { 'X-BAPI-SIGN-TYPE': 2, 'X-BAPI-API-KEY': this.key, 'X-BAPI-TIMESTAMP': signResult.timestamp, 'X-BAPI-SIGN': signResult.sign, 'X-BAPI-RECV-WINDOW': signResult.recvWindow, ...options.headers, }; if (method === 'GET') { return { ...options, headers, params: signResult.originalParams, }; } return { ...options, headers, data: signResult.originalParams, }; } /** * @private Make a HTTP request to a specific endpoint. Private endpoints are automatically signed. */ private async _call( method: Method, endpoint: string, params?: any, isPublicApi?: boolean, ): Promise { // Sanity check to make sure it's only ever prefixed by one forward slash const requestUrl = [this.baseUrl, endpoint].join( endpoint.startsWith('/') ? '' : '/', ); // Build a request and handle signature process const options = await this.buildRequest( method, requestUrl, params, isPublicApi, ); if (ENABLE_HTTP_TRACE) { console.log('full request: ', options); } // Dispatch request return axios(options) .then((response) => { if (response.status == 200) { const perAPIRateLimits = this.options.parseAPIRateLimits ? parseRateLimitHeaders( response.headers, this.options.throwOnFailedRateLimitParse === true, ) : undefined; return { rateLimitApi: perAPIRateLimits, ...response.data, }; } throw response; }) .catch((e) => this.parseException(e)); } /** * @private generic handler to parse request exceptions */ parseException(e: any): unknown { if (this.options.parse_exceptions === false) { throw e; } // Something happened in setting up the request that triggered an Error if (!e.response) { if (!e.request) { throw e.message; } // request made but no response received throw e; } // The request was made and the server responded with a status code // that falls out of the range of 2xx const response: AxiosResponse = e.response; throw { code: response.status, message: response.statusText, body: response.data, headers: response.headers, requestOptions: this.options, }; } private async signMessage( paramsStr: string, secret: string, method: SignEncodeMethod, algorithm: SignAlgorithm, ): Promise { if (typeof this.options.customSignMessageFn === 'function') { return this.options.customSignMessageFn(paramsStr, secret); } return await signMessage(paramsStr, secret, method, algorithm); } /** * @private sign request and set recv window */ private async signRequest( data: T, method: Method, signMethod: SignMethod, ): Promise> { const timestamp = Date.now() + (this.timeOffset || 0); const res: SignedRequest = { originalParams: { ...data, }, sign: '', timestamp, recvWindow: 0, serializedParams: '', }; if (!this.key || !this.secret) { return res; } const key = this.key; const recvWindow = res.originalParams.recv_window || this.options.recv_window || 5000; const strictParamValidation = this.options.strict_param_validation; const encodeSerialisedValues = this.options.encodeSerialisedValues; // In case the parent function needs it (e.g. USDC uses a header) res.recvWindow = recvWindow; // usdc is different for some reason if (signMethod === 'v5auth') { const sortProperties = false; const signRequestParams = method === 'GET' ? serializeParams( res.originalParams, strictParamValidation, sortProperties, encodeSerialisedValues, ) : JSON.stringify(res.originalParams); const paramsStr = timestamp + key + recvWindow + signRequestParams; res.sign = await this.signMessage( paramsStr, this.secret, 'hex', 'SHA-256', ); res.serializedParams = signRequestParams; // console.log('sign req: ', { // req: paramsStr, // sign: res.sign, // }); return res; } return res; } /** * Trigger time sync and store promise. Use force: true, if automatic time sync is disabled */ private syncTime(force?: boolean): Promise { if (!force && !this.options.enable_time_sync) { this.timeOffset = 0; return Promise.resolve(false); } if (this.syncTimePromise !== null) { return this.syncTimePromise; } this.syncTimePromise = this.fetchTimeOffset().then((offset) => { this.timeOffset = offset; this.syncTimePromise = null; }); return this.syncTimePromise; } /** * Estimate drift based on client<->server latency */ async fetchTimeOffset(): Promise { try { const start = Date.now(); const serverTime = await this.fetchServerTime(); if (!serverTime || isNaN(serverTime)) { throw new Error( `fetchServerTime() returned non-number: "${serverTime}" typeof(${typeof serverTime})`, ); } const end = Date.now(); const severTimeMs = serverTime * 1000; const avgDrift = (end - start) / 2; return Math.ceil(severTimeMs - end + avgDrift); } catch (e) { console.error('Failed to fetch get time offset: ', e); return 0; } } }