305 lines
7.8 KiB
TypeScript
305 lines
7.8 KiB
TypeScript
import axios, { AxiosRequestConfig, AxiosResponse, Method } from 'axios';
|
|
|
|
import { signMessage } from './node-support';
|
|
import {
|
|
RestClientOptions,
|
|
serializeParams,
|
|
RestClientType,
|
|
REST_CLIENT_TYPE_ENUM,
|
|
agentSource,
|
|
} from './requestUtils';
|
|
|
|
// 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:', JSON.stringify(response, null, 2));
|
|
// return response;
|
|
// });
|
|
|
|
interface SignedRequestContext {
|
|
timestamp: number;
|
|
api_key?: string;
|
|
recv_window?: number;
|
|
// spot is diff from the rest...
|
|
recvWindow?: number;
|
|
}
|
|
|
|
interface SignedRequest<T> {
|
|
originalParams: T & SignedRequestContext;
|
|
paramsWithSign?: T & SignedRequestContext & { sign: string };
|
|
sign: string;
|
|
}
|
|
|
|
export default abstract class BaseRestClient {
|
|
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;
|
|
private clientType: RestClientType;
|
|
|
|
/** Function that calls exchange API to query & resolve server time, used by time sync */
|
|
abstract fetchServerTime(): Promise<number>;
|
|
|
|
constructor(
|
|
key: string | undefined,
|
|
secret: string | undefined,
|
|
baseUrl: string,
|
|
options: RestClientOptions = {},
|
|
requestOptions: AxiosRequestConfig = {},
|
|
clientType: RestClientType
|
|
) {
|
|
this.timeOffset = null;
|
|
this.syncTimePromise = null;
|
|
|
|
this.clientType = clientType;
|
|
|
|
this.options = {
|
|
recv_window: 5000,
|
|
|
|
/** Throw errors if any params are undefined */
|
|
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,
|
|
...options,
|
|
};
|
|
|
|
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
|
|
...requestOptions,
|
|
headers: {
|
|
'x-referer': 'bybitapinode',
|
|
},
|
|
};
|
|
|
|
this.baseUrl = baseUrl;
|
|
|
|
if (key && !secret) {
|
|
throw new Error(
|
|
'API Key & Secret are both required for private enpoints'
|
|
);
|
|
}
|
|
|
|
if (this.options.enable_time_sync) {
|
|
this.syncTime();
|
|
setInterval(this.syncTime.bind(this), +this.options.sync_interval_ms!);
|
|
}
|
|
|
|
this.key = key;
|
|
this.secret = secret;
|
|
}
|
|
|
|
private isSpotClient() {
|
|
return this.clientType === REST_CLIENT_TYPE_ENUM.spot;
|
|
}
|
|
|
|
get(endpoint: string, params?: any) {
|
|
return this._call('GET', endpoint, params, true);
|
|
}
|
|
|
|
post(endpoint: string, params?: any) {
|
|
return this._call('POST', endpoint, params, true);
|
|
}
|
|
|
|
getPrivate(endpoint: string, params?: any) {
|
|
return this._call('GET', endpoint, params, false);
|
|
}
|
|
|
|
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(params?: any, 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();
|
|
}
|
|
|
|
return this.signRequest(params);
|
|
}
|
|
|
|
/**
|
|
* @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<any> {
|
|
const options = {
|
|
...this.globalRequestOptions,
|
|
url: [this.baseUrl, endpoint].join(endpoint.startsWith('/') ? '' : '/'),
|
|
method: method,
|
|
json: true,
|
|
};
|
|
|
|
for (const key in params) {
|
|
if (typeof params[key] === 'undefined') {
|
|
delete params[key];
|
|
}
|
|
}
|
|
|
|
const signResult = await this.prepareSignParams(params, isPublicApi);
|
|
|
|
if (method === 'GET' || this.isSpotClient()) {
|
|
options.params = signResult.paramsWithSign;
|
|
if (options.params?.agentSource) {
|
|
options.data = {
|
|
agentSource: agentSource,
|
|
};
|
|
}
|
|
} else {
|
|
options.data = signResult.paramsWithSign;
|
|
}
|
|
|
|
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 sign request and set recv window
|
|
*/
|
|
private async signRequest<T extends Object>(
|
|
data: T & SignedRequestContext
|
|
): Promise<SignedRequest<T>> {
|
|
const res: SignedRequest<T> = {
|
|
originalParams: {
|
|
...data,
|
|
api_key: this.key,
|
|
timestamp: Date.now() + (this.timeOffset || 0),
|
|
},
|
|
sign: '',
|
|
};
|
|
|
|
// Optional, set to 5000 by default. Increase if timestamp/recv_window errors are seen.
|
|
if (this.options.recv_window && !res.originalParams.recv_window) {
|
|
if (this.isSpotClient()) {
|
|
res.originalParams.recvWindow = this.options.recv_window;
|
|
} else {
|
|
res.originalParams.recv_window = this.options.recv_window;
|
|
}
|
|
}
|
|
|
|
if (this.key && this.secret) {
|
|
const serializedParams = serializeParams(
|
|
res.originalParams,
|
|
this.options.strict_param_validation
|
|
);
|
|
res.sign = await signMessage(serializedParams, this.secret);
|
|
res.paramsWithSign = {
|
|
...res.originalParams,
|
|
sign: res.sign,
|
|
};
|
|
}
|
|
|
|
return res;
|
|
}
|
|
|
|
/**
|
|
* Trigger time sync and store promise. Use force: true, if automatic time sync is disabled
|
|
*/
|
|
private syncTime(force?: boolean): Promise<any> {
|
|
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<number> {
|
|
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;
|
|
}
|
|
}
|
|
}
|