import axios, { AxiosRequestConfig, AxiosResponse, Method } from 'axios'; import { signMessage } from './node-support'; import { serializeParams, RestClientOptions, GenericAPIResponse, FtxDomain, serializeParamPayload, programId, programKey, programId2, isFtxUS } from './requestUtils'; type ApiHeaders = 'key' | 'ts' | 'sign' | 'subaccount'; const getHeader = (headerId: ApiHeaders, domain: FtxDomain = 'ftxcom'): string => { if (domain === 'ftxcom') { switch (headerId) { case 'key': return 'FTX-KEY'; case 'ts': return 'FTX-TS'; case 'sign': return 'FTX-SIGN'; case 'subaccount': return 'FTX-SUBACCOUNT'; } } if (domain === 'ftxus') { switch (headerId) { case 'key': return 'FTXUS-KEY'; case 'ts': return 'FTXUS-TS'; case 'sign': return 'FTXUS-SIGN'; case 'subaccount': return 'FTXUS-SUBACCOUNT'; } } console.warn('Unknown header requested: ', { headerId, domain }); return 'null'; } export default class RequestUtil { private timeOffset: number | null; private syncTimePromise: null | Promise<any>; private options: RestClientOptions; private baseUrl: string; private globalRequestOptions: AxiosRequestConfig; private key: string | undefined; private secret: string | undefined; constructor( key: string | undefined, secret: string | undefined, baseUrl: string, options: RestClientOptions = {}, requestOptions: AxiosRequestConfig = {}, ) { this.timeOffset = null; this.syncTimePromise = null; this.options = { recv_window: 5000, // how often to sync time drift with exchange servers sync_interval_ms: 3600000, // if true, we'll throw errors if any params are undefined strict_param_validation: false, ...options, }; this.globalRequestOptions = { // in ms == 5 minutes by default timeout: 1000 * 60 * 5, headers: { }, // custom request options based on axios specs - see: https://github.com/axios/axios#request-config ...requestOptions, }; if (typeof key === 'string') { this.globalRequestOptions.headers[getHeader('key', options.domain)] = key; } if (typeof this.options.subAccountName === 'string') { this.globalRequestOptions.headers[getHeader('subaccount', options.domain)] = this.options.subAccountName; } this.baseUrl = baseUrl; if (key && !secret) { throw new Error('API Key & Secret are both required for private endpoints') } if (this.options.disable_time_sync !== true) { this.syncTime(); setInterval(this.syncTime.bind(this), +this.options.sync_interval_ms!); } this.key = key; this.secret = secret; } get(endpoint: string, params?: any): GenericAPIResponse { return this._call('GET', endpoint, params); } post(endpoint: string, params?: any): GenericAPIResponse { return this._call('POST', endpoint, { ...params, [programKey]: isFtxUS(this.options) ? programId : programId2 }); } delete(endpoint: string, params?: any): GenericAPIResponse { return this._call('DELETE', endpoint, params); } /** * @private Make a HTTP request to a specific endpoint. Private endpoints are automatically signed. */ async _call(method: Method, endpoint: string, params?: string | object): GenericAPIResponse { const options = { ...this.globalRequestOptions, method: method, json: true }; options.url = endpoint.startsWith('https') ? endpoint : [this.baseUrl, endpoint].join('/'); const isGetRequest = method === 'GET'; const serialisedParams = serializeParamPayload(isGetRequest, params, this.options.strict_param_validation); // Add request sign if (this.key && this.secret) { if (this.timeOffset === null && !this.options.disable_time_sync) { await this.syncTime(); } const { timestamp, sign } = await this.getRequestSignature(method, endpoint, this.secret, serialisedParams); options.headers[getHeader('ts', this.options.domain)] = String(timestamp); options.headers[getHeader('sign', this.options.domain)] = sign; } if (isGetRequest) { options.url += serialisedParams; } else { options.data = params; } return axios(options).then(response => { if (response.status == 200) { return 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 getRequestSignature( method: Method, endpoint: string, secret: string | undefined, serialisedParams: string= '' ): Promise<{ timestamp: number; sign: string; }> { const timestamp = Date.now() + (this.timeOffset || 0); if (!secret) { return { timestamp, sign: '' }; } let signature_payload; if (serialisedParams==='?') { signature_payload = `${timestamp}${method}/api/${endpoint}`; } else { signature_payload = `${timestamp}${method}/api/${endpoint}${serialisedParams}`; } return { timestamp, sign: await signMessage(signature_payload, secret) }; } /** * @private sign request and set recv window */ signRequest(data: any): Promise<any> { const params = { ...data, api_key: this.key, timestamp: Date.now() + (this.timeOffset || 0) }; // Optional, set to 5000 by default. Increase if timestamp/recv_window errors are seen. if (this.options.recv_window && !params.recv_window) { params.recv_window = this.options.recv_window; } if (this.key && this.secret) { const serializedParams = serializeParams(params, this.options.strict_param_validation); params.sign = signMessage(serializedParams, this.secret); } return params; } /** * @private trigger time sync and store promise */ syncTime(): GenericAPIResponse { if (this.options.disable_time_sync === true) { return Promise.resolve(false); } if (this.syncTimePromise !== null) { return this.syncTimePromise; } this.syncTimePromise = this.getTimeOffset().then(offset => { this.timeOffset = offset; this.syncTimePromise = null; }); return this.syncTimePromise; } /** * @deprecated move this somewhere else, because endpoints shouldn't be hardcoded here */ async getTimeOffset(): Promise<number> { const start = Date.now(); try { const response = await this.get('https://otc.ftx.com/api/time'); const result = new Date(response.result).getTime(); const end = Date.now(); return Math.ceil(result - end + ((end - start) / 2)); } catch (e) { return 0; } } };