From 63b8168c451e0a6f5ac5677e5a3e20806e1f8832 Mon Sep 17 00:00:00 2001 From: Rosco Kalis Date: Sun, 16 Aug 2020 19:43:12 +0200 Subject: [PATCH] Add get transaction details from Electrum - Add functions for getting a single transaction details or bulk - Add GET and POST routes for these functions - Add tests +mock data for these functions - Add test helper function for stubbing a method for unit tests - Add test helper function for asserting an API error --- src/routes/v3/electrumx.js | 152 ++++++++++++++++++++++++ test/v3/a01-electrumx.js | 202 ++++++++++++++++++++++++++++++++ test/v3/mocks/electrumx-mock.js | 71 ++++++++++- 3 files changed, 424 insertions(+), 1 deletion(-) diff --git a/src/routes/v3/electrumx.js b/src/routes/v3/electrumx.js index 025766c..884a3b5 100644 --- a/src/routes/v3/electrumx.js +++ b/src/routes/v3/electrumx.js @@ -50,6 +50,8 @@ 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.post('/tx/data', _this.transactionDetailsBulk) _this.router.get('/balance/:address', _this.getBalance) _this.router.post('/balance', _this.balanceBulk) _this.router.get('/transactions/:address', _this.getTransactions) @@ -324,6 +326,156 @@ class Electrum { } } + // Returns a promise that resolves to transaction details data for a txid. + // Expects input to be a txid string, and input validation to have already + // been done by parent, calling function. + async _transactionDetailsFromElectrum (txid, verbose = true) { + try { + if (!_this.isReady) { + throw new Error( + 'ElectrumX server connection is not ready. Call await connectToServer() first.' + ) + } + + // Query the utxos from the ElectrumX server. + const electrumResponse = await _this.electrumx.request('blockchain.transaction.get', txid, verbose) + // console.log( + // `electrumResponse: ${JSON.stringify(electrumResponse, null, 2)}` + // ) + + return electrumResponse + } catch (err) { + // console.log('err: ', err) + + // Write out error to error log. + wlogger.error('Error in elecrumx.js/_transactionDetailsFromElectrum(): ', err) + throw err + } + } + + /** + * @api {get} /electrumx/tx/data/{txid} Get transaction details for a TXID + * @apiName transaction details for a TXID + * @apiGroup ElectrumX / Fulcrum + * @apiDescription Returns an object with transaction details of the TXID + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v3/electrumx/tx/data/a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d" -H "accept: application/json" + * + */ + // GET handler for single transaction + async getTransactionDetails (req, res, next) { + try { + const txid = req.params.txid + const verbose = req.query.verbose + + // Reject if txid is anything other than a string + if (typeof txid !== 'string') { + res.status(400) + return res.json({ + success: false, + error: 'txid must be a string' + }) + } + + wlogger.debug( + 'Executing electrumx/getTransactionDetails with this txid: ', + txid + ) + + // Get data from ElectrumX server. + const electrumResponse = await _this._transactionDetailsFromElectrum(txid, verbose) + // 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, + details: electrumResponse + }) + } catch (err) { + // Write out error to error log. + wlogger.error('Error in elecrumx.js/getTransactionDetails().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {post} /electrumx/tx/data Get transaction details for an array of TXIDs + * @apiName Transaction details for an array of TXIDs + * @apiGroup ElectrumX / Fulcrum + * @apiDescription Returns an array of objects with transaction details of an array of TXIDs. + * Limited to 20 items per request. + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v3/electrumx/tx/data" -H "accept: application/json" -H "Content-Type: application/json" -d '{"txids":["a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d","a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d"], "verbose":false}' + * + * + */ + // POST handler for bulk queries on transaction details + async transactionDetailsBulk (req, res, next) { + try { + const txids = req.body.txids + const verbose = req.body.verbose || true + + // Reject if txids is not an array. + if (!Array.isArray(txids)) { + res.status(400) + return res.json({ + success: false, + error: 'txids needs to be an array. Use GET for single txid.' + }) + } + + // Enforce array size rate limits + if (!_this.routeUtils.validateArraySize(req, txids)) { + 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/transactionDetailsBulk with these txids: ', + txids + ) + + // Loops through each address and creates an array of Promises, querying + // the Electrum server in parallel. + const transactions = txids.map(async (txid, index) => { + // console.log(`address: ${address}`) + const details = await _this._transactionDetailsFromElectrum(txid, verbose) + + return { details, txid } + }) + + // 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, + transactions: result + }) + } catch (err) { + wlogger.error('Error in electrumx.js/transactionDetailsBulk().', 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 c93efc8..7b95b4d 100644 --- a/test/v3/a01-electrumx.js +++ b/test/v3/a01-electrumx.js @@ -30,6 +30,16 @@ const mockData = require('./mocks/electrumx-mock') const util = require('util') util.inspect.defaultOptions = { depth: 1 } +function expectRouteError (res, result, expectedError, code = 400) { + assert.equal(res.statusCode, code, `HTTP status code ${code} expected.`) + + assert.property(result, 'error') + assert.include(result.error, expectedError) + + assert.property(result, 'success') + assert.equal(result.success, false) +} + describe('#ElectrumX Router', () => { let req, res let sandbox @@ -82,6 +92,16 @@ describe('#ElectrumX Router', () => { // }) + function stubMethodForUnitTests (obj, method, value) { + if (!process.env.TEST === 'unit') return false + + electrumxRoute.isReady = true // Force flag. + + sandbox.stub(obj, method).resolves(value) + + return true + } + describe('#root', () => { // root route handler. const root = electrumxRoute.root @@ -412,6 +432,188 @@ describe('#ElectrumX Router', () => { }) }) + describe('#_transactionDetailsFromElectrum', () => { + it('should return error object for invalid txid', async () => { + const txid = '4db095f34d632a4daf942142c291f1f2abb5ba2e1ccac919d85bdc2f671fb25' + + stubMethodForUnitTests(electrumxRoute.electrumx, 'request', new Error('Invalid tx hash')) + + const result = await electrumxRoute._transactionDetailsFromElectrum(txid) + + assert.instanceOf(result, Error) + assert.include(result.message, 'Invalid tx hash') + }) + + it('should get details for a single txid', async () => { + const txid = '4db095f34d632a4daf942142c291f1f2abb5ba2e1ccac919d85bdc2f671fb251' + + stubMethodForUnitTests(electrumxRoute.electrumx, 'request', mockData.txDetails) + + const result = await electrumxRoute._transactionDetailsFromElectrum(txid) + + assert.isObject(result) + assert.property(result, 'blockhash') + assert.property(result, 'hash') + assert.property(result, 'hex') + assert.property(result, 'vin') + assert.property(result, 'vout') + assert.equal(result.hash, txid) + }) + }) + + describe('#getTransactionDetails', () => { + it('should throw 400 if txid is not a string', async () => { + req.params.txid = 5 + + const result = await electrumxRoute.getTransactionDetails(req, res) + + expectRouteError(res, result, 'txid must be a string') + }) + + it('should throw 400 on array input', async () => { + req.params.address = ['4db095f34d632a4daf942142c291f1f2abb5ba2e1ccac919d85bdc2f671fb251'] + + const result = await electrumxRoute.getTransactionDetails(req, res) + + expectRouteError(res, result, 'txid must be a string') + }) + + it('should return error object for invalid txid', async () => { + req.params.txid = '4db095f34d632a4daf942142c291f1f2abb5ba2e1ccac919d85bdc2f671fb25' + + stubMethodForUnitTests(electrumxRoute.electrumx, 'request', new Error('Invalid tx hash')) + + // Call the details API. + const result = await electrumxRoute.getTransactionDetails(req, res) + + expectRouteError(res, result, 'Invalid tx hash') + }) + + it('should get details for a single txid', async () => { + req.params.txid = '4db095f34d632a4daf942142c291f1f2abb5ba2e1ccac919d85bdc2f671fb251' + + stubMethodForUnitTests(electrumxRoute.electrumx, 'request', mockData.txDetails) + + // Call the details API. + const result = await electrumxRoute.getTransactionDetails(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.property(result, 'details') + assert.isObject(result.details) + + assert.property(result.details, 'blockhash') + assert.property(result.details, 'hash') + assert.property(result.details, 'hex') + assert.property(result.details, 'vin') + assert.property(result.details, 'vout') + assert.equal(result.details.hash, req.params.txid) + }) + }) + + describe('#transactionDetailsBulk', () => { + it('should throw an error for an empty body', async () => { + req.body = {} + + const result = await electrumxRoute.transactionDetailsBulk(req, res) + + expectRouteError(res, result, 'txids needs to be an array') + }) + + it('should error on non-array single txid', async () => { + req.body = { + txid: '4db095f34d632a4daf942142c291f1f2abb5ba2e1ccac919d85bdc2f671fb251' + } + + const result = await electrumxRoute.transactionDetailsBulk(req, res) + + expectRouteError(res, result, 'txids needs to be an array') + }) + + it('should NOT throw 400 error for an invalid txid', async () => { + req.body = { + txids: ['4db095f34d632a4daf942142c291f1f2abb5ba2e1ccac919d85bdc2f671fb25'] + } + + stubMethodForUnitTests(electrumxRoute.electrumx, 'request', mockData.txDetails) + + const result = await electrumxRoute.transactionDetailsBulk(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, 'transactions') + assert.isArray(result.transactions) + }) + + it('should throw 429 error if txid array is too large', async () => { + const testArray = [] + for (var i = 0; i < 25; i++) testArray.push('') + + req.body.txids = testArray + + const result = await electrumxRoute.transactionDetailsBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + expectRouteError(res, result, 'Array too large', 429) + }) + + it('should get details for a single txid', async () => { + req.body = { + txids: ['4db095f34d632a4daf942142c291f1f2abb5ba2e1ccac919d85bdc2f671fb251'] + } + + stubMethodForUnitTests(electrumxRoute.electrumx, 'request', mockData.txDetails) + + // Call the details API. + const result = await electrumxRoute.transactionDetailsBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.property(result, 'transactions') + assert.isArray(result.transactions) + + assert.property(result.transactions[0], 'txid') + assert.property(result.transactions[0], 'details') + + assert.property(result.transactions[0].details, 'blockhash') + assert.property(result.transactions[0].details, 'hash') + assert.property(result.transactions[0].details, 'hex') + assert.property(result.transactions[0].details, 'vin') + assert.property(result.transactions[0].details, 'vout') + }) + + it('should get details for multiple txids', async () => { + req.body = { + txids: [ + '4db095f34d632a4daf942142c291f1f2abb5ba2e1ccac919d85bdc2f671fb251', + '4db095f34d632a4daf942142c291f1f2abb5ba2e1ccac919d85bdc2f671fb251' + ] + } + + stubMethodForUnitTests(electrumxRoute.electrumx, 'request', mockData.txDetails) + + // Call the details API. + const result = await electrumxRoute.transactionDetailsBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`)' + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.isArray(result.transactions) + assert.isObject(result.transactions[0].details) + assert.equal(result.transactions.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 9aaf10c..53a2b5d 100644 --- a/test/v3/mocks/electrumx-mock.js +++ b/test/v3/mocks/electrumx-mock.js @@ -39,9 +39,78 @@ const mempool = [ } ] +const txDetails = { + blockhash: '0000000000000000002aaf94953da3b487317508ebd1003a1d75d6d6ec2e75cc', + blocktime: 1578327094, + confirmations: 31861, + hash: '4db095f34d632a4daf942142c291f1f2abb5ba2e1ccac919d85bdc2f671fb251', + hex: '020000000265d13ef402840c8a51f39779afb7ae4d49e4b0a3c24a3d0e7742038f2c679667010000006441dd1dd72770cadede1a7fd0363574846c48468a398ddfa41a9677c74cac8d2652b682743725a3b08c6c2021a629011e11a264d9036e9d5311e35b5f4937ca7b4e4121020797d8fd4d2fa6fd7cdeabe2526bfea2b90525d6e8ad506ec4ee3c53885aa309ffffffff65d13ef402840c8a51f39779afb7ae4d49e4b0a3c24a3d0e7742038f2c679667000000006441347d7f218c11c04487c1ad8baac28928fb10e5054cd4494b94d078cfa04ccf68e064fb188127ff656c0b98e9ce87f036d183925d0d0860605877d61e90375f774121028a53f95eb631b460854fc836b2e5d31cad16364b4dc3d970babfbdcc3f2e4954ffffffff035ac355000000000017a914189ce02e332548f4804bac65cba68202c9dbf822878dfd0800000000001976a914285bb350881b21ac89724c6fb6dc914d096cd53b88acf9ef3100000000001976a91445f1f1c4a9b9419a5088a3e9c24a293d7a150e6488ac00000000', + locktime: 0, + size: 392, + time: 1578327094, + txid: '4db095f34d632a4daf942142c291f1f2abb5ba2e1ccac919d85bdc2f671fb251', + version: 2, + vin: [ + { + scriptSig: { + asm: 'dd1dd72770cadede1a7fd0363574846c48468a398ddfa41a9677c74cac8d2652b682743725a3b08c6c2021a629011e11a264d9036e9d5311e35b5f4937ca7b4e[ALL|FORKID] 020797d8fd4d2fa6fd7cdeabe2526bfea2b90525d6e8ad506ec4ee3c53885aa309', + hex: '41dd1dd72770cadede1a7fd0363574846c48468a398ddfa41a9677c74cac8d2652b682743725a3b08c6c2021a629011e11a264d9036e9d5311e35b5f4937ca7b4e4121020797d8fd4d2fa6fd7cdeabe2526bfea2b90525d6e8ad506ec4ee3c53885aa309' + }, + sequence: 4294967295, + txid: '6796672c8f0342770e3d4ac2a3b0e4494daeb7af7997f3518a0c8402f43ed165', + vout: 1 + }, + { + scriptSig: { + asm: '347d7f218c11c04487c1ad8baac28928fb10e5054cd4494b94d078cfa04ccf68e064fb188127ff656c0b98e9ce87f036d183925d0d0860605877d61e90375f77[ALL|FORKID] 028a53f95eb631b460854fc836b2e5d31cad16364b4dc3d970babfbdcc3f2e4954', + hex: '41347d7f218c11c04487c1ad8baac28928fb10e5054cd4494b94d078cfa04ccf68e064fb188127ff656c0b98e9ce87f036d183925d0d0860605877d61e90375f774121028a53f95eb631b460854fc836b2e5d31cad16364b4dc3d970babfbdcc3f2e4954' + }, + sequence: 4294967295, + txid: '6796672c8f0342770e3d4ac2a3b0e4494daeb7af7997f3518a0c8402f43ed165', + vout: 0 + } + ], + vout: [ + { + n: 0, + scriptPubKey: { + addresses: ['bitcoincash: pqvfecpwxvj53ayqfwkxtjaxsgpvnklcyg8xewk9hl'], + asm: 'OP_HASH160 189ce02e332548f4804bac65cba68202c9dbf822 OP_EQUAL', + hex: 'a914189ce02e332548f4804bac65cba68202c9dbf82287', + reqSigs: 1, + type: 'scripthash' + }, + value: 0.0562057 + }, + { + n: 1, + scriptPubKey: { + addresses: ['bitcoincash: qq59hv6s3qdjrtyfwfxxldkuj9xsjmx48vrz882knz'], + asm: 'OP_DUP OP_HASH160 285bb350881b21ac89724c6fb6dc914d096cd53b OP_EQUALVERIFY OP_CHECKSIG', + hex: '76a914285bb350881b21ac89724c6fb6dc914d096cd53b88ac', + reqSigs: 1, + type: 'pubkeyhash' + }, + value: 0.00589197 + }, + { + n: 2, + scriptPubKey: { + addresses: ['bitcoincash: qpzlruwy4xu5rxjs3z37nsj29y7h59gwvsu4ddp0u4'], + asm: 'OP_DUP OP_HASH160 45f1f1c4a9b9419a5088a3e9c24a293d7a150e64 OP_EQUALVERIFY OP_CHECKSIG', + hex: '76a91445f1f1c4a9b9419a5088a3e9c24a293d7a150e6488ac', + reqSigs: 1, + type: 'pubkeyhash' + }, + value: 0.03272697 + } + ] +} + module.exports = { utxos, balance, txHistory, - mempool + mempool, + txDetails }