bitmart-api
Version:
Complete & robust Node.js SDK for BitMart's REST APIs and WebSockets, with TypeScript declarations.
390 lines • 14.9 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.WebsocketClient = exports.PUBLIC_WS_KEYS = exports.WS_LOGGER_CATEGORY = void 0;
const BaseWSClient_js_1 = require("./lib/BaseWSClient.js");
const misc_util_js_1 = require("./lib/misc-util.js");
const webCryptoAPI_js_1 = require("./lib/webCryptoAPI.js");
const websocket_util_js_1 = require("./lib/websocket/websocket-util.js");
exports.WS_LOGGER_CATEGORY = { category: 'bitmart-ws' };
/** Any WS keys in this list will trigger auth on connect, if credentials are available */
const PRIVATE_WS_KEYS = [
websocket_util_js_1.WS_KEY_MAP.spotPrivateV1,
websocket_util_js_1.WS_KEY_MAP.futuresPrivateV1,
websocket_util_js_1.WS_KEY_MAP.futuresPrivateV2,
];
/** Any WS keys in this list will ALWAYS skip the authentication process, even if credentials are available */
exports.PUBLIC_WS_KEYS = [
websocket_util_js_1.WS_KEY_MAP.spotPublicV1,
websocket_util_js_1.WS_KEY_MAP.futuresPublicV1,
websocket_util_js_1.WS_KEY_MAP.futuresPublicV2,
];
class WebsocketClient extends BaseWSClient_js_1.BaseWebsocketClient {
/**
* Request connection of all dependent (public & private) websockets, instead of waiting for automatic connection by library
*/
connectAll() {
return [
this.connect(websocket_util_js_1.WS_KEY_MAP.spotPublicV1),
this.connect(websocket_util_js_1.WS_KEY_MAP.spotPrivateV1),
this.connect(websocket_util_js_1.WS_KEY_MAP.futuresPublicV2),
this.connect(websocket_util_js_1.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);
}
}
}
/**
*
* Internal methods
*
*/
sendPingEvent(wsKey) {
switch (wsKey) {
case websocket_util_js_1.WS_KEY_MAP.spotPublicV1:
case websocket_util_js_1.WS_KEY_MAP.spotPrivateV1: {
return this.tryWsSend(wsKey, 'ping');
}
case websocket_util_js_1.WS_KEY_MAP.futuresPublicV1:
case websocket_util_js_1.WS_KEY_MAP.futuresPrivateV1:
case websocket_util_js_1.WS_KEY_MAP.futuresPublicV2:
case websocket_util_js_1.WS_KEY_MAP.futuresPrivateV2: {
return this.tryWsSend(wsKey, '{"action":"ping"}');
}
default: {
throw (0, misc_util_js_1.neverGuard)(wsKey, `Unhandled ping format: "${wsKey}"`);
}
}
}
isWsPong(msg) {
// bitmart spot
if (msg?.data === 'pong') {
return true;
}
// bitmart futures
// if (typeof event?.data === 'string') {
// 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(event) {
const results = [];
try {
const parsed = JSON.parse(event.data);
const responseEvents = ['subscribe', 'unsubscribe'];
const authenticatedEvents = ['login', 'access'];
const eventAction = parsed.event || parsed.action;
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;
}
this.logger.error(`!! Unhandled string event type "${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,
});
}
return results;
}
/**
* Determines if a topic is for a private channel, using a hardcoded list of strings
*/
isPrivateChannel(topic) {
const splitTopic = topic.toLowerCase().split('/');
if (!splitTopic.length) {
return false;
}
const topicName = splitTopic[1];
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;
}
return false;
}
getWsKeyForMarket(market, isPrivate) {
return isPrivate
? market === 'spot'
? websocket_util_js_1.WS_KEY_MAP.spotPrivateV1
: websocket_util_js_1.WS_KEY_MAP.futuresPrivateV2
: market === 'spot'
? websocket_util_js_1.WS_KEY_MAP.spotPublicV1
: websocket_util_js_1.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 (0, misc_util_js_1.neverGuard)(key, `Unhandled ws key "${key}"`);
}
}
}
getWsKeyForTopic(topic) {
const market = this.getMarketForTopic(topic);
const isPrivateTopic = this.isPrivateChannel(topic);
return this.getWsKeyForMarket(market, isPrivateTopic);
}
getPrivateWSKeys() {
return PRIVATE_WS_KEYS;
}
getWsUrl(wsKey) {
if (this.options.wsUrl) {
return this.options.wsUrl;
}
const networkKey = 'livenet';
return websocket_util_js_1.WS_BASE_URL_MAP[wsKey][networkKey];
}
/** 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 (0, misc_util_js_1.neverGuard)(wsKey, 'getWsKeyForTopic(): Unhandled wsKey');
}
}
}
/**
* Map one or more topics into fully prepared "subscribe request" events (already stringified and ready to send)
*/
getWsSubscribeEventsForTopics(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, 'subscribe', batch);
subscribeEvents.push(JSON.stringify(subscribeEvent));
}
return subscribeEvents;
}
const subscribeEvent = this.getWsRequestEvent(market, 'subscribe', topics);
return [JSON.stringify(subscribeEvent)];
}
/**
* 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 (0, misc_util_js_1.neverGuard)(market, `Unhandled market "${market}"`);
}
}
}
async getWsAuthRequestEvent(wsKey) {
const market = this.getWsMarketForWsKey(wsKey);
if (!this.options.apiKey ||
!this.options.apiSecret ||
!this.options.apiMemo) {
throw new Error('Cannot auth - missing api key, secret or memo in config');
}
const signTimestamp = Date.now() + this.options.recvWindow;
const signMessageInput = signTimestamp + '#' + this.options.apiMemo + '#' + 'bitmart.WebSocket';
let signature;
if (typeof this.options.customSignMessageFn === 'function') {
signature = await this.options.customSignMessageFn(signMessageInput, this.options.apiSecret);
}
else {
signature = await (0, webCryptoAPI_js_1.signMessage)(signMessageInput, this.options.apiSecret, 'hex');
}
const authArgs = [this.options.apiKey, `${signTimestamp}`, signature];
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 (0, misc_util_js_1.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) {
const topicsByWsKey = {
futuresPrivateV1: [],
futuresPublicV1: [],
futuresPrivateV2: [],
futuresPublicV2: [],
spotPrivateV1: [],
spotPublicV1: [],
};
for (const topic in topics) {
const wsKeyForTopic = this.getWsKeyForTopic(topic);
const wsKeyTopicList = topicsByWsKey[wsKeyForTopic];
if (!wsKeyTopicList.includes(topic)) {
wsKeyTopicList.push(topic);
}
}
return topicsByWsKey;
}
}
exports.WebsocketClient = WebsocketClient;
//# sourceMappingURL=WebsocketClient.js.map