From 13cd799e7c6f1a166702eafea6015466c01869ff Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Tue, 21 Jan 2025 16:47:14 +0000 Subject: [PATCH] feat(): use web crypto API by default for sign, expose param to inject custom sign function --- examples/fasterHmacSign.ts | 183 +++++++++++++++++++++++++++++ src/types/websockets/ws-general.ts | 7 -- src/util/BaseRestClient.ts | 29 ++++- src/util/BaseWSClient.ts | 2 - src/util/browser-support.ts | 29 ----- src/util/node-support.ts | 9 -- src/util/requestUtils.ts | 7 ++ src/util/webCryptoAPI.ts | 84 +++++++++++++ src/websocket-client.ts | 20 +++- webpack/webpack.config.js | 2 - 10 files changed, 317 insertions(+), 55 deletions(-) create mode 100644 examples/fasterHmacSign.ts delete mode 100644 src/util/browser-support.ts delete mode 100644 src/util/node-support.ts create mode 100644 src/util/webCryptoAPI.ts diff --git a/examples/fasterHmacSign.ts b/examples/fasterHmacSign.ts new file mode 100644 index 0000000..818087d --- /dev/null +++ b/examples/fasterHmacSign.ts @@ -0,0 +1,183 @@ +import { createHmac } from 'crypto'; +import { DefaultLogger, RestClientV5, WebsocketClient } from '../src/index'; + +// or +// import { createHmac } from 'crypto'; +// import { DefaultLogger, RestClientV5, WebsocketClient } from 'bybit-api'; + +/** + * Injecting a custom signMessage function. + * + * As of version 4.0.0 of the bybit-api Node.js/TypeScript/JavaScript + * SDK for Bybit, the SDK uses the Web Crypto API for signing requests. + * While it is compatible with Node and Browser environments, it is + * slightly slower than using Node's native crypto module (only + * available in backend Node environments). + * + * For latency sensitive users, you can inject the previous node crypto sign + * method (or your own even faster-implementation), if this change affects you. + * + * This example demonstrates how to inject a custom sign function, to achieve + * the same peformance as seen before the Web Crypto API was introduced. + * + * For context on standard usage, the "signMessage" function is used: + * - During every single API call + * - After opening a new private WebSocket connection + * + */ + +const key = process.env.API_KEY_COM; +const secret = process.env.API_SECRET_COM; + +const restClient = new RestClientV5({ + key: key, + secret: secret, + parseAPIRateLimits: true, + /** + * Set this to true to enable demo trading: + */ + demoTrading: true, + /** + * Overkill in almost every case, but if you need any optimisation available, + * you can inject a faster sign mechanism such as node's native createHmac: + */ + customSignMessageFn: async (message, secret) => { + return createHmac('sha256', secret).update(message).digest('hex'); + }, +}); + +// Optional, uncomment the "silly" override to log a lot more info about what the WS client is doing +const customLogger = { + ...DefaultLogger, + // silly: (...params) => console.log('trace', ...params), +}; + +const wsClient = new WebsocketClient( + { + key: key, + secret: secret, + /** + * Set this to true to enable demo trading for the private account data WS + * Topics: order,execution,position,wallet,greeks + */ + demoTrading: true, + /** + * Overkill in almost every case, but if you need any optimisation available, + * you can inject a faster sign mechanism such as node's native createHmac: + */ + customSignMessageFn: async (message, secret) => { + return createHmac('sha256', secret).update(message).digest('hex'); + }, + }, + customLogger, +); + +function setWsClientEventListeners( + websocketClient: WebsocketClient, + accountRef: string, +): Promise { + return new Promise((resolve) => { + websocketClient.on('update', (data) => { + console.log(new Date(), accountRef, 'data ', JSON.stringify(data)); + // console.log('raw message received ', JSON.stringify(data, null, 2)); + }); + + websocketClient.on('open', (data) => { + console.log( + new Date(), + accountRef, + 'connection opened open:', + data.wsKey, + ); + }); + websocketClient.on('response', (data) => { + console.log( + new Date(), + accountRef, + 'log response: ', + JSON.stringify(data, null, 2), + ); + + if (typeof data.req_id === 'string') { + const topics = data.req_id.split(','); + if (topics.length) { + console.log(new Date(), accountRef, 'Subscribed to topics: ', topics); + return resolve(); + } + } + }); + websocketClient.on('reconnect', ({ wsKey }) => { + console.log( + new Date(), + accountRef, + 'ws automatically reconnecting.... ', + wsKey, + ); + }); + websocketClient.on('reconnected', (data) => { + console.log(new Date(), accountRef, 'ws has reconnected ', data?.wsKey); + }); + websocketClient.on('error', (data) => { + console.error(new Date(), accountRef, 'ws exception: ', data); + }); + }); +} + +(async () => { + try { + const onSubscribed = setWsClientEventListeners(wsClient, 'demoAcc'); + + wsClient.subscribeV5(['position', 'execution', 'wallet'], 'linear'); + + // Simple promise to ensure we're subscribed before trying anything else + await onSubscribed; + + // Start trading + const balResponse1 = await restClient.getWalletBalance({ + accountType: 'UNIFIED', + }); + console.log('balResponse1: ', JSON.stringify(balResponse1, null, 2)); + + const demoFunds = await restClient.requestDemoTradingFunds(); + console.log('requested demo funds: ', demoFunds); + + const balResponse2 = await restClient.getWalletBalance({ + accountType: 'UNIFIED', + }); + console.log('balResponse2: ', JSON.stringify(balResponse2, null, 2)); + + /** Simple examples for private REST API calls with bybit's V5 REST APIs */ + const response = await restClient.getPositionInfo({ + category: 'linear', + symbol: 'BTCUSDT', + }); + + console.log('response:', response); + + // Trade USDT linear perps + const buyOrderResult = await restClient.submitOrder({ + category: 'linear', + symbol: 'BTCUSDT', + orderType: 'Market', + qty: '1', + side: 'Buy', + }); + console.log('buyOrderResult:', buyOrderResult); + + const sellOrderResult = await restClient.submitOrder({ + category: 'linear', + symbol: 'BTCUSDT', + orderType: 'Market', + qty: '1', + side: 'Sell', + }); + console.log('sellOrderResult:', sellOrderResult); + + const balResponse3 = await restClient.getWalletBalance({ + accountType: 'UNIFIED', + }); + console.log('balResponse2: ', JSON.stringify(balResponse3, null, 2)); + } catch (e) { + console.error('request failed: ', e); + } +})(); diff --git a/src/types/websockets/ws-general.ts b/src/types/websockets/ws-general.ts index 26f3fbb..130e2c5 100644 --- a/src/types/websockets/ws-general.ts +++ b/src/types/websockets/ws-general.ts @@ -138,12 +138,6 @@ export interface WSClientConfigurableOptions { * Look in the examples folder for a demonstration on using node's createHmac instead. */ customSignMessageFn?: (message: string, secret: string) => Promise; - - /** - * If you authenticated the WS API before, automatically try to - * re-authenticate the WS API if you're disconnected/reconnected for any reason. - */ - reauthWSAPIOnReconnect?: boolean; } /** @@ -158,5 +152,4 @@ export interface WebsocketClientOptions extends WSClientConfigurableOptions { recvWindow: number; authPrivateConnectionsOnConnect: boolean; authPrivateRequests: boolean; - reauthWSAPIOnReconnect: boolean; } diff --git a/src/util/BaseRestClient.ts b/src/util/BaseRestClient.ts index eb46c7f..5b43ae2 100644 --- a/src/util/BaseRestClient.ts +++ b/src/util/BaseRestClient.ts @@ -11,7 +11,7 @@ import { parseRateLimitHeaders, serializeParams, } from './requestUtils'; -import { signMessage } from './node-support'; +import { SignAlgorithm, SignEncodeMethod, signMessage } from './webCryptoAPI'; const ENABLE_HTTP_TRACE = typeof process === 'object' && @@ -394,6 +394,18 @@ export default abstract class BaseRestClient { }; } + private async signMessage( + paramsStr: string, + secret: string, + method: SignEncodeMethod, + algorithm: SignAlgorithm, + ): Promise { + if (typeof this.options.customSignMessageFn === 'function') { + return this.options.customSignMessageFn(paramsStr, secret); + } + return await signMessage(paramsStr, secret, method, algorithm); + } + /** * @private sign request and set recv window */ @@ -441,7 +453,13 @@ export default abstract class BaseRestClient { const paramsStr = timestamp + key + recvWindow + signRequestParams; - res.sign = await signMessage(paramsStr, this.secret); + res.sign = await this.signMessage( + paramsStr, + this.secret, + 'hex', + 'SHA-256', + ); + res.serializedParams = signRequestParams; // console.log('sign req: ', { @@ -473,7 +491,12 @@ export default abstract class BaseRestClient { sortProperties, encodeValues, ); - res.sign = await signMessage(res.serializedParams, this.secret); + res.sign = await this.signMessage( + res.serializedParams, + this.secret, + 'hex', + 'SHA-256', + ); // @ts-ignore res.paramsWithSign = { diff --git a/src/util/BaseWSClient.ts b/src/util/BaseWSClient.ts index cccf766..8edd0fa 100644 --- a/src/util/BaseWSClient.ts +++ b/src/util/BaseWSClient.ts @@ -167,8 +167,6 @@ export abstract class BaseWebsocketClient< authPrivateConnectionsOnConnect: true, // Individual requests do not require a signature, so this is disabled. authPrivateRequests: false, - // Automatically re-authenticate the WS API connection, if previously authenticated. TODO: - reauthWSAPIOnReconnect: true, ...options, }; diff --git a/src/util/browser-support.ts b/src/util/browser-support.ts deleted file mode 100644 index 280ce37..0000000 --- a/src/util/browser-support.ts +++ /dev/null @@ -1,29 +0,0 @@ -export async function signMessage( - message: string, - secret: string, -): Promise { - const encoder = new TextEncoder(); - // eslint-disable-next-line no-undef - const key = await window.crypto.subtle.importKey( - 'raw', - encoder.encode(secret), - { name: 'HMAC', hash: { name: 'SHA-256' } }, - false, - ['sign'], - ); - - // eslint-disable-next-line no-undef - const signature = await window.crypto.subtle.sign( - 'HMAC', - key, - encoder.encode(message), - ); - - return Array.prototype.map - .call( - new Uint8Array(signature), - (x: { toString: (arg0: number) => string }) => - ('00' + x.toString(16)).slice(-2), - ) - .join(''); -} diff --git a/src/util/node-support.ts b/src/util/node-support.ts deleted file mode 100644 index 0d352d7..0000000 --- a/src/util/node-support.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createHmac } from 'crypto'; - -/** This is async because the browser version uses a promise (browser-support) */ -export async function signMessage( - message: string, - secret: string, -): Promise { - return createHmac('sha256', secret).update(message).digest('hex'); -} diff --git a/src/util/requestUtils.ts b/src/util/requestUtils.ts index 89c14db..492f7a5 100644 --- a/src/util/requestUtils.ts +++ b/src/util/requestUtils.ts @@ -77,6 +77,13 @@ export interface RestClientOptions { /** Default: false. Enable to throw error if rate limit parser fails */ throwOnFailedRateLimitParse?: boolean; + + /** + * Allows you to provide a custom "signMessage" function, e.g. to use node's much faster createHmac method + * + * Look in the examples folder for a demonstration on using node's createHmac instead. + */ + customSignMessageFn?: (message: string, secret: string) => Promise; } /** diff --git a/src/util/webCryptoAPI.ts b/src/util/webCryptoAPI.ts new file mode 100644 index 0000000..2aea730 --- /dev/null +++ b/src/util/webCryptoAPI.ts @@ -0,0 +1,84 @@ +import { neverGuard } from './websockets'; + +function bufferToB64(buffer: ArrayBuffer): string { + let binary = ''; + const bytes = new Uint8Array(buffer); + const len = bytes.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + return globalThis.btoa(binary); +} + +export type SignEncodeMethod = 'hex' | 'base64'; +export type SignAlgorithm = 'SHA-256' | 'SHA-512'; + +/** + * Similar to node crypto's `createHash()` function + */ +export async function hashMessage( + message: string, + method: SignEncodeMethod, + algorithm: SignAlgorithm, +): Promise { + const encoder = new TextEncoder(); + + const buffer = await globalThis.crypto.subtle.digest( + algorithm, + encoder.encode(message), + ); + + switch (method) { + case 'hex': { + return Array.from(new Uint8Array(buffer)) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); + } + case 'base64': { + return bufferToB64(buffer); + } + default: { + throw neverGuard(method, `Unhandled sign method: "${method}"`); + } + } +} + +/** + * Sign a message, with a secret, using the Web Crypto API + */ +export async function signMessage( + message: string, + secret: string, + method: SignEncodeMethod, + algorithm: SignAlgorithm, +): Promise { + const encoder = new TextEncoder(); + + const key = await globalThis.crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: algorithm }, + false, + ['sign'], + ); + + const buffer = await globalThis.crypto.subtle.sign( + 'HMAC', + key, + encoder.encode(message), + ); + + switch (method) { + case 'hex': { + return Array.from(new Uint8Array(buffer)) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); + } + case 'base64': { + return bufferToB64(buffer); + } + default: { + throw neverGuard(method, `Unhandled sign method: "${method}"`); + } + } +} diff --git a/src/websocket-client.ts b/src/websocket-client.ts index cd397ff..03c23f9 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -28,7 +28,6 @@ import { isWsPong, neverGuard, } from './util'; -import { signMessage } from './util/node-support'; import { BaseWebsocketClient, EmittableEvent, @@ -42,6 +41,7 @@ import { WsOperation, WsRequestOperationBybit, } from './types/websockets/ws-api'; +import { SignAlgorithm, signMessage } from './util/webCryptoAPI'; const WS_LOGGER_CATEGORY = { category: 'bybit-ws' }; @@ -527,6 +527,18 @@ export class WebsocketClient extends BaseWebsocketClient< return ''; } + private async signMessage( + paramsStr: string, + secret: string, + method: 'hex' | 'base64', + algorithm: SignAlgorithm, + ): Promise { + if (typeof this.options.customSignMessageFn === 'function') { + return this.options.customSignMessageFn(paramsStr, secret); + } + return await signMessage(paramsStr, secret, method, algorithm); + } + protected async getWsAuthRequestEvent(wsKey: WsKey): Promise { try { const { signature, expiresAt } = await this.getWsAuthSignature(wsKey); @@ -566,9 +578,11 @@ export class WebsocketClient extends BaseWebsocketClient< const signatureExpiresAt = Date.now() + this.getTimeOffsetMs() + recvWindow; - const signature = await signMessage( + const signature = await this.signMessage( 'GET/realtime' + signatureExpiresAt, secret, + 'hex', + 'SHA-256', ); return { @@ -951,7 +965,7 @@ export class WebsocketClient extends BaseWebsocketClient< * * Returned promise is rejected if an exception is detected in the reply OR the connection disconnects for any reason (even if automatic reconnect will happen). * - * After a fresh connection, you should always send a login request first. + * Authentication is automatic. If you didn't request authentication yourself, there might be a small delay after your first request, while the SDK automatically authenticates. * * If you authenticated once and you're reconnected later (e.g. connection temporarily lost), the SDK will by default automatically: * - Detect you were authenticated to the WS API before diff --git a/webpack/webpack.config.js b/webpack/webpack.config.js index 75aefc3..4b2617f 100644 --- a/webpack/webpack.config.js +++ b/webpack/webpack.config.js @@ -19,8 +19,6 @@ function generateConfig(name) { // Add '.ts' and '.tsx' as resolvable extensions. extensions: [".webpack.js", ".web.js", ".ts", ".tsx", ".js"], alias: { - [path.resolve(__dirname, "../lib/util/node-support.js")]: - path.resolve(__dirname, "../lib/util/browser-support.js"), process: "process/browser", } },