From ec951bf36bc473efe00b83553285bd6af415e668 Mon Sep 17 00:00:00 2001 From: 2rdzy <232089760+2rdzy@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:14:05 +0200 Subject: [PATCH 1/2] Add a test harness, CI and current package metadata Add node:test tests against a small in-process Electrum server over TCP and TLS (with a committed, test-only certificate), covering requests, errors, concurrency, batches, notifications and close. Run them in CI on Node 18, 20 and 22. Point the repository, bugs and homepage fields at this fork, require Node 18 or later, and publish only index.js and lib. --- .github/workflows/test.yml | 19 ++++++++++ package.json | 31 +++++++++++----- test/client.test.js | 67 +++++++++++++++++++++++++++++++++ test/fixtures/cert.pem | 19 ++++++++++ test/fixtures/key.pem | 28 ++++++++++++++ test/helpers/client.js | 23 ++++++++++++ test/helpers/server.js | 76 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 254 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 test/client.test.js create mode 100644 test/fixtures/cert.pem create mode 100644 test/fixtures/key.pem create mode 100644 test/helpers/client.js create mode 100644 test/helpers/server.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..9df8ef9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,19 @@ +name: test + +on: + push: + branches: [master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node: [18, 20, 22] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + - run: npm test diff --git a/package.json b/package.json index d6bf75e..7e2cdbe 100644 --- a/package.json +++ b/package.json @@ -4,16 +4,16 @@ "description": "Electrum protocol client for React Native & Node.js", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --test test/*.test.js" }, "dependencies": {}, "devDependencies": {}, "repository": { "type": "git", - "url": "git://github.com/janoside/electrum-client.git" + "url": "git+https://github.com/2rdzy/electrum-client.git" }, "bugs": { - "url": "https://github.com/janoside/electrum-client/issues" + "url": "https://github.com/2rdzy/electrum-client/issues" }, "keywords": [ "bitcoin", @@ -21,13 +21,26 @@ "electrumx" ], "engines": { - "node": ">=6" + "node": ">=18" }, "contributors": [ - { "name": "Yuki Akiyama" }, - { "name": "7kharov" }, - { "name": "overtorment" }, - { "name": "janoside" } + { + "name": "Yuki Akiyama" + }, + { + "name": "7kharov" + }, + { + "name": "overtorment" + }, + { + "name": "janoside" + } ], - "license": "MIT" + "license": "MIT", + "homepage": "https://github.com/2rdzy/electrum-client#readme", + "files": [ + "index.js", + "lib" + ] } diff --git a/test/client.test.js b/test/client.test.js new file mode 100644 index 0000000..36653c9 --- /dev/null +++ b/test/client.test.js @@ -0,0 +1,67 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { after, before, describe, test } = require('node:test'); + +const { startServer } = require('./helpers/server.js'); +const { withClient } = require('./helpers/client.js'); + +const handler = request => { + switch (request.method) { + case 'server.version': return ['test-server 1.0', '1.4']; + case 'echo': return request.params; + case 'blockchain.scripthash.get_balance': return { confirmed: request.params[0].length, unconfirmed: 0 }; + case 'fail': throw { code: 2, message: 'nope' }; + default: throw { code: -32601, message: `unknown method ${request.method}` }; + } +}; + +for (const [protocol, useTls] of [['tcp', false], ['tls', true]]) { + describe(`over ${protocol}`, () => { + let server; + + before(async () => { server = await startServer({ useTls, handler }); }); + after(() => server.close()); + + test('negotiates the protocol version', () => withClient(server, protocol, client => { + assert.deepEqual(client.versionInfo, ['test-server 1.0', '1.4']); + })); + + test('resolves a request with its result', () => withClient(server, protocol, async client => { + assert.deepEqual(await client.request('echo', ['a', 1]), ['a', 1]); + })); + + test('rejects a request with the server error', () => withClient(server, protocol, async client => { + await assert.rejects(client.request('fail', []), { code: 2, message: 'nope' }); + })); + + test('keeps concurrent requests apart', () => withClient(server, protocol, async client => { + const results = await Promise.all([1, 2, 3, 4, 5].map(n => client.request('echo', [n]))); + + assert.deepEqual(results, [[1], [2], [3], [4], [5]]); + })); + + test('resolves a batch with one entry per parameter, tagged with it', () => withClient(server, protocol, async client => { + const results = await client.blockchainScripthash_getBalanceBatch(['aa', 'bbbb', 'cccccc']); + + assert.deepEqual(results.map(r => r.param), ['aa', 'bbbb', 'cccccc']); + assert.deepEqual(results.map(r => r.result.confirmed), [2, 4, 6]); + })); + + test('emits subscription notifications', () => withClient(server, protocol, async client => { + const notified = new Promise(resolve => client.subscribe.once('blockchain.headers.subscribe', resolve)); + + server.broadcast(JSON.stringify({ jsonrpc: '2.0', method: 'blockchain.headers.subscribe', params: [{ height: 7 }] })); + + assert.deepEqual(await notified, [{ height: 7 }]); + })); + + test('refuses requests after close', async () => { + await withClient(server, protocol, async client => { + client.close(); + + await assert.rejects(client.request('echo', []), /Connection to server lost/); + }); + }); + }); +} diff --git a/test/fixtures/cert.pem b/test/fixtures/cert.pem new file mode 100644 index 0000000..a2ae783 --- /dev/null +++ b/test/fixtures/cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDJzCCAg+gAwIBAgIUC8QQdK2UWC7Gzx1+oiCJs2ear6wwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MTAwNTIwMTMyNVoYDzIxMjYw +OTExMjAxMzI1WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQCpRxHcBqjb/VShru2qOjUGM+B9owDhiwKzM47xQ/iF +G7k0mtbFv9lHTL9NWTTEHy7prg461Z2XCP3QeNYYjV4udy9G2U9YuJuHHT+gx7br +Cr1Q6WT6kqc7RXe0R79GggqHpi02s3g/qbVO/OalBSvVBFpxxw9uMdG9Q7V5y7Jn +WI9NmYB/WRkBQS4dygw+wjS4igUF3j6vwR40gXm3QA66RxdSDNTOLnWLp6On92wj +DpmGNPciXCxhXquwW0o7ylRnQYsXpafHEMHl1uGjJQpXBuvgV/q1VmLFNKbp00T0 +YxiXV2gQA+IgnFMSQ7XKoKHPVJx1gPr97ERTiLYe/ci7AgMBAAGjbzBtMB0GA1Ud +DgQWBBT+7ktr7S5GLg39hICE95f36RZXzTAfBgNVHSMEGDAWgBT+7ktr7S5GLg39 +hICE95f36RZXzTAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQTMBGCCWxvY2FsaG9z +dIcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEAhSP9Gxcxde1s8jfTStMn+VNmKGG3 +cWIVnw1IYf8PCTUx5z2gi1MjBpEV4QyL0tWNvfk0yfDCZJ24kXqLbgo338T7A5pQ +SfJJ1XMP/INRWtcsrnQZA4MdflD+9LzDjSaTNw8rLTQqemrRGLcDXfwmk+rAsGSJ +N20d/Txy1Ixlu7bBE95vMS5nHMK3P6XtlbZPGdDyZnN7JoomUYghWUkmtM00w1Ck +WPqNBCVsTRJZgyhgzvIXEBD7XcN4vMR6wWoTUhRxpW9QqlSQyzVr/Ik9DBh+QZ/n +DVdsD2+EMFtg0jzOJ9+fn+dMYpNJOjis8D1hAJZiku500/R2xjlmVdwYeg== +-----END CERTIFICATE----- diff --git a/test/fixtures/key.pem b/test/fixtures/key.pem new file mode 100644 index 0000000..03bc7b3 --- /dev/null +++ b/test/fixtures/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCpRxHcBqjb/VSh +ru2qOjUGM+B9owDhiwKzM47xQ/iFG7k0mtbFv9lHTL9NWTTEHy7prg461Z2XCP3Q +eNYYjV4udy9G2U9YuJuHHT+gx7brCr1Q6WT6kqc7RXe0R79GggqHpi02s3g/qbVO +/OalBSvVBFpxxw9uMdG9Q7V5y7JnWI9NmYB/WRkBQS4dygw+wjS4igUF3j6vwR40 +gXm3QA66RxdSDNTOLnWLp6On92wjDpmGNPciXCxhXquwW0o7ylRnQYsXpafHEMHl +1uGjJQpXBuvgV/q1VmLFNKbp00T0YxiXV2gQA+IgnFMSQ7XKoKHPVJx1gPr97ERT +iLYe/ci7AgMBAAECggEABiIzx3aGT9JSFWRdQDnJChHUeeOaIzys5bQMjeQZ8S4b ++91QbZc8rD9fqdHS/k2S9EIA9KjLg67TuOYSpo5uHIjnsaLWI9oskyzAIsyZwtJ/ +dvBotEZJa6BiF1Tc3WqA2dbE9KJ0Ik+9PhYegAQ787AAsaFg3Gv5tD3r1B9JYO00 +I2nQ36zxw1YlOwHS5LBgYSePHlVP2BYo6M21C/JUs6oECL0DshNRkbE0qLv95trL +JHq0PRx/0ABwpcfgjEDftvA/kFsxDrP3HEnoHIdvGawO/wiURQQiIEEIwtGkMmTP +/7hXSUOf8XWrRk1+9ljxHpIhMEizbVgW0vjvQi9a4QKBgQDQSQhmzef9zM677qvs +xmjdP4S5XQMQXxCRXRYBVs15OURM+MRvWG+zhuhnMMI8tNe9jNCci3jpk5O5NKUN +Bn3P/SLGkr9odzD2wZ+9dw/KHpaCwuGqnngTGKgTaBWA0yyl6Kk59lUbABz0T9vN +87xREtUF2x752Vugpb7wZjt6RwKBgQDQDmr6fA/tUlrPxyb5QINXmFzNrv2WYCQn +L4mZi8fAaSUIb04+LGaKSEr8JhGKir5Vgy7OLQQT5AuPWeBkJcP324jddvJ03cdO +WSVGQ2r1pOjKz+N8egFVGCgi+YE21H2g6LbpXRH4vm/pLv1m7Agp+qy/mJkWe7kn +4GkVKStD7QKBgBhLLGuxQIndxY8t+2eWjMyhh/2G18ClFe2eid6GHVkyUMEZku/Z +xM2PwrOmgEFMZo15Syu3KNc1m1lSxp2KUEpeIBxBKYopvNBLLTKQHItanfuKd3/L +E/5GNfRtg3SPLPn89SAJl0ua68WjOuP+tcxPAgG42RgTPhiyvF6VzgIpAoGBAMJf +pewr+YlDNloFedDFR6+PRaxXPsy9NylwzQGpSE3nLVTq/9MwEgBAt+VH/31KT7V+ +ID04ayXWGEKtueh2K9YkwIH+vVcXVztIJ320QHoLvfy0KZhCN3pzXhQGu4QpEtFA +eJoefErCR7U3VEBSZPIrIjbBbsxFfF7X1fSSCLylAoGBAKZ652nLK3Tctg5F1PVm +PMO0K4Vs4rcXBURFq6vLB5QEs0pSC3kjYhrbT8cW6L8zThueFnGrK9SopGing/e8 +d6zhhkx5C+OzkDxbyirJKOzCtjrssVTEZId17dDPfr06YDXsD6+6pvFYxeDRxL5J +WhiL3lHrR0+npjrBWe9zJjpy +-----END PRIVATE KEY----- diff --git a/test/helpers/client.js b/test/helpers/client.js new file mode 100644 index 0000000..7b16b53 --- /dev/null +++ b/test/helpers/client.js @@ -0,0 +1,23 @@ +'use strict'; + +// The client expects net and tls to be injected as globals (see lib/client.js). +global.net = require('net'); +global.tls = require('tls'); + +const ElectrumClient = require('../../index.js'); + +// Connect a client to `server`, run `fn(client)`, and always close the client. +async function withClient(server, protocol, fn, { options, callbacks } = {}) { + const client = new ElectrumClient(server.port, '127.0.0.1', protocol, options, callbacks); + + await client.initElectrum({ client: 'electrum-client-test', version: '1.4' }, { retryPeriod: 100000, maxRetry: 0, callback: () => {} }); + + try { + return await fn(client); + + } finally { + client.close(); + } +} + +module.exports = { ElectrumClient, withClient }; diff --git a/test/helpers/server.js b/test/helpers/server.js new file mode 100644 index 0000000..fb88787 --- /dev/null +++ b/test/helpers/server.js @@ -0,0 +1,76 @@ +'use strict'; + +// A minimal Electrum-protocol server for tests: newline-delimited JSON-RPC over +// TCP or TLS. `handler(request)` returns the result for a request, or throws +// `{code, message}` to send an error. + +const fs = require('fs'); +const net = require('net'); +const path = require('path'); +const tls = require('tls'); + +const fixtures = path.join(__dirname, '..', 'fixtures'); + +const tlsOptions = { + key: fs.readFileSync(path.join(fixtures, 'key.pem')), + cert: fs.readFileSync(path.join(fixtures, 'cert.pem')), +}; + +function reply(request, handler) { + try { + return { jsonrpc: '2.0', id: request.id, result: handler(request) }; + + } catch (err) { + return { jsonrpc: '2.0', id: request.id, error: { code: err.code || -1, message: err.message || String(err) } }; + } +} + +async function startServer({ useTls = false, handler = req => req.params } = {}) { + const sockets = new Set(); + const received = []; + + const onConnection = socket => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + socket.on('error', () => {}); + socket.setEncoding('utf8'); + + let buffer = ''; + socket.on('data', chunk => { + buffer += chunk; + + let index; + while ((index = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, index); + buffer = buffer.slice(index + 1); + + const message = JSON.parse(line); + received.push(message); + + const out = Array.isArray(message) + ? message.map(request => reply(request, handler)) + : reply(message, handler); + + socket.write(JSON.stringify(out) + '\n'); + } + }); + }; + + const server = useTls ? tls.createServer(tlsOptions, onConnection) : net.createServer(onConnection); + + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + + return { + port: server.address().port, + received, + sockets, + // send a raw line to every connected client (for notifications and malformed input) + broadcast: line => sockets.forEach(socket => socket.write(line + '\n')), + close: () => new Promise(resolve => { + sockets.forEach(socket => socket.destroy()); + server.close(resolve); + }), + }; +} + +module.exports = { startServer, tlsOptions }; From 3f4b96673ce924b5efdc25309d63fa32d9838d0d Mon Sep 17 00:00:00 2001 From: 2rdzy <232089760+2rdzy@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:26:44 +0200 Subject: [PATCH 2/2] Parse messages in linear time and bound the receive buffer The parser split the whole buffer on every message, so a reply holding many messages (a long address history) took quadratic time: 20,000 messages of about 200 bytes took 21 seconds, and now take 15 milliseconds. It also kept growing the buffer for a server that never sends a newline. Cut each message out with indexOf, skip empty lines, and refuse more than maxBuffer bytes (default 64 MiB, option maxBuffer) without a delimiter by reporting an error and dropping the connection. createRecuesiveParser, an internal helper used only by the parser, is removed. --- lib/client.js | 11 +++++++-- lib/util.js | 54 ++++++++++++++++++++--------------------- test/helpers/client.js | 2 +- test/helpers/server.js | 2 ++ test/limits.test.js | 29 ++++++++++++++++++++++ test/util.test.js | 55 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 123 insertions(+), 30 deletions(-) create mode 100644 test/limits.test.js create mode 100644 test/util.test.js diff --git a/lib/client.js b/lib/client.js index 7900023..3d7f646 100644 --- a/lib/client.js +++ b/lib/client.js @@ -27,7 +27,7 @@ class Client { this.subscribe = new EventEmitter(); this.mp = new util.MessageParser((body, n) => { this.onMessage(body, n); - }); + }, '\n', options && options.maxBuffer); this._protocol = protocol; // saving defaults this._options = options; @@ -195,7 +195,14 @@ class Client { } onRecv(chunk) { - this.mp.run(chunk); + try { + this.mp.run(chunk); + + } catch (err) { + // the server is sending something we refuse to hold: report it and drop the connection + this.onError(err); + this.conn.destroy(); + } } onError(e) { diff --git a/lib/util.js b/lib/util.js index 3de9c54..0d39e0a 100644 --- a/lib/util.js +++ b/lib/util.js @@ -9,26 +9,6 @@ const makeRequest = (exports.makeRequest = (method, params, id) => { }); }); -const createRecuesiveParser = (exports.createRecuesiveParser = (max_depth, delimiter) => { - const MAX_DEPTH = max_depth; - const DELIMITER = delimiter; - const recursiveParser = (n, buffer, callback) => { - if (buffer.length === 0) { - return { code: 0, buffer: buffer }; - } - if (n > MAX_DEPTH) { - return { code: 1, buffer: buffer }; - } - const xs = buffer.split(DELIMITER); - if (xs.length === 1) { - return { code: 0, buffer: buffer }; - } - callback(xs.shift(), n); - return recursiveParser(n + 1, xs.join(DELIMITER), callback); - }; - return recursiveParser; -}); - const createPromiseResult = (exports.createPromiseResult = (resolve, reject) => { return (err, result) => { if (err) reject(err); @@ -49,21 +29,41 @@ const createPromiseResultBatch = (exports.createPromiseResultBatch = (resolve, r }; }); +const DEFAULT_MAX_BUFFER = 64 * 1024 * 1024; + class MessageParser { - constructor(callback) { + constructor(callback, delimiter = '\n', maxBuffer = DEFAULT_MAX_BUFFER) { this.buffer = ''; this.callback = callback; - this.recursiveParser = createRecuesiveParser(20, '\n'); + this.delimiter = delimiter; + this.maxBuffer = maxBuffer; } + + // Feed received text; the callback gets each complete message. Each message + // is cut out with a single indexOf, so a large reply costs time linear in its size. run(chunk) { this.buffer += chunk; - while (true) { - const res = this.recursiveParser(0, this.buffer, this.callback); - this.buffer = res.buffer; - if (res.code === 0) { - break; + + let start = 0; + let end; + let n = 0; + while ((end = this.buffer.indexOf(this.delimiter, start)) !== -1) { + const body = this.buffer.slice(start, end); + start = end + this.delimiter.length; + + if (body.length > 0) { + this.callback(body, n++); } } + + this.buffer = start > 0 ? this.buffer.slice(start) : this.buffer; + + if (this.buffer.length > this.maxBuffer) { + // a message that never ends: stop holding on to it + this.buffer = ''; + + throw new Error(`Received more than ${this.maxBuffer} bytes without a message delimiter`); + } } } exports.MessageParser = MessageParser; diff --git a/test/helpers/client.js b/test/helpers/client.js index 7b16b53..7f3b678 100644 --- a/test/helpers/client.js +++ b/test/helpers/client.js @@ -10,7 +10,7 @@ const ElectrumClient = require('../../index.js'); async function withClient(server, protocol, fn, { options, callbacks } = {}) { const client = new ElectrumClient(server.port, '127.0.0.1', protocol, options, callbacks); - await client.initElectrum({ client: 'electrum-client-test', version: '1.4' }, { retryPeriod: 100000, maxRetry: 0, callback: () => {} }); + await client.initElectrum({ client: 'electrum-client-test', version: '1.4' }, { retryPeriod: 10, maxRetry: 0, callback: () => {} }); try { return await fn(client); diff --git a/test/helpers/server.js b/test/helpers/server.js index fb88787..439b5d8 100644 --- a/test/helpers/server.js +++ b/test/helpers/server.js @@ -66,6 +66,8 @@ async function startServer({ useTls = false, handler = req => req.params } = {}) sockets, // send a raw line to every connected client (for notifications and malformed input) broadcast: line => sockets.forEach(socket => socket.write(line + '\n')), + // send raw text with no line ending + write: text => sockets.forEach(socket => socket.write(text)), close: () => new Promise(resolve => { sockets.forEach(socket => socket.destroy()); server.close(resolve); diff --git a/test/limits.test.js b/test/limits.test.js new file mode 100644 index 0000000..168ecf4 --- /dev/null +++ b/test/limits.test.js @@ -0,0 +1,29 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { describe, test } = require('node:test'); + +const { startServer } = require('./helpers/server.js'); +const { withClient } = require('./helpers/client.js'); + +describe('limits', () => { + test('drops the connection and reports an error when a message grows past maxBuffer', async () => { + const server = await startServer({ handler: request => request.method === 'server.version' ? ['t', '1.4'] : request.params }); + const errors = []; + let closed; + const closedPromise = new Promise(resolve => { closed = resolve; }); + + try { + await withClient(server, 'tcp', async client => { + server.write('x'.repeat(5000)); + + await closedPromise; + + assert.match(errors[0].message, /without a message delimiter/); + }, { options: { maxBuffer: 1000 }, callbacks: { onError: e => errors.push(e), onClose: () => closed() } }); + + } finally { + await server.close(); + } + }); +}); diff --git a/test/util.test.js b/test/util.test.js new file mode 100644 index 0000000..822dc7c --- /dev/null +++ b/test/util.test.js @@ -0,0 +1,55 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { describe, test } = require('node:test'); + +const { MessageParser } = require('../lib/util.js'); + +function collect(parser, ...chunks) { + const bodies = []; + parser.callback = body => bodies.push(body); + chunks.forEach(chunk => parser.run(chunk)); + + return bodies; +} + +describe('MessageParser', () => { + test('splits on newlines and joins messages split across chunks', () => { + assert.deepEqual(collect(new MessageParser(), 'a\nb', 'c\nd\n'), ['a', 'bc', 'd']); + }); + + test('skips empty lines', () => { + assert.deepEqual(collect(new MessageParser(), 'a\n\n\nb\n'), ['a', 'b']); + }); + + test('keeps an incomplete message until its end arrives', () => { + const parser = new MessageParser(); + + assert.deepEqual(collect(parser, 'abc'), []); + assert.deepEqual(collect(parser, 'def\n'), ['abcdef']); + }); + + test('handles more than twenty messages in one chunk', () => { + const lines = Array.from({ length: 5000 }, (_, i) => `m${i}`); + + assert.deepEqual(collect(new MessageParser(), lines.join('\n') + '\n'), lines); + }); + + test('stays fast on a large chunk of many messages', () => { + const line = JSON.stringify({ id: 1, result: 'y'.repeat(200) }); + const chunk = (line + '\n').repeat(20000); + + const started = Date.now(); + const bodies = collect(new MessageParser(), chunk); + + assert.equal(bodies.length, 20000); + assert.ok(Date.now() - started < 2000, 'parsing took too long'); + }); + + test('refuses a message that never ends', () => { + const parser = new MessageParser(() => {}, '\n', 1000); + + assert.throws(() => parser.run('x'.repeat(2000)), /without a message delimiter/); + assert.equal(parser.buffer, ''); + }); +});