From fd3fd4380089260664c05eba0b19e31fdba96319 Mon Sep 17 00:00:00 2001 From: Rosco Kalis Date: Wed, 19 Aug 2020 21:12:00 +0200 Subject: [PATCH 1/3] Add /block/headers GET/POST endpoints + tests and mock data --- src/routes/v3/electrumx.js | 171 ++++++++++++++++++++++ test/v3/a01-electrumx.js | 244 +++++++++++++++++++++++++++++++- test/v3/mocks/electrumx-mock.js | 8 +- 3 files changed, 420 insertions(+), 3 deletions(-) diff --git a/src/routes/v3/electrumx.js b/src/routes/v3/electrumx.js index 91949a8..7636b05 100644 --- a/src/routes/v3/electrumx.js +++ b/src/routes/v3/electrumx.js @@ -52,6 +52,8 @@ class Electrum { _this.router.post('/utxos', _this.utxosBulk) _this.router.get('/tx/data/:address', _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 } From 5e04125bc394d1f6f35becf24a3ff3b4a62b1f25 Mon Sep 17 00:00:00 2001 From: Rosco Kalis Date: Wed, 26 Aug 2020 12:46:53 +0200 Subject: [PATCH 2/3] Update electrum-cash version --- package-lock.json | 22 +++++++++++++++++++--- package.json | 2 +- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0627e17..0bd55b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1402,6 +1402,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,10 +4439,11 @@ "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": "1.2.0", + "resolved": "https://registry.npmjs.org/electrum-cash/-/electrum-cash-1.2.0.tgz", + "integrity": "sha512-NZarCF2U+6uXYd697GYAuP3qdDaFUx+6zWcO8SzyeNyRCNZaUm/rscUAm7+QCgNpUnj8tEGdLj5LG2mchaZRSQ==", "requires": { + "async-mutex": "^0.2.4", "debug": "^4.1.1" } }, diff --git a/package.json b/package.json index cc19cff..287051b 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": "^1.2.0", "express": "^4.15.5", "express-basic-auth": "^1.1.3", "express-rate-limit": "^5.0.0", From 66c99ae442e3450ad1e0558753ff454cc5ad5fa7 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Thu, 27 Aug 2020 16:54:59 -0700 Subject: [PATCH 3/3] Adding temp.sh to the gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 148357a..28fae49 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ start-fullstack-test.sh start-free-main.sh start-local-mainnet.sh.save start-local-testnet.sh.save +temp.sh coverage start-my-infra