From ef752bf610a144c22a1a24a707d5df0a791f358d Mon Sep 17 00:00:00 2001 From: carlosmiei <43336371+carlosmiei@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:12:36 +0400 Subject: [PATCH 1/2] fix(client): spotCloseDataStream removal and other fixes --- src/node-binance-api.ts | 88 ++++++++++++++++++++++------ tests/ws-endpoints-migration.test.ts | 13 ++++ 2 files changed, 84 insertions(+), 17 deletions(-) diff --git a/src/node-binance-api.ts b/src/node-binance-api.ts index 35d13af9..b60d967e 100644 --- a/src/node-binance-api.ts +++ b/src/node-binance-api.ts @@ -60,6 +60,7 @@ export default class Binance { 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`; + wsApiDemo = `wss://demo-ws-api.binance.com/ws-api/v3`; verbose = false; @@ -285,6 +286,7 @@ export default class Binance { } getWsApiUrl() { + if (this.Options.demo) return this.wsApiDemo; if (this.Options.test) return this.wsApiTest; return this.wsApi; } @@ -741,12 +743,10 @@ export default class Binance { */ async signedRequest(url: string, data: Dict = {}, method: HttpMethod = 'GET', noDataInSignature = false) { this.requireApiSecret('signedRequest'); - const isListenKeyEndpoint = url.includes('v3/userDataStream'); - let query = method === 'POST' && noDataInSignature ? '' : this.makeQueryString(data); let signature = undefined; - if (!noDataInSignature && !isListenKeyEndpoint) { + if (!noDataInSignature) { data.timestamp = new Date().getTime(); if (this.timeOffset) data.timestamp += this.timeOffset; @@ -1873,6 +1873,11 @@ export default class Binance { throw new Error(`futuresSubscribe: cannot combine '${category}' stream "${streams[0]}" with '${mismatchCategory}' stream "${mismatch}" on one connection. Binance routes futures streams to separate /public, /market and /private endpoints; subscribe to each category separately.`); } const baseUrl = this.getFStreamUrl(category); + // Private combined streams use ?listenKey=&listenKey= query params + // instead of ?streams=/ + const wsUrl = category === 'private' + ? baseUrl.replace(/\?streams=$/, '?') + streams.map(k => 'listenKey=' + k).join('&') + : baseUrl + queryParams; let ws: any = undefined; if (socksproxy) { socksproxy = this.proxyReplacewithIp(socksproxy); @@ -1882,14 +1887,14 @@ export default class Binance { host: this.parseProxy(socksproxy)[1], port: this.parseProxy(socksproxy)[2] }); - ws = new WebSocket(baseUrl + queryParams, { agent }); + ws = new WebSocket(wsUrl, { agent }); } else if (httpsproxy) { if (this.Options.verbose) this.Options.log(`futuresSubscribe: using proxy server ${httpsproxy}`); const config = url.parse(httpsproxy); const agent = new HttpsProxyAgent(config); - ws = new WebSocket(baseUrl + queryParams, { agent }); + ws = new WebSocket(wsUrl, { agent }); } else { - ws = new WebSocket(baseUrl + queryParams); + ws = new WebSocket(wsUrl); } ws.reconnect = this.Options.reconnect; @@ -3942,9 +3947,10 @@ export default class Binance { /** * Ensures a WebSocket API connection is open for the given connectionId * @param {string} connectionId - connection identifier + * @param {function} messageHandler - handler for event messages when a new connection is created * @return {promise} - resolves when the connection is open */ - private ensureWsApiConnection(connectionId: string): Promise { + private ensureWsApiConnection(connectionId: string, messageHandler: Callback = () => {}): Promise { return new Promise((resolve, reject) => { const existing = this.wsApiConnections[connectionId]; if (existing) { @@ -3958,7 +3964,7 @@ export default class Binance { return; } } - const ws = this.connectWsApi(connectionId, () => {}, () => {}); + const ws = this.connectWsApi(connectionId, messageHandler, () => {}); ws.once('open', () => resolve()); ws.once('error', (err: Error) => reject(err)); }); @@ -4378,20 +4384,68 @@ export default class Binance { return res; } + /** + * Opens the spot user data stream by subscribing over the WebSocket API. + * POST /api/v3/userDataStream was removed by Binance on 2026-02-20; user data + * streams are now started with the userDataStream.subscribe.signature WS-API method. + * Events are routed to the callbacks configured via userData()/Options. + * @return {promise} - resolves with { subscriptionId } + */ async spotGetDataStream(params: Dict = {}) { - return await this.privateSpotRequest('v3/userDataStream', params, 'POST', true); + const connectionId = 'userData'; + await this.ensureWsApiConnection(connectionId, this.userDataHandler.bind(this)); + 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, + ...params + }); + this.Options.userDataSubscriptionId = result.subscriptionId; + return result; } - async spotKeepDataStream(listenKey: string | undefined = undefined, params: Dict = {}) { - listenKey = listenKey || this.Options.listenKey; - if (!listenKey) throw new Error('A listenKey is required, either as an argument or in this.Options.listenKey'); - return await this.privateSpotRequest('v3/userDataStream', { listenKey, ...params }, 'PUT'); + /** + * Kept for backwards compatibility: PUT /api/v3/userDataStream was removed by + * Binance on 2026-02-20 and WS-API subscriptions need no keepalive — they live as + * long as the connection. This only verifies the connection is still open. + */ + async spotKeepDataStream() { + const ws = this.wsApiConnections['userData']; + if (!ws || ws.readyState !== WebSocket.OPEN) { + throw new Error('spotKeepDataStream: no open user data stream connection, start one with userData() or spotGetDataStream()'); + } + if (this.Options.verbose) this.Options.log('spotKeepDataStream: keepalive is no longer required, WS-API subscriptions live as long as the connection'); + return {}; } - async spotCloseDataStream(listenKey: string | undefined = undefined, params: Dict = {}) { - listenKey = listenKey || this.Options.listenKey; - if (!listenKey) throw new Error('A listenKey is required, either as an argument or in this.Options.listenKey'); - return await this.privateSpotRequest('v3/userDataStream', { listenKey, ...params }, 'DELETE'); + /** + * Closes the spot user data stream by unsubscribing over the WebSocket API. + * DELETE /api/v3/userDataStream was removed by Binance on 2026-02-20; user data + * streams are now closed with the userDataStream.unsubscribe WS-API method. + * @param {number} subscriptionId - optional subscription to close; defaults to the + * subscription created by userData(). When neither is available, all subscriptions + * on the connection are closed. The WebSocket connection itself is terminated once + * the tracked subscription (or all subscriptions) has been closed. + */ + async spotCloseDataStream(subscriptionId: number | undefined = undefined, params: Dict = {}) { + const connectionId = 'userData'; + const ws = this.wsApiConnections[connectionId]; + if (!ws || ws.readyState !== WebSocket.OPEN) { + throw new Error('spotCloseDataStream: no open user data stream connection, start one with userData()'); + } + const tracked = this.Options.userDataSubscriptionId; + subscriptionId = subscriptionId ?? tracked; + const requestParams: Dict = { ...params }; + if (subscriptionId !== undefined) requestParams.subscriptionId = subscriptionId; + const result = await this.sendWsApiRequest(connectionId, 'userDataStream.unsubscribe', requestParams); + if (subscriptionId === undefined || subscriptionId === tracked) { + this.Options.userDataSubscriptionId = undefined; + this.terminateWsApi(connectionId, false); + } + return result; } // /** diff --git a/tests/ws-endpoints-migration.test.ts b/tests/ws-endpoints-migration.test.ts index 2875f047..76864a6a 100644 --- a/tests/ws-endpoints-migration.test.ts +++ b/tests/ws-endpoints-migration.test.ts @@ -180,6 +180,19 @@ describe('futuresSubscribe rejects mixed-category combined streams', function () }); }); +describe('futuresSubscribe combined private streams', function () { + it('uses listenKey query params for multiple listenKeys', function () { + const k1 = 'pqia91ma19a5s61cv6a81va65sdf19v8a65a1a5s61cv6a81va65sdf19v8a1a65a1a5s61cv6a81va65sd'; + const k2 = k1.split('').reverse().join(''); + const ws: any = binance.futuresSubscribe([k1, k2], () => { }); + try { + assert.equal(ws.url, `wss://fstream.binance.com/private/stream?listenKey=${k1}&listenKey=${k2}`); + } finally { + ws.terminate(); + } + }); +}); + describe('Live: production market stream (aggTrade via /market/)', function () { let trade; let cnt = 0; From 5e64b3675e3cbb8a108b848214bd3de6de057c7c Mon Sep 17 00:00:00 2001 From: carlosmiei <43336371+carlosmiei@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:27:52 +0400 Subject: [PATCH 2/2] add tests --- tests/binance-ws-api-userdata.test.ts | 61 +++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/binance-ws-api-userdata.test.ts b/tests/binance-ws-api-userdata.test.ts index f67b8087..6c4f1a65 100644 --- a/tests/binance-ws-api-userdata.test.ts +++ b/tests/binance-ws-api-userdata.test.ts @@ -460,6 +460,17 @@ describe('WebSocket API JSON-RPC', function () { } }).timeout(35000); }); + + describe('spotCloseDataStream', function () { + it('should reject when no user data stream connection is open', async function () { + try { + await binance.spotCloseDataStream(); + assert.fail('Should have thrown an error'); + } catch (error: any) { + assert(error.message.includes('no open user data stream connection'), 'Should indicate the connection is missing'); + } + }); + }); }); describe('WebSocket API Live Tests', function () { @@ -504,6 +515,56 @@ describe('WebSocket API Live Tests', function () { ); }); + it('should close the user data stream with spotCloseDataStream', function (done) { + this.timeout(TIMEOUT); + + binance.websockets.userData( + (data) => { + console.log('User data event:', data); + }, + undefined, + undefined, + async (endpoint) => { + try { + assert((binance as any).Options.userDataSubscriptionId !== undefined, 'Should have subscription ID'); + + const result = await binance.spotCloseDataStream(); + console.log('Unsubscribed:', result); + + assert((binance as any).Options.userDataSubscriptionId === undefined, 'Subscription ID should be cleared'); + assert((binance as any).wsApiConnections['userData'] === undefined, 'Connection should be removed'); + done(); + } catch (error: any) { + stopWsApiConnections(); + done(error); + } + } + ); + }); + + it('should manage the stream lifecycle with spotGetDataStream/spotKeepDataStream/spotCloseDataStream', async function () { + this.timeout(TIMEOUT); + + try { + const subscription = await binance.spotGetDataStream(); + console.log('Subscribed:', subscription); + assert(subscription !== null, WARN_SHOULD_BE_NOT_NULL); + assert(subscription.subscriptionId !== undefined, WARN_SHOULD_HAVE_KEY + 'subscriptionId'); + assert((binance as any).Options.userDataSubscriptionId === subscription.subscriptionId, 'Subscription ID should be tracked'); + + const keepAlive = await binance.spotKeepDataStream(); + assert(keepAlive !== null, WARN_SHOULD_BE_NOT_NULL); + + const result = await binance.spotCloseDataStream(); + console.log('Unsubscribed:', result); + assert((binance as any).Options.userDataSubscriptionId === undefined, 'Subscription ID should be cleared'); + assert((binance as any).wsApiConnections['userData'] === undefined, 'Connection should be removed'); + } catch (error) { + stopWsApiConnections(); + throw error; + } + }); + it('should receive execution and balance events when creating a market order', function (done) { this.timeout(TIMEOUT);