diff --git a/README.md b/README.md index eef63d7..f0b14a4 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,6 @@ This is a fork and alternative implementation of the [rest.bitcoin.com](https://github.com/Bitcoin-com/rest.bitcoin.com) repository. The function of this code is to create a REST API server to provide a common interface for working with a BCH full node and various indexers. -This repository is intended to be used for my own personal use, but it's licensed -under the MIT license like the original rest repo was, so people are free to use -it and modify it as they see fit. This repository is intended to be paired with my alternative implementation of [BITBOX SDK](https://github.com/Bitcoin-com/bitbox-sdk): diff --git a/src/app.js b/src/app.js index 751ed53..8413aea 100644 --- a/src/app.js +++ b/src/app.js @@ -56,6 +56,12 @@ const rawtransactionsV3 = require("./routes/v3/rawtransactions") const utilV3 = require("./routes/v3/util") const slpV3 = require("./routes/v3/slp") const xpubV3 = require("./routes/v3/xpub") +const bitcoreV3 = require("./routes/v3/bitcore") +const blockbookV3 = require("./routes/v3/blockbook") +const insightV3 = require("./routes/v3/insight") +const insightBlockV3 = require("./routes/v3/insight/block") +const insightTranactionV3 = require("./routes/v3/insight/transaction") +const insightAddressV3 = require("./routes/v3/insight/address") require("dotenv").config() @@ -135,6 +141,12 @@ app.use(`/${v3prefix}/` + `rawtransactions`, rawtransactionsV3.router) app.use(`/${v3prefix}/` + `util`, utilV3.router) app.use(`/${v3prefix}/` + `slp`, slpV3.router) app.use(`/${v3prefix}/` + `xpub`, xpubV3.router) +app.use(`/${v3prefix}/` + `bitcore`, bitcoreV3.router) +app.use(`/${v3prefix}/` + `blockbook`, blockbookV3.router) +app.use(`/${v3prefix}/` + `insight`, insightV3.router) +app.use(`/${v3prefix}/` + `insight/block`, insightBlockV3.router) +app.use(`/${v3prefix}/` + `insight/transaction`, insightTranactionV3.router) +app.use(`/${v3prefix}/` + `insight/address`, insightAddressV3.router) // catch 404 and forward to error handler app.use((req, res, next) => { diff --git a/src/routes/v3/insight/address.js b/src/routes/v3/insight/address.js new file mode 100644 index 0000000..a939120 --- /dev/null +++ b/src/routes/v3/insight/address.js @@ -0,0 +1,792 @@ +/* + Address route +*/ + +"use strict" + +const express = require("express") +const requestUtils = require("../services/requestUtils") +const axios = require("axios") +const routeUtils = require("../route-utils") +const wlogger = require("../../../util/winston-logging") + +//const router = express.Router() +const router = express.Router() + +// Used for processing error messages before sending them to the user. +const util = require("util") +util.inspect.defaultOptions = { depth: 1 } + +const BITBOXJS = require("@chris.troutner/bitbox-js") +const BITBOX = new BITBOXJS() + +// Use the default (and max) page size of 1000 +// https://github.com/bitpay/insight-api#notes-on-upgrading-from-v03 +const PAGE_SIZE = 1000 + +// Connect the route endpoints to their handler functions. +router.get("/", root) +router.get("/details/:address", detailsSingle) +router.post("/details", detailsBulk) +router.post("/utxo", utxoBulk) +router.get("/utxo/:address", utxoSingle) +router.post("/unconfirmed", unconfirmedBulk) +router.get("/unconfirmed/:address", unconfirmedSingle) +router.get("/transactions/:address", transactionsSingle) +router.post("/transactions", transactionsBulk) +router.get("/fromXPub/:xpub", fromXPubSingle) + +// Root API endpoint. Simply acknowledges that it exists. +function root(req, res, next) { + return res.json({ status: "address" }) +} + +// Query the Insight API for details on a single BCH address. +// Returns a Promise. +async function detailsFromInsight(thisAddress, currentPage = 0) { + try { + let addr + if ( + process.env.BITCOINCOM_BASEURL === "https://bch-insight.bitpay.com/api/" + ) + addr = BITBOX.Address.toCashAddress(thisAddress) + else addr = BITBOX.Address.toLegacyAddress(thisAddress) + + let path = `${process.env.BITCOINCOM_BASEURL}addr/${addr}` + + // Set from and to params based on currentPage and pageSize + // https://github.com/bitpay/insight-api/blob/master/README.md#notes-on-upgrading-from-v02 + const from = currentPage * PAGE_SIZE + const to = from + PAGE_SIZE + path = `${path}?from=${from}&to=${to}` + + // Query the Insight server. + const axiosResponse = await axios.get(path) + const retData = axiosResponse.data + //console.log(`retData: ${util.inspect(retData)}`) + + // Calculate pagesTotal from response + const pagesTotal = Math.ceil(retData.txApperances / PAGE_SIZE) + + // Append different address formats to the return data. + retData.legacyAddress = BITBOX.Address.toLegacyAddress(retData.addrStr) + retData.cashAddress = BITBOX.Address.toCashAddress(retData.addrStr) + delete retData.addrStr + + // Append pagination information to the return data. + retData.currentPage = currentPage + retData.pagesTotal = pagesTotal + + return retData + } catch (err) { + // Dev Note: Do not log error messages here. Throw them instead and let the + // parent function handle it. + throw err + } +} + +// POST handler for bulk queries on address details +// curl -d '{"addresses": ["bchtest:qzjtnzcvzxx7s0na88yrg3zl28wwvfp97538sgrrmr", "bchtest:qp6hgvevf4gzz6l7pgcte3gaaud9km0l459fa23dul"]}' -H "Content-Type: application/json" http://localhost:3000/v2/address/details +// curl -d '{"addresses": ["bchtest:qzjtnzcvzxx7s0na88yrg3zl28wwvfp97538sgrrmr", "bchtest:qp6hgvevf4gzz6l7pgcte3gaaud9km0l459fa23dul"], "from": 1, "to": 5}' -H "Content-Type: application/json" http://localhost:3000/v2/address/details +async function detailsBulk(req, res, next) { + try { + let addresses = req.body.addresses + const currentPage = req.body.page ? parseInt(req.body.page, 10) : 0 + + // Reject if addresses is not an array. + if (!Array.isArray(addresses)) { + res.status(400) + return res.json({ + error: "addresses needs to be an array. Use GET for single address." + }) + } + + // Enforce array size rate limits + if (!routeUtils.validateArraySize(req, addresses)) { + res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 + return res.json({ + error: `Array too large.` + }) + } + + wlogger.debug(`Executing address/details with these addresses: `, addresses) + + // Validate each element in the address array. + for (let i = 0; i < addresses.length; i++) { + const thisAddress = addresses[i] + + // Ensure the input is a valid BCH address. + try { + BITBOX.Address.toLegacyAddress(thisAddress) + } catch (err) { + res.status(400) + return res.json({ + error: `Invalid BCH address. Double check your address is valid: ${thisAddress}` + }) + } + + // Prevent a common user error. Ensure they are using the correct network address. + const networkIsValid = routeUtils.validateNetwork(thisAddress) + if (!networkIsValid) { + res.status(400) + return res.json({ + error: `Invalid network for address ${thisAddress}. Trying to use a testnet address on mainnet, or vice versa.` + }) + } + } + + // Loops through each address and creates an array of Promises, querying + // Insight API in parallel. + addresses = addresses.map(async (address, index) => + detailsFromInsight(address, currentPage) + ) + + // Wait for all parallel Insight requests to return. + const result = await axios.all(addresses) + + // 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 }) + } + + //logger.error(`Error in detailsBulk(): `, err) + wlogger.error(`Error in address.ts/detailsBulk().`, err) + + res.status(500) + return res.json({ error: util.inspect(err) }) + } +} + +// GET handler for single address details +async function detailsSingle(req, res, next) { + try { + const address = req.params.address + const currentPage = req.query.page ? parseInt(req.query.page, 10) : 0 + + if (!address || address === "") { + res.status(400) + return res.json({ error: "address can not be empty" }) + } + + // Reject if address is an array. + if (Array.isArray(address)) { + res.status(400) + return res.json({ + error: "address can not be an array. Use POST for bulk upload." + }) + } + + wlogger.debug( + `Executing address/detailsSingle with this address: `, + address + ) + + // Ensure the input is a valid BCH address. + try { + var legacyAddr = BITBOX.Address.toLegacyAddress(address) + } catch (err) { + res.status(400) + return res.json({ + error: `Invalid BCH address. Double check your address is valid: ${address}` + }) + } + + // Prevent a common user error. Ensure they are using the correct network address. + const networkIsValid = routeUtils.validateNetwork(address) + if (!networkIsValid) { + res.status(400) + return res.json({ + error: `Invalid network. Trying to use a testnet address on mainnet, or vice versa.` + }) + } + + // Query the Insight API. + const retData = await detailsFromInsight(address, currentPage) + + // 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. + //logger.error(`Error in address.ts/detailsSingle: `, err) + wlogger.error(`Error in address.ts/detailsSingle().`, err) + + res.status(500) + return res.json({ error: util.inspect(err) }) + } +} + +// Retrieve UTXO data from the Insight API +async function utxoFromInsight(thisAddress) { + try { + let addr + if ( + process.env.BITCOINCOM_BASEURL === "https://bch-insight.bitpay.com/api/" + ) + addr = BITBOX.Address.toCashAddress(thisAddress) + else addr = BITBOX.Address.toLegacyAddress(thisAddress) + + const path = `${process.env.BITCOINCOM_BASEURL}addr/${addr}/utxo` + + // Query the Insight server. + const response = await axios.get(path) + + // Append different address formats to the return data. + const retData = { + utxos: Array, + legacyAddress: String, + cashAddress: String, + scriptPubKey: String + } + if (response.data.length && response.data[0].scriptPubKey) { + const spk = response.data[0].scriptPubKey + retData.scriptPubKey = spk + } + retData.legacyAddress = BITBOX.Address.toLegacyAddress(thisAddress) + retData.cashAddress = BITBOX.Address.toCashAddress(thisAddress) + retData.utxos = response.data.map(utxo => { + delete utxo.address + delete utxo.scriptPubKey + return utxo + }) + //console.log(`utxoFromInsight retData: ${util.inspect(retData)}`) + + return retData + } catch (err) { + // Dev Note: Do not log error messages here. Throw them instead and let the + // parent function handle it. + throw err + } +} + +// Retrieve UTXO information for an address. +async function utxoBulk(req, res, next) { + try { + let addresses = req.body.addresses + + // Reject if address is not an array. + if (!Array.isArray(addresses)) { + res.status(400) + return res.json({ error: "addresses needs to be an array" }) + } + + // Enforce array size rate limits + if (!routeUtils.validateArraySize(req, addresses)) { + res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 + return res.json({ + error: `Array too large.` + }) + } + + // Validate each element in the address array. + for (let i = 0; i < addresses.length; i++) { + const thisAddress = addresses[i] + + // Ensure the input is a valid BCH address. + try { + BITBOX.Address.toLegacyAddress(thisAddress) + } catch (er) { + //if (er.message.includes("Unsupported address format")) + res.status(400) + return res.json({ + error: `Invalid BCH address. Double check your address is valid: ${thisAddress}` + }) + } + + // Prevent a common user error. Ensure they are using the correct network address. + const networkIsValid = routeUtils.validateNetwork(thisAddress) + if (!networkIsValid) { + res.status(400) + return res.json({ + error: `Invalid network for address ${thisAddress}. Trying to use a testnet address on mainnet, or vice versa.` + }) + } + } + + wlogger.debug( + `Executing address/utxoBulk with these addresses: `, + addresses + ) + + // Loops through each address and creates an array of Promises, querying + // Insight API in parallel. + addresses = addresses.map(async (address, index) => + utxoFromInsight(address) + ) + + // Wait for all parallel Insight requests to return. + const result = await axios.all(addresses) + + 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 }) + } + + // Write out error to error log. + //logger.error(`Error in rawtransactions/decodeRawTransaction: `, err) + wlogger.error(`Error in address.ts/utxoBulk().`, err) + + res.status(500) + return res.json({ error: util.inspect(err) }) + } +} + +// GET handler for single address details +async function utxoSingle(req, res, next) { + try { + const address = req.params.address + if (!address || address === "") { + res.status(400) + return res.json({ error: "address can not be empty" }) + } + + // Reject if address is an array. + if (Array.isArray(address)) { + res.status(400) + return res.json({ + error: "address can not be an array. Use POST for bulk upload." + }) + } + + wlogger.debug(`Executing address/utxoSingle with this address: `, address) + + // Ensure the input is a valid BCH address. + try { + var legacyAddr = BITBOX.Address.toLegacyAddress(address) + } catch (err) { + res.status(400) + return res.json({ + error: `Invalid BCH address. Double check your address is valid: ${address}` + }) + } + + // Prevent a common user error. Ensure they are using the correct network address. + const networkIsValid = routeUtils.validateNetwork(address) + if (!networkIsValid) { + res.status(400) + return res.json({ + error: `Invalid network. Trying to use a testnet address on mainnet, or vice versa.` + }) + } + + // Query the Insight API. + const retData = await utxoFromInsight(address) + + // Return the array of 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. + //logger.error(`Error in rawtransactions/decodeRawTransaction: `, err) + wlogger.error(`Error in address.ts/utxoSingle().`, err) + + res.status(500) + return res.json({ error: util.inspect(err) }) + } +} + +// Retrieve any unconfirmed TX information for a given address. +async function unconfirmedBulk(req, res, next) { + try { + const addresses = req.body.addresses + + // Reject if address is not an array. + if (!Array.isArray(addresses)) { + res.status(400) + return res.json({ error: "addresses needs to be an array" }) + } + + wlogger.debug(`Executing address/utxo with these addresses: `, addresses) + + // Enforce array size rate limits + if (!routeUtils.validateArraySize(req, addresses)) { + res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 + return res.json({ + error: `Array too large.` + }) + } + + // Validate each element in the address array. + for (let i = 0; i < addresses.length; i++) { + const thisAddress = addresses[i] + + // Ensure the input is a valid BCH address. + try { + BITBOX.Address.toLegacyAddress(thisAddress) + } catch (err) { + res.status(400) + return res.json({ + error: `Invalid BCH address. Double check your address is valid: ${thisAddress}` + }) + } + + // Prevent a common user error. Ensure they are using the correct network address. + const networkIsValid = routeUtils.validateNetwork(thisAddress) + if (!networkIsValid) { + res.status(400) + return res.json({ + error: `Invalid network for address ${thisAddress}. Trying to use a testnet address on mainnet, or vice versa.` + }) + } + } + + // Collect an array of promises. + const promises = addresses.map(address => utxoFromInsight(address)) + + // Wait for all parallel Insight requests to return. + const result = await axios.all(promises) + + // Loop through each result + const finalResult = result.map(elem => { + //console.log(`elem: ${util.inspect(elem)}`) + + // Filter out confirmed transactions. + const unconfirmedUtxos = elem.utxos.filter( + utxo => utxo.confirmations === 0 + ) + + elem.utxos = unconfirmedUtxos + + return elem + }) + + // Return the array of retrieved address information. + res.status(200) + return res.json(finalResult) + } 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. + //logger.error(`Error in rawtransactions/decodeRawTransaction: `, err) + wlogger.error(`Error in address.ts/unconfirmedBulk().`, err) + + res.status(500) + return res.json({ error: util.inspect(err) }) + } +} + +// GET handler. Retrieve any unconfirmed TX information for a given address. +async function unconfirmedSingle(req, res, next) { + try { + const address = req.params.address + if (!address || address === "") { + res.status(400) + return res.json({ error: "address can not be empty" }) + } + + // Reject if address is an array. + if (Array.isArray(address)) { + res.status(400) + return res.json({ + error: "address can not be an array. Use POST for bulk upload." + }) + } + + wlogger.debug(`Executing address/utxoSingle with this address: `, address) + + // Ensure the input is a valid BCH address. + try { + var legacyAddr = BITBOX.Address.toLegacyAddress(address) + } catch (err) { + res.status(400) + return res.json({ + error: `Invalid BCH address. Double check your address is valid: ${address}` + }) + } + + // Prevent a common user error. Ensure they are using the correct network address. + const networkIsValid = routeUtils.validateNetwork(address) + if (!networkIsValid) { + res.status(400) + return res.json({ + error: `Invalid network. Trying to use a testnet address on mainnet, or vice versa.` + }) + } + + // Query the Insight API. + const retData = await utxoFromInsight(address) + //console.log(`retData: ${JSON.stringify(retData,null,2)}`) + + // Loop through each returned UTXO. + const unconfirmedUTXOs = [] + for (let j = 0; j < retData.utxos.length; j++) { + const thisUtxo = retData.utxos[j] + + // Only interested in UTXOs with no confirmations. + if (thisUtxo.confirmations === 0) unconfirmedUTXOs.push(thisUtxo) + } + + retData.utxos = unconfirmedUTXOs + + // Return the array of 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. + //logger.error(`Error in rawtransactions/decodeRawTransaction: `, err) + wlogger.error(`Error in address.ts/unconfirmedSingle().`, err) + + res.status(500) + return res.json({ error: util.inspect(err) }) + } +} + +// Retrieve transaction data from the Insight API +async function transactionsFromInsight(thisAddress, currentPage = 0) { + try { + const path = `${process.env.BITCOINCOM_BASEURL}txs/?address=${thisAddress}&pageNum=${currentPage}` + + // Query the Insight server. + const response = await axios.get(path) + + // Append different address formats to the return data. + const retData = response.data + retData.legacyAddress = BITBOX.Address.toLegacyAddress(thisAddress) + retData.cashAddress = BITBOX.Address.toCashAddress(thisAddress) + retData.currentPage = currentPage + + return retData + } catch (err) { + // Dev Note: Do not log error messages here. Throw them instead and let the + // parent function handle it. + throw err + } +} + +// Get an array of TX information for a given address. +async function transactionsBulk(req, res, next) { + try { + let addresses = req.body.addresses + const currentPage = req.body.page ? parseInt(req.body.page, 10) : 0 + + // Reject if address is not an array. + if (!Array.isArray(addresses)) { + res.status(400) + return res.json({ error: "addresses needs to be an array" }) + } + + wlogger.debug(`Executing address/utxo with these addresses: `, addresses) + + // Enforce array size rate limits + if (!routeUtils.validateArraySize(req, addresses)) { + res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 + return res.json({ + error: `Array too large.` + }) + } + + // Validate each element in the address array. + for (let i = 0; i < addresses.length; i++) { + const thisAddress = addresses[i] + + // Ensure the input is a valid BCH address. + try { + BITBOX.Address.toLegacyAddress(thisAddress) + } catch (err) { + res.status(400) + return res.json({ + error: `Invalid BCH address. Double check your address is valid: ${thisAddress}` + }) + } + + // Prevent a common user error. Ensure they are using the correct network address. + const networkIsValid = routeUtils.validateNetwork(thisAddress) + if (!networkIsValid) { + res.status(400) + return res.json({ + error: `Invalid network for address ${thisAddress}. Trying to use a testnet address on mainnet, or vice versa.` + }) + } + } + + // Loop through each address and collect an array of promises. + addresses = addresses.map(async (address, index) => + transactionsFromInsight(address, currentPage) + ) + + // Wait for all parallel Insight requests to return. + const result = await axios.all(addresses) + + // 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 }) + } + + // Write out error to error log. + //logger.error(`Error in rawtransactions/decodeRawTransaction: `, err) + wlogger.error(`Error in address.ts/transactionsBulk().`, err) + + res.status(500) + return res.json({ error: util.inspect(err) }) + } +} + +// GET handler. Retrieve any unconfirmed TX information for a given address. +async function transactionsSingle(req, res, next) { + try { + const address = req.params.address + const currentPage = req.query.page ? parseInt(req.query.page, 10) : 0 + + if (!address || address === "") { + res.status(400) + return res.json({ error: "address can not be empty" }) + } + + // Reject if address is an array. + if (Array.isArray(address)) { + res.status(400) + return res.json({ + error: "address can not be an array. Use POST for bulk upload." + }) + } + + wlogger.debug( + `Executing address/transactionsSingle with this address: `, + address + ) + + // Ensure the input is a valid BCH address. + try { + var legacyAddr = BITBOX.Address.toLegacyAddress(address) + } catch (err) { + res.status(400) + return res.json({ + error: `Invalid BCH address. Double check your address is valid: ${address}` + }) + } + + // Prevent a common user error. Ensure they are using the correct network address. + const networkIsValid = routeUtils.validateNetwork(address) + if (!networkIsValid) { + res.status(400) + return res.json({ + error: `Invalid network. Trying to use a testnet address on mainnet, or vice versa.` + }) + } + + // Query the Insight API. + const retData = await transactionsFromInsight(address, currentPage) + //console.log(`retData: ${JSON.stringify(retData,null,2)}`) + + // Return the array of 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. + //logger.error(`Error in rawtransactions/decodeRawTransaction: `, err) + wlogger.error(`Error in address.ts/transactionsSingle().`, err) + + res.status(500) + return res.json({ error: util.inspect(err) }) + } +} + +async function fromXPubSingle(req, res, next) { + try { + const xpub = req.params.xpub + const hdPath = req.query.hdPath ? req.query.hdPath : "0" + + if (!xpub || xpub === "") { + res.status(400) + return res.json({ error: "xpub can not be empty" }) + } + + // Reject if xpub is an array. + if (Array.isArray(xpub)) { + res.status(400) + return res.json({ + error: "xpub can not be an array. Use POST for bulk upload." + }) + } + + wlogger.debug(`Executing address/fromXPub with this xpub: `, xpub) + + const cashAddr = BITBOX.Address.fromXPub(xpub, hdPath) + const legacyAddr = BITBOX.Address.toLegacyAddress(cashAddr) + res.status(200) + return res.json({ + cashAddress: cashAddr, + legacyAddress: legacyAddr + }) + } 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. + //logger.error(`Error in rawtransactions/decodeRawTransaction: `, err) + wlogger.error(`Error in address.ts/fromXPubSingle().`, err) + + res.status(500) + return res.json({ error: util.inspect(err) }) + } +} + +module.exports = { + router, + testableComponents: { + root, + detailsBulk, + detailsSingle, + utxoBulk, + utxoSingle, + unconfirmedBulk, + unconfirmedSingle, + transactionsBulk, + transactionsSingle, + fromXPubSingle + } +} diff --git a/src/routes/v3/insight/block.js b/src/routes/v3/insight/block.js new file mode 100644 index 0000000..ce8a964 --- /dev/null +++ b/src/routes/v3/insight/block.js @@ -0,0 +1,279 @@ +"use strict" + +const express = require("express") +const requestUtils = require("../services/requestUtils") +const axios = require("axios") +const bitbox = require("../services/bitbox") +const wlogger = require("../../../util/winston-logging") + +const routeUtils = require("../route-utils") + +// Used for processing error messages before sending them to the user. +const util = require("util") +util.inspect.defaultOptions = { depth: 3 } + +const router = express.Router() +//const BitboxHTTP = bitbox.getInstance() + +router.get("/", root) +router.get("/detailsByHash/:hash", detailsByHashSingle) +router.post("/detailsByHash", detailsByHashBulk) +router.get("/detailsByHeight/:height", detailsByHeightSingle) +router.post("/detailsByHeight", detailsByHeightBulk) + +function root(req, res, next) { + return res.json({ status: "block" }) +} + +// Call the insight server to get block details based on the hash. +async function detailsByHashSingle(req, res, next) { + try { + const hash = req.params.hash + + // Reject if hash is empty + if (!hash || hash === "") { + res.status(400) + return res.json({ error: "hash must not be empty" }) + } + + const response = await axios.get( + `${process.env.BITCOINCOM_BASEURL}block/${hash}` + ) + //console.log(`response.data: ${JSON.stringify(response.data,null,2)}`) + + const parsed = response.data + return res.json(parsed) + } catch (error) { + //console.log(`error object: ${util.inspect(error)}`) + + // Attempt to decode the error message. + const { msg, status } = routeUtils.decodeError(error) + if (msg) { + res.status(status) + return res.json({ error: msg }) + } + + if (error.response && error.response.status === 404) { + res.status(404) + return res.json({ error: "Not Found" }) + } + + // Write out error to error log. + //logger.error(`Error in block/detailsByHash: `, error) + wlogger.error(`Error in block.ts/detailsByHashSingle().`, error) + + res.status(500) + return res.json({ error: util.inspect(error) }) + } +} + +async function detailsByHashBulk(req, res, next) { + try { + const hashes = req.body.hashes + + // Reject if hashes is not an array. + if (!Array.isArray(hashes)) { + res.status(400) + return res.json({ + error: "hashes needs to be an array. Use GET for single address." + }) + } + + // Enforce array size rate limits + if (!routeUtils.validateArraySize(req, hashes)) { + res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 + return res.json({ + error: `Array too large.` + }) + } + + // Validate each hash in the array. + for (let i = 0; i < hashes.length; i++) { + const thisHash = hashes[i] + + if (thisHash.length !== 64) { + res.status(400) + return res.json({ + error: `Invalid hash. Double check your hash is valid: ${thisHash}` + }) + } + } + + // Loop through each hash and creates an array of promises + const axiosPromises = hashes.map(async hash => + axios.get(`${process.env.BITCOINCOM_BASEURL}block/${hash}`) + ) + + // Wait for all parallel promises to return. + const axiosResult = await axios.all(axiosPromises) + + // Extract the data component from the axios response. + const result = axiosResult.map(x => x.data) + //console.log(`result: ${util.inspect(result)}`) + + res.status(200) + return res.json(result) + } catch (error) { + // Attempt to decode the error message. + const { msg, status } = routeUtils.decodeError(error) + if (msg) { + res.status(status) + return res.json({ error: msg }) + } + + if (error.response && error.response.status === 404) { + res.status(404) + return res.json({ error: "Not Found" }) + } + + // Write out error to error log. + //logger.error(`Error in block/detailsByHash: `, error) + wlogger.error(`Error in block.ts/detailsByHashBulk().`, error) + + res.status(500) + return res.json({ error: util.inspect(error) }) + } +} + +// Call the Full Node to get block hash based on height, then call the Insight +// server to get details from that hash. +async function detailsByHeightSingle(req, res, next) { + try { + const height = req.params.height + + // Reject if id is empty + if (!height || height === "") { + res.status(400) + return res.json({ error: "height must not be empty" }) + } + + const { + BitboxHTTP, + username, + password, + requestConfig + } = routeUtils.setEnvVars() + + requestConfig.data.id = "getblockhash" + requestConfig.data.method = "getblockhash" + requestConfig.data.params = [parseInt(height)] + + const response = await BitboxHTTP(requestConfig) + + const hash = response.data.result + //console.log(`response.data: ${util.inspect(response.data)}`) + + // Call detailsByHashSingle now that the hash has been retrieved. + req.params.hash = hash + return detailsByHashSingle(req, res, next) + } 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. + //logger.error(`Error in control/getInfo: `, error) + wlogger.error(`Error in block.ts/detailsByHeightSingle().`, err) + + res.status(500) + return res.json({ error: util.inspect(err) }) + } +} + +async function detailsByHeightBulk(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({ + error: "heights needs to be an array. Use GET for single height." + }) + } + + // Enforce array size rate limits + if (!routeUtils.validateArraySize(req, heights)) { + res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 + return res.json({ + error: `Array too large.` + }) + } + + wlogger.debug(`Executing detailsByHeight with these heights: `, heights) + + // Validate each element in the address array. + for (let i = 0; i < heights.length; i++) { + const thisHeight = heights[i] + + // Reject if id is empty + if (!thisHeight || thisHeight === "") { + res.status(400) + return res.json({ error: "height must not be empty" }) + } + } + + const { + BitboxHTTP, + username, + password, + requestConfig + } = routeUtils.setEnvVars() + + // Loop through each height and creates an array of requests to call in parallel + const promises = heights.map(async height => { + requestConfig.data.id = "getblockhash" + requestConfig.data.method = "getblockhash" + requestConfig.data.params = [parseInt(height)] + + const response = await BitboxHTTP(requestConfig) + + const hash = response.data.result + + const axiosResult = await axios.get( + `${process.env.BITCOINCOM_BASEURL}block/${hash}` + ) + + return axiosResult.data + }) + + // Wait for all parallel Insight requests to return. + const result = await axios.all(promises) + + res.status(200) + return res.json(result) + } catch (error) { + // Attempt to decode the error message. + const { msg, status } = routeUtils.decodeError(error) + if (msg) { + res.status(status) + return res.json({ error: msg }) + } + + if (error.response && error.response.status === 404) { + res.status(404) + return res.json({ error: "Not Found" }) + } + + // Write out error to error log. + //logger.error(`Error in block/detailsByHash: `, error) + wlogger.error(`Error in block.ts/detailsByHeightBulk().`, error) + + res.status(500) + return res.json({ error: util.inspect(error) }) + } +} + +module.exports = { + router, + testableComponents: { + root, + detailsByHashSingle, + detailsByHashBulk, + detailsByHeightSingle, + detailsByHeightBulk + } +} diff --git a/src/routes/v3/insight/index.js b/src/routes/v3/insight/index.js new file mode 100644 index 0000000..ad5037d --- /dev/null +++ b/src/routes/v3/insight/index.js @@ -0,0 +1,38 @@ +/* + Insight API route + A parent route that encompases the other libraries. +*/ + +"use strict" + +const express = require("express") +const requestUtils = require("../services/requestUtils") +const axios = require("axios") +const routeUtils = require("../route-utils") +const wlogger = require("../../../util/winston-logging") + +const router = express.Router() + +// Used for processing error messages before sending them to the user. +const util = require("util") +util.inspect.defaultOptions = { depth: 1 } + +const BITBOXJS = require("@chris.troutner/bitbox-js") +const BITBOX = new BITBOXJS() + +//const BITCORE_URL = process.env.BITCORE_URL + +// Connect the route endpoints to their handler functions. +router.get("/", root) + +// Root API endpoint. Simply acknowledges that it exists. +function root(req, res, next) { + return res.json({ status: "address" }) +} + +module.exports = { + router, + testableComponents: { + root + } +} diff --git a/src/routes/v3/insight/transaction.js b/src/routes/v3/insight/transaction.js new file mode 100644 index 0000000..ccb16e9 --- /dev/null +++ b/src/routes/v3/insight/transaction.js @@ -0,0 +1,187 @@ +"use strict" + +const express = require("express") +const router = express.Router() +const axios = require("axios") + +const routeUtils = require("../route-utils") +const wlogger = require("../../../util/winston-logging") + +const BITBOXJS = require("@chris.troutner/bitbox-js") +const BITBOX = new BITBOXJS() + +// Used to convert error messages to strings, to safely pass to users. +const util = require("util") +util.inspect.defaultOptions = { depth: 3 } + +// Manipulates and formats the raw data comming from Insight API. +const processInputs = tx => { + // Add legacy and cashaddr to tx vin + if (tx.vin) { + tx.vin.forEach(vin => { + if (!vin.coinbase) { + vin.value = vin.valueSat + const address = vin.addr + if (address) { + vin.legacyAddress = BITBOX.Address.toLegacyAddress(address) + vin.cashAddress = BITBOX.Address.toCashAddress(address) + delete vin.addr + } + delete vin.valueSat + delete vin.doubleSpentTxID + } + }) + } + + // Add legacy and cashaddr to tx vout + if (tx.vout) { + tx.vout.forEach(vout => { + // Overwrite value string with value in satoshis + //vout.value = parseFloat(vout.value) * 100000000 + + if (vout.scriptPubKey) { + if (vout.scriptPubKey.addresses) { + const cashAddrs = [] + vout.scriptPubKey.addresses.forEach(addr => { + const cashAddr = BITBOX.Address.toCashAddress(addr) + cashAddrs.push(cashAddr) + }) + vout.scriptPubKey.cashAddrs = cashAddrs + } + } + }) + } +} + +router.get("/", root) +router.post("/details", detailsBulk) +router.get("/details/:txid", detailsSingle) + +function root(req, res, next) { + return res.json({ status: "transaction" }) +} + +// Retrieve transaction data from the Insight API +// This function is also used by the SLP route library. +async function transactionsFromInsight(txid) { + try { + const path = `${process.env.BITCOINCOM_BASEURL}tx/${txid}` + + // Query the Insight server. + const response = await axios.get(path) + //console.log(`Insight output: ${JSON.stringify(response.data, null, 2)}`) + + // Parse the data. + const parsed = response.data + if (parsed) processInputs(parsed) + + return parsed + } catch (err) { + // Dev Note: Do not log error messages here. Throw them instead and let the + // parent function handle it. + throw err + } +} + +async function detailsBulk(req, res, next) { + try { + const txids = req.body.txids + + // Reject if address is not an array. + if (!Array.isArray(txids)) { + res.status(400) + return res.json({ error: "txids needs to be an array" }) + } + + // 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 transaction/details with these txids: `, txids) + + // Collect an array of promises + const promises = txids.map( + async txid => await transactionsFromInsight(txid) + ) + + // Wait for all parallel promises to return. + const result = await Promise.all(promises) + + // Return the array of retrieved transaction 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 transactions.ts/detailsBulk().`, err) + + //console.log(`Error in transaction details: `, err) + res.status(500) + return res.json({ error: util.inspect(err) }) + } +} + +// GET handler. Retrieve any unconfirmed TX information for a given address. +async function detailsSingle(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." + }) + } + + wlogger.debug( + `Executing transaction.ts/detailsSingle with this txid: `, + txid + ) + + // Query the Insight API. + const retData = await transactionsFromInsight(txid) + //console.log(`retData: ${JSON.stringify(retData,null,2)}`) + + // Return the array of 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. + //logger.error(`Error in rawtransactions/decodeRawTransaction: `, err) + wlogger.error(`Error in transactions.ts/detailsSingle().`, err) + + res.status(500) + return res.json({ error: util.inspect(err) }) + } +} + +module.exports = { + router, + transactionsFromInsight, + testableComponents: { + root, + detailsBulk, + detailsSingle + } +} diff --git a/test/v3/address.js b/test/v3/address.js new file mode 100644 index 0000000..c1cafdc --- /dev/null +++ b/test/v3/address.js @@ -0,0 +1,1373 @@ +/* + TESTS FOR THE ADDRESS.JS LIBRARY + + This test file uses the environment variable TEST to switch between unit + and integration tests. By default, TEST is set to 'unit'. Set this variable + to 'integration' to run the tests against BCH mainnet. + + To-Do: + -/details/:address + --Verify to/from query options work correctly. + -GET /unconfirmed/:address & POST /unconfirmed + --Should initiate a transfer of BCH to verify unconfirmed TX. + ---This would be more of an e2e test. +*/ + +"use strict" + +const chai = require("chai") +const assert = chai.assert +const addressRoute = require("../../src/routes/v3/insight/address") +const nock = require("nock") // HTTP mocking + +let originalUrl // Used during transition from integration to unit tests. + +// Mocking data. +const { mockReq, mockRes } = require("./mocks/express-mocks") +const mockData = require("./mocks/address-mock") + +// Used for debugging. +const util = require("util") +util.inspect.defaultOptions = { depth: 1 } + +describe("#AddressRouter", () => { + let req, res + + before(() => { + originalUrl = process.env.BITCOINCOM_BASEURL + + // Set default environment variables for unit tests. + if (!process.env.TEST) process.env.TEST = "unit" + if (process.env.TEST === "unit") + process.env.BITCOINCOM_BASEURL = "http://fakeurl/api/" + + // console.log(`Testing type is: ${process.env.TEST}`) + }) + + // Setup the mocks before each test. + beforeEach(() => { + // Mock the req and res objects used by Express routes. + req = mockReq + res = mockRes + + // Explicitly reset the parmas and body. + req.params = {} + req.body = {} + req.query = {} + + // Activate nock if it's inactive. + if (!nock.isActive()) nock.activate() + }) + + afterEach(() => { + // Clean up HTTP mocks. + nock.cleanAll() // clear interceptor list. + nock.restore() + }) + + after(() => { + process.env.BITCOINCOM_BASEURL = originalUrl + }) + + describe("#root", () => { + // root route handler. + const root = addressRoute.testableComponents.root + + it("should respond to GET for base route", async () => { + const result = root(req, res) + + assert.equal(result.status, "address", "Returns static string") + }) + }) + + describe("#AddressDetailsBulk", () => { + // details route handler. + const detailsBulk = addressRoute.testableComponents.detailsBulk + + it("should throw an error for an empty body", async () => { + req.body = {} + + const result = await detailsBulk(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "addresses needs to be an array", + "Proper error message" + ) + }) + + it("should error on non-array single address", async () => { + req.body = { + address: `qzs02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c` + } + + const result = await detailsBulk(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "addresses needs to be an array", + "Proper error message" + ) + }) + + it("should throw an error for an invalid address", async () => { + req.body = { + addresses: [`02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c`] + } + + const result = await detailsBulk(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "Invalid BCH address", + "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.addresses = testArray + + const result = await detailsBulk(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ["error"]) + assert.include(result.error, "Array too large") + }) + + it("should detect a network mismatch", async () => { + req.body = { + addresses: [`bitcoincash:qqqvv56zepke5k0xeaehlmjtmkv9ly2uzgkxpajdx3`] + } + + const result = await detailsBulk(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include(result.error, "Invalid network", "Proper error message") + }) + + it("should throw 500 when network issues", async () => { + const savedUrl = process.env.BITCOINCOM_BASEURL + + try { + req.body = { + addresses: [`bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4`] + } + + // Switch the Insight URL to something that will error out. + process.env.BITCOINCOM_BASEURL = "http://fakeurl/api/" + + const result = await detailsBulk(req, res) + //console.log(`network issue result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.BITCOINCOM_BASEURL = 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.BITCOINCOM_BASEURL = savedUrl + } + }) + + it("should default to page 0", async () => { + req.body = { + addresses: [`bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4`] + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/addr/mgps7qxk2Z5ma4mXsviznnet8wx4VvMPFz?from=0&to=1000`) + .reply(200, mockData.mockAddressDetails) + } + + // Call the details API. + const result = await detailsBulk(req, res) + //console.log(`result: ${util.inspect(result)}`) + + // Assert current page defaults to 0 + assert.equal(result[0].currentPage, 0) + }) + + it("should process the requested page", async () => { + req.body = { + addresses: [`bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4`], + page: 5 + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/addr/mgps7qxk2Z5ma4mXsviznnet8wx4VvMPFz?from=5000&to=6000`) + .reply(200, mockData.mockAddressDetails) + } + + // Call the details API. + const result = await detailsBulk(req, res) + //console.log(`result: ${util.inspect(result)}`) + + // Assert current page is same as requested + assert.equal(result[0].currentPage, 5) + }) + + it("should calculate the total number of pages", async () => { + req.body = { + addresses: [`bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4`] + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/addr/mgps7qxk2Z5ma4mXsviznnet8wx4VvMPFz?from=0&to=1000`) + .reply(200, mockData.mockAddressDetails) + } + + // Call the details API. + const result = await detailsBulk(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.equal(result[0].pagesTotal, 1) + }) + + it("should get details for a single address", async () => { + req.body = { + addresses: [`bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4`] + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/addr/mgps7qxk2Z5ma4mXsviznnet8wx4VvMPFz?from=0&to=1000`) + .reply(200, mockData.mockAddressDetails) + } + + // Call the details API. + const result = await detailsBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Assert that required fields exist in the returned object. + assert.equal(result.length, 1, "Array with one entry") + assert.hasAllKeys(result[0], [ + "balance", + "balanceSat", + "totalReceived", + "totalReceivedSat", + "totalSent", + "totalSentSat", + "unconfirmedBalance", + "unconfirmedBalanceSat", + "unconfirmedTxApperances", + "txApperances", + "transactions", + "legacyAddress", + "cashAddress", + "currentPage", + "pagesTotal" + ]) + }) + + it("should get details for multiple addresses", async () => { + req.body = { + addresses: [ + `bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4`, + `bchtest:qzknfggae0av6yvxk77gmyq7syc67yux6sk80haqyr` + ] + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/addr/mgps7qxk2Z5ma4mXsviznnet8wx4VvMPFz?from=0&to=1000`) + .reply(200, mockData.mockAddressDetails) + + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/addr/mwJnEzXzKkveF2q5Af9jxi9j1zrtWAnPU8?from=0&to=1000`) + .reply(200, mockData.mockAddressDetails) + } + + // Call the details API. + const result = await detailsBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.equal(result.length, 2, "2 outputs for 2 inputs") + }) + }) + + describe("#AddressDetailsSingle", () => { + // details route handler. + const detailsSingle = addressRoute.testableComponents.detailsSingle + + it("should throw 400 if address is empty", async () => { + const result = await detailsSingle(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ["error"]) + assert.include(result.error, "address can not be empty") + }) + + it("should error on an array", async () => { + req.params.address = [`qzs02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c`] + + const result = await detailsSingle(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "address can not be an array", + "Proper error message" + ) + }) + + it("should throw an error for an invalid address", async () => { + req.params.address = `02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c` + + const result = await detailsSingle(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "Invalid BCH address", + "Proper error message" + ) + }) + + it("should detect a network mismatch", async () => { + req.params.address = `bitcoincash:qqqvv56zepke5k0xeaehlmjtmkv9ly2uzgkxpajdx3` + + const result = await detailsSingle(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include(result.error, "Invalid network", "Proper error message") + }) + + it("should throw 500 when network issues", async () => { + const savedUrl = process.env.BITCOINCOM_BASEURL + + try { + req.params.address = `qzs02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c` + + // Switch the Insight URL to something that will error out. + process.env.BITCOINCOM_BASEURL = "http://fakeurl/api/" + + const result = await detailsSingle(req, res) + + // Restore the saved URL. + process.env.BITCOINCOM_BASEURL = 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.BITCOINCOM_BASEURL = savedUrl + } + }) + + it("should default to page 0", async () => { + req.params.address = `bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4` + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/addr/mgps7qxk2Z5ma4mXsviznnet8wx4VvMPFz?from=0&to=1000`) + .reply(200, mockData.mockAddressDetails) + } + + // Call the details API. + const result = await detailsSingle(req, res) + //console.log(`result: ${util.inspect(result)}`) + + // Assert current page defaults to 0 + assert.equal(result.currentPage, 0) + }) + + it("should process the requested page", async () => { + req.params.address = `bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4` + req.query.page = 5 + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/addr/mgps7qxk2Z5ma4mXsviznnet8wx4VvMPFz?from=5000&to=6000`) + .reply(200, mockData.mockAddressDetails) + } + + // Call the details API. + const result = await detailsSingle(req, res) + + // Assert current page is same as requested + assert.equal(result.currentPage, 5) + }) + + it("should calculate the total number of pages", async () => { + req.params.address = `bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4` + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/addr/mgps7qxk2Z5ma4mXsviznnet8wx4VvMPFz?from=0&to=1000`) + .reply(200, mockData.mockAddressDetails) + } + + // Call the details API. + const result = await detailsSingle(req, res) + + assert.equal(result.pagesTotal, 1) + }) + + it("should get details for a single address", async () => { + req.params.address = `bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4` + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/addr/mgps7qxk2Z5ma4mXsviznnet8wx4VvMPFz?from=0&to=1000`) + .reply(200, mockData.mockAddressDetails) + } + + // Call the details API. + const result = await detailsSingle(req, res) + //console.log(`result: ${util.inspect(result)}`) + + // Assert that required fields exist in the returned object. + assert.hasAllKeys(result, [ + "balance", + "balanceSat", + "totalReceived", + "totalReceivedSat", + "totalSent", + "totalSentSat", + "unconfirmedBalance", + "unconfirmedBalanceSat", + "unconfirmedTxApperances", + "txApperances", + "transactions", + "legacyAddress", + "cashAddress", + "currentPage", + "pagesTotal" + ]) + }) + }) + + describe("#AddressUtxoBulk", () => { + // utxo route handler. + const utxoBulk = addressRoute.testableComponents.utxoBulk + + it("should throw an error for an empty body", async () => { + req.body = {} + + const result = await utxoBulk(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "addresses needs to be an array", + "Proper error message" + ) + }) + + it("should error on non-array single address", async () => { + req.body = { + address: `qzs02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c` + } + + const result = await utxoBulk(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "addresses needs to be an array", + "Proper error message" + ) + }) + + it("should throw an error for an invalid address", async () => { + req.body = { + addresses: [`02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c`] + } + + const result = await utxoBulk(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "Invalid BCH address", + "Proper error message" + ) + }) + + it("should detect a network mismatch", async () => { + req.body = { + addresses: [`bitcoincash:qqqvv56zepke5k0xeaehlmjtmkv9ly2uzgkxpajdx3`] + } + + const result = await utxoBulk(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include(result.error, "Invalid network", "Proper error message") + }) + + it("should throw 500 when network issues", async () => { + const savedUrl = process.env.BITCOINCOM_BASEURL + + try { + req.body = { + addresses: [`qzs02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c`] + } + + // Switch the Insight URL to something that will error out. + process.env.BITCOINCOM_BASEURL = "http://fakeurl/api" + + const result = await utxoBulk(req, res) + + // Restore the saved URL. + process.env.BITCOINCOM_BASEURL = 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.BITCOINCOM_BASEURL = savedUrl + } + }) + + it("should get utxos for a single address", async () => { + req.body = { + addresses: [`bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4`] + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/addr/mgps7qxk2Z5ma4mXsviznnet8wx4VvMPFz/utxo`) + .reply(200, mockData.mockUtxoDetails) + } + + // Call the details API. + const result = await utxoBulk(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result, "result should be an array") + + // Each element should have these primary properties. + assert.hasAllKeys(result[0], [ + "utxos", + "legacyAddress", + "cashAddress", + "scriptPubKey" + ]) + + // Validate the UTXO data structure. + assert.hasAnyKeys(result[0].utxos[0], [ + "txid", + "vout", + "amount", + "satoshis", + "height", + "confirmations" + ]) + }) + + it("should get utxos for mulitple addresses", async () => { + req.body = { + addresses: [ + `bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4`, + `bchtest:qzknfggae0av6yvxk77gmyq7syc67yux6sk80haqyr` + ] + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/addr/mgps7qxk2Z5ma4mXsviznnet8wx4VvMPFz/utxo`) + .reply(200, mockData.mockUtxoDetails) + + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/addr/mwJnEzXzKkveF2q5Af9jxi9j1zrtWAnPU8/utxo`) + .reply(200, mockData.mockUtxoDetails) + } + + // Call the details API. + const result = await utxoBulk(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.equal(result.length, 2, "2 outputs for 2 inputs") + }) + + 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.addresses = testArray + + const result = await utxoBulk(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ["error"]) + assert.include(result.error, "Array too large") + }) + }) + + describe("#AddressUtxoSingle", () => { + // details route handler. + const utxoSingle = addressRoute.testableComponents.utxoSingle + + it("should throw 400 if address is empty", async () => { + const result = await utxoSingle(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ["error"]) + assert.include(result.error, "address can not be empty") + }) + + it("should error on an array", async () => { + req.params.address = [`qzs02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c`] + + const result = await utxoSingle(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "address can not be an array", + "Proper error message" + ) + }) + + it("should throw an error for an invalid address", async () => { + req.params.address = `02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c` + + const result = await utxoSingle(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "Invalid BCH address", + "Proper error message" + ) + }) + + it("should detect a network mismatch", async () => { + req.params.address = `bitcoincash:qqqvv56zepke5k0xeaehlmjtmkv9ly2uzgkxpajdx3` + + const result = await utxoSingle(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include(result.error, "Invalid network", "Proper error message") + }) + + it("should throw 500 when network issues", async () => { + const savedUrl = process.env.BITCOINCOM_BASEURL + + try { + req.params.address = `bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4` + + // Switch the Insight URL to something that will error out. + process.env.BITCOINCOM_BASEURL = "http://fakeurl/api/" + + const result = await utxoSingle(req, res) + + // Restore the saved URL. + process.env.BITCOINCOM_BASEURL = 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.BITCOINCOM_BASEURL = savedUrl + } + }) + + it("should get details for a single address", async () => { + req.params.address = `bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4` + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/addr/mgps7qxk2Z5ma4mXsviznnet8wx4VvMPFz/utxo`) + .reply(200, mockData.mockUtxoDetails) + } + + // Call the details API. + const result = await utxoSingle(req, res) + //console.log(`result: ${util.inspect(result)}`) + + // Each element should have these primary properties. + assert.hasAllKeys(result, [ + "utxos", + "legacyAddress", + "cashAddress", + "scriptPubKey" + ]) + + // Validate the UTXO data structure. + assert.hasAnyKeys(result.utxos[0], [ + "txid", + "vout", + "amount", + "satoshis", + "height", + "confirmations" + ]) + }) + }) + + describe("#AddressUnconfirmedBulk", () => { + // unconfirmed route handler. + const unconfirmedBulk = addressRoute.testableComponents.unconfirmedBulk + + it("should throw an error for an empty body", async () => { + req.body = {} + + const result = await unconfirmedBulk(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "addresses needs to be an array", + "Proper error message" + ) + }) + + it("should error on non-array single address", async () => { + req.body = { + address: `bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4` + } + + const result = await unconfirmedBulk(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "addresses needs 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.addresses = testArray + + const result = await unconfirmedBulk(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 = { + addresses: [`02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c`] + } + + const result = await unconfirmedBulk(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "Invalid BCH address", + "Proper error message" + ) + }) + + it("should detect a network mismatch", async () => { + req.body = { + addresses: [`bitcoincash:qqqvv56zepke5k0xeaehlmjtmkv9ly2uzgkxpajdx3`] + } + + const result = await unconfirmedBulk(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include(result.error, "Invalid network", "Proper error message") + }) + + it("should throw 500 when network issues", async () => { + const savedUrl = process.env.BITCOINCOM_BASEURL + + try { + req.body = { + addresses: [`qzs02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c`] + } + + // Switch the Insight URL to something that will error out. + process.env.BITCOINCOM_BASEURL = "http://fakeurl/api" + + const result = await unconfirmedBulk(req, res) + + // Restore the saved URL. + process.env.BITCOINCOM_BASEURL = 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.BITCOINCOM_BASEURL = savedUrl + } + }) + + it("should get unconfirmed data for a single address", async () => { + req.body = { + addresses: [`bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4`] + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/addr/mgps7qxk2Z5ma4mXsviznnet8wx4VvMPFz/utxo`) + .reply(200, mockData.mockUtxoDetails) + } + + // Call the details API. + const result = await unconfirmedBulk(req, res) + //console.log(`result: ${util.inspect(result)}`) + //console.log(`result[0].utxos: ${util.inspect(result[0].utxos)}`) + + assert.isArray(result, "result should be an array") + + // Dev note: Unconfirmed TXs are hard to test in an integration test because + // the nature of an unconfirmed transation is transient. It quickly becomes + // confirmed and thus should not show up. + }) + + it("should get unconfirmed data for an array of addresses", async () => { + req.body = { + addresses: [ + `bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4`, + `bchtest:qzknfggae0av6yvxk77gmyq7syc67yux6sk80haqyr` + ] + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/addr/mgps7qxk2Z5ma4mXsviznnet8wx4VvMPFz/utxo`) + .reply(200, mockData.mockUtxoDetails) + + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/addr/mwJnEzXzKkveF2q5Af9jxi9j1zrtWAnPU8/utxo`) + .reply(200, mockData.mockUtxoDetails) + } + + // Call the details API. + const result = await unconfirmedBulk(req, res) + + assert.isArray(result) + }) + }) + + describe("#AddressUnconfirmedSingle", () => { + // details route handler. + const unconfirmedSingle = addressRoute.testableComponents.unconfirmedSingle + + it("should throw 400 if address is empty", async () => { + const result = await unconfirmedSingle(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ["error"]) + assert.include(result.error, "address can not be empty") + }) + + it("should error on an array", async () => { + req.params.address = [`qzs02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c`] + + const result = await unconfirmedSingle(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "address can not be an array", + "Proper error message" + ) + }) + + it("should throw an error for an invalid address", async () => { + req.params.address = `02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c` + + const result = await unconfirmedSingle(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "Invalid BCH address", + "Proper error message" + ) + }) + + it("should detect a network mismatch", async () => { + req.params.address = `bitcoincash:qqqvv56zepke5k0xeaehlmjtmkv9ly2uzgkxpajdx3` + + const result = await unconfirmedSingle(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include(result.error, "Invalid network", "Proper error message") + }) + + it("should throw 500 when network issues", async () => { + const savedUrl = process.env.BITCOINCOM_BASEURL + + try { + req.params.address = `qzs02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c` + + // Switch the Insight URL to something that will error out. + process.env.BITCOINCOM_BASEURL = "http://fakeurl/api/" + + const result = await unconfirmedSingle(req, res) + + // Restore the saved URL. + process.env.BITCOINCOM_BASEURL = 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.BITCOINCOM_BASEURL = savedUrl + } + }) + + it("should get details for a single address", async () => { + req.params.address = `bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4` + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/addr/mgps7qxk2Z5ma4mXsviznnet8wx4VvMPFz/utxo`) + .reply(200, mockData.mockUtxoDetails) + } + + // Call the details API. + const result = await unconfirmedSingle(req, res) + //console.log(`result: ${util.inspect(result)}`) + + // Each element should have these primary properties. + assert.hasAllKeys(result, [ + "utxos", + "legacyAddress", + "cashAddress", + "scriptPubKey" + ]) + + assert.isArray(result.utxos) + }) + }) + + describe("#AddressTransactionsBulk", () => { + // unconfirmed route handler. + const transactionsBulk = addressRoute.testableComponents.transactionsBulk + + it("should throw an error for an empty body", async () => { + req.body = {} + + const result = await transactionsBulk(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "addresses needs to be an array", + "Proper error message" + ) + }) + + it("should error on non-array single address", async () => { + req.body = { + address: `qzs02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c` + } + + const result = await transactionsBulk(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "addresses needs 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.addresses = testArray + + const result = await transactionsBulk(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 = { + addresses: [`02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c`] + } + + const result = await transactionsBulk(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "Invalid BCH address", + "Proper error message" + ) + }) + + it("should detect a network mismatch", async () => { + req.body = { + addresses: [`bitcoincash:qqqvv56zepke5k0xeaehlmjtmkv9ly2uzgkxpajdx3`] + } + + const result = await transactionsBulk(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include(result.error, "Invalid network", "Proper error message") + }) + + it("should throw 500 when network issues", async () => { + const savedUrl = process.env.BITCOINCOM_BASEURL + + try { + req.body = { + addresses: [`qzs02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c`] + } + + // Switch the Insight URL to something that will error out. + process.env.BITCOINCOM_BASEURL = "http://fakeurl/api" + + const result = await transactionsBulk(req, res) + + // Restore the saved URL. + process.env.BITCOINCOM_BASEURL = 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.BITCOINCOM_BASEURL = savedUrl + } + }) + + it("should default to page 0", async () => { + req.body = { + addresses: [`bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4`] + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get( + `/txs/?address=bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4&pageNum=0` + ) + .reply(200, mockData.mockTransactions) + } + + // Call the endpoint + const result = await transactionsBulk(req, res) + + // Assert current page defaults to 0 + assert.equal(result[0].currentPage, 0) + }) + + it("should process the requested page", async () => { + req.body = { + addresses: [`bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4`], + page: 5 + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get( + `/txs/?address=bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4&pageNum=5` + ) + .reply(200, mockData.mockTransactions) + } + + // Call the endpoint + const result = await transactionsBulk(req, res) + + // Assert current page is same as requested + assert.equal(result[0].currentPage, 5) + }) + + it("should get transactions for a single address", async () => { + req.body = { + addresses: [`bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4`] + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get( + `/txs/?address=bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4&pageNum=0` + ) + .reply(200, mockData.mockTransactions) + } + + // Call the details API. + const result = await transactionsBulk(req, res) + + assert.isArray(result, "result should be an array") + + assert.exists(result[0].pagesTotal) + assert.exists(result[0].currentPage) + assert.exists(result[0].txs) + assert.isArray(result[0].txs) + assert.exists(result[0].legacyAddress) + assert.exists(result[0].cashAddress) + }) + + it("should get transactions for an array of addresses", async () => { + req.body = { + addresses: [ + `bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4`, + `bchtest:qzknfggae0av6yvxk77gmyq7syc67yux6sk80haqyr` + ] + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get( + `/txs/?address=bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4&pageNum=0` + ) + .reply(200, mockData.mockTransactions) + + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get( + `/txs/?address=bchtest:qzknfggae0av6yvxk77gmyq7syc67yux6sk80haqyr&pageNum=0` + ) + .reply(200, mockData.mockTransactions) + } + + // Call the details API. + const result = await transactionsBulk(req, res) + + assert.isArray(result, "result should be an array") + + assert.equal(result.length, 2, "Array should have 2 elements") + }) + }) + + describe("#AddressTransactionsSingle", () => { + // details route handler. + const transactionsSingle = + addressRoute.testableComponents.transactionsSingle + + it("should throw 400 if address is empty", async () => { + const result = await transactionsSingle(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ["error"]) + assert.include(result.error, "address can not be empty") + }) + + it("should error on an array", async () => { + req.params.address = [`qzs02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c`] + + const result = await transactionsSingle(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "address can not be an array", + "Proper error message" + ) + }) + + it("should throw an error for an invalid address", async () => { + req.params.address = `02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c` + + const result = await transactionsSingle(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "Invalid BCH address", + "Proper error message" + ) + }) + + it("should detect a network mismatch", async () => { + req.params.address = `bitcoincash:qqqvv56zepke5k0xeaehlmjtmkv9ly2uzgkxpajdx3` + + const result = await transactionsSingle(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include(result.error, "Invalid network", "Proper error message") + }) + + it("should throw 500 when network issues", async () => { + const savedUrl = process.env.BITCOINCOM_BASEURL + + try { + req.params.address = `qzs02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c` + + // Switch the Insight URL to something that will error out. + process.env.BITCOINCOM_BASEURL = "http://fakeurl/api/" + + const result = await transactionsSingle(req, res) + + // Restore the saved URL. + process.env.BITCOINCOM_BASEURL = 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.BITCOINCOM_BASEURL = savedUrl + } + }) + + it("should default to page 0", async () => { + req.params.address = `bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4` + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get( + `/txs/?address=bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4&pageNum=0` + ) + .reply(200, mockData.mockTransactions) + } + + // Call the endpoint + const result = await transactionsSingle(req, res) + + // Assert current page defaults to 0 + assert.equal(result.currentPage, 0) + }) + + it("should process the requested page", async () => { + req.params.address = `bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4` + req.query.page = 5 + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get( + `/txs/?address=bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4&pageNum=5` + ) + .reply(200, mockData.mockTransactions) + } + + // Call the endpoint + const result = await transactionsSingle(req, res) + + // Assert current page is same as requested + assert.equal(result.currentPage, 5) + }) + + it("should get details for a single address", async () => { + req.params.address = `bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4` + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get( + `/txs/?address=bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4&pageNum=0` + ) + .reply(200, mockData.mockTransactions) + } + + // Call the details API. + const result = await transactionsSingle(req, res) + //console.log(`result: ${util.inspect(result)}`) + + // Assert that required fields exist in the returned object. + assert.exists(result.pagesTotal) + assert.exists(result.currentPage) + assert.exists(result.txs) + assert.isArray(result.txs) + assert.exists(result.legacyAddress) + assert.exists(result.cashAddress) + }) + }) + + describe("#AddressFromXPubSingle", () => { + // details route handler. + const fromXPubSingle = addressRoute.testableComponents.fromXPubSingle + + it("should throw 400 if xpub is empty", async () => { + const result = await fromXPubSingle(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ["error"]) + assert.include(result.error, "xpub can not be empty") + }) + + it("should error on an array", async () => { + req.params.xpub = [ + `tpubDHTK2jqg73w3GwoiHfAMbMYML1HN8FhrUxD9rFgbSgHXdwwrY6pAFqKDfUHhqw7vreaZty5hPGjb1S7ZPQeMmu6TFHAKfY9tJpYbvaGjPRM` + ] + + const result = await fromXPubSingle(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "xpub can not be an array", + "Proper error message" + ) + }) + + it("should throw 500 when network issues", async () => { + const savedUrl = process.env.BITCOINCOM_BASEURL + + try { + req.params.xpub = `tpubDHTK2jqg73w3GwoiHfAMbMYML1HN8FhrUxD9rFgbSgHXdwwrY6pAFqKDfUHhqw7vreaZty5hPGjb1S7ZPQeMmu6TFHAKfY9tJpYbvaGjPRM` + + // Switch the Insight URL to something that will error out. + process.env.BITCOINCOM_BASEURL = "http://fakeurl/api/" + + const result = await fromXPubSingle(req, res) + + // Restore the saved URL. + process.env.BITCOINCOM_BASEURL = 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.BITCOINCOM_BASEURL = savedUrl + } + }) + + it("should create an address from xpub", async () => { + req.params.xpub = `tpubDHTK2jqg73w3GwoiHfAMbMYML1HN8FhrUxD9rFgbSgHXdwwrY6pAFqKDfUHhqw7vreaZty5hPGjb1S7ZPQeMmu6TFHAKfY9tJpYbvaGjPRM` + + // Mock the Insight URL for unit tests. + // TODO add unit test + // if (process.env.TEST === "unit") { + // nock(`${process.env.BITCOINCOM_BASEURL}`) + // .get( + // `/txs/?address=bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4&pageNum=0` + // ) + // .reply(200, mockData.mockTransactions) + // } + + // Call the details API. + const result = await fromXPubSingle(req, res) + //console.log(`result: ${util.inspect(result)}`) + + // Assert that required fields exist in the returned object. + assert.exists(result.legacyAddress) + assert.exists(result.cashAddress) + }) + }) +}) diff --git a/test/v3/block.js b/test/v3/block.js new file mode 100644 index 0000000..a78fb71 --- /dev/null +++ b/test/v3/block.js @@ -0,0 +1,610 @@ +"use strict" + +const blockRoute = require("../../src/routes/v3/insight/block") +const chai = require("chai") +const assert = chai.assert +const nock = require("nock") // HTTP mocking + +let originalEnvVars // Used during transition from integration to unit tests. + +// Mocking data. +const { mockReq, mockRes } = require("./mocks/express-mocks") +const mockData = require("./mocks/block-mock") + +// Used for debugging. +const util = require("util") +util.inspect.defaultOptions = { depth: 1 } + +describe("#Block", () => { + let req, res + + before(() => { + // Save existing environment variables. + originalEnvVars = { + BITCOINCOM_BASEURL: process.env.BITCOINCOM_BASEURL, + RPC_BASEURL: process.env.RPC_BASEURL, + RPC_USERNAME: process.env.RPC_USERNAME, + RPC_PASSWORD: process.env.RPC_PASSWORD + } + + // Set default environment variables for unit tests. + if (!process.env.TEST) process.env.TEST = "unit" + if (process.env.TEST === "unit") { + process.env.BITCOINCOM_BASEURL = "http://fakeurl/api/" + process.env.RPC_BASEURL = "http://fakeurl/api" + process.env.RPC_USERNAME = "fakeusername" + process.env.RPC_PASSWORD = "fakepassword" + } + }) + + // Setup the mocks before each test. + beforeEach(() => { + // Mock the req and res objects used by Express routes. + req = mockReq + res = mockRes + + // Explicitly reset the parmas and body. + req.params = {} + req.body = {} + + // Activate nock if it's inactive. + if (!nock.isActive()) nock.activate() + }) + + afterEach(() => { + // Clean up HTTP mocks. + nock.cleanAll() // clear interceptor list. + nock.restore() + }) + + after(() => { + // Restore any pre-existing environment variables. + process.env.BITCOINCOM_BASEURL = originalEnvVars.BITCOINCOM_BASEURL + process.env.RPC_BASEURL = originalEnvVars.RPC_BASEURL + process.env.RPC_USERNAME = originalEnvVars.RPC_USERNAME + process.env.RPC_PASSWORD = originalEnvVars.RPC_PASSWORD + }) + + describe("#root", () => { + // root route handler. + const root = blockRoute.testableComponents.root + + it("should respond to GET for base route", async () => { + const result = root(req, res) + + assert.equal(result.status, "block", "Returns static string") + }) + }) + + describe("#detailsByHashSingle", () => { + const detailsByHash = blockRoute.testableComponents.detailsByHashSingle + + it("should throw an error for an empty hash", async () => { + req.params.hash = "" + + const result = await detailsByHash(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "hash must not be empty", + "Proper error message" + ) + }) + + it("should throw 50X when network issues", async () => { + // Save the existing RPC URL. + const savedUrl = process.env.BITCOINCOM_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.BITCOINCOM_BASEURL = "http://fakeurl/api/" + + req.params.hash = "abc123" + const result = await detailsByHash(req, res) + //console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.BITCOINCOM_BASEURL = savedUrl + + assert.isAbove(res.statusCode, 499, "HTTP status code 50X expected.") + //assert.include(result.error, "ENOTFOUND", "Error message expected") + }) + + it("should throw an error for invalid hash", async () => { + req.params.hash = "abc123" + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/block/${req.params.hash}`) + .reply(404, "Not found") + } + + const result = await detailsByHash(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.include(result.error, "Not found", "Proper error message") + }) + + it("should GET /detailsByHash/:hash", async () => { + req.params.hash = + "00000000000000645dec6503d3f5eafb0d2537a7a28f181d721dec7c44154c79" + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/block/${req.params.hash}`) + .reply(200, mockData.mockBlockDetails) + } + + const result = await detailsByHash(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.hasAnyKeys(result, [ + "hash", + "size", + "height", + "version", + "merkleroot", + "tx", + "time", + "nonce", + "bits", + "difficulty", + "chainwork", + "confirmations", + "previousblockhash", + "nextblockhash", + "reward", + "isMainChain", + "poolInfo" + ]) + assert.isArray(result.tx) + }) + }) + + describe("#detailsByHashBulk", () => { + // details route handler. + const detailsByHashBulk = blockRoute.testableComponents.detailsByHashBulk + + it("should throw an error for an empty body", async () => { + req.body = {} + + const result = await detailsByHashBulk(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "hashes needs to be an array", + "Proper error message" + ) + }) + + it("should error on non-array single address", async () => { + req.body = { + hashes: + "00000000000000645dec6503d3f5eafb0d2537a7a28f181d721dec7c44154c79" + } + + const result = await detailsByHashBulk(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "hashes needs 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.hashes = testArray + + const result = await detailsByHashBulk(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 hash", async () => { + req.body = { + hashes: [`abc123`] + } + + const result = await detailsByHashBulk(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include(result.error, "Invalid hash", "Proper error message") + }) + + it("should throw 500 when network issues", async () => { + const savedUrl = process.env.BITCOINCOM_BASEURL + + try { + req.body = { + hashes: [ + "00000000000000645dec6503d3f5eafb0d2537a7a28f181d721dec7c44154c79" + ] + } + + // Switch the Insight URL to something that will error out. + process.env.BITCOINCOM_BASEURL = "http://fakeurl/api/" + + const result = await detailsByHashBulk(req, res) + + // Restore the saved URL. + process.env.BITCOINCOM_BASEURL = 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.BITCOINCOM_BASEURL = savedUrl + } + }) + + it("should get details for a single hash", async () => { + req.body = { + hashes: [ + "00000000000000645dec6503d3f5eafb0d2537a7a28f181d721dec7c44154c79" + ] + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/block/${req.body.hashes[0]}`) + .reply(200, mockData.mockBlockDetails) + } + + // Call the details API. + const result = await detailsByHashBulk(req, res) + //console.log(`result: ${util.inspect(result)}`) + + // Assert that required fields exist in the returned object. + assert.equal(result.length, 1, "Array with one entry") + assert.hasAllKeys(result[0], [ + "bits", + "chainwork", + "confirmations", + "difficulty", + "hash", + "height", + "isMainChain", + "merkleroot", + "nextblockhash", + "nonce", + "poolInfo", + "previousblockhash", + "reward", + "size", + "time", + "tx", + "version" + ]) + }) + + it("should get details for multiple hashes", async () => { + req.body = { + hashes: [ + `00000000000000645dec6503d3f5eafb0d2537a7a28f181d721dec7c44154c79`, + `00000000c2b2c19cf499f57d5b0f724c6df753330d7acc7d4a8ebe412d427bd0` + ] + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/block/${req.body.hashes[0]}`) + .reply(200, mockData.mockBlockDetails) + + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/block/${req.body.hashes[1]}`) + .reply(200, mockData.mockBlockDetails) + } + + // Call the details API. + const result = await detailsByHashBulk(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.equal(result.length, 2, "2 outputs for 2 inputs") + }) + + it("should throw an error if hash not found", async () => { + req.body = { + hashes: [ + `00000000000000645dec6503d3f5eafb0d2537a7a28f181d721dec7c44abcdef` + ] + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/block/${req.body.hashes[0]}`) + //.reply(404, { error: { message: "Not Found" } }) + .reply(404, "Not found") + } + + const result = await detailsByHashBulk(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 404, "HTTP status code 404 expected.") + assert.include(result.error, "Not found", "Proper error message") + }) + }) + + describe("Block Details By Height", () => { + // block route handler. + const detailsByHeight = blockRoute.testableComponents.detailsByHeightSingle + + it("should throw an error for an empty height", async () => { + req.params.height = "" + + const result = await detailsByHeight(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "height must not be empty", + "Proper error message" + ) + }) + + it("should throw 500 when network issues", async () => { + // Save the existing RPC URL. + const savedUrl = process.env.BITCOINCOM_BASEURL + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.BITCOINCOM_BASEURL = "http://fakeurl/api/" + process.env.RPC_BASEURL = "http://fakeurl/api/" + + req.params.height = "abc123" + const result = await detailsByHeight(req, res) + //console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.BITCOINCOM_BASEURL = savedUrl + process.env.RPC_BASEURL = savedUrl2 + + assert.isAbove( + res.statusCode, + 499, + "HTTP status code 500 or great expected." + ) + //assert.include(result.error, "ENOTFOUND", "Error message expected") + }) + + it("should throw an error for invalid height", async () => { + req.params.height = "abc123" + + // Mock the RPC call for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.RPC_BASEURL}`) + .post(``) + .reply(500, { + error: { + code: -1, + message: "JSON value is not an integer as expected" + } + }) + } + + const result = await detailsByHeight(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "JSON value is not an integer as expected", + "Proper error message" + ) + }) + + it("should GET /detailsByHeight/:height", async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.RPC_BASEURL}`) + .post(``) + .reply(200, { result: mockData.mockBlockHash }) + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get( + `/block/00000000000000645dec6503d3f5eafb0d2537a7a28f181d721dec7c44154c79` + ) + .reply(200, mockData.mockBlockDetails) + } + + req.params.height = 500000 + + const result = await detailsByHeight(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.hasAnyKeys(result, [ + "hash", + "size", + "height", + "version", + "merkleroot", + "tx", + "time", + "nonce", + "bits", + "difficulty", + "chainwork", + "confirmations", + "previousblockhash", + "nextblockhash", + "reward", + "isMainChain", + "poolInfo" + ]) + assert.isArray(result.tx) + }) + }) + + describe("#detailsByHeightBulk", () => { + // details route handler. + const detailsByHeightBulk = + blockRoute.testableComponents.detailsByHeightBulk + + it("should throw an error for an empty body", async () => { + req.body = {} + + const result = await detailsByHeightBulk(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "heights needs to be an array", + "Proper error message" + ) + }) + + it("should error on non-array single height", async () => { + req.body = { + heights: 500000 + } + + const result = await detailsByHeightBulk(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "heights needs 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.heights = testArray + + const result = await detailsByHeightBulk(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ["error"]) + assert.include(result.error, "Array too large") + }) + + it("should throw error for an invalid height", async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.RPC_BASEURL}`) + .post(``) + .reply(500, { + error: { + code: -1, + message: "JSON value is not an integer as expected" + } + }) + } + + req.body.heights = [`abc123`] + + const result = await detailsByHeightBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + }) + + it("should throw 500 when network issues", async () => { + const savedUrl = process.env.BITCOINCOM_BASEURL + + try { + req.body.heights = [`500000`] + + // Switch the Insight URL to something that will error out. + process.env.BITCOINCOM_BASEURL = "http://fakeurl/api/" + + const result = await detailsByHeightBulk(req, res) + + // Restore the saved URL. + process.env.BITCOINCOM_BASEURL = 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.BITCOINCOM_BASEURL = savedUrl + } + }) + + it("should get details for a single height", async () => { + req.body.heights = [`500000`] + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.RPC_BASEURL}`) + .post(``) + .reply(200, { result: mockData.mockBlockHash }) + + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/block/${mockData.mockBlockHash}`) + .reply(200, mockData.mockBlockDetails) + } + + // Call the details API. + const result = await detailsByHeightBulk(req, res) + //console.log(`result: ${util.inspect(result)}`) + + // Assert that required fields exist in the returned object. + assert.equal(result.length, 1, "Array with one entry") + assert.hasAllKeys(result[0], [ + "bits", + "chainwork", + "confirmations", + "difficulty", + "hash", + "height", + "isMainChain", + "merkleroot", + "nextblockhash", + "nonce", + "poolInfo", + "previousblockhash", + "reward", + "size", + "time", + "tx", + "version" + ]) + }) + + it("should get details for multiple block heights", async () => { + req.body = { + heights: [`500000`, `500001`] + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.RPC_BASEURL}`) + .post(``) + .times(2) + .reply(200, { result: mockData.mockBlockHash }) + + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/block/${mockData.mockBlockHash}`) + .times(2) + .reply(200, mockData.mockBlockDetails) + } + + // Call the details API. + const result = await detailsByHeightBulk(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/transaction.js b/test/v3/transaction.js new file mode 100644 index 0000000..0c2ef79 --- /dev/null +++ b/test/v3/transaction.js @@ -0,0 +1,333 @@ +/* + TESTS FOR THE TRANSACTION.TS LIBRARY + + This test file uses the environment variable TEST to switch between unit + and integration tests. By default, TEST is set to 'unit'. Set this variable + to 'integration' to run the tests against BCH mainnet. + + TODO: + -See "should throw an error for an invalid txid" for detailsSingle: + --The error handler should be refactored to return an intelligent error message, + instead of the 503 error it is returning now. +*/ + +"use strict" + +const chai = require("chai") +const assert = chai.assert +const transactionRoute = require("../../src/routes/v3/insight/transaction") +const nock = require("nock") // HTTP mocking + +let originalEnvVars // Used during transition from integration to unit tests. + +// Mocking data. +const { mockReq, mockRes } = require("./mocks/express-mocks") +const mockData = require("./mocks/transaction-mocks") + +// Used for debugging. +const util = require("util") +util.inspect.defaultOptions = { depth: 1 } + +describe("#Transactions", () => { + let req, res + + before(() => { + // Save existing environment variables. + originalEnvVars = { + BITCOINCOM_BASEURL: process.env.BITCOINCOM_BASEURL, + RPC_BASEURL: process.env.RPC_BASEURL, + RPC_USERNAME: process.env.RPC_USERNAME, + RPC_PASSWORD: process.env.RPC_PASSWORD + } + + // Set default environment variables for unit tests. + if (!process.env.TEST) process.env.TEST = "unit" + if (process.env.TEST === "unit") { + process.env.BITCOINCOM_BASEURL = "http://fakeurl/api/" + process.env.RPC_BASEURL = "http://fakeurl/api" + process.env.RPC_USERNAME = "fakeusername" + process.env.RPC_PASSWORD = "fakepassword" + } + }) + + // Setup the mocks before each test. + beforeEach(() => { + // Mock the req and res objects used by Express routes. + req = mockReq + res = mockRes + + // Explicitly reset the parmas and body. + req.params = {} + req.body = {} + req.query = {} + + // Activate nock if it's inactive. + if (!nock.isActive()) nock.activate() + }) + + afterEach(() => { + // Clean up HTTP mocks. + nock.cleanAll() // clear interceptor list. + nock.restore() + }) + + after(() => { + // Restore any pre-existing environment variables. + process.env.BITCOINCOM_BASEURL = originalEnvVars.BITCOINCOM_BASEURL + process.env.RPC_BASEURL = originalEnvVars.RPC_BASEURL + process.env.RPC_USERNAME = originalEnvVars.RPC_USERNAME + process.env.RPC_PASSWORD = originalEnvVars.RPC_PASSWORD + }) + + describe("#root", async () => { + // root route handler. + const root = transactionRoute.testableComponents.root + + it("should respond to GET for base route", async () => { + const result = root(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.equal(result.status, "transaction", "Returns static string") + }) + }) + + describe("#detailsBulk", async () => { + const detailsBulk = transactionRoute.testableComponents.detailsBulk + + it("should throw an error for an empty body", async () => { + req.body = {} + + const result = await detailsBulk(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "txids needs to be an array", + "Proper error message" + ) + }) + + it("should error on non-array single txid", async () => { + req.body = { + txids: `6f235bd3a689f03c11969cd649ccad592462ca958bc519a30194e7a67b349a40` + } + + const result = await detailsBulk(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "txids needs to be an array", + "Proper error message" + ) + }) + + it("should throw an error for an invalid txid", async () => { + const fakeTXID = `02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c` + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/tx/${fakeTXID}`) + .reply(400, { + result: { error: "parameter 1 must be hexadecimal string" } + }) + } + + req.body = { + txids: [fakeTXID] + } + + const result = await detailsBulk(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + }) + + it("should process a single txid", async () => { + const txid = `6f235bd3a689f03c11969cd649ccad592462ca958bc519a30194e7a67b349a40` + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/tx/${txid}`) + .reply(200, mockData.mockDetails) + } + + req.body = { + txids: [txid] + } + + const result = await detailsBulk(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.hasAnyKeys(result[0], [ + "txid", + "version", + "locktime", + "vin", + "vout", + "blockhash", + "blockheight", + "confirmations", + "time", + "blocktime", + "valueOut", + "size", + "valueIn", + "fees" + ]) + }) + + it("should process a multiple txids", async () => { + const txid1 = `6f235bd3a689f03c11969cd649ccad592462ca958bc519a30194e7a67b349a40` + const txid2 = `8d4fd4dcaa9d8051dc7d862dc23d8aa23e20b77b9c928c49380685459caa7043` + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/tx/${txid1}`) + .reply(200, mockData.mockDetails) + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/tx/${txid2}`) + .reply(200, mockData.mockDetails) + } + + req.body = { + txids: [txid1, txid2] + } + + const result = await detailsBulk(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.hasAnyKeys(result[0], [ + "txid", + "version", + "locktime", + "vin", + "vout", + "blockhash", + "blockheight", + "confirmations", + "time", + "blocktime", + "valueOut", + "size", + "valueIn", + "fees" + ]) + }) + }) + + describe("#detailsSingle", () => { + // details route handler. + const detailsSingle = transactionRoute.testableComponents.detailsSingle + + it("should throw 400 if txid is empty", async () => { + const result = await detailsSingle(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 = [`qzs02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c`] + + const result = await detailsSingle(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 an error for an invalid txid", async () => { + if (process.env.TEST !== "unit") { + req.params.txid = `02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c` + + const result = await detailsSingle(req, res) + //console.log(`result: ${util.inspect(result)}`) + + // The error handling code should probably be updated to respond with a better + // error message. + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "parameter 1 must be hexadecimal string", + "Proper error message" + ) + } + }) + + it("should throw 500 when network issues", async () => { + const savedUrl = process.env.BITCOINCOM_BASEURL + + try { + req.params.txid = `6f235bd3a689f03c11969cd649ccad592462ca958bc519a30194e7a67b349a40` + + // Switch the Insight URL to something that will error out. + process.env.BITCOINCOM_BASEURL = "http://fakeurl/api/" + + const result = await detailsSingle(req, res) + + // Restore the saved URL. + process.env.BITCOINCOM_BASEURL = savedUrl + + assert.isAbove( + res.statusCode, + 499, + "HTTP status code 500 or greater expected." + ) + //assert.include(result.error,"Network error: Could not communicate with full node","Error message expected") + } catch (err) { + // Restore the saved URL. + process.env.BITCOINCOM_BASEURL = savedUrl + } + }) + + it("should get details for a single address", async () => { + const txid = `6f235bd3a689f03c11969cd649ccad592462ca958bc519a30194e7a67b349a40` + req.params.txid = txid + + // Mock the Insight URL for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.BITCOINCOM_BASEURL}`) + .get(`/tx/${txid}`) + .reply(200, mockData.mockDetails) + } + + // Call the details API. + const result = await detailsSingle(req, res) + //console.log(`result: ${util.inspect(result)}`) + + // Assert that required fields exist in the returned object. + assert.hasAllKeys(result, [ + "txid", + "version", + "locktime", + "vin", + "vout", + "blockhash", + "blockheight", + "confirmations", + "time", + "blocktime", + "valueOut", + "size", + "valueIn", + "fees" + ]) + assert.isArray(result.vin) + assert.isArray(result.vout) + }) + }) +})