diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index fa8f557b..bb43ff34 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -39,7 +39,9 @@ jobs: run: npm run ws-tests-spot - name: Ws Live Tests (futures) run: npm run ws-tests-futures + - name: Ws Live Tests (api userdata) + run: npm run ws-api-userdata-tests - name: CJS test run: npm run test-cjs - name: Package test - run: npm run package-test \ No newline at end of file + run: npm run package-test diff --git a/package.json b/package.json index f710d458..bb6c457b 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "ws-tests": "mocha ./tests/binance-class-ws.test.ts", "ws-tests-spot": "mocha ./tests/binance-ws-spot.test.ts --exit", "ws-tests-futures": "mocha ./tests/binance-ws-futures.test.ts --exit", + "ws-api-userdata-tests": "mocha ./tests/binance-ws-api-userdata.test.ts --exit", "test-debug": "mocha --inspect-brk", "lint": "eslint src/", "cover": "istanbul cover _mocha --report lcovonly", diff --git a/src/node-binance-api.ts b/src/node-binance-api.ts index e2beed89..547ad60f 100644 --- a/src/node-binance-api.ts +++ b/src/node-binance-api.ts @@ -57,6 +57,8 @@ export default class Binance { combineStream = `wss://stream.binance.${this.domain}:9443/stream?streams=`; combineStreamTest = `wss://stream.testnet.binance.vision/stream?streams=`; combineStreamDemo = `wss://demo-stream.binance.com/stream?streams=`; + wsApi = `wss://ws-api.binance.${this.domain}:443/ws-api/v3`; + wsApiTest = `wss://ws-api.testnet.binance.vision/ws-api/v3`; verbose = false; @@ -93,6 +95,8 @@ export default class Binance { headers: Dict = {}; subscriptions: Dict = {}; futuresSubscriptions: Dict = {}; + wsApiConnections: Dict = {}; // WebSocket API connections + wsApiPendingRequests: Dict = {}; // Pending JSON-RPC requests futuresInfo: Dict = {}; futuresMeta: Dict = {}; futuresTicks: Dict = {}; @@ -279,6 +283,11 @@ export default class Binance { return this.stream; } + getWsApiUrl() { + if (this.Options.test) return this.wsApiTest; + return this.wsApi; + } + getDStreamSingleUrl() { if (this.Options.demo) return this.dstreamSingleDemo; if (this.Options.test) return this.dstreamSingleTest; @@ -1504,6 +1513,143 @@ export default class Binance { ws.terminate(); } + /** + * Connect to WebSocket API for bidirectional JSON-RPC communication + * @param {string} connectionId - unique identifier for this connection + * @param {function} messageHandler - callback for handling incoming messages/events + * @param {function} reconnect - reconnect callback + * @return {WebSocket} - WebSocket connection + */ + connectWsApi(connectionId: string, messageHandler: Callback, reconnect?: Callback) { + const httpsproxy = this.getHttpsProxy(); + let socksproxy = this.getSocksProxy(); + let ws: WebSocket = undefined; + + if (socksproxy) { + socksproxy = this.proxyReplacewithIp(socksproxy); + if (this.Options.verbose) this.Options.log('WebSocket API: using socks proxy server ' + socksproxy); + const agent = new SocksProxyAgent({ + protocol: this.parseProxy(socksproxy)[0], + host: this.parseProxy(socksproxy)[1], + port: this.parseProxy(socksproxy)[2] + }); + ws = new WebSocket(this.getWsApiUrl(), { agent: agent }); + } else if (httpsproxy) { + const config = url.parse(httpsproxy); + const agent = new HttpsProxyAgent(config); + if (this.Options.verbose) this.Options.log('WebSocket API: using proxy server ' + agent); + ws = new WebSocket(this.getWsApiUrl(), { agent: agent }); + } else { + ws = new WebSocket(this.getWsApiUrl()); + } + + if (this.Options.verbose) this.Options.log('WebSocket API: Connected to ' + this.getWsApiUrl()); + (ws as any).reconnect = this.Options.reconnect; + (ws as any).connectionId = connectionId; + (ws as any).isAlive = false; + + ws.on('open', this.handleSocketOpen.bind(this, ws, null)); + ws.on('pong', this.handleSocketHeartbeat.bind(this, ws)); + ws.on('error', this.handleSocketError.bind(this, ws)); + ws.on('close', this.handleSocketClose.bind(this, ws, reconnect)); + ws.on('message', data => { + try { + if (this.Options.verbose) this.Options.log('WebSocket API data:', data); + const message = JSONbig.parse(data as any); + + // Handle JSON-RPC responses + if (message.id && this.wsApiPendingRequests[message.id]) { + const pending = this.wsApiPendingRequests[message.id]; + delete this.wsApiPendingRequests[message.id]; + + if (message.status === 200) { + pending.resolve(message.result); + } else { + pending.reject(new Error(`WebSocket API error: ${message.error?.msg || 'Unknown error'}`)); + } + } + // Handle events (messages without 'id' or with 'subscriptionId') + else if (message.subscriptionId !== undefined || message.event) { + messageHandler(message); + } + } catch (error) { + this.Options.log('WebSocket API: Parse error: ' + error.message); + } + }); + + this.wsApiConnections[connectionId] = ws; + return ws; + } + + /** + * Send a JSON-RPC request on the WebSocket API connection + * @param {string} connectionId - connection identifier + * @param {string} method - JSON-RPC method name + * @param {object} params - method parameters + * @return {Promise} - resolves with the result or rejects with error + */ + sendWsApiRequest(connectionId: string, method: string, params: any = {}): Promise { + return new Promise((resolve, reject) => { + const ws = this.wsApiConnections[connectionId]; + if (!ws || ws.readyState !== WebSocket.OPEN) { + reject(new Error('WebSocket API connection not open')); + return; + } + + const requestId = this.generateRequestId(); + const request = { + id: requestId, + method: method, + params: params + }; + + this.wsApiPendingRequests[requestId] = { resolve, reject }; + + if (this.Options.verbose) { + this.Options.log('WebSocket API: Sending request:', JSON.stringify(request)); + } + + ws.send(JSON.stringify(request), (error) => { + if (error) { + delete this.wsApiPendingRequests[requestId]; + reject(error); + } + }); + + // Timeout after 30 seconds + setTimeout(() => { + if (this.wsApiPendingRequests[requestId]) { + delete this.wsApiPendingRequests[requestId]; + reject(new Error('WebSocket API request timeout')); + } + }, 30000); + }); + } + + /** + * Generate a unique request ID for JSON-RPC requests + * @return {string} - unique request ID + */ + generateRequestId(): string { + return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`; + } + + /** + * Terminate a WebSocket API connection + * @param {string} connectionId - connection identifier + * @param {boolean} reconnect - whether to reconnect + * @return {undefined} + */ + terminateWsApi(connectionId: string, reconnect = false) { + if (this.Options.verbose) this.Options.log('WebSocket API terminating:', connectionId); + const ws = this.wsApiConnections[connectionId]; + if (!ws) return; + ws.removeAllListeners('message'); + ws.reconnect = reconnect; + ws.terminate(); + delete this.wsApiConnections[connectionId]; + } + /** * Futures heartbeat code with a shared single interval tick * @return {undefined} @@ -2788,16 +2934,36 @@ export default class Binance { * @return {undefined} */ userDataHandler(data: any) { - const type = data.e; - this.Options.all_updates_callback(data); + // Handle new WebSocket API format where events are wrapped + // New format: { subscriptionId: 0, event: { e: "eventType", ... } } + // Old format: { e: "eventType", ... } + let eventData = data; + if (data.subscriptionId !== undefined && data.event) { + eventData = data.event; + } + + const type = eventData.e; + + // Handle event stream termination + if (type === 'eventStreamTerminated') { + this.Options.log('User Data Stream terminated at ' + eventData.E); + if (this.Options.all_updates_callback) this.Options.all_updates_callback(eventData); + return; + } + + if (this.Options.all_updates_callback) this.Options.all_updates_callback(eventData); + if (type === 'outboundAccountInfo') { // XXX: Deprecated in 2020-09-08 } else if (type === 'executionReport') { - if (this.Options.execution_callback) this.Options.execution_callback(data); + if (this.Options.execution_callback) this.Options.execution_callback(eventData); } else if (type === 'listStatus') { - if (this.Options.list_status_callback) this.Options.list_status_callback(data); + if (this.Options.list_status_callback) this.Options.list_status_callback(eventData); } else if (type === 'outboundAccountPosition' || type === 'balanceUpdate') { - if (this.Options.balance_callback) this.Options.balance_callback(data); + if (this.Options.balance_callback) this.Options.balance_callback(eventData); + } else if (type === 'externalLockUpdate') { + // Handle external lock updates (e.g., when balance is locked for margin collateral) + if (this.Options.balance_callback) this.Options.balance_callback(eventData); } else { this.Options.log('Unexpected userData: ' + type); } @@ -2809,18 +2975,36 @@ export default class Binance { * @return {undefined} */ userMarginDataHandler(data: any) { - const type = data.e; + // Handle new WebSocket API format where events are wrapped + // New format: { subscriptionId: 0, event: { e: "eventType", ... } } + // Old format: { e: "eventType", ... } + let eventData = data; + if (data.subscriptionId !== undefined && data.event) { + eventData = data.event; + } - if (this.Options.margin_all_updates_callback) this.Options.all_updates_callback(data); + const type = eventData.e; + + // Handle event stream termination + if (type === 'eventStreamTerminated') { + this.Options.log('Margin Data Stream terminated at ' + eventData.E); + if (this.Options.margin_all_updates_callback) this.Options.margin_all_updates_callback(eventData); + return; + } + + if (this.Options.margin_all_updates_callback) this.Options.margin_all_updates_callback(eventData); if (type === 'outboundAccountInfo') { // XXX: Deprecated in 2020-09-08 } else if (type === 'executionReport') { - if (this.Options.margin_execution_callback) this.Options.margin_execution_callback(data); + if (this.Options.margin_execution_callback) this.Options.margin_execution_callback(eventData); } else if (type === 'listStatus') { - if (this.Options.margin_list_status_callback) this.Options.margin_list_status_callback(data); + if (this.Options.margin_list_status_callback) this.Options.margin_list_status_callback(eventData); } else if (type === 'outboundAccountPosition' || type === 'balanceUpdate') { - this.Options.margin_balance_callback(data); + if (this.Options.margin_balance_callback) this.Options.margin_balance_callback(eventData); + } else if (type === 'externalLockUpdate') { + // Handle external lock updates (e.g., when balance is locked for margin collateral) + if (this.Options.margin_balance_callback) this.Options.margin_balance_callback(eventData); } } @@ -5819,26 +6003,42 @@ export default class Binance { */ userData(all_updates_callback?: Callback, balance_callback?: Callback, execution_callback?: Callback, subscribed_callback?: Callback, list_status_callback?: Callback) { const reconnect = () => { - if (this.Options.reconnect) this.userData(all_updates_callback, balance_callback, execution_callback, subscribed_callback); + if (this.Options.reconnect) this.userData(all_updates_callback, balance_callback, execution_callback, subscribed_callback, list_status_callback); }; - this.apiRequest(this.getSpotUrl() + 'v3/userDataStream', {}, 'POST').then((response: any) => { - this.Options.listenKey = response.listenKey; - const keepAlive = this.spotListenKeyKeepAlive; - const self = this; - setTimeout(async function userDataKeepAlive() { // keepalive - try { - await self.apiRequest(self.getSpotUrl() + 'v3/userDataStream?listenKey=' + self.Options.listenKey, {}, 'PUT'); - setTimeout(userDataKeepAlive, keepAlive); // 30 minute keepalive - } catch (error) { - setTimeout(userDataKeepAlive, 60000); // retry in 1 minute + + // Set up callbacks + this.Options.all_updates_callback = all_updates_callback; + this.Options.balance_callback = balance_callback; + this.Options.execution_callback = execution_callback ? execution_callback : balance_callback; + this.Options.list_status_callback = list_status_callback; + + // Connect to WebSocket API + const connectionId = 'userData'; + const ws = this.connectWsApi(connectionId, this.userDataHandler.bind(this), reconnect); + + ws.on('open', async () => { + try { + // Subscribe using userDataStream.subscribe.signature method + const timestamp = Date.now(); + const query = `apiKey=${this.APIKEY}×tamp=${timestamp}`; + const signature = this.generateSignature(query); + + const result = await this.sendWsApiRequest(connectionId, 'userDataStream.subscribe.signature', { + apiKey: this.APIKEY, + timestamp: timestamp, + signature: signature + }); + + this.Options.userDataSubscriptionId = result.subscriptionId; + if (this.Options.verbose) { + this.Options.log(`User Data Stream subscribed with subscriptionId: ${result.subscriptionId}`); } - }, keepAlive); // 30 minute keepalive - this.Options.all_updates_callback = all_updates_callback; - this.Options.balance_callback = balance_callback; - this.Options.execution_callback = execution_callback ? execution_callback : balance_callback;//This change is required to listen for Orders - this.Options.list_status_callback = list_status_callback; - const subscription = this.subscribe(this.Options.listenKey, this.userDataHandler.bind(this), reconnect) as any; - if (subscribed_callback) subscribed_callback(subscription.endpoint); + + if (subscribed_callback) subscribed_callback(connectionId); + } catch (error) { + this.Options.log('User Data Stream subscription error:', error.message); + if (reconnect) setTimeout(reconnect, 5000); + } }); } @@ -5851,30 +6051,95 @@ export default class Binance { * @return {undefined} */ userMarginData(all_updates_callback?: Callback, balance_callback?: Callback, execution_callback?: Callback, subscribed_callback?: Callback, list_status_callback?: Callback) { + const self = this; const reconnect = () => { - if (this.Options.reconnect) this.userMarginData(balance_callback, execution_callback, subscribed_callback); + if (this.Options.reconnect) this.userMarginData(all_updates_callback, balance_callback, execution_callback, subscribed_callback, list_status_callback); }; - this.apiRequest(this.sapi + 'v1/userDataStream', {}, 'POST').then((response: any) => { - this.Options.listenMarginKey = response.listenKey; - const url = this.sapi + 'v1/userDataStream?listenKey=' + this.Options.listenMarginKey; - const apiRequest = this.apiRequest; - const keepAlive = this.spotListenKeyKeepAlive; - setTimeout(async function userDataKeepAlive() { // keepalive + // Set up callbacks + this.Options.margin_all_updates_callback = all_updates_callback; + this.Options.margin_balance_callback = balance_callback; + this.Options.margin_execution_callback = execution_callback; + this.Options.margin_list_status_callback = list_status_callback; + + // Get listenToken from REST API + this.apiRequest(this.sapi + 'v1/userListenToken', {}, 'POST').then((response: any) => { + const listenToken = response.token; + const expirationTime = response.expirationTime; + this.Options.marginListenToken = listenToken; + this.Options.marginListenTokenExpiry = expirationTime; + + if (this.Options.verbose) { + this.Options.log(`Margin listenToken obtained, expires at: ${new Date(expirationTime).toISOString()}`); + } + + // Connect to WebSocket API + const connectionId = 'userMarginData'; + const ws = this.connectWsApi(connectionId, this.userMarginDataHandler.bind(this), reconnect); + + ws.on('open', async () => { try { - await apiRequest(url, {}, 'PUT'); - // if (err) setTimeout(userDataKeepAlive, 60000); // retry in 1 minute - setTimeout(userDataKeepAlive, keepAlive); // 30 minute keepalive + // Subscribe using userDataStream.subscribe.listenToken method + const result = await this.sendWsApiRequest(connectionId, 'userDataStream.subscribe.listenToken', { + listenToken: listenToken + }); + + this.Options.marginDataSubscriptionId = result.subscriptionId; + const subscriptionExpiry = result.expirationTime; + + if (this.Options.verbose) { + this.Options.log(`Margin Data Stream subscribed with subscriptionId: ${result.subscriptionId}`); + this.Options.log(`Subscription expires at: ${new Date(subscriptionExpiry).toISOString()}`); + } + + // Set up renewal before expiration (renew 5 minutes before expiry) + const renewalTime = subscriptionExpiry - Date.now() - (5 * 60 * 1000); + if (renewalTime > 0) { + setTimeout(async function renewSubscription() { + try { + // Get new listenToken + const renewResponse: any = await self.apiRequest(self.sapi + 'v1/userListenToken', {}, 'POST'); + const newListenToken = renewResponse.token; + const newExpirationTime = renewResponse.expirationTime; + + if (self.Options.verbose) { + self.Options.log(`New margin listenToken obtained, expires at: ${new Date(newExpirationTime).toISOString()}`); + } + + // Re-subscribe with new token + const renewResult = await self.sendWsApiRequest(connectionId, 'userDataStream.subscribe.listenToken', { + listenToken: newListenToken + }); + + self.Options.marginDataSubscriptionId = renewResult.subscriptionId; + const newSubscriptionExpiry = renewResult.expirationTime; + + if (self.Options.verbose) { + self.Options.log(`Margin Data Stream renewed with subscriptionId: ${renewResult.subscriptionId}`); + } + + // Schedule next renewal + const nextRenewalTime = newSubscriptionExpiry - Date.now() - (5 * 60 * 1000); + if (nextRenewalTime > 0) { + setTimeout(renewSubscription, nextRenewalTime); + } + } catch (error) { + self.Options.log('Margin Data Stream renewal error:', error.message); + // Attempt to reconnect + if (reconnect) setTimeout(reconnect, 5000); + } + }, renewalTime); + } + + if (subscribed_callback) subscribed_callback(connectionId); } catch (error) { - setTimeout(userDataKeepAlive, 60000); // retry in 1 minute + this.Options.log('Margin Data Stream subscription error:', error.message); + if (reconnect) setTimeout(reconnect, 5000); } - }, keepAlive); // 30 minute keepalive - this.Options.margin_all_updates_callback = all_updates_callback; - this.Options.margin_balance_callback = balance_callback; - this.Options.margin_execution_callback = execution_callback; - this.Options.margin_list_status_callback = list_status_callback; - const subscription = this.subscribe(this.Options.listenMarginKey, this.userMarginDataHandler.bind(this), reconnect) as any; - if (subscribed_callback) subscribed_callback(subscription.endpoint); + }); + }).catch((error: any) => { + this.Options.log('Failed to obtain margin listenToken:', error.message); + if (reconnect) setTimeout(reconnect, 5000); }); } diff --git a/tests/binance-ws-api-userdata.test.ts b/tests/binance-ws-api-userdata.test.ts new file mode 100644 index 00000000..10ec9923 --- /dev/null +++ b/tests/binance-ws-api-userdata.test.ts @@ -0,0 +1,684 @@ +import Binance from '../src/node-binance-api'; +import { assert } from 'chai'; +import WebSocket from 'ws'; + +const WARN_SHOULD_BE_OBJ = 'should be an object'; +const WARN_SHOULD_BE_NOT_NULL = 'should not be null'; +const WARN_SHOULD_HAVE_KEY = 'should have key '; +const WARN_SHOULD_BE_TYPE = 'should be a '; +const TIMEOUT = 60000; + +const binance = new Binance().options({ + APIKEY: 'X4BHNSimXOK6RKs2FcKqExquJtHjMxz5hWqF0BBeVnfa5bKFMk7X0wtkfEz0cPrJ', + APISECRET: 'x8gLihunpNq0d46F2q0TWJmeCDahX5LMXSlv3lSFNbMI3rujSOpTDKdhbcmPSf2i', + test: true, + verbose: false +}); + +const stopWsApiConnections = function (log = false) { + const connections = (binance as any).wsApiConnections; + for (let connectionId in connections) { + if (log) console.log('Terminated WebSocket API connection: ' + connectionId); + (binance as any).terminateWsApi(connectionId); + } +} + +describe('WebSocket API Infrastructure', function () { + + describe('generateRequestId', function () { + it('should generate unique request IDs', function () { + const id1 = (binance as any).generateRequestId(); + const id2 = (binance as any).generateRequestId(); + + assert(typeof id1 === 'string', WARN_SHOULD_BE_TYPE + 'string'); + assert(typeof id2 === 'string', WARN_SHOULD_BE_TYPE + 'string'); + assert(id1 !== id2, 'IDs should be unique'); + assert(id1.length > 0, 'ID should not be empty'); + }); + }); + + describe('getWsApiUrl', function () { + it('should return testnet URL when test mode is enabled', function () { + const url = (binance as any).getWsApiUrl(); + assert(typeof url === 'string', WARN_SHOULD_BE_TYPE + 'string'); + assert(url.includes('testnet'), 'should include testnet'); + assert(url.includes('ws-api'), 'should include ws-api'); + assert(url.includes('/ws-api/v3'), 'should include API version'); + }); + + it('should return production URL when test mode is disabled', function () { + const prodBinance = new Binance().options({ + APIKEY: 'test', + APISECRET: 'test', + test: false + }); + const url = (prodBinance as any).getWsApiUrl(); + assert(typeof url === 'string', WARN_SHOULD_BE_TYPE + 'string'); + assert(!url.includes('testnet'), 'should not include testnet'); + assert(url.includes('ws-api.binance.com'), 'should be production URL'); + }); + }); + + describe('connectWsApi', function () { + it('should create WebSocket API connection', function (done) { + this.timeout(TIMEOUT); + + const connectionId = 'test-connection'; + const ws = (binance as any).connectWsApi(connectionId, (data: any) => { + // Message handler + }, () => { + // Reconnect handler + }); + + ws.on('open', () => { + assert(ws !== null, WARN_SHOULD_BE_NOT_NULL); + assert(ws.readyState === WebSocket.OPEN, 'WebSocket should be open'); + assert((ws as any).connectionId === connectionId, 'Connection ID should match'); + + stopWsApiConnections(true); + done(); + }); + + ws.on('error', (error: Error) => { + stopWsApiConnections(); + done(error); + }); + }); + }); +}); + +describe('User Data Handler - Event Format Support', function () { + + describe('userDataHandler - Old Event Format', function () { + it('should handle old format outboundAccountPosition event', function () { + let capturedEvent: any = null; + + (binance as any).Options.all_updates_callback = (data: any) => { + capturedEvent = data; + }; + + const oldFormatEvent = { + e: 'outboundAccountPosition', + E: 1564034571105, + u: 1564034571073, + B: [ + { a: 'ETH', f: '10000.000000', l: '0.000000' } + ] + }; + + (binance as any).userDataHandler(oldFormatEvent); + + assert(capturedEvent !== null, WARN_SHOULD_BE_NOT_NULL); + assert(capturedEvent.e === 'outboundAccountPosition', 'Event type should match'); + assert(capturedEvent.E === 1564034571105, 'Event time should match'); + assert(Array.isArray(capturedEvent.B), 'Balances should be an array'); + }); + + it('should handle old format executionReport event', function () { + let capturedEvent: any = null; + + (binance as any).Options.execution_callback = (data: any) => { + capturedEvent = data; + }; + + const oldFormatEvent = { + e: 'executionReport', + E: 1499405658658, + s: 'ETHBTC', + c: 'mUvoqJxFIILMdfAW5iGSOW', + S: 'BUY', + o: 'LIMIT', + x: 'NEW', + X: 'NEW' + }; + + (binance as any).userDataHandler(oldFormatEvent); + + assert(capturedEvent !== null, WARN_SHOULD_BE_NOT_NULL); + assert(capturedEvent.e === 'executionReport', 'Event type should match'); + assert(capturedEvent.s === 'ETHBTC', 'Symbol should match'); + }); + }); + + describe('userDataHandler - New Event Format', function () { + it('should handle new format outboundAccountPosition event', function () { + let capturedEvent: any = null; + + (binance as any).Options.all_updates_callback = (data: any) => { + capturedEvent = data; + }; + + const newFormatEvent = { + subscriptionId: 0, + event: { + e: 'outboundAccountPosition', + E: 1564034571105, + u: 1564034571073, + B: [ + { a: 'ETH', f: '10000.000000', l: '0.000000' } + ] + } + }; + + (binance as any).userDataHandler(newFormatEvent); + + assert(capturedEvent !== null, WARN_SHOULD_BE_NOT_NULL); + assert(capturedEvent.e === 'outboundAccountPosition', 'Event type should match'); + assert(capturedEvent.E === 1564034571105, 'Event time should match'); + assert(Array.isArray(capturedEvent.B), 'Balances should be an array'); + }); + + it('should handle new format executionReport event', function () { + let capturedEvent: any = null; + + (binance as any).Options.execution_callback = (data: any) => { + capturedEvent = data; + }; + + const newFormatEvent = { + subscriptionId: 1, + event: { + e: 'executionReport', + E: 1499405658658, + s: 'ETHBTC', + c: 'mUvoqJxFIILMdfAW5iGSOW', + S: 'BUY', + o: 'LIMIT', + x: 'NEW', + X: 'NEW' + } + }; + + (binance as any).userDataHandler(newFormatEvent); + + assert(capturedEvent !== null, WARN_SHOULD_BE_NOT_NULL); + assert(capturedEvent.e === 'executionReport', 'Event type should match'); + assert(capturedEvent.s === 'ETHBTC', 'Symbol should match'); + }); + + it('should handle eventStreamTerminated event', function () { + let capturedEvent: any = null; + let loggedMessage = ''; + + (binance as any).Options.all_updates_callback = (data: any) => { + capturedEvent = data; + }; + + const originalLog = (binance as any).Options.log; + (binance as any).Options.log = (msg: string) => { + loggedMessage = msg; + }; + + const terminatedEvent = { + subscriptionId: 0, + event: { + e: 'eventStreamTerminated', + E: 1728973001334 + } + }; + + (binance as any).userDataHandler(terminatedEvent); + + assert(capturedEvent !== null, WARN_SHOULD_BE_NOT_NULL); + assert(capturedEvent.e === 'eventStreamTerminated', 'Event type should match'); + assert(loggedMessage.includes('terminated'), 'Should log termination'); + + (binance as any).Options.log = originalLog; + }); + + it('should handle externalLockUpdate event', function () { + let capturedEvent: any = null; + + (binance as any).Options.balance_callback = (data: any) => { + capturedEvent = data; + }; + + const lockUpdateEvent = { + subscriptionId: 0, + event: { + e: 'externalLockUpdate', + E: 1581557507324, + a: 'NEO', + d: '10.00000000', + T: 1581557507268 + } + }; + + (binance as any).userDataHandler(lockUpdateEvent); + + assert(capturedEvent !== null, WARN_SHOULD_BE_NOT_NULL); + assert(capturedEvent.e === 'externalLockUpdate', 'Event type should match'); + assert(capturedEvent.a === 'NEO', 'Asset should match'); + assert(capturedEvent.d === '10.00000000', 'Delta should match'); + }); + + it('should handle balanceUpdate event', function () { + let capturedEvent: any = null; + + (binance as any).Options.balance_callback = (data: any) => { + capturedEvent = data; + }; + + const balanceUpdateEvent = { + subscriptionId: 0, + event: { + e: 'balanceUpdate', + E: 1573200697110, + a: 'BTC', + d: '100.00000000', + T: 1573200697068 + } + }; + + (binance as any).userDataHandler(balanceUpdateEvent); + + assert(capturedEvent !== null, WARN_SHOULD_BE_NOT_NULL); + assert(capturedEvent.e === 'balanceUpdate', 'Event type should match'); + assert(capturedEvent.a === 'BTC', 'Asset should match'); + }); + + it('should handle listStatus event', function () { + let capturedEvent: any = null; + + (binance as any).Options.list_status_callback = (data: any) => { + capturedEvent = data; + }; + + const listStatusEvent = { + subscriptionId: 0, + event: { + e: 'listStatus', + E: 1564035303637, + s: 'ETHBTC', + g: 2, + c: 'OCO', + l: 'EXEC_STARTED', + L: 'EXECUTING' + } + }; + + (binance as any).userDataHandler(listStatusEvent); + + assert(capturedEvent !== null, WARN_SHOULD_BE_NOT_NULL); + assert(capturedEvent.e === 'listStatus', 'Event type should match'); + assert(capturedEvent.s === 'ETHBTC', 'Symbol should match'); + }); + }); +}); + +/* Skip margin in CI as does not support testnet +describe('Margin Data Handler - Event Format Support', function () { + + describe('userMarginDataHandler - Old Event Format', function () { + it('should handle old format margin events', function () { + let capturedEvent: any = null; + + (binance as any).Options.margin_all_updates_callback = (data: any) => { + capturedEvent = data; + }; + + const oldFormatEvent = { + e: 'outboundAccountPosition', + E: 1564034571105, + u: 1564034571073, + B: [ + { a: 'BTC', f: '1.00000000', l: '0.50000000' } + ] + }; + + (binance as any).userMarginDataHandler(oldFormatEvent); + + assert(capturedEvent !== null, WARN_SHOULD_BE_NOT_NULL); + assert(capturedEvent.e === 'outboundAccountPosition', 'Event type should match'); + }); + }); + + describe('userMarginDataHandler - New Event Format', function () { + it('should handle new format margin events', function () { + let capturedEvent: any = null; + + (binance as any).Options.margin_all_updates_callback = (data: any) => { + capturedEvent = data; + }; + + const newFormatEvent = { + subscriptionId: 1, + event: { + e: 'outboundAccountPosition', + E: 1564034571105, + u: 1564034571073, + B: [ + { a: 'BTC', f: '1.00000000', l: '0.50000000' } + ] + } + }; + + (binance as any).userMarginDataHandler(newFormatEvent); + + assert(capturedEvent !== null, WARN_SHOULD_BE_NOT_NULL); + assert(capturedEvent.e === 'outboundAccountPosition', 'Event type should match'); + }); + + it('should handle margin eventStreamTerminated', function () { + let capturedEvent: any = null; + let loggedMessage = ''; + + (binance as any).Options.margin_all_updates_callback = (data: any) => { + capturedEvent = data; + }; + + const originalLog = (binance as any).Options.log; + (binance as any).Options.log = (msg: string) => { + loggedMessage = msg; + }; + + const terminatedEvent = { + subscriptionId: 1, + event: { + e: 'eventStreamTerminated', + E: 1728973001334 + } + }; + + (binance as any).userMarginDataHandler(terminatedEvent); + + assert(capturedEvent !== null, WARN_SHOULD_BE_NOT_NULL); + assert(capturedEvent.e === 'eventStreamTerminated', 'Event type should match'); + assert(loggedMessage.includes('Margin'), 'Should log margin termination'); + + (binance as any).Options.log = originalLog; + }); + + it('should handle margin executionReport', function () { + let capturedEvent: any = null; + + (binance as any).Options.margin_execution_callback = (data: any) => { + capturedEvent = data; + }; + + const executionEvent = { + subscriptionId: 1, + event: { + e: 'executionReport', + E: 1499405658658, + s: 'BTCUSDT', + c: 'marginOrder123', + S: 'SELL', + o: 'MARKET', + x: 'TRADE', + X: 'FILLED' + } + }; + + (binance as any).userMarginDataHandler(executionEvent); + + assert(capturedEvent !== null, WARN_SHOULD_BE_NOT_NULL); + assert(capturedEvent.e === 'executionReport', 'Event type should match'); + assert(capturedEvent.s === 'BTCUSDT', 'Symbol should match'); + }); + }); +}); +*/ + +describe('WebSocket API JSON-RPC', function () { + + describe('sendWsApiRequest', function () { + it('should reject when connection is not open', async function () { + try { + await (binance as any).sendWsApiRequest('non-existent', 'test.method', {}); + assert.fail('Should have thrown an error'); + } catch (error: any) { + assert(error.message.includes('not open'), 'Should indicate connection is not open'); + } + }); + + it('should timeout after 30 seconds', async function () { + this.timeout(35000); + + const connectionId = 'timeout-test'; + const ws = (binance as any).connectWsApi(connectionId, () => {}, () => {}); + + await new Promise((resolve) => { + ws.on('open', resolve); + }); + + // Override send to prevent actual sending + const originalSend = ws.send; + ws.send = (data: any, callback: any) => { + if (callback) callback(); // Call callback without error + }; + + try { + await (binance as any).sendWsApiRequest(connectionId, 'test.method', {}); + assert.fail('Should have timed out'); + } catch (error: any) { + assert(error.message.includes('timeout'), 'Should indicate timeout'); + } finally { + ws.send = originalSend; + stopWsApiConnections(); + } + }).timeout(35000); + }); +}); + +describe('WebSocket API Live Tests', function () { + + describe('userData WebSocket API Connection', function () { + it('should connect and subscribe to user data stream', function (done) { + this.timeout(TIMEOUT); + + let subscriptionReceived = false; + + binance.websockets.userData( + (data) => { + // All updates callback + console.log('User data event:', data); + }, + (balance) => { + // Balance callback + console.log('Balance update:', balance); + }, + (execution) => { + // Execution callback + console.log('Execution report:', execution); + }, + (endpoint) => { + // Subscribed callback + console.log('Subscribed to:', endpoint); + subscriptionReceived = true; + + assert(endpoint !== null, WARN_SHOULD_BE_NOT_NULL); + assert(typeof endpoint === 'string', WARN_SHOULD_BE_TYPE + 'string'); + + // Wait a bit then cleanup + setTimeout(() => { + stopWsApiConnections(true); + done(); + }, 5000); + }, + (listStatus) => { + // List status callback + console.log('List status:', listStatus); + } + ); + }); + + it('should receive execution and balance events when creating a market order', function (done) { + this.timeout(TIMEOUT); + + let executionReceived = false; + let balanceReceived = false; + let subscriptionReady = false; + + binance.websockets.userData( + (data) => { + // All updates callback + console.log('Event received:', data.e, data); + + // Check if we received both events + if (executionReceived && balanceReceived && subscriptionReady) { + console.log('✅ Both execution and balance events received!'); + setTimeout(() => { + stopWsApiConnections(true); + done(); + }, 2000); + } + }, + (balance) => { + // Balance callback + console.log('📊 Balance update received:', balance); + balanceReceived = true; + + assert(balance !== null, WARN_SHOULD_BE_NOT_NULL); + assert(typeof balance === 'object', WARN_SHOULD_BE_OBJ); + + // Verify it's a balance-related event + const eventType = balance.e; + assert( + eventType === 'balanceUpdate' || eventType === 'outboundAccountPosition', + 'Should be a balance event type' + ); + + if (eventType === 'balanceUpdate') { + assert(Object.prototype.hasOwnProperty.call(balance, 'a'), 'Should have asset'); + assert(Object.prototype.hasOwnProperty.call(balance, 'd'), 'Should have delta'); + } else if (eventType === 'outboundAccountPosition') { + assert(Object.prototype.hasOwnProperty.call(balance, 'B'), 'Should have balances array'); + assert(Array.isArray(balance.B), 'Balances should be an array'); + } + + // Check if both events received + if (executionReceived && balanceReceived && subscriptionReady) { + console.log('✅ Both execution and balance events received!'); + setTimeout(() => { + stopWsApiConnections(true); + done(); + }, 2000); + } + }, + (execution) => { + // Execution callback + console.log('📈 Execution report received:', execution); + executionReceived = true; + + assert(execution !== null, WARN_SHOULD_BE_NOT_NULL); + assert(typeof execution === 'object', WARN_SHOULD_BE_OBJ); + assert(execution.e === 'executionReport', 'Should be execution report'); + + // Verify execution report structure + assert(Object.prototype.hasOwnProperty.call(execution, 's'), WARN_SHOULD_HAVE_KEY + 'symbol'); + assert(Object.prototype.hasOwnProperty.call(execution, 'S'), WARN_SHOULD_HAVE_KEY + 'side'); + assert(Object.prototype.hasOwnProperty.call(execution, 'o'), WARN_SHOULD_HAVE_KEY + 'order type'); + assert(Object.prototype.hasOwnProperty.call(execution, 'X'), WARN_SHOULD_HAVE_KEY + 'order status'); + assert(Object.prototype.hasOwnProperty.call(execution, 'x'), WARN_SHOULD_HAVE_KEY + 'execution type'); + + console.log(` Symbol: ${execution.s}`); + console.log(` Side: ${execution.S}`); + console.log(` Order Type: ${execution.o}`); + console.log(` Execution Type: ${execution.x}`); + console.log(` Order Status: ${execution.X}`); + + // Check if both events received + if (executionReceived && balanceReceived && subscriptionReady) { + console.log('✅ Both execution and balance events received!'); + setTimeout(() => { + stopWsApiConnections(true); + done(); + }, 2000); + } + }, + async (endpoint) => { + // Subscribed callback + console.log('Connected to user data stream:', endpoint); + subscriptionReady = true; + + assert(endpoint !== null, WARN_SHOULD_BE_NOT_NULL); + + // Wait a moment for WebSocket to be fully ready + await new Promise(resolve => setTimeout(resolve, 2000)); + + try { + // Create a small market buy order for BNBUSDT (typically has low price) + console.log('Creating test market order...'); + + const orderResult = await binance.marketBuy('BNBUSDT', 0.01); + + console.log('Order created:', orderResult); + assert(orderResult !== null, 'Order should be created'); + assert(orderResult.symbol === 'BNBUSDT', 'Order symbol should match'); + assert(orderResult.side === 'BUY', 'Order side should be BUY'); + assert(orderResult.type === 'MARKET', 'Order type should be MARKET'); + + // Events should be received automatically through WebSocket + // If events are not received within timeout, test will fail + console.log('Waiting for execution and balance events...'); + + // Set a backup timeout in case events are not received + setTimeout(() => { + if (!executionReceived || !balanceReceived) { + console.error('⚠️ Timeout: Not all events received'); + console.error(` Execution received: ${executionReceived}`); + console.error(` Balance received: ${balanceReceived}`); + stopWsApiConnections(true); + done(new Error('Did not receive all expected events within timeout')); + } + }, 25000); // 25 second timeout + + } catch (error: any) { + console.error('Error creating order:', error.message); + stopWsApiConnections(true); + done(error); + } + }, + (listStatus) => { + // List status callback + console.log('List status:', listStatus); + } + ); + }); + }); + + /* Skip margin in CI as does not support testnet + describe('userMarginData WebSocket API Connection', function () { + it('should connect and subscribe to margin data stream', function (done) { + this.timeout(TIMEOUT); + + binance.websockets.userMarginData( + (data) => { + // All updates callback + console.log('Margin data event:', data); + }, + (balance) => { + // Balance callback + console.log('Margin balance update:', balance); + }, + (execution) => { + // Execution callback + console.log('Margin execution report:', execution); + }, + (endpoint) => { + // Subscribed callback + console.log('Subscribed to margin stream:', endpoint); + + assert(endpoint !== null, WARN_SHOULD_BE_NOT_NULL); + assert(typeof endpoint === 'string', WARN_SHOULD_BE_TYPE + 'string'); + + // Verify subscription tracking + const subscriptionId = (binance as any).Options.marginDataSubscriptionId; + assert(subscriptionId !== undefined, 'Should have subscription ID'); + + // Wait a bit then cleanup + setTimeout(() => { + stopWsApiConnections(true); + done(); + }, 5000); + }, + (listStatus) => { + // List status callback + console.log('Margin list status:', listStatus); + } + ); + }); + }); + */ +});