diff --git a/.gitignore b/.gitignore index a8296b5..74cc34f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ start-local-server test-local-server start-decatur-main start-decatur-test +start-bitcoincom.sh + coverage start-my-infra dist/routes/** diff --git a/src/routes/v3/blockbook.js b/src/routes/v3/blockbook.js index 747098a..1b6d0a4 100644 --- a/src/routes/v3/blockbook.js +++ b/src/routes/v3/blockbook.js @@ -27,6 +27,8 @@ router.get("/balance/:address", balanceSingle) router.post("/balance", balanceBulk) router.get("/utxos/:address", utxosSingle) router.post("/utxos", utxosBulk) +router.get("/tx/:txid", txSingle) +router.post("/tx", txBulk) // Root API endpoint. Simply acknowledges that it exists. function root(req, res, next) { @@ -367,6 +369,147 @@ async function utxosBulk(req, res, next) { } } +// Query the Blockbook Node API for transactions on a single TXID. +// Returns a Promise. +async function transactionsFromBlockbook(txid) { + try { + //console.log(`BLOCKBOOK_URL: ${BLOCKBOOK_URL}`) + + const path = `${process.env.BLOCKBOOK_URL}api/v2/tx/${txid}` + + // Query the Blockbook Node API. + const axiosResponse = await axios.get(path) + const retPromise = axiosResponse.data + //console.log(`retData: ${util.inspect(retData)}`) + + return retPromise + } catch (err) { + // Dev Note: Do not log error messages here. Throw them instead and let the + // parent function handle it. + throw err + } +} + +// GET handler for single transaction details. +async function txSingle(req, res, next) { + try { + const txid = req.params.txid + + if (!txid || txid === "") { + res.status(400) + return res.json({ error: "txid can not be empty" }) + } + + // Reject if address is an array. + if (Array.isArray(txid)) { + res.status(400) + return res.json({ + error: "txid can not be an array. Use POST for bulk upload." + }) + } + + // TODO: Add regex comparison of txid to ensure it's valid. + if (txid.length !== 64) { + res.status(400) + return res.json({ + error: `txid must be of length 64 (not ${txid.length})` + }) + } + + wlogger.debug(`Executing blockbook/txSingle with this txid: `, txid) + + // Query the Blockbook Node API. + const retData = await transactionsFromBlockbook(txid) + + // Return the retrieved address information. + res.status(200) + return res.json(retData) + } catch (err) { + // Attempt to decode the error message. + const { msg, status } = routeUtils.decodeError(err) + if (msg) { + res.status(status) + return res.json({ error: msg }) + } + + // Write out error to error log. + wlogger.error(`Error in blockbook.js/txSingle().`, err) + + res.status(500) + return res.json({ error: util.inspect(err) }) + } +} + +// POST handler for bulk queries on tx details +async function txBulk(req, res, next) { + try { + let txids = req.body.txids + const currentPage = req.body.page ? parseInt(req.body.page, 10) : 0 + + // Reject if txids is not an array. + if (!Array.isArray(txids)) { + res.status(400) + return res.json({ + error: "txids need to be an array. Use GET for single address." + }) + } + + // Enforce array size rate limits + if (!routeUtils.validateArraySize(req, txids)) { + res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 + return res.json({ + error: `Array too large.` + }) + } + + wlogger.debug(`Executing blockbook.js/txBulk with these txids: `, txids) + + // Validate each element in the txids array. + for (let i = 0; i < txids.length; i++) { + const thisTxid = txids[i] + + if (!thisTxid || thisTxid === "") { + res.status(400) + return res.json({ error: "txid can not be empty" }) + } + + // TODO: Add regex comparison of txid to ensure it's valid. + if (thisTxid.length !== 64) { + res.status(400) + return res.json({ + error: `txid must be of length 64 (not ${thisTxid.length})` + }) + } + } + + // Loops through each address and creates an array of Promises, querying + // Insight API in parallel. + txids = txids.map(async (txid, index) => + //console.log(`address: ${address}`) + transactionsFromBlockbook(txid) + ) + + // Wait for all parallel Insight requests to return. + const result = await axios.all(txids) + + // Return the array of retrieved address information. + res.status(200) + return res.json(result) + } catch (err) { + // Attempt to decode the error message. + const { msg, status } = routeUtils.decodeError(err) + if (msg) { + res.status(status) + return res.json({ error: msg }) + } + + wlogger.error(`Error in blockbook.js/txBulk().`, err) + + res.status(500) + return res.json({ error: util.inspect(err) }) + } +} + module.exports = { router, testableComponents: { @@ -374,6 +517,8 @@ module.exports = { balanceSingle, balanceBulk, utxosSingle, - utxosBulk + utxosBulk, + txSingle, + txBulk } } diff --git a/test/v3/blockbook.js b/test/v3/blockbook.js index b127daa..b05407c 100644 --- a/test/v3/blockbook.js +++ b/test/v3/blockbook.js @@ -38,6 +38,8 @@ describe("#Blockbook Router", () => { mockServerUrl = `http://fakeurl` } // console.log(`Testing type is: ${process.env.TEST}`) + + if (!process.env.NETWORK) process.env.NETWORK = "testnet" }) // Setup the mocks before each test. @@ -586,4 +588,293 @@ describe("#Blockbook Router", () => { assert.equal(result.length, 2, "2 outputs for 2 inputs") }) }) + + describe("#txSingle", () => { + // route handler + const txSingle = blockbookRoute.testableComponents.txSingle + + it("should throw 400 if txid is empty", async () => { + const result = await txSingle(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ["error"]) + assert.include(result.error, "txid can not be empty") + }) + + it("should error on an array", async () => { + req.params.txid = [ + `5fe9b74056319a8c87f45cc745030715a6180758b94938dbf90d639d55652392` + ] + + const result = await txSingle(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "txid can not be an array", + "Proper error message" + ) + }) + + it("should throw 400 if txid is not a valid txid", async () => { + req.params.txid = `abc` + + const result = await txSingle(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ["error"]) + assert.include(result.error, "txid must be of length 64") + }) + + it("should throw 500 when network issues", async () => { + const savedUrl = process.env.BLOCKBOOK_URL + + try { + req.params.txid = `5fe9b74056319a8c87f45cc745030715a6180758b94938dbf90d639d55652392` + + // Switch the Insight URL to something that will error out. + process.env.BLOCKBOOK_URL = "http://fakeurl/api/" + + const result = await txSingle(req, res) + + // Restore the saved URL. + process.env.BLOCKBOOK_URL = savedUrl + + assert.equal(res.statusCode, 500, "HTTP status code 500 expected.") + assert.include(result.error, "ENOTFOUND", "Error message expected") + } catch (err) { + // Restore the saved URL. + process.env.BLOCKBOOK_URL = savedUrl + } + }) + + it("should get tx details for a single txid", async () => { + req.params.txid = `5fe9b74056319a8c87f45cc745030715a6180758b94938dbf90d639d55652392` + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BLOCKBOOK_URL}`) + .get(uri => uri.includes("/")) + .reply(200, mockData.mockTx) + } + + // process.env.BLOCKBOOK_URL = `https://157.230.178.198:19131/` + // process.env.NODE_TLS_REJECT_UNAUTHORIZED = 0 + + // Call the details API. + const result = await txSingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.hasAnyKeys(result, [ + "txid", + "version", + "vin", + "vout", + "blockHash", + "blockHeight", + "confirmations", + "blockTime", + "value", + "valueIn", + "fees", + "hex" + ]) + + // Vin + assert.isArray(result.vin) + assert.hasAnyKeys(result.vin[0], [ + "txid", + "sequence", + "n", + "addresses", + "value", + "hex" + ]) + + // Vout + assert.isArray(result.vout) + assert.hasAnyKeys(result.vout[0], [ + "value", + "n", + "spent", + "hex", + "addresses" + ]) + }) + }) + + describe("#txBulk", () => { + // route handler + const txBulk = blockbookRoute.testableComponents.txBulk + + it("should throw an error for an empty body", async () => { + req.body = {} + + const result = await txBulk(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "txids need to be an array", + "Proper error message" + ) + }) + + it("should error on non-array single address", async () => { + req.body.txids = `5fe9b74056319a8c87f45cc745030715a6180758b94938dbf90d639d55652392` + + const result = await txBulk(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "txids need to be an array", + "Proper error message" + ) + }) + + 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 txBulk(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ["error"]) + assert.include(result.error, "Array too large") + }) + + it("should throw an error for an invalid address", async () => { + req.body = { + txids: [ + `5fe9b74056319a8c87f45cc745030715a6180758b94938dbf90d639d55652392`, + `abc` + ] + } + + const result = await txBulk(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "txid must be of length 64", + "Proper error message" + ) + }) + + it("should throw 500 when network issues", async () => { + const savedUrl = process.env.BLOCKBOOK_URL + + try { + req.body = { + txids: [ + `5fe9b74056319a8c87f45cc745030715a6180758b94938dbf90d639d55652392` + ] + } + + // Switch the Insight URL to something that will error out. + process.env.BLOCKBOOK_URL = "http://fakeurl/api/" + + const result = await txBulk(req, res) + //console.log(`network issue result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.BLOCKBOOK_URL = savedUrl + + assert.isAbove(res.statusCode, 499, "HTTP status code 500 expected.") + //assert.include(result.error, "ENOTFOUND", "Error message expected") + assert.include( + result.error, + "Network error: Could not communicate", + "Error message expected" + ) + } catch (err) { + // Restore the saved URL. + process.env.BLOCKBOOK_URL = savedUrl + } + }) + + it("should get details for a single address", async () => { + req.body = { + txids: [ + `5fe9b74056319a8c87f45cc745030715a6180758b94938dbf90d639d55652392` + ] + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(mockServerUrl) + .get(uri => uri.includes("/")) + .reply(200, mockData.mockTx) + } + + // Call the details API. + const result = await txBulk(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.hasAnyKeys(result[0], [ + "txid", + "version", + "vin", + "vout", + "blockHash", + "blockHeight", + "confirmations", + "blockTime", + "value", + "valueIn", + "fees", + "hex" + ]) + + // Vin + assert.isArray(result[0].vin) + assert.hasAnyKeys(result[0].vin[0], [ + "txid", + "sequence", + "n", + "addresses", + "value", + "hex" + ]) + + // Vout + assert.isArray(result[0].vout) + assert.hasAnyKeys(result[0].vout[0], [ + "value", + "n", + "spent", + "hex", + "addresses" + ]) + }) + + it("should get details for multiple txid", async () => { + req.body = { + txids: [ + `5fe9b74056319a8c87f45cc745030715a6180758b94938dbf90d639d55652392`, + `5fe9b74056319a8c87f45cc745030715a6180758b94938dbf90d639d55652392` + ] + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(mockServerUrl) + .get(uri => uri.includes("/")) + .times(2) + .reply(200, mockData.mockTx) + } + + // Call the details API. + const result = await txBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.equal(result.length, 2, "2 outputs for 2 inputs") + }) + }) }) diff --git a/test/v3/mocks/blockbook-mock.js b/test/v3/mocks/blockbook-mock.js index 1a3b89c..8246e80 100644 --- a/test/v3/mocks/blockbook-mock.js +++ b/test/v3/mocks/blockbook-mock.js @@ -28,7 +28,48 @@ const mockUtxos = [ } ] +const mockTx = { + txid: "5fe9b74056319a8c87f45cc745030715a6180758b94938dbf90d639d55652392", + version: 2, + vin: [ + { + txid: "85ddb8215fc3701a493cf1c450644c5ef32c55aaa2f48ae2d008944394f3e4d3", + sequence: 4294967295, + n: 0, + addresses: ["bchtest:qqmd9unmhkpx4pkmr6fkrr8rm6y77vckjvqe8aey35"], + value: "16983000648", + hex: + "47304402202378e55f4d02bb932498deef22dfc1f7a4984858c3b55017e225dd567172252e0220373d27710b5d42a72ac9725959f1605a912fee0ae86a7ad36e7d6d796f14ca29412103c346eee77a77a8d3e073dacc0532ca7a5b9747bc06d88bf091cac9f4bc8bb792" + } + ], + vout: [ + { + value: "16973000422", + n: 0, + spent: true, + hex: "76a91436d2f27bbd826a86db1e93618ce3de89ef33169388ac", + addresses: ["bchtest:qqmd9unmhkpx4pkmr6fkrr8rm6y77vckjvqe8aey35"] + }, + { + value: "10000000", + n: 1, + hex: "76a9140e5b4ad9008bb9a027b7e2d0ef958914e12db20788ac", + addresses: ["bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4"] + } + ], + blockHash: "00000000005242edac4635ac2375a454e801cc1be8b131b622328089731e5e30", + blockHeight: 1265275, + confirmations: 65402, + blockTime: 1540912733, + value: "16983000422", + valueIn: "16983000648", + fees: "226", + hex: + "0200000001d3e4f394439408d0e28af4a2aa552cf35e4c6450c4f13c491a70c35f21b8dd85000000006a47304402202378e55f4d02bb932498deef22dfc1f7a4984858c3b55017e225dd567172252e0220373d27710b5d42a72ac9725959f1605a912fee0ae86a7ad36e7d6d796f14ca29412103c346eee77a77a8d3e073dacc0532ca7a5b9747bc06d88bf091cac9f4bc8bb792ffffffff02e66eabf3030000001976a91436d2f27bbd826a86db1e93618ce3de89ef33169388ac80969800000000001976a9140e5b4ad9008bb9a027b7e2d0ef958914e12db20788ac00000000" +} + module.exports = { mockBalance, - mockUtxos + mockUtxos, + mockTx }