diff --git a/.gitignore b/.gitignore index a945acb..09f14ca 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ free-bch-api-main.sh free-bch-api-test.sh start-local-mainnet.sh.save start-local-testnet.sh.save +temp.sh coverage start-my-infra diff --git a/README.md b/README.md index f0376e1..47ac800 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Both bch-api and bch-js are part of the - [API Documentation](https://fullstack.cash/documentation) - [Example Code](https://fullstack.cash/examples) -Have questions? Need help? Join our community support +Have questions? Need help? Join our community support: [Telegram channel](https://t.me/bch_js_toolkit) ## Features diff --git a/package-lock.json b/package-lock.json index 0627e17..9b2c98f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -872,6 +872,14 @@ "resolved": "https://registry.npmjs.org/@types/tus-js-client/-/tus-js-client-1.8.0.tgz", "integrity": "sha512-lWxu5+6qfyfwsW99GzUeJ9y9JeOSSLduKxgYMvaYM7sGTDKZsrIIHTUbHI2P016xhXtu9NxmUM3GrB4i14ie4A==" }, + "@types/ws": { + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.2.6.tgz", + "integrity": "sha512-Q07IrQUSNpr+cXU4E4LtkSIBPie5GLZyyMC1QtQYRLWz701+XcoVygGUZgvLqElq1nU4ICldMYPnexlBsg3dqQ==", + "requires": { + "@types/node": "*" + } + }, "@uppy/companion-client": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/@uppy/companion-client/-/companion-client-1.4.5.tgz", @@ -1402,6 +1410,21 @@ "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==" }, + "async-mutex": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.2.4.tgz", + "integrity": "sha512-fcQKOXUKMQc57JlmjBCHtkKNrfGpHyR7vu18RfuLfeTAf4hK9PgOadPR5cDrBQ682zasrLUhJFe7EKAHJOduDg==", + "requires": { + "tslib": "^2.0.0" + }, + "dependencies": { + "tslib": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.1.tgz", + "integrity": "sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ==" + } + } + }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -4424,11 +4447,22 @@ "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" }, "electrum-cash": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/electrum-cash/-/electrum-cash-1.0.1.tgz", - "integrity": "sha512-snMgRt6JzsHdCdf+Un1rLj4RXjje1WuFVTx3Xx+6mitEibYalmD+x0ts146VF4Ki3+42DQKXtQXl0bzCAY7Jtw==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/electrum-cash/-/electrum-cash-2.0.2.tgz", + "integrity": "sha512-b/mLctjwnmfp4cuvJbl9wgCxf4GWWiKPYZXEtekoyGPviZwOe2bnMC7LGOxAMpOW0YyU4W12kK6rbqskQc6EkA==", "requires": { - "debug": "^4.1.1" + "@types/ws": "^7.2.6", + "async-mutex": "^0.2.4", + "debug": "^4.1.1", + "isomorphic-ws": "^4.0.1", + "ws": "^7.3.1" + }, + "dependencies": { + "ws": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.3.1.tgz", + "integrity": "sha512-D3RuNkynyHmEJIpD2qrgVkc9DQ23OrN/moAwZX4L8DfvszsJxpjQuUq3LMx6HoYji9fbIOBY18XWBsAux1ZZUA==" + } } }, "elliptic": { @@ -7551,6 +7585,11 @@ "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", "dev": true }, + "isomorphic-ws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz", + "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==" + }, "isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", diff --git a/package.json b/package.json index cc19cff..aa87993 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "cors": "^2.8.3", "debug": "~4.1.1", "dotenv": "^8.0.0", - "electrum-cash": "^1.0.1", + "electrum-cash": "^2.0.2", "express": "^4.15.5", "express-basic-auth": "^1.1.3", "express-rate-limit": "^5.0.0", diff --git a/src/routes/v3/electrumx.js b/src/routes/v3/electrumx.js index 91949a8..5dbb4d0 100644 --- a/src/routes/v3/electrumx.js +++ b/src/routes/v3/electrumx.js @@ -10,7 +10,7 @@ const axios = require('axios') const util = require('util') const bitcore = require('bitcore-lib-cash') -const ElectrumCash = require('electrum-cash').Client +const ElectrumCash = require('electrum-cash').ElectrumClient // const ElectrumCash = require('/home/trout/work/personal/electrum-cash/electrum.js').Client // eslint-disable-line const wlogger = require('../../util/winston-logging') @@ -50,8 +50,10 @@ class Electrum { _this.router.get('/', _this.root) _this.router.get('/utxos/:address', _this.getUtxos) _this.router.post('/utxos', _this.utxosBulk) - _this.router.get('/tx/data/:address', _this.getTransactionDetails) + _this.router.get('/tx/data/:txid', _this.getTransactionDetails) _this.router.post('/tx/data', _this.transactionDetailsBulk) + _this.router.get('/block/headers/:height', _this.getBlockHeaders) + _this.router.post('/block/headers', _this.blockHeadersBulk) _this.router.get('/balance/:address', _this.getBalance) _this.router.post('/balance', _this.balanceBulk) _this.router.get('/transactions/:address', _this.getTransactions) @@ -489,6 +491,175 @@ class Electrum { } } + // Returns a promise that resolves to block header data for a block height. + // Expects input to be a height number, and input validation to have already + // been done by parent, calling function. + async _blockHeadersFromElectrum (height, count = 1) { + try { + if (!_this.isReady) { + throw new Error( + 'ElectrumX server connection is not ready. Call await connectToServer() first.' + ) + } + + // Query the block header from the ElectrumX server. + const electrumResponse = await _this.electrumx.request('blockchain.block.headers', height, count) + // console.log( + // `electrumResponse: ${JSON.stringify(electrumResponse, null, 2)}` + // ) + + const HEADER_SIZE = 80 * 2 + + if (!(electrumResponse instanceof Error)) { + const headers = electrumResponse.hex.match(new RegExp(`.{1,${HEADER_SIZE}}`, 'g')) + return headers + } + + return electrumResponse + } catch (err) { + // console.log('err: ', err) + + // Write out error to error log. + wlogger.error( + 'Error in elecrumx.js/_blockHeaderFromElectrum(): ', + err + ) + throw err + } + } + + /** + * @api {get} /electrumx/block/headers/{height} Get `count` block headers starting at a height + * @apiName Block header data for a `count` blocks starting at a block height + * @apiGroup ElectrumX / Fulcrum + * @apiDescription Returns an array with block headers starting at the block height + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v3/electrumx/block/header/42?count=2" -H "accept: application/json" + * + */ + // GET handler for single block headers + async getBlockHeaders (req, res, next) { + try { + const height = Number(req.params.height) + const count = req.query.count === undefined ? 1 : Number(req.query.count) + + // Reject if height is not a number + if (Number.isNaN(height) || height < 0) { + res.status(400) + return res.json({ + success: false, + error: 'height must be a positive number' + }) + } + + // Reject if height is not a number + if (Number.isNaN(count) || count < 0) { + res.status(400) + return res.json({ + success: false, + error: 'count must be a positive number' + }) + } + + wlogger.debug( + 'Executing electrumx/getBlockHeaders with this height: ', + height + ) + + // Get data from ElectrumX server. + const electrumResponse = await _this._blockHeadersFromElectrum(height, count) + // console.log(`_transactionDetailsFromElectrum(): ${JSON.stringify(electrumResponse, null, 2)}`) + + // Pass the error message if ElectrumX reports an error. + if (electrumResponse instanceof Error) { + res.status(400) + return res.json({ + success: false, + error: electrumResponse.message + }) + } + + res.status(200) + return res.json({ + success: true, + headers: electrumResponse + }) + } catch (err) { + // Write out error to error log. + wlogger.error('Error in elecrumx.js/getBlockHeader().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {post} /electrumx/block/headers Get block headers for an array of height + count pairs + * @apiName Block headers for an array of height + count pairs + * @apiGroup ElectrumX / Fulcrum + * @apiDescription Returns an array of objects with blockheaders of an array of TXIDs. + * Limited to 20 items per request. + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v3/electrumx/block/headers" -H "accept: application/json" -H "Content-Type: application/json" -d '{"heights":[{ "height": 42, count: 2 }, { "height": 100, count: 5 }]}' + * + */ + // POST handler for bulk queries on block headers + async blockHeadersBulk (req, res, next) { + try { + const heights = req.body.heights + + // Reject if heights is not an array. + if (!Array.isArray(heights)) { + res.status(400) + return res.json({ + success: false, + error: 'heights needs to be an array. Use GET for single height.' + }) + } + + // Enforce array size rate limits + if (!_this.routeUtils.validateArraySize(req, heights)) { + res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 + return res.json({ + success: false, + error: 'Array too large.' + }) + } + + wlogger.debug( + 'Executing electrumx.js/blockHeadersBulk with these txids: ', + heights + ) + + // Loops through each address and creates an array of Promises, querying + // the Electrum server in parallel. + const transactions = heights.map(async (obj) => { + const headers = await _this._blockHeadersFromElectrum( + obj.height, + obj.count + ) + + return { headers } + }) + + // Wait for all parallel Electrum requests to return. + const result = await Promise.all(transactions) + + // Return the array of retrieved transaction details. + res.status(200) + return res.json({ + success: true, + headers: result + }) + } catch (err) { + wlogger.error('Error in electrumx.js/blockHeadersBulk().', err) + + return _this.errorHandler(err, res) + } + } + // Returns a promise that resolves to a balance for an address. Expects input // to be a cash address, and input validation to have already been done by // parent, calling function. diff --git a/test/v3/a01-electrumx.js b/test/v3/a01-electrumx.js index f477dd4..7492568 100644 --- a/test/v3/a01-electrumx.js +++ b/test/v3/a01-electrumx.js @@ -96,7 +96,7 @@ describe('#ElectrumX Router', () => { // A wrapper for stubbing with the Sinon sandbox. function stubMethodForUnitTests (obj, method, value) { - if (!process.env.TEST === 'unit') return false + if (process.env.TEST !== 'unit') return false electrumxRoute.isReady = true // Force flag. @@ -495,7 +495,7 @@ describe('#ElectrumX Router', () => { }) it('should throw 400 on array input', async () => { - req.params.address = [ + req.params.txid = [ '4db095f34d632a4daf942142c291f1f2abb5ba2e1ccac919d85bdc2f671fb251' ] @@ -666,6 +666,246 @@ describe('#ElectrumX Router', () => { }) }) + describe('#_blockHeadersFromElectrum', () => { + it('should return error object for invalid block height', async () => { + const height = -10 + + stubMethodForUnitTests( + electrumxRoute.electrumx, + 'request', + new Error('Invalid height') + ) + + const result = await electrumxRoute._blockHeadersFromElectrum(height, 2) + + assert.instanceOf(result, Error) + assert.include(result.message, 'Invalid height') + }) + + it('should return error object for invalid count', async () => { + const height = 42 + + stubMethodForUnitTests( + electrumxRoute.electrumx, + 'request', + new Error('Invalid count') + ) + + const result = await electrumxRoute._blockHeadersFromElectrum(height, -1) + + assert.instanceOf(result, Error) + assert.include(result.message, 'Invalid count') + }) + + it('should get block header for a single block height', async () => { + const height = 42 + + const mockedResponse = { count: 2, hex: mockData.blockHeaders.join(''), max: 2016 } + + stubMethodForUnitTests( + electrumxRoute.electrumx, + 'request', + mockedResponse + ) + + const result = await electrumxRoute._blockHeadersFromElectrum(height, 2) + + assert.isArray(result) + assert.deepEqual(result, mockData.blockHeaders) + }) + }) + + describe('#getBlockheaders', () => { + it('should throw 400 if height is not a number', async () => { + req.params.height = 'Hello' + + const result = await electrumxRoute.getBlockHeaders(req, res) + + expectRouteError(res, result, 'height must be a positive number') + }) + + it('should throw 400 if height is negative', async () => { + req.params.height = -42 + + const result = await electrumxRoute.getBlockHeaders(req, res) + + expectRouteError(res, result, 'height must be a positive number') + }) + + it('should throw 400 if count is not a number', async () => { + req.params.height = 42 + req.query.count = 'Hello' + + const result = await electrumxRoute.getBlockHeaders(req, res) + + expectRouteError(res, result, 'count must be a positive number') + }) + + it('should throw 400 if count is negative', async () => { + req.params.height = 42 + req.query.count = -10 + + const result = await electrumxRoute.getBlockHeaders(req, res) + + expectRouteError(res, result, 'count must be a positive number') + }) + + it('should throw 400 on array input', async () => { + req.params.height = [42, 42] + + const result = await electrumxRoute.getBlockHeaders(req, res) + + expectRouteError(res, result, 'height must be a positive number') + }) + + it('should return error object for invalid height', async () => { + req.params.height = 1000000000 + + stubMethodForUnitTests( + electrumxRoute.electrumx, + 'request', + new Error('Invalid height') + ) + + // Call the details API. + const result = await electrumxRoute.getBlockHeaders(req, res) + + expectRouteError(res, result, 'Invalid height') + }) + + it('should get headers for a single block height with count 2', async () => { + req.params.height = 42 + req.query.count = 2 + + stubMethodForUnitTests( + electrumxRoute, + '_blockHeadersFromElectrum', + mockData.blockHeaders + ) + + // Call the details API. + const result = await electrumxRoute.getBlockHeaders(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.property(result, 'headers') + assert.isArray(result.headers) + assert.deepEqual(result.headers, mockData.blockHeaders) + }) + }) + + describe('#blockHeadersBulk', () => { + it('should throw an error for an empty body', async () => { + req.body = {} + + const result = await electrumxRoute.blockHeadersBulk(req, res) + + expectRouteError(res, result, 'heights needs to be an array') + }) + + it('should error on non-array single height', async () => { + req.body = { + heights: 42 + } + + const result = await electrumxRoute.blockHeadersBulk(req, res) + + expectRouteError(res, result, 'heights needs to be an array') + }) + + it('should NOT throw 400 error for an invalid height', async () => { + req.body = { + heights: [ + { height: -10, count: 2 } + ] + } + + stubMethodForUnitTests( + electrumxRoute, + '_blockHeadersFromElectrum', + mockData.blockHeaders + ) + + const result = await electrumxRoute.blockHeadersBulk(req, res) + + // This should probably throw a 400 error, but to be consistent with the other + // bulk endpoints it doesn't throw. This will change in the future + // expectRouteError(res, result, 'Invalid tx hash') + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.property(result, 'headers') + assert.isArray(result.headers) + }) + + it('should throw 429 error if heights array is too large', async () => { + const testArray = [] + for (var i = 0; i < 25; i++) testArray.push('') + + req.body.heights = testArray + + const result = await electrumxRoute.blockHeadersBulk(req, res) + + expectRouteError(res, result, 'Array too large', 429) + }) + + it('should get details for a single height', async () => { + req.body = { + heights: [ + { height: 42, count: 2 } + ] + } + + stubMethodForUnitTests( + electrumxRoute, + '_blockHeadersFromElectrum', + mockData.blockHeaders + ) + + // Call the details API. + const result = await electrumxRoute.blockHeadersBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.property(result, 'headers') + assert.isArray(result.headers) + + assert.property(result.headers[0], 'headers') + assert.isArray(result.headers[0].headers) + }) + + it('should get details for multiple txids', async () => { + req.body = { + heights: [ + { height: 42, count: 2 }, + { height: 42, count: 2 } + ] + } + + stubMethodForUnitTests( + electrumxRoute, + '_blockHeadersFromElectrum', + mockData.blockHeaders + ) + + // Call the details API. + const result = await electrumxRoute.blockHeadersBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`)' + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.isArray(result.headers) + assert.isArray(result.headers[0].headers) + assert.equal(result.headers.length, 2, '2 outputs for 2 inputs') + }) + }) + describe('#_balanceFromElectrumx', () => { it('should throw error for invalid address', async () => { try { diff --git a/test/v3/mocks/electrumx-mock.js b/test/v3/mocks/electrumx-mock.js index 53a2b5d..41c96a1 100644 --- a/test/v3/mocks/electrumx-mock.js +++ b/test/v3/mocks/electrumx-mock.js @@ -107,10 +107,16 @@ const txDetails = { ] } +const blockHeaders = [ + '010000008b52bbd72c2f49569059f559c1b1794de5192e4f7d6d2b03c7482bad0000000083e4f8a9d502ed0c419075c1abb5d56f878a2e9079e5612bfb76a2dc37d9c42741dd6849ffff001d2b909dd6', + '01000000f528fac1bcb685d0cd6c792320af0300a5ce15d687c7149548904e31000000004e8985a786d864f21e9cbb7cbdf4bc9265fe681b7a0893ac55a8e919ce035c2f85de6849ffff001d385ccb7c' +] + module.exports = { utxos, balance, txHistory, mempool, - txDetails + txDetails, + blockHeaders }