bitmart-api
Version:
Complete & robust Node.js SDK for BitMart's REST APIs and WebSockets, with TypeScript declarations.
560 lines • 21.6 kB
JavaScript
import { BaseWebsocketClient } from './lib/BaseWSClient.js';
import { neverGuard } from './lib/misc-util.js';
import { signMessage, } from './lib/webCryptoAPI.js';
import { WS_BASE_URL_MAP, WS_KEY_MAP, } from './lib/websocket/websocket-util.js';
/** Any WS keys in this list will trigger auth on connect, if credentials are available */
const PRIVATE_WS_KEYS = [
WS_KEY_MAP.spotPrivateV1,
WS_KEY_MAP.futuresPrivateV1,
WS_KEY_MAP.futuresPrivateV2,
];
/** Any WS keys in this list will ALWAYS skip the authentication process, even if credentials are available */
export const PUBLIC_WS_KEYS = [
WS_KEY_MAP.spotPublicV1,
WS_KEY_MAP.futuresPublicV1,
WS_KEY_MAP.futuresPublicV2,
];
const WS_LOGGER_CATEGORY_ID = 'bitmart-ws';
const WS_LOGGER_CATEGORY = {
category: WS_LOGGER_CATEGORY_ID,
};
export class WebsocketClient extends BaseWebsocketClient {
constructor(options, logger) {
super({ ...options, wsLoggerCategory: WS_LOGGER_CATEGORY_ID }, logger);
}
/**
*
* Request connection of all dependent (public & private) websockets, instead of waiting for automatic connection by library
*/
connectAll() {
return [
this.connect(WS_KEY_MAP.spotPublicV1),
this.connect(WS_KEY_MAP.spotPrivateV1),
this.connect(WS_KEY_MAP.futuresPublicV2),
this.connect(WS_KEY_MAP.futuresPrivateV2),
];
}
/**
* Request subscription to one or more topics.
*
* - Subscriptions are automatically routed to the correct websocket connection.
* - Authentication/connection is automatic.
* - Resubscribe after network issues is automatic.
*
* Call `unsubscribeTopics(topics)` to remove topics
*/
subscribeTopics(topics) {
const topicsByWsKey = this.arrangeTopicsIntoWsKeyGroups(topics);
for (const untypedWsKey in topicsByWsKey) {
const typedWsKey = untypedWsKey;
const topics = topicsByWsKey[typedWsKey];
if (topics.length) {
this.subscribeTopicsForWsKey(topics, typedWsKey);
}
}
}
/**
* Unsubscribe from one or more topics.
*
* - Requests are automatically routed to the correct websocket connection.
* - These topics will be removed from the topic cache, so they won't be subscribed to again.
*/
unsubscribeTopics(topics) {
const topicsByWsKey = this.arrangeTopicsIntoWsKeyGroups(topics);
for (const untypedWsKey in topicsByWsKey) {
const typedWsKey = untypedWsKey;
const topics = topicsByWsKey[typedWsKey];
if (topics.length) {
this.unsubscribeTopicsForWsKey(topics, typedWsKey);
}
}
}
/**
* Subscribe to topics & track/persist them. They will be automatically resubscribed to if the connection drops/reconnects.
* @param wsTopics topic or list of topics
* @param isPrivate optional - the library will try to detect private topics, you can use this to mark a topic as private (if the topic isn't recognised yet)
*/
subscribe(wsTopics, market, isPrivate) {
const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics];
const topicsByWsKey = this.arrangeTopicsIntoWsKeyGroups(topics, market, isPrivate);
const promises = [];
for (const untypedWsKey in topicsByWsKey) {
const typedWsKey = untypedWsKey;
const topics = topicsByWsKey[typedWsKey];
if (topics.length) {
promises.push(this.subscribeTopicsForWsKey(topics, typedWsKey));
}
}
return promises;
}
/**
* Unsubscribe from topics & remove them from memory. They won't be re-subscribed to if the connection reconnects.
* @param wsTopics topic or list of topics
* @param isPrivateTopic optional - the library will try to detect private topics, you can use this to mark a topic as private (if the topic isn't recognised yet)
*/
unsubscribe(wsTopics, market, isPrivate) {
const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics];
const topicsByWsKey = this.arrangeTopicsIntoWsKeyGroups(topics, market, isPrivate);
const promises = [];
for (const untypedWsKey in topicsByWsKey) {
const typedWsKey = untypedWsKey;
const topics = topicsByWsKey[typedWsKey];
if (topics.length) {
promises.push(this.unsubscribeTopicsForWsKey(topics, typedWsKey));
}
}
return promises;
}
/**
*
*
* Internal methods - not intended for public use
*
*
*/
/**
* Note: implementing this method will wipe the WsStore state for this WsKey, once this method returns
*/
isCustomReconnectionNeeded() {
return false;
}
async triggerCustomReconnectionWorkflow() {
return;
}
/**
* @returns The WS URL to connect to for this WS key
*/
async getWsUrl(wsKey) {
if (this.options.wsUrl) {
return this.options.wsUrl;
}
// Demo environment is only available for V2 Futures
const networkKey = this.options.demoTrading &&
(wsKey === WS_KEY_MAP.futuresPublicV2 ||
wsKey === WS_KEY_MAP.futuresPrivateV2)
? 'demo'
: 'livenet';
const url = WS_BASE_URL_MAP[wsKey][networkKey];
if (!url) {
// Fallback to livenet if demo is not available for this wsKey
return WS_BASE_URL_MAP[wsKey].livenet;
}
return url;
}
async signMessage(paramsStr, secret, method, algorithm = 'SHA-256') {
if (typeof this.options.customSignMessageFn === 'function') {
return this.options.customSignMessageFn(paramsStr, secret);
}
return await signMessage(paramsStr, secret, method, algorithm);
}
async getWsAuthRequestEvent(wsKey) {
try {
const { signature, expiresAt } = await this.getWsAuthSignature(wsKey);
const authArgs = [this.options.apiKey, `${expiresAt}`, signature];
const market = this.getWsMarketForWsKey(wsKey);
if (market === 'futures') {
authArgs.push('web');
}
switch (market) {
case 'spot': {
const wsRequestEvent = {
op: 'login',
args: authArgs,
};
return wsRequestEvent;
}
case 'futures': {
// https://developer-pro.bitmart.com/en/futuresv2/#private-login
const wsRequestEvent = {
action: 'access',
args: authArgs,
};
return wsRequestEvent;
}
default: {
throw neverGuard(market, `Unhandled market "${market}"`);
}
}
}
catch (e) {
this.logger.error(e, { ...WS_LOGGER_CATEGORY, wsKey });
throw e;
}
}
async getWsAuthSignature(wsKey) {
const { apiKey, apiSecret, apiMemo } = this.options;
if (!apiKey || !apiSecret || !apiMemo) {
this.logger.error('Cannot authenticate websocket, either api key, secret or memo are missing.', { ...WS_LOGGER_CATEGORY, wsKey });
throw new Error('Cannot auth - missing api key, secret or memo in config');
}
this.logger.trace("Getting auth'd request params", {
...WS_LOGGER_CATEGORY,
wsKey,
});
const recvWindow = this.options.recvWindow || 5000;
const signatureExpiresAt = Date.now() + this.getTimeOffsetMs() + recvWindow;
const signMessageInput = signatureExpiresAt +
'#' +
this.options.apiMemo +
'#' +
'bitmart.WebSocket';
const signature = await this.signMessage(signMessageInput, apiSecret, 'hex');
return {
expiresAt: signatureExpiresAt,
signature,
};
}
sendPingEvent(wsKey) {
switch (wsKey) {
case WS_KEY_MAP.spotPublicV1:
case WS_KEY_MAP.spotPrivateV1: {
return this.tryWsSend(wsKey, 'ping');
}
case WS_KEY_MAP.futuresPublicV1:
case WS_KEY_MAP.futuresPrivateV1:
case WS_KEY_MAP.futuresPublicV2:
case WS_KEY_MAP.futuresPrivateV2: {
return this.tryWsSend(wsKey, '{"action":"ping"}');
}
default: {
throw neverGuard(wsKey, `Unhandled ping format: "${wsKey}"`);
}
}
}
sendPongEvent(wsKey) {
this.tryWsSend(wsKey, JSON.stringify({ op: 'pong' }));
}
/** Force subscription requests to be sent in smaller batches, if a number is returned */
getMaxTopicsPerSubscribeEvent(wsKey) {
switch (wsKey) {
case 'futuresPrivateV1':
case 'futuresPublicV1':
case 'spotPrivateV1':
case 'spotPublicV1':
case 'futuresPrivateV2':
case 'futuresPublicV2': {
// Return a number if there's a limit on the number of sub topics per rq
return 20;
}
default: {
throw neverGuard(wsKey, 'getWsKeyForTopic(): Unhandled wsKey');
}
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
authPrivateConnectionsOnConnect(_wsKey) {
return this.options.authPrivateConnectionsOnConnect;
}
/**
* @returns one or more correctly structured request events for performing a operations over WS. This can vary per exchange spec.
*/
async getWsRequestEvents(market, operation, requests,
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
_wsKey) {
const wsRequestEvents = [];
const wsRequestBuildingErrors = [];
switch (market) {
case 'futures':
case 'spot': {
const topics = requests.map((r) => r.topic);
// Previously used to track topics in a request. Keeping this for subscribe/unsubscribe requests, no need for incremental values
const req_id = ['subscribe', 'unsubscribe'].includes(operation) && topics.length
? topics.join(',')
: this.getNewRequestId() + '';
// handles differences in spot vs futures
const wsEvent = this.getWsRequestEvent(market, operation, topics);
const midflightWsEvent = {
requestKey: req_id,
requestEvent: wsEvent,
};
wsRequestEvents.push({
...midflightWsEvent,
});
break;
}
default: {
throw neverGuard(market, `Unhandled market "${market}"`);
}
}
if (wsRequestBuildingErrors.length) {
const label = wsRequestBuildingErrors.length === requests.length ? 'all' : 'some';
this.logger.error(`Failed to build/send ${wsRequestBuildingErrors.length} event(s) for ${label} WS requests due to exceptions`, {
...WS_LOGGER_CATEGORY,
wsRequestBuildingErrors,
wsRequestBuildingErrorsStringified: JSON.stringify(wsRequestBuildingErrors, null, 2),
});
}
return wsRequestEvents;
}
/**
* Determines if a topic is for a private channel, using a hardcoded list of strings
*/
isPrivateTopicRequest(request) {
const rawTopicName = request?.topic?.toLowerCase();
if (!rawTopicName) {
return false;
}
const splitTopic = rawTopicName.toLowerCase().split('/');
if (!splitTopic.length) {
return false;
}
const topicName = splitTopic[1];
return this.isPrivateTopic(topicName);
}
isPrivateTopic(topicName) {
if (!topicName) {
// console.error(`No topic name? "${topicName}" from topic "${topic}"?`);
return false;
}
if (
/** Spot */
topicName.startsWith('user') ||
/** Futures */
topicName.startsWith('asset') ||
topicName.startsWith('position') ||
topicName.startsWith('order') ||
topicName.startsWith('position')) {
return true;
}
// spot/user/order:BTC_USDT -> user/order:BTC_USDT
// ^ will pass the above check, or fall back to the next level:
// user/order:BTC_USDT -> order:BTC_USDT
const splitTopic = topicName.toLowerCase().split('/');
if (splitTopic.length) {
const splitTopicName = splitTopic[1];
return this.isPrivateTopic(splitTopicName);
}
return false;
}
// No pings expected from Bitmart
isWsPing(msg) {
if (!msg) {
return false;
}
return false;
}
isWsPong(msg) {
// bitmart spot
if (msg?.data === 'pong') {
return true;
}
if (typeof msg?.event?.data === 'string' &&
msg.event.data.startsWith('pong')) {
return true;
}
// this.logger.info(`Not a pong: `, msg);
return false;
}
resolveEmittableEvents(wsKey, event) {
const results = [];
try {
const parsed = JSON.parse(event.data);
const responseEvents = ['subscribe', 'unsubscribe'];
const authenticatedEvents = ['login', 'access'];
const generalEventAction = parsed.event || parsed.action;
const spotEventAction = parsed.table; // e.g. table: 'spot/user/order'
const futuresEventAction = parsed.group; // e.g. group: 'futures/klineBin1m:ETHUSDT'
const eventAction = generalEventAction || spotEventAction || futuresEventAction;
if (typeof eventAction === 'string') {
if (parsed.success === false) {
results.push({
eventType: 'exception',
event: parsed,
});
return results;
}
// These are request/reply pattern events (e.g. after subscribing to topics or authenticating)
if (responseEvents.includes(eventAction)) {
results.push({
eventType: 'response',
event: parsed,
});
return results;
}
// Request/reply pattern for authentication success
if (authenticatedEvents.includes(eventAction)) {
results.push({
eventType: 'authenticated',
event: parsed,
});
return results;
}
// spot events
if (parsed.table) {
results.push({
eventType: 'update',
event: parsed,
});
return results;
}
// futures events
if (parsed.group) {
results.push({
eventType: 'update',
event: parsed,
});
return results;
}
this.logger.error(`!! Unhandled string event type "${eventAction}". Defaulting to "update" channel...`, parsed);
// Fallback to update/data channel for everything else
results.push({
eventType: 'update',
event: parsed,
});
return results;
}
this.logger.error(`!! Unhandled NON-STRING event type "${eventAction}" (type: ${typeof eventAction}). Defaulting to "update" channel...`, parsed);
results.push({
eventType: 'update',
event: parsed,
});
}
catch (e) {
results.push({
event: {
message: 'Failed to parse event data due to exception',
exception: e,
eventData: event.data,
},
eventType: 'exception',
});
this.logger.error('Failed to parse event data due to exception: ', {
exception: e,
eventData: event.data,
wsKey,
});
}
return results;
}
getWsKeyForMarket(market, isPrivate) {
return isPrivate
? market === 'spot'
? WS_KEY_MAP.spotPrivateV1
: WS_KEY_MAP.futuresPrivateV2
: market === 'spot'
? WS_KEY_MAP.spotPublicV1
: WS_KEY_MAP.futuresPublicV2;
}
getWsMarketForWsKey(key) {
switch (key) {
case 'futuresPrivateV1':
case 'futuresPublicV1':
case 'futuresPrivateV2':
case 'futuresPublicV2': {
return 'futures';
}
case 'spotPrivateV1':
case 'spotPublicV1': {
return 'spot';
}
default: {
throw neverGuard(key, `Unhandled ws key "${key}"`);
}
}
}
getWsKeyForTopic(topic) {
const market = this.getMarketForTopic(topic);
const isPrivateTopic = this.isPrivateTopic(topic);
return this.getWsKeyForMarket(market, isPrivateTopic);
}
getPrivateWSKeys() {
return PRIVATE_WS_KEYS;
}
isAuthOnConnectWsKey(wsKey) {
return PRIVATE_WS_KEYS.includes(wsKey);
}
/**
* Map one or more topics into fully prepared "unsubscribe request" events (already stringified and ready to send)
*/
getWsUnsubscribeEventsForTopics(topics, wsKey) {
if (!topics.length) {
return [];
}
const market = this.getWsMarketForWsKey(wsKey);
const subscribeEvents = [];
const maxTopicsPerEvent = this.getMaxTopicsPerSubscribeEvent(wsKey);
if (maxTopicsPerEvent &&
maxTopicsPerEvent !== null &&
topics.length > maxTopicsPerEvent) {
for (let i = 0; i < topics.length; i += maxTopicsPerEvent) {
const batch = topics.slice(i, i + maxTopicsPerEvent);
const subscribeEvent = this.getWsRequestEvent(market, 'unsubscribe', batch);
subscribeEvents.push(JSON.stringify(subscribeEvent));
}
return subscribeEvents;
}
const subscribeEvent = this.getWsRequestEvent(market, 'subscribe', topics);
return [JSON.stringify(subscribeEvent)];
}
/**
* @returns a correctly structured events for performing an operation over WS. This can vary per exchange spec.
*/
getWsRequestEvent(market, operation, args) {
switch (market) {
case 'spot': {
const wsRequestEvent = {
op: operation,
args: args,
};
return wsRequestEvent;
}
case 'futures': {
const wsRequestEvent = {
action: operation,
args: args,
};
return wsRequestEvent;
}
default: {
throw neverGuard(market, `Unhandled market "${market}"`);
}
}
}
/**
* This exchange API is split into "markets" that behave differently (different base URLs).
* The market can easily be resolved using the topic name.
*/
getMarketForTopic(topic) {
if (topic.startsWith('futures')) {
return 'futures';
}
if (topic.startsWith('spot')) {
return 'spot';
}
throw new Error(`Could not resolve "market" for topic: "${topic}"`);
}
/**
* Used to split sub/unsub logic by websocket connection
*/
arrangeTopicsIntoWsKeyGroups(topics, byMarket, isPrivate) {
const topicsByWsKey = {
futuresPrivateV1: [],
futuresPublicV1: [],
futuresPrivateV2: [],
futuresPublicV2: [],
spotPrivateV1: [],
spotPublicV1: [],
};
// array of string topics
for (const topic of topics) {
// Backwards comaptibility with how old subscribe method worked:
if (byMarket) {
const isPrivateTopic = isPrivate || this.isPrivateTopic(topic);
const wsKeyForTopic = this.getWsKeyForMarket(byMarket, isPrivateTopic);
const wsKeyTopicList = topicsByWsKey[wsKeyForTopic];
if (!wsKeyTopicList.includes(topic)) {
wsKeyTopicList.push(topic);
}
}
else {
const wsKeyForTopic = this.getWsKeyForTopic(topic);
const wsKeyTopicList = topicsByWsKey[wsKeyForTopic];
if (!wsKeyTopicList.includes(topic)) {
wsKeyTopicList.push(topic);
}
}
}
return topicsByWsKey;
}
}
//# sourceMappingURL=WebsocketClient.js.map