Skip to content

Commit c49bec0

Browse files
authored
Merge pull request #996 from ccxt/spotCloseDataStream-fix
fix(client): spotCloseDataStream removal and other fixes
2 parents 466d8cc + 5e64b36 commit c49bec0

3 files changed

Lines changed: 145 additions & 17 deletions

File tree

src/node-binance-api.ts

Lines changed: 71 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export default class Binance {
6060
combineStreamDemo = `wss://demo-stream.binance.com/stream?streams=`;
6161
wsApi = `wss://ws-api.binance.${this.domain}:443/ws-api/v3`;
6262
wsApiTest = `wss://ws-api.testnet.binance.vision/ws-api/v3`;
63+
wsApiDemo = `wss://demo-ws-api.binance.com/ws-api/v3`;
6364

6465
verbose = false;
6566

@@ -285,6 +286,7 @@ export default class Binance {
285286
}
286287

287288
getWsApiUrl() {
289+
if (this.Options.demo) return this.wsApiDemo;
288290
if (this.Options.test) return this.wsApiTest;
289291
return this.wsApi;
290292
}
@@ -741,12 +743,10 @@ export default class Binance {
741743
*/
742744
async signedRequest(url: string, data: Dict = {}, method: HttpMethod = 'GET', noDataInSignature = false) {
743745
this.requireApiSecret('signedRequest');
744-
const isListenKeyEndpoint = url.includes('v3/userDataStream');
745-
746746
let query = method === 'POST' && noDataInSignature ? '' : this.makeQueryString(data);
747747

748748
let signature = undefined;
749-
if (!noDataInSignature && !isListenKeyEndpoint) {
749+
if (!noDataInSignature) {
750750
data.timestamp = new Date().getTime();
751751

752752
if (this.timeOffset) data.timestamp += this.timeOffset;
@@ -1873,6 +1873,11 @@ export default class Binance {
18731873
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.`);
18741874
}
18751875
const baseUrl = this.getFStreamUrl(category);
1876+
// Private combined streams use ?listenKey=<k1>&listenKey=<k2> query params
1877+
// instead of ?streams=<a>/<b>
1878+
const wsUrl = category === 'private'
1879+
? baseUrl.replace(/\?streams=$/, '?') + streams.map(k => 'listenKey=' + k).join('&')
1880+
: baseUrl + queryParams;
18761881
let ws: any = undefined;
18771882
if (socksproxy) {
18781883
socksproxy = this.proxyReplacewithIp(socksproxy);
@@ -1882,14 +1887,14 @@ export default class Binance {
18821887
host: this.parseProxy(socksproxy)[1],
18831888
port: this.parseProxy(socksproxy)[2]
18841889
});
1885-
ws = new WebSocket(baseUrl + queryParams, { agent });
1890+
ws = new WebSocket(wsUrl, { agent });
18861891
} else if (httpsproxy) {
18871892
if (this.Options.verbose) this.Options.log(`futuresSubscribe: using proxy server ${httpsproxy}`);
18881893
const config = url.parse(httpsproxy);
18891894
const agent = new HttpsProxyAgent(config);
1890-
ws = new WebSocket(baseUrl + queryParams, { agent });
1895+
ws = new WebSocket(wsUrl, { agent });
18911896
} else {
1892-
ws = new WebSocket(baseUrl + queryParams);
1897+
ws = new WebSocket(wsUrl);
18931898
}
18941899

18951900
ws.reconnect = this.Options.reconnect;
@@ -3942,9 +3947,10 @@ export default class Binance {
39423947
/**
39433948
* Ensures a WebSocket API connection is open for the given connectionId
39443949
* @param {string} connectionId - connection identifier
3950+
* @param {function} messageHandler - handler for event messages when a new connection is created
39453951
* @return {promise} - resolves when the connection is open
39463952
*/
3947-
private ensureWsApiConnection(connectionId: string): Promise<void> {
3953+
private ensureWsApiConnection(connectionId: string, messageHandler: Callback = () => {}): Promise<void> {
39483954
return new Promise((resolve, reject) => {
39493955
const existing = this.wsApiConnections[connectionId];
39503956
if (existing) {
@@ -3958,7 +3964,7 @@ export default class Binance {
39583964
return;
39593965
}
39603966
}
3961-
const ws = this.connectWsApi(connectionId, () => {}, () => {});
3967+
const ws = this.connectWsApi(connectionId, messageHandler, () => {});
39623968
ws.once('open', () => resolve());
39633969
ws.once('error', (err: Error) => reject(err));
39643970
});
@@ -4378,20 +4384,68 @@ export default class Binance {
43784384
return res;
43794385
}
43804386

4387+
/**
4388+
* Opens the spot user data stream by subscribing over the WebSocket API.
4389+
* POST /api/v3/userDataStream was removed by Binance on 2026-02-20; user data
4390+
* streams are now started with the userDataStream.subscribe.signature WS-API method.
4391+
* Events are routed to the callbacks configured via userData()/Options.
4392+
* @return {promise} - resolves with { subscriptionId }
4393+
*/
43814394
async spotGetDataStream(params: Dict = {}) {
4382-
return await this.privateSpotRequest('v3/userDataStream', params, 'POST', true);
4395+
const connectionId = 'userData';
4396+
await this.ensureWsApiConnection(connectionId, this.userDataHandler.bind(this));
4397+
const timestamp = Date.now();
4398+
const query = `apiKey=${this.APIKEY}&timestamp=${timestamp}`;
4399+
const signature = this.generateSignature(query);
4400+
const result = await this.sendWsApiRequest(connectionId, 'userDataStream.subscribe.signature', {
4401+
apiKey: this.APIKEY,
4402+
timestamp: timestamp,
4403+
signature: signature,
4404+
...params
4405+
});
4406+
this.Options.userDataSubscriptionId = result.subscriptionId;
4407+
return result;
43834408
}
43844409

4385-
async spotKeepDataStream(listenKey: string | undefined = undefined, params: Dict = {}) {
4386-
listenKey = listenKey || this.Options.listenKey;
4387-
if (!listenKey) throw new Error('A listenKey is required, either as an argument or in this.Options.listenKey');
4388-
return await this.privateSpotRequest('v3/userDataStream', { listenKey, ...params }, 'PUT');
4410+
/**
4411+
* Kept for backwards compatibility: PUT /api/v3/userDataStream was removed by
4412+
* Binance on 2026-02-20 and WS-API subscriptions need no keepalive — they live as
4413+
* long as the connection. This only verifies the connection is still open.
4414+
*/
4415+
async spotKeepDataStream() {
4416+
const ws = this.wsApiConnections['userData'];
4417+
if (!ws || ws.readyState !== WebSocket.OPEN) {
4418+
throw new Error('spotKeepDataStream: no open user data stream connection, start one with userData() or spotGetDataStream()');
4419+
}
4420+
if (this.Options.verbose) this.Options.log('spotKeepDataStream: keepalive is no longer required, WS-API subscriptions live as long as the connection');
4421+
return {};
43894422
}
43904423

4391-
async spotCloseDataStream(listenKey: string | undefined = undefined, params: Dict = {}) {
4392-
listenKey = listenKey || this.Options.listenKey;
4393-
if (!listenKey) throw new Error('A listenKey is required, either as an argument or in this.Options.listenKey');
4394-
return await this.privateSpotRequest('v3/userDataStream', { listenKey, ...params }, 'DELETE');
4424+
/**
4425+
* Closes the spot user data stream by unsubscribing over the WebSocket API.
4426+
* DELETE /api/v3/userDataStream was removed by Binance on 2026-02-20; user data
4427+
* streams are now closed with the userDataStream.unsubscribe WS-API method.
4428+
* @param {number} subscriptionId - optional subscription to close; defaults to the
4429+
* subscription created by userData(). When neither is available, all subscriptions
4430+
* on the connection are closed. The WebSocket connection itself is terminated once
4431+
* the tracked subscription (or all subscriptions) has been closed.
4432+
*/
4433+
async spotCloseDataStream(subscriptionId: number | undefined = undefined, params: Dict = {}) {
4434+
const connectionId = 'userData';
4435+
const ws = this.wsApiConnections[connectionId];
4436+
if (!ws || ws.readyState !== WebSocket.OPEN) {
4437+
throw new Error('spotCloseDataStream: no open user data stream connection, start one with userData()');
4438+
}
4439+
const tracked = this.Options.userDataSubscriptionId;
4440+
subscriptionId = subscriptionId ?? tracked;
4441+
const requestParams: Dict = { ...params };
4442+
if (subscriptionId !== undefined) requestParams.subscriptionId = subscriptionId;
4443+
const result = await this.sendWsApiRequest(connectionId, 'userDataStream.unsubscribe', requestParams);
4444+
if (subscriptionId === undefined || subscriptionId === tracked) {
4445+
this.Options.userDataSubscriptionId = undefined;
4446+
this.terminateWsApi(connectionId, false);
4447+
}
4448+
return result;
43954449
}
43964450

43974451
// /**

tests/binance-ws-api-userdata.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,17 @@ describe('WebSocket API JSON-RPC', function () {
460460
}
461461
}).timeout(35000);
462462
});
463+
464+
describe('spotCloseDataStream', function () {
465+
it('should reject when no user data stream connection is open', async function () {
466+
try {
467+
await binance.spotCloseDataStream();
468+
assert.fail('Should have thrown an error');
469+
} catch (error: any) {
470+
assert(error.message.includes('no open user data stream connection'), 'Should indicate the connection is missing');
471+
}
472+
});
473+
});
463474
});
464475

465476
describe('WebSocket API Live Tests', function () {
@@ -504,6 +515,56 @@ describe('WebSocket API Live Tests', function () {
504515
);
505516
});
506517

518+
it('should close the user data stream with spotCloseDataStream', function (done) {
519+
this.timeout(TIMEOUT);
520+
521+
binance.websockets.userData(
522+
(data) => {
523+
console.log('User data event:', data);
524+
},
525+
undefined,
526+
undefined,
527+
async (endpoint) => {
528+
try {
529+
assert((binance as any).Options.userDataSubscriptionId !== undefined, 'Should have subscription ID');
530+
531+
const result = await binance.spotCloseDataStream();
532+
console.log('Unsubscribed:', result);
533+
534+
assert((binance as any).Options.userDataSubscriptionId === undefined, 'Subscription ID should be cleared');
535+
assert((binance as any).wsApiConnections['userData'] === undefined, 'Connection should be removed');
536+
done();
537+
} catch (error: any) {
538+
stopWsApiConnections();
539+
done(error);
540+
}
541+
}
542+
);
543+
});
544+
545+
it('should manage the stream lifecycle with spotGetDataStream/spotKeepDataStream/spotCloseDataStream', async function () {
546+
this.timeout(TIMEOUT);
547+
548+
try {
549+
const subscription = await binance.spotGetDataStream();
550+
console.log('Subscribed:', subscription);
551+
assert(subscription !== null, WARN_SHOULD_BE_NOT_NULL);
552+
assert(subscription.subscriptionId !== undefined, WARN_SHOULD_HAVE_KEY + 'subscriptionId');
553+
assert((binance as any).Options.userDataSubscriptionId === subscription.subscriptionId, 'Subscription ID should be tracked');
554+
555+
const keepAlive = await binance.spotKeepDataStream();
556+
assert(keepAlive !== null, WARN_SHOULD_BE_NOT_NULL);
557+
558+
const result = await binance.spotCloseDataStream();
559+
console.log('Unsubscribed:', result);
560+
assert((binance as any).Options.userDataSubscriptionId === undefined, 'Subscription ID should be cleared');
561+
assert((binance as any).wsApiConnections['userData'] === undefined, 'Connection should be removed');
562+
} catch (error) {
563+
stopWsApiConnections();
564+
throw error;
565+
}
566+
});
567+
507568
it('should receive execution and balance events when creating a market order', function (done) {
508569
this.timeout(TIMEOUT);
509570

tests/ws-endpoints-migration.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,19 @@ describe('futuresSubscribe rejects mixed-category combined streams', function ()
180180
});
181181
});
182182

183+
describe('futuresSubscribe combined private streams', function () {
184+
it('uses listenKey query params for multiple listenKeys', function () {
185+
const k1 = 'pqia91ma19a5s61cv6a81va65sdf19v8a65a1a5s61cv6a81va65sdf19v8a1a65a1a5s61cv6a81va65sd';
186+
const k2 = k1.split('').reverse().join('');
187+
const ws: any = binance.futuresSubscribe([k1, k2], () => { });
188+
try {
189+
assert.equal(ws.url, `wss://fstream.binance.com/private/stream?listenKey=${k1}&listenKey=${k2}`);
190+
} finally {
191+
ws.terminate();
192+
}
193+
});
194+
});
195+
183196
describe('Live: production market stream (aggTrade via /market/)', function () {
184197
let trade;
185198
let cnt = 0;

0 commit comments

Comments
 (0)