diff --git a/src/types/websockets.ts b/src/types/websockets.ts index 180f518..b85ca02 100644 --- a/src/types/websockets.ts +++ b/src/types/websockets.ts @@ -1,6 +1,7 @@ import { RestClientOptions, WS_KEY_MAP } from '../util'; -export type APIMarket = 'inverse' | 'linear' | 'spot' | 'spotV3'; //| 'v3'; +/** For spot markets, spotV3 is recommended */ +export type APIMarket = 'inverse' | 'linear' | 'spot' | 'spotv3'; //| 'v3'; // Same as inverse futures export type WsPublicInverseTopic = diff --git a/src/util/websocket-util.ts b/src/util/websocket-util.ts index 72f6d53..f88d4c2 100644 --- a/src/util/websocket-util.ts +++ b/src/util/websocket-util.ts @@ -47,7 +47,7 @@ export const WS_BASE_URL_MAP: Record< testnet: 'wss://stream-testnet.bybit.com/spot/ws', }, }, - spotV3: { + spotv3: { public: { livenet: 'wss://stream.bybit.com/spot/public/v3', testnet: 'wss://stream-testnet.bybit.com/spot/public/v3', @@ -69,6 +69,8 @@ export const WS_KEY_MAP = { spotV3Public: 'spotV3Public', } as const; +export const WS_AUTH_ON_CONNECT_KEYS: WsKey[] = [WS_KEY_MAP.spotV3Private]; + export const PUBLIC_WS_KEYS = [ WS_KEY_MAP.linearPublic, WS_KEY_MAP.spotPublic, @@ -115,3 +117,8 @@ export function getSpotWsKeyForTopic( } return WS_KEY_MAP.spotPublic; } + +export const WS_ERROR_ENUM = { + NOT_AUTHENTICATED_SPOT_V3: '-1004', + BAD_API_KEY_SPOT_V3: '10003', +}; diff --git a/src/websocket-client.ts b/src/websocket-client.ts index 3f5c92e..349d617 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -26,6 +26,7 @@ import { getSpotWsKeyForTopic, WsConnectionStateEnum, PUBLIC_WS_KEYS, + WS_AUTH_ON_CONNECT_KEYS, WS_KEY_MAP, DefaultLogger, WS_BASE_URL_MAP, @@ -136,7 +137,7 @@ export class WebsocketClient extends EventEmitter { this.connectPublic(); break; } - case 'spotV3': { + case 'spotv3': { this.restClient = new SpotClientV3( undefined, undefined, @@ -221,7 +222,7 @@ export class WebsocketClient extends EventEmitter { this.connect(WS_KEY_MAP.spotPrivate), ]; } - case 'spotV3': { + case 'spotv3': { return [ this.connect(WS_KEY_MAP.spotV3Public), this.connect(WS_KEY_MAP.spotV3Private), @@ -244,7 +245,7 @@ export class WebsocketClient extends EventEmitter { case 'spot': { return this.connect(WS_KEY_MAP.spotPublic); } - case 'spotV3': { + case 'spotv3': { return this.connect(WS_KEY_MAP.spotV3Public); } default: { @@ -267,7 +268,7 @@ export class WebsocketClient extends EventEmitter { case 'spot': { return this.connect(WS_KEY_MAP.spotPrivate); } - case 'spotV3': { + case 'spotv3': { return this.connect(WS_KEY_MAP.spotV3Private); } default: { @@ -354,12 +355,49 @@ export class WebsocketClient extends EventEmitter { return ''; } + try { + const { signature, expiresAt } = await this.getWsAuthSignature(wsKey); + + const authParams = { + api_key: this.options.key, + expires: expiresAt, + signature, + }; + + return '?' + serializeParams(authParams); + } catch (e) { + this.logger.error(e, { ...loggerCategory, wsKey }); + return ''; + } + } + + private async sendAuthRequest(wsKey: WsKey): Promise { + try { + const { signature, expiresAt } = await this.getWsAuthSignature(wsKey); + + const request = { + op: 'auth', + args: [this.options.key, expiresAt, signature], + req_id: `${wsKey}-auth`, + }; + + return this.tryWsSend(wsKey, JSON.stringify(request)); + } catch (e) { + this.logger.error(e, { ...loggerCategory, wsKey }); + } + } + + private async getWsAuthSignature( + wsKey: WsKey + ): Promise<{ expiresAt: number; signature: string }> { + const { key, secret } = this.options; + if (!key || !secret) { this.logger.warning( 'Cannot authenticate websocket, either api or private keys missing.', { ...loggerCategory, wsKey } ); - return ''; + throw new Error(`Cannot auth - missing api or secret in config`); } this.logger.debug("Getting auth'd request params", { @@ -378,13 +416,10 @@ export class WebsocketClient extends EventEmitter { secret ); - const authParams = { - api_key: this.options.key, - expires: signatureExpiresAt, + return { + expiresAt: signatureExpiresAt, signature, }; - - return '?' + serializeParams(authParams); } private reconnectWithDelay(wsKey: WsKey, connectionDelayMs: number) { @@ -451,6 +486,7 @@ export class WebsocketClient extends EventEmitter { return; } const wsMessage = JSON.stringify({ + req_id: topics.join(','), op: 'subscribe', args: topics, }); @@ -518,7 +554,7 @@ export class WebsocketClient extends EventEmitter { return ws; } - private onWsOpen(event, wsKey: WsKey) { + private async onWsOpen(event, wsKey: WsKey) { if ( this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTING) ) { @@ -538,8 +574,14 @@ export class WebsocketClient extends EventEmitter { this.setWsState(wsKey, WsConnectionStateEnum.CONNECTED); - // TODO: persistence not working yet for spot topics - if (wsKey !== 'spotPublic' && wsKey !== 'spotPrivate') { + // Some websockets require an auth packet to be sent after opening the connection + if (WS_AUTH_ON_CONNECT_KEYS.includes(wsKey)) { + this.logger.info(`Sending auth request...`); + await this.sendAuthRequest(wsKey); + } + + // TODO: persistence not working yet for spot v1 topics + if (wsKey !== WS_KEY_MAP.spotPublic && wsKey !== WS_KEY_MAP.spotPrivate) { this.requestSubscribeTopics(wsKey, [...this.wsStore.getTopics(wsKey)]); } @@ -554,6 +596,8 @@ export class WebsocketClient extends EventEmitter { // any message can clear the pong timer - wouldn't get a message if the ws dropped this.clearPongTimer(wsKey); + // this.logger.silly('Received event', { ...this.logger, wsKey, event }); + const msg = JSON.parse((event && event.data) || event); if (msg['success'] || msg?.pong) { if (isWsPong(msg)) { @@ -564,11 +608,20 @@ export class WebsocketClient extends EventEmitter { return; } - if (msg.topic) { + if (msg?.topic) { return this.emit('update', msg); } - this.logger.warning('Got unhandled ws message', { + if ( + // spot v1 + msg?.code || + // spot v3 + msg?.type === 'error' + ) { + return this.emit('error', msg); + } + + this.logger.warning('Unhandled/unrecognised ws event message', { ...loggerCategory, message: msg, event, @@ -639,10 +692,10 @@ export class WebsocketClient extends EventEmitter { return WS_BASE_URL_MAP.spot.private[networkKey]; } case WS_KEY_MAP.spotV3Public: { - return WS_BASE_URL_MAP.spot.public[networkKey]; + return WS_BASE_URL_MAP.spotv3.public[networkKey]; } case WS_KEY_MAP.spotV3Private: { - return WS_BASE_URL_MAP.spot.private[networkKey]; + return WS_BASE_URL_MAP.spotv3.private[networkKey]; } case WS_KEY_MAP.inverse: { // private and public are on the same WS connection @@ -669,7 +722,7 @@ export class WebsocketClient extends EventEmitter { case 'spot': { return getSpotWsKeyForTopic(topic, 'v1'); } - case 'spotV3': { + case 'spotv3': { return getSpotWsKeyForTopic(topic, 'v3'); } default: { @@ -740,7 +793,7 @@ export class WebsocketClient extends EventEmitter { }); } - // TODO: persistance for subbed topics. Look at ftx-api implementation. + /** @deprecated use "market: 'spotv3" client */ public subscribePublicSpotTrades(symbol: string, binary?: boolean) { if (!this.isSpot()) { throw this.wrongMarketError('spot'); @@ -759,6 +812,7 @@ export class WebsocketClient extends EventEmitter { ); } + /** @deprecated use "market: 'spotv3" client */ public subscribePublicSpotTradingPair(symbol: string, binary?: boolean) { if (!this.isSpot()) { throw this.wrongMarketError('spot'); @@ -777,6 +831,7 @@ export class WebsocketClient extends EventEmitter { ); } + /** @deprecated use "market: 'spotv3" client */ public subscribePublicSpotV1Kline( symbol: string, candleSize: KlineInterval, @@ -803,6 +858,7 @@ export class WebsocketClient extends EventEmitter { //ws.send('{"symbol":"BTCUSDT","topic":"mergedDepth","event":"sub","params":{"binary":false,"dumpScale":1}}'); //ws.send('{"symbol":"BTCUSDT","topic":"diffDepth","event":"sub","params":{"binary":false}}'); + /** @deprecated use "market: 'spotv3" client */ public subscribePublicSpotOrderbook( symbol: string, depth: 'full' | 'merge' | 'delta', diff --git a/test/spot/ws.private.v3.test.ts b/test/spot/ws.private.v3.test.ts new file mode 100644 index 0000000..f867f1e --- /dev/null +++ b/test/spot/ws.private.v3.test.ts @@ -0,0 +1,124 @@ +import { + WebsocketClient, + WSClientConfigurableOptions, + WS_ERROR_ENUM, + WS_KEY_MAP, +} from '../../src'; +import { + silentLogger, + waitForSocketEvent, + WS_OPEN_EVENT_PARTIAL, +} from '../ws.util'; + +describe('Private Spot V3 Websocket Client', () => { + const API_KEY = process.env.API_KEY_COM; + const API_SECRET = process.env.API_SECRET_COM; + + const wsClientOptions: WSClientConfigurableOptions = { + market: 'spotv3', + key: API_KEY, + secret: API_SECRET, + }; + const wsTopic = `outboundAccountInfo`; + + describe('with invalid credentials', () => { + it('should reject private subscribe if keys/signature are incorrect', async () => { + const badClient = new WebsocketClient( + { + ...wsClientOptions, + key: 'bad', + secret: 'bad', + }, + silentLogger + ); + + // const wsOpenPromise = waitForSocketEvent(badClient, 'open'); + const wsResponsePromise = waitForSocketEvent(badClient, 'response'); + // const wsUpdatePromise = waitForSocketEvent(wsClient, 'update'); + + badClient.connectPrivate(); + badClient.subscribe(wsTopic); + + expect(wsResponsePromise).rejects.toMatchObject({ + ret_code: WS_ERROR_ENUM.BAD_API_KEY_SPOT_V3, + ret_msg: expect.any(String), + type: 'error', + }); + + try { + await Promise.all([wsResponsePromise]); + } catch (e) { + // console.error() + } + badClient.closeAll(); + }); + }); + + describe('with valid API credentails', () => { + let wsClient: WebsocketClient; + + it('should have api credentials to test with', () => { + expect(API_KEY).toStrictEqual(expect.any(String)); + expect(API_SECRET).toStrictEqual(expect.any(String)); + }); + + beforeAll(() => { + wsClient = new WebsocketClient(wsClientOptions, silentLogger); + wsClient.connectPrivate(); + // logAllEvents(wsClient); + }); + + afterAll(() => { + wsClient.closeAll(); + }); + + it('should open a private ws connection', async () => { + const wsOpenPromise = waitForSocketEvent(wsClient, 'open'); + const wsResponsePromise = waitForSocketEvent(wsClient, 'response'); + + expect(wsOpenPromise).resolves.toMatchObject({ + event: WS_OPEN_EVENT_PARTIAL, + wsKey: WS_KEY_MAP.spotV3Private, + }); + + try { + await Promise.all([wsOpenPromise]); + } catch (e) { + expect(e).toBeFalsy(); + } + + try { + expect(await wsResponsePromise).toMatchObject({ + op: 'auth', + success: true, + req_id: `${WS_KEY_MAP.spotV3Private}-auth`, + }); + } catch (e) { + console.error(`Wait for "${wsTopic}" event exception: `, e); + expect(e).toBeFalsy(); + } + }); + + it('should subscribe to private outboundAccountInfo events', async () => { + const wsResponsePromise = waitForSocketEvent(wsClient, 'response'); + + // expect(wsUpdatePromise).resolves.toStrictEqual(''); + wsClient.subscribe(wsTopic); + + try { + expect(await wsResponsePromise).toMatchObject({ + op: 'subscribe', + success: true, + ret_msg: '', + req_id: wsTopic, + }); + } catch (e) { + console.error( + `Wait for "${wsTopic}" subscription response exception: `, + e + ); + expect(e).toBeFalsy(); + } + }); + }); +}); diff --git a/test/spot/ws.public.v1.test.ts b/test/spot/ws.public.v1.test.ts index 8a9f94a..97d24bf 100644 --- a/test/spot/ws.public.v1.test.ts +++ b/test/spot/ws.public.v1.test.ts @@ -6,6 +6,7 @@ import { import { logAllEvents, silentLogger, + fullLogger, waitForSocketEvent, WS_OPEN_EVENT_PARTIAL, } from '../ws.util'; @@ -20,7 +21,7 @@ describe('Public Spot V1 Websocket Client', () => { beforeAll(() => { wsClient = new WebsocketClient(wsClientOptions, silentLogger); wsClient.connectPublic(); - // logAllEvents(wsClient); + logAllEvents(wsClient); }); afterAll(() => { diff --git a/test/spot/ws.public.v3.test.ts b/test/spot/ws.public.v3.test.ts index 6ae44e9..8fe07b0 100644 --- a/test/spot/ws.public.v3.test.ts +++ b/test/spot/ws.public.v3.test.ts @@ -6,6 +6,7 @@ import { import { logAllEvents, silentLogger, + fullLogger, waitForSocketEvent, WS_OPEN_EVENT_PARTIAL, } from '../ws.util'; @@ -14,7 +15,7 @@ describe('Public Spot V3 Websocket Client', () => { let wsClient: WebsocketClient; const wsClientOptions: WSClientConfigurableOptions = { - market: 'spotV3', + market: 'spotv3', }; beforeAll(() => { @@ -42,15 +43,27 @@ describe('Public Spot V3 Websocket Client', () => { const wsResponsePromise = waitForSocketEvent(wsClient, 'response'); const wsUpdatePromise = waitForSocketEvent(wsClient, 'update'); - const wsTopic = 'orderbook.40.BTCUSDT'; + const symbol = 'BTCUSDT'; + const wsTopic = `orderbook.40.${symbol}`; + expect(wsResponsePromise).resolves.toMatchObject({ - request: { - args: [wsTopic], - op: 'subscribe', - }, + op: 'subscribe', success: true, + ret_msg: 'subscribe', + req_id: wsTopic, + }); + + expect(wsUpdatePromise).resolves.toMatchObject({ + data: { + a: expect.any(Array), + b: expect.any(Array), + s: symbol, + t: expect.any(Number), + }, + topic: wsTopic, + ts: expect.any(Number), + type: 'delta', }); - expect(wsUpdatePromise).resolves.toStrictEqual(''); wsClient.subscribe(wsTopic); diff --git a/test/ws.util.ts b/test/ws.util.ts index 4b1ff75..5cb4bc5 100644 --- a/test/ws.util.ts +++ b/test/ws.util.ts @@ -5,8 +5,17 @@ export const silentLogger = { debug: () => {}, notice: () => {}, info: () => {}, - warning: () => {}, - error: () => {}, + warning: (...params) => console.warn('warning', ...params), + error: (...params) => console.error('error', ...params), +}; + +export const fullLogger = { + silly: (...params) => console.log('silly', ...params), + debug: (...params) => console.log('debug', ...params), + notice: (...params) => console.log('notice', ...params), + info: (...params) => console.info('info', ...params), + warning: (...params) => console.warn('warning', ...params), + error: (...params) => console.error('error', ...params), }; export const WS_OPEN_EVENT_PARTIAL = { @@ -26,20 +35,29 @@ export function waitForSocketEvent( ); }, timeoutMs); + function cleanup() { + clearTimeout(timeout); + resolvedOnce = true; + wsClient.removeListener(event, (e) => resolver(e)); + wsClient.removeListener('error', (e) => rejector(e)); + } + let resolvedOnce = false; - wsClient.on(event, (event) => { - clearTimeout(timeout); + function resolver(event) { resolve(event); - resolvedOnce = true; - }); + cleanup(); + } - wsClient.on('error', (event) => { - clearTimeout(timeout); + function rejector(event) { if (!resolvedOnce) { reject(event); } - }); + cleanup(); + } + + wsClient.on(event, (e) => resolver(e)); + wsClient.on('error', (e) => rejector(e)); // if (event !== 'close') { // wsClient.on('close', (event) => {