├── .eslintrc.js ├── .github └── workflows │ └── node.yml ├── .gitignore ├── LICENSE ├── README.md ├── example ├── bitcoin.js ├── example.js ├── raii_client_transaction_send.js ├── scripthash.js └── subscribe.js ├── index.js ├── package-lock.json ├── package.json ├── src ├── electrum │ ├── client.js │ └── util.js └── socket │ ├── socket_client.js │ ├── socket_client_tcp.js │ ├── socket_client_ws.js │ └── util.js └── test ├── config.js ├── integration_test.js └── tx.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'env': { 3 | 'browser': true, 4 | 'es6': true, 5 | 'node': true, 6 | }, 7 | 'extends': [ 8 | 'google', 9 | ], 10 | 'globals': { 11 | 'Atomics': 'readonly', 12 | 'SharedArrayBuffer': 'readonly', 13 | }, 14 | 'parserOptions': { 15 | 'ecmaVersion': 2018, 16 | 'sourceType': 'module', 17 | }, 18 | 'rules': { 19 | 'indent': ['error', 2, { "SwitchCase": 1 }], 20 | 'max-len': 0, 21 | 'require-jsdoc': 0, // Added just temporarily 22 | 'camelcase': 0, 23 | 'semi': ["error", "never"], 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /.github/workflows/node.yml: -------------------------------------------------------------------------------- 1 | name: Node.js 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | lint: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - uses: actions/setup-node@v2 17 | with: 18 | node-version: "14.x" 19 | cache: "npm" 20 | 21 | - name: Install dependencies 22 | run: npm install 23 | 24 | - name: Check linting 25 | run: npm run lint 26 | 27 | test: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v2 31 | 32 | - uses: actions/setup-node@v2 33 | with: 34 | node-version: "14.x" 35 | cache: "npm" 36 | 37 | - name: Install dependencies 38 | run: npm install 39 | 40 | - name: Run tests 41 | run: npm test 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Yuki Akiyama 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # electrum-client-js 2 | 3 | JavaScript implementation of [Electrum Protocol] Client. 4 | 5 | This is a library that can communicate with the [ElectrumX Server] 6 | on `tcp`, `ssl`, `ws` and `wss` protocols. 7 | 8 | Works in node.js and browser. 9 | 10 | Implements methods described in [Electrum Protocol methods] documentation. 11 | 12 | Subscriptions and notifications are also supported, please see [example](example/subscribe.js). 13 | 14 | ## Continuous Integration 15 | 16 | Latest build status: 17 | [![CI](https://github.com/keep-network/electrum-client-js/actions/workflows/node.yml/badge.svg?branch=main)](https://github.com/keep-network/electrum-client-js/actions/workflows/node.yml) 18 | 19 | ## Install 20 | 21 | ``` 22 | npm install --save @keep-network/electrum-client-js 23 | ``` 24 | 25 | ## Usage 26 | 27 | ```js 28 | const ElectrumClient = require('@keep-network/electrum-client-js') 29 | 30 | async function main() { 31 | const client = new ElectrumClient( 32 | 'electrum.bitaroo.net', 33 | 50002, 34 | 'ssl' 35 | ) 36 | 37 | try { 38 | await client.connect( 39 | 'electrum-client-js', // optional client name 40 | '1.4.2' // optional protocol version 41 | ) 42 | 43 | const header = await client.blockchain_headers_subscribe() 44 | console.log('Current header:', header) 45 | 46 | await client.close() 47 | } catch (err) { 48 | console.error(err) 49 | } 50 | } 51 | 52 | main() 53 | ``` 54 | See more [examples](example/). 55 | 56 | 57 | [Electrum Protocol]: https://electrumx.readthedocs.io/en/latest/protocol.html 58 | [Electrum Protocol methods]: https://electrumx.readthedocs.io/en/latest/protocol-methods.html 59 | [ElectrumX Server]: https://electrumx.readthedocs.io/en/latest/ 60 | -------------------------------------------------------------------------------- /example/bitcoin.js: -------------------------------------------------------------------------------- 1 | const ElectrumClient = require('..') 2 | 3 | const config = { 4 | host: 'electrum.bitaroo.net', 5 | port: 50002, 6 | protocol: 'ssl', 7 | } 8 | 9 | const main = async () => { 10 | console.log('Connecting...') 11 | const client = new ElectrumClient(config.host, config.port, config.protocol) 12 | 13 | await client.connect() 14 | 15 | try { 16 | const ver = await client.server_version('electrum-client-js', '1.4') 17 | console.log('Negotiated version:', ver) 18 | 19 | const balance = await client.blockchain_scripthash_getBalance('740485f380ff6379d11ef6fe7d7cdd68aea7f8bd0d953d9fdf3531fb7d531833') 20 | console.log('Balance:', balance) 21 | 22 | const unspent = await client.blockchain_scripthash_listunspent('740485f380ff6379d11ef6fe7d7cdd68aea7f8bd0d953d9fdf3531fb7d531833') 23 | console.log('Unspent:', unspent) 24 | } catch (e) { 25 | console.error(e) 26 | } 27 | 28 | await client.close() 29 | } 30 | 31 | main().catch(console.error) 32 | -------------------------------------------------------------------------------- /example/example.js: -------------------------------------------------------------------------------- 1 | const ElectrumClient = require('..') 2 | 3 | async function main() { 4 | const client = new ElectrumClient( 5 | 'electrum.bitaroo.net', 6 | 50002, 7 | 'ssl' 8 | ) 9 | 10 | try { 11 | await client.connect( 12 | 'electrum-client-js', // optional client name 13 | '1.4.2' // optional protocol version 14 | ) 15 | 16 | const header = await client.blockchain_headers_subscribe() 17 | console.log('Current header:', header) 18 | 19 | await client.close() 20 | } catch (err) { 21 | console.error(err) 22 | } 23 | } 24 | 25 | main() 26 | -------------------------------------------------------------------------------- /example/raii_client_transaction_send.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const ElectrumClient = require('..') 3 | 4 | const createRaiiClient = (port, host, protocol, options) => { 5 | return (params, promise) => { 6 | const name = params.join(':') 7 | const client = new ElectrumClient(port, host, protocol, options) 8 | 9 | console.time(name) 10 | 11 | return client.connect() 12 | .then(() => { 13 | return promise(client) 14 | }).catch((e) => { 15 | client.close() 16 | console.timeEnd(name) 17 | throw e 18 | }).then((res) => { 19 | client.close() 20 | console.timeEnd(name) 21 | return res 22 | }) 23 | } 24 | } 25 | 26 | const main = async (hex) => { 27 | const hosts = ['electrum.bitaroo.net', 'electrumx.tamami-foundation.org'] 28 | 29 | const host = hosts[Math.floor(Math.random() * hosts.length)] 30 | 31 | const connect = createRaiiClient(host, 50001, 'tcp') 32 | 33 | await connect(['blockchain_transaction_broadcast', hex], async (client) => { 34 | const ver = await client.server_version('2.7.11', '1.4') 35 | console.log('Version:', ver) 36 | 37 | const result = await client.blockchain_transaction_broadcast(hex) 38 | console.log('Result:', result) 39 | }) 40 | } 41 | 42 | const getopt = () => { 43 | return process.argv.slice(2)[0] 44 | } 45 | 46 | main(getopt()).catch(console.log) 47 | -------------------------------------------------------------------------------- /example/scripthash.js: -------------------------------------------------------------------------------- 1 | const ElectrumClient = require('..') 2 | 3 | const main = async () => { 4 | const ecl = new ElectrumClient('electrum.bitaroo.net', 50002, 'tls', true) 5 | await ecl.connect() 6 | try { 7 | const ver = await ecl.server_version('3.0.5', '1.4') 8 | console.log(ver) 9 | const balance = await ecl.blockchain_scripthash_getBalance('676ca8550e249787290b987e12cebdb2e9b26d88c003d836ffb1cb03ffcbea7c') 10 | console.log(balance) 11 | const unspent = await ecl.blockchain_scripthash_listunspent('676ca8550e249787290b987e12cebdb2e9b26d88c003d836ffb1cb03ffcbea7c') 12 | console.log(unspent) 13 | const history = await ecl.blockchain_scripthash_getHistory('676ca8550e249787290b987e12cebdb2e9b26d88c003d836ffb1cb03ffcbea7c') 14 | console.log(history) 15 | const mempool = await ecl.blockchain_scripthash_getMempool('676ca8550e249787290b987e12cebdb2e9b26d88c003d836ffb1cb03ffcbea7c') 16 | console.log(mempool) 17 | } catch (e) { 18 | console.log(e) 19 | } 20 | await ecl.close() 21 | } 22 | main().catch(console.log) 23 | -------------------------------------------------------------------------------- /example/subscribe.js: -------------------------------------------------------------------------------- 1 | const ElectrumClient = require('..') 2 | const sleep = (ms) => new Promise((resolve, _) => setTimeout(() => resolve(), ms)) 3 | 4 | const main = async () => { 5 | try { 6 | const ecl = new ElectrumClient('electrum.bitaroo.net', 50002, 'tls') 7 | 8 | ecl.events.on('blockchain.headers.subscribe', console.log) 9 | ecl.events.on('blockchain.scripthash.subscribe', console.log) 10 | 11 | await ecl.connect() 12 | 13 | 14 | const header = await ecl.blockchain_headers_subscribe() 15 | console.log('Latest header:', header) 16 | 17 | const scripthashStatus = await ecl.blockchain_scripthash_subscribe('f3aa57a41424146327e5c88c25db8953dd16c6ab6273cdb74a4404ed4d0f5714') 18 | console.log('Latest scripthash status:', scripthashStatus) 19 | 20 | console.log('Waiting for notifications...') 21 | 22 | while (true) { 23 | // Keep connection alive. 24 | await sleep(1000) 25 | await ecl.server_ping() 26 | } 27 | } catch (e) { 28 | console.error(e) 29 | } 30 | } 31 | 32 | main() 33 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const ElectrumClient = require('./src/electrum/client') 2 | 3 | module.exports = ElectrumClient 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@keep-network/electrum-client-js", 3 | "version": "0.1.1", 4 | "description": "Electrum protocol client for node.js and browser", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "mocha --timeout 10000", 8 | "lint": "eslint .", 9 | "lint:fix": "eslint --fix ." 10 | }, 11 | "dependencies": { 12 | "websocket": "^1.0.29" 13 | }, 14 | "devDependencies": { 15 | "chai": "^4.2.0", 16 | "electrum-host-parse": "^0.1.1", 17 | "eslint": "^6.1.0", 18 | "eslint-config-google": "^0.13.0", 19 | "fs": "0.0.1-security", 20 | "mocha": "^6.2.3" 21 | }, 22 | "homepage": "https://github.com/keep-network/electrum-client-js#readme", 23 | "repository": { 24 | "type": "git", 25 | "url": "git://github.com/keep-network/electrum-client-js.git" 26 | }, 27 | "bugs": { 28 | "url": "https://github.com/keep-network/electrum-client-js/issues" 29 | }, 30 | "keywords": [ 31 | "client", 32 | "electrum", 33 | "bitcoin" 34 | ], 35 | "engines": { 36 | "node": ">=6" 37 | }, 38 | "author": "Yuki Akiyama", 39 | "license": "MIT" 40 | } 41 | -------------------------------------------------------------------------------- /src/electrum/client.js: -------------------------------------------------------------------------------- 1 | const SocketClient = require('../socket/socket_client') 2 | const util = require('./util') 3 | 4 | const keepAliveInterval = 450 * 1000 // 7.5 minutes as recommended by ElectrumX SESSION_TIMEOUT 5 | 6 | class ElectrumClient extends SocketClient { 7 | constructor(host, port, protocol, options) { 8 | super(host, port, protocol, options) 9 | } 10 | 11 | async connect(clientName, electrumProtocolVersion, persistencePolicy = {maxRetry: 10, callback: null}) { 12 | this.persistencePolicy = persistencePolicy 13 | 14 | this.timeLastCall = 0 15 | 16 | if (this.status === 0) { 17 | try { 18 | // Connect to Electrum Server. 19 | await super.connect() 20 | 21 | // Get banner. 22 | const banner = await this.server_banner() 23 | console.log(banner) 24 | 25 | // Negotiate protocol version. 26 | if (clientName && electrumProtocolVersion) { 27 | const version = await this.server_version(clientName, electrumProtocolVersion) 28 | console.log(`Negotiated version: [${version}]`) 29 | } 30 | } catch (err) { 31 | throw new Error(`failed to connect to electrum server: [${err}]`) 32 | } 33 | 34 | this.keepAlive() 35 | } 36 | } 37 | 38 | async request(method, params) { 39 | if (this.status === 0) { 40 | throw new Error('connection not established') 41 | } 42 | 43 | this.timeLastCall = new Date().getTime() 44 | 45 | const response = new Promise((resolve, reject) => { 46 | const id = ++this.id 47 | 48 | const content = util.makeRequest(method, params, id) 49 | 50 | this.callback_message_queue[id] = util.createPromiseResult(resolve, reject) 51 | 52 | this.client.send(content + '\n') 53 | }) 54 | 55 | return await response 56 | } 57 | 58 | /** 59 | * Ping the server to ensure it is responding, and to keep the session alive. 60 | * The server may disconnect clients that have sent no requests for roughly 10 61 | * minutes. It sends a ping request every 2 minutes. If the request fails it 62 | * logs an error and closes the connection. 63 | */ 64 | async keepAlive() { 65 | if (this.status !== 0) { 66 | this.keepAliveHandle = setInterval( 67 | async (client) => { 68 | if (this.timeLastCall !== 0 && 69 | new Date().getTime() > this.timeLastCall + (keepAliveInterval / 2)) { 70 | await client.server_ping() 71 | .catch((err) => { 72 | console.error(`ping to server failed: [${err}]`) 73 | client.close() // TODO: we should reconnect 74 | }) 75 | } 76 | }, 77 | keepAliveInterval, 78 | this // pass this context as an argument to function 79 | ) 80 | } 81 | } 82 | 83 | close() { 84 | return super.close() 85 | } 86 | 87 | onClose() { 88 | super.onClose() 89 | 90 | const list = [ 91 | 'server.peers.subscribe', 92 | 'blockchain.numblocks.subscribe', 93 | 'blockchain.headers.subscribe', 94 | 'blockchain.address.subscribe', 95 | ] 96 | 97 | // TODO: We should probably leave listeners if the have persistency policy. 98 | list.forEach((event) => this.events.removeAllListeners(event)) 99 | 100 | // Stop keep alive. 101 | clearInterval(this.keepAliveHandle) 102 | 103 | // TODO: Refactor persistency 104 | // if (this.persistencePolicy) { 105 | // if (this.persistencePolicy.maxRetry > 0) { 106 | // this.reconnect(); 107 | // this.persistencePolicy.maxRetry -= 1; 108 | // } else if (this.persistencePolicy.callback != null) { 109 | // this.persistencePolicy.callback(); 110 | // } 111 | // } 112 | } 113 | 114 | // TODO: Refactor persistency 115 | // reconnect() { 116 | // return this.initElectrum(this.electrumConfig); 117 | // } 118 | 119 | // ElectrumX API 120 | // 121 | // Documentation: 122 | // https://electrumx.readthedocs.io/en/latest/protocol-methods.html 123 | // 124 | server_version(client_name, protocol_version) { 125 | return this.request('server.version', [client_name, protocol_version]) 126 | } 127 | server_banner() { 128 | return this.request('server.banner', []) 129 | } 130 | server_ping() { 131 | return this.request('server.ping', []) 132 | } 133 | server_addPeer(features) { 134 | return this.request('server.add_peer', [features]) 135 | } 136 | server_donation_address() { 137 | return this.request('server.donation_address', []) 138 | } 139 | server_features() { 140 | return this.request('server.features', []) 141 | } 142 | server_peers_subscribe() { 143 | return this.request('server.peers.subscribe', []) 144 | } 145 | blockchain_address_getProof(address) { 146 | return this.request('blockchain.address.get_proof', [address]) 147 | } 148 | blockchain_scripthash_getBalance(scripthash) { 149 | return this.request('blockchain.scripthash.get_balance', [scripthash]) 150 | } 151 | blockchain_scripthash_getHistory(scripthash) { 152 | return this.request('blockchain.scripthash.get_history', [scripthash]) 153 | } 154 | blockchain_scripthash_getMempool(scripthash) { 155 | return this.request('blockchain.scripthash.get_mempool', [scripthash]) 156 | } 157 | blockchain_scripthash_listunspent(scripthash) { 158 | return this.request('blockchain.scripthash.listunspent', [scripthash]) 159 | } 160 | blockchain_scripthash_subscribe(scripthash) { 161 | return this.request('blockchain.scripthash.subscribe', [scripthash]) 162 | } 163 | blockchain_scripthash_unsubscribe(scripthash) { 164 | return this.request('blockchain.scripthash.unsubscribe', [scripthash]) 165 | } 166 | blockchain_block_header(height, cpHeight = 0) { 167 | return this.request('blockchain.block.header', [height, cpHeight]) 168 | } 169 | blockchain_block_headers(startHeight, count, cpHeight = 0) { 170 | return this.request('blockchain.block.headers', [startHeight, count, cpHeight]) 171 | } 172 | blockchainEstimatefee(number) { 173 | return this.request('blockchain.estimatefee', [number]) 174 | } 175 | blockchain_headers_subscribe() { 176 | return this.request('blockchain.headers.subscribe', []) 177 | } 178 | blockchain_relayfee() { 179 | return this.request('blockchain.relayfee', []) 180 | } 181 | blockchain_transaction_broadcast(rawtx) { 182 | return this.request('blockchain.transaction.broadcast', [rawtx]) 183 | } 184 | blockchain_transaction_get(tx_hash, verbose) { 185 | return this.request('blockchain.transaction.get', [tx_hash, verbose ? verbose : false]) 186 | } 187 | blockchain_transaction_getMerkle(tx_hash, height) { 188 | return this.request('blockchain.transaction.get_merkle', [tx_hash, height]) 189 | } 190 | mempool_getFeeHistogram() { 191 | return this.request('mempool.get_fee_histogram', []) 192 | } 193 | // --------------------------------- 194 | // protocol 1.1 deprecated method 195 | // --------------------------------- 196 | blockchain_utxo_getAddress(tx_hash, index) { 197 | return this.request('blockchain.utxo.get_address', [tx_hash, index]) 198 | } 199 | blockchain_numblocks_subscribe() { 200 | return this.request('blockchain.numblocks.subscribe', []) 201 | } 202 | // --------------------------------- 203 | // protocol 1.2 deprecated method 204 | // --------------------------------- 205 | blockchain_block_getChunk(index) { 206 | return this.request('blockchain.block.get_chunk', [index]) 207 | } 208 | blockchain_address_getBalance(address) { 209 | return this.request('blockchain.address.get_balance', [address]) 210 | } 211 | blockchain_address_getHistory(address) { 212 | return this.request('blockchain.address.get_history', [address]) 213 | } 214 | blockchain_address_getMempool(address) { 215 | return this.request('blockchain.address.get_mempool', [address]) 216 | } 217 | blockchain_address_listunspent(address) { 218 | return this.request('blockchain.address.listunspent', [address]) 219 | } 220 | blockchain_address_subscribe(address) { 221 | return this.request('blockchain.address.subscribe', [address]) 222 | } 223 | } 224 | 225 | module.exports = ElectrumClient 226 | -------------------------------------------------------------------------------- /src/electrum/util.js: -------------------------------------------------------------------------------- 1 | function makeRequest(method, params, id) { 2 | return JSON.stringify({ 3 | jsonrpc: '2.0', 4 | method: method, 5 | params: params, 6 | id: id, 7 | }) 8 | } 9 | 10 | function createPromiseResult(resolve, reject) { 11 | return (err, result) => { 12 | if (err) reject(err) 13 | else resolve(result) 14 | } 15 | }; 16 | 17 | 18 | module.exports = { 19 | makeRequest, 20 | createPromiseResult, 21 | } 22 | -------------------------------------------------------------------------------- /src/socket/socket_client.js: -------------------------------------------------------------------------------- 1 | 2 | const EventEmitter = require('events').EventEmitter 3 | const util = require('./util') 4 | 5 | const TCPSocketClient = require('./socket_client_tcp') 6 | const WebSocketClient = require('./socket_client_ws') 7 | 8 | class SocketClient { 9 | constructor(host, port, protocol, options) { 10 | this.id = 0 11 | this.host = host 12 | this.port = port 13 | this.protocol = protocol 14 | this.options = options 15 | this.status = 0 16 | this.callback_message_queue = {} 17 | this.events = new EventEmitter() 18 | this.mp = new util.MessageParser((body, n) => { 19 | this.onMessage(body, n) 20 | }) 21 | 22 | switch (protocol) { 23 | case 'tcp': 24 | case 'tls': 25 | case 'ssl': 26 | this.client = new TCPSocketClient(this, host, port, protocol, options) 27 | break 28 | case 'ws': 29 | case 'wss': 30 | this.client = new WebSocketClient(this, host, port, protocol, options) 31 | break 32 | default: 33 | throw new Error(`invalid protocol: [${protocol}]`) 34 | } 35 | } 36 | 37 | async connect() { 38 | if (this.status === 1) { 39 | return Promise.resolve() 40 | } 41 | 42 | this.status = 1 43 | return this.client.connect() 44 | } 45 | 46 | close() { 47 | if (this.status === 0) { 48 | return 49 | } 50 | 51 | this.client.close() 52 | 53 | this.status = 0 54 | } 55 | 56 | response(msg) { 57 | const callback = this.callback_message_queue[msg.id] 58 | 59 | if (callback) { 60 | delete this.callback_message_queue[msg.id] 61 | if (msg.error) { 62 | callback(msg.error.message) 63 | } else { 64 | callback(null, msg.result) 65 | } 66 | } else { 67 | console.log('Can\'t get callback') 68 | } 69 | } 70 | 71 | onMessage(body, n) { 72 | const msg = JSON.parse(body) 73 | if (msg instanceof Array) { 74 | ; // don't support batch request 75 | } else { 76 | if (msg.id !== void 0) { 77 | this.response(msg) 78 | } else { 79 | this.subscribe.emit(msg.method, msg.params) 80 | } 81 | } 82 | } 83 | 84 | onConnect() { 85 | } 86 | 87 | onClose(event) { 88 | this.status = 0 89 | Object.keys(this.callback_message_queue).forEach((key) => { 90 | this.callback_message_queue[key](new Error('close connect')) 91 | delete this.callback_message_queue[key] 92 | }) 93 | } 94 | 95 | onRecv(chunk) { 96 | this.mp.run(chunk) 97 | } 98 | 99 | onEnd(error) { 100 | console.log(`onEnd: [${error}]`) 101 | } 102 | 103 | onError(error) { 104 | console.log(`onError: [${error}]`) 105 | } 106 | } 107 | 108 | module.exports = SocketClient 109 | -------------------------------------------------------------------------------- /src/socket/socket_client_tcp.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const net = require('net') 3 | 4 | const TIMEOUT = 10000 5 | 6 | class SocketClient { 7 | constructor(self, host, port, protocol, options) { 8 | let conn 9 | switch (protocol) { 10 | case 'tcp': 11 | conn = new net.Socket() 12 | break 13 | case 'tls': 14 | case 'ssl': 15 | let tls 16 | try { 17 | tls = require('tls') 18 | } catch (e) { 19 | throw new Error('tls package could not be loaded') 20 | } 21 | conn = new tls.TLSSocket(options) 22 | break 23 | default: 24 | throw new Error('not supported protocol', protocol) 25 | } 26 | this.host = host 27 | this.port = port 28 | initialize(self, conn) 29 | this.client = conn 30 | } 31 | 32 | async connect() { 33 | const client = this.client 34 | 35 | return new Promise((resolve, reject) => { 36 | const errorHandler = (e) => reject(e) 37 | client.connect(this.port, this.host, () => { 38 | client.removeListener('error', errorHandler) 39 | resolve() 40 | }) 41 | client.on('error', errorHandler) 42 | }) 43 | } 44 | 45 | async close() { 46 | this.client.end() 47 | this.client.destroy() 48 | } 49 | 50 | send(data) { 51 | this.client.write(data) 52 | } 53 | } 54 | 55 | function initialize(self, conn) { 56 | conn.setTimeout(TIMEOUT) 57 | conn.setEncoding('utf8') 58 | conn.setKeepAlive(true, 0) 59 | conn.setNoDelay(true) 60 | 61 | conn.on('connect', () => { 62 | conn.setTimeout(0) 63 | self.onConnect() 64 | }) 65 | 66 | conn.on('close', (e) => { 67 | self.onClose(e) 68 | }) 69 | 70 | conn.on('timeout', () => { 71 | const e = new Error('ETIMEDOUT') 72 | e.errorno = 'ETIMEDOUT' 73 | e.code = 'ETIMEDOUT' 74 | e.connect = false 75 | conn.emit('error', e) 76 | }) 77 | 78 | conn.on('data', (chunk) => { 79 | conn.setTimeout(0) 80 | self.onRecv(chunk) 81 | }) 82 | 83 | conn.on('end', (e) => { 84 | conn.setTimeout(0) 85 | self.onEnd(e) 86 | }) 87 | 88 | conn.on('error', (e) => { 89 | self.onError(e) 90 | }) 91 | } 92 | 93 | module.exports = SocketClient 94 | -------------------------------------------------------------------------------- /src/socket/socket_client_ws.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const W3CWebSocket = require('websocket').w3cwebsocket 3 | 4 | class WebSocketClient { 5 | constructor(self, host, port, protocol, options) { 6 | this.self = self 7 | this.host = host 8 | this.port = port 9 | this.protocol = protocol 10 | this.options = options 11 | this.client = null 12 | } 13 | 14 | async connect() { 15 | const url = `${this.protocol}://${this.host}:${this.port}` 16 | 17 | // TODO: Add docs 18 | // https://github.com/theturtle32/WebSocket-Node/blob/master/docs/W3CWebSocket.md#constructor 19 | const client = new W3CWebSocket( 20 | url, 21 | undefined, 22 | undefined, 23 | undefined, 24 | this.options 25 | ) 26 | 27 | this.client = client 28 | 29 | return new Promise((resolve, reject) => { 30 | client.onerror = (error) => { 31 | this.self.onError(error) 32 | } 33 | 34 | client.onclose = (event) => { 35 | this.self.onClose(event) 36 | reject(new Error(`websocket connection closed: code: [${event.code}], reason: [${event.reason}]`)) 37 | } 38 | 39 | client.onmessage = (message) => { 40 | this.self.onMessage(message.data) 41 | } 42 | 43 | client.onopen = () => { 44 | if (client.readyState === client.OPEN) { 45 | this.self.onConnect() 46 | resolve() 47 | } 48 | } 49 | }) 50 | } 51 | 52 | async close() { 53 | this.client.close(1000, 'close connection') 54 | } 55 | 56 | // string 57 | send(data) { 58 | this.client.send(data) 59 | } 60 | } 61 | 62 | module.exports = WebSocketClient 63 | -------------------------------------------------------------------------------- /src/socket/util.js: -------------------------------------------------------------------------------- 1 | function createRecursiveParser(max_depth, delimiter) { 2 | const MAX_DEPTH = max_depth 3 | const DELIMITER = delimiter 4 | 5 | const recursiveParser = (n, buffer, callback) => { 6 | if (buffer.length === 0) { 7 | return {code: 0, buffer: buffer} 8 | } 9 | if (n > MAX_DEPTH) { 10 | return {code: 1, buffer: buffer} 11 | } 12 | const xs = buffer.split(DELIMITER) 13 | if (xs.length === 1) { 14 | return {code: 0, buffer: buffer} 15 | } 16 | callback(xs.shift(), n) 17 | return recursiveParser(n + 1, xs.join(DELIMITER), callback) 18 | } 19 | return recursiveParser 20 | }; 21 | 22 | 23 | class MessageParser { 24 | constructor(callback) { 25 | this.buffer = '' 26 | this.callback = callback 27 | this.recursiveParser = createRecursiveParser(20, '\n') 28 | } 29 | 30 | run(chunk) { 31 | this.buffer += chunk 32 | while (true) { 33 | const res = this.recursiveParser(0, this.buffer, this.callback) 34 | this.buffer = res.buffer 35 | if (res.code === 0) { 36 | break 37 | } 38 | } 39 | } 40 | } 41 | 42 | exports.MessageParser = MessageParser 43 | -------------------------------------------------------------------------------- /test/config.js: -------------------------------------------------------------------------------- 1 | const servers = { 2 | tcp: { 3 | protocol: 'tcp', port: '50001', host: 'electrum.bitaroo.net', 4 | }, 5 | ssl: { 6 | protocol: 'ssl', port: '50002', host: 'electrum.bitaroo.net', 7 | }, 8 | ws: { 9 | protocol: 'ws', port: '50003', host: 'electrumx-server.tbtc.svc.cluster.local', 10 | }, 11 | wss: { 12 | protocol: 'wss', port: '50004', host: 'electrumx-server.tbtc.svc.cluster.local', 13 | }, 14 | } 15 | 16 | const serversArray = [ 17 | servers.tcp, 18 | servers.ssl, 19 | // FIXME: WebSocket is commented out for CI, until we find public servers for this protocol. 20 | // electrumServers.ws, 21 | // electrumServers.wss, 22 | ] 23 | 24 | module.exports = { 25 | servers, 26 | serversArray, 27 | } 28 | -------------------------------------------------------------------------------- /test/integration_test.js: -------------------------------------------------------------------------------- 1 | const ElectrumClient = require('../.') 2 | 3 | const chai = require('chai') 4 | const assert = chai.assert 5 | 6 | const fs = require('fs') 7 | 8 | const config = require('./config') 9 | 10 | describe('ElectrumClient', async () => { 11 | let txData 12 | 13 | before(async () => { 14 | txData = JSON.parse(await fs.readFileSync('./test/tx.json', 'utf8')) 15 | }) 16 | 17 | context('when connected', async () => { 18 | config.serversArray.forEach((server) => { 19 | describe(`for ${server.protocol} protocol`, async () => { 20 | let client 21 | 22 | before(async () => { 23 | client = new ElectrumClient( 24 | server.host, 25 | server.port, 26 | server.protocol, 27 | server.options 28 | ) 29 | 30 | await client 31 | .connect('test_client' + server.protocol, '1.4.2') 32 | .catch((err) => { 33 | console.error( 34 | `failed to connect with config [${JSON.stringify( 35 | server 36 | )}]: [${err}]` 37 | ) 38 | }) 39 | }) 40 | 41 | after(async () => { 42 | await client.close() 43 | }) 44 | 45 | it('request returns result', async () => { 46 | const expectedResult = txData.hex 47 | const result = await client.blockchain_transaction_get(txData.hash) 48 | 49 | assert.equal(result, expectedResult, 'unexpected result') 50 | }) 51 | }) 52 | }) 53 | }) 54 | 55 | context('when not connected', async () => { 56 | before(async () => { 57 | const server = config.servers.tcp 58 | 59 | client = new ElectrumClient( 60 | server.host, 61 | server.port, 62 | server.protocol, 63 | server.options 64 | ) 65 | }) 66 | 67 | it('request throws error', async () => { 68 | await client.blockchain_transaction_get(txData.hash).then( 69 | (value) => { 70 | // onFulfilled 71 | assert.fail('not failed as expected') 72 | }, 73 | (reason) => { 74 | // onRejected 75 | assert.include(reason.toString(), `connection not established`) 76 | } 77 | ) 78 | }) 79 | }) 80 | // TODO: Add tests 81 | }) 82 | -------------------------------------------------------------------------------- /test/tx.json: -------------------------------------------------------------------------------- 1 | { 2 | "hash": "d60033c5cf5c199208a9c656a29967810c4e428c22efb492fdd816e6a0a1e548", 3 | "hex": "010000000001011746bd867400f3494b8f44c24b83e1aa58c4f0ff25b4a61cffeffd4bc0f9ba300000000000ffffffff024897070000000000220020a4333e5612ab1a1043b25755c89b16d55184a42f81799e623e6bc39db8539c180000000000000000166a14edb1b5c2f39af0fec151732585b1049b07895211024730440220276e0ec78028582054d86614c65bc4bf85ff5710b9d3a248ca28dd311eb2fa6802202ec950dd2a8c9435ff2d400cc45d7a4854ae085f49e05cc3f503834546d410de012103732783eef3af7e04d3af444430a629b16a9261e4025f52bf4d6d026299c37c7400000000" 4 | } 5 | --------------------------------------------------------------------------------