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 41ae0b415bb0e8f970252df6ccfdbaa0bd51e8c0 Mon Sep 17 00:00:00 2001 From: 2rdzy <232089760+2rdzy@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:16:07 +0200 Subject: [PATCH 2/2] Handle unexpected replies and falsy results without crashing - A reply that matches no pending request, a line that is not JSON, and an error that cannot be tied to a request used to throw inside the socket's data handler and take the process down. Report them through onError. - Resolve a request with its real result. 'msg.result || msg' returned the whole message for results such as null (server.ping), 0, '' or false. - requestBatch with no parameters now resolves empty at once; it used to register a callback under the previous request's id and overwrite it. - Fix the method name in blockchainBlock_headers (blockchain.block.headeres). - Log the reason when a keep-alive ping fails; it was dropped. --- index.js | 4 +-- lib/client.js | 55 ++++++++++++++++++++++++++------------- test/client.test.js | 63 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 20 deletions(-) diff --git a/index.js b/index.js index 056cd73..e1b06f6 100644 --- a/index.js +++ b/index.js @@ -119,7 +119,7 @@ class ElectrumClient extends Client { this.timeout = setTimeout(() => { if (this.timeLastCall !== 0 && new Date().getTime() > this.timeLastCall + pingPeriod) { this.server_ping().catch((reason) => { - this.log('Keep-Alive ping failed: ', reason); + this.log(`Keep-Alive ping failed: ${reason}`); }); } }, pingPeriod); @@ -205,7 +205,7 @@ class ElectrumClient extends Client { return this.request('blockchain.block.get_header', [height]); } blockchainBlock_headers(start_height, count) { - return this.request('blockchain.block.headeres', [start_height, count]); + return this.request('blockchain.block.headers', [start_height, count]); } blockchainEstimatefee(number) { return this.request('blockchain.estimatefee', [number]); diff --git a/lib/client.js b/lib/client.js index 7900023..45e003e 100644 --- a/lib/client.js +++ b/lib/client.js @@ -124,6 +124,10 @@ class Client { if (this.status === 0) { return Promise.reject(new Error('Connection to server lost, please retry')); } + if (params.length === 0) { + // nothing to send; registering a callback now would overwrite the one for the previous request + return Promise.resolve([]); + } return new Promise((resolve, reject) => { let arguments_far_calls = {}; let contents = []; @@ -145,40 +149,55 @@ class Client { response(msg) { let callback; - if (!msg.id && msg[0] && msg[0].id) { - // this is a response from batch request + if (Array.isArray(msg)) { + // this is a response from batch request: the callback is registered under one of the ids for (let m of msg) { - if (m.id && this.callback_message_queue[m.id]) { + if (m && m.id && this.callback_message_queue[m.id]) { callback = this.callback_message_queue[m.id]; delete this.callback_message_queue[m.id]; } } } else { callback = this.callback_message_queue[msg.id]; + delete this.callback_message_queue[msg.id]; } - if (callback) { - delete this.callback_message_queue[msg.id]; - if (msg.error) { - callback(msg.error); - } else { - callback(null, msg.result || msg); - } + if (!callback) { + // a late reply (after a close or timeout) or one we never asked for: not worth crashing over + this.onError(new Error('Received a response that matches no pending request')); + return; + } + + if (Array.isArray(msg)) { + callback(null, msg); + } else if (msg.error) { + callback(msg.error); } else { - throw new Error("Error getting callback while handling response"); + callback(null, msg.result); } } onMessage(body, n) { - const msg = JSON.parse(body); + let msg; + try { + msg = JSON.parse(body); + + } catch (err) { + this.onError(new Error(`Received invalid JSON from server: ${err.message}`)); + return; + } + if (msg instanceof Array) { this.response(msg); - } else { - if (msg.id !== void 0) { - this.response(msg); - } else { - this.subscribe.emit(msg.method, msg.params); - } + } else if (msg === null || typeof msg !== 'object') { + this.onError(new Error('Received an unexpected message from server')); + } else if (msg.id !== void 0 && msg.id !== null) { + this.response(msg); + } else if (msg.method) { + this.subscribe.emit(msg.method, msg.params); + } else if (msg.error) { + // an error that cannot be tied to a request, such as a rejected batch + this.onError(new Error(msg.error.message || 'Server error')); } } diff --git a/test/client.test.js b/test/client.test.js index 36653c9..42a069b 100644 --- a/test/client.test.js +++ b/test/client.test.js @@ -65,3 +65,66 @@ for (const [protocol, useTls] of [['tcp', false], ['tls', true]]) { }); }); } + +describe('protocol handling', () => { + let server; + + const handler = request => { + switch (request.method) { + case 'server.version': return ['test-server 1.0', '1.4']; + case 'server.ping': return null; + case 'zero': return 0; + case 'empty': return ''; + case 'no': return false; + case 'echo': return request.params; + default: throw { code: -32601, message: `unknown method ${request.method}` }; + } + }; + + before(async () => { server = await startServer({ handler }); }); + after(() => server.close()); + + test('resolves falsy results as they are', () => withClient(server, 'tcp', async client => { + assert.equal(await client.server_ping(), null); + assert.equal(await client.request('zero', []), 0); + assert.equal(await client.request('empty', []), ''); + assert.equal(await client.request('no', []), false); + })); + + test('sends blockchain.block.headers', () => withClient(server, 'tcp', async client => { + await client.blockchainBlock_headers(100, 2).catch(() => {}); + + assert.ok(server.received.some(m => m.method === 'blockchain.block.headers' && m.params[0] === 100)); + })); + + test('an empty batch resolves empty and leaves other requests alone', () => withClient(server, 'tcp', async client => { + const pending = client.request('echo', ['still here']); + + assert.deepEqual(await client.requestBatch('echo', []), []); + assert.deepEqual(await pending, ['still here']); + })); + + test('survives a response it did not ask for', () => { + const errors = []; + + return withClient(server, 'tcp', async client => { + server.broadcast(JSON.stringify({ jsonrpc: '2.0', id: 99999, result: 'late' })); + server.broadcast(JSON.stringify({ jsonrpc: '2.0', error: { code: 1, message: 'batch too big' } })); + + assert.deepEqual(await client.request('echo', ['ok']), ['ok']); + assert.equal(errors.length, 2); + assert.match(errors[1].message, /batch too big/); + }, { callbacks: { onError: e => errors.push(e) } }); + }); + + test('survives a line that is not JSON', () => { + const errors = []; + + return withClient(server, 'tcp', async client => { + server.broadcast('this is not json'); + + assert.deepEqual(await client.request('echo', ['ok']), ['ok']); + assert.match(errors[0].message, /invalid JSON/); + }, { callbacks: { onError: e => errors.push(e) } }); + }); +});