UNPKG

bitmart-api

Version:

Complete & robust Node.js SDK for BitMart's REST APIs and WebSockets, with TypeScript declarations.

560 lines 21.6 kB
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