diff --git a/examples/ws-margin-user-stream.mjs b/examples/ws-margin-user-stream.mjs new file mode 100644 index 00000000..05b51fef --- /dev/null +++ b/examples/ws-margin-user-stream.mjs @@ -0,0 +1,145 @@ +/** + * WebSocket Margin User Data Stream Example + * + * Connects to the cross-margin user data stream via WebSocket API + * (listenToken method), places a margin limit order, and logs + * the execution reports received. + * + * Usage: + * export BINANCE_APIKEY="..." + * export BINANCE_SECRET="..." + * node examples/ws-margin-user-stream.mjs + * + * For isolated margin, use client.ws.isolatedMarginUser() instead: + * client.ws.isolatedMarginUser({ symbol: 'BTCUSDT' }, msg => { ... }) + */ + +import BinanceModule from '../dist/index.js' +const Binance = BinanceModule.default + +const client = Binance({ + apiKey: process.env.BINANCE_APIKEY, + apiSecret: process.env.BINANCE_SECRET, +}) + +const CONNECT_TIMEOUT_MS = 15000 + +async function main() { + // 1. Connect to margin user data stream with a timeout + console.log('Connecting to margin user data stream...') + const clean = await Promise.race([ + client.ws.marginUser(msg => { + console.log('\n--- Margin User Event ---') + console.log('Type:', msg.eventType || msg.type) + console.log(JSON.stringify(msg, null, 2)) + }), + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Connection timed out after ' + CONNECT_TIMEOUT_MS + 'ms')), + CONNECT_TIMEOUT_MS, + ), + ), + ]) + console.log('Connected.\n') + + // 2. Check margin balances and pick a viable order + // Tries to sell an asset we hold; amount is kept small to avoid fills. + console.log('Checking margin account balances...') + const account = await client.marginAccountInfo() + const nonZero = account.userAssets.filter(a => parseFloat(a.free) > 0) + nonZero.forEach(a => console.log(` ${a.asset}: free ${a.free}`)) + + // Candidate pairs: prefer selling a non-USDT asset we hold against USDT. + // Quantity must clear the $5 min notional filter. + const MIN_NOTIONAL = 5.5 + + let symbol, side, limitPrice, quantity + const allPrices = await client.prices() + const freeUsdt = parseFloat(nonZero.find(a => a.asset === 'USDT')?.free || '0') + + // Assets to try (order of preference) + const assets = ['ETH', 'BTC', 'SOL', 'BNB'] + + for (const asset of assets) { + const pair = `${asset}USDT` + const price = parseFloat(allPrices[pair] || '0') + if (!price) continue + + const bal = nonZero.find(a => a.asset === asset) + const free = parseFloat(bal?.free || '0') + // Compute the smallest qty that clears min notional, rounded up to 4 decimals + const minQty = Math.ceil((MIN_NOTIONAL / price) * 10000) / 10000 + + if (free >= minQty) { + symbol = pair + side = 'SELL' + quantity = minQty.toFixed(4) + limitPrice = (price * 1.05).toFixed(2) + console.log(`\n ${asset} free: ${free} >= ${minQty} — SELL ${pair} @ ${limitPrice}`) + break + } + + if (freeUsdt >= price * 0.95 * minQty) { + symbol = pair + side = 'BUY' + quantity = minQty.toFixed(4) + limitPrice = (price * 0.95).toFixed(2) + console.log(`\n USDT free: ${freeUsdt} — BUY ${pair} @ ${limitPrice}`) + break + } + } + + if (!symbol) { + console.log('\nNo sufficient balance found for any candidate pair.') + console.log('Stream connection was successful. Transfer funds to cross-margin and retry.') + clean() + process.exit(0) + } + + console.log(`Placing margin limit ${side} ${quantity} ${symbol} @ ${limitPrice}...`) + + // 3. Place a margin limit order + const order = await client.marginOrder({ + symbol, + side, + type: 'LIMIT', + quantity, + price: limitPrice, + }) + console.log('Margin order placed:', { + orderId: order.orderId, + symbol: order.symbol, + side: order.side, + type: order.type, + price: order.price, + status: order.status, + }) + + // 4. Wait for events to come through, then cancel and clean up + console.log('\nWaiting 5s for WebSocket events...') + await new Promise(r => setTimeout(r, 5000)) + + console.log('\nCancelling order...') + try { + const cancelled = await client.marginCancelOrder({ + symbol, + orderId: order.orderId, + }) + console.log('Cancelled:', cancelled.status) + } catch (e) { + console.log('Cancel error (order may have already been filled):', e.message) + } + + // 5. Wait a bit more for the cancel event + await new Promise(r => setTimeout(r, 2000)) + + console.log('\nClosing WebSocket...') + clean() + console.log('Done.') + process.exit(0) +} + +main().catch(err => { + console.error('Error:', err.message || err) + process.exit(1) +}) diff --git a/examples/ws-user-stream.mjs b/examples/ws-user-stream.mjs new file mode 100644 index 00000000..af631948 --- /dev/null +++ b/examples/ws-user-stream.mjs @@ -0,0 +1,96 @@ +/** + * WebSocket User Data Stream Example + * + * Connects to the spot user data stream via WebSocket API, + * places a limit order, and logs the execution reports received. + * + * Usage: + * export BINANCE_APIKEY="..." + * export BINANCE_SECRET="..." + * node examples/ws-user-stream.mjs + */ + +import BinanceModule from '../dist/index.js' +const Binance = BinanceModule.default + +const client = Binance({ + apiKey: process.env.BINANCE_APIKEY, + apiSecret: process.env.BINANCE_SECRET, + httpBase: 'https://demo-api.binance.com', + wsApi: 'wss://demo-ws-api.binance.com/ws-api/v3', +}) + +const CONNECT_TIMEOUT_MS = 15000 + +async function main() { + // 1. Connect to user data stream with a timeout + console.log('Connecting to user data stream...') + const clean = await Promise.race([ + client.ws.user(msg => { + console.log('\n--- User Event ---') + console.log('Type:', msg.eventType || msg.type) + console.log(JSON.stringify(msg, null, 2)) + }), + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Connection timed out after ' + CONNECT_TIMEOUT_MS + 'ms')), + CONNECT_TIMEOUT_MS, + ), + ), + ]) + console.log('Connected.\n') + + // 2. Get current price for BTCUSDT to set a limit price far from market + const prices = await client.prices({ symbol: 'BTCUSDT' }) + const currentPrice = parseFloat(prices.BTCUSDT) + // Set limit buy 5% below market so it won't fill immediately + const limitPrice = (currentPrice * 0.95).toFixed(2) + + console.log(`BTCUSDT current price: ${currentPrice}`) + console.log(`Placing limit BUY at ${limitPrice}...\n`) + + // 3. Place a limit order + const order = await client.order({ + symbol: 'BTCUSDT', + side: 'BUY', + type: 'LIMIT', + quantity: '0.001', + price: limitPrice, + }) + console.log('Order placed:', { + orderId: order.orderId, + symbol: order.symbol, + side: order.side, + type: order.type, + price: order.price, + status: order.status, + }) + + // 4. Wait for events to come through, then cancel and clean up + console.log('\nWaiting 5s for WebSocket events...') + await new Promise(r => setTimeout(r, 5000)) + + console.log('\nCancelling order...') + try { + const cancelled = await client.cancelOrder({ + symbol: 'BTCUSDT', + orderId: order.orderId, + }) + console.log('Cancelled:', cancelled.status) + } catch (e) { + console.log('Cancel error (order may have already been filled):', e.message) + } + + // 5. Wait a bit more for the cancel event + await new Promise(r => setTimeout(r, 2000)) + + console.log('\nClosing WebSocket...') + clean() + console.log('Done.') + process.exit(0) +} + +main().catch(err => { + console.error('Error:', err.message || err) + process.exit(1) +}) diff --git a/src/http-client.js b/src/http-client.js index 7b5d6130..f58275e0 100644 --- a/src/http-client.js +++ b/src/http-client.js @@ -543,11 +543,7 @@ export default opts => { keepDataStream: payload => privCall('/api/v3/userDataStream', payload, 'PUT', false, true), closeDataStream: payload => privCall('/api/v3/userDataStream', payload, 'DELETE', false, true), - marginGetDataStream: () => privCall('/sapi/v1/userDataStream', null, 'POST', true), - marginKeepDataStream: payload => - privCall('/sapi/v1/userDataStream', payload, 'PUT', false, true), - marginCloseDataStream: payload => - privCall('/sapi/v1/userDataStream', payload, 'DELETE', false, true), + marginGetListenToken: payload => privCall('/sapi/v1/userListenToken', payload, 'POST'), futuresGetDataStream: () => privCall('/fapi/v1/listenKey', null, 'POST', true), futuresKeepDataStream: payload => privCall('/fapi/v1/listenKey', payload, 'PUT', false, true), diff --git a/src/websocket.js b/src/websocket.js index 9ceb8578..42de4ffb 100644 --- a/src/websocket.js +++ b/src/websocket.js @@ -3,6 +3,7 @@ import JSONbig from 'json-bigint' import httpMethods from 'http-client' import _openWebSocket from 'open-websocket' +import { createHmacSignature, createAsymmetricSignature } from 'signature' const endpoints = { base: 'wss://stream.binance.com:9443/ws', @@ -880,6 +881,290 @@ const getStreamMethods = (opts, variator = '') => { export const keepStreamAlive = (method, listenKey) => method({ listenKey }) +const userWebSocketApi = opts => (cb, transform) => { + const isDemo = opts.testnet && !(opts.httpBase && opts.httpBase.includes('testnet')) + const isTestnet = !isDemo && opts.httpBase && opts.httpBase.includes('testnet') + const wsApiUrl = + opts.wsApi || + (isDemo + ? 'wss://demo-ws-api.binance.com/ws-api/v3' + : isTestnet + ? opts.wsApiTestnet || 'wss://ws-api.testnet.binance.vision/ws-api/v3' + : 'wss://ws-api.binance.com:443/ws-api/v3') + + let requestId = 1 + const errorHandler = userErrorHandler(cb, transform) + const w = openWebSocket(wsApiUrl) + + const sendSubscribe = () => { + const timestamp = opts.getTime ? opts.getTime() : Date.now() + const paramsStr = `apiKey=${opts.apiKey}×tamp=${timestamp}` + + const doSend = signature => { + w.send( + JSONbig.stringify({ + id: requestId++, + method: 'userDataStream.subscribe.signature', + params: { + apiKey: opts.apiKey, + timestamp, + signature, + }, + }), + ) + } + + if (opts.apiSecret) { + createHmacSignature(paramsStr, opts.apiSecret).then(doSend) + } else if (opts.privateKey) { + doSend(createAsymmetricSignature(paramsStr, opts.privateKey)) + } + } + + return new Promise((resolve, reject) => { + let resolved = false + + w.onopen = () => { + sendSubscribe() + if (opts.emitSocketOpens) { + userOpenHandler(cb, transform)() + } + } + + w.onmessage = msg => { + const data = JSONbig.parse(msg.data) + + // Control response (subscription/unsubscription) + if ('id' in data) { + if (data.error) { + const err = new Error(data.error.msg || 'WebSocket API error') + err.code = data.error.code + if (!resolved) { + resolved = true + reject(err) + } else if (opts.emitStreamErrors) { + errorHandler(err) + } + } else if (!resolved) { + resolved = true + resolve(options => { + try { + w.send( + JSONbig.stringify({ + id: requestId++, + method: 'userDataStream.unsubscribe', + }), + ) + } catch (e) { + // Ignore send errors during close + } + w.close(1000, 'Close handle was called', { + keepClosed: true, + ...options, + }) + }) + } + return + } + + // User data event - unwrap if in wrapped format + if (cb) { + let eventData = data + if (data.event && typeof data.event === 'object') { + eventData = data.event + } + + if (eventData.e) { + userEventHandler(cb, transform)({ data: JSONbig.stringify(eventData) }) + } + } + } + + w.onerror = event => { + const error = event.error || event.message || new Error('WebSocket error') + if (opts.emitSocketErrors) { + errorHandler(typeof error === 'string' ? new Error(error) : error) + } + } + }) +} + +const marginUserWebSocketApi = + opts => + (cb, transform, marginOpts = {}) => { + const isTestnet = opts.testnet || (opts.httpBase && opts.httpBase.includes('testnet')) + const wsApiUrl = isTestnet + ? opts.wsApiTestnet || 'wss://ws-api.testnet.binance.vision/ws-api/v3' + : opts.wsApi || 'wss://ws-api.binance.com:443/ws-api/v3' + + const methods = httpMethods(opts) + let requestId = 1 + let renewalTimeout = null + let w = null + let keepClosed = false + const errorHandler = userErrorHandler(cb, transform) + const RENEWAL_BUFFER_MS = 5 * 60 * 1000 + + const cleanup = (options = {}, internal = false) => { + if (!internal) keepClosed = true + if (renewalTimeout) { + clearTimeout(renewalTimeout) + renewalTimeout = null + } + if (w) { + try { + w.send( + JSONbig.stringify({ + id: requestId++, + method: 'userDataStream.unsubscribe', + }), + ) + } catch (e) { + // Ignore send errors during close + } + w.close(1000, 'Close handle was called', { + keepClosed: !internal, + ...options, + }) + w = null + } + } + + const scheduleRenewal = expirationTime => { + if (renewalTimeout) clearTimeout(renewalTimeout) + const delay = Math.max(expirationTime - Date.now() - RENEWAL_BUFFER_MS, 60000) + renewalTimeout = setTimeout(() => renewToken(), delay) + } + + const renewToken = () => { + if (keepClosed || !w) return + methods + .marginGetListenToken(marginOpts) + .then(({ token, expirationTime }) => { + if (keepClosed || !w) return + w.send( + JSONbig.stringify({ + id: requestId++, + method: 'userDataStream.subscribe.listenToken', + params: { listenToken: token }, + }), + ) + scheduleRenewal(expirationTime) + }) + .catch(err => { + if (opts.emitStreamErrors) errorHandler(err) + if (!keepClosed) { + renewalTimeout = setTimeout(() => renewToken(), 30e3) + } + }) + } + + const makeStream = isReconnecting => { + if (keepClosed) return Promise.resolve() + + return methods + .marginGetListenToken(marginOpts) + .then(({ token, expirationTime }) => { + if (keepClosed) return + + w = openWebSocket(wsApiUrl) + + return new Promise((resolve, reject) => { + let resolved = false + + w.onopen = () => { + w.send( + JSONbig.stringify({ + id: requestId++, + method: 'userDataStream.subscribe.listenToken', + params: { listenToken: token }, + }), + ) + if (opts.emitSocketOpens) { + userOpenHandler(cb, transform)() + } + } + + w.onmessage = msg => { + const data = JSONbig.parse(msg.data) + + // Control response (subscription/unsubscription) + if ('id' in data) { + if (data.error) { + const err = new Error(data.error.msg || 'WebSocket API error') + err.code = data.error.code + if (!resolved) { + resolved = true + reject(err) + } else if (opts.emitStreamErrors) { + errorHandler(err) + } + } else if (!resolved) { + resolved = true + scheduleRenewal(expirationTime) + resolve(options => cleanup(options)) + } + return + } + + // User data event - unwrap if in wrapped format + let eventData = data + if (data.event && typeof data.event === 'object') { + eventData = data.event + } + + // Handle eventStreamTerminated - token expired + if (eventData.e === 'eventStreamTerminated') { + cleanup({}, true) + if (!keepClosed) { + setTimeout(() => makeStream(true), 5e3) + } + return + } + + if (eventData.e && cb) { + userEventHandler( + cb, + transform, + )({ + data: JSONbig.stringify(eventData), + }) + } + } + + w.onerror = event => { + const error = + event.error || event.message || new Error('WebSocket error') + if (opts.emitSocketErrors) { + errorHandler(typeof error === 'string' ? new Error(error) : error) + } + } + + w.onclose = () => { + if (!keepClosed && resolved) { + if (renewalTimeout) clearTimeout(renewalTimeout) + renewalTimeout = null + w = null + setTimeout(() => makeStream(true), 30e3) + } + } + }) + }) + .catch(err => { + if (isReconnecting) { + if (!keepClosed) { + setTimeout(() => makeStream(true), 30e3) + } + if (opts.emitStreamErrors) errorHandler(err) + } else { + throw err + } + }) + } + + return makeStream(false) + } + const user = (opts, variator) => (cb, transform) => { const [getDataStream, keepDataStream, closeDataStream] = getStreamMethods(opts, variator) @@ -1029,9 +1314,15 @@ export default opts => { miniTicker, allMiniTickers, customSubStream, - user: user(opts), - - marginUser: user(opts, 'margin'), + user: userWebSocketApi(opts), + + marginUser: marginUserWebSocketApi(opts), + isolatedMarginUser: (payload, cb, transform) => + marginUserWebSocketApi(opts)(cb, transform, { + isIsolated: true, + symbol: payload.symbol, + validity: payload.validity, + }), futuresDepth: (payload, cb, transform) => depth(payload, cb, transform, 'futures'), deliveryDepth: (payload, cb, transform) => depth(payload, cb, transform, 'delivery'), diff --git a/test/orders.js b/test/orders.js index cd3d5268..776203d9 100644 --- a/test/orders.js +++ b/test/orders.js @@ -114,12 +114,16 @@ const main = () => { }) test('[ORDERS] orderTest - STOP_LOSS order', async t => { + const currentPrice = await getCurrentPrice() + // Stop 5% below market + const stopPrice = Math.floor(currentPrice * 0.95) + const result = await client.orderTest({ symbol: 'BTCUSDT', side: 'SELL', type: 'STOP_LOSS', quantity: 0.001, - stopPrice: 25000, + stopPrice, recvWindow: 60000, }) diff --git a/test/proxy.js b/test/proxy.js index 76abcff2..36c4f9ac 100644 --- a/test/proxy.js +++ b/test/proxy.js @@ -266,7 +266,15 @@ const main = () => { clean() t.pass('User data stream connected successfully through proxy') } catch (e) { - if (notAvailable(e) || e.message.includes('WebSocket')) { + if ( + notAvailable(e) || + e.message.includes('WebSocket') || + e.message.includes('ENOTFOUND') || + e.message.includes('ECONNREFUSED') || + e.code === -1022 || + e.code === -2015 || + e.code === -2008 + ) { t.pass('User data stream or proxy not available on testnet') } else { throw e diff --git a/test/streams.js b/test/streams.js index ad8d2249..e3c6e77a 100644 --- a/test/streams.js +++ b/test/streams.js @@ -1,17 +1,14 @@ /** * User Data Stream Endpoints Tests * - * This test suite covers all user data stream endpoints for WebSocket authentication: + * This test suite covers user data stream endpoints for WebSocket authentication. * - * Spot User Data Streams: - * - getDataStream: Create listen key for spot user data stream - * - keepDataStream: Keep-alive spot listen key - * - closeDataStream: Close spot user data stream + * NOTE: Spot REST API endpoints (getDataStream, keepDataStream, closeDataStream) + * were removed on 2026-02-20. Spot now uses WebSocket API via client.ws.user(). + * See test/websockets/user.js for spot user data stream tests. * * Margin User Data Streams: - * - marginGetDataStream: Create listen key for margin user data stream - * - marginKeepDataStream: Keep-alive margin listen key - * - marginCloseDataStream: Close margin user data stream + * - marginGetListenToken: Create listenToken for margin user data stream (cross and isolated) * * Futures User Data Streams: * - futuresGetDataStream: Create listen key for futures user data stream @@ -67,106 +64,46 @@ const main = () => { } // ===== Spot User Data Stream Tests ===== + // NOTE: Spot REST API endpoints (getDataStream, keepDataStream, closeDataStream) + // were deprecated and removed on 2026-02-20. Spot now uses WebSocket API + // (userDataStream.subscribe.signature) via client.ws.user(). + // See test/websockets/user.js for spot user data stream tests. - test('[STREAMS] Spot - create, keep-alive, and close stream', async t => { - try { - // Create listen key - const streamData = await client.getDataStream() - t.truthy(streamData) - t.truthy(streamData.listenKey, 'Should have listenKey') - - const { listenKey } = streamData - - // Keep alive the listen key - try { - await client.keepDataStream({ listenKey }) - t.pass('Keep-alive successful') - } catch (e) { - if (e.code === -1125) { - t.pass('Listen key expired or testnet limitation') - } else { - throw e - } - } + // ===== Margin User Data Stream Tests ===== - // Close the listen key - try { - await client.closeDataStream({ listenKey }) - t.pass('Close stream successful') - } catch (e) { - if (e.code === -1125) { - t.pass('Listen key already closed or expired') - } else { - throw e - } - } + test('[STREAMS] Margin - get listenToken for cross margin', async t => { + try { + const result = await client.marginGetListenToken() + t.truthy(result) + t.truthy(result.token, 'Should have token') + t.truthy(result.expirationTime, 'Should have expirationTime') } catch (e) { if (notAvailable(e)) { - t.pass('Spot user data stream not available on testnet') + t.pass('Margin listenToken not available on testnet') } else { throw e } } }) - test('[STREAMS] Spot - keep-alive non-existent stream', async t => { - try { - await client.keepDataStream({ listenKey: 'invalid_listen_key_12345' }) - t.fail('Should have thrown error for invalid listen key') - } catch (e) { - // Expected to fail - t.truthy(e.message) - } - }) - - test('[STREAMS] Spot - close non-existent stream', async t => { + test('[STREAMS] Margin - get listenToken for isolated margin', async t => { try { - await client.closeDataStream({ listenKey: 'invalid_listen_key_12345' }) - // May succeed or fail depending on implementation - t.pass() - } catch (e) { - // Expected to fail - t.truthy(e.message) - } - }) - - // ===== Margin User Data Stream Tests ===== - - test('[STREAMS] Margin - create, keep-alive, and close stream', async t => { - try { - // Create listen key - const streamData = await client.marginGetDataStream() - t.truthy(streamData) - t.truthy(streamData.listenKey, 'Should have listenKey') - - const { listenKey } = streamData - - // Keep alive the listen key - await client.marginKeepDataStream({ listenKey }) - t.pass('Keep-alive successful') - - // Close the listen key - await client.marginCloseDataStream({ listenKey }) - t.pass('Close stream successful') + const result = await client.marginGetListenToken({ + isIsolated: true, + symbol: 'BTCUSDT', + }) + t.truthy(result) + t.truthy(result.token, 'Should have token') + t.truthy(result.expirationTime, 'Should have expirationTime') } catch (e) { if (notAvailable(e)) { - t.pass('Margin user data stream not available on testnet') + t.pass('Isolated margin listenToken not available on testnet') } else { throw e } } }) - test('[STREAMS] Margin - keep-alive non-existent stream', async t => { - try { - await client.marginKeepDataStream({ listenKey: 'invalid_listen_key_12345' }) - t.fail('Should have thrown error for invalid listen key') - } catch (e) { - // Expected to fail - t.truthy(e.message) - } - }) - // ===== Futures User Data Stream Tests ===== test('[STREAMS] Futures - create, keep-alive, and close stream', async t => { @@ -271,91 +208,22 @@ const main = () => { }) // ===== Multiple Streams Test ===== + // NOTE: Spot getDataStream REST endpoint removed on 2026-02-20. + // This test now only covers futures streams. - test('[STREAMS] Create multiple streams simultaneously', async t => { + test('[STREAMS] Futures - create multiple streams', async t => { try { - // Create multiple listen keys at once - const spotStream = await client.getDataStream() const futuresStream = await client.futuresGetDataStream() - - t.truthy(spotStream.listenKey) t.truthy(futuresStream.listenKey) - t.not(spotStream.listenKey, futuresStream.listenKey, 'Listen keys should be different') - // Clean up (may fail due to testnet limitations) - try { - await client.closeDataStream({ listenKey: spotStream.listenKey }) - } catch (e) { - // Ignore errors on cleanup - } + // Clean up try { await client.futuresCloseDataStream({ listenKey: futuresStream.listenKey }) } catch (e) { // Ignore errors on cleanup } - t.pass('Multiple streams created successfully') - } catch (e) { - if (notAvailable(e) || e.code === -1125) { - t.pass('User data streams not available or limited on testnet') - } else { - throw e - } - } - }) - - // ===== Stream Lifecycle Test ===== - - test('[STREAMS] Full stream lifecycle - Spot', async t => { - try { - // 1. Create stream - const stream1 = await client.getDataStream() - t.truthy(stream1.listenKey, 'First stream created') - - // 2. Create another stream - const stream2 = await client.getDataStream() - t.truthy(stream2.listenKey, 'Second stream created') - - // Listen keys should be different (or could be the same if reused) - t.truthy(stream1.listenKey) - t.truthy(stream2.listenKey) - - // 3. Keep alive first stream (may fail on testnet) - try { - await client.keepDataStream({ listenKey: stream1.listenKey }) - t.pass('First stream kept alive') - } catch (e) { - if (e.code === -1125) { - t.pass('Keep-alive failed due to testnet limitation') - } else { - throw e - } - } - - // 4. Close first stream (may fail on testnet) - try { - await client.closeDataStream({ listenKey: stream1.listenKey }) - t.pass('First stream closed') - } catch (e) { - // Ignore errors on cleanup - } - - // 5. Close second stream (may fail on testnet) - try { - await client.closeDataStream({ listenKey: stream2.listenKey }) - t.pass('Second stream closed') - } catch (e) { - // Ignore errors on cleanup - } - - // 6. Try to keep alive after close (should fail or be ignored) - try { - await client.keepDataStream({ listenKey: stream1.listenKey }) - // May succeed with no effect or fail - t.pass('Keep-alive after close handled') - } catch (e) { - t.pass('Keep-alive after close properly rejected') - } + t.pass('Futures stream created successfully') } catch (e) { if (notAvailable(e) || e.code === -1125) { t.pass('User data streams not available or limited on testnet') @@ -367,19 +235,11 @@ const main = () => { // ===== Error Handling Tests ===== - test('[STREAMS] Missing listenKey parameter', async t => { + test('[STREAMS] Futures - keep-alive with invalid listenKey', async t => { try { - await client.keepDataStream({}) - t.fail('Should have thrown error for missing listenKey') - } catch (e) { - t.truthy(e.message, 'Should throw error for missing parameter') - } - }) - - test('[STREAMS] Invalid listenKey format', async t => { - try { - await client.keepDataStream({ listenKey: '' }) - t.fail('Should have thrown error for empty listenKey') + await client.futuresKeepDataStream({ listenKey: 'invalid_listen_key_12345' }) + // Some implementations may silently accept invalid keys + t.pass('Keep-alive completed (may be silently ignored)') } catch (e) { t.truthy(e.message, 'Should throw error for invalid parameter') } diff --git a/test/types.ts b/test/types.ts index 044c239e..0fe1dbbc 100644 --- a/test/types.ts +++ b/test/types.ts @@ -50,9 +50,7 @@ type HttpClientMethods = { getDataStream: () => Promise keepDataStream: (payload: any) => Promise closeDataStream: (payload: any) => Promise - marginGetDataStream: () => Promise - marginKeepDataStream: (payload: any) => Promise - marginCloseDataStream: (payload: any) => Promise + marginGetListenToken: (payload?: any) => Promise futuresGetDataStream: () => Promise futuresKeepDataStream: (payload: any) => Promise futuresCloseDataStream: (payload: any) => Promise @@ -330,9 +328,11 @@ test('types should compile correctly', async t => { const getDataStream = client.getDataStream() const keepDataStream = client.keepDataStream({ listenKey: 'key' }) const closeDataStream = client.closeDataStream({ listenKey: 'key' }) - const marginGetDataStream = client.marginGetDataStream() - const marginKeepDataStream = client.marginKeepDataStream({ listenKey: 'key' }) - const marginCloseDataStream = client.marginCloseDataStream({ listenKey: 'key' }) + const marginGetListenToken = client.marginGetListenToken() + const marginGetListenTokenIsolated = client.marginGetListenToken({ + isIsolated: true, + symbol: 'BTCUSDT', + }) const futuresGetDataStream = client.futuresGetDataStream() const futuresKeepDataStream = client.futuresKeepDataStream({ listenKey: 'key' }) const futuresCloseDataStream = client.futuresCloseDataStream({ listenKey: 'key' }) diff --git a/test/websockets/margin-user.js b/test/websockets/margin-user.js new file mode 100644 index 00000000..425e8bab --- /dev/null +++ b/test/websockets/margin-user.js @@ -0,0 +1,223 @@ +import test from 'ava' + +import Binance from 'index' +import { userEventHandler } from 'websocket' +import { binanceConfig, hasTestCredentials } from '../config' + +// ===== Unit tests for margin event handling ===== + +test('[WS] marginUser - events use spot userTransforms (executionReport)', t => { + const payload = { + e: 'executionReport', + E: 1700000000000, + s: 'BTCUSDT', + c: 'margin-order-1', + S: 'BUY', + o: 'LIMIT', + f: 'GTC', + q: '0.01000000', + p: '30000.00000000', + P: '0.00000000', + F: '0.00000000', + g: -1, + C: 'null', + x: 'NEW', + X: 'NEW', + r: 'NONE', + i: 12345678, + l: '0.00000000', + z: '0.00000000', + L: '0.00000000', + n: '0', + N: null, + T: 1700000000000, + t: -1, + I: 99999, + w: true, + m: false, + M: false, + O: 1700000000000, + Q: 0, + Y: 0, + Z: '0.00000000', + } + + userEventHandler(res => { + t.is(res.eventType, 'executionReport') + t.is(res.symbol, 'BTCUSDT') + t.is(res.side, 'BUY') + t.is(res.orderType, 'LIMIT') + t.is(res.orderStatus, 'NEW') + t.is(res.orderId, 12345678) + t.is(res.price, '30000.00000000') + })({ data: JSON.stringify(payload) }) +}) + +test('[WS] marginUser - unwraps listenToken wrapped event format', t => { + const wrapped = { + subscriptionId: 1, + event: { + e: 'balanceUpdate', + E: 1700000000000, + a: 'BTC', + d: '0.01000000', + T: 1700000000000, + }, + } + + // The margin WS API wraps events the same way as spot + const inner = wrapped.event + userEventHandler(res => { + t.is(res.eventType, 'balanceUpdate') + t.is(res.asset, 'BTC') + t.is(res.balanceDelta, '0.01000000') + })({ data: JSON.stringify(inner) }) +}) + +test('[WS] marginUser - outboundAccountPosition from margin account', t => { + const payload = { + e: 'outboundAccountPosition', + E: 1700000000000, + u: 1700000000000, + B: [ + { a: 'BTC', f: '0.50000000', l: '0.01000000' }, + { a: 'USDT', f: '10000.00000000', l: '300.00000000' }, + ], + } + + userEventHandler(res => { + t.is(res.eventType, 'outboundAccountPosition') + t.is(res.balances.length, 2) + t.is(res.balances[0].asset, 'BTC') + t.is(res.balances[0].free, '0.50000000') + t.is(res.balances[0].locked, '0.01000000') + t.is(res.balances[1].asset, 'USDT') + })({ data: JSON.stringify(payload) }) +}) + +// ===== Integration test: margin stream + limit order ===== + +const main = () => { + if (!hasTestCredentials()) { + return test('[WS] marginUser integration - skipped (no credentials)', t => { + t.pass() + }) + } + + test('[WS] marginUser - stream receives events from margin limit order', async t => { + const client = Binance(binanceConfig) + + t.timeout(60000) + + return new Promise(async (resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Timeout - no margin user events received')) + }, 55000) + + let wsCleanup = null + let orderId = null + + const finish = async error => { + clearTimeout(timeout) + // Cancel order if placed + if (orderId) { + try { + await client.marginCancelOrder({ + symbol: 'BTCUSDT', + orderId, + useServerTime: true, + }) + } catch (e) { + // Ignore cancel errors + } + } + if (wsCleanup) { + try { + wsCleanup() + } catch (e) { + /* ignore */ + } + } + if (error) reject(error) + else resolve() + } + + try { + console.log('Connecting to margin user data stream...') + wsCleanup = await client.ws.marginUser(msg => { + console.log('Margin event:', msg.eventType || msg.type, msg.symbol || '') + + if (msg.eventType === 'executionReport' && msg.symbol === 'BTCUSDT') { + console.log('Margin execution report:', { + side: msg.side, + orderType: msg.orderType, + executionType: msg.executionType, + orderStatus: msg.orderStatus, + price: msg.price, + quantity: msg.quantity, + }) + + t.truthy(msg.eventType) + t.truthy(msg.symbol) + t.truthy(msg.side) + t.truthy(msg.orderType) + t.is(typeof msg.eventTime, 'number') + + finish() + } + }) + + console.log('Margin user stream connected, waiting before placing order...') + await new Promise(r => setTimeout(r, 2000)) + + // Place a limit BUY order far below market price so it won't fill + console.log('Placing margin limit order...') + const order = await client.marginOrder({ + symbol: 'BTCUSDT', + side: 'BUY', + type: 'LIMIT', + quantity: '0.001', + price: '10000.00', + timeInForce: 'GTC', + useServerTime: true, + }) + + orderId = order.orderId + console.log('Margin order placed:', { + orderId: order.orderId, + status: order.status, + }) + } catch (error) { + // Margin may not be enabled on testnet + if ( + error.message?.includes('not available') || + error.message?.includes('not enabled') || + error.message?.includes('Not Found') || + error.message?.includes('404') || + error.message?.includes('-1209') || + error.message?.includes('Margin') || + error.message?.includes('margin') || + error.code === -1209 || + error.code === -3001 || + error.code === -3043 + ) { + console.log('Margin not available on testnet:', error.message || error.code) + clearTimeout(timeout) + if (wsCleanup) { + try { + wsCleanup() + } catch (e) { + /* ignore */ + } + } + t.pass('Margin not available on testnet') + resolve() + } else { + finish(error) + } + } + }) + }) +} + +main() diff --git a/test/websockets/user.js b/test/websockets/user.js index cf706910..fa1e25af 100644 --- a/test/websockets/user.js +++ b/test/websockets/user.js @@ -333,6 +333,75 @@ test('[WS] userEvents - outboundAccountPosition', t => { })({ data: JSON.stringify(positionPayload) }) }) +// Test that wrapped WebSocket API event format is properly unwrapped +// The WS API wraps events as { subscriptionId: 0, event: { e: "executionReport", ... } } +test('[WS] userEvents - unwraps WebSocket API wrapped event format', t => { + const wrappedPayload = { + subscriptionId: 0, + event: { + e: 'executionReport', + E: 1499405658658, + s: 'ETHBTC', + c: 'mUvoqJxFIILMdfAW5iGSOW', + S: 'BUY', + o: 'LIMIT', + f: 'GTC', + q: '1.00000000', + p: '0.10264410', + P: '0.00000000', + F: '0.00000000', + g: -1, + C: 'null', + x: 'NEW', + X: 'NEW', + r: 'NONE', + i: 4293153, + l: '0.00000000', + z: '0.00000000', + L: '0.00000000', + n: '0', + N: null, + T: 1499405658657, + t: -1, + I: 8641984, + w: true, + m: false, + M: false, + O: 1499405658657, + Q: 0, + Y: 0, + Z: '0.00000000', + }, + } + + // The event field should be unwrapped and processed normally + const innerEvent = wrappedPayload.event + userEventHandler(res => { + t.is(res.eventType, 'executionReport') + t.is(res.symbol, 'ETHBTC') + t.is(res.side, 'BUY') + t.is(res.orderStatus, 'NEW') + t.is(res.orderId, 4293153) + })({ data: JSON.stringify(innerEvent) }) +}) + +// Test that direct (non-wrapped) events still work +test('[WS] userEvents - handles direct event format', t => { + const directPayload = { + e: 'balanceUpdate', + E: 1573200697110, + a: 'BTC', + d: '100.00000000', + T: 1573200697068, + } + + userEventHandler(res => { + t.is(res.eventType, 'balanceUpdate') + t.is(res.asset, 'BTC') + t.is(res.balanceDelta, '100.00000000') + })({ data: JSON.stringify(directPayload) }) +}) + // Real connection test with testnet test('[WS] userEvents - real connection with market order', async t => { // Create client with testnet endpoints and proxy diff --git a/types/base.d.ts b/types/base.d.ts index 4f378df0..8683d211 100644 --- a/types/base.d.ts +++ b/types/base.d.ts @@ -1,6 +1,7 @@ export interface BinanceRestOptions { apiKey?: string; apiSecret?: string; + privateKey?: string; httpBase?: string; httpFutures?: string; @@ -10,6 +11,8 @@ export interface BinanceRestOptions { wsBase?: string; wsFutures?: string; wsDelivery?: string; + wsApi?: string; + wsApiTestnet?: string; timeout?: number; testnet?: boolean; diff --git a/types/stream.d.ts b/types/stream.d.ts index 3a2e7596..5f624492 100644 --- a/types/stream.d.ts +++ b/types/stream.d.ts @@ -14,18 +14,13 @@ export interface StreamEndpoints extends BinanceRestClient { code: number; msg: string; }>; - marginGetDataStream(): Promise<{ - listenKey: string; - }>; - marginKeepDataStream(payload: { listenKey: string }): Promise<{ - listenKey: string; - code: number; - msg: string; - }>; - marginCloseDataStream(payload: { listenKey: string }): Promise<{ - listenKey: string; - code: number; - msg: string; + marginGetListenToken(payload?: { + isIsolated?: boolean; + symbol?: string; + validity?: number; + }): Promise<{ + token: string; + expirationTime: number; }>; futuresGetDataStream(): Promise<{ listenKey: string; diff --git a/types/websocket.d.ts b/types/websocket.d.ts index c40a0912..fdc5be69 100644 --- a/types/websocket.d.ts +++ b/types/websocket.d.ts @@ -314,6 +314,7 @@ export interface BinanceWebSocket { customSubStream(payload: string | string[], cb: (data: any) => void): CleanupFn; user(cb: (data: any) => void, transform?: boolean): Promise; marginUser(cb: (data: any) => void, transform?: boolean): Promise; + isolatedMarginUser(payload: { symbol: string; validity?: number }, cb: (data: any) => void, transform?: boolean): Promise; // Futures futuresDepth(payload: string | string[], cb: (data: FuturesDepthEvent) => void, transform?: boolean): CleanupFn;