From 1ab408a757144ea2dfa5ffcc38ee8cd0c7fa54bd Mon Sep 17 00:00:00 2001 From: Daniel Gonzalez Date: Tue, 1 Jun 2021 22:20:57 -0400 Subject: [PATCH] feat(tx): Created v5 Fulcrum /tx endpoints in bch-api --- src/routes/v5/electrumx.js | 254 +++++++++++++----------------- test/v5/a01-electrumx.js | 268 ++++++++++++++++++++++++++++++++ test/v5/mocks/electrumx-mock.js | 6 + 3 files changed, 382 insertions(+), 146 deletions(-) diff --git a/src/routes/v5/electrumx.js b/src/routes/v5/electrumx.js index 371e5c7..dc40a36 100644 --- a/src/routes/v5/electrumx.js +++ b/src/routes/v5/electrumx.js @@ -52,9 +52,9 @@ class Electrum { this.router.post('/balance', this.balanceBulk) this.router.get('/utxos/:address', this.getUtxos) this.router.post('/utxos', this.utxosBulk) - // this.router.get('/tx/data/:txid', this.getTransactionDetails) - // this.router.post('/tx/data', this.transactionDetailsBulk) - // this.router.post('/tx/broadcast', this.broadcastTransaction) + this.router.get('/tx/data/:txid', this.getTransactionDetails) + this.router.post('/tx/data', this.transactionDetailsBulk) + this.router.post('/tx/broadcast', this.broadcastTransaction) // this.router.get('/block/headers/:height', this.getBlockHeaders) // this.router.post('/block/headers', this.blockHeadersBulk) // this.router.get('/transactions/:address', this.getTransactions) @@ -423,53 +423,40 @@ class Electrum { * */ // 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) - // } - // } + 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 response = await _this.axios.get( + `${_this.fulcrumApi}electrumx/tx/data/${txid}` + ) + // console.log(`_transactionDetailsFromElectrum(): ${JSON.stringify(electrumResponse, null, 2)}`) + + res.status(200) + return res.json(response.data) + } 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 @@ -484,61 +471,47 @@ class Electrum { * */ // 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(400) // 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) - // } - // } + 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(400) // 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 + ) + + const response = await _this.axios.post( + `${_this.fulcrumApi}electrumx/tx/data`, + { txids, verbose } + ) + + // Return the array of retrieved transaction details. + res.status(200) + return res.json(response.data) + } catch (err) { + wlogger.error('Error in electrumx.js/transactionDetailsBulk().', err) + + return _this.errorHandler(err, res) + } + } // Returns a promise that resolves to transaction ID of the broadcasted transaction or an error. // Expects input to be a txHex string, and input validation to have already @@ -584,47 +557,36 @@ class Electrum { * */ // POST handler for broadcasting a single transaction - // async broadcastTransaction (req, res, next) { - // try { - // const txHex = req.body.txHex - // - // if (typeof txHex !== 'string') { - // res.status(400) - // return res.json({ - // success: false, - // error: 'request body must be a string.' - // }) - // } - // - // wlogger.debug( - // 'Executing electrumx/broadcastTransaction with this tx hex: ', - // txHex - // ) - // - // // Get data from ElectrumX server. - // const electrumResponse = await _this._broadcastTransactionWithElectrum(txHex) - // // console.log(`_utxosFromElectrumx(): ${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, - // txid: electrumResponse - // }) - // } catch (err) { - // wlogger.error('Error in electrumx.js/broadcastTransaction().', err) - // - // return _this.errorHandler(err, res) - // } - // } + async broadcastTransaction (req, res, next) { + try { + const txHex = req.body.txHex + if (typeof txHex !== 'string') { + res.status(400) + return res.json({ + success: false, + error: 'request body must be a string.' + }) + } + + wlogger.debug( + 'Executing electrumx/broadcastTransaction with this tx hex: ', + txHex + ) + + // Get data from ElectrumX server. + const response = await _this.axios.post( + `${_this.fulcrumApi}electrumx/tx/broadcast`, + { txHex } + ) + + res.status(200) + return res.json(response.data) + } catch (err) { + wlogger.error('Error in electrumx.js/broadcastTransaction().', err) + + return _this.errorHandler(err, res) + } + } // 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 diff --git a/test/v5/a01-electrumx.js b/test/v5/a01-electrumx.js index 2c90739..c38d426 100644 --- a/test/v5/a01-electrumx.js +++ b/test/v5/a01-electrumx.js @@ -609,6 +609,274 @@ describe('#Electrumx', () => { }) }) + describe('#getTransactionDetails', () => { + it('should throw 400 if tx is empty', async () => { + const result = await electrumxRoute.getTransactionDetails(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 400, 'Expect 400 status code') + + assert.property(result, 'error') + assert.include(result.error, 'txid must be a string') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + it('should throw 400 on array input', async () => { + req.params.txid = [ + 'a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d' + ] + + const result = await electrumxRoute.getTransactionDetails(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 400, 'Expect 400 status code') + + assert.property(result, 'error') + assert.include(result.error, 'txid must be a string') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + it('should pass errors from electrum-cash to user', async () => { + req.params.txid = '02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c' + + const result = await electrumxRoute.getTransactionDetails(req, res) + + assert.property(result, 'error') + assert.include(result.error.error, 'Invalid tx hash') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + it('should get details for a single tx', async () => { + req.params.txid = + 'a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d' + + // Mock unit tests to prevent live network calls. + if (process.env.TEST === 'unit') { + electrumxRoute.isReady = true // Force flag. + + sandbox + .stub(electrumxRoute.axios, 'get') + .resolves({ data: { success: true, details: 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.property(result.details, 'blockhash') + assert.property(result.details, 'blocktime') + assert.property(result.details, 'confirmations') + assert.property(result.details, 'hash') + assert.property(result.details, 'hex') + assert.property(result.details, 'locktime') + assert.property(result.details, 'size') + assert.property(result.details, 'time') + assert.property(result.details, 'txid') + assert.property(result.details, 'version') + assert.property(result.details, 'vin') + assert.property(result.details, 'vout') + }) + }) + + describe('#transactionDetailsBulk', () => { + it('should throw 400 if txids is empty', async () => { + const result = await electrumxRoute.transactionDetailsBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 400, 'Expect 400 status code') + + assert.property(result, 'error') + assert.include(result.error, 'txids needs to be an array') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + it('should throw 400 if input provided is not array', async () => { + req.body.txids = + 'a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d' + + const result = await electrumxRoute.transactionDetailsBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 400, 'Expect 400 status code') + + assert.property(result, 'error') + assert.include(result.error, 'txids needs to be an array') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + it('should throw 400 error if addresses 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)}`) + + assert.property(result, 'error') + assert.include(result.error, 'Array too large') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + it('should handle error', async () => { + req.body.txids = [ + 'a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d', + 'a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d' + ] + // Force error + sandbox.stub(electrumxRoute.axios, 'post').throws(new Error('Test error')) + + // 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, false) + + assert.property(result, 'error') + assert.include(result.error, 'Test error') + }) + /* it('should pass errors from electrum-cash to user', async () => { + req.body.txids = [ + '02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c', + '02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c' + ] + + // 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, false) + + assert.property(result, 'error') + assert.include(result.error, 'Test error') + }) */ + it('should get details for an array of tx', async () => { + req.body.txids = [ + 'a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d' + ] + + // Mock unit tests to prevent live network calls. + if (process.env.TEST === 'unit') { + electrumxRoute.isReady = true // Force flag. + + sandbox + .stub(electrumxRoute.axios, 'post') + .resolves({ data: mockData.txDetailsBulk }) + } + + // 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') + const tx = result.transactions[0] + + assert.property(tx, 'details') + assert.property(tx.details, 'blockhash') + assert.property(tx.details, 'blocktime') + assert.property(tx.details, 'confirmations') + assert.property(tx.details, 'hash') + assert.property(tx.details, 'hex') + assert.property(tx.details, 'locktime') + assert.property(tx.details, 'size') + assert.property(tx.details, 'time') + assert.property(tx.details, 'txid') + assert.property(tx.details, 'version') + assert.property(tx.details, 'vin') + assert.property(tx.details, 'vout') + }) + }) + + describe('#broadcastTransaction', () => { + it('should throw 400 if txHex is empty', async () => { + const result = await electrumxRoute.broadcastTransaction(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 400, 'Expect 400 status code') + + assert.property(result, 'error') + assert.include(result.error, 'request body must be a string') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + it('should throw 400 on invalid input type', async () => { + req.body.txHex = [mockData.txDetails.hex] + + const result = await electrumxRoute.broadcastTransaction(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 400, 'Expect 400 status code') + + assert.property(result, 'error') + assert.include(result.error, 'request body must be a string') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + it('should pass errors from electrum-cash to user', async () => { + req.body.txHex = mockData.txDetails.hex.substring(10) + const result = await electrumxRoute.broadcastTransaction(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 400, 'Expect 400 status code') + + assert.property(result, 'error') + assert.include(result.error.error, 'the transaction was rejected') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + it('should broadcast transaction', async function () { + req.body.txHex = mockData.txDetails.hex + + // Mock unit tests to prevent live network calls. + if (process.env.TEST === 'unit') { + electrumxRoute.isReady = true // Force flag. + + sandbox + .stub(electrumxRoute.axios, 'post') + .resolves({ data: { success: true, txid: mockData.txDetails.hash } }) + } else { + return this.skip() + } + + // Call the details API. + const result = await electrumxRoute.broadcastTransaction(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.property(result, 'txid') + assert.isString(result.txid) + }) + }) + // describe('#_utxosFromElectrumx', () => { // it('should throw error for invalid address', async () => { // try { diff --git a/test/v5/mocks/electrumx-mock.js b/test/v5/mocks/electrumx-mock.js index ada1f2f..37c73a4 100644 --- a/test/v5/mocks/electrumx-mock.js +++ b/test/v5/mocks/electrumx-mock.js @@ -166,6 +166,11 @@ const txDetails = { ] } +const txDetailsBulk = { + success: true, + transactions: [{ details: txDetails }] +} + const blockHeaders = [ '010000008b52bbd72c2f49569059f559c1b1794de5192e4f7d6d2b03c7482bad0000000083e4f8a9d502ed0c419075c1abb5d56f878a2e9079e5612bfb76a2dc37d9c42741dd6849ffff001d2b909dd6', '01000000f528fac1bcb685d0cd6c792320af0300a5ce15d687c7149548904e31000000004e8985a786d864f21e9cbb7cbdf4bc9265fe681b7a0893ac55a8e919ce035c2f85de6849ffff001d385ccb7c' @@ -178,6 +183,7 @@ module.exports = { txHistory, mempool, txDetails, + txDetailsBulk, blockHeaders, balances }