diff --git a/package.json b/package.json index 77e8141..f4629c9 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "test": "npm run lint && npm run test-v4", "lint": "standard --env mocha --fix", "test-v4": "export NETWORK=mainnet && nyc --reporter=text mocha --exit --timeout 60000 test/v4/", + "test-v5": "export NETWORK=mainnet && nyc --reporter=text mocha --exit --timeout 60000 test/v5/", "test:integration": "mocha test/v4/integration", "test:integration:slpdb": "mocha --timeout 25000 -g '#validate2Single' test/v4/integration/slp*.js", "test:integration:nft": "mocha --timeout 25000 -g '#nft' test/v4/integration/nft.js", diff --git a/src/app.js b/src/app.js index 91aba75..1cf9c41 100644 --- a/src/app.js +++ b/src/app.js @@ -39,6 +39,21 @@ const EncryptionV4 = require('./routes/v4/encryption') const PriceV4 = require('./routes/v4/price') const Ninsight = require('./routes/v4/ninsight') +// v5 +const healthCheckV5 = require('./routes/v5/health-check') +const BlockchainV5 = require('./routes/v5/full-node/blockchain') +const ControlV5 = require('./routes/v5/full-node/control') +const MiningV5 = require('./routes/v5/full-node/mining') +const networkV5 = require('./routes/v5/full-node/network') +const RawtransactionsV5 = require('./routes/v5/full-node/rawtransactions') +const UtilV5 = require('./routes/v5/util') +const SlpV5 = require('./routes/v5/slp') +const xpubV5 = require('./routes/v5/xpub') +const ElectrumXV5 = require('./routes/v5/electrumx') +const EncryptionV5 = require('./routes/v5/encryption') +const PriceV5 = require('./routes/v5/price') +// const Ninsight = require('./routes/v5/ninsight') + require('dotenv').config() // Instantiate v4 route libraries. @@ -53,6 +68,18 @@ const encryptionv4 = new EncryptionV4() const pricev4 = new PriceV4() const utilV4 = new UtilV4({ electrumx: electrumxv4 }) +// Instantiate v5 route libraries. +const blockchainV5 = new BlockchainV5() +const controlV5 = new ControlV5() +const miningV5 = new MiningV5() +const rawtransactionsV5 = new RawtransactionsV5() +const slpV5 = new SlpV5() +const electrumxv5 = new ElectrumXV5() +electrumxv5.connect() +const encryptionv5 = new EncryptionV5() +const pricev5 = new PriceV5() +const utilV5 = new UtilV5({ electrumx: electrumxv5 }) + const app = express() app.locals.env = process.env @@ -94,12 +121,14 @@ app.use(express.static(path.join(__dirname, 'public'))) app.use('/', logReqInfo) const v4prefix = 'v4' +const v5prefix = 'v5' // START Rate Limits const auth = new AuthMW() // Ensure req.locals and res.locals objects exist. app.use(`/${v4prefix}/`, rateLimits.populateLocals) +app.use(`/${v5prefix}/`, rateLimits.populateLocals) // Allow users to turn off rate limits with an environment variable. const DO_NOT_USE_RATE_LIMITS = process.env.DO_NOT_USE_RATE_LIMITS || false @@ -110,13 +139,16 @@ if (!DO_NOT_USE_RATE_LIMITS) { console.log('Rate limits are being used') // Inspect the header for a JWT token. app.use(`/${v4prefix}/`, jwtAuth.getTokenFromHeaders) + app.use(`/${v5prefix}/`, jwtAuth.getTokenFromHeaders) // Instantiate the authorization middleware, used to implement pro-tier rate limiting. // Handles Anonymous and Basic Authorization schemes used by passport.js app.use(`/${v4prefix}/`, auth.mw()) + app.use(`/${v5prefix}/`, auth.mw()) // Experimental rate limits app.use(`/${v4prefix}/`, rateLimits.applyRateLimits) + app.use(`/${v5prefix}/`, rateLimits.applyRateLimits) // Rate limit on all v4 routes // Establish and enforce rate limits. @@ -143,6 +175,23 @@ app.use(`/${v4prefix}/` + 'util', utilV4.router) const ninsight = new Ninsight() app.use(`/${v4prefix}/` + 'ninsight', ninsight.router) +// Connect v5 routes +app.use(`/${v5prefix}/` + 'health-check', healthCheckV5) +app.use(`/${v5prefix}/` + 'blockchain', blockchainV5.router) +app.use(`/${v5prefix}/` + 'control', controlV5.router) +app.use(`/${v5prefix}/` + 'mining', miningV5.router) +app.use(`/${v5prefix}/` + 'network', networkV5) +app.use(`/${v5prefix}/` + 'rawtransactions', rawtransactionsV5.router) +app.use(`/${v5prefix}/` + 'slp', slpV5.router) +app.use(`/${v5prefix}/` + 'xpub', xpubV5.router) +app.use(`/${v5prefix}/` + 'electrumx', electrumxv5.router) +app.use(`/${v5prefix}/` + 'encryption', encryptionv5.router) +app.use(`/${v5prefix}/` + 'price', pricev5.router) +app.use(`/${v5prefix}/` + 'util', utilV5.router) + +// const ninsight = new Ninsight() +app.use(`/${v5prefix}/` + 'ninsight', ninsight.router) + // catch 404 and forward to error handler app.use((req, res, next) => { const err = { diff --git a/src/routes/v5/electrumx.js b/src/routes/v5/electrumx.js new file mode 100644 index 0000000..5115925 --- /dev/null +++ b/src/routes/v5/electrumx.js @@ -0,0 +1,1373 @@ +/* + Electrum API route +*/ + +'use strict' + +const express = require('express') +const router = express.Router() +const axios = require('axios') +const util = require('util') +const bitcore = require('bitcore-lib-cash') + +const ElectrumCash = require('electrum-cash').ElectrumClient +// const ElectrumCash = require('/home/trout/work/personal/electrum-cash/electrum.js').Client // eslint-disable-line + +const wlogger = require('../../util/winston-logging') +const config = require('../../../config') + +const RouteUtils = require('../../util/route-utils') +const routeUtils = new RouteUtils() + +const BCHJS = require('@psf/bch-js') +const bchjs = new BCHJS() + +let _this + +class Electrum { + constructor () { + _this = this + + _this.config = config + _this.axios = axios + _this.routeUtils = routeUtils + _this.bchjs = bchjs + _this.bitcore = bitcore + + _this.electrumx = new ElectrumCash( + 'bch-api', + '1.4.1', + process.env.FULCRUM_URL, + process.env.FULCRUM_PORT + // '192.168.0.6', + // '50002' + ) + + _this.isReady = false + // _this.connectToServers() + + _this.router = router + _this.router.get('/', _this.root) + _this.router.get('/utxos/:address', _this.getUtxos) + _this.router.post('/utxos', _this.utxosBulk) + _this.router.get('/tx/data/:txid', _this.getTransactionDetails) + _this.router.post('/tx/data', _this.transactionDetailsBulk) + _this.router.post('/tx/broadcast', _this.broadcastTransaction) + _this.router.get('/block/headers/:height', _this.getBlockHeaders) + _this.router.post('/block/headers', _this.blockHeadersBulk) + _this.router.get('/balance/:address', _this.getBalance) + _this.router.post('/balance', _this.balanceBulk) + _this.router.get('/transactions/:address', _this.getTransactions) + _this.router.post('/transactions', _this.transactionsBulk) + _this.router.get('/unconfirmed/:address', _this.getMempool) + _this.router.post('/unconfirmed', _this.mempoolBulk) + } + + // Initializes a connection to electrum servers. + async connect () { + try { + console.log('Attempting to connect to ElectrumX server...') + + // console.log('_this.electrumx: ', _this.electrumx) + + // Return immediately if a connection has already been established. + if (_this.isReady) return true + + // Connect to the server. + await _this.electrumx.connect() + + // Set the connection flag. + _this.isReady = true + + // Periodically check the connection. If it's not connected, attempt to + // reconnect. + _this.reconnectIntervalHandle = setInterval(async function () { + const status = _this.electrumx.connection.status + // console.log(`Electrumx status: ${status}`) + + // 1 = connected. If we're not connected, attemp to reconnect. + if (status !== 1) { + wlogger.info(`Electrumx not connectes. Status: ${status}`) + wlogger.info('Attempting to reconnect...') + await _this.electrumx.connect() + wlogger.info('...reconnected.') + } + }, 30000) + + console.log('...Successfully connected to ElectrumX server.') + + // console.log(`_this.isReady: ${_this.isReady}`) + return _this.isReady + } catch (err) { + console.log('err: ', err) + wlogger.error('Error in electrumx.js/connect(): ', err) + // throw err + } + } + + // Disconnect from the ElectrumX server. + async disconnect () { + try { + // Return immediately if the isReady flag is false. + if (!_this.isReady) return true + + // Disable the reconnect timer. + clearInterval(_this.reconnectIntervalHandle) + + // Disconnect from the server. + await _this.electrumx.disconnect() + + // Clear the isReady flag. + _this.isReady = false + + // Return true to signal that the disconnection happened successfully. + return true + } catch (err) { + // console.log(`err: `, err) + wlogger.error('Error in electrumx.js/disconnect()') + throw err + } + } + + // DRY error handler. + errorHandler (err, res) { + // Attempt to decode the error message. + const { msg, status } = _this.routeUtils.decodeError(err) + if (msg) { + res.status(status) + return res.json({ success: false, error: msg }) + } + + // Handle error patterns specific to this route. + if (err.message) { + res.status(400) + return res.json({ success: false, error: err.message }) + } + + // If error can be handled, return the stack trace + res.status(500) + return res.json({ error: util.inspect(err) }) + } + + // Root API endpoint. Simply acknowledges that it exists. + root (req, res, next) { + return res.json({ status: 'electrumx' }) + } + + // Returns a promise that resolves to UTXO data for an address. Expects input + // to be a cash address, and input validation to have already been done by + // parent, calling function. + async _utxosFromElectrumx (address) { + try { + // Convert the address to a scripthash. + const scripthash = _this.addressToScripthash(address) + + if (!_this.isReady) { + throw new Error( + 'ElectrumX server connection is not ready. Call await connectToServer() first.' + ) + } + + // Query the utxos from the ElectrumX server. + const electrumResponse = await _this.electrumx.request( + 'blockchain.scripthash.listunspent', + scripthash + ) + // console.log( + // `electrumResponse: ${JSON.stringify(electrumResponse, null, 2)}` + // ) + + return electrumResponse + } catch (err) { + // console.log('err: ', err) + + // Write out error to error log. + wlogger.error('Error in elecrumx.js/_utxosFromElectrumx(): ', err) + throw err + } + } + + /** + * @api {get} /electrumx/utxos/{addr} Get utxos for a single address. + * @apiName UTXOs for a single address + * @apiGroup ElectrumX / Fulcrum + * @apiDescription Returns an object with UTXOs associated with an address. + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/electrumx/utxos/bitcoincash:qr69kyzha07dcecrsvjwsj4s6slnlq4r8c30lxnur3" -H "accept: application/json" + * + */ + // GET handler for single balance + async getUtxos (req, res, next) { + try { + const address = req.params.address + + // Reject if address is an array. + if (Array.isArray(address)) { + res.status(400) + return res.json({ + success: false, + error: 'address can not be an array. Use POST for bulk upload.' + }) + } + + const cashAddr = _this.bchjs.Address.toCashAddress(address) + + // Prevent a common user error. Ensure they are using the correct network address. + const networkIsValid = _this.routeUtils.validateNetwork(cashAddr) + if (!networkIsValid) { + res.status(400) + return res.json({ + success: false, + error: + 'Invalid network. Trying to use a testnet address on mainnet, or vice versa.' + }) + } + + wlogger.debug( + 'Executing electrumx/getUtxos with this address: ', + cashAddr + ) + + // Get data from ElectrumX server. + const electrumResponse = await _this._utxosFromElectrumx(cashAddr) + // console.log(`_utxosFromElectrumx(): ${JSON.stringify(electrumResponse, null, 2)}`) + + // Pass the error message if ElectrumX reports an error. + if (Object.prototype.hasOwnProperty.call(electrumResponse, 'code')) { + res.status(400) + return res.json({ + success: false, + message: electrumResponse.message + }) + } + + res.status(200) + return res.json({ + success: true, + utxos: electrumResponse + }) + } catch (err) { + // Write out error to error log. + wlogger.error('Error in elecrumx.js/getUtxos().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {post} /electrumx/utxo Get utxos for an array of addresses. + * @apiName UTXOs for an array of addresses + * @apiGroup ElectrumX / Fulcrum + * @apiDescription Returns an array of objects with UTXOs associated with an address. + * Limited to 20 items per request. + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v4/electrumx/utxos" -H "accept: application/json" -H "Content-Type: application/json" -d '{"addresses":["bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf","bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf"]}' + * + * + */ + // POST handler for bulk queries on address details + async utxosBulk (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 (!_this.routeUtils.validateArraySize(req, addresses)) { + res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 + return res.json({ + error: 'Array too large.' + }) + } + + wlogger.debug( + 'Executing electrumx.js/utxoBulk 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 { + _this.bchjs.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 = _this.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) => { + // console.log(`address: ${address}`) + const utxos = await _this._utxosFromElectrumx(address) + + return { + utxos, + address + } + }) + + // Wait for all parallel Insight requests to return. + const result = await Promise.all(addresses) + + // Return the array of retrieved address information. + res.status(200) + return res.json({ + success: true, + utxos: result + }) + } catch (err) { + wlogger.error('Error in electrumx.js/utxoBulk().', err) + + return _this.errorHandler(err, res) + } + } + + // Returns a promise that resolves to transaction details data for a txid. + // Expects input to be a txid string, and input validation to have already + // been done by parent, calling function. + async _transactionDetailsFromElectrum (txid, verbose = true) { + try { + if (!_this.isReady) { + throw new Error( + 'ElectrumX server connection is not ready. Call await connectToServer() first.' + ) + } + + // Query the utxos from the ElectrumX server. + const electrumResponse = await _this.electrumx.request( + 'blockchain.transaction.get', + txid, + verbose + ) + // console.log( + // `electrumResponse: ${JSON.stringify(electrumResponse, null, 2)}` + // ) + + return electrumResponse + } catch (err) { + // console.log('err: ', err) + + // Write out error to error log. + wlogger.error( + 'Error in elecrumx.js/_transactionDetailsFromElectrum(): ', + err + ) + throw err + } + } + + /** + * @api {get} /electrumx/tx/data/{txid} Get transaction details for a TXID + * @apiName transaction details for a TXID + * @apiGroup ElectrumX / Fulcrum + * @apiDescription Returns an object with transaction details of the TXID + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/electrumx/tx/data/a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d" -H "accept: application/json" + * + */ + // GET handler for single transaction + async getTransactionDetails (req, res, next) { + try { + const txid = req.params.txid + const verbose = req.query.verbose + + // Reject if txid is anything other than a string + if (typeof txid !== 'string') { + res.status(400) + return res.json({ + success: false, + error: 'txid must be a string' + }) + } + + wlogger.debug( + 'Executing electrumx/getTransactionDetails with this txid: ', + txid + ) + + // Get data from ElectrumX server. + const electrumResponse = await _this._transactionDetailsFromElectrum( + txid, + verbose + ) + // console.log(`_transactionDetailsFromElectrum(): ${JSON.stringify(electrumResponse, null, 2)}`) + + // Pass the error message if ElectrumX reports an error. + if (electrumResponse instanceof Error) { + res.status(400) + return res.json({ + success: false, + error: electrumResponse.message + }) + } + + res.status(200) + return res.json({ + success: true, + details: electrumResponse + }) + } catch (err) { + // Write out error to error log. + wlogger.error('Error in elecrumx.js/getTransactionDetails().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {post} /electrumx/tx/data Get transaction details for an array of TXIDs + * @apiName Transaction details for an array of TXIDs + * @apiGroup ElectrumX / Fulcrum + * @apiDescription Returns an array of objects with transaction details of an array of TXIDs. + * Limited to 20 items per request. + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v4/electrumx/tx/data" -H "accept: application/json" -H "Content-Type: application/json" -d '{"txids":["a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d","a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d"], "verbose":false}' + * + * + */ + // POST handler for bulk queries on transaction details + async transactionDetailsBulk (req, res, next) { + try { + const txids = req.body.txids + const verbose = req.body.verbose || true + + // Reject if txids is not an array. + if (!Array.isArray(txids)) { + res.status(400) + return res.json({ + success: false, + error: 'txids needs to be an array. Use GET for single txid.' + }) + } + + // Enforce array size rate limits + if (!_this.routeUtils.validateArraySize(req, txids)) { + res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 + return res.json({ + success: false, + error: 'Array too large.' + }) + } + + wlogger.debug( + 'Executing electrumx.js/transactionDetailsBulk with these txids: ', + txids + ) + + // Loops through each address and creates an array of Promises, querying + // the Electrum server in parallel. + const transactions = txids.map(async (txid, index) => { + // console.log(`address: ${address}`) + const details = await _this._transactionDetailsFromElectrum( + txid, + verbose + ) + + return { details, txid } + }) + + // Wait for all parallel Electrum requests to return. + const result = await Promise.all(transactions) + + // Return the array of retrieved transaction details. + res.status(200) + return res.json({ + success: true, + transactions: result + }) + } catch (err) { + wlogger.error('Error in electrumx.js/transactionDetailsBulk().', err) + + return _this.errorHandler(err, res) + } + } + + // Returns a promise that resolves to transaction ID of the broadcasted transaction or an error. + // Expects input to be a txHex string, and input validation to have already + // been done by parent, calling function. + async _broadcastTransactionWithElectrum (txHex) { + try { + if (!_this.isReady) { + throw new Error( + 'ElectrumX server connection is not ready. Call await connectToServer() first.' + ) + } + + // Broadcast the transaction hex to the ElectrumX server. + const electrumResponse = await _this.electrumx.request( + 'blockchain.transaction.broadcast', + txHex + ) + // console.log( + // `electrumResponse: ${JSON.stringify(electrumResponse, null, 2)}` + // ) + + return electrumResponse + } catch (err) { + // console.log('err: ', err) + + // Write out error to error log. + wlogger.error( + 'Error in elecrumx.js/_transactionDetailsFromElectrum(): ', + err + ) + throw err + } + } + + /** + * @api {post} /electrumx/tx/broadcast Broadcast a raw transaction + * @apiName Broadcast a raw transaction + * @apiGroup ElectrumX / Fulcrum + * @apiDescription Broadcast a raw transaction and return the transaction ID on success or error on failure. + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v4/electrumx/tx/broadcast" -H "accept: application/json" -H "Content-Type: application/json" -d '{"txHex":"020000000265d13ef402840c8a51f39779afb7ae4d49e4b0a3c24a3d0e7742038f2c679667010000006441dd1dd72770cadede1a7fd0363574846c48468a398ddfa41a9677c74cac8d2652b682743725a3b08c6c2021a629011e11a264d9036e9d5311e35b5f4937ca7b4e4121020797d8fd4d2fa6fd7cdeabe2526bfea2b90525d6e8ad506ec4ee3c53885aa309ffffffff65d13ef402840c8a51f39779afb7ae4d49e4b0a3c24a3d0e7742038f2c679667000000006441347d7f218c11c04487c1ad8baac28928fb10e5054cd4494b94d078cfa04ccf68e064fb188127ff656c0b98e9ce87f036d183925d0d0860605877d61e90375f774121028a53f95eb631b460854fc836b2e5d31cad16364b4dc3d970babfbdcc3f2e4954ffffffff035ac355000000000017a914189ce02e332548f4804bac65cba68202c9dbf822878dfd0800000000001976a914285bb350881b21ac89724c6fb6dc914d096cd53b88acf9ef3100000000001976a91445f1f1c4a9b9419a5088a3e9c24a293d7a150e6488ac00000000"}' + * + */ + // POST handler for broadcasting a single transaction + async broadcastTransaction (req, res, next) { + try { + const txHex = req.body.txHex + + if (typeof txHex !== 'string') { + res.status(400) + return res.json({ + success: false, + error: 'request body must be a string.' + }) + } + + wlogger.debug( + 'Executing electrumx/broadcastTransaction with this tx hex: ', + txHex + ) + + // Get data from ElectrumX server. + const electrumResponse = await _this._broadcastTransactionWithElectrum(txHex) + // console.log(`_utxosFromElectrumx(): ${JSON.stringify(electrumResponse, null, 2)}`) + + // Pass the error message if ElectrumX reports an error. + if (electrumResponse instanceof Error) { + res.status(400) + return res.json({ + success: false, + error: electrumResponse.message + }) + } + + res.status(200) + return res.json({ + success: true, + txid: electrumResponse + }) + } catch (err) { + wlogger.error('Error in electrumx.js/broadcastTransaction().', err) + + return _this.errorHandler(err, res) + } + } + + // Returns a promise that resolves to block header data for a block height. + // Expects input to be a height number, and input validation to have already + // been done by parent, calling function. + async _blockHeadersFromElectrum (height, count = 1) { + try { + if (!_this.isReady) { + throw new Error( + 'ElectrumX server connection is not ready. Call await connectToServer() first.' + ) + } + + // Query the block header from the ElectrumX server. + const electrumResponse = await _this.electrumx.request('blockchain.block.headers', height, count) + // console.log( + // `electrumResponse: ${JSON.stringify(electrumResponse, null, 2)}` + // ) + + const HEADER_SIZE = 80 * 2 + + if (!(electrumResponse instanceof Error)) { + const headers = electrumResponse.hex.match(new RegExp(`.{1,${HEADER_SIZE}}`, 'g')) + return headers + } + + return electrumResponse + } catch (err) { + // console.log('err: ', err) + + // Write out error to error log. + wlogger.error( + 'Error in elecrumx.js/_blockHeaderFromElectrum(): ', + err + ) + throw err + } + } + + /** + * @api {get} /electrumx/block/headers/{height} Get `count` block headers starting at a height + * @apiName Block header data for a `count` blocks starting at a block height + * @apiGroup ElectrumX / Fulcrum + * @apiDescription Returns an array with block headers starting at the block height + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/electrumx/block/header/42?count=2" -H "accept: application/json" + * + */ + // GET handler for single block headers + async getBlockHeaders (req, res, next) { + try { + const height = Number(req.params.height) + const count = req.query.count === undefined ? 1 : Number(req.query.count) + + // Reject if height is not a number + if (Number.isNaN(height) || height < 0) { + res.status(400) + return res.json({ + success: false, + error: 'height must be a positive number' + }) + } + + // Reject if height is not a number + if (Number.isNaN(count) || count < 0) { + res.status(400) + return res.json({ + success: false, + error: 'count must be a positive number' + }) + } + + wlogger.debug( + 'Executing electrumx/getBlockHeaders with this height: ', + height + ) + + // Get data from ElectrumX server. + const electrumResponse = await _this._blockHeadersFromElectrum(height, count) + // console.log(`_transactionDetailsFromElectrum(): ${JSON.stringify(electrumResponse, null, 2)}`) + + // Pass the error message if ElectrumX reports an error. + if (electrumResponse instanceof Error) { + res.status(400) + return res.json({ + success: false, + error: electrumResponse.message + }) + } + + res.status(200) + return res.json({ + success: true, + headers: electrumResponse + }) + } catch (err) { + // Write out error to error log. + wlogger.error('Error in elecrumx.js/getBlockHeader().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {post} /electrumx/block/headers Get block headers for an array of height + count pairs + * @apiName Block headers for an array of height + count pairs + * @apiGroup ElectrumX / Fulcrum + * @apiDescription Returns an array of objects with blockheaders of an array of TXIDs. + * Limited to 20 items per request. + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v4/electrumx/block/headers" -H "accept: application/json" -H "Content-Type: application/json" -d '{"heights":[{ "height": 42, count: 2 }, { "height": 100, count: 5 }]}' + * + */ + // POST handler for bulk queries on block headers + async blockHeadersBulk (req, res, next) { + try { + const heights = req.body.heights + + // Reject if heights is not an array. + if (!Array.isArray(heights)) { + res.status(400) + return res.json({ + success: false, + error: 'heights needs to be an array. Use GET for single height.' + }) + } + + // Enforce array size rate limits + if (!_this.routeUtils.validateArraySize(req, heights)) { + res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 + return res.json({ + success: false, + error: 'Array too large.' + }) + } + + wlogger.debug( + 'Executing electrumx.js/blockHeadersBulk with these txids: ', + heights + ) + + // Loops through each address and creates an array of Promises, querying + // the Electrum server in parallel. + const transactions = heights.map(async (obj) => { + const headers = await _this._blockHeadersFromElectrum( + obj.height, + obj.count + ) + + return { headers } + }) + + // Wait for all parallel Electrum requests to return. + const result = await Promise.all(transactions) + + // Return the array of retrieved transaction details. + res.status(200) + return res.json({ + success: true, + headers: result + }) + } catch (err) { + wlogger.error('Error in electrumx.js/blockHeadersBulk().', err) + + return _this.errorHandler(err, res) + } + } + + // Returns a promise that resolves to a balance for an address. Expects input + // to be a cash address, and input validation to have already been done by + // parent, calling function. + async _balanceFromElectrumx (address) { + try { + // Convert the address to a scripthash. + const scripthash = _this.addressToScripthash(address) + + if (!_this.isReady) { + throw new Error( + 'ElectrumX server connection is not ready. Call await connectToServer() first.' + ) + } + + // Query the address balance from the ElectrumX server. + const electrumResponse = await _this.electrumx.request( + 'blockchain.scripthash.get_balance', + scripthash + ) + // console.log( + // `electrumResponse: ${JSON.stringify(electrumResponse, null, 2)}` + // ) + + return electrumResponse + } catch (err) { + // console.log('err1: ', err) + + // Write out error to error log. + wlogger.error('Error in elecrumx.js/_utxosFromElectrumx(): ', err) + throw err + } + } + + /** + * @api {get} /electrumx/balance/{addr} Get balance for a single address. + * @apiName Balance for a single address + * @apiGroup ElectrumX / Fulcrum + * @apiDescription Returns an object with confirmed and unconfirmed balance associated with an address. + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/electrumx/balance/bitcoincash:qr69kyzha07dcecrsvjwsj4s6slnlq4r8c30lxnur3" -H "accept: application/json" + * + */ + // GET handler for single balance + async getBalance (req, res, next) { + try { + const address = req.params.address + + // Reject if address is an array. + if (Array.isArray(address)) { + res.status(400) + return res.json({ + success: false, + error: 'address can not be an array. Use POST for bulk upload.' + }) + } + + // Ensure the address is in cash address format. + const cashAddr = _this.bchjs.Address.toCashAddress(address) + + // Prevent a common user error. Ensure they are using the correct network address. + const networkIsValid = _this.routeUtils.validateNetwork(cashAddr) + if (!networkIsValid) { + res.status(400) + return res.json({ + success: false, + error: + 'Invalid network. Trying to use a testnet address on mainnet, or vice versa.' + }) + } + + wlogger.debug( + 'Executing electrumx/getBalance with this address: ', + cashAddr + ) + + // Get data from ElectrumX server. + const electrumResponse = await _this._balanceFromElectrumx(cashAddr) + // console.log(`_utxosFromElectrumx(): ${JSON.stringify(electrumResponse, null, 2)}`) + + // Pass the error message if ElectrumX reports an error. + if (Object.prototype.hasOwnProperty.call(electrumResponse, 'code')) { + res.status(400) + return res.json({ + success: false, + message: electrumResponse.message + }) + } + + res.status(200) + return res.json({ + success: true, + balance: electrumResponse + }) + } catch (err) { + // Write out error to error log. + wlogger.error('Error in elecrumx.js/getBalance().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {post} /electrumx/balance Get balances for an array of addresses. + * @apiName Balances for an array of addresses + * @apiGroup ElectrumX / Fulcrum + * @apiDescription Returns an array of balanes associated with an array of address. + * Limited to 20 items per request. + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v4/electrumx/balance" -H "accept: application/json" -H "Content-Type: application/json" -d '{"addresses":["bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf","bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf"]}' + * + * + */ + // POST handler for bulk queries on address balance + async balanceBulk (req, res, next) { + try { + let addresses = req.body.addresses + + // 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 (!_this.routeUtils.validateArraySize(req, addresses)) { + res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 + return res.json({ + error: 'Array too large.' + }) + } + + wlogger.debug( + 'Executing electrumx.js/balanceBulk 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 { + _this.bchjs.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 = _this.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 + // ElectrumX API in parallel. + addresses = addresses.map(async (address, index) => { + // console.log(`address: ${address}`) + const balance = await _this._balanceFromElectrumx(address) + + return { + balance, + address + } + }) + + // Wait for all parallel Insight requests to return. + const result = await Promise.all(addresses) + + // Return the array of retrieved address information. + res.status(200) + return res.json({ + success: true, + balances: result + }) + } catch (err) { + wlogger.error('Error in electrumx.js/balanceBulk().', err) + + return _this.errorHandler(err, res) + } + } + + // Returns a promise that resolves an array of transaction history for an + // address. Expects input to be a cash address, and input validation to have + // already been done by parent, calling function. + async _transactionsFromElectrumx (address) { + try { + // Convert the address to a scripthash. + const scripthash = _this.addressToScripthash(address) + + if (!_this.isReady) { + throw new Error( + 'ElectrumX server connection is not ready. Call await connectToServer() first.' + ) + } + + // Query the address transaction history from the ElectrumX server. + const electrumResponse = await _this.electrumx.request( + 'blockchain.scripthash.get_history', + scripthash + ) + // console.log( + // `electrumResponse: ${JSON.stringify(electrumResponse, null, 2)}` + // ) + + return electrumResponse + } catch (err) { + // console.log('err1: ', err) + + // Write out error to error log. + wlogger.error('Error in elecrumx.js/_transactionsFromElectrumx(): ', err) + throw err + } + } + + /** + * @api {get} /electrumx/transactions/{addr} Get transaction history for a single address. + * @apiName Transaction history for a single address + * @apiGroup ElectrumX / Fulcrum + * @apiDescription Returns an array of historical transactions associated with an address. + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/electrumx/transactions/bitcoincash:qr69kyzha07dcecrsvjwsj4s6slnlq4r8c30lxnur3" -H "accept: application/json" + * + */ + // GET handler for single balance + async getTransactions (req, res, next) { + try { + const address = req.params.address + + // Reject if address is an array. + if (Array.isArray(address)) { + res.status(400) + return res.json({ + success: false, + error: 'address can not be an array. Use POST for bulk upload.' + }) + } + + // Ensure the address is in cash address format. + const cashAddr = _this.bchjs.Address.toCashAddress(address) + + // Prevent a common user error. Ensure they are using the correct network address. + const networkIsValid = _this.routeUtils.validateNetwork(cashAddr) + if (!networkIsValid) { + res.status(400) + return res.json({ + success: false, + error: + 'Invalid network. Trying to use a testnet address on mainnet, or vice versa.' + }) + } + + wlogger.debug( + 'Executing electrumx/getTransactions with this address: ', + cashAddr + ) + + // Get data from ElectrumX server. + const electrumResponse = await _this._transactionsFromElectrumx(cashAddr) + // console.log(`_utxosFromElectrumx(): ${JSON.stringify(electrumResponse, null, 2)}`) + + // Pass the error message if ElectrumX reports an error. + if (Object.prototype.hasOwnProperty.call(electrumResponse, 'code')) { + res.status(400) + return res.json({ + success: false, + message: electrumResponse.message + }) + } + + res.status(200) + return res.json({ + success: true, + transactions: electrumResponse + }) + } catch (err) { + // Write out error to error log. + wlogger.error('Error in elecrumx.js/getTransactions().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {post} /electrumx/transactions Get the transaction history for an array of addresses. + * @apiName Transactions for an array of addresses + * @apiGroup ElectrumX / Fulcrum + * @apiDescription Returns an array of transactions associated with an array of address. + * Limited to 20 items per request. + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v4/electrumx/transactions" -H "accept: application/json" -H "Content-Type: application/json" -d '{"addresses":["bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf","bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf"]}' + * + * + */ + // POST handler for bulk queries on transaction histories for addresses. + async transactionsBulk (req, res, next) { + try { + let addresses = req.body.addresses + + // 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 (!_this.routeUtils.validateArraySize(req, addresses)) { + res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 + return res.json({ + error: 'Array too large.' + }) + } + + wlogger.debug( + 'Executing electrumx.js/transactionsBulk 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 { + _this.bchjs.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 = _this.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 + // ElectrumX API in parallel. + addresses = addresses.map(async (address, index) => { + // console.log(`address: ${address}`) + const transactions = await _this._transactionsFromElectrumx(address) + + return { + transactions, + address + } + }) + + // Wait for all parallel Insight requests to return. + const result = await Promise.all(addresses) + + // Return the array of retrieved address information. + res.status(200) + return res.json({ + success: true, + transactions: result + }) + } catch (err) { + wlogger.error('Error in electrumx.js/transactionsBulk().', err) + + return _this.errorHandler(err, res) + } + } + + // Returns a promise that resolves to unconfirmed UTXO data (mempool) for an address. + // Expects input to be a cash address, and input validation to have + // already been done by parent, calling function. + async _mempoolFromElectrumx (address) { + try { + // Convert the address to a scripthash. + const scripthash = _this.addressToScripthash(address) + + if (!_this.isReady) { + throw new Error( + 'ElectrumX server connection is not ready. Call await connectToServer() first.' + ) + } + + // Query the unconfirmed utxos from the ElectrumX server. + const electrumResponse = await _this.electrumx.request( + 'blockchain.scripthash.get_mempool', + scripthash + ) + // console.log( + // `electrumResponse: ${JSON.stringify(electrumResponse, null, 2)}` + // ) + + return electrumResponse + } catch (err) { + // console.log('err: ', err) + + // Write out error to error log. + wlogger.error('Error in elecrumx.js/_mempoolFromElectrumx(): ', err) + throw err + } + } + + /** + * @api {get} /electrumx/unconfirmed/{addr} Get unconfirmed utxos for a single address. + * @apiName Unconfirmed UTXOs for a single address + * @apiGroup ElectrumX / Fulcrum + * @apiDescription Returns an object with unconfirmed UTXOs associated with an address. + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/electrumx/unconfirmed/bitcoincash:qr69kyzha07dcecrsvjwsj4s6slnlq4r8c30lxnur3" -H "accept: application/json" + * + */ + // GET handler for single balance + async getMempool (req, res, next) { + try { + const address = req.params.address + + // Reject if address is an array. + if (Array.isArray(address)) { + res.status(400) + return res.json({ + success: false, + error: 'address can not be an array. Use POST for bulk upload.' + }) + } + + // Ensure the address is in cash address format. + const cashAddr = _this.bchjs.Address.toCashAddress(address) + + // Prevent a common user error. Ensure they are using the correct network address. + const networkIsValid = _this.routeUtils.validateNetwork(cashAddr) + if (!networkIsValid) { + res.status(400) + return res.json({ + success: false, + error: + 'Invalid network. Trying to use a testnet address on mainnet, or vice versa.' + }) + } + + wlogger.debug( + 'Executing electrumx/getMempool with this address: ', + cashAddr + ) + + // Get data from ElectrumX server. + const electrumResponse = await _this._mempoolFromElectrumx(cashAddr) + // console.log(`_mempoolFromElectrumx(): ${JSON.stringify(electrumResponse, null, 2)}`) + + // Pass the error message if ElectrumX reports an error. + if (Object.prototype.hasOwnProperty.call(electrumResponse, 'code')) { + res.status(400) + return res.json({ + success: false, + message: electrumResponse.message + }) + } + + res.status(200) + return res.json({ + success: true, + utxos: electrumResponse + }) + } catch (err) { + // Write out error to error log. + wlogger.error('Error in elecrumx.js/getMempool().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {post} /electrumx/unconfirmed Get unconfirmed utxos for an array of addresses. + * @apiName Unconfirmed UTXOs for an array of addresses + * @apiGroup ElectrumX / Fulcrum + * @apiDescription Returns an array of objects with unconfirmed UTXOs associated with an address. + * Limited to 20 items per request. + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v4/electrumx/unconfirmed" -H "accept: application/json" -H "Content-Type: application/json" -d '{"addresses":["bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf","bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf"]}' + * + * + */ + // POST handler for bulk queries on address details + async mempoolBulk (req, res, next) { + try { + let addresses = req.body.addresses + + // 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 (!_this.routeUtils.validateArraySize(req, addresses)) { + res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 + return res.json({ + error: 'Array too large.' + }) + } + + wlogger.debug( + 'Executing electrumx.js/mempoolBulk 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 { + _this.bchjs.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 = _this.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) => { + // console.log(`address: ${address}`) + const utxos = await _this._mempoolFromElectrumx(address) + + return { + utxos, + address + } + }) + + // Wait for all parallel Insight requests to return. + const result = await Promise.all(addresses) + + // Return the array of retrieved address information. + res.status(200) + return res.json({ + success: true, + utxos: result + }) + } catch (err) { + wlogger.error('Error in electrumx.js/mempoolBulk().', err) + + return _this.errorHandler(err, res) + } + } + + // Convert a 'bitcoincash:...' address to a script hash used by ElectrumX. + addressToScripthash (addrStr) { + try { + // console.log(`addrStr: ${addrStr}`) + + const address = _this.bitcore.Address.fromString(addrStr) + // console.log(`address: ${address}`) + + const script = address.isPayToPublicKeyHash() + ? _this.bitcore.Script.buildPublicKeyHashOut(address) + : _this.bitcore.Script.buildScriptHashOut(address) + // console.log(`script: ${script}`) + + const scripthash = _this.bitcore.crypto.Hash.sha256(script.toBuffer()) + .reverse() + .toString('hex') + // console.log(`scripthash: ${scripthash}`) + + return scripthash + } catch (err) { + wlogger.error('Error in electrumx.js/addressToScripthash()') + throw err + } + } +} + +module.exports = Electrum diff --git a/src/routes/v5/encryption.js b/src/routes/v5/encryption.js new file mode 100644 index 0000000..5f7b54b --- /dev/null +++ b/src/routes/v5/encryption.js @@ -0,0 +1,192 @@ +/* + Encryption API route +*/ + +'use strict' + +const express = require('express') +const router = express.Router() +const axios = require('axios') +const util = require('util') + +const wlogger = require('../../util/winston-logging') +const config = require('../../../config') + +const RouteUtils = require('../../util/route-utils') +const routeUtils = new RouteUtils() + +const BCHJS = require('@psf/bch-js') +const restURL = process.env.LOCAL_RESTURL + ? process.env.LOCAL_RESTURL + : 'https://api.fullstack.cash/v4/' +const bchjs = new BCHJS({ restURL }) + +let _this + +class Encryption { + constructor () { + _this = this + + _this.config = config + _this.axios = axios + _this.routeUtils = routeUtils + _this.bchjs = bchjs + // _this.blockbook = blockbook + // _this.rawTransactions = rawTransactions + + _this.router = router + _this.router.get('/', _this.root) + _this.router.get('/publickey/:address', _this.getPublicKey) + } + + // DRY error handler. + errorHandler (err, res) { + // Attempt to decode the error message. + const { msg, status } = _this.routeUtils.decodeError(err) + if (msg) { + res.status(status) + return res.json({ success: false, error: msg }) + } + + // Handle error patterns specific to this route. + if (err.message) { + res.status(400) + return res.json({ success: false, error: err.message }) + } + + wlogger.error('Unhandled error in Encryption library: ', err) + + // If error can be handled, return the stack trace + res.status(500) + return res.json({ error: util.inspect(err) }) + } + + // Root API endpoint. Simply acknowledges that it exists. + root (req, res, next) { + return res.json({ status: 'encryption' }) + } + + /** + * @api {get} /encryption/publickey/{addr} Get public key for a BCH address. + * @apiName Get encryption key for bch address + * @apiGroup Encryption + * @apiDescription Searches the blockchain for a public key associated with a + * BCH address. Returns an object. If successful, the publicKey property will + * contain a hexidecimal representation of the public key. + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/encryption/publickey/bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf" -H "accept: application/json" + * + */ + async getPublicKey (req, res, next) { + try { + const address = req.params.address + + // Reject if address is an array. + if (Array.isArray(address)) { + res.status(400) + return res.json({ + success: false, + error: 'address can not be an array.' + }) + } + + // Generate a user object that can be passed along with internal calls + // from bch-js. + const usrObj = { + ip: req._remoteAddress, + jwtToken: req.locals.jwtToken, + proLimit: req.locals.proLimit, + apiLevel: req.locals.apiLevel + } + + const cashAddr = _this.bchjs.Address.toCashAddress(address) + + // Prevent a common user error. Ensure they are using the correct network address. + const networkIsValid = _this.routeUtils.validateNetwork(cashAddr) + if (!networkIsValid) { + res.status(400) + return res.json({ + success: false, + error: + 'Invalid network. Trying to use a testnet address on mainnet, or vice versa.' + }) + } + + // console.log( + wlogger.debug( + 'Executing encryption/getPublicKey with this address: ', + cashAddr + ) + + const rawTxData = await _this.bchjs.Electrumx.transactions([cashAddr], usrObj) + // console.log(`rawTxData: ${JSON.stringify(rawTxData, null, 2)}`) + + // Extract just the TXIDs + const txids = rawTxData.transactions[0].transactions.map((elem) => elem.tx_hash) + // console.log(`txids: ${JSON.stringify(txids, null, 2)}`) + + // throw error if there is no transaction history. + if (!txids || txids.length === 0) { + throw new Error('No transaction history.') + } + + // Loop through the transaction history and search for the public key. + for (let i = 0; i < txids.length; i++) { + const thisTx = txids[i] + + const txDetails = await _this.bchjs.RawTransactions.getRawTransaction( + [thisTx], + true, + usrObj + ) + // console.log(`txDetails: ${JSON.stringify(txDetails, null, 2)}`) + + const vin = txDetails[0].vin + + // Loop through each input. + for (let j = 0; j < vin.length; j++) { + const thisVin = vin[j] + // console.log(`thisVin: ${JSON.stringify(thisVin, null, 2)}`) + + // Extract the script signature. + const scriptSig = thisVin.scriptSig.asm.split(' ') + // console.log(`scriptSig: ${JSON.stringify(scriptSig, null, 2)}`) + + // Extract the public key from the script signature. + const pubKey = scriptSig[scriptSig.length - 1] + // console.log(`pubKey: ${pubKey}`) + + // Generate cash address from public key. + const keyBuf = Buffer.from(pubKey, 'hex') + const ec = _this.bchjs.ECPair.fromPublicKey(keyBuf) + const cashAddr2 = _this.bchjs.ECPair.toCashAddress(ec) + // console.log(`cashAddr2: ${cashAddr2}`) + + // If public keys match, this is the correct public key. + if (cashAddr === cashAddr2) { + res.status(200) + return res.json({ + success: true, + publicKey: pubKey + }) + } + } + } + + res.status(200) + return res.json({ + success: false, + publicKey: 'not found' + }) + } catch (err) { + // console.log('Error in encryption.js/getPublicKey().', err) + wlogger.error('Error in encryption.js/getPublicKey().', err) + + return _this.errorHandler(err, res) + } + } +} + +module.exports = Encryption diff --git a/src/routes/v5/full-node/blockchain.js b/src/routes/v5/full-node/blockchain.js new file mode 100644 index 0000000..8656599 --- /dev/null +++ b/src/routes/v5/full-node/blockchain.js @@ -0,0 +1,989 @@ +/* + A library for interacting with the Full Node +*/ + +'use strict' + +const express = require('express') +const router = express.Router() + +const axios = require('axios') +const wlogger = require('../../../util/winston-logging') + +const RouteUtils = require('../../../util/route-utils') +const routeUtils = new RouteUtils() + +// Used to convert error messages to strings, to safely pass to users. +const util = require('util') +util.inspect.defaultOptions = { depth: 1 } + +const BCHJS = require('@psf/bch-js') +const bchjs = new BCHJS() + +let _this + +class Blockchain { + constructor () { + _this = this + + this.bchjs = bchjs + this.axios = axios + this.routeUtils = routeUtils + + this.router = router + this.router.get('/', this.root) + this.router.get('/getBestBlockHash', this.getBestBlockHash) + this.router.get('/getBlockchainInfo', this.getBlockchainInfo) + this.router.get('/getBlockCount', this.getBlockCount) + this.router.get('/getBlockHeader/:hash', this.getBlockHeaderSingle) + this.router.post('/getBlockHeader', this.getBlockHeaderBulk) + this.router.get('/getChainTips', this.getChainTips) + this.router.get('/getDifficulty', this.getDifficulty) + this.router.get('/getMempoolEntry/:txid', this.getMempoolEntrySingle) + this.router.post('/getMempoolEntry', this.getMempoolEntryBulk) + this.router.get( + '/getMempoolAncestors/:txid', + this.getMempoolAncestorsSingle + ) + this.router.get('/getMempoolInfo', this.getMempoolInfo) + this.router.get('/getRawMempool', this.getRawMempool) + this.router.get('/getTxOut/:txid/:n', this.getTxOut) + this.router.post('/getTxOut', this.getTxOutPost) + this.router.get('/getTxOutProof/:txid', this.getTxOutProofSingle) + this.router.post('/getTxOutProof', this.getTxOutProofBulk) + this.router.get('/verifyTxOutProof/:proof', this.verifyTxOutProofSingle) + this.router.post('/verifyTxOutProof', this.verifyTxOutProofBulk) + this.router.post('/getBlock', this.getBlock) + } + + root (req, res, next) { + return res.json({ status: 'blockchain' }) + } + + // DRY error handler. + errorHandler (err, res) { + // Attempt to decode the error message. + const { msg, status } = _this.routeUtils.decodeError(err) + if (msg) { + res.status(status) + return res.json({ error: msg }) + } + + res.status(500) + return res.json({ error: util.inspect(err) }) + } + + /** + * @api {get} /blockchain/getBestBlockHash Get best block hash + * @apiName GetBestBlockHash + * @apiGroup Blockchain + * @apiDescription Returns the hash of the best (tip) block in the longest + * block chain. + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/blockchain/getBestBlockHash" -H "accept: application/json" + * + * @apiSuccess {String} bestBlockHash 000000000000000002bc884334336d99c9a9c616670a9244c6a8c1fc35aa91a1 + */ + async getBestBlockHash (req, res, next) { + try { + // Axios options + const options = _this.routeUtils.getAxiosOptions() + options.data.id = 'getbestblockhash' + options.data.method = 'getbestblockhash' + options.data.params = [] + + const response = await _this.axios.request(options) + // console.log(`response.data: ${JSON.stringify(response.data, null, 2)}`) + + return res.json(response.data.result) + } catch (err) { + // Write out error to error log. + wlogger.error('Error in blockchain.ts/getBestBlockHash().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {get} /blockchain/getBlockchainInfo Get blockchain info + * @apiName GetBlockchainInfo + * @apiGroup Blockchain + * @apiDescription Returns an object containing various state info regarding blockchain processing. + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/blockchain/getBlockchainInfo" -H "accept: application/json" + * + * @apiSuccess {Object} object Object containing data + * @apiSuccess {String} object.chain "main" + * @apiSuccess {Number} object.blocks 561838 + * @apiSuccess {Number} object.headers 561838 + * @apiSuccess {String} object.bestblockhash "000000000000000002307dd38cd01c7308b8febfcdf5772cf087b5bb023d55bc" + * @apiSuccess {Number} object.difficulty 246585566638.1496 + * @apiSuccess {String} object.mediantime 1545402693 + * @apiSuccess {Number} object.verificationprogress 0.999998831622689 + * @apiSuccess {Boolean} object.chainwork "000000000000000000000000000000000000000000d8c09a8ab7262080266b3e" + * @apiSuccess {Number} object.pruned false + * @apiSuccess {Array} object.softforks Array of objects + * @apiSuccess {String} object.softforks.id "bip34" + * @apiSuccess {String} object.softforks.version 2 + * @apiSuccess {Object} object.softforks.reject + * @apiSuccess {String} object.softforks.reject.status true + */ + async getBlockchainInfo (req, res, next) { + try { + // Axios options + const options = _this.routeUtils.getAxiosOptions() + options.data.id = 'getblockchaininfo' + options.data.method = 'getblockchaininfo' + options.data.params = [] + + const response = await _this.axios.request(options) + + return res.json(response.data.result) + } catch (err) { + // Write out error to error log. + wlogger.error('Error in blockchain.ts/getBlockchainInfo().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {get} /blockchain/getBlockCount Get Block Count + * @apiName GetBlockCount + * @apiGroup Blockchain + * @apiDescription Returns the number of blocks in the longest blockchain. + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/blockchain/getBlockCount" -H "accept: application/json" + * + * @apiSuccess {Number} bestBlockCount 587665 + */ + async getBlockCount (req, res, next) { + try { + // Axios options + const options = _this.routeUtils.getAxiosOptions() + options.data.id = 'getblockcount' + options.data.method = 'getblockcount' + options.data.params = [] + + const response = await _this.axios.request(options) + + return res.json(response.data.result) + } catch (err) { + // Write out error to error log. + // logger.error(`Error in rawtransactions/decodeRawTransaction: `, err) + wlogger.error('Error in blockchain.ts/getBlockCount().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {get} /blockchain/getBlockHeader/:hash Get single block header + * @apiName GetSingleBlockHeader + * @apiGroup Blockchain + * @apiDescription If verbose is false (default), returns a string that is + * serialized, hex-encoded data for blockheader 'hash'. If verbose is true, + * returns an Object with information about blockheader hash. + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/blockchain/getBlockHeader/000000000000000005e14d3f9fdfb70745308706615cfa9edca4f4558332b201?verbose=true" -H "accept: application/json" + * + * @apiParam {String} hash block hash + * @apiParam {Boolean} verbose Return verbose data + * + * @apiSuccess {Object} object Object containing data + * @apiSuccess {String} object.hash "000000000000000005e14d3f9fdfb70745308706615cfa9edca4f4558332b201" + * @apiSuccess {Number} object.confirmations 61839 + * @apiSuccess {Number} object.height 500000 + * @apiSuccess {Number} object.version 536870912 + * @apiSuccess {String} object.versionHex "20000000" + * @apiSuccess {String} object.merkleroot "4af279645e1b337e655ae3286fc2ca09f58eb01efa6ab27adedd1e9e6ec19091" + * @apiSuccess {Number} object.time 1509343584 + * @apiSuccess {Number} object.mediantime 1509336533 + * @apiSuccess {Number} object.nonce 3604508752 + * @apiSuccess {String} object.bits "1809b91a" + * @apiSuccess {Number} object.difficulty 113081236211.4533 + * @apiSuccess {String} object.chainwork "0000000000000000000000000000000000000000007ae48aca46e3b449ad9714" + * @apiSuccess {String} object.previousblockhash "0000000000000000043831d6ebb013716f0580287ee5e5687e27d0ed72e6e523" + * @apiSuccess {String} object.nextblockhash "00000000000000000568f0a96bf4348847bc84e455cbfec389f27311037a20f3" + */ + async getBlockHeaderSingle (req, res, next) { + try { + let verbose = false + if (req.query.verbose && req.query.verbose.toString() === 'true') { + verbose = true + } + + const hash = req.params.hash + if (!hash || hash === '') { + res.status(400) + return res.json({ error: 'hash can not be empty' }) + } + + // Axios options + const options = _this.routeUtils.getAxiosOptions() + options.data.id = 'getblockheader' + options.data.method = 'getblockheader' + options.data.params = [hash, verbose] + + const response = await _this.axios.request(options) + + return res.json(response.data.result) + } catch (err) { + // Write out error to error log. + // logger.error(`Error in rawtransactions/decodeRawTransaction: `, err) + wlogger.error('Error in blockchain.ts/getBlockHeaderSingle().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {post} /blockchain/getBlockHeader Get multiple block headers + * @apiName GetBulkBlockHeader + * @apiGroup Blockchain + * @apiDescription If verbose is false (default), returns a string that is + * serialized, hex-encoded data for blockheader 'hash'. If verbose is true, + * returns an Object with information about blockheader hash. + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v4/blockchain/getBlockHeader" -H "accept: application/json" -H "Content-Type: application/json" -d "{\"hashes\":[\"000000000000000005e14d3f9fdfb70745308706615cfa9edca4f4558332b201\",\"00000000000000000568f0a96bf4348847bc84e455cbfec389f27311037a20f3\"],\"verbose\":true}" + * + * @apiParam {String} hash block hash + * @apiParam {Boolean} verbose Return verbose data + * + * @apiSuccess {Array} array array containing objects + * @apiSuccess {String} object.hash "000000000000000005e14d3f9fdfb70745308706615cfa9edca4f4558332b201" + * @apiSuccess {Number} object.confirmations 61839 + * @apiSuccess {Number} object.height 500000 + * @apiSuccess {Number} object.version 536870912 + * @apiSuccess {String} object.versionHex "20000000" + * @apiSuccess {String} object.merkleroot "4af279645e1b337e655ae3286fc2ca09f58eb01efa6ab27adedd1e9e6ec19091" + * @apiSuccess {Number} object.time 1509343584 + * @apiSuccess {Number} object.mediantime 1509336533 + * @apiSuccess {Number} object.nonce 3604508752 + * @apiSuccess {String} object.bits "1809b91a" + * @apiSuccess {Number} object.difficulty 113081236211.4533 + * @apiSuccess {String} object.chainwork "0000000000000000000000000000000000000000007ae48aca46e3b449ad9714" + * @apiSuccess {String} object.previousblockhash "0000000000000000043831d6ebb013716f0580287ee5e5687e27d0ed72e6e523" + * @apiSuccess {String} object.nextblockhash "00000000000000000568f0a96bf4348847bc84e455cbfec389f27311037a20f3" + */ + async getBlockHeaderBulk (req, res, next) { + try { + const hashes = req.body.hashes + const verbose = req.body.verbose ? req.body.verbose : false + + if (!Array.isArray(hashes)) { + res.status(400) + return res.json({ + error: 'hashes needs to be an array. Use GET for single hash.' + }) + } + + // Enforce array size rate limits + if (!routeUtils.validateArraySize(req, hashes)) { + res.status(400) // https://github.com/Bitcoin-com/api.fullstack.cash/issues/330 + return res.json({ + error: 'Array too large.' + }) + } + + wlogger.debug( + 'Executing blockchain/getBlockHeaderBulk with these hashes: ', + hashes + ) + + // Validate each hash in the array. + for (let i = 0; i < hashes.length; i++) { + const hash = hashes[i] + + if (hash.length !== 64) { + res.status(400) + return res.json({ error: `This is not a hash: ${hash}` }) + } + } + + // Axios options + const options = _this.routeUtils.getAxiosOptions() + + // Loop through each hash and creates an array of requests to call in parallel + const promises = hashes.map(async (hash) => { + options.data.id = 'getblockheader' + options.data.method = 'getblockheader' + options.data.params = [hash, verbose] + + return _this.axios.request(options) + }) + + const axiosResult = await _this.axios.all(promises) + + // Extract the data component from the axios response. + const result = axiosResult.map((x) => x.data.result) + + res.status(200) + return res.json(result) + } catch (err) { + // Write out error to error log. + // logger.error(`Error in rawtransactions/decodeRawTransaction: `, err) + wlogger.error('Error in blockchain.ts/getBlockHeaderBulk().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {get} /blockchain/getChainTips Get Chain Tips + * @apiName getChainTips + * @apiGroup Blockchain + * @apiDescription Return information about all known tips in the block tree, + * including the main chain as well as orphaned branches. + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/blockchain/getChainTips" -H "accept: application/json" + * + */ + async getChainTips (req, res, next) { + try { + // Axios options + const options = _this.routeUtils.getAxiosOptions() + + options.data.id = 'getchaintips' + options.data.method = 'getchaintips' + options.data.params = [] + + const response = await _this.axios.request(options) + return res.json(response.data.result) + } catch (err) { + // Write out error to error log. + // logger.error(`Error in rawtransactions/decodeRawTransaction: `, err) + wlogger.error('Error in blockchain.ts/getChainTips().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {get} /blockchain/getDifficulty Get difficulty + * @apiName getDifficulty + * @apiGroup Blockchain + * @apiDescription Get the current difficulty value, used to regulate mining + * power on the network. + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/blockchain/getDifficulty" -H "accept: application/json" + * + */ + async getDifficulty (req, res, next) { + try { + // Axios options + const options = _this.routeUtils.getAxiosOptions() + + options.data.id = 'getdifficulty' + options.data.method = 'getdifficulty' + options.data.params = [] + + const response = await _this.axios.request(options) + + return res.json(response.data.result) + } catch (err) { + // Write out error to error log. + // logger.error(`Error in rawtransactions/decodeRawTransaction: `, err) + wlogger.error('Error in blockchain.ts/getDifficulty().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {get} /blockchain/getMempoolEntry/:txid Get single mempool entry + * @apiName getMempoolEntry + * @apiGroup Blockchain + * @apiDescription Returns mempool data for given transaction. TXID must be in + * mempool (unconfirmed) + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/blockchain/getMempoolEntry/fe28050b93faea61fa88c4c630f0e1f0a1c24d0082dd0e10d369e13212128f33" -H "accept: application/json" + * + */ + async getMempoolEntrySingle (req, res, next) { + try { + // Validate input parameter + const txid = req.params.txid + if (!txid || txid === '') { + res.status(400) + return res.json({ error: 'txid can not be empty' }) + } + + // Axios options + const options = _this.routeUtils.getAxiosOptions() + + options.data.id = 'getmempoolentry' + options.data.method = 'getmempoolentry' + options.data.params = [txid] + + const response = await _this.axios.request(options) + + return res.json(response.data.result) + } catch (err) { + // Write out error to error log. + // logger.error(`Error in rawtransactions/decodeRawTransaction: `, err) + wlogger.error('Error in blockchain.ts/getMempoolEntrySingle().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {post} /blockchain/getMempoolEntry Get bulk mempool entry + * @apiName getMempoolEntryBulk + * @apiGroup Blockchain + * @apiDescription Returns mempool data for multiple transactions + * + * @apiExample Example usage: + * curl -X POST https://api.fullstack.cash/v4/blockchain/getMempoolEntry -H "Content-Type: application/json" -d "{\"txids\":[\"a5f972572ee1753e2fd2457dd61ce5f40fa2f8a30173d417e49feef7542c96a1\",\"5165dc531aad05d1149bb0f0d9b7bda99c73e2f05e314bcfb5b4bb9ca5e1af5e\"]}" + */ + async getMempoolEntryBulk (req, res, next) { + try { + const txids = req.body.txids + + if (!Array.isArray(txids)) { + res.status(400) + return res.json({ + error: 'txids needs to be an array. Use GET for single txid.' + }) + } + + // Enforce array size rate limits + if (!routeUtils.validateArraySize(req, txids)) { + res.status(400) // https://github.com/Bitcoin-com/api.fullstack.cash/issues/330 + return res.json({ + error: 'Array too large.' + }) + } + + wlogger.debug( + 'Executing blockchain/getMempoolEntry with these txids: ', + txids + ) + + // Validate each element in the array + for (let i = 0; i < txids.length; i++) { + const txid = txids[i] + + if (txid.length !== 64) { + res.status(400) + return res.json({ error: 'This is not a txid' }) + } + } + + // Axios options + const options = _this.routeUtils.getAxiosOptions() + + // Loop through each txid and creates an array of requests to call in parallel + const promises = txids.map(async (txid) => { + options.data.id = 'getmempoolentry' + options.data.method = 'getmempoolentry' + options.data.params = [txid] + + return _this.axios.request(options) + }) + + const axiosResult = await _this.axios.all(promises) + + // Extract the data component from the axios response. + const result = axiosResult.map((x) => x.data.result) + + res.status(200) + return res.json(result) + } catch (err) { + // Write out error to error log. + // logger.error(`Error in rawtransactions/decodeRawTransaction: `, err) + wlogger.error('Error in blockchain.ts/getMempoolEntryBulk().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {get} /blockchain/getMempoolAncestors/:txid Get Mempool Ancestors + * @apiName getMempoolAncestors + * @apiGroup Blockchain + * @apiDescription Returns mempool ancestors data for given TXID. It must be in + * mempool (unconfirmed). This call is handy to tell if a UTXO is bumping up + * against the 25 ancestor chain-limit. + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/blockchain/getMempoolAncestors/fe28050b93faea61fa88c4c630f0e1f0a1c24d0082dd0e10d369e13212128f33" -H "accept: application/json" + * + */ + async getMempoolAncestorsSingle (req, res, next) { + try { + // Validate input parameter + const txid = req.params.txid + if (!txid || txid === '') { + res.status(400) + return res.json({ error: 'txid can not be empty' }) + } + + let verbose = req.params.verbose + if (verbose === undefined) verbose = false + + // Axios options + const options = _this.routeUtils.getAxiosOptions() + + options.data.id = 'getmempoolancestors' + options.data.method = 'getmempoolancestors' + options.data.params = [txid, verbose] + + const response = await _this.axios.request(options) + // console.log(`response: ${util.inspect(response)}`) + + return res.json(response.data.result) + } catch (err) { + // Write out error to error log. + // logger.error(`Error in rawtransactions/decodeRawTransaction: `, err) + wlogger.error('Error in blockchain.ts/getMempoolAncestorsSingle().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {get} /blockchain/getMempoolInfo Get mempool info + * @apiName getMempoolInfo + * @apiGroup Blockchain + * @apiDescription Returns details on the active state of the TX memory pool. + * + * @apiExample Example usage: + * curl -X GET https://api.fullstack.cash/v4/getMempoolInfo -H "accept: application/json" + * + */ + async getMempoolInfo (req, res, next) { + try { + // Axios options + const options = _this.routeUtils.getAxiosOptions() + + options.data.id = 'getmempoolinfo' + options.data.method = 'getmempoolinfo' + options.data.params = [] + + const response = await _this.axios.request(options) + return res.json(response.data.result) + } catch (err) { + // Write out error to error log. + // logger.error(`Error in rawtransactions/decodeRawTransaction: `, err) + wlogger.error('Error in blockchain.ts/getMempoolInfo().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {get} /blockchain/getRawMempool Get mempool info + * @apiName getMempoolInfo + * @apiGroup Blockchain + * @apiDescription Returns details on the active state of the TX memory pool. + * + * @apiExample Example usage: + * curl -X GET https://api.fullstack.cash/v4/getMempoolInfo -H "accept: application/json" + * + */ + + /** + * @api {get} /blockchain/getRawMempool/?verbose= Get raw mempool + * @apiName getRawMempool + * @apiGroup Blockchain + * @apiDescription Returns all transaction ids in memory pool as a json array + * of string transaction ids. + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/getRawMempool/?verbose=true" -H "accept: application/json" + * + * @apiParam {Boolean} verbose Return verbose data + * + */ + async getRawMempool (req, res, next) { + try { + // Axios options + const options = _this.routeUtils.getAxiosOptions() + + let verbose = false + if (req.query.verbose && req.query.verbose === 'true') verbose = true + + options.data.id = 'getrawmempool' + options.data.method = 'getrawmempool' + options.data.params = [verbose] + + const response = await _this.axios.request(options) + + return res.json(response.data.result) + } catch (err) { + // Write out error to error log. + // logger.error(`Error in rawtransactions/decodeRawTransaction: `, err) + wlogger.error('Error in blockchain.ts/getRawMempool().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {get} /blockchain/getTxOut/:txid/:n?mempool= Get Tx Out + * @apiName getTxOut + * @apiGroup Blockchain + * @apiDescription Returns details about an unspent transaction output. + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/blockchain/getTxOut/fe28050b93faea61fa88c4c630f0e1f0a1c24d0082dd0e10d369e13212128f33/0?mempool=false" -H "accept: application/json" + * + * @apiParam {String} txid Transaction id (required) + * @apiParam {Number} n Output number (required) + * @apiParam {Boolean} mempool Check mempool or not (optional) + * + */ + // Returns details about an unspent transaction output. + async getTxOut (req, res, next) { + try { + // Validate input parameter + const txid = req.params.txid + if (!txid || txid === '') { + res.status(400) + return res.json({ error: 'txid can not be empty' }) + } + + let n = req.params.n + if (n === undefined || n === '') { + res.status(400) + return res.json({ error: 'n can not be empty' }) + } + n = parseInt(n) + + let includeMempool = false + if (req.query.includeMempool && req.query.includeMempool === 'true') { + includeMempool = true + } + + // Axios options + const options = _this.routeUtils.getAxiosOptions() + + options.data.id = 'gettxout' + options.data.method = 'gettxout' + options.data.params = [txid, n, includeMempool] + + // console.log(`requestConfig: ${JSON.stringify(requestConfig, null, 2)}`) + + const response = await _this.axios.request(options) + + return res.json(response.data.result) + } catch (err) { + // Write out error to error log. + // logger.error(`Error in rawtransactions/decodeRawTransaction: `, err) + wlogger.error('Error in blockchain.ts/getTxOut().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {post} /blockchain/getTxOut Validate a UTXO + * @apiName getTxOut + * @apiGroup Blockchain + * @apiDescription Returns details about an unspent transaction output (UTXO). + * + * @apiExample Example usage: + * curl "https://api.fullstack.cash/v4/blockchain/getTxOut/" -X POST -H "Content-Type: application/json" --data-binary '{"txid":"d5228d2cdc77fbe5a9aa79f19b0933b6802f9f0067f42847fc4fe343664723e5","vout":0,"mempool":true}' + * + * @apiParam {String} txid Transaction id (required) + * @apiParam {Number} vout of transaction (required) + * @apiParam {Boolean} mempool Check mempool or not (optional) + * + */ + // Returns details about an unspent transaction output. + async getTxOutPost (req, res, next) { + try { + const txid = req.body.txid + let n = req.body.vout + const mempool = req.body.mempool ? req.body.mempool : true + + // Validate input parameter + if (!txid || txid === '') { + res.status(400) + return res.json({ error: 'txid can not be empty' }) + } + + if (n === undefined || n === '') { + res.status(400) + return res.json({ error: 'vout can not be empty' }) + } + n = parseInt(n) + + // Axios options + const options = _this.routeUtils.getAxiosOptions() + + options.data.id = 'gettxout' + options.data.method = 'gettxout' + options.data.params = [txid, n, mempool] + + // console.log(`requestConfig: ${JSON.stringify(requestConfig, null, 2)}`) + + const response = await _this.axios.request(options) + + return res.json(response.data.result) + } catch (err) { + // Write out error to error log. + // logger.error(`Error in rawtransactions/decodeRawTransaction: `, err) + wlogger.error('Error in blockchain.ts/getTxOutPost().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {get} /blockchain/getTxOutProofSingle/:txid Get Tx Out Proof + * @apiName getTxOutProofSingle + * @apiGroup Blockchain + * @apiDescription Returns a hex-encoded proof that 'txid' was included in a block. + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/blockchain/getTxOutProofSingle/fe28050b93faea61fa88c4c630f0e1f0a1c24d0082dd0e10d369e13212128f33" -H "accept: application/json" + * + * @apiParam {String} txid Transaction id (required) + * + */ + async getTxOutProofSingle (req, res, next) { + try { + // Validate input parameter + const txid = req.params.txid + if (!txid || txid === '') { + res.status(400) + return res.json({ error: 'txid can not be empty' }) + } + + // Axios options + const options = _this.routeUtils.getAxiosOptions() + + options.data.id = 'gettxoutproof' + options.data.method = 'gettxoutproof' + options.data.params = [[txid]] + + const response = await _this.axios.request(options) + + return res.json(response.data.result) + } catch (err) { + // Write out error to error log. + // logger.error(`Error in rawtransactions/decodeRawTransaction: `, err) + wlogger.error('Error in blockchain.ts/getTxOutProofSingle().', err) + + return _this.errorHandler(err, res) + } + } + + // Returns a hex-encoded proof that 'txid' was included in a block. + async getTxOutProofBulk (req, res, next) { + try { + const txids = req.body.txids + + // Reject if txids is not an array. + if (!Array.isArray(txids)) { + res.status(400) + return res.json({ + error: 'txids needs to be an array. Use GET for single txid.' + }) + } + + // Enforce array size rate limits + if (!routeUtils.validateArraySize(req, txids)) { + res.status(400) // https://github.com/Bitcoin-com/api.fullstack.cash/issues/330 + return res.json({ + error: 'Array too large.' + }) + } + + // Axios options + const options = _this.routeUtils.getAxiosOptions() + + // Validate each element in the array. + for (let i = 0; i < txids.length; i++) { + const txid = txids[i] + + if (txid.length !== 64) { + res.status(400) + return res.json({ + error: `Invalid txid. Double check your txid is valid: ${txid}` + }) + } + } + + wlogger.debug( + 'Executing blockchain/getTxOutProof with these txids: ', + txids + ) + + // Loop through each txid and creates an array of requests to call in parallel + const promises = txids.map(async (txid) => { + options.data.id = 'gettxoutproof' + options.data.method = 'gettxoutproof' + options.data.params = [[txid]] + + return _this.axios.request(options) + }) + + // Wait for all parallel promisses to resolve. + const axiosResult = await _this.axios.all(promises) + + // Extract the data component from the axios response. + const result = axiosResult.map((x) => x.data.result) + + res.status(200) + return res.json(result) + } catch (err) { + // Write out error to error log. + // logger.error(`Error in rawtransactions/decodeRawTransaction: `, err) + wlogger.error('Error in blockchain.ts/getTxOutProofBulk().', err) + + return _this.errorHandler(err, res) + } + } + + async verifyTxOutProofSingle (req, res, next) { + try { + // Validate input parameter + const proof = req.params.proof + if (!proof || proof === '') { + res.status(400) + return res.json({ error: 'proof can not be empty' }) + } + + // Axios options + const options = _this.routeUtils.getAxiosOptions() + + options.data.id = 'verifytxoutproof' + options.data.method = 'verifytxoutproof' + options.data.params = [req.params.proof] + + const response = await _this.axios.request(options) + + return res.json(response.data.result) + } catch (err) { + // Write out error to error log. + // logger.error(`Error in rawtransactions/decodeRawTransaction: `, err) + wlogger.error('Error in blockchain.ts/verifyTxOutProofSingle().', err) + + return _this.errorHandler(err, res) + } + } + + async verifyTxOutProofBulk (req, res, next) { + try { + const proofs = req.body.proofs + + // Reject if proofs is not an array. + if (!Array.isArray(proofs)) { + res.status(400) + return res.json({ + error: 'proofs needs to be an array. Use GET for single proof.' + }) + } + + // Enforce array size rate limits + if (!routeUtils.validateArraySize(req, proofs)) { + res.status(400) // https://github.com/Bitcoin-com/api.fullstack.cash/issues/330 + return res.json({ + error: 'Array too large.' + }) + } + + // Axios options + const options = _this.routeUtils.getAxiosOptions() + + // Validate each element in the array. + for (let i = 0; i < proofs.length; i++) { + const proof = proofs[i] + + if (!proof || proof === '') { + res.status(400) + return res.json({ error: `proof can not be empty: ${proof}` }) + } + } + + wlogger.debug( + 'Executing blockchain/verifyTxOutProof with these proofs: ', + proofs + ) + + // Loop through each proof and creates an array of requests to call in parallel + const promises = proofs.map(async (proof) => { + options.data.id = 'verifytxoutproof' + options.data.method = 'verifytxoutproof' + options.data.params = [proof] + + return _this.axios.request(options) + }) + + // Wait for all parallel promisses to resolve. + const axiosResult = await _this.axios.all(promises) + + // Extract the data component from the axios response. + const result = axiosResult.map((x) => x.data.result[0]) + + res.status(200) + return res.json(result) + } catch (err) { + // Write out error to error log. + // logger.error(`Error in rawtransactions/decodeRawTransaction: `, err) + wlogger.error('Error in blockchain.ts/verifyTxOutProofBulk().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {post} /blockchain/getBlock/ Get block details + * @apiName getBlock + * @apiGroup Blockchain + * @apiDescription Returns block details + * + * @apiExample Example usage: + * curl "https://api.fullstack.cash/v4/blockchain/getblock/" -X POST -H "Content-Type: application/json" --data-binary '{"blockhash":"000000000000000002a5fe0bdd6e3f04342a975c0f55e57f97e73bb90041676b","verbosity":0 }' + * + * @apiParam {String} blockhash Block hash (required) + * @apiParam {Number} verbosity Default 1 (optional) + * + */ + async getBlock (req, res, next) { + try { + // Validate input parameter + const blockhash = req.body.blockhash + let verbosity = req.body.verbosity + + // Default to a value of 1 if another verbosity level is not defined. + if (!verbosity && verbosity !== 0) verbosity = 1 + + if (!blockhash || blockhash === '') { + res.status(400) + return res.json({ error: 'blockhash can not be empty' }) + } + + // Axios options + const options = _this.routeUtils.getAxiosOptions() + + options.data.id = 'getblock' + options.data.method = 'getblock' + options.data.params = [blockhash, verbosity] + + const response = await _this.axios.request(options) + + return res.json(response.data.result) + } catch (err) { + // Write out error to error log. + // logger.error(`Error in rawtransactions/decodeRawTransaction: `, err) + wlogger.error('Error in blockchain.js/getBlock()', err) + + return _this.errorHandler(err, res) + } + } +} + +module.exports = Blockchain diff --git a/src/routes/v5/full-node/control.js b/src/routes/v5/full-node/control.js new file mode 100644 index 0000000..f0b0468 --- /dev/null +++ b/src/routes/v5/full-node/control.js @@ -0,0 +1,116 @@ +'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 RouteUtils = require('../../../util/route-utils') +const routeUtils = new RouteUtils() + +// Used for processing error messages before sending them to the user. +const util = require('util') +util.inspect.defaultOptions = { depth: 1 } + +let _this + +class Control { + constructor () { + _this = this + + _this.axios = axios + _this.routeUtils = routeUtils + + _this.router = router + _this.router.get('/', _this.root) + _this.router.get('/getNetworkInfo', _this.getNetworkInfo) + } + + root (req, res, next) { + return res.json({ status: 'control' }) + } + + // DRY error handler. + errorHandler (err, res) { + // Attempt to decode the error message. + const { msg, status } = _this.routeUtils.decodeError(err) + if (msg) { + res.status(status) + return res.json({ error: msg }) + } + + res.status(500) + return res.json({ error: util.inspect(err) }) + } + + /** + * @api {get} /control/getnetworkinfo Get Network Info + * @apiName GetNetworkInfo + * @apiGroup Control + * @apiDescription RPC call which gets basic full node information. + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/control/getnetworkinfo" -H "accept: application/json" + * + */ + async getNetworkInfo (req, res, next) { + // Axios options + const options = _this.routeUtils.getAxiosOptions() + + options.data.id = 'getnetworkinfo' + options.data.method = 'getnetworkinfo' + options.data.params = [] + + try { + // const response = await BitboxHTTP(requestConfig) + const response = await _this.axios.request(options) + + return res.json(response.data.result) + } catch (error) { + wlogger.error('Error in control.ts/getNetworkInfo().', error) + + return _this.errorHandler(error, res) + } + } + // router.get('/getMemoryInfo', (req, res, next) => { + // BitboxHTTP({ + // method: 'post', + // auth: { + // username: username, + // password: password + // }, + // data: { + // jsonrpc: "1.0", + // id:"getmemoryinfo", + // method: "getmemoryinfo" + // } + // }) + // .then((response) => { + // res.json(response.data.result); + // }) + // .catch((error) => { + // res.send(error.response.data.error.message); + // }); + // }); + // + // router.get('/help', (req, res, next) => { + // BITBOX.Control.help() + // .then((result) => { + // res.json(result); + // }, (err) => { console.log(err); + // }); + // }); + // + // router.post('/stop', (req, res, next) => { + // BITBOX.Control.stop() + // .then((result) => { + // res.json(result); + // }, (err) => { console.log(err); + // }); + // }); +} + +module.exports = Control diff --git a/src/routes/v5/full-node/mining.js b/src/routes/v5/full-node/mining.js new file mode 100644 index 0000000..fe01f03 --- /dev/null +++ b/src/routes/v5/full-node/mining.js @@ -0,0 +1,169 @@ +'use strict' + +const express = require('express') +const router = express.Router() +const axios = require('axios') + +const RouteUtils = require('../../../util/route-utils') +const routeUtils = new RouteUtils() + +const wlogger = require('../../../util/winston-logging') + +// Used to convert error messages to strings, to safely pass to users. +const util = require('util') +util.inspect.defaultOptions = { depth: 1 } + +let _this + +class Mining { + constructor () { + _this = this + + _this.axios = axios + _this.routeUtils = routeUtils + + _this.router = router + _this.router.get('/', _this.root) + _this.router.get('/getMiningInfo', _this.getMiningInfo) + _this.router.get('/getNetworkHashPS', _this.getNetworkHashPS) + } + + root (req, res, next) { + return res.json({ status: 'mining' }) + } + + // DRY error handler. + errorHandler (err, res) { + // Attempt to decode the error message. + const { msg, status } = _this.routeUtils.decodeError(err) + if (msg) { + res.status(status) + return res.json({ error: msg }) + } + + res.status(500) + return res.json({ error: util.inspect(err) }) + } + + // router.get('/getBlockTemplate/:templateRequest', (req, res, next) => { + // BitboxHTTP({ + // method: 'post', + // auth: { + // username: username, + // password: password + // }, + // data: { + // jsonrpc: "1.0", + // id:"getblocktemplate", + // method: "getblocktemplate", + // params: [ + // req.params.templateRequest + // ] + // } + // }) + // .then((response) => { + // res.json(response.data.result); + // }) + // .catch((error) => { + // res.send(error.response.data.error.message); + // }); + // }); + + /** + * @api {get} /mining/getMiningInfo Get Mining Info. + * @apiName Mining info. + * @apiGroup Mining + * @apiDescription Returns a json object containing mining-related information. + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/mining/getMiningInfo" -H "accept: application/json" + * + * + */ + async getMiningInfo (req, res, next) { + try { + const options = _this.routeUtils.getAxiosOptions() + + options.data.id = 'getmininginfo' + options.data.method = 'getmininginfo' + options.data.params = [] + + const response = await _this.axios.request(options) + + return res.json(response.data.result) + } catch (err) { + wlogger.error('Error in mining.ts/getMiningInfo().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {get} /mining/getNetworkHashps?nblocks=&height= Get Estimated network hashes per second. + * @apiName Estimated network hashes per second. + * @apiGroup Mining + * @apiDescription Returns the estimated network hashes per second based on the last n blocks. Pass in [blocks] to override # of blocks, -1 specifies since last difficulty change. Pass in [height] to estimate the network speed at the time when a certain block was found. + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/mining/getNetworkHashps?nblocks=120&height=-1" -H "accept: application/json" + * + * + */ + + async getNetworkHashPS (req, res, next) { + try { + let nblocks = 120 // Default + let height = -1 // Default + if (req.query.nblocks) nblocks = parseInt(req.query.nblocks) + if (req.query.height) height = parseInt(req.query.height) + + const options = _this.routeUtils.getAxiosOptions() + + options.data.id = 'getnetworkhashps' + options.data.method = 'getnetworkhashps' + options.data.params = [nblocks, height] + + const response = await _this.axios.request(options) + + return res.json(response.data.result) + } catch (err) { + wlogger.error('Error in mining.ts/getNetworkHashPS().', err) + + return _this.errorHandler(err, res) + } + } + + // router.post('/submitBlock/:hex', (req, res, next) => { + // let parameters = ''; + // if(req.query.parameters && req.query.parameters !== '') { + // parameters = true; + // } + // + // BitboxHTTP({ + // method: 'post', + // auth: { + // username: username, + // password: password + // }, + // data: { + // jsonrpc: "1.0", + // id:"submitblock", + // method: "submitblock", + // params: [ + // req.params.hex, + // parameters + // ] + // } + // }) + // .then((response) => { + // res.json(response.data.result); + // }) + // .catch((error) => { + // res.send(error.response.data.error.message); + // }); + // }); +} + +module.exports = Mining diff --git a/src/routes/v5/full-node/network.js b/src/routes/v5/full-node/network.js new file mode 100644 index 0000000..3e0c2ed --- /dev/null +++ b/src/routes/v5/full-node/network.js @@ -0,0 +1,168 @@ +'use strict' + +const express = require('express') +const router = express.Router() + +router.get('/', async (req, res, next) => { + res.json({ status: 'network' }) +}) + +// router.post('/addNode/:node/:command', (req, res, next) => { +// BITBOX.Network.addNode(req.params.node, req.params.command) +// .then((result) => { +// res.json(result); +// }, (err) => { console.log(err); +// }); +// }); +// +// router.post('/clearBanned', (req, res, next) => { +// BITBOX.Network.clearBanned() +// .then((result) => { +// res.json(result); +// }, (err) => { console.log(err); +// }); +// }); +// +// router.post('/disconnectNode/:address/:nodeid', (req, res, next) => { +// BITBOX.Network.disconnectNode(req.params.address, req.params.nodeid) +// .then((result) => { +// res.json(result); +// }, (err) => { console.log(err); +// }); +// }); +// +// router.get('/getAddedNodeInfo/:node', (req, res, next) => { +// BITBOX.Network.getAddedNodeInfo(req.params.node) +// .then((result) => { +// res.json(result); +// }, (err) => { console.log(err); +// }); +// }); +// +// router.get('/getConnectionCount', (req, res, next) => { +// BitboxHTTP({ +// method: 'post', +// auth: { +// username: username, +// password: password +// }, +// data: { +// jsonrpc: "1.0", +// id:"getconnectioncount", +// method: "getconnectioncount" +// } +// }) +// .then((response) => { +// res.json(response.data.result); +// }) +// .catch((error) => { +// res.send(error.response.data.error.message); +// }); +// }); +// +// router.get('/getNetTotals', (req, res, next) => { +// BitboxHTTP({ +// method: 'post', +// auth: { +// username: username, +// password: password +// }, +// data: { +// jsonrpc: "1.0", +// id:"getnettotals", +// method: "getnettotals" +// } +// }) +// .then((response) => { +// res.json(response.data.result); +// }) +// .catch((error) => { +// res.send(error.response.data.error.message); +// }); +// }); +// +// router.get('/getNetworkInfo', (req, res, next) => { +// BitboxHTTP({ +// method: 'post', +// auth: { +// username: username, +// password: password +// }, +// data: { +// jsonrpc: "1.0", +// id:"getnetworkinfo", +// method: "getnetworkinfo" +// } +// }) +// .then((response) => { +// res.json(response.data.result); +// }) +// .catch((error) => { +// res.send(error.response.data.error.message); +// }); +// }); +// +// router.get('/getPeerInfo', (req, res, next) => { +// BitboxHTTP({ +// method: 'post', +// auth: { +// username: username, +// password: password +// }, +// data: { +// jsonrpc: "1.0", +// id:"getpeerinfo", +// method: "getpeerinfo" +// } +// }) +// .then((response) => { +// res.json(response.data.result); +// }) +// .catch((error) => { +// res.send(error.response.data.error.message); +// }); +// }); +// +// router.get('/ping', (req, res, next) => { +// BitboxHTTP({ +// method: 'post', +// auth: { +// username: username, +// password: password +// }, +// data: { +// jsonrpc: "1.0", +// id:"ping", +// method: "ping" +// } +// }) +// .then((response) => { +// res.json(JSON.stringify(response.data.result)); +// }) +// .catch((error) => { +// res.send(error.response.data.error.message); +// }); +// }); +// +// router.post('/setBan/:subnet/:command', (req, res, next) => { +// // TODO finish this +// BITBOX.Network.getConnectionCount(req.params.subnet, req.params.command) +// .then((result) => { +// res.json(result); +// }, (err) => { console.log(err); +// }); +// }); +// +// router.post('/setNetworkActive/:state', (req, res, next) => { +// let state = true; +// if(req.params.state && req.params.state === 'false') { +// state = false; +// } +// BITBOX.Network.getConnectionCount(state) +// .then((result) => { +// res.json(result); +// }, (err) => { console.log(err); +// }); +// }); + +module.exports = router diff --git a/src/routes/v5/full-node/rawtransactions.js b/src/routes/v5/full-node/rawtransactions.js new file mode 100644 index 0000000..5bce14e --- /dev/null +++ b/src/routes/v5/full-node/rawtransactions.js @@ -0,0 +1,589 @@ +'use strict' + +const express = require('express') +const router = express.Router() +const axios = require('axios') + +const RouteUtils = require('../../../util/route-utils') +const routeUtils = new RouteUtils() + +const wlogger = require('../../../util/winston-logging') + +// Used to convert error messages to strings, to safely pass to users. +const util = require('util') +util.inspect.defaultOptions = { depth: 1 } + +let _this +class RawTransactions { + constructor () { + _this = this + + // Encapsulate external dependencies. + this.axios = axios + this.routeUtils = routeUtils + + // Define Express routes. + this.router = router + this.router.get('/', this.root) + this.router.get( + '/decodeRawTransaction/:hex', + this.decodeRawTransactionSingle + ) + this.router.post('/decodeRawTransaction', this.decodeRawTransactionBulk) + this.router.get('/decodeScript/:hex', this.decodeScriptSingle) + this.router.post('/decodeScript', this.decodeScriptBulk) + this.router.post('/getRawTransaction', this.getRawTransactionBulk) + this.router.get('/getRawTransaction/:txid', this.getRawTransactionSingle) + this.router.post('/sendRawTransaction', this.sendRawTransactionBulk) + this.router.get('/sendRawTransaction/:hex', this.sendRawTransactionSingle) + } + + root (req, res, next) { + return res.json({ status: 'rawtransactions' }) + } + + // DRY error handler. + errorHandler (err, res) { + // Attempt to decode the error message. + const { msg, status } = _this.routeUtils.decodeError(err) + if (msg) { + res.status(status) + return res.json({ error: msg }) + } + + res.status(500) + return res.json({ error: util.inspect(err) }) + } + + // Decode transaction hex into a JSON object. + // GET + /** + * @api {get} /rawtransactions/decodeRawTransaction/{hex} Decode Single Raw Transaction. + * @apiName Decode Single Raw Transaction + * @apiGroup Raw Transaction + * @apiDescription Return a JSON object representing the serialized, hex-encoded transaction. + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/rawtransactions/decodeRawTransaction/02000000010e991f7ccec410f27d333f737f149b5d3be6728687da81072e638aed0063a176010000006b483045022100cd20443b0af090053450bc4ab00d563d4ac5955bb36e0135b00b8a96a19f233302205047f2c70a08c6ef4b76f2d198b33a31d17edfaa7e1e9e865894da0d396009354121024d4e7f522f67105b7bf5f9dbe557e7b2244613fdfcd6fe09304f93877328f6beffffffff02a0860100000000001976a9140ee020c07f39526ac5505c54fa1ab98490979b8388acb5f0f70b000000001976a9143a9b2b0c12fe722fcf653b6ef5dcc38732d6ff5188ac00000000" -H "accept: application/json" + */ + async decodeRawTransactionSingle (req, res, next) { + try { + const hex = req.params.hex + + // Throw an error if hex is empty. + if (!hex || hex === '') { + res.status(400) + return res.json({ error: 'hex can not be empty' }) + } + const options = _this.routeUtils.getAxiosOptions() + + options.data.id = 'decoderawtransaction' + options.data.method = 'decoderawtransaction' + options.data.params = [hex] + + const response = await _this.axios.request(options) + return res.json(response.data.result) + } catch (err) { + // Write out error to error log. + // logger.error(`Error in rawtransactions/decodeRawTransaction: `, err) + wlogger.error( + 'Error in rawtransactions.ts/decodeRawTransactionSingle().', + err + ) + return _this.errorHandler(err, res) + } + } + + /** + * @api {post} /rawtransactions/decodeRawTransaction Decode Bulk Raw Transactions. + * @apiName Decode Bulk Raw Transactions + * @apiGroup Raw Transaction + * @apiDescription Return bulk hex encoded transaction. + * + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v4/rawtransactions/decodeRawTransaction" -H "accept: application/json" -H "Content-Type: application/json" -d '{"hexes":["01000000013ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a000000006a4730440220540986d1c58d6e76f8f05501c520c38ce55393d0ed7ed3c3a82c69af04221232022058ea43ed6c05fec0eccce749a63332ed4525460105346f11108b9c26df93cd72012103083dfc5a0254613941ddc91af39ff90cd711cdcde03a87b144b883b524660c39ffffffff01807c814a000000001976a914d7e7c4e0b70eaa67ceff9d2823d1bbb9f6df9a5188ac00000000","01000000013ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a000000006a4730440220540986d1c58d6e76f8f05501c520c38ce55393d0ed7ed3c3a82c69af04221232022058ea43ed6c05fec0eccce749a63332ed4525460105346f11108b9c26df93cd72012103083dfc5a0254613941ddc91af39ff90cd711cdcde03a87b144b883b524660c39ffffffff01807c814a000000001976a914d7e7c4e0b70eaa67ceff9d2823d1bbb9f6df9a5188ac00000000"]}' + * + * + */ + async decodeRawTransactionBulk (req, res, next) { + try { + const hexes = req.body.hexes + + if (!Array.isArray(hexes)) { + res.status(400) + return res.json({ error: 'hexes must be an array' }) + } + + // Enforce array size rate limits + if (!_this.routeUtils.validateArraySize(req, hexes)) { + res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 + return res.json({ + error: 'Array too large.' + }) + } + + // const results = [] + + // Validate each element in the address array. + for (let i = 0; i < hexes.length; i++) { + const thisHex = hexes[i] + + // Reject if id is empty + if (!thisHex || thisHex === '') { + res.status(400) + return res.json({ error: 'Encountered empty hex' }) + } + } + + const options = _this.routeUtils.getAxiosOptions() + + // Loop through each height and creates an array of requests to call in parallel + const promises = hexes.map(async (hex) => { + options.data.id = 'decoderawtransaction' + options.data.method = 'decoderawtransaction' + options.data.params = [hex] + + return _this.axios.request(options) + }) + + // Wait for all parallel Insight requests to return. + const axiosResult = await _this.axios.all(promises) + + // Retrieve the data part of the result. + const result = axiosResult.map((x) => x.data.result) + + res.status(200) + return res.json(result) + } catch (err) { + // Write out error to error log. + // logger.error(`Error in rawtransactions/getRawTransaction: `, err) + wlogger.error( + 'Error in rawtransactions.ts/decodeRawTransactionBulk().', + err + ) + return _this.errorHandler(err, res) + } + } + + // Decode a raw transaction from hex to assembly. + // GET single + /** + * @api {get} /rawtransactions/decodeScript/{hex} Decode Single Script. + * @apiName Decode Single Script + * @apiGroup Raw Transaction + * @apiDescription Decode a hex-encoded script. + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/rawtransactions/decodeScript/4830450221009a51e00ec3524a7389592bc27bea4af5104a59510f5f0cfafa64bbd5c164ca2e02206c2a8bbb47eabdeed52f17d7df668d521600286406930426e3a9415fe10ed592012102e6e1423f7abde8b70bca3e78a7d030e5efabd3eb35c19302542b5fe7879c1a16" -H "accept: application/json" + * + * + */ + async decodeScriptSingle (req, res, next) { + try { + const hex = req.params.hex + + // Throw an error if hex is empty. + if (!hex || hex === '') { + res.status(400) + return res.json({ error: 'hex can not be empty' }) + } + + const options = _this.routeUtils.getAxiosOptions() + + options.data.id = 'decodescript' + options.data.method = 'decodescript' + options.data.params = [hex] + + const response = await _this.axios.request(options) + return res.json(response.data.result) + } catch (err) { + // Write out error to error log. + // logger.error(`Error in rawtransactions/decodeScript: `, err) + wlogger.error('Error in rawtransactions.ts/decodeScriptSingle().', err) + + return _this.errorHandler(err, res) + } + } + + // Decode a raw transaction from hex to assembly. + // POST bulk + /** + * @api {post} /rawtransactions/decodeScript Bulk Decode Script. + * @apiName Bulk Decode Script + * @apiGroup Raw Transaction + * @apiDescription Decode multiple hex-encoded scripts. + * + * + * @apiExample Example usage: + *curl -X POST "https://api.fullstack.cash/v4/rawtransactions/decodeScript" -H "accept:" -H "Content-Type: application/json" -d '{"hexes":["01000000013ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a000000006a4730440220540986d1c58d6e76f8f05501c520c38ce55393d0ed7ed3c3a82c69af04221232022058ea43ed6c05fec0eccce749a63332ed4525460105346f11108b9c26df93cd72012103083dfc5a0254613941ddc91af39ff90cd711cdcde03a87b144b883b524660c39ffffffff01807c814a000000001976a914d7e7c4e0b70eaa67ceff9d2823d1bbb9f6df9a5188ac00000000","01000000013ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a000000006a4730440220540986d1c58d6e76f8f05501c520c38ce55393d0ed7ed3c3a82c69af04221232022058ea43ed6c05fec0eccce749a63332ed4525460105346f11108b9c26df93cd72012103083dfc5a0254613941ddc91af39ff90cd711cdcde03a87b144b883b524660c39ffffffff01807c814a000000001976a914d7e7c4e0b70eaa67ceff9d2823d1bbb9f6df9a5188ac00000000"]}' + * + * + */ + async decodeScriptBulk (req, res, next) { + try { + const hexes = req.body.hexes + + // Validation + if (!Array.isArray(hexes)) { + res.status(400) + return res.json({ error: 'hexes must be an array' }) + } + + // Enforce array size rate limits + if (!_this.routeUtils.validateArraySize(req, hexes)) { + res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 + return res.json({ + error: 'Array too large.' + }) + } + + // Validate each hex in the array + for (let i = 0; i < hexes.length; i++) { + const hex = hexes[i] + + // Throw an error if hex is empty. + if (!hex || hex === '') { + res.status(400) + return res.json({ error: 'Encountered empty hex' }) + } + } + + const options = _this.routeUtils.getAxiosOptions() + + // Loop through each hex and create an array of promises + const promises = hexes.map(async (hex) => { + options.data.id = 'decodescript' + options.data.method = 'decodescript' + options.data.params = [hex] + + const response = await _this.axios.request(options) + return response + }) + + // Wait for all parallel promises to return. + const resolved = await Promise.all(promises) + + // Retrieve the data from each resolved promise. + const result = resolved.map((x) => x.data.result) + + res.status(200) + return res.json(result) + } catch (err) { + // Write out error to error log. + // logger.error(`Error in rawtransactions/decodeScript: `, err) + wlogger.error('Error in rawtransactions.ts/decodeScriptBulk().', err) + return _this.errorHandler(err, res) + } + } + + // Retrieve raw transactions details from the full node. + + async getRawTransactionsFromNode (txid, verbose) { + try { + const options = _this.routeUtils.getAxiosOptions() + + options.data.id = 'getrawtransaction' + options.data.method = 'getrawtransaction' + options.data.params = [txid, verbose] + + const response = await _this.axios.request(options) + + return response.data.result + } catch (err) { + wlogger.error('Error in rawtransactions.ts/getRawTransactionsFromNode().') + throw err + } + } + + // Get a JSON object breakdown of transaction details. + // POST + /** + * @api {post} /rawtransactions/getRawTransaction Get Bulk Raw Transactions. + * @apiName Get Bulk Raw Transactions. + * @apiGroup Raw Transaction + * @apiDescription Return the raw transaction data for multiple transactions. If verbose is 'true', returns an Object with information about 'txid'. If verbose is 'false' or omitted, returns a string that is serialized, hex-encoded data for 'txid'. + * + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v4/rawtransactions/getRawTransaction" -H "accept: application/json" -H "Content-Type: application/json" -d '{"txids":["a5f972572ee1753e2fd2457dd61ce5f40fa2f8a30173d417e49feef7542c96a1","5165dc531aad05d1149bb0f0d9b7bda99c73e2f05e314bcfb5b4bb9ca5e1af5e"],"verbose":true}' + * + */ + async getRawTransactionBulk (req, res, next) { + try { + let verbose = 0 + if (req.body.verbose) verbose = 1 + + const txids = req.body.txids + if (!Array.isArray(txids)) { + res.status(400) + return res.json({ error: 'txids must be an array' }) + } + + // Enforce array size rate limits + if (!_this.routeUtils.validateArraySize(req, txids)) { + res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 + return res.json({ + error: 'Array too large.' + }) + } + + // Validate each txid in the array. + for (let i = 0; i < txids.length; i++) { + const txid = txids[i] + + if (!txid || txid === '') { + res.status(400) + return res.json({ error: 'Encountered empty TXID' }) + } + + if (txid.length !== 64) { + res.status(400) + return res.json({ + error: `parameter 1 must be of length 64 (not ${txid.length})` + }) + } + } + + // Loop through each txid and create an array of promises + const promises = txids.map(async (txid) => + _this.getRawTransactionsFromNode(txid, verbose) + ) + + // Wait for all parallel promises to return. + const axiosResult = await _this.axios.all(promises) + + res.status(200) + return res.json(axiosResult) + } catch (err) { + // Write out error to error log. + // logger.error(`Error in rawtransactions/getRawTransaction: `, err) + wlogger.error('Error in rawtransactions.ts/getRawTransactionBulk().', err) + return _this.errorHandler(err, res) + } + } + + // Get a JSON object breakdown of transaction details. + // GET + /** + * @api {get} /rawtransactions/getRawTransaction/{txid} Return the raw transaction data. + * @apiName Get Raw Transaction + * @apiGroup Raw Transaction + * @apiDescription return the raw transaction data. If verbose is 'true', returns an Object with information about 'txid'. If verbose is 'false' or omitted, returns a string that is serialized, hex-encoded data for 'txid'. + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/rawtransactions/getRawTransaction/fe28050b93faea61fa88c4c630f0e1f0a1c24d0082dd0e10d369e13212128f33?verbose=true" -H "accept: application/json" + * + * + */ + async getRawTransactionSingle (req, res, next) { + try { + let verbose = 0 + if (req.query.verbose === 'true') verbose = 1 + + const txid = req.params.txid + if (!txid || txid === '') { + res.status(400) + return res.json({ error: 'txid can not be empty' }) + } + + if (txid.length !== 64) { + res.status(400) + return res.json({ + error: `parameter 1 must be of length 64 (not ${txid.length})` + }) + } + + const data = await _this.getRawTransactionsFromNode(txid, verbose) + + return res.json(data) + } catch (err) { + // Write out error to error log. + // logger.error(`Error in rawtransactions/getRawTransaction: `, err) + wlogger.error( + 'Error in rawtransactions.ts/getRawTransactionSingle().', + err + ) + + return _this.errorHandler(err, res) + } + } + + // Transmit a raw transaction to the BCH network. + /** + * @api {post} /rawtransactions/sendRawTransaction Send Bulk Raw Transactions. + * @apiName Send Bulk Raw Transactions + * @apiGroup Raw Transaction + * @apiDescription Submits multiple raw transaction (serialized, hex-encoded) to local node and network. + * + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v4/rawtransactions/sendRawTransaction" -H "accept:application/json" -H "Content-Type: application/json" -d '{"hexes":["01000000013ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a000000006a4730440220540986d1c58d6e76f8f05501c520c38ce55393d0ed7ed3c3a82c69af04221232022058ea43ed6c05fec0eccce749a63332ed4525460105346f11108b9c26df93cd72012103083dfc5a0254613941ddc91af39ff90cd711cdcde03a87b144b883b524660c39ffffffff01807c814a000000001976a914d7e7c4e0b70eaa67ceff9d2823d1bbb9f6df9a5188ac00000000","01000000013ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a000000006a4730440220540986d1c58d6e76f8f05501c520c38ce55393d0ed7ed3c3a82c69af04221232022058ea43ed6c05fec0eccce749a63332ed4525460105346f11108b9c26df93cd72012103083dfc5a0254613941ddc91af39ff90cd711cdcde03a87b144b883b524660c39ffffffff01807c814a000000001976a914d7e7c4e0b70eaa67ceff9d2823d1bbb9f6df9a5188ac00000000"]}' + * + * + */ + async sendRawTransactionBulk (req, res, next) { + try { + // Validation + const hexes = req.body.hexes + + // Reject if input is not an array + if (!Array.isArray(hexes)) { + res.status(400) + return res.json({ error: 'hex must be an array' }) + } + + let options = _this.routeUtils.getAxiosOptions() + options = _this.sendTxOptions(options) + + // Enforce array size rate limits + if (!_this.routeUtils.validateArraySize(req, hexes)) { + res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 + return res.json({ + error: 'Array too large.' + }) + } + + // Validate each element + for (let i = 0; i < hexes.length; i++) { + const hex = hexes[i] + + if (hex === '') { + res.status(400) + return res.json({ + error: 'Encountered empty hex' + }) + } + } + + // Dev Note CT 1/31/2019: + // Sending the 'sendrawtrnasaction' RPC call to a full node in parallel will + // not work. Testing showed that the full node will return the same TXID for + // different TX hexes. I believe this is by design, to prevent double spends. + // In parallel, we are essentially asking the node to broadcast a new TX before + // it's finished broadcast the previous one. Serial execution is required. + + // How to send TX hexes in parallel the WRONG WAY: + /* + // Collect an array of promises. + const promises = hexes.map(async (hex: any) => { + requestConfig.data.id = "sendrawtransaction" + requestConfig.data.method = "sendrawtransaction" + requestConfig.data.params = [hex] + return await BitboxHTTP(requestConfig) + }) + // Wait for all parallel Insight requests to return. + const axiosResult: Array = await axios.all(promises) + // Retrieve the data part of the result. + const result = axiosResult.map(x => x.data.result) + */ + + // Sending them serially. + const result = [] + for (let i = 0; i < hexes.length; i++) { + const hex = hexes[i] + + options.data.id = 'sendrawtransaction' + options.data.method = 'sendrawtransaction' + options.data.params = [hex] + + const rpcResult = await _this.axios.request(options) + + result.push(rpcResult.data.result) + } + + res.status(200) + return res.json(result) + } catch (err) { + wlogger.error( + 'Error in rawtransactions.ts/sendRawTransactionBulk().', + err + ) + return _this.errorHandler(err, res) + } + } + + // Transmit a raw transaction to the BCH network. + /** + * @api {get} /rawtransactions/sendRawTransaction/{hex} Send Single Raw Transaction. + * @apiName Send Single Raw Transaction + * @apiGroup Raw Transaction + * @apiDescription Submits single raw transaction (serialized, hex-encoded) to local node and network. + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/rawtransactions/sendRawTransaction/01000000013ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a000000006a4730440220540986d1c58d6e76f8f05501c520c38ce55393d0ed7ed3c3a82c69af04221232022058ea43ed6c05fec0eccce749a63332ed4525460105346f11108b9c26df93cd72012103083dfc5a0254613941ddc91af39ff90cd711cdcde03a87b144b883b524660c39ffffffff01807c814a000000001976a914d7e7c4e0b70eaa67ceff9d2823d1bbb9f6df9a5188ac00000000" -H "accept: " + * + * + */ + async sendRawTransactionSingle (req, res, next) { + try { + const hex = req.params.hex // URL parameter + + // Reject if input is not an array or a string + if (typeof hex !== 'string') { + res.status(400) + return res.json({ error: 'hex must be a string' }) + } + + // Validation + if (hex === '') { + res.status(400) + return res.json({ + error: 'Encountered empty hex' + }) + } + + let options = _this.routeUtils.getAxiosOptions() + options = _this.sendTxOptions(options) + + // RPC call + options.data.id = 'sendrawtransaction' + options.data.method = 'sendrawtransaction' + options.data.params = [hex] + + const rpcResult = await _this.axios.request(options) + + const result = rpcResult.data.result + + res.status(200) + return res.json(result) + } catch (err) { + wlogger.error( + 'Error in rawtransactions.ts/sendRawTransactionSingle().', + err + ) + return _this.errorHandler(err, res) + } + } + + // This method modifies the default axios options. It attempts to inject + // a specific full node to use when broadcasting transactions. This is useful + // because it leverages the built-in protections that a full node has against + // accidental double spends. It mitigates a corner-case when rapidly spending + // TXs on load balanced nodes. By piping all TX sends through a single node, + // accidental double spends can be reduced. + sendTxOptions (options) { + try { + const sendUrl = process.env.RPC_SENDURL + + if (sendUrl !== 'undefined' && sendUrl !== undefined) { + // console.log(`original options: ${JSON.stringify(options, null, 2)}`) + + options.baseURL = process.env.RPC_SENDURL + + // console.log(`modified options: ${JSON.stringify(options, null, 2)}`) + } + + return options + } catch (err) { + wlogger.error('Error in rawtransactions.js/sendTxOptions()') + throw err + } + } +} + +module.exports = RawTransactions diff --git a/src/routes/v5/health-check.js b/src/routes/v5/health-check.js new file mode 100644 index 0000000..28262fd --- /dev/null +++ b/src/routes/v5/health-check.js @@ -0,0 +1,16 @@ +/* + This health-check API can be used to test the server for aliveness and + readiness. +*/ + +'use strict' + +const express = require('express') +const router = express.Router() + +/* GET home page. */ +router.get('/', (req, res, next) => { + res.json({ status: true }) +}) + +module.exports = router diff --git a/src/routes/v5/ninsight.js b/src/routes/v5/ninsight.js new file mode 100644 index 0000000..614be72 --- /dev/null +++ b/src/routes/v5/ninsight.js @@ -0,0 +1,32 @@ +/* + A library for interacting with the Bitcoin.com ninsight (not Insight) indexer. +*/ + +'use strict' + +const express = require('express') +// const axios = require('axios') +// const routeUtils = require('./route-utils') +// const wlogger = require('../../util/winston-logging') + +const router = express.Router() + +// const BCHJS = require('@psf/bch-js') +// const bchjs = new BCHJS() + +// let _this + +class Ninsight { + constructor () { + // _this = this + + this.router = router + this.router.get('/', this.root) + } + + root (req, res, next) { + return res.json({ status: 'ninsight' }) + } +} + +module.exports = Ninsight diff --git a/src/routes/v5/price.js b/src/routes/v5/price.js new file mode 100644 index 0000000..5156458 --- /dev/null +++ b/src/routes/v5/price.js @@ -0,0 +1,193 @@ +/* + This price route is really just a wrapper for another API price endpoint. + The main reason for hosting this price wrapper is so that the price can + be accessible over Tor. Tor is typically blocked by other REST API servers. +*/ + +'use strict' + +const express = require('express') +const axios = require('axios') +const wlogger = require('../../util/winston-logging') +const util = require('util') + +const RouteUtils = require('../../util/route-utils') +const routeUtils = new RouteUtils() + +let _this // Global context for 'this' instance of the Class. + +class Price { + constructor () { + _this = this + + this.axios = axios + this.routeUtils = routeUtils + + this.priceUrl = 'https://api.coinbase.com/v2/exchange-rates?currency=BCH' + this.coinexPriceUrl = + 'https://api.coinex.com/v1/market/ticker?market=bchausdt' + + this.bchCoinexPriceUrl = + 'https://api.coinex.com/v1/market/ticker?market=bchusdt' + + this.router = express.Router() + this.router.get('/', _this.root) + this.router.get('/usd', _this.getUSD) + this.router.get('/rates', _this.getBCHRate) + this.router.get('/bchausd', _this.getBCHAUSD) + this.router.get('/bchusd', _this.getBCHUSD) + } + + // DRY error handler. + errorHandler (err, res) { + // Attempt to decode the error message. + const { msg, status } = _this.routeUtils.decodeError(err) + if (msg) { + res.status(status) + return res.json({ error: msg }) + } + + res.status(500) + return res.json({ error: util.inspect(err) }) + } + + // Root API endpoint. Simply acknowledges that it exists. + root (req, res, next) { + return res.json({ status: 'price' }) + } + + /** + * @api {get} /price/usd Get the USD price of BCH + * @apiName Get the USD price of BCH + * @apiGroup Price + * @apiDescription Get the USD price of BCH from Coinbase. + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/price/usd" -H "accept: application/json" + * + */ + async getUSD (req, res, next) { + try { + // Request options + const opt = { + method: 'get', + baseURL: _this.priceUrl, + timeout: 15000 + } + + const response = await axios.request(opt) + // console.log(`response.data: ${JSON.stringify(response.data, null, 2)}`) + + return res.json({ usd: Number(response.data.data.rates.USD) }) + } catch (err) { + // Write out error to error log. + wlogger.error('Error in price.js/getUSD().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {get} /price/usd Get rates for several different currencies + * @apiName Get rates for several different currencies + * @apiGroup Price + * @apiDescription Get rates for several different currencies from Coinbase. + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/price/rates" -H "accept: application/json" + * + */ + // Get rates for several different currencies + async getBCHRate (req, res, next) { + try { + // Request options + const opt = { + method: 'get', + baseURL: _this.priceUrl, + timeout: 15000 + } + + const response = await axios.request(opt) + // console.log(`response.data: ${JSON.stringify(response.data, null, 2)}`) + + return res.json(response.data.data.rates) + } catch (err) { + // Write out error to error log. + wlogger.error('Error in price.js/getUSD().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {get} /price/bchausd Get the USD price of BCHA + * @apiName Get the USD price of BCHA + * @apiGroup Price + * @apiDescription Get the USD price of BCHA from Coinex. + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/price/bchausd" -H "accept: application/json" + * + */ + async getBCHAUSD (req, res, next) { + try { + // Request options + const opt = { + method: 'get', + baseURL: _this.coinexPriceUrl, + timeout: 15000 + } + + const response = await axios.request(opt) + // console.log(`response.data: ${JSON.stringify(response.data, null, 2)}`) + + const price = Number(response.data.data.ticker.last) + + return res.json({ usd: price }) + } catch (err) { + // Write out error to error log. + wlogger.error('Error in price.js/getBCHAUSD().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {get} /price/bchusd Get the USD price of BCH + * @apiName Get the USD price of BCH + * @apiGroup Price + * @apiDescription Get the USD price of BCH from Coinex. + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/price/bchusd" -H "accept: application/json" + * + */ + async getBCHUSD (req, res, next) { + try { + // Request options + const opt = { + method: 'get', + baseURL: _this.bchCoinexPriceUrl, + timeout: 15000 + } + + const response = await axios.request(opt) + // console.log(`response.data: ${JSON.stringify(response.data, null, 2)}`) + + const price = Number(response.data.data.ticker.last) + + return res.json({ usd: price }) + } catch (err) { + // Write out error to error log. + wlogger.error('Error in price.js/getBCHUSD().', err) + + return _this.errorHandler(err, res) + } + } +} + +module.exports = Price diff --git a/src/routes/v5/services/slpdb.js b/src/routes/v5/services/slpdb.js new file mode 100644 index 0000000..c19c52e --- /dev/null +++ b/src/routes/v5/services/slpdb.js @@ -0,0 +1,351 @@ +const axios = require('axios') + +const SLPSDK = require('@psf/bch-js') +const SLP = new SLPSDK() + +class Slpdb { + // Gets transaction history for all tokens for an address. Can also specify + // block height, but defaults to 0. + async getHistoricalSlpTransactions (addressList, fromBlock = 0) { + // Build SLPDB or query from addressList + const orQueryArray = [] + for (const address of addressList) { + const cashAddress = SLP.SLP.Address.toCashAddress(address) + const slpAddress = SLP.SLP.Address.toSLPAddress(address) + + const cashQuery = { + 'in.e.a': cashAddress.slice(12) + } + const slpQuery = { + 'slp.detail.outputs.address': slpAddress + } + + orQueryArray.push(cashQuery) + orQueryArray.push(slpQuery) + } + + const query = { + v: 3, + q: { + find: { + db: ['c', 'u'], + $query: { + $or: orQueryArray, + 'slp.valid': true, + 'blk.i': { + $not: { + $lte: fromBlock + } + } + }, + $orderby: { + 'blk.i': -1 + } + }, + project: { + _id: 0, + 'tx.h': 1, + 'in.i': 1, + 'in.e': 1, + 'out.e': 1, + 'out.a': 1, + 'slp.detail': 1, + blk: 1 + }, + limit: 500 + } + } + + const result = await this.runQuery(query) + // console.log(`result.data: ${JSON.stringify(result.data, null, 2)}`) + + let transactions = [] + + // Add confirmed transactions + if (result.data && result.data.c) { + transactions = transactions.concat(result.data.c) + } + + // Add unconfirmed transactions + if (result.data && result.data.u) { + transactions = transactions.concat(result.data.u) + } + + return transactions + } + + async getTokenStats (tokenId) { + const [ + totalMinted, + totalBurned, + tokenDetails, + circulatingSupply + ] = await Promise.all([ + this.getTotalMinted(tokenId), + this.getTotalBurned(tokenId), + this.getTokenDetails(tokenId), + this.getTotalCirculating(tokenId) + ]) + + tokenDetails.totalMinted = tokenDetails.initialTokenQty + totalMinted + tokenDetails.totalBurned = totalBurned + + // tokenDetails.circulatingSupply = + // tokenDetails.totalMinted - tokenDetails.totalBurned + tokenDetails.circulatingSupply = circulatingSupply + + return tokenDetails + } + + generateCredentials () { + // Generate the Basic Authentication header for a private instance of SLPDB. + const SLPDB_PASS = process.env.SLPDB_PASS + ? process.env.SLPDB_PASS + : 'BITBOX' + const username = 'BITBOX' + const password = SLPDB_PASS + const combined = `${username}:${password}` + var base64Credential = Buffer.from(combined).toString('base64') + var readyCredential = `Basic ${base64Credential}` + + const options = { + headers: { + authorization: readyCredential, + timeout: 30000 + } + } + + return options + } + + async runQuery (query) { + const queryString = JSON.stringify(query) + const queryBase64 = Buffer.from(queryString).toString('base64') + const url = `${process.env.SLPDB_URL}q/${queryBase64}` + + const options = this.generateCredentials() + + const response = await axios.get(url, options) + return response + } + + async getTotalMinted (tokenId) { + const query = { + v: 3, + q: { + db: ['g'], + aggregate: [ + { + $match: { + 'tokenDetails.tokenIdHex': tokenId, + 'graphTxn.outputs.status': { + $in: [ + 'BATON_SPENT_IN_MINT', + 'BATON_UNSPENT', + 'BATON_SPENT_NOT_IN_MINT' + ] + } + } + }, + { + $unwind: '$graphTxn.outputs' + }, + { + $group: { + _id: null, + count: { + $sum: '$graphTxn.outputs.slpAmount' + } + } + } + ], + limit: 1 + } + } + + const result = await this.runQuery(query) + + if (!result.data.g.length) { + return 0 + } + + return parseFloat(result.data.g[0].count) + } + + async getTotalCirculating (tokenId) { + const query = { + v: 3, + q: { + db: ['g'], + aggregate: [ + { + $match: { + 'tokenDetails.tokenIdHex': tokenId, + 'graphTxn.outputs': { + $elemMatch: { + status: 'UNSPENT', + slpAmount: { $gte: 0 } + } + } + } + }, + { $unwind: '$graphTxn.outputs' }, + { + $match: { + 'graphTxn.outputs.status': 'UNSPENT', + 'graphTxn.outputs.slpAmount': { $gte: 0 } + } + }, + { + $group: { + _id: null, + circulating_supply: { + $sum: '$graphTxn.outputs.slpAmount' + } + } + } + ], + limit: 100000 + } + } + + const result = await this.runQuery(query) + // console.log(`result.data: ${JSON.stringify(result.data, null, 2)}`) + + if (!result.data.g.length) { + return 0 + } + + return parseFloat(result.data.g[0].circulating_supply) + } + + async getTotalBurned (tokenId) { + const query = { + v: 3, + q: { + db: ['g'], + aggregate: [ + { + $match: { + 'tokenDetails.tokenIdHex': tokenId, + 'graphTxn.outputs.status': { + $in: [ + 'SPENT_NON_SLP', + 'BATON_SPENT_INVALID_SLP', + 'SPENT_INVALID_SLP', + 'BATON_SPENT_NON_SLP', + 'MISSING_BCH_VOUT', + 'BATON_MISSING_BCH_VOUT', + 'BATON_SPENT_NOT_IN_MINT', + 'EXCESS_INPUT_BURNED' + ] + } + } + }, + { + $unwind: '$graphTxn.outputs' + }, + { + $match: { + 'graphTxn.outputs.status': { + $in: [ + 'SPENT_NON_SLP', + 'BATON_SPENT_INVALID_SLP', + 'SPENT_INVALID_SLP', + 'BATON_SPENT_NON_SLP', + 'MISSING_BCH_VOUT', + 'BATON_MISSING_BCH_VOUT', + 'BATON_SPENT_NOT_IN_MINT', + 'EXCESS_INPUT_BURNED' + ] + } + } + }, + { + $group: { + _id: null, + count: { + $sum: '$graphTxn.outputs.slpAmount' + } + } + } + ], + limit: 1 + } + } + + const result = await this.runQuery(query) + + if (!result.data.g.length) { + return 0 + } + + return parseFloat(result.data.g[0].count) + } + + async getTokenDetails (tokenId) { + const query = { + v: 3, + q: { + db: ['t'], + find: { + $query: { + 'tokenDetails.tokenIdHex': tokenId + } + }, + project: { tokenDetails: 1, tokenStats: 1, _id: 0 }, + limit: 1 + } + } + + const result = await this.runQuery(query) + + if (!result.data.t.length) { + throw new Error('Token could not be found') + } + + const token = this.formatTokenOutput(result.data.t[0]) + + return token + } + + formatTokenOutput (token) { + // console.log(`token: ${JSON.stringify(token, null, 2)}`) + + token.tokenDetails.id = token.tokenDetails.tokenIdHex + delete token.tokenDetails.tokenIdHex + token.tokenDetails.documentHash = token.tokenDetails.documentSha256Hex + delete token.tokenDetails.documentSha256Hex + token.tokenDetails.initialTokenQty = parseFloat( + token.tokenDetails.genesisOrMintQuantity + ) + delete token.tokenDetails.genesisOrMintQuantity + delete token.tokenDetails.transactionType + delete token.tokenDetails.batonVout + delete token.tokenDetails.sendOutputs + + token.tokenDetails.blockCreated = token.tokenStats.block_created + token.tokenDetails.blockLastActiveSend = + token.tokenStats.block_last_active_send + token.tokenDetails.blockLastActiveMint = + token.tokenStats.block_last_active_mint + token.tokenDetails.txnsSinceGenesis = + token.tokenStats.qty_valid_txns_since_genesis + token.tokenDetails.validAddresses = + token.tokenStats.qty_valid_token_addresses + token.tokenDetails.mintingBatonStatus = + token.tokenStats.minting_baton_status + + delete token.tokenStats.block_last_active_send + delete token.tokenStats.block_last_active_mint + delete token.tokenStats.qty_valid_txns_since_genesis + delete token.tokenStats.qty_valid_token_addresses + + token.tokenDetails.timestampUnix = token.tokenDetails.timestamp_unix + delete token.tokenDetails.timestamp_unix + + return token.tokenDetails + } +} + +module.exports = Slpdb diff --git a/src/routes/v5/slp.js b/src/routes/v5/slp.js new file mode 100644 index 0000000..6509118 --- /dev/null +++ b/src/routes/v5/slp.js @@ -0,0 +1,2365 @@ +'use strict' + +const express = require('express') +const router = express.Router() +const axios = require('axios') +const BigNumber = require('bignumber.js') + +const RouteUtils = require('../../util/route-utils') +const routeUtils = new RouteUtils() + +const Slpdb = require('./services/slpdb') + +// const strftime = require('strftime') +const wlogger = require('../../util/winston-logging') + +// Instantiate a local copy of bch-js using the local REST API server. +const LOCAL_RESTURL = process.env.LOCAL_RESTURL + ? process.env.LOCAL_RESTURL + : 'https://api.fullstack.cash/v4/' + +const BCHJS = require('@psf/bch-js') +// const BCHJS = require('../../../../bch-js') +const bchjs = new BCHJS({ restURL: LOCAL_RESTURL }) + +// Used to convert error messages to strings, to safely pass to users. +const util = require('util') +util.inspect.defaultOptions = { depth: 5 } + +// Setup JSON RPC +// const BitboxHTTP = axios.create({ +// baseURL: process.env.RPC_BASEURL +// }) +// const username = process.env.RPC_USERNAME +// const password = process.env.RPC_PASSWORD + +// Determine the Access password for a private instance of SLPDB. +// https://gist.github.com/christroutner/fc717ca704dec3dded8b52fae387eab2 +// Password for General Purpose (GP) SLPDB. +const SLPDB_PASS_GP = process.env.SLPDB_PASS_GP + ? process.env.SLPDB_PASS_GP + : 'BITBOX' +// Password for Whitelist (WL) SLPDB. +const SLPDB_PASS_WL = process.env.SLPDB_PASS_WL + ? process.env.SLPDB_PASS_WL + : 'BITBOX' + +// const rawtransactions = require('./full-node/rawtransactions') +const RawTransactions = require('./full-node/rawtransactions') +const rawTransactions = new RawTransactions() + +// Setup REST and TREST URLs used by slpjs +// Dev note: this allows for unit tests to mock the URL. +if (!process.env.REST_URL) { + process.env.REST_URL = 'https://bchn.fullstack.cash/v4/' +} +if (!process.env.TREST_URL) { + process.env.TREST_URL = 'https://testnet.fullstack.cash/v4/' +} + +let _this + +class Slp { + constructor () { + _this = this + + // Encapsulate external libraries. + _this.axios = axios + _this.routeUtils = routeUtils + _this.BigNumber = BigNumber + _this.bchjs = bchjs + _this.rawTransactions = rawTransactions + _this.slpdb = new Slpdb() + + _this.router = router + + _this.router.get('/', _this.root) + // _this.router.get('/list', _this.list) + _this.router.get('/list/:tokenId', _this.listSingleToken) + _this.router.post('/list', _this.listBulkToken) + _this.router.get('/balancesForAddress/:address', _this.balancesForAddress) + _this.router.post('/balancesForAddress', _this.balancesForAddressBulk) + _this.router.get('/balancesForToken/:tokenId', _this.balancesForTokenSingle) + _this.router.get('/convert/:address', _this.convertAddressSingle) + _this.router.post('/convert', _this.convertAddressBulk) + _this.router.post('/validateTxid', _this.validateBulk) + _this.router.get('/validateTxid/:txid', _this.validateSingle) + _this.router.get('/validateTxid2/:txid', _this.validate2Single) + _this.router.get('/validateTxid3/:txid', _this.validate3Single) + _this.router.post('/validateTxid3', _this.validate3Bulk) + _this.router.get('/whitelist', _this.getSlpWhitelist) + _this.router.get('/txDetails/:txid', _this.txDetails) + _this.router.get('/tokenStats/:tokenId', _this.tokenStats) + _this.router.get( + '/transactions/:tokenId/:address', + _this.txsTokenIdAddressSingle + ) + _this.router.get( + '/transactionHistoryAllTokens/:address', + _this.txsByAddressSingle + ) + _this.router.post('/generateSendOpReturn', _this.generateSendOpReturn) + _this.router.post('/hydrateUtxos', _this.hydrateUtxos) + _this.router.post('/hydrateUtxosWL', _this.hydrateUtxosWL) + _this.router.get('/status', _this.getStatus) + _this.router.get('/nftChildren/:tokenId', _this.getNftChildren) + _this.router.get('/nftGroup/:tokenId', _this.getNftGroup) + } + + // DRY error handler. + errorHandler (err, res) { + // console.error('Entering slp.js/errorHandler(). err: ', err) + + // Attempt to decode the error message. + const { msg, status } = _this.routeUtils.decodeError(err) + console.log('slp.js/errorHandler msg from decodeError: ', msg) + console.log('slp.js/errorHandler status from decodeError: ', status) + + if (msg) { + res.status(status) + return res.json({ error: msg }) + } + + res.status(500) + return res.json({ error: util.inspect(err) }) + } + + formatTokenOutput (token) { + token.tokenDetails.id = token.tokenDetails.tokenIdHex + delete token.tokenDetails.tokenIdHex + token.tokenDetails.documentHash = token.tokenDetails.documentSha256Hex + delete token.tokenDetails.documentSha256Hex + token.tokenDetails.initialTokenQty = parseFloat( + token.tokenDetails.genesisOrMintQuantity + ) + delete token.tokenDetails.genesisOrMintQuantity + delete token.tokenDetails.transactionType + delete token.tokenDetails.batonVout + delete token.tokenDetails.sendOutputs + + if (token.tokenDetails.versionType === 65 && token.nftParentId) { + token.tokenDetails.nftParentId = token.nftParentId + } + + token.tokenDetails.blockCreated = token.tokenStats.block_created + token.tokenDetails.blockLastActiveSend = + token.tokenStats.block_last_active_send + token.tokenDetails.blockLastActiveMint = + token.tokenStats.block_last_active_mint + token.tokenDetails.txnsSinceGenesis = + token.tokenStats.qty_valid_txns_since_genesis + token.tokenDetails.validAddresses = + token.tokenStats.qty_valid_token_addresses + token.tokenDetails.totalMinted = parseFloat( + token.tokenStats.qty_token_minted + ) + token.tokenDetails.totalBurned = parseFloat( + token.tokenStats.qty_token_burned + ) + token.tokenDetails.circulatingSupply = parseFloat( + token.tokenStats.qty_token_circulating_supply + ) + token.tokenDetails.mintingBatonStatus = + token.tokenStats.minting_baton_status + + delete token.tokenStats.block_last_active_send + delete token.tokenStats.block_last_active_mint + delete token.tokenStats.qty_valid_txns_since_genesis + delete token.tokenStats.qty_valid_token_addresses + return token + } + + root (req, res, next) { + return res.json({ status: 'slp' }) + } + + /** + * @api {get} /slp/list/{tokenId} List single SLP token by id. + * @apiName List single SLP token by id. + * @apiGroup SLP + * @apiDescription Returns the list single SLP token by id. + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/slp/list/259908ae44f46ef585edef4bcc1e50dc06e4c391ac4be929fae27235b8158cf1" -H "accept:application/json" + * + * + */ + async listSingleToken (req, res, next) { + try { + const tokenId = req.params.tokenId + + if (!tokenId || tokenId === '') { + res.status(400) + return res.json({ error: 'tokenId can not be empty' }) + } + + const t = await _this.lookupToken(tokenId) + + res.status(200) + return res.json(t) + } catch (err) { + wlogger.error('Error in slp.ts/listSingleToken().', err) + return _this.errorHandler(err, res) + + // return res.json({ error: `Error in /list/:tokenId: ${err.message}` }) + } + } + + /** + * @api {post} /slp/list/ List Bulk SLP token . + * @apiName List Bulk SLP token. + * @apiGroup SLP + * @apiDescription Returns the list bulk SLP token by id. + * + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v4/slp/list" -H "accept:application/json" -H "Content-Type: application/json" -d '{"tokenIds":["7380843cd1089a1a01783f86af37734dc99667a1cdc577391b5f6ea42fc1bfb4","9ba379fe8171176d4e7e6771d9a24cd0e044c7b788d5f86a3fdf80904832b2c0"]}' + * + * + */ + async listBulkToken (req, res, next) { + try { + const tokenIds = req.body.tokenIds + + // Reject if tokenIds is not an array. + if (!Array.isArray(tokenIds)) { + res.status(400) + return res.json({ + error: 'tokenIds needs to be an array. Use GET for single tokenId.' + }) + } + + // Enforce array size rate limits + if (!_this.routeUtils.validateArraySize(req, tokenIds)) { + res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 + return res.json({ + error: 'Array too large.' + }) + } + + const query = { + v: 3, + q: { + db: ['t'], + find: { + 'tokenDetails.tokenIdHex': { + $in: tokenIds + } + }, + project: { tokenDetails: 1, tokenStats: 1, _id: 0 }, + sort: { 'tokenStats.block_created': -1 }, + limit: 10000 + } + } + + const s = JSON.stringify(query) + const b64 = Buffer.from(s).toString('base64') + const url = `${process.env.SLPDB_URL}q/${b64}` + // Request options + const opt = { + method: 'get', + baseURL: url + } + const tokenRes = await _this.axios.request(opt) + + const formattedTokens = [] + const txids = [] + + if (tokenRes.data.t.length) { + tokenRes.data.t.forEach((token) => { + txids.push(token.tokenDetails.tokenIdHex) + token = _this.formatTokenOutput(token) + formattedTokens.push(token.tokenDetails) + }) + } + + tokenIds.forEach((tokenId) => { + if (!txids.includes(tokenId)) { + formattedTokens.push({ + id: tokenId, + valid: false + }) + } + }) + + res.status(200) + return res.json(formattedTokens) + } catch (err) { + wlogger.error('Error in slp.ts/listBulkToken().', err) + return _this.errorHandler(err, res) + + // return res.json({ error: `Error in /list/:tokenId: ${err.message}` }) + } + } + + async lookupToken (tokenId) { + try { + const query = { + v: 3, + q: { + db: ['t'], + find: { + $query: { + 'tokenDetails.tokenIdHex': tokenId + } + }, + project: { tokenDetails: 1, tokenStats: 1, nftParentId: 1, _id: 0 }, + limit: 1000 + } + } + + const s = JSON.stringify(query) + const b64 = Buffer.from(s).toString('base64') + const url = `${process.env.SLPDB_URL}q/${b64}` + + // console.log(`url: ${url}`) + // Request options + const opt = { + method: 'get', + baseURL: url + } + const tokenRes = await _this.axios.request(opt) + // console.log(`tokenRes.data: ${util.inspect(tokenRes.data,null,2)}`) + // console.log( + // `tokenRes.data.t[0]: ${util.inspect(tokenRes.data.t[0], null, 2)}` + // ) + + const formattedTokens = [] + + if (tokenRes.data.t.length) { + tokenRes.data.t.forEach((token) => { + token = _this.formatTokenOutput(token) + formattedTokens.push(token.tokenDetails) + }) + } + + let t + formattedTokens.forEach((token) => { + if (token.id === tokenId) t = token + }) + + // If token could not be found. + if (t === undefined) { + t = { + id: 'not found' + } + } + + return t + } catch (err) { + wlogger.error('Error in slp.ts/lookupToken().', err) + // console.log(`Error in slp.ts/lookupToken()`) + throw err + } + } + + /** + * @api {get} /slp/balancesForAddress/{address} List SLP balance for address. + * @apiName List SLP balance for address. + * @apiGroup SLP + * @apiDescription Returns List SLP balance for address. + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/slp/balancesForAddress/simpleledger:qz9tzs6d5097ejpg279rg0rnlhz546q4fsnck9wh5m" -H "accept:application/json" + * + * + */ + // Retrieve token balances for all tokens for a single address. + async balancesForAddress (req, res, next) { + try { + // Validate the input data. + const address = req.params.address + if (!address || address === '') { + res.status(400) + return res.json({ error: 'address can not be empty' }) + } + + // Ensure the input is a valid BCH address. + try { + _this.bchjs.SLP.Address.toCashAddress(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 cashAddr = _this.bchjs.SLP.Address.toCashAddress(address) + const networkIsValid = _this.routeUtils.validateNetwork(cashAddr) + if (!networkIsValid) { + res.status(400) + return res.json({ + error: + 'Invalid network. Trying to use a testnet address on mainnet, or vice versa.' + }) + } + + const query = { + v: 3, + q: { + db: ['g'], + aggregate: [ + { + $match: { + 'graphTxn.outputs': { + $elemMatch: { + address: _this.bchjs.SLP.Address.toSLPAddress(address), + status: 'UNSPENT', + slpAmount: { $gte: 0 } + } + } + } + }, + { + $unwind: '$graphTxn.outputs' + }, + { + $match: { + 'graphTxn.outputs.address': _this.bchjs.SLP.Address.toSLPAddress( + address + ), + 'graphTxn.outputs.status': 'UNSPENT', + 'graphTxn.outputs.slpAmount': { $gte: 0 } + } + }, + { + $project: { + amount: '$graphTxn.outputs.slpAmount', + address: '$graphTxn.outputs.address', + txid: '$graphTxn.txid', + vout: '$graphTxn.outputs.vout', + tokenId: '$tokenDetails.tokenIdHex' + } + }, + { + $group: { + _id: '$tokenId', + balanceString: { + $sum: '$amount' + }, + slpAddress: { + $first: '$address' + } + } + } + ], + limit: 10000 + } + } + + const s = JSON.stringify(query) + const b64 = Buffer.from(s).toString('base64') + const url = `${process.env.SLPDB_URL}q/${b64}` + + const options = _this.generateCredentialsGP() + // Request options + const opt = { + method: 'get', + baseURL: url, + headers: options.headers, + timeout: options.timeout + } + const tokenRes = await _this.axios.request(opt) + // console.log(`tokenRes.data.g: ${JSON.stringify(tokenRes.data.g, null, 2)}`) + + const tokenIds = [] + if (tokenRes.data.g.length > 0) { + tokenRes.data.g = tokenRes.data.g.map((token) => { + token.tokenId = token._id + tokenIds.push(token.tokenId) + token.balance = parseFloat(token.balanceString) + + delete token._id + + return token + }) + + const promises = tokenIds.map(async (tokenId) => { + const query2 = { + v: 3, + q: { + db: ['t'], + find: { + $query: { + 'tokenDetails.tokenIdHex': tokenId + } + }, + project: { + 'tokenDetails.decimals': 1, + 'tokenDetails.tokenIdHex': 1, + _id: 0 + }, + limit: 1000 + } + } + + const s2 = JSON.stringify(query2) + const b642 = Buffer.from(s2).toString('base64') + const url2 = `${process.env.SLPDB_URL}q/${b642}` + // Request options + const opt = { + method: 'get', + baseURL: url2, + headers: options.headers, + timeout: options.timeout + } + const tokenRes2 = await _this.axios.request(opt) + // console.log(`tokenRes2.data: ${JSON.stringify(tokenRes2.data, null, 2)}`) + + return tokenRes2.data + }) + + const details = await _this.axios.all(promises) + + tokenRes.data.g = tokenRes.data.g.map((token) => { + details.forEach((detail) => { + if (detail.t[0].tokenDetails.tokenIdHex === token.tokenId) { + token.decimalCount = detail.t[0].tokenDetails.decimals + } + }) + return token + }) + + return res.json(tokenRes.data.g) + } + + return res.json('No balance for this address') + } catch (err) { + wlogger.error('Error in slp.ts/balancesForAddress().', err) + + return _this.errorHandler(err, res) + + // return res.json({ + // error: `Error in /address/:address: ${err.message}` + // }) + } + } + + /** + * @api {post} /slp/balancesForAddress List SLP balances for an array of addresses. + * @apiName List SLP balances for an array of addresses. + * @apiGroup SLP + * @apiDescription Returns SLP balances for an array of addresses. + * + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v4/slp/balancesForAddress" -d "{\"addresses\":[\"simpleledger:qqss4zp80hn6szsa4jg2s9fupe7g5tcg5ucdyl3r57\"]}" -H "accept:application/json" + * + * + */ + async balancesForAddressBulk (req, res, next) { + try { + const addresses = req.body.addresses + + // Reject if addresses 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 (!_this.routeUtils.validateArraySize(req, addresses)) { + res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 + return res.json({ + error: 'Array too large.' + }) + } + + wlogger.debug( + 'Executing slp/balancesForAddresss with these addresses: ', + addresses + ) + + // Loop through each address and do error checking. + for (let i = 0; i < addresses.length; i++) { + const address = addresses[i] + + // Validate the input data. + if (!address || address === '') { + res.status(400) + return res.json({ error: 'address can not be empty' }) + } + + // Ensure the input is a valid BCH address. + try { + _this.bchjs.SLP.Address.toCashAddress(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 cashAddr = _this.bchjs.SLP.Address.toCashAddress(address) + const networkIsValid = _this.routeUtils.validateNetwork(cashAddr) + if (!networkIsValid) { + res.status(400) + return res.json({ + error: + 'Invalid network. Trying to use a testnet address on mainnet, or vice versa.' + }) + } + } + + const options = _this.generateCredentialsGP() + + // Collect an array of promises, one for each request to slpserve. + // This is a nested array of promises. + const balancesPromises = addresses.map(async (address) => { + const query = { + v: 3, + q: { + db: ['g'], + aggregate: [ + { + $match: { + 'graphTxn.outputs': { + $elemMatch: { + address: _this.bchjs.SLP.Address.toSLPAddress(address), + status: 'UNSPENT', + slpAmount: { $gte: 0 } + } + } + } + }, + { + $unwind: '$graphTxn.outputs' + }, + { + $match: { + 'graphTxn.outputs.address': _this.bchjs.SLP.Address.toSLPAddress( + address + ), + 'graphTxn.outputs.status': 'UNSPENT', + 'graphTxn.outputs.slpAmount': { $gte: 0 } + } + }, + { + $project: { + amount: '$graphTxn.outputs.slpAmount', + address: '$graphTxn.outputs.address', + txid: '$graphTxn.txid', + vout: '$graphTxn.outputs.vout', + tokenId: '$tokenDetails.tokenIdHex' + } + }, + { + $group: { + _id: '$tokenId', + balanceString: { + $sum: '$amount' + }, + slpAddress: { + $first: '$address' + } + } + } + ], + limit: 10000 + } + } + + const s = JSON.stringify(query) + const b64 = Buffer.from(s).toString('base64') + const url = `${process.env.SLPDB_URL}q/${b64}` + // Request options + const opt = { + method: 'get', + baseURL: url, + headers: options.headers, + timeout: options.timeout + } + const tokenRes = await _this.axios.request(opt) + // console.log(`tokenRes.data: ${JSON.stringify(tokenRes.data, null, 2)}`) + + const tokenIds = [] + + if (tokenRes.data.g.length > 0) { + tokenRes.data.g = tokenRes.data.g.map((token) => { + token.tokenId = token._id + tokenIds.push(token.tokenId) + token.balance = parseFloat(token.balanceString) + delete token._id + return token + }) + } + + // Collect another array of promises. + const promises = tokenIds.map(async (tokenId) => { + const query2 = { + v: 3, + q: { + db: ['t'], + find: { + $query: { + 'tokenDetails.tokenIdHex': tokenId + } + }, + project: { + 'tokenDetails.decimals': 1, + 'tokenDetails.tokenIdHex': 1, + _id: 0 + }, + limit: 1000 + } + } + + const s2 = JSON.stringify(query2) + const b642 = Buffer.from(s2).toString('base64') + const url2 = `${process.env.SLPDB_URL}q/${b642}` + const opt = { + method: 'get', + baseURL: url2, + headers: options.headers, + timeout: options.timeout + } + const tokenRes2 = await _this.axios.request(opt) + // console.log(`tokenRes2.data: ${JSON.stringify(tokenRes2.data, null, 2)}`) + + return tokenRes2.data + }) + + // Wait for all the promises to resolve. + const details = await Promise.all(promises) + + tokenRes.data.g = tokenRes.data.g.map((token) => { + details.forEach((detail) => { + if (detail.t[0].tokenDetails.tokenIdHex === token.tokenId) { + token.decimalCount = detail.t[0].tokenDetails.decimals + } + }) + + return token + }) + + return tokenRes.data.g + }) + + // Wait for all the promises to resolve. + const axiosResult = await _this.axios.all(balancesPromises) + + return res.json(axiosResult) + } catch (err) { + wlogger.error('Error in slp.js/balancesForAddressBulk().', err) + + return _this.errorHandler(err, res) + // return res.json({ + // error: `Error in POST balancesForAddress: ${err.message}` + // }) + } + } + + /** + * @api {get} /slp/balancesForToken/{TokenId} List SLP addresses and balances for tokenId. + * @apiName List SLP addresses and balances for tokenId. + * @apiGroup SLP + * @apiDescription Returns List SLP addresses and balances for tokenId. + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/slp/balancesForToken/9ba379fe8171176d4e7e6771d9a24cd0e044c7b788d5f86a3fdf80904832b2c0" -H "accept:application/json" + * + * + */ + // Retrieve token balances for all addresses by single tokenId. + async balancesForTokenSingle (req, res, next) { + try { + // Validate the input data. + const tokenId = req.params.tokenId + if (!tokenId || tokenId === '') { + res.status(400) + return res.json({ error: 'tokenId can not be empty' }) + } + + const query = { + v: 3, + q: { + db: ['g'], + aggregate: [ + { + $match: { + 'graphTxn.outputs': { + $elemMatch: { + status: 'UNSPENT', + slpAmount: { $gte: 0 } + } + }, + 'tokenDetails.tokenIdHex': tokenId + } + }, + { + $unwind: '$graphTxn.outputs' + }, + { + $match: { + 'graphTxn.outputs.status': 'UNSPENT', + 'graphTxn.outputs.slpAmount': { $gte: 0 }, + 'tokenDetails.tokenIdHex': tokenId + } + }, + { + $project: { + token_balance: '$graphTxn.outputs.slpAmount', + address: '$graphTxn.outputs.address', + txid: '$graphTxn.txid', + vout: '$graphTxn.outputs.vout', + tokenId: '$tokenDetails.tokenIdHex' + } + }, + { + $group: { + _id: '$address', + token_balance: { + $sum: '$token_balance' + } + } + } + ], + limit: 10000 + } + } + + const s = JSON.stringify(query) + const b64 = Buffer.from(s).toString('base64') + const url = `${process.env.SLPDB_URL}q/${b64}` + + const options = _this.generateCredentialsGP() + const opt = { + method: 'get', + baseURL: url, + headers: options.headers, + timeout: options.timeout + } + // Get data from SLPDB. + const tokenRes = await _this.axios.request(opt) + // console.log(`tokenRes.data: ${JSON.stringify(tokenRes.data, null, 2)}`) + + const resBalances = tokenRes.data.g.map((addy, index) => { + delete addy.satoshis_balance + addy.tokenBalanceString = addy.token_balance + addy.slpAddress = addy._id + addy.tokenId = tokenId + delete addy._id + delete addy.token_balance + + return addy + }) + + return res.json(resBalances) + } catch (err) { + wlogger.error('Error in slp.ts/balancesForTokenSingle().', err) + + return _this.errorHandler(err, res) + // return res.json({ + // error: `Error in /balancesForToken/:tokenId: ${err.message}` + // }) + } + } + + /** + * @api {get} /slp/convert/{address} Convert address to slpAddr, cashAddr and legacy. + * @apiName Convert address to slpAddr, cashAddr and legacy. + * @apiGroup SLP + * @apiDescription Convert address to slpAddr, cashAddr and legacy. + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/slp/convert/simpleledger:qz9tzs6d5097ejpg279rg0rnlhz546q4fsnck9wh5m" -H "accept:application/json" + * + * + */ + async convertAddressSingle (req, res, next) { + try { + const address = req.params.address + + // Validate input + if (!address || address === '') { + res.status(400) + return res.json({ error: 'address can not be empty' }) + } + + const slpAddr = _this.bchjs.SLP.Address.toSLPAddress(address) + + const obj = { + slpAddress: '', + cashAddress: '', + legacyAddress: '' + } + obj.slpAddress = slpAddr + obj.cashAddress = _this.bchjs.SLP.Address.toCashAddress(slpAddr) + obj.legacyAddress = _this.bchjs.SLP.Address.toLegacyAddress( + obj.cashAddress + ) + + res.status(200) + return res.json(obj) + } catch (err) { + wlogger.error('Error in slp.ts/convertAddressSingle().', err) + return _this.errorHandler(err, res) + // return res.json({ + // error: `Error in /address/convert/:address: ${err.message}` + // }) + } + } + + /** + * @api {post} /slp/convert/ Convert multiple addresses to cash, legacy and simpleledger format. + * @apiName Convert multiple addresses to cash, legacy and simpleledger format. + * @apiGroup SLP + * @apiDescription Convert multiple addresses to cash, legacy and simpleledger format. + * + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v4/slp/convert" -H "accept:application/json" -H "Content-Type: application/json" -d '{"addresses":["simpleledger:qrxa0unrn67rtn85v7asfddhhth43ecnxua0antk2l"]}' + * + * + */ + async convertAddressBulk (req, res, next) { + const addresses = req.body.addresses + + // Reject if hashes 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 (!_this.routeUtils.validateArraySize(req, addresses)) { + res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 + return res.json({ + error: 'Array too large.' + }) + } + + // Convert each address in the array. + const convertedAddresses = [] + for (let i = 0; i < addresses.length; i++) { + const address = addresses[i] + + // Validate input + if (!address || address === '') { + res.status(400) + return res.json({ error: 'address can not be empty' }) + } + + const slpAddr = _this.bchjs.SLP.Address.toSLPAddress(address) + + const obj = { + slpAddress: '', + cashAddress: '', + legacyAddress: '' + } + obj.slpAddress = slpAddr + obj.cashAddress = _this.bchjs.SLP.Address.toCashAddress(slpAddr) + obj.legacyAddress = _this.bchjs.SLP.Address.toLegacyAddress( + obj.cashAddress + ) + + convertedAddresses.push(obj) + } + + res.status(200) + return res.json(convertedAddresses) + } + + /** + * @api {post} /slp/validateTxid/ Validate multiple SLP transactions by txid. + * @apiName Validate multiple SLP transactions by txid. + * @apiGroup SLP + * @apiDescription Validate multiple SLP transactions by txid. + * + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v4/slp/validateTxid" -H "accept:application/json" -H "Content-Type: application/json" -d '{"txids":["f7e5199ef6669ad4d078093b3ad56e355b6ab84567e59ad0f08a5ad0244f783a","fb0eeaa501a6e1acb721669c62a3f70741f48ae0fd7f4b8e1d72088785c51952"]}' + * + * + */ + async validateBulk (req, res, next) { + try { + const txids = req.body.txids + + // Reject if txids 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 (!_this.routeUtils.validateArraySize(req, txids)) { + res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 + return res.json({ + error: 'Array too large.' + }) + } + + wlogger.debug('Executing slp/validate with these txids: ', txids) + + const query = { + v: 3, + q: { + db: ['c', 'u'], + find: { + 'tx.h': { $in: txids } + }, + limit: 300, + project: { 'slp.valid': 1, 'tx.h': 1, 'slp.invalidReason': 1 } + } + } + const s = JSON.stringify(query) + const b64 = Buffer.from(s).toString('base64') + const url = `${process.env.SLPDB_URL}q/${b64}` + // console.log('url: ', url) + + const options = _this.generateCredentialsGP() + + // Get data from SLPDB. + const opt = { + method: 'get', + baseURL: url, + headers: options.headers, + timeout: options.timeout + } + const tokenRes = await _this.axios.request(opt) + // console.log(`tokenRes.data: ${JSON.stringify(tokenRes.data, null, 2)}`) + + let formattedTokens = [] + + // Combine the confirmed and unconfirmed collections. + const concatArray = tokenRes.data.c.concat(tokenRes.data.u) + + const tokenIds = [] + if (concatArray.length > 0) { + concatArray.forEach((token) => { + tokenIds.push(token.tx.h) // txid + + const validationResult = { + txid: token.tx.h, + valid: token.slp.valid + } + + // If the txid is invalid, add the reason it's invalid. + if (!validationResult.valid) { + validationResult.invalidReason = token.slp.invalidReason + } + + formattedTokens.push(validationResult) + }) + + // If a user-provided txid doesn't exist in the data, add it with + // valid:null property. + // 'null' indicates that SLPDB does not know about the transaction. It + // either has not seen it or has not processed it yet. A determination + // can not be made. + txids.forEach((txid) => { + if (!tokenIds.includes(txid)) { + formattedTokens.push({ + txid: txid, + valid: null + }) + } + }) + } else { + // Corner case: No results were returned from SLPDB. Mark each entry + // as 'null' + for (let i = 0; i < txids.length; i++) { + formattedTokens.push({ + txid: txids[i], + valid: null + }) + } + } + + // Catch a corner case of repeated txids. SLPDB will remove redundent TXIDs, + // which will cause the output array to be smaller than the input array. + if (txids.length > formattedTokens.length) { + const newOutput = [] + for (let i = 0; i < txids.length; i++) { + const thisTxid = txids[i] + + // Find the element that matches the current txid. + const elem = formattedTokens.filter((x) => x.txid === thisTxid) + + newOutput.push(elem[0]) + } + + // Replace the original output object with the new output object. + formattedTokens = newOutput + } + + // Put the output array in the same order as the input array. + const outAry = [] + for (let i = 0; i < txids.length; i++) { + const thisTxid = txids[i] + + // Need to use Array.find() because the returned output array is out + // of order with respect to the txid input array. + const output = formattedTokens.find((elem) => elem.txid === thisTxid) + // console.log(`output: ${JSON.stringify(output, null, 2)}`) + + outAry.push(output) + } + + res.status(200) + return res.json(outAry) + } catch (err) { + wlogger.error('Error in slp.ts/validateBulk().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {get} /slp/validateTxid/{txid} Validate single SLP transaction by txid. + * @apiName Validate single SLP transaction by txid. + * @apiGroup SLP + * @apiDescription Validate single SLP transaction by txid, using SLPDB. + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/slp/validateTxid/f7e5199ef6669ad4d078093b3ad56e355b6ab84567e59ad0f08a5ad0244f783a" -H "accept:application/json" + * + * + */ + async validateSingle (req, res, next) { + try { + const txid = req.params.txid + + // Validate input + if (!txid || txid === '') { + res.status(400) + return res.json({ error: 'txid can not be empty' }) + } + + wlogger.debug('Executing slp/validate/:txid with this txid: ', txid) + + const query = { + v: 3, + q: { + db: ['c', 'u'], + find: { + 'tx.h': txid + }, + limit: 300, + project: { 'slp.valid': 1, 'tx.h': 1, 'slp.invalidReason': 1 } + } + } + + const options = _this.generateCredentialsGP() + + const s = JSON.stringify(query) + const b64 = Buffer.from(s).toString('base64') + const url = `${process.env.SLPDB_URL}q/${b64}` + const opt = { + method: 'get', + baseURL: url, + headers: options.headers, + timeout: options.timeout + } + // Get data from SLPDB. + const tokenRes = await _this.axios.request(opt) + // console.log(`tokenRes.data: ${JSON.stringify(tokenRes.data, null, 2)}`) + + // Default return value. + let result = { + txid: txid, + valid: null + } + + // Build result. + const concatArray = tokenRes.data.c.concat(tokenRes.data.u) + if (concatArray.length > 0) { + result = { + txid: concatArray[0].tx.h, + valid: concatArray[0].slp.valid + } + if (!result.valid) { + result.invalidReason = concatArray[0].slp.invalidReason + } + } + + res.status(200) + return res.json(result) + } catch (err) { + wlogger.error('Error in slp.js/validateSingle().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {get} /slp/validateTxid2/{txid} Validate 2 Single + * @apiName Validate a single SLP transaction by txid using slp-validate. + * @apiGroup SLP + * @apiDescription Validate single SLP transaction by txid, using slp-validate. + * Slower, less efficient method of validating an SLP TXID using the slp-validate + * npm library. This method is independent of SLPDB and can be used as a fall-back + * when SLPDB returns 'null' values. + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/slp/validateTxid2/f7e5199ef6669ad4d078093b3ad56e355b6ab84567e59ad0f08a5ad0244f783a" -H "accept:application/json" + * + * + */ + async validate2Single (req, res, next) { + try { + const txid = req.params.txid + + // Validate input + if (!txid || txid === '') { + res.status(400) + return res.json({ error: 'txid can not be empty' }) + } + + wlogger.debug( + 'Executing slp/validate2Single/:txid with this txid: ', + txid + ) + + // null by default. + // Default return value. + const result = { + txid: txid, + isValid: null, + msg: '' + } + + // Request options + const opt = { + method: 'get', + baseURL: `${process.env.SLP_API_URL}slp/validate/${txid}`, + timeout: 10000 // Exit after 10 seconds. + } + const tokenRes = await _this.axios.request(opt) + // console.log(`tokenRes.data: ${JSON.stringify(tokenRes.data, null, 2)}`) + // console.log(`tokenRes: `, tokenRes) + + // Overwrite the default value with the result from slp-api. + result.isValid = tokenRes.data.isValid + + res.status(200) + return res.json(result) + } catch (err) { + // console.log('validate2Single error: ', err) + wlogger.error('Error in slp.ts/validate2Single().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {get} /slp/whitelist SLP token whitelist. + * @apiName SLP token whitelist + * @apiGroup SLP + * @apiDescription Get tokens that are on the whitelist. + * SLPDB is typically used to validate SLP transactions. It can become unstable + * during periods of high network usage. A second SLPDB has been implemented + * that is much more stable, because it only tracks a whitelist of SLP tokens. + * This endpoint will return information on the SLP tokens that are included + * in that whitelist. + * + * For tokens on the whitelist, the /slp/validateTxid3 endpoints can be used. + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/slp/whitelist" -H "accept:application/json" + * + */ + async getSlpWhitelist (req, res, next) { + try { + const list = [ + { + name: 'USDH', + tokenId: + 'c4b0d62156b3fa5c8f3436079b5394f7edc1bef5dc1cd2f9d0c4d46f82cca479' + }, + { + name: 'SPICE', + tokenId: + '4de69e374a8ed21cbddd47f2338cc0f479dc58daa2bbe11cd604ca488eca0ddf' + }, + { + name: 'PSF', + tokenId: + '38e97c5d7d3585a2cbf3f9580c82ca33985f9cb0845d4dcce220cb709f9538b0' + }, + { + name: 'TROUT', + tokenId: + 'a4fb5c2da1aa064e25018a43f9165040071d9e984ba190c222a7f59053af84b2' + }, + { + name: 'PSFTEST', + tokenId: + 'd0ef4de95b78222bfee2326ab11382f4439aa0855936e2fe6ac129a8d778baa0' + } + ] + + res.status(200) + return res.json(list) + } catch (err) { + wlogger.error('Error in slp.ts/whitelist().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {get} /slp/validateTxid3/{txid} Validate 3 Single + * @apiName Validate a single txid against a whitelist SLPDB + * @apiGroup SLP + * @apiDescription Alternative validation for tokens on the whitelist + * This endpoint is exactly the same as /slp/validateTxid/{txid} but it uses + * a different SLPDB. This server only indexes the SLP tokens that are on the + * whitelist. You can see which tokens are on the whitelist by calling the + * /slp/whitelist endpoint. + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/slp/validateTxid3/f7e5199ef6669ad4d078093b3ad56e355b6ab84567e59ad0f08a5ad0244f783a" -H "accept:application/json" + * + * + */ + async validate3Single (req, res, next) { + try { + const txid = req.params.txid + // console.log('validate3Single txid: ', txid) + + // Validate input + if (!txid || txid === '') { + res.status(400) + return res.json({ error: 'txid can not be empty' }) + } + + wlogger.debug('Executing slp/validate/:txid with this txid: ', txid) + + const query = { + v: 3, + q: { + db: ['c', 'u'], + find: { + 'tx.h': txid + }, + limit: 300, + project: { 'slp.valid': 1, 'tx.h': 1, 'slp.invalidReason': 1 } + } + } + + const options = _this.generateCredentialsWL() + + const s = JSON.stringify(query) + const b64 = Buffer.from(s).toString('base64') + const url = `${process.env.SLPDB_WHITELIST_URL}q/${b64}` + const opt = { + method: 'get', + baseURL: url, + headers: options.headers, + timeout: options.timeout + } + // Get data from SLPDB. + const tokenRes = await _this.axios.request(opt) + // console.log(`tokenRes.data: ${JSON.stringify(tokenRes.data, null, 2)}`) + + // Default return value. + let result = { + txid: txid, + valid: null + } + + // Build result. + const concatArray = tokenRes.data.c.concat(tokenRes.data.u) + if (concatArray.length > 0) { + result = { + txid: concatArray[0].tx.h, + valid: concatArray[0].slp.valid + } + if (!result.valid) { + result.invalidReason = concatArray[0].slp.invalidReason + } + } + + res.status(200) + return res.json(result) + } catch (err) { + wlogger.error('Error in slp.js/validate3Single().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {post} /slp/validateTxid3/ Validate 3 Bulk + * @apiName Validate an array of TXIDs against a whitelist SLPDB + * @apiGroup SLP + * @apiDescription Alternative validation for tokens on the whitelist + * This endpoint is exactly the same as /slp/validateTxid but it uses + * a different SLPDB. This server only indexes the SLP tokens that are on the + * whitelist. You can see which tokens are on the whitelist by calling the + * /slp/whitelist endpoint. + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v4/slp/validateTxid3" -H "accept:application/json" -H "Content-Type: application/json" -d '{"txids":["f7e5199ef6669ad4d078093b3ad56e355b6ab84567e59ad0f08a5ad0244f783a","fb0eeaa501a6e1acb721669c62a3f70741f48ae0fd7f4b8e1d72088785c51952"]}' + * + * + */ + async validate3Bulk (req, res, next) { + try { + const txids = req.body.txids + // console.log(`validate3Bulk txids: `, txids) + + // Reject if txids 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 (!_this.routeUtils.validateArraySize(req, txids)) { + res.status(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 + return res.json({ + error: 'Array too large.' + }) + } + + wlogger.debug('Executing slp/validate with these txids: ', txids) + + const query = { + v: 3, + q: { + db: ['c', 'u'], + find: { + 'tx.h': { $in: txids } + }, + limit: 300, + project: { 'slp.valid': 1, 'tx.h': 1, 'slp.invalidReason': 1 } + } + } + const s = JSON.stringify(query) + const b64 = Buffer.from(s).toString('base64') + const url = `${process.env.SLPDB_WHITELIST_URL}q/${b64}` + // console.log('url: ', url) + + const options = _this.generateCredentialsWL() + + // Get data from SLPDB. + const opt = { + method: 'get', + baseURL: url, + headers: options.headers, + timeout: options.timeout + } + const tokenRes = await _this.axios.request(opt) + // console.log(`tokenRes.data: ${JSON.stringify(tokenRes.data, null, 2)}`) + + let formattedTokens = [] + + // Combine the confirmed and unconfirmed collections. + const concatArray = tokenRes.data.c.concat(tokenRes.data.u) + + const tokenIds = [] + if (concatArray.length > 0) { + concatArray.forEach((token) => { + tokenIds.push(token.tx.h) // txid + + const validationResult = { + txid: token.tx.h, + valid: token.slp.valid + } + + // If the txid is invalid, add the reason it's invalid. + if (!validationResult.valid) { + validationResult.invalidReason = token.slp.invalidReason + } + + formattedTokens.push(validationResult) + }) + + // If a user-provided txid doesn't exist in the data, add it with + // valid:null property. + // 'null' indicates that SLPDB does not know about the transaction. It + // either has not seen it or has not processed it yet. A determination + // can not be made. + txids.forEach((txid) => { + if (!tokenIds.includes(txid)) { + formattedTokens.push({ + txid: txid, + valid: null + }) + } + }) + } else { + // Corner case: No results were returned from SLPDB. Mark each entry + // as 'null' + for (let i = 0; i < txids.length; i++) { + formattedTokens.push({ + txid: txids[i], + valid: null + }) + } + } + + // Catch a corner case of repeated txids. SLPDB will remove redundent TXIDs, + // which will cause the output array to be smaller than the input array. + if (txids.length > formattedTokens.length) { + const newOutput = [] + for (let i = 0; i < txids.length; i++) { + const thisTxid = txids[i] + + // Find the element that matches the current txid. + const elem = formattedTokens.filter((x) => x.txid === thisTxid) + + newOutput.push(elem[0]) + } + + // Replace the original output object with the new output object. + formattedTokens = newOutput + } + + // console.log( + // `formattedTokens: ${JSON.stringify(formattedTokens, null, 2)}` + // ) + + // Put the output array in the same order as the input array. + const outAry = [] + for (let i = 0; i < txids.length; i++) { + const thisTxid = txids[i] + + // Need to use Array.find() because the returned output array is out + // of order with respect to the txid input array. + const output = formattedTokens.find((elem) => elem.txid === thisTxid) + // console.log(`output: ${JSON.stringify(output, null, 2)}`) + + outAry.push(output) + } + + res.status(200) + return res.json(outAry) + } catch (err) { + wlogger.error('Error in slp.js/validate3Bulk().', err) + + return _this.errorHandler(err, res) + } + } + + /** + * @api {get} /slp/txDetails/{txid} SLP transaction details. + * @apiName SLP transaction details. + * @apiGroup SLP + * @apiDescription Transaction details on a token transfer. + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/slp/txDetails/8ab4ac5dea3f9024e3954ee5b61452955d659a34561f79ef62ac44e133d0980e" -H "accept:application/json" + * + * + */ + async txDetails (req, res, next) { + try { + // Validate input parameter + const txid = req.params.txid + if (!txid || txid === '') { + res.status(400) + return res.json({ error: 'txid can not be empty' }) + } + + if (txid.length !== 64) { + res.status(400) + return res.json({ error: 'This is not a txid' }) + } + + const query = { + v: 3, + db: ['g'], + q: { + find: { + 'tx.h': txid + }, + limit: 300 + } + } + + const s = JSON.stringify(query) + const b64 = Buffer.from(s).toString('base64') + const url = `${process.env.SLPDB_URL}q/${b64}` + + const options = _this.generateCredentialsGP() + const opt = { + method: 'get', + baseURL: url, + headers: options.headers, + timeout: options.timeout + } + // Get token data from SLPDB + const tokenRes = await _this.axios.request(opt) + // console.log(`tokenRes: ${util.inspect(tokenRes)}`) + + // Return 'not found' error if both the confirmed and unconfirmed + // collections are empty. + if (tokenRes.data.c.length === 0 && tokenRes.data.u.length === 0) { + res.status(404) + return res.json({ error: 'TXID not found' }) + } + + // Format the returned data to an object. + const formatted = await _this.formatToRestObject(tokenRes) + // console.log(`formatted: ${JSON.stringify(formatted,null,2)}`) + + // Get information on the transaction from Insight API. + // const retData = await transactions.transactionsFromInsight(txid) + const retData = await _this.rawTransactions.getRawTransactionsFromNode( + txid, + true + ) + // console.log(`retData: ${JSON.stringify(retData, null, 2)}`) + + // Return both the tx data from Insight and the formatted token information. + const response = { + retData, + ...formatted + } + + res.status(200) + return res.json(response) + } catch (err) { + wlogger.error('Error in slp.ts/txDetails().', err) + + // Handle corner case of mis-typted txid + // if (err.error && err.error.indexOf('Not found') > -1) { + // res.status(400) + // return res.json({ error: 'TXID not found' }) + // } + + return _this.errorHandler(err, res) + } + } + + /** + * @api {get} /slp/tokenStats/{tokenId} List stats for a single slp token. + * @apiName List stats for a single slp token. + * @apiGroup SLP + * @apiDescription Return list stats for a single slp token. + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/slp/tokenStats/9ba379fe8171176d4e7e6771d9a24cd0e044c7b788d5f86a3fdf80904832b2c0" -H "accept:application/json" + * + * + */ + async tokenStats (req, res, next) { + try { + const tokenId = req.params.tokenId + if (!tokenId || tokenId === '') { + res.status(400) + return res.json({ error: 'tokenId can not be empty' }) + } + + const tokenStats = await _this.slpdb.getTokenStats(tokenId) + + res.status(200) + return res.json(tokenStats) + } catch (err) { + wlogger.error('Error in slp.ts/tokenStats().', err) + return _this.errorHandler(err, res) + // return res.json({ error: `Error in /tokenStats: ${err.message}` }) + } + } + + /** + * @api {get} /slp/transactions/{tokenId}/{address} SLP transactions by tokenId and address. + * @apiName SLP transactions by tokenId and address. + * @apiGroup SLP + * @apiDescription Transactions by tokenId and address. + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/slp/transactions/9ba379fe8171176d4e7e6771d9a24cd0e044c7b788d5f86a3fdf80904832b2c0/simpleledger:qrxa0unrn67rtn85v7asfddhhth43ecnxua0antk2l" -H "accept:application/json" + * + * + */ + // Retrieve transactions by tokenId and address. + async txsTokenIdAddressSingle (req, res, next) { + try { + // Validate the input data. + const tokenId = req.params.tokenId + if (!tokenId || tokenId === '') { + res.status(400) + return res.json({ error: 'tokenId can not be empty' }) + } + + const address = req.params.address + if (!address || address === '') { + res.status(400) + return res.json({ error: 'address can not be empty' }) + } + + const query = { + v: 3, + q: { + find: { + db: ['c', 'u'], + $query: { + $or: [ + { + 'in.e.a': address + }, + { + 'out.e.a': address + } + ], + 'slp.detail.tokenIdHex': tokenId + }, + $orderby: { + 'blk.i': -1 + } + }, + limit: 100 + }, + r: { + f: '[.[] | { txid: .tx.h, tokenDetails: .slp } ]' + } + } + + const s = JSON.stringify(query) + const b64 = Buffer.from(s).toString('base64') + const url = `${process.env.SLPDB_URL}q/${b64}` + const opt = { + method: 'get', + baseURL: url + } + // Get data from SLPDB. + const tokenRes = await _this.axios.request(opt) + // console.log(`tokenRes.data: ${JSON.stringify(tokenRes.data, null, 2)}`) + + return res.json(tokenRes.data.c) + } catch (err) { + wlogger.error('Error in slp.ts/txsTokenIdAddressSingle().', err) + + return _this.errorHandler(err, res) + // return res.json({ + // error: `Error in /transactions/:tokenId/:address: ${err.message}` + // }) + } + } + + // Generates a Basic Authorization header for slpserve. + generateCredentialsGP () { + // Generate the Basic Authentication header for a private instance of SLPDB. + const username = 'BITBOX' + const password = SLPDB_PASS_GP + const combined = `${username}:${password}` + // console.log(`combined: ${combined}`) + var base64Credential = Buffer.from(combined).toString('base64') + var readyCredential = `Basic ${base64Credential}` + + const options = { + headers: { + authorization: readyCredential + }, + timeout: 15000 + } + + return options + } + + // Generates a Basic Authorization header for slpserve. + generateCredentialsWL () { + // Generate the Basic Authentication header for a private instance of SLPDB. + const username = 'BITBOX' + const password = SLPDB_PASS_WL + const combined = `${username}:${password}` + // console.log(`combined: ${combined}`) + var base64Credential = Buffer.from(combined).toString('base64') + var readyCredential = `Basic ${base64Credential}` + + const options = { + headers: { + authorization: readyCredential + }, + timeout: 15000 + } + + return options + } + + // Format the response from SLPDB into an object. + async formatToRestObject (slpDBFormat) { + // console.log(`slpDBFormat.data: ${JSON.stringify(slpDBFormat.data, null, 2)}`) + + const transaction = slpDBFormat.data.u.length + ? slpDBFormat.data.u[0] + : slpDBFormat.data.c[0] + + // const inputs = transaction.in + + // const outputs = transaction.out + const tokenOutputs = transaction.slp.detail.outputs + + const sendOutputs = ['0'] + tokenOutputs.map((x) => { + const string = parseFloat(x.amount) * 100000000 + sendOutputs.push(string.toString()) + }) + + // Because you are not using Insight API indexer, you do not get the + // sending addresses from an indexer or from the node. + // However, they are available from the SLPDB output. + const tokenInputs = transaction.in + // Collect the input addresses + const sendInputs = [] + for (let i = 0; i < tokenInputs.length; i += 1) { + const tokenInput = tokenInputs[i] + const sendInput = {} + sendInput.address = tokenInput.e.a + sendInputs.push(sendInput) + } + + const obj = { + tokenInfo: { + versionType: transaction.slp.detail.versionType, + tokenName: transaction.slp.detail.name, + tokenTicker: transaction.slp.detail.symbol, + transactionType: transaction.slp.detail.transactionType, + tokenIdHex: transaction.slp.detail.tokenIdHex, + sendOutputs: sendOutputs, + sendInputsFull: sendInputs, + sendOutputsFull: transaction.slp.detail.outputs + }, + tokenIsValid: transaction.slp.valid + } + + return obj + } + + // Retrieve transactions by address. + async txsByAddressSingle (req, res, next) { + try { + // Validate the input data. + const address = req.params.address + if (!address || address === '') { + res.status(400) + return res.json({ error: 'address can not be empty' }) + } + + // Ensure the input is a valid BCH address. + try { + _this.bchjs.SLP.Address.toCashAddress(address) + } catch (err) { + res.status(400) + return res.json({ + error: `Invalid BCH address. Double check your address is valid: ${address}` + }) + } + + // Ensure it is using the correct network. + const cashAddr = _this.bchjs.SLP.Address.toCashAddress(address) + const networkIsValid = routeUtils.validateNetwork(cashAddr) + if (!networkIsValid) { + res.status(400) + return res.json({ + error: + 'Invalid network. Trying to use a testnet address on mainnet, or vice versa.' + }) + } + + const transactions = await _this.slpdb.getHistoricalSlpTransactions([ + address + ]) + // console.log(`transactions: ${JSON.stringify(transactions, null, 2)}`) + + res.status(200) + return res.json(transactions) + } catch (err) { + wlogger.error('Error in slp.ts/txsByAddressSingle().', err) + + // Decode the error message. + const { msg, status } = routeUtils.decodeError(err) + if (msg) { + res.status(status) + return res.json({ error: msg }) + } + + res.status(500) + return res.json({ + error: `Error in /transactionHistoryAllTokens/:address: ${err.message}` + }) + } + } + + /** + * @api {post} /slp/generateSendOpReturn/ generateSendOpReturn + * @apiName SLP generateSendOpReturn + * @apiGroup SLP + * @apiDescription Generate the hex required for a SLP Send OP_RETURN. + * + * This will return a hexidecimal representation of the OP_RETURN code that + * can be used to generate an SLP Send transaction. The number of outputs + * (1 or 2) will also be returned. + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v4/slp/generateSendOpReturn" -H "accept:application/json" -H "Content-Type: application/json" -d '{"tokenUtxos":[{"tokenId": "0a321bff9761f28e06a268b14711274bb77617410a16807bd0437ef234a072b1","decimals": 0, "tokenQty": 2}], "sendQty": 1.5}' + * + * + */ + // Get OP_RETURN script and outputs + async generateSendOpReturn (req, res, next) { + try { + const tokenUtxos = req.body.tokenUtxos + // console.log(`tokenUtxos: `, tokenUtxos) + + const _sendQty = req.body.sendQty + const sendQty = Number(_sendQty) + + // console.log(`sendQty: `, sendQty) + + // Reject if tokenUtxos is not an array. + if (!Array.isArray(tokenUtxos)) { + res.status(400) + return res.json({ + error: 'tokenUtxos needs to be an array.' + }) + } + + // Reject if tokenUtxos array is empty. + if (!tokenUtxos.length) { + res.status(400) + return res.json({ + error: 'tokenUtxos array can not be empty.' + }) + } + + // Reject if sendQty is not an number. + if (!sendQty) { + res.status(400) + return res.json({ + error: 'sendQty must be a number.' + }) + } + + // console.log('sendQty: ', sendQty) + // console.log(`tokenUtxos: `, tokenUtxos) + const opReturn = await _this.bchjs.SLP.TokenType1.generateSendOpReturn( + tokenUtxos, + sendQty + ) + + const script = opReturn.script.toString('hex') + // console.log(`script: ${script}`) + + res.status(200) + return res.json({ script, outputs: opReturn.outputs }) + } catch (err) { + console.log('err: ', err) + wlogger.error('Error in slp.js/generateSendOpReturn().', err) + + // Decode the error message. + const { msg, status } = routeUtils.decodeError(err) + if (msg) { + res.status(status) + return res.json({ error: msg }) + } + + res.status(500) + return res.json({ + error: 'Error in /generateSendOpReturn()' + }) + } + } + + /** + * @api {post} /slp/hydrateUtxos/ hydrateUtxos + * @apiName SLP hydrateUtxos + * @apiGroup SLP + * @apiDescription Hydrate UTXO data with SLP information. + * + * Expects an array of UTXO objects as input. Returns an array of equal size. + * Returns UTXO data hydrated with token information. If the UTXO does not + * belong to a SLP transaction, it will return an isValid property set to + * false. If the UTXO is part of an SLP transaction, it will return the UTXO + * object with additional SLP information attached. An isValid property will + * be included. If its value is true, the UTXO is a valid SLP UTXO. If the + * value is null, then SLPDB has not yet processed that txid and validity has + * not been confirmed. + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v4/slp/hydrateUtxos" -H "accept:application/json" -H "Content-Type: application/json" -d '{"utxos":[{"utxos":[{"txid": "d56a2b446d8149c39ca7e06163fe8097168c3604915f631bc58777d669135a56","vout": 3, "value": "6816", "height": 606848, "confirmations": 13, "satoshis": 6816}, {"txid": "d56a2b446d8149c39ca7e06163fe8097168c3604915f631bc58777d669135a56","vout": 2, "value": "546", "height": 606848, "confirmations": 13, "satoshis": 546}]}]}' + * + * + */ + async hydrateUtxos (req, res, next) { + try { + const utxos = req.body.utxos + + // Extract a delay value if the user passed it in. + const usrObjIn = req.body.usrObj + let utxoDelay = 0 + if (usrObjIn && usrObjIn.utxoDelay) { + utxoDelay = usrObjIn.utxoDelay + } + + // console.log('req: ', req) + // console.log(`req._remoteAddress: ${req._remoteAddress}`) + + // Generate a user object that can be passed along with internal calls + // from bch-js. + const usrObj = { + ip: req._remoteAddress, + jwtToken: req.locals.jwtToken, + proLimit: req.locals.proLimit, + apiLevel: req.locals.apiLevel, + utxoDelay + } + + // Validate inputs + if (!Array.isArray(utxos)) { + res.status(422) + return res.json({ + error: 'Input must be an array.' + }) + } + + if (!utxos.length) { + res.status(422) + return res.json({ + error: 'Array should not be empty' + }) + } + + if (utxos.length > 20) { + res.status(422) + return res.json({ + error: 'Array too long, max length is 20' + }) + } + + if (!utxos[0].utxos) { + res.status(422) + return res.json({ + error: 'Each element in array should have a utxos property' + }) + } + + // Loop through each address and query the UTXOs for that element. + for (let i = 0; i < utxos.length; i++) { + const theseUtxos = utxos[i].utxos + + // Get SLP token details. + const details = await _this.bchjs.SLP.Utils.tokenUtxoDetails( + theseUtxos, + usrObj + ) + // console.log('details: ', details) + + // Replace the original UTXO data with the hydrated data. + utxos[i].utxos = details + } + + res.status(200) + return res.json({ slpUtxos: utxos }) + } catch (err) { + wlogger.error('Error in slp.js/hydrateUtxos().', err) + // console.error('Error in slp.js/hydrateUtxos().', err) + + // Decode the error message. + const { msg, status } = routeUtils.decodeError(err) + // console.log('msg: ', msg) + // console.log('status: ', status) + + if (msg) { + res.status(status) + return res.json({ error: msg, message: msg, success: false }) + } + + res.status(500) + return res.json({ + error: 'Undetermined error in hydrateUtxos()', + message: err.message + }) + } + } + + /** + * @api {post} /slp/hydrateUtxosWL/ hydrateUtxosWL + * @apiName SLP hydrateUtxosWL + * @apiGroup SLP + * @apiDescription Hydrate UTXO data with SLP information, using only the whitelist SLPDB. + * + * This call is identical to `hydrateUtxos`, except it will only use the + * filtered SLPDB with a whitelist. This results in faster performance, more + * reliable uptime, but more frequent `isValid: null` values. Some use-cases + * prioritize the speed and reliability over acceptance of a wide range of + * SLP tokens. + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v4/slp/hydrateUtxosWL" -H "accept:application/json" -H "Content-Type: application/json" -d '{"utxos":[{"utxos":[{"txid": "d56a2b446d8149c39ca7e06163fe8097168c3604915f631bc58777d669135a56","vout": 3, "value": "6816", "height": 606848, "confirmations": 13, "satoshis": 6816}, {"txid": "d56a2b446d8149c39ca7e06163fe8097168c3604915f631bc58777d669135a56","vout": 2, "value": "546", "height": 606848, "confirmations": 13, "satoshis": 546}]}]}' + * + * + */ + async hydrateUtxosWL (req, res, next) { + try { + const utxos = req.body.utxos + + // Extract a delay value if the user passed it in. + const usrObjIn = req.body.usrObj + let utxoDelay = 0 + if (usrObjIn && usrObjIn.utxoDelay) { + utxoDelay = usrObjIn.utxoDelay + } + + // Generate a user object that can be passed along with internal calls + // from bch-js. + const usrObj = { + ip: req._remoteAddress, + jwtToken: req.locals.jwtToken, + proLimit: req.locals.proLimit, + apiLevel: req.locals.apiLevel, + utxoDelay + } + + // Validate inputs + if (!Array.isArray(utxos)) { + res.status(422) + return res.json({ + error: 'Input must be an array.' + }) + } + + if (!utxos.length) { + res.status(422) + return res.json({ + error: 'Array should not be empty' + }) + } + + if (utxos.length > 20) { + res.status(422) + return res.json({ + error: 'Array too long, max length is 20' + }) + } + + if (!utxos[0].utxos) { + res.status(422) + return res.json({ + error: 'Each element in array should have a utxos property' + }) + } + + // Loop through each address and query the UTXOs for that element. + for (let i = 0; i < utxos.length; i++) { + const theseUtxos = utxos[i].utxos + // console.log(`theseUtxos: ${JSON.stringify(theseUtxos, null, 2)}`) + + // Get SLP token details. + const details = await _this.bchjs.SLP.Utils.tokenUtxoDetailsWL( + theseUtxos, + usrObj + ) + // console.log('details : ', details) + + // Replace the original UTXO data with the hydrated data. + utxos[i].utxos = details + } + + res.status(200) + return res.json({ slpUtxos: utxos }) + } catch (err) { + wlogger.error('Error in slp.js/hydrateUtxosWL().', err) + console.error('Error in slp.js/hydrateUtxosWL().', err) + + // Decode the error message. + const { msg, status } = routeUtils.decodeError(err) + console.log('msg: ', msg) + console.log('status: ', status) + if (msg) { + res.status(status) + return res.json({ error: msg, message: msg, success: false }) + } + + res.status(500) + return res.json({ + error: 'Error in hydrateUtxosWL()', + message: 'Error in hydrateUtxosWL()' + }) + } + } + + /** + * @api {get} /slp/status Get the health status of SLPDB + * @apiName Get the health status of SLPDB + * @apiGroup SLP + * @apiDescription Get the health status of SLPDB + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/slp/status" -H "accept:application/json" -H "Content-Type: application/json" + * + */ + async getStatus (req, res, next) { + try { + const query = { + v: 3, + q: { + db: ['s'], + find: { context: 'SLPDB' }, + limit: 10 + } + } + + const s = JSON.stringify(query) + const b64 = Buffer.from(s).toString('base64') + const url = `${process.env.SLPDB_URL}q/${b64}` + // Request options + const opt = { + method: 'get', + baseURL: url + } + const tokenRes = await _this.axios.request(opt) + + const status = tokenRes.data.s[0] + + res.status(200) + return res.json(status) + } catch (err) { + // console.log(err) + wlogger.error('Error in slp.js/getStatus().', err) + return _this.errorHandler(err, res) + } + } + + /** + * @api {get} /slp/nftChildren/{tokenId} Get all NFT children for a given NFT group + * @apiName Get all NFT children for a given NFT group + * @apiGroup SLP + * @apiDescription Get all NFT children for a given NFT group + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/slp/nftChildren/68cd33ecd909068fbea318ae5ff1d6207cf754e53b191327d6d73b6916424c0a" -H "accept:application/json" + * + */ + async getNftChildren (req, res, next) { + try { + // Validate the input data. + const tokenId = req.params.tokenId + + if (!tokenId || tokenId === '') { + res.status(400) + return res.json({ error: 'tokenId can not be empty' }) + } + + const token = await _this.lookupToken(tokenId) + // console.log(`token: ${JSON.stringify(token, null, 2)}`) + + if (!token || token.id === 'not found' || token.versionType !== 129) { + res.status(400) + return res.json({ error: 'NFT group does not exists' }) + } + + const query = { + v: 3, + q: { + db: ['t'], + aggregate: [ + { $match: { nftParentId: tokenId } }, + { $skip: 0 }, // TODO: pass start point + { $limit: 100 } // TODO: pass count limit + ] + } + } + + const s = JSON.stringify(query) + const b64 = Buffer.from(s).toString('base64') + const url = `${process.env.SLPDB_URL}q/${b64}` + // Request options + const opt = { + method: 'get', + baseURL: url + } + const childrenIds = [] + const childrenRes = await _this.axios.request(opt) + // console.log(`childrenRes.data: ${JSON.stringify(childrenRes.data, null, 2)}`) + if (!childrenRes || !childrenRes.data || !childrenRes.data.t) { + res.status(400) + return res.json({ error: 'No children data in the group' }) + } + + childrenRes.data.t.forEach(function (token) { + // console.log(`info: ${JSON.stringify(token, null, 2)}`) + if ( + token.tokenDetails.versionType === 65 && + token.tokenDetails.transactionType === 'GENESIS' + ) { + childrenIds.push(token.tokenDetails.tokenIdHex) + } + }) + + res.status(200) + return res.json({ nftChildren: childrenIds }) + } catch (err) { + // console.log(err) + wlogger.error('Error in slp.js/getNftChildren().', err) + return _this.errorHandler(err, res) + } + } + + /** + * @api {get} /slp/nftGroup/{tokenId} Get the NFT group for a given NFT child token + * @apiName Get the NFT group for a given NFT child token + * @apiGroup SLP + * @apiDescription Get the NFT group for a given NFT child token + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/slp/nftGroup/45a30085691d6ea586e3ec2aa9122e9b0e0d6c3c1fd357decccc15d8efde48a9" -H "accept:application/json" + * + */ + async getNftGroup (req, res, next) { + try { + // Validate the input data. + const tokenId = req.params.tokenId + + if (!tokenId || tokenId === '') { + res.status(400) + return res.json({ error: 'tokenId can not be empty' }) + } + + const token = await _this.lookupToken(tokenId) + // console.log(`token: ${JSON.stringify(token, null, 2)}`) + + if ( + !token || + token.id === 'not found' || + token.versionType !== 65 || + !token.nftParentId + ) { + res.status(400) + return res.json({ error: 'NFT child does not exists' }) + } + + const parentToken = await _this.lookupToken(token.nftParentId) + // console.log(`parentToken: ${JSON.stringify(token, null, 2)}`) + if ( + !parentToken || + parentToken.id === 'not found' || + parentToken.versionType !== 129 + ) { + res.status(400) + return res.json({ error: 'NFT group does not exists' }) + } + + res.status(200) + return res.json({ nftGroup: parentToken }) + } catch (err) { + // console.log(err) + wlogger.error('Error in slp.js/getNftGroup().', err) + return _this.errorHandler(err, res) + } + } +} + +module.exports = Slp diff --git a/src/routes/v5/util.js b/src/routes/v5/util.js new file mode 100644 index 0000000..e3d20a7 --- /dev/null +++ b/src/routes/v5/util.js @@ -0,0 +1,606 @@ +'use strict' + +const express = require('express') +const router = express.Router() +const axios = require('axios') + +const RouteUtils = require('../../util/route-utils') +const routeUtils = new RouteUtils() + +const wlogger = require('../../util/winston-logging') + +const util = require('util') +util.inspect.defaultOptions = { depth: 1 } + +const BCHJS = require('@psf/bch-js') +const bchjs = new BCHJS() +// const BCHJS_TESTNET = 'https://testnet.bchjs.cash/v4/' + +// const bchjsHTTP = axios.create({ +// baseURL: process.env.RPC_BASEURL +// }) + +// const username = process.env.RPC_USERNAME +// const password = process.env.RPC_PASSWORD + +// const requestConfig = { +// method: 'post', +// auth: { +// username: username, +// password: password +// }, +// data: { +// jsonrpc: '1.0' +// } +// } + +let _this + +class UtilRoute { + constructor (utilConfig) { + this.bchjs = bchjs + // this.blockbook = blockbook + + if (!utilConfig) { + throw new Error( + 'Must pass a config object when instantiating the Util library.' + ) + } + if (!utilConfig.electrumx) { + throw new Error( + 'Must pass an instance of Electrumx when instantiating the Util library.' + ) + } + + this.electrumx = utilConfig.electrumx + + this.router = router + this.router.get('/', this.root) + this.router.get('/validateAddress/:address', this.validateAddressSingle) + this.router.post('/validateAddress', this.validateAddressBulk) + this.router.post('/sweep', this.sweepWif) + + _this = this + } + + root (req, res, next) { + return res.json({ status: 'util' }) + } + + /** + * @api {get} /util/validateAddress/{address} Get information about single bitcoin cash address. + * @apiName Information about single bitcoin cash address + * @apiGroup Util + * @apiDescription Returns information about single bitcoin cash address. + * + * + * @apiExample Example usage: + * curl -X GET "https://api.fullstack.cash/v4/util/validateAddress/bitcoincash:qzs02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c" -H "accept: application/json" + * + * + */ + async validateAddressSingle (req, res, next) { + try { + const address = req.params.address + if (!address || address === '') { + res.status(400) + return res.json({ error: 'address can not be empty' }) + } + + const { + BitboxHTTP, + // username, + // password, + requestConfig + } = routeUtils.setEnvVars() + + requestConfig.data.id = 'validateaddress' + requestConfig.data.method = 'validateaddress' + requestConfig.data.params = [address] + + const response = await BitboxHTTP(requestConfig) + + return res.json(response.data.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 util.ts/validateAddressSingle().', err) + + res.status(500) + return res.json({ error: util.inspect(err) }) + } + } + + /** + * @api {post} /util/validateAddress Get information about bulk bitcoin cash addresses.. + * @apiName Information about bulk bitcoin cash addresses. + * @apiGroup Util + * @apiDescription Returns information about bulk bitcoin cash addresses.. + * + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v4/util/validateAddress" -H "accept: application/json" -H "Content-Type: application/json" -d '{"addresses":["bitcoincash:qzs02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c","bitcoincash:qrehqueqhw629p6e57994436w730t4rzasnly00ht0"]}' + * curl -X POST "https://api.fullstack.cash/v4/util/validateAddress" -H "accept: application/json" -H "Content-Type: application/json" -d '{"addresses":["bitcoincash:qzs02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c","bitcoincash:qrehqueqhw629p6e57994436w730t4rzasnly00ht0"],"from": 1, "to": 5}' + * + * + */ + async validateAddressBulk (req, res, next) { + try { + const addresses = req.body.addresses + + // 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(400) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 + return res.json({ + error: 'Array too large.' + }) + } + + // Validate each element in the array. + for (let i = 0; i < addresses.length; i++) { + const address = addresses[i] + + // Ensure the input is a valid BCH address. + try { + bchjs.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.' + }) + } + } + + wlogger.debug('Executing util/validate with these addresses: ', addresses) + + const { + BitboxHTTP, + // username, + // password, + requestConfig + } = routeUtils.setEnvVars() + + // Loop through each address and creates an array of requests to call in parallel + const promises = addresses.map(async (address) => { + requestConfig.data.id = 'validateaddress' + requestConfig.data.method = 'validateaddress' + requestConfig.data.params = [address] + + return BitboxHTTP(requestConfig) + }) + + // Wait for all parallel Insight requests to return. + const axiosResult = await axios.all(promises) + + // Retrieve the data part of the result. + const result = axiosResult.map((x) => x.data.result) + + 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 util.ts/validateAddressSingle().', err) + + res.status(500) + return res.json({ error: util.inspect(err) }) + } + } + + /** + * @api {post} /util/sweep Sweep BCH and tokens + * @apiName Sweep BCH and tokens from a paper wallet + * @apiGroup Util + * @apiDescription This function can be used to check the BCH balance of a + * paper wallet. It can also be used to sweep BCH and tokens from a paper + * wallet and send them to a destination address. + * + * Note: It does not yet support multiple token classes on the same paper wallet. + * + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v4/util/sweep" -H "accept: application/json" -H "Content-Type: application/json" -d '{"wif":"Kz52sdXLAiKtH82iAFRi6aTZanWtW5Eyv37KWdpY6tTv7pjoHste", "balanceOnly": true}' + * curl -X POST "https://api.fullstack.cash/v4/util/sweep" -H "accept: application/json" -H "Content-Type: application/json" -d '{"wif":"Kz52sdXLAiKtH82iAFRi6aTZanWtW5Eyv37KWdpY6tTv7pjoHste", "toAddr": "bitcoincash:qpt8m4kqu963geedyrur6pdggqmv5kxwnq0rn322qu"}' + * + * + */ + + async sweepWif (req, res, next) { + try { + // Validate input + const wif = req.body.wif + const toAddr = req.body.toAddr + const balanceOnly = req.body.balanceOnly + + if (typeof wif !== 'string' || wif.length !== 52) { + res.status(400) + return res.json({ + error: 'WIF needs to a proper compressed WIF starting with K or L' + }) + } + + if (!balanceOnly) { + // Only throw error if balanceOnly is false or undefined. + if (!toAddr || toAddr === '') { + res.status(400) + return res.json({ error: 'address can not be empty' }) + } + } + + wlogger.debug('Executing util/sweepWif with this address: ', toAddr) + + // Generate a private and public key pair from the WIF. + const ecPair = bchjs.ECPair.fromWIF(wif) + const fromAddr = bchjs.ECPair.toCashAddress(ecPair) + + // Get a balance on the public address + const balances = await _this.electrumx._balanceFromElectrumx(fromAddr) + // console.log(`balances: ${JSON.stringify(balances, null, 2)}`) + + // Total balance is the sum of the confirmed and unconfirmed balance. + const totalBalance = balances.confirmed + balances.unconfirmed + + // Exit if balance is zero. + if (isNaN(totalBalance) || totalBalance === 0) { + res.status(422) + return res.json({ error: 'No balance found at BCH address.' }) + } + + // Exit if this is a balance-only call. + if (balanceOnly) { + res.status(200) + return res.json(totalBalance) + } + + // Get all UTXOs help by the address. + const utxos = await _this.electrumx._utxosFromElectrumx(fromAddr) + // console.log(`utxos: ${JSON.stringify(utxos, null, 2)}`) + + const tokenUtxos = [] + const bchUtxos = [] + + // Exit if there are no UTXOs. + if (utxos.length === 0) { + res.status(422) + return res.json({ error: 'No utxos found.' }) + } + + // Figure out which UTXOs are associated with SLP tokens. + const isTokenUtxo = await _this.bchjs.SLP.Utils.tokenUtxoDetails(utxos) + // console.log(`isTokenUtxo: ${JSON.stringify(isTokenUtxo, null, 2)}`) + + // Separate the bch and token UTXOs. + for (let i = 0; i < utxos.length; i++) { + // Filter based on isTokenUtxo. + if (!isTokenUtxo[i]) bchUtxos.push(utxos[i]) + else tokenUtxos.push(isTokenUtxo[i]) + } + // console.log( + // `bchUtxos.length: ${bchUtxos.length}, tokenUtxos.length: ${tokenUtxos.length}` + // ) + + // Throw error if no BCH to move tokens. + if (bchUtxos.length === 0 && tokenUtxos.length > 0) { + res.status(422) + return res.json({ + error: + 'Tokens found, but no BCH UTXOs found. Add BCH to wallet to move tokens.' + }) + } + + // console.log(`tokenUtxos: ${JSON.stringify(tokenUtxos, null, 2)}`) + + const options = { + ecPair, + utxos, + fromAddr, + toAddr, + bchUtxos, + tokenUtxos + } + + let hex + + // Choose the sweeping algorithm, based on if there are tokens or not. + if (tokenUtxos.length === 0) hex = await _this._sweepBCH(options) + else hex = await _this._sweepTokens(options, bchUtxos, tokenUtxos) + // console.log(`hex: ${hex}`) + + // Throw error if there is more than one token class. + + // Generate a transaction to move tokens and BCH. + + // Broadcast the transaction. + const txid = _this.bchjs.RawTransactions.sendRawTransaction([hex]) + + res.status(200) + return res.json(txid) + } catch (err) { + // Attempt to decode the error message. + const { msg, status } = routeUtils.decodeError(err) + if (msg) { + res.status(status) + return res.json({ error: msg }) + } + + // Catch the specific case of multiple tokens. + if ( + err.message && + err.message.indexOf('Multiple token classes detected') > -1 + ) { + res.status(422) + return res.json({ error: err.message }) + } + + wlogger.error('Error in util.js/sweepWif().', err) + console.error('Error in util.js/sweepWif().', err) + + res.status(500) + return res.json({ error: err.message }) + } + } + + // Sweep BCH only from a private WIF. + async _sweepBCH (options) { + try { + // const wif = flags.wif + // const toAddr = flags.address + + const ecPair = options.ecPair + const toAddr = options.toAddr + + // const fromAddr = this.BITBOX.ECPair.toCashAddress(ecPair) + // + // // Get the UTXOs for that address. + // let utxos = await this.BITBOX.Blockbook.utxo(fromAddr) + // console.log(`utxos: ${JSON.stringify(utxos, null, 2)}`) + + let utxos = options.utxos + + // Ensure all utxos have the satoshis property. + utxos = utxos.map((x) => { + x.satoshis = Number(x.value) + return x + }) + // console.log(`utxos: ${JSON.stringify(utxos, null, 2)}`) + + // instance of transaction builder + let transactionBuilder + if (options.testnet) { + transactionBuilder = new _this.bchjs.TransactionBuilder('testnet') + } else transactionBuilder = new _this.bchjs.TransactionBuilder() + + let originalAmount = 0 + + // Loop through all UTXOs. + for (let i = 0; i < utxos.length; i++) { + const utxo = utxos[i] + + originalAmount = originalAmount + utxo.value + + transactionBuilder.addInput(utxo.tx_hash, utxo.tx_pos) + } + + if (originalAmount < 546) { + throw new Error( + 'Original amount less than the dust limit. Not enough BCH to send.' + ) + } + + // get byte count to calculate fee. paying 1 sat/byte + const byteCount = _this.bchjs.BitcoinCash.getByteCount( + { P2PKH: utxos.length }, + { P2PKH: 1 } + ) + const fee = Math.ceil(1.1 * byteCount) + + // amount to send to receiver. It's the original amount - 1 sat/byte for tx size + const sendAmount = originalAmount - fee + + // add output w/ address and amount to send + transactionBuilder.addOutput( + _this.bchjs.Address.toLegacyAddress(toAddr), + sendAmount + ) + + // Loop through each input and sign + let redeemScript + for (var i = 0; i < utxos.length; i++) { + const utxo = utxos[i] + + transactionBuilder.sign( + i, + ecPair, + redeemScript, + transactionBuilder.hashTypes.SIGHASH_ALL, + utxo.value + ) + } + + // build tx + const tx = transactionBuilder.build() + + // output rawhex + const hex = tx.toHex() + return hex + } catch (err) { + wlogger.error('Error in util.js/sweepBCH().') + throw err + } + } + + // Sweep BCH and tokens from a WIF. + async _sweepTokens (options) { + try { + // const { ecPair, utxos, fromAddr, toAddr, bchUtxos, tokenUtxos } = options + const { ecPair, utxos, toAddr, bchUtxos, tokenUtxos } = options + + // Input validation + if (!Array.isArray(bchUtxos) || bchUtxos.length === 0) { + throw new Error('bchUtxos need to be an array with one UTXO.') + } + if (!Array.isArray(tokenUtxos) || tokenUtxos.length === 0) { + throw new Error('tokenUtxos need to be an array with one UTXO.') + } + + // if (flags.testnet) + // this.BITBOX = new config.BCHLIB({ restURL: config.TESTNET_REST }) + + // console.log(`tokenUtxos: ${JSON.stringify(tokenUtxos, null, 2)}`) + + // Ensure there is only one class of token in the wallet. Throw an error if + // there is more than one. + const tokenId = tokenUtxos[0].tokenId + const otherTokens = tokenUtxos.filter((x) => x.tokenId !== tokenId) + if (otherTokens.length > 0) { + throw new Error( + 'Multiple token classes detected. This function only supports a single class of token.' + ) + } + + // instance of transaction builder + let transactionBuilder + if (options.testnet) { + transactionBuilder = new _this.bchjs.TransactionBuilder('testnet') + } else transactionBuilder = new _this.bchjs.TransactionBuilder() + + // Combine all the UTXOs into a single array. + const allUtxos = utxos + // console.log(`allUtxos: ${JSON.stringify(allUtxos, null, 2)}`) + + // Loop through all UTXOs. + let originalAmount = 0 + for (let i = 0; i < allUtxos.length; i++) { + const utxo = allUtxos[i] + + originalAmount = originalAmount + utxo.value + + transactionBuilder.addInput(utxo.tx_hash, utxo.tx_pos) + } + + if (originalAmount < 300) { + throw new Error( + 'Not enough BCH to send. Send more BCH to the wallet to pay miner fees.' + ) + } + + // get byte count to calculate fee. paying 1 sat + // Note: This may not be totally accurate. Just guessing on the byteCount size. + // const byteCount = this.BITBOX.BitcoinCash.getByteCount( + // { P2PKH: 3 }, + // { P2PKH: 5 } + // ) + // //console.log(`byteCount: ${byteCount}`) + // const satoshisPerByte = 1.1 + // const txFee = Math.floor(satoshisPerByte * byteCount) + // console.log(`txFee: ${txFee} satoshis\n`) + const txFee = 500 + + // amount to send back to the sending address. It's the original amount - 1 sat/byte for tx size + const remainder = originalAmount - txFee - 546 + if (remainder < 1) { + throw new Error('Selected UTXO does not have enough satoshis') + } + // console.log(`remainder: ${remainder}`) + + // Tally up the quantity of tokens + let tokenQty = 0 + for (let i = 0; i < tokenUtxos.length; i++) { + tokenQty += tokenUtxos[i].tokenQty + } + // console.log(`tokenQty: ${tokenQty}`) + + // Generate the OP_RETURN entry for an SLP SEND transaction. + // console.log(`Generating op-return.`) + const { + script, + outputs + } = _this.bchjs.SLP.TokenType1.generateSendOpReturn(tokenUtxos, tokenQty) + // console.log(`token outputs: ${outputs}`) + + // Since we are sweeping all tokens from the WIF, there generateOpReturn() + // function should only compute 1 token output. If it returns 2, then there + // is something unexpected happening. + if (outputs > 1) { + throw new Error( + 'More than one class of token detected. Sweep feature not supported.' + ) + } + + // Add OP_RETURN as first output. + const data = _this.bchjs.Script.encode(script) + transactionBuilder.addOutput(data, 0) + + // Send dust transaction representing tokens being sent. + transactionBuilder.addOutput( + _this.bchjs.Address.toLegacyAddress(toAddr), + 546 + ) + + // Last output: send remaining BCH + transactionBuilder.addOutput( + _this.bchjs.Address.toLegacyAddress(toAddr), + remainder + ) + // console.log(`utxo: ${JSON.stringify(utxo, null, 2)}`) + + // Sign each UTXO being consumed. + let redeemScript + for (let i = 0; i < allUtxos.length; i++) { + const thisUtxo = allUtxos[i] + // console.log(`thisUtxo: ${JSON.stringify(thisUtxo, null, 2)}`) + + transactionBuilder.sign( + i, + ecPair, + redeemScript, + transactionBuilder.hashTypes.SIGHASH_ALL, + thisUtxo.value + ) + } + + // build tx + const tx = transactionBuilder.build() + + // output rawhex + const hex = tx.toHex() + // console.log(`Transaction raw hex: `) + // console.log(hex) + + return hex + } catch (err) { + wlogger.error('Error in util.js/sweepBCH().') + throw err + } + } +} + +module.exports = UtilRoute diff --git a/src/routes/v5/xpub.js b/src/routes/v5/xpub.js new file mode 100644 index 0000000..e3410c9 --- /dev/null +++ b/src/routes/v5/xpub.js @@ -0,0 +1,82 @@ +/* + xpub route +*/ + +'use strict' + +const express = require('express') + +const RouteUtils = require('../../util/route-utils') +const routeUtils = new RouteUtils() + +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 BCHJS = require('@psf/bch-js') +const bchjs = new BCHJS() + +// Connect the route endpoints to their handler functions. +router.get('/', root) +router.get('/fromXPub/:xpub', fromXPubSingle) + +// Root API endpoint. Simply acknowledges that it exists. +function root (req, res, next) { + return res.json({ status: 'address' }) +} + +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 = bchjs.Address.fromXPub(xpub, hdPath) + const legacyAddr = bchjs.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. + wlogger.error('Error in address.ts/fromXPubSingle().', err) + + res.status(500) + return res.json({ error: util.inspect(err) }) + } +} + +module.exports = { + router, + testableComponents: { + root, + fromXPubSingle + } +} diff --git a/test/v5/a01-electrumx.js b/test/v5/a01-electrumx.js new file mode 100644 index 0000000..65119cf --- /dev/null +++ b/test/v5/a01-electrumx.js @@ -0,0 +1,1894 @@ +/* + TESTS FOR THE ELECTRUMX.JS LIBRARY + + Named with a01 prefix so that these tests are run first. Something about running + the Blcokbook and Blockchain tests screws up these tests. Spent a couple hours + debugging and couldn't isolate the source of the issue, but renaming the file + was an easy fix. + + 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: +*/ + +'use strict' + +const chai = require('chai') +const assert = chai.assert + +const sinon = require('sinon') + +const ElecrumxRoute = require('../../src/routes/v4/electrumx') + +// Mocking data. +const { mockReq, mockRes } = require('./mocks/express-mocks') +const mockData = require('./mocks/electrumx-mock') + +// Used for debugging. +const util = require('util') +util.inspect.defaultOptions = { depth: 1 } + +// A wrapper for asserting that the correct response is returned when an error +// is expected. +function expectRouteError (res, result, expectedError, code = 400) { + assert.equal(res.statusCode, code, `HTTP status code ${code} expected.`) + + assert.property(result, 'error') + assert.include(result.error, expectedError) + + assert.property(result, 'success') + assert.equal(result.success, false) +} + +describe('#Electrumx', () => { + let req, res + let sandbox + const electrumxRoute = new ElecrumxRoute() + + before(async () => { + if (!process.env.TEST) process.env.TEST = 'unit' + console.log(`Testing type is: ${process.env.TEST}`) + + if (!process.env.NETWORK) process.env.NETWORK = 'testnet' + + // Connect to electrumx servers if this is an integration test. + if (process.env.TEST === 'integration') { + await electrumxRoute.connect() + console.log('Connected to ElectrumX server') + } + }) + + after(async () => { + // console.log(`electrumxRoute.electrumx: `, electrumxRoute.electrumx) + + // Disconnect from the electrumx server if this is an integration test. + if (process.env.TEST === 'integration') { + await electrumxRoute.disconnect() + console.log('Disconnected from ElectrumX server') + } + }) + + // 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 = {} + + sandbox = sinon.createSandbox() + + // electrumxRoute = new ElecrumxRoute() + }) + + afterEach(() => { + sandbox.restore() + }) + + after(() => { + // + }) + + // A wrapper for stubbing with the Sinon sandbox. + function stubMethodForUnitTests (obj, method, value) { + if (process.env.TEST !== 'unit') return false + + electrumxRoute.isReady = true // Force flag. + + sandbox.stub(obj, method).resolves(value) + + return true + } + + describe('#root', () => { + // root route handler. + const root = electrumxRoute.root + + it('should respond to GET for base route', async () => { + const result = root(req, res) + + assert.equal(result.status, 'electrumx', 'Returns static string') + }) + }) + + describe('#addressToScripthash', () => { + it('should accurately return a scripthash for a P2PKH address', () => { + const addr = 'bitcoincash:qpr270a5sxphltdmggtj07v4nskn9gmg9yx4m5h7s4' + + const scripthash = electrumxRoute.addressToScripthash(addr) + + const expectedOutput = + 'bce4d5f2803bd1ed7c1ba00dcb3edffcbba50524af7c879d6bb918d04f138965' + + assert.equal(scripthash, expectedOutput) + }) + + it('should accurately return a scripthash for a P2SH address', () => { + const addr = 'bitcoincash:pz0z7u9p96h2p6hfychxdrmwgdlzpk5luc5yks2wxq' + + const scripthash = electrumxRoute.addressToScripthash(addr) + + const expectedOutput = + '8bc2235c8e7d5634d9ec429fc0171f2c58e728d4f1e2fb7e440e313133cfa4f0' + + assert.equal(scripthash, expectedOutput) + }) + }) + + describe('#_utxosFromElectrumx', () => { + it('should throw error for invalid address', async () => { + try { + // Address has invalid checksum. + const address = 'bitcoincash:qr69kyzha07dcecrsvjwsj4s6slnlq4r8c30lxnur2' + + // Call the details API. + await electrumxRoute._utxosFromElectrumx(address) + + assert.equal(true, false, 'Unexpected code path') + } catch (err) { + assert.include(err.message, 'Invalid checksum') + } + }) + + it('should return empty array for address with no utxos', async () => { + // Address has invalid checksum. + const address = 'bchtest:qqtmlpspjakqlvywae226esrcdrj9auynuwadh55uf' + + // Mock unit tests to prevent live network calls. + if (process.env.TEST === 'unit') { + electrumxRoute.isReady = true // Force flag. + + sandbox.stub(electrumxRoute.electrumx, 'request').resolves([]) + } + + // Call the details API. + const result = await electrumxRoute._utxosFromElectrumx(address) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isArray(result) + assert.equal(result.length, 0) + }) + + it('should get balance for a single address', async () => { + const address = 'bitcoincash:qp3sn6vlwz28ntmf3wmyra7jqttfx7z6zgtkygjhc7' + + // Mock unit tests to prevent live network calls. + if (process.env.TEST === 'unit') { + electrumxRoute.isReady = true // Force flag. + + sandbox + .stub(electrumxRoute.electrumx, 'request') + .resolves(mockData.utxos) + } + + // Call the details API. + const result = await electrumxRoute._utxosFromElectrumx(address) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isArray(result) + assert.property(result[0], 'height') + assert.property(result[0], 'tx_hash') + assert.property(result[0], 'tx_pos') + assert.property(result[0], 'value') + }) + }) + + describe('#getUtxos', () => { + it('should throw 422 if address is empty', async () => { + const result = await electrumxRoute.getUtxos(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 422, 'Expect 422 status code') + + assert.property(result, 'error') + assert.include(result.error, 'Unsupported address format') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + it('should throw 400 on array input', async () => { + req.params.address = ['qzs02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c'] + + const result = await electrumxRoute.getUtxos(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 400, 'Expect 400 status code') + + assert.property(result, 'error') + assert.include(result.error, 'address can not be an array') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + it('should throw an error for an invalid address', async () => { + req.params.address = '02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c' + + const result = await electrumxRoute.getUtxos(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 422, 'Expect 422 status code') + + assert.property(result, 'error') + assert.include(result.error, 'Unsupported address format') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + it('should detect a network mismatch', async () => { + req.params.address = 'bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4' + + const result = await electrumxRoute.getUtxos(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 400, 'Expect 400 status code') + + assert.property(result, 'error') + assert.include(result.error, 'Invalid network', 'Proper error message') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + it('should pass errors from ElectrumX to user', async () => { + // Address has invalid checksum. + req.params.address = + 'bitcoincash:qr69kyzha07dcecrsvjwsj4s6slnlq4r8c30lxnur2' + + // Mock unit tests to prevent live network calls. + if (process.env.TEST === 'unit') { + electrumxRoute.isReady = true // Force flag. + + sandbox + .stub(electrumxRoute.electrumx, 'request') + .resolves(mockData.utxos) + } + + // Call the details API. + const result = await electrumxRoute.getUtxos(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'success') + assert.equal(result.success, false) + + assert.property(result, 'error') + assert.include(result.error, 'Unsupported address format') + }) + + it('should get balance for a single address', async () => { + req.params.address = + 'bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf' + + // Mock unit tests to prevent live network calls. + if (process.env.TEST === 'unit') { + electrumxRoute.isReady = true // Force flag. + + sandbox + .stub(electrumxRoute, '_utxosFromElectrumx') + .resolves(mockData.utxos) + } + + // Call the details API. + const result = await electrumxRoute.getUtxos(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.property(result, 'utxos') + assert.isArray(result.utxos) + + assert.property(result.utxos[0], 'height') + assert.property(result.utxos[0], 'tx_hash') + assert.property(result.utxos[0], 'tx_pos') + assert.property(result.utxos[0], 'value') + }) + }) + + describe('#utxosBulk', () => { + it('should throw an error for an empty body', async () => { + req.body = {} + + const result = await electrumxRoute.utxosBulk(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 electrumxRoute.utxosBulk(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 electrumxRoute.utxosBulk(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 electrumxRoute.utxosBulk(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: ['bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4'] + } + + const result = await electrumxRoute.utxosBulk(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 get details for a single address', async () => { + req.body = { + addresses: ['bitcoincash:qp3sn6vlwz28ntmf3wmyra7jqttfx7z6zgtkygjhc7'] + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === 'unit') { + electrumxRoute.isReady = true // Force flag. + + sandbox + .stub(electrumxRoute, '_utxosFromElectrumx') + .resolves(mockData.utxos) + } + + // Call the details API. + const result = await electrumxRoute.utxosBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.property(result, 'utxos') + assert.isArray(result.utxos) + + assert.property(result.utxos[0], 'address') + assert.property(result.utxos[0], 'utxos') + + assert.isArray(result.utxos[0].utxos) + assert.property(result.utxos[0].utxos[0], 'height') + assert.property(result.utxos[0].utxos[0], 'tx_hash') + assert.property(result.utxos[0].utxos[0], 'tx_pos') + assert.property(result.utxos[0].utxos[0], 'value') + }) + + it('should get utxos for multiple addresses', async () => { + req.body = { + addresses: [ + 'bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf', + 'bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf' + ] + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === 'unit') { + electrumxRoute.isReady = true // Force flag. + + sandbox + .stub(electrumxRoute, '_utxosFromElectrumx') + .resolves(mockData.utxos) + } + + // Call the details API. + const result = await electrumxRoute.utxosBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.isArray(result.utxos) + assert.isArray(result.utxos[0].utxos) + assert.equal(result.utxos.length, 2, '2 outputs for 2 inputs') + }) + }) + + describe('#_transactionDetailsFromElectrum', () => { + it('should return error object for invalid txid', async () => { + const txid = + '4db095f34d632a4daf942142c291f1f2abb5ba2e1ccac919d85bdc2f671fb25' + + stubMethodForUnitTests( + electrumxRoute.electrumx, + 'request', + new Error('Invalid tx hash') + ) + + const result = await electrumxRoute._transactionDetailsFromElectrum(txid) + + assert.instanceOf(result, Error) + assert.include(result.message, 'Invalid tx hash') + }) + + it('should get details for a single txid', async () => { + const txid = + '4db095f34d632a4daf942142c291f1f2abb5ba2e1ccac919d85bdc2f671fb251' + + stubMethodForUnitTests( + electrumxRoute.electrumx, + 'request', + mockData.txDetails + ) + + const result = await electrumxRoute._transactionDetailsFromElectrum(txid) + + assert.isObject(result) + assert.property(result, 'blockhash') + assert.property(result, 'hash') + assert.property(result, 'hex') + assert.property(result, 'vin') + assert.property(result, 'vout') + assert.equal(result.hash, txid) + }) + }) + + describe('#getTransactionDetails', () => { + it('should throw 400 if txid is not a string', async () => { + req.params.txid = 5 + + const result = await electrumxRoute.getTransactionDetails(req, res) + + expectRouteError(res, result, 'txid must be a string') + }) + + it('should throw 400 on array input', async () => { + req.params.txid = [ + '4db095f34d632a4daf942142c291f1f2abb5ba2e1ccac919d85bdc2f671fb251' + ] + + const result = await electrumxRoute.getTransactionDetails(req, res) + + expectRouteError(res, result, 'txid must be a string') + }) + + it('should return error object for invalid txid', async () => { + req.params.txid = + '4db095f34d632a4daf942142c291f1f2abb5ba2e1ccac919d85bdc2f671fb25' + + stubMethodForUnitTests( + electrumxRoute.electrumx, + 'request', + new Error('Invalid tx hash') + ) + + // Call the details API. + const result = await electrumxRoute.getTransactionDetails(req, res) + + expectRouteError(res, result, 'Invalid tx hash') + }) + + it('should get details for a single txid', async () => { + req.params.txid = + '4db095f34d632a4daf942142c291f1f2abb5ba2e1ccac919d85bdc2f671fb251' + + stubMethodForUnitTests( + electrumxRoute.electrumx, + 'request', + mockData.txDetails + ) + + // Call the details API. + const result = await electrumxRoute.getTransactionDetails(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.property(result, 'details') + assert.isObject(result.details) + + assert.property(result.details, 'blockhash') + assert.property(result.details, 'hash') + assert.property(result.details, 'hex') + assert.property(result.details, 'vin') + assert.property(result.details, 'vout') + assert.equal(result.details.hash, req.params.txid) + }) + }) + + describe('#transactionDetailsBulk', () => { + it('should throw an error for an empty body', async () => { + req.body = {} + + const result = await electrumxRoute.transactionDetailsBulk(req, res) + + expectRouteError(res, result, 'txids needs to be an array') + }) + + it('should error on non-array single txid', async () => { + req.body = { + txid: '4db095f34d632a4daf942142c291f1f2abb5ba2e1ccac919d85bdc2f671fb251' + } + + const result = await electrumxRoute.transactionDetailsBulk(req, res) + + expectRouteError(res, result, 'txids needs to be an array') + }) + + it('should NOT throw 400 error for an invalid txid', async () => { + req.body = { + txids: [ + '4db095f34d632a4daf942142c291f1f2abb5ba2e1ccac919d85bdc2f671fb25' + ] + } + + stubMethodForUnitTests( + electrumxRoute.electrumx, + 'request', + mockData.txDetails + ) + + const result = await electrumxRoute.transactionDetailsBulk(req, res) + + // This should probably throw a 400 error, but to be consistent with the other + // bulk endpoints it doesn't throw. This will change in the future + // expectRouteError(res, result, 'Invalid tx hash') + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.property(result, 'transactions') + assert.isArray(result.transactions) + }) + + it('should throw 400 error if txid array is too large', async () => { + const testArray = [] + for (var i = 0; i < 25; i++) testArray.push('') + + req.body.txids = testArray + + const result = await electrumxRoute.transactionDetailsBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + expectRouteError(res, result, 'Array too large', 400) + }) + + it('should get details for a single txid', async () => { + req.body = { + txids: [ + '4db095f34d632a4daf942142c291f1f2abb5ba2e1ccac919d85bdc2f671fb251' + ] + } + + stubMethodForUnitTests( + electrumxRoute.electrumx, + 'request', + mockData.txDetails + ) + + // Call the details API. + const result = await electrumxRoute.transactionDetailsBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.property(result, 'transactions') + assert.isArray(result.transactions) + + assert.property(result.transactions[0], 'txid') + assert.property(result.transactions[0], 'details') + + assert.property(result.transactions[0].details, 'blockhash') + assert.property(result.transactions[0].details, 'hash') + assert.property(result.transactions[0].details, 'hex') + assert.property(result.transactions[0].details, 'vin') + assert.property(result.transactions[0].details, 'vout') + }) + + it('should get details for multiple txids', async () => { + req.body = { + txids: [ + '4db095f34d632a4daf942142c291f1f2abb5ba2e1ccac919d85bdc2f671fb251', + '4db095f34d632a4daf942142c291f1f2abb5ba2e1ccac919d85bdc2f671fb251' + ] + } + + stubMethodForUnitTests( + electrumxRoute.electrumx, + 'request', + mockData.txDetails + ) + + // Call the details API. + const result = await electrumxRoute.transactionDetailsBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`)' + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.isArray(result.transactions) + assert.isObject(result.transactions[0].details) + assert.equal(result.transactions.length, 2, '2 outputs for 2 inputs') + }) + }) + + describe('#_blockHeadersFromElectrum', () => { + it('should return error object for invalid block height', async () => { + const height = -10 + + stubMethodForUnitTests( + electrumxRoute.electrumx, + 'request', + new Error('Invalid height') + ) + + const result = await electrumxRoute._blockHeadersFromElectrum(height, 2) + + assert.instanceOf(result, Error) + assert.include(result.message, 'Invalid height') + }) + + it('should return error object for invalid count', async () => { + const height = 42 + + stubMethodForUnitTests( + electrumxRoute.electrumx, + 'request', + new Error('Invalid count') + ) + + const result = await electrumxRoute._blockHeadersFromElectrum(height, -1) + + assert.instanceOf(result, Error) + assert.include(result.message, 'Invalid count') + }) + + it('should get block header for a single block height', async () => { + const height = 42 + + const mockedResponse = { count: 2, hex: mockData.blockHeaders.join(''), max: 2016 } + + stubMethodForUnitTests( + electrumxRoute.electrumx, + 'request', + mockedResponse + ) + + const result = await electrumxRoute._blockHeadersFromElectrum(height, 2) + + assert.isArray(result) + assert.deepEqual(result, mockData.blockHeaders) + }) + }) + + describe('#getBlockheaders', () => { + it('should throw 400 if height is not a number', async () => { + req.params.height = 'Hello' + + const result = await electrumxRoute.getBlockHeaders(req, res) + + expectRouteError(res, result, 'height must be a positive number') + }) + + it('should throw 400 if height is negative', async () => { + req.params.height = -42 + + const result = await electrumxRoute.getBlockHeaders(req, res) + + expectRouteError(res, result, 'height must be a positive number') + }) + + it('should throw 400 if count is not a number', async () => { + req.params.height = 42 + req.query.count = 'Hello' + + const result = await electrumxRoute.getBlockHeaders(req, res) + + expectRouteError(res, result, 'count must be a positive number') + }) + + it('should throw 400 if count is negative', async () => { + req.params.height = 42 + req.query.count = -10 + + const result = await electrumxRoute.getBlockHeaders(req, res) + + expectRouteError(res, result, 'count must be a positive number') + }) + + it('should throw 400 on array input', async () => { + req.params.height = [42, 42] + + const result = await electrumxRoute.getBlockHeaders(req, res) + + expectRouteError(res, result, 'height must be a positive number') + }) + + it('should return error object for invalid height', async () => { + req.params.height = 1000000000 + + stubMethodForUnitTests( + electrumxRoute.electrumx, + 'request', + new Error('Invalid height') + ) + + // Call the details API. + const result = await electrumxRoute.getBlockHeaders(req, res) + + expectRouteError(res, result, 'Invalid height') + }) + + it('should get headers for a single block height with count 2', async () => { + req.params.height = 42 + req.query.count = 2 + + stubMethodForUnitTests( + electrumxRoute, + '_blockHeadersFromElectrum', + mockData.blockHeaders + ) + + // Call the details API. + const result = await electrumxRoute.getBlockHeaders(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.property(result, 'headers') + assert.isArray(result.headers) + assert.deepEqual(result.headers, mockData.blockHeaders) + }) + }) + + describe('#blockHeadersBulk', () => { + it('should throw an error for an empty body', async () => { + req.body = {} + + const result = await electrumxRoute.blockHeadersBulk(req, res) + + expectRouteError(res, result, 'heights needs to be an array') + }) + + it('should error on non-array single height', async () => { + req.body = { + heights: 42 + } + + const result = await electrumxRoute.blockHeadersBulk(req, res) + + expectRouteError(res, result, 'heights needs to be an array') + }) + + it('should NOT throw 400 error for an invalid height', async () => { + req.body = { + heights: [ + { height: -10, count: 2 } + ] + } + + stubMethodForUnitTests( + electrumxRoute, + '_blockHeadersFromElectrum', + mockData.blockHeaders + ) + + const result = await electrumxRoute.blockHeadersBulk(req, res) + + // This should probably throw a 400 error, but to be consistent with the other + // bulk endpoints it doesn't throw. This will change in the future + // expectRouteError(res, result, 'Invalid tx hash') + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.property(result, 'headers') + assert.isArray(result.headers) + }) + + it('should throw 400 error if heights array is too large', async () => { + const testArray = [] + for (var i = 0; i < 25; i++) testArray.push('') + + req.body.heights = testArray + + const result = await electrumxRoute.blockHeadersBulk(req, res) + + expectRouteError(res, result, 'Array too large', 400) + }) + + it('should get details for a single height', async () => { + req.body = { + heights: [ + { height: 42, count: 2 } + ] + } + + stubMethodForUnitTests( + electrumxRoute, + '_blockHeadersFromElectrum', + mockData.blockHeaders + ) + + // Call the details API. + const result = await electrumxRoute.blockHeadersBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.property(result, 'headers') + assert.isArray(result.headers) + + assert.property(result.headers[0], 'headers') + assert.isArray(result.headers[0].headers) + }) + + it('should get details for multiple txids', async () => { + req.body = { + heights: [ + { height: 42, count: 2 }, + { height: 42, count: 2 } + ] + } + + stubMethodForUnitTests( + electrumxRoute, + '_blockHeadersFromElectrum', + mockData.blockHeaders + ) + + // Call the details API. + const result = await electrumxRoute.blockHeadersBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`)' + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.isArray(result.headers) + assert.isArray(result.headers[0].headers) + assert.equal(result.headers.length, 2, '2 outputs for 2 inputs') + }) + }) + + describe('#_broadcastTransactionWithElectrum', () => { + it('should return error object for invalid formatted transaction', async () => { + const invalidHex = mockData.txDetails.hex.substring(10) + + stubMethodForUnitTests( + electrumxRoute.electrumx, + 'request', + new Error('Error: the transaction was rejected by network rules.\n\nTX decode failed\n') + ) + + const result = await electrumxRoute._broadcastTransactionWithElectrum(invalidHex) + + assert.instanceOf(result, Error) + assert.include(result.message, 'TX decode failed') + }) + + it('should return txid for valid transaction', async function () { + // We cannot send an actual broadcast transaction to mainnet + if (process.env.TEST !== 'unit') return this.skip() + + const validHex = mockData.txDetails.hex + + stubMethodForUnitTests( + electrumxRoute.electrumx, + 'request', + mockData.txDetails.hash + ) + + const result = await electrumxRoute._broadcastTransactionWithElectrum(validHex) + + assert.typeOf(result, 'string') + assert.equal(result, mockData.txDetails.hash) + }) + }) + + describe('#broadcastTransaction', () => { + it('should throw an error for a non-string', async () => { + req.body = 456 + + stubMethodForUnitTests( + electrumxRoute, + '_broadcastTransactionWithElectrum', + new Error('request body must be a string.') + ) + + const result = await electrumxRoute.broadcastTransaction(req, res) + + expectRouteError(res, result, 'request body must be a string.') + }) + + it('should throw an error object for invalid formatted transaction', async () => { + req.body.txHex = mockData.txDetails.hex.substring(10) + + stubMethodForUnitTests( + electrumxRoute, + '_broadcastTransactionWithElectrum', + new Error('Error: the transaction was rejected by network rules.\n\nTX decode failed\n') + ) + + const result = await electrumxRoute.broadcastTransaction(req, res) + + expectRouteError(res, result, 'TX decode failed') + }) + + it('should return txid for valid transaction', async function () { + // We cannot send an actual broadcast transaction to mainnet + if (process.env.TEST !== 'unit') return this.skip() + + req.body.txHex = mockData.txDetails.hex + + stubMethodForUnitTests( + electrumxRoute, + '_broadcastTransactionWithElectrum', + mockData.txDetails.hash + ) + + const result = await electrumxRoute.broadcastTransaction(req, res) + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.property(result, 'txid') + assert.equal(result.txid, mockData.txDetails.hash) + }) + }) + + describe('#_balanceFromElectrumx', () => { + it('should throw error for invalid address', async () => { + try { + // Address has invalid checksum. + const address = 'bitcoincash:qr69kyzha07dcecrsvjwsj4s6slnlq4r8c30lxnur2' + + // Mock unit tests to prevent live network calls. + // if (process.env.TEST === 'unit') { + // electrumxRoute.isReady = true // Force flag. + // + // sandbox + // .stub(electrumxRoute.electrumx, 'request') + // .throws('Invalid Argument: Invalid checksum:') + // } + + // Call the details API. + await electrumxRoute._balanceFromElectrumx(address) + + assert.equal(true, false, 'Unexpected code path') + } catch (err) { + // console.log('err2: ', err) + assert.include(err.message, 'Invalid checksum') + } + }) + + it('should get balance for a single address', async () => { + const address = 'bitcoincash:qp3sn6vlwz28ntmf3wmyra7jqttfx7z6zgtkygjhc7' + + // Mock unit tests to prevent live network calls. + if (process.env.TEST === 'unit') { + electrumxRoute.isReady = true // Force flag. + + sandbox + .stub(electrumxRoute.electrumx, 'request') + .resolves(mockData.balance) + } + + // Call the details API. + const result = await electrumxRoute._balanceFromElectrumx(address) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'confirmed') + assert.property(result, 'unconfirmed') + }) + + it('should get balance for an address with no transaction history', async () => { + const address = 'bitcoincash:qp2ew6pvrs22jtsvtjyumjgas6jkvgn2hy3ad4wpw8' + + // Mock unit tests to prevent live network calls. + if (process.env.TEST === 'unit') { + electrumxRoute.isReady = true // Force flag. + + sandbox + .stub(electrumxRoute.electrumx, 'request') + .resolves(mockData.balance) + } + + // Call the details API. + const result = await electrumxRoute._balanceFromElectrumx(address) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'confirmed') + assert.property(result, 'unconfirmed') + }) + }) + + describe('#getBalance', () => { + it('should throw 400 if address is empty', async () => { + const result = await electrumxRoute.getBalance(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 422, 'Expect 422 status code') + + assert.property(result, 'error') + assert.include(result.error, 'Unsupported address format') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + it('should throw 400 on array input', async () => { + req.params.address = ['qzs02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c'] + + const result = await electrumxRoute.getBalance(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 400, 'Expect 400 status code') + + assert.property(result, 'error') + assert.include(result.error, 'address can not be an array') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + it('should throw an error for an invalid address', async () => { + req.params.address = '02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c' + + const result = await electrumxRoute.getBalance(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 422, 'Expect 422 status code') + + assert.property(result, 'error') + assert.include(result.error, 'Unsupported address format') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + it('should detect a network mismatch', async () => { + req.params.address = 'bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4' + + const result = await electrumxRoute.getBalance(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 400, 'Expect 400 status code') + + assert.property(result, 'error') + assert.include(result.error, 'Invalid network', 'Proper error message') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + it('should pass errors from electrum-cash to user', async () => { + // Address has invalid checksum. + req.params.address = + 'bitcoincash:qr69kyzha07dcecrsvjwsj4s6slnlq4r8c30lxnur2' + + // Call the details API. + const result = await electrumxRoute.getBalance(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'success') + assert.equal(result.success, false) + + assert.property(result, 'error') + assert.include(result.error, 'Unsupported address format') + }) + + it('should get balance for a single address', async () => { + req.params.address = + 'bitcoincash:qp3sn6vlwz28ntmf3wmyra7jqttfx7z6zgtkygjhc7' + + // Mock unit tests to prevent live network calls. + if (process.env.TEST === 'unit') { + electrumxRoute.isReady = true // Force flag. + + sandbox + .stub(electrumxRoute, '_balanceFromElectrumx') + .resolves(mockData.balance) + } + + // Call the details API. + const result = await electrumxRoute.getBalance(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.property(result, 'balance') + assert.property(result.balance, 'confirmed') + assert.property(result.balance, 'unconfirmed') + }) + }) + + describe('#balanceBulk', () => { + it('should throw an error for an empty body', async () => { + req.body = {} + + const result = await electrumxRoute.balanceBulk(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 electrumxRoute.balanceBulk(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 electrumxRoute.balanceBulk(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 electrumxRoute.balanceBulk(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: ['bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4'] + } + + const result = await electrumxRoute.balanceBulk(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 get details for a single address', async () => { + req.body = { + addresses: ['bitcoincash:qp3sn6vlwz28ntmf3wmyra7jqttfx7z6zgtkygjhc7'] + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === 'unit') { + electrumxRoute.isReady = true // Force flag. + + sandbox + .stub(electrumxRoute, '_balanceFromElectrumx') + .resolves(mockData.balance) + } + + // Call the details API. + const result = await electrumxRoute.balanceBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.property(result, 'balances') + assert.isArray(result.balances) + + assert.property(result.balances[0], 'address') + assert.property(result.balances[0], 'balance') + + assert.property(result.balances[0].balance, 'confirmed') + assert.property(result.balances[0].balance, 'unconfirmed') + }) + + it('should get utxos for multiple addresses', async () => { + req.body = { + addresses: [ + 'bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf', + 'bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf' + ] + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === 'unit') { + electrumxRoute.isReady = true // Force flag. + + sandbox + .stub(electrumxRoute, '_balanceFromElectrumx') + .resolves(mockData.balance) + } + + // Call the details API. + const result = await electrumxRoute.balanceBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.isArray(result.balances) + assert.equal(result.balances.length, 2, '2 outputs for 2 inputs') + }) + }) + + describe('#_transactionsFromElectrumx', () => { + it('should throw error for invalid address', async () => { + try { + // Address has invalid checksum. + const address = 'bitcoincash:qr69kyzha07dcecrsvjwsj4s6slnlq4r8c30lxnur2' + + // Call the details API. + await electrumxRoute._transactionsFromElectrumx(address) + + assert.equal(true, false, 'Unexpected code path') + } catch (err) { + // console.log('err2: ', err) + assert.include(err.message, 'Invalid checksum') + } + }) + + it('should get transaction history for a single address', async () => { + const address = 'bitcoincash:qp3sn6vlwz28ntmf3wmyra7jqttfx7z6zgtkygjhc7' + + // Mock unit tests to prevent live network calls. + if (process.env.TEST === 'unit') { + electrumxRoute.isReady = true // Force flag. + + sandbox + .stub(electrumxRoute.electrumx, 'request') + .resolves(mockData.txHistory) + } + + // Call the details API. + const result = await electrumxRoute._transactionsFromElectrumx(address) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isArray(result) + assert.property(result[0], 'height') + assert.property(result[0], 'tx_hash') + }) + + it('should get history for an address with no transaction history', async () => { + const address = 'bitcoincash:qp2ew6pvrs22jtsvtjyumjgas6jkvgn2hy3ad4wpw8' + + // Mock unit tests to prevent live network calls. + if (process.env.TEST === 'unit') { + electrumxRoute.isReady = true // Force flag. + + sandbox.stub(electrumxRoute.electrumx, 'request').resolves([]) + } + + // Call the details API. + const result = await electrumxRoute._transactionsFromElectrumx(address) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isArray(result) + assert.equal(result.length, 0) + }) + }) + + describe('#getTransactions', () => { + it('should throw 400 if address is empty', async () => { + const result = await electrumxRoute.getTransactions(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 422, 'Expect 422 status code') + + assert.property(result, 'error') + assert.include(result.error, 'Unsupported address format') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + it('should throw 400 on array input', async () => { + req.params.address = ['qzs02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c'] + + const result = await electrumxRoute.getTransactions(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 400, 'Expect 400 status code') + + assert.property(result, 'error') + assert.include(result.error, 'address can not be an array') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + it('should throw an error for an invalid address', async () => { + req.params.address = '02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c' + + const result = await electrumxRoute.getTransactions(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 422, 'Expect 422 status code') + + assert.property(result, 'error') + assert.include(result.error, 'Unsupported address format') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + it('should detect a network mismatch', async () => { + req.params.address = 'bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4' + + const result = await electrumxRoute.getTransactions(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 400, 'Expect 400 status code') + + assert.property(result, 'error') + assert.include(result.error, 'Invalid network', 'Proper error message') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + it('should pass errors from ElectrumX to user', async () => { + // Address has invalid checksum. + req.params.address = + 'bitcoincash:qr69kyzha07dcecrsvjwsj4s6slnlq4r8c30lxnur2' + + // Call the details API. + const result = await electrumxRoute.getTransactions(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'success') + assert.equal(result.success, false) + + assert.property(result, 'error') + assert.include(result.error, 'Unsupported address format') + }) + + it('should get transactions for a single address', async () => { + req.params.address = + 'bitcoincash:qp3sn6vlwz28ntmf3wmyra7jqttfx7z6zgtkygjhc7' + + // Mock unit tests to prevent live network calls. + if (process.env.TEST === 'unit') { + electrumxRoute.isReady = true // Force flag. + + sandbox + .stub(electrumxRoute, '_transactionsFromElectrumx') + .resolves(mockData.txHistory) + } + + // Call the details API. + const result = await electrumxRoute.getTransactions(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.property(result, 'transactions') + assert.isArray(result.transactions) + assert.property(result.transactions[0], 'height') + assert.property(result.transactions[0], 'tx_hash') + }) + }) + + describe('#balanceBulk', () => { + it('should throw an error for an empty body', async () => { + req.body = {} + + const result = await electrumxRoute.transactionsBulk(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 electrumxRoute.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 an error for an invalid address', async () => { + req.body = { + addresses: ['02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c'] + } + + const result = await electrumxRoute.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 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 electrumxRoute.transactionsBulk(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: ['bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4'] + } + + const result = await electrumxRoute.transactionsBulk(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 get details for a single address', async () => { + req.body = { + addresses: ['bitcoincash:qp3sn6vlwz28ntmf3wmyra7jqttfx7z6zgtkygjhc7'] + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === 'unit') { + electrumxRoute.isReady = true // Force flag. + + sandbox + .stub(electrumxRoute, '_transactionsFromElectrumx') + .resolves(mockData.txHistory) + } + + // Call the details API. + const result = await electrumxRoute.transactionsBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.property(result, 'transactions') + assert.isArray(result.transactions) + + assert.property(result.transactions[0], 'address') + assert.property(result.transactions[0], 'transactions') + + assert.isArray(result.transactions[0].transactions) + assert.property(result.transactions[0].transactions[0], 'height') + assert.property(result.transactions[0].transactions[0], 'tx_hash') + }) + + it('should get utxos for multiple addresses', async () => { + req.body = { + addresses: [ + 'bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf', + 'bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf' + ] + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === 'unit') { + electrumxRoute.isReady = true // Force flag. + + sandbox + .stub(electrumxRoute, '_transactionsFromElectrumx') + .resolves(mockData.txHistory) + } + + // Call the details API. + const result = await electrumxRoute.transactionsBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.isArray(result.transactions) + assert.equal(result.transactions.length, 2, '2 outputs for 2 inputs') + }) + }) + + describe('#_mempoolFromElectrumx', () => { + it('should throw error for invalid address', async () => { + try { + // Address has invalid checksum. + const address = 'bitcoincash:qr69kyzha07dcecrsvjwsj4s6slnlq4r8c30lxnur2' + + // Call the details API. + await electrumxRoute._mempoolFromElectrumx(address) + + assert.equal(true, false, 'Unexpected code path') + } catch (err) { + assert.include(err.message, 'Invalid checksum') + } + }) + + it('should return empty array for address with no unconfirmed utxos', async () => { + // Address has invalid checksum. + const address = 'bchtest:qqtmlpspjakqlvywae226esrcdrj9auynuwadh55uf' + + // Mock unit tests to prevent live network calls. + if (process.env.TEST === 'unit') { + electrumxRoute.isReady = true // Force flag. + + sandbox.stub(electrumxRoute.electrumx, 'request').resolves([]) + } + + // Call the details API. + const result = await electrumxRoute._mempoolFromElectrumx(address) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isArray(result) + assert.equal(result.length, 0) + }) + + it('should get mempool for a single address', async () => { + const address = 'bitcoincash:qp3sn6vlwz28ntmf3wmyra7jqttfx7z6zgtkygjhc7' + + // Mock unit tests to prevent live network calls. + if (process.env.TEST === 'unit') { + electrumxRoute.isReady = true // Force flag. + + sandbox + .stub(electrumxRoute.electrumx, 'request') + .resolves(mockData.mempool) + } else { + // Skip this test for integrations. Unconfirmed UTXOs are transient and + // not easy to test in real-time. + assert.equal(true, true) + return + } + + // Call the details API. + const result = await electrumxRoute._mempoolFromElectrumx(address) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isArray(result) + assert.property(result[0], 'height') + assert.property(result[0], 'tx_hash') + assert.property(result[0], 'fee') + }) + }) + + describe('#getMempool', () => { + it('should throw 400 if address is empty', async () => { + const result = await electrumxRoute.getMempool(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 422, 'Expect 422 status code') + + assert.property(result, 'error') + assert.include(result.error, 'Unsupported address format') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + it('should throw 400 on array input', async () => { + req.params.address = ['qzs02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c'] + + const result = await electrumxRoute.getMempool(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 400, 'Expect 400 status code') + + assert.property(result, 'error') + assert.include(result.error, 'address can not be an array') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + it('should throw an error for an invalid address', async () => { + req.params.address = '02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c' + + const result = await electrumxRoute.getMempool(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 422, 'Expect 422 status code') + + assert.property(result, 'error') + assert.include(result.error, 'Unsupported address format') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + it('should detect a network mismatch', async () => { + req.params.address = 'bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4' + + const result = await electrumxRoute.getMempool(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(res.statusCode, 400, 'Expect 400 status code') + + assert.property(result, 'error') + assert.include(result.error, 'Invalid network', 'Proper error message') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + it('should pass errors from electrum-cash to user', async () => { + // Address has invalid checksum. + req.params.address = + 'bitcoincash:qr69kyzha07dcecrsvjwsj4s6slnlq4r8c30lxnur2' + + // Call the details API. + const result = await electrumxRoute.getMempool(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'success') + assert.equal(result.success, false) + + assert.property(result, 'error') + assert.include(result.error, 'Unsupported address format') + }) + + it('should get mempool for a single address', async () => { + req.params.address = + 'bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf' + + // Mock unit tests to prevent live network calls. + if (process.env.TEST === 'unit') { + electrumxRoute.isReady = true // Force flag. + + sandbox + .stub(electrumxRoute, '_mempoolFromElectrumx') + .resolves(mockData.mempool) + } else { + // Skip this test for integrations. Unconfirmed UTXOs are transient and + // not easy to test in real-time. + assert.equal(true, true) + return + } + + // Call the details API. + const result = await electrumxRoute.getMempool(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.property(result, 'utxos') + assert.isArray(result.utxos) + + assert.property(result.utxos[0], 'height') + assert.property(result.utxos[0], 'tx_hash') + assert.property(result.utxos[0], 'fee') + }) + }) + + describe('#mempoolBulk', () => { + it('should throw an error for an empty body', async () => { + req.body = {} + + const result = await electrumxRoute.mempoolBulk(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 electrumxRoute.mempoolBulk(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 electrumxRoute.mempoolBulk(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 electrumxRoute.mempoolBulk(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: ['bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4'] + } + + const result = await electrumxRoute.mempoolBulk(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 get mempool details for a single address', async () => { + req.body = { + addresses: ['bitcoincash:qp3sn6vlwz28ntmf3wmyra7jqttfx7z6zgtkygjhc7'] + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === 'unit') { + electrumxRoute.isReady = true // Force flag. + + sandbox + .stub(electrumxRoute, '_mempoolFromElectrumx') + .resolves(mockData.mempool) + } else { + // Skip this test for integrations. Unconfirmed UTXOs are transient and + // not easy to test in real-time. + assert.equal(true, true) + return + } + + // Call the details API. + const result = await electrumxRoute.mempoolBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.property(result, 'utxos') + assert.isArray(result.utxos) + + assert.property(result.utxos[0], 'address') + assert.property(result.utxos[0], 'utxos') + + assert.isArray(result.utxos[0].utxos) + assert.property(result.utxos[0].utxos[0], 'height') + assert.property(result.utxos[0].utxos[0], 'tx_hash') + assert.property(result.utxos[0].utxos[0], 'fee') + }) + + it('should get mempool for multiple addresses', async () => { + req.body = { + addresses: [ + 'bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf', + 'bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf' + ] + } + + // Mock the Insight URL for unit tests. + if (process.env.TEST === 'unit') { + electrumxRoute.isReady = true // Force flag. + + sandbox + .stub(electrumxRoute, '_mempoolFromElectrumx') + .resolves(mockData.mempool) + } else { + // Skip this test for integrations. Unconfirmed UTXOs are transient and + // not easy to test in real-time. + assert.equal(true, true) + return + } + + // Call the details API. + const result = await electrumxRoute.mempoolBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.isArray(result.utxos) + assert.isArray(result.utxos[0].utxos) + assert.equal(result.utxos.length, 2, '2 outputs for 2 inputs') + }) + }) +}) diff --git a/test/v5/blockchain.js b/test/v5/blockchain.js new file mode 100644 index 0000000..3853f7f --- /dev/null +++ b/test/v5/blockchain.js @@ -0,0 +1,2026 @@ +/* + TODO: + -getRawMempool + --Add tests for 'verbose' input values + -getMempoolEntry & getMempoolEntryBulk + --Needs e2e test to create unconfirmed tx, for real-world test. +*/ + +'use strict' + +const chai = require('chai') +const assert = chai.assert +const sinon = require('sinon') + +const Blockchain = require('../../src/routes/v4/full-node/blockchain') +const uut = new Blockchain() + +const util = require('util') +util.inspect.defaultOptions = { depth: 1 } + +if (!process.env.TEST) process.env.TEST = 'unit' + +// Mocking data. +const { mockReq, mockRes } = require('./mocks/express-mocks') +const mockData = require('./mocks/blockchain-mock') + +let originalEnvVars // Used during transition from integration to unit tests. + +describe('#BlockchainRouter', () => { + let req, res + let sandbox + + // local node will be started in regtest mode on the port 48332 + // before(panda.runLocalNode) + + 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 === '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 = {} + sandbox = sinon.createSandbox() + }) + + afterEach(() => { + // Restore Sandbox + sandbox.restore() + }) + + after(() => { + // otherwise the panda will run forever + // process.exit() + + // 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', () => { + it('should respond to GET for base route', async () => { + const result = uut.root(req, res) + + assert.equal(result.status, 'blockchain', 'Returns static string') + }) + }) + + describe('getBestBlockHash()', () => { + it('should throw 503 when network issues', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + const result = await uut.getBestBlockHash(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + const result = await uut.getBestBlockHash(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.getBestBlockHash(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should GET /getBestBlockHash', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockBlockHash } }) + } + + const result = await uut.getBestBlockHash(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isString(result) + assert.equal(result.length, 64, 'Hash string is fixed length') + }) + }) + + describe('getBlockchainInfo()', () => { + it('should throw 503 when network issues', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + const result = await uut.getBlockchainInfo(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + const result = await uut.getBestBlockHash(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.getBestBlockHash(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should GET /getBlockchainInfo', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockBlockchainInfo } }) + } + + const result = await uut.getBlockchainInfo(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAnyKeys(result, [ + 'chain', + 'blocks', + 'headers', + 'bestblockhash', + 'difficulty', + 'mediantime', + 'verificationprogress', + 'chainwork', + 'pruned', + 'softforks', + 'bip9_softforks' + ]) + }) + }) + + describe('getBlockCount()', () => { + it('should throw 503 when network issues', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + const result = await uut.getBlockCount(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + const result = await uut.getBlockCount(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.getBlockCount(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should GET /getBlockCount', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: 126769 } }) + } + + const result = await uut.getBlockCount(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isNumber(result) + }) + }) + + describe('getBlockHeaderSingle()', async () => { + it('should throw 400 error if hash is missing', async () => { + const result = await uut.getBlockHeaderSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'hash can not be empty') + }) + it('should throw 503 when network issues', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + req.params.hash = + '00000000000008c3679777df34f1a09565f98b2400a05b7c8da72525fdca3900' + + const result = await uut.getBlockHeaderSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Network error: Could not communicate with full node', + 'Error message expected' + ) + }) + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + req.params.hash = + '00000000000008c3679777df34f1a09565f98b2400a05b7c8da72525fdca3900' + + const result = await uut.getBlockHeaderSingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + req.params.hash = + '00000000000008c3679777df34f1a09565f98b2400a05b7c8da72525fdca3900' + + const result = await uut.getBlockHeaderSingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + it('should GET block header', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox.stub(uut.axios, 'request').resolves({ + data: { + result: + '000000202ed2723e7590e2b937f6821a99d6764cb8799bf30f8e300000000000000000001d311c02df9a1e3f57b8dbdcf97ec8dbc3109a26779724c63e560b29ad9ea501e2af955d286403183049e39c' + } + }) + } + + req.params.hash = + '000000000000000002fa9d7851b284c53a5831651b04211c266badf2ad2d8ef0' + + const result = await uut.getBlockHeaderSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isString(result) + assert.equal( + result, + '000000202ed2723e7590e2b937f6821a99d6764cb8799bf30f8e300000000000000000001d311c02df9a1e3f57b8dbdcf97ec8dbc3109a26779724c63e560b29ad9ea501e2af955d286403183049e39c' + ) + }) + + it('should GET verbose block header', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockBlockHeader } }) + } + + req.query.verbose = true + req.params.hash = + '000000000000000002fa9d7851b284c53a5831651b04211c266badf2ad2d8ef0' + + const result = await uut.getBlockHeaderSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, [ + 'hash', + 'confirmations', + 'height', + 'version', + 'versionHex', + 'merkleroot', + 'time', + 'mediantime', + 'nonce', + 'bits', + 'difficulty', + 'chainwork', + 'previousblockhash', + 'nextblockhash', + 'nTx' + ]) + }) + }) + + // + + describe('#getBlockHeaderBulk', () => { + it('should throw an error for an empty body', async () => { + req.body = {} + + const result = await uut.getBlockHeaderBulk(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 hash', async () => { + req.body.hashes = + '00000000000008c3679777df34f1a09565f98b2400a05b7c8da72525fdca3900' + + const result = await uut.getBlockHeaderBulk(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 uut.getBlockHeaderBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Array too large') + }) + + it('should throw a 400 error for an invalid hash', async () => { + req.body.hashes = ['badHash'] + + await uut.getBlockHeaderBulk(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.hashes = [ + '00000000000008c3679777df34f1a09565f98b2400a05b7c8da72525fdca3900' + ] + + // Switch the Insight URL to something that will error out. + process.env.BITCOINCOM_BASEURL = 'http://fakeurl/api/' + + const result = await uut.getBlockHeaderBulk(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('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + req.body.hashes = [ + '000000000000000002fa9d7851b284c53a5831651b04211c266badf2ad2d8ef0' + ] + + const result = await uut.getBlockHeaderBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + req.body.hashes = [ + '000000000000000002fa9d7851b284c53a5831651b04211c266badf2ad2d8ef0' + ] + + const result = await uut.getBlockHeaderBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should get concise block header for a single hash', async () => { + req.body.hashes = [ + '000000000000000002fa9d7851b284c53a5831651b04211c266badf2ad2d8ef0' + ] + + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockBlockHeaderConcise } }) + } + + // Call the details API. + const result = await uut.getBlockHeaderBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Assert that required fields exist in the returned object. + assert.isArray(result) + assert.equal( + result[0], + '000000202ed2723e7590e2b937f6821a99d6764cb8799bf30f8e300000000000000000001d311c02df9a1e3f57b8dbdcf97ec8dbc3109a26779724c63e560b29ad9ea501e2af955d286403183049e39c' + ) + }) + + it('should get verbose block header for a single hash', async () => { + req.body = { + hashes: [ + '000000000000000002fa9d7851b284c53a5831651b04211c266badf2ad2d8ef0' + ], + verbose: true + } + + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockBlockHeader } }) + } + + // Call the details API. + const result = await uut.getBlockHeaderBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Assert that required fields exist in the returned object. + assert.isArray(result) + assert.hasAllKeys(result[0], [ + 'hash', + 'confirmations', + 'height', + 'version', + 'versionHex', + 'merkleroot', + 'time', + 'mediantime', + 'nonce', + 'bits', + 'difficulty', + 'chainwork', + 'previousblockhash', + 'nextblockhash', + 'nTx' + ]) + }) + + it('should get details for multiple block heights', async () => { + req.body.hashes = [ + '000000000000000002fa9d7851b284c53a5831651b04211c266badf2ad2d8ef0', + '000000000000000002fa9d7851b284c53a5831651b04211c266badf2ad2d8ef0' + ] + + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockBlockHeaderConcise } }) + } + + // Call the details API. + const result = await uut.getBlockHeaderBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.equal(result.length, 2, '2 outputs for 2 inputs') + }) + }) + describe('getChainTips()', () => { + it('should throw 503 when network issues', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + const result = await uut.getChainTips(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + const result = await uut.getChainTips(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.getChainTips(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should GET /getChainTips', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockChainTips } }) + } + + const result = await uut.getChainTips(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.hasAnyKeys(result[0], ['height', 'hash', 'branchlen', 'status']) + }) + }) + describe('getDifficulty()', () => { + it('should throw 503 when network issues', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + const result = await uut.getDifficulty(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + const result = await uut.getDifficulty(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.getDifficulty(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should GET /getDifficulty', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: 4049809.205246544 } }) + } + + const result = await uut.getDifficulty(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isNumber(result) + }) + }) + describe('getMempoolInfo()', () => { + it('should throw 503 when network issues', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + const result = await uut.getMempoolInfo(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + const result = await uut.getMempoolInfo(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.getMempoolInfo(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should GET /getMempoolInfo', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockMempoolInfo } }) + } + + const result = await uut.getMempoolInfo(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAnyKeys(result, [ + 'result', + 'bytes', + 'usage', + 'maxmempool', + 'mempoolminfree' + ]) + }) + }) + + describe('getRawMempool()', () => { + it('should throw 503 when network issues', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + const result = await uut.getRawMempool(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + const result = await uut.getRawMempool(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.getRawMempool(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should GET /getRawMempool', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockRawMempool } }) + } + + const result = await uut.getRawMempool(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + // Not sure what other assertions should be made here. + }) + }) + + describe('getMempoolEntrySingle()', () => { + it('should throw 400 if txid is empty', async () => { + const result = await uut.getMempoolEntrySingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'txid can not be empty') + }) + + it('should throw 503 when network issues', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + req.params.txid = + 'd65881582ff2bff36747d7a0d0e273f10281abc8bd5c15df5d72f8f3fa779cde' + + const result = await uut.getMempoolEntrySingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + req.params.txid = + 'd65881582ff2bff36747d7a0d0e273f10281abc8bd5c15df5d72f8f3fa779cde' + + const result = await uut.getMempoolEntrySingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + req.params.txid = + 'd65881582ff2bff36747d7a0d0e273f10281abc8bd5c15df5d72f8f3fa779cde' + + const result = await uut.getMempoolEntrySingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should GET /getMempoolEntry', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox.stub(uut.axios, 'request').resolves({ + data: { result: { error: 'Transaction not in mempool' } } + }) + } + + req.params.txid = + 'd65881582ff2bff36747d7a0d0e273f10281abc8bd5c15df5d72f8f3fa779cde' + + const result = await uut.getMempoolEntrySingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.isString(result.error) + assert.equal(result.error, 'Transaction not in mempool') + }) + }) + describe('#getMempoolEntryBulk', () => { + it('should throw an error for an empty body', async () => { + req.body = {} + + const result = await uut.getMempoolEntryBulk(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 = + 'd65881582ff2bff36747d7a0d0e273f10281abc8bd5c15df5d72f8f3fa779cde' + + const result = await uut.getMempoolEntryBulk(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 400 error if addresses array is too large', async () => { + const testArray = [] + for (var i = 0; i < 25; i++) testArray.push('') + + req.body.txids = testArray + + const result = await uut.getMempoolEntryBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Array too large') + }) + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + req.body.txids = [ + 'd65881582ff2bff36747d7a0d0e273f10281abc8bd5c15df5d72f8f3fa779cde' + ] + + const result = await uut.getMempoolEntryBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + req.body.txids = [ + 'd65881582ff2bff36747d7a0d0e273f10281abc8bd5c15df5d72f8f3fa779cde' + ] + + const result = await uut.getMempoolEntryBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + // Only execute on integration tests. + if (process.env.TEST !== 'unit') { + // Dev-note: This test passes because it expects an error. TXIDs do not + // stay in the mempool for long, so it does not work well for a unit or + // integration test. + it('should retrieve single mempool entry', async () => { + req.body.txids = [ + 'd65881582ff2bff36747d7a0d0e273f10281abc8bd5c15df5d72f8f3fa779cde' + ] + + const result = await uut.getMempoolEntryBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.isString(result.error) + assert.equal(result.error, 'Transaction not in mempool') + }) + + // Dev-note: This test passes because it expects an error. TXIDs do not + // stay in the mempool for long, so it does not work well for a unit or + // integration test. + it('should retrieve multiple mempool entries', async () => { + req.body.txids = [ + 'd65881582ff2bff36747d7a0d0e273f10281abc8bd5c15df5d72f8f3fa779cde', + 'd65881582ff2bff36747d7a0d0e273f10281abc8bd5c15df5d72f8f3fa779cde' + ] + + const result = await uut.getMempoolEntryBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.isString(result.error) + assert.equal(result.error, 'Transaction not in mempool') + }) + } + }) + describe('getMempoolAncestorsSingle()', () => { + it('should throw 400 if txid is empty', async () => { + const result = await uut.getMempoolAncestorsSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'txid can not be empty') + }) + + it('should throw 503 when network issues', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + req.params.txid = + 'd65881582ff2bff36747d7a0d0e273f10281abc8bd5c15df5d72f8f3fa779cde' + + const result = await uut.getMempoolAncestorsSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + req.params.txid = + 'd65881582ff2bff36747d7a0d0e273f10281abc8bd5c15df5d72f8f3fa779cde' + + const result = await uut.getMempoolAncestorsSingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + req.params.txid = + 'd65881582ff2bff36747d7a0d0e273f10281abc8bd5c15df5d72f8f3fa779cde' + + const result = await uut.getMempoolAncestorsSingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + it('should GET /getMempoolAncestorsSingle', async () => { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockAncestors } }) + + req.params.txid = + 'bb0d349892d351da2767f8c45f6f7949713ff09bd12838d53e76158ddee3ce93' + + const result = await uut.getMempoolAncestorsSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + }) + }) + + describe('getTxOut()', () => { + it('should throw 400 if txid is empty', async () => { + const result = await uut.getTxOut(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'txid can not be empty') + }) + + it('should throw 400 if n is empty', async () => { + req.params.txid = 'sometxid' + const result = await uut.getTxOut(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'n can not be empty') + }) + + it('should throw 503 when network issues', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + req.params.txid = + 'd65881582ff2bff36747d7a0d0e273f10281abc8bd5c15df5d72f8f3fa779cde' + req.params.n = 0 + + const result = await uut.getTxOut(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + req.params.txid = + '197dcda59864b1eee05498fd3c52cad787ec56ab7e635503cb39f9ab6f295d5d' + req.params.n = 0 + req.query.include_mempool = 'true' + + const result = await uut.getTxOut(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + req.params.txid = + '197dcda59864b1eee05498fd3c52cad787ec56ab7e635503cb39f9ab6f295d5d' + req.params.n = 0 + req.query.include_mempool = 'true' + + const result = await uut.getTxOut(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + // This test can only run for unit tests. See TODO at the top of this file. + it('should GET /getTxOut', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockTxOut } }) + } + + req.params.txid = + '197dcda59864b1eee05498fd3c52cad787ec56ab7e635503cb39f9ab6f295d5d' + req.params.n = 0 + req.query.include_mempool = 'true' + + const result = await uut.getTxOut(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.hasAllKeys(result, [ + 'bestblock', + 'confirmations', + 'value', + 'scriptPubKey', + 'coinbase' + ]) + assert.hasAllKeys(result.scriptPubKey, [ + 'asm', + 'hex', + 'reqSigs', + 'type', + 'addresses' + ]) + assert.isArray(result.scriptPubKey.addresses) + }) + }) + + describe('getTxOutPost()', () => { + it('should throw 400 if txid is empty', async () => { + const result = await uut.getTxOutPost(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'txid can not be empty') + }) + + it('should throw 400 if n is empty', async () => { + req.body.txid = 'sometxid' + const result = await uut.getTxOutPost(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'vout can not be empty') + }) + + it('should throw 503 when network issues', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + req.body.txid = + 'd65881582ff2bff36747d7a0d0e273f10281abc8bd5c15df5d72f8f3fa779cde' + req.body.vout = 0 + + const result = await uut.getTxOutPost(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + req.body.txid = + '197dcda59864b1eee05498fd3c52cad787ec56ab7e635503cb39f9ab6f295d5d' + req.body.vout = 0 + req.body.mempool = true + + const result = await uut.getTxOutPost(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + req.body.txid = + '197dcda59864b1eee05498fd3c52cad787ec56ab7e635503cb39f9ab6f295d5d' + req.body.vout = 0 + req.body.mempool = true + + const result = await uut.getTxOutPost(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should POST /getTxOut', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockTxOut } }) + } + + req.body.txid = + '197dcda59864b1eee05498fd3c52cad787ec56ab7e635503cb39f9ab6f295d5d' + req.body.vout = 0 + req.body.mempool = true + + const result = await uut.getTxOutPost(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.hasAllKeys(result, [ + 'bestblock', + 'confirmations', + 'value', + 'scriptPubKey', + 'coinbase' + ]) + assert.hasAllKeys(result.scriptPubKey, [ + 'asm', + 'hex', + 'reqSigs', + 'type', + 'addresses' + ]) + assert.isArray(result.scriptPubKey.addresses) + }) + }) + + describe('getTxOutProofSingle()', () => { + it('should throw 400 if txid is empty', async () => { + const result = await uut.getTxOutProofSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'txid can not be empty') + }) + + it('should throw 503 when network issues', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + req.params.txid = + 'd65881582ff2bff36747d7a0d0e273f10281abc8bd5c15df5d72f8f3fa779cde' + + const result = await uut.getTxOutProofSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + req.params.txid = + '197dcda59864b1eee05498fd3c52cad787ec56ab7e635503cb39f9ab6f295d5d' + req.params.n = 0 + req.query.include_mempool = 'true' + + const result = await uut.getTxOutProofSingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + req.params.txid = + '197dcda59864b1eee05498fd3c52cad787ec56ab7e635503cb39f9ab6f295d5d' + req.params.n = 0 + req.query.include_mempool = 'true' + + const result = await uut.getTxOutProofSingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should GET /getTxOutProof', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockTxOutProof } }) + } + + req.params.txid = + '2c7ae9f865f7ce0c33c189b2f83414176903ce4b06ed9f8b7bcf55efbd4a7266' + + const result = await uut.getTxOutProofSingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isString(result) + }) + }) + + describe('#getTxOutProofBulk', () => { + it('should throw an error for an empty body', async () => { + req.body = {} + + const result = await uut.getTxOutProofBulk(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 = + 'd65881582ff2bff36747d7a0d0e273f10281abc8bd5c15df5d72f8f3fa779cde' + + const result = await uut.getTxOutProofBulk(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 400 error if addresses array is too large', async () => { + const testArray = [] + for (var i = 0; i < 25; i++) testArray.push('') + + req.body.txids = testArray + + const result = await uut.getTxOutProofBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Array too large') + }) + + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + req.body.txids = [ + '2c7ae9f865f7ce0c33c189b2f83414176903ce4b06ed9f8b7bcf55efbd4a7266' + ] + + const result = await uut.getTxOutProofBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + req.body.txids = [ + '2c7ae9f865f7ce0c33c189b2f83414176903ce4b06ed9f8b7bcf55efbd4a7266' + ] + + const result = await uut.getTxOutProofBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should GET proof for single txid', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockTxOutProof } }) + } + + req.body.txids = [ + '2c7ae9f865f7ce0c33c189b2f83414176903ce4b06ed9f8b7bcf55efbd4a7266' + ] + + const result = await uut.getTxOutProofBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isArray(result) + assert.isString(result[0]) + }) + + it('should GET proof for multiple txids', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockTxOutProof } }) + } + + req.body.txids = [ + '2c7ae9f865f7ce0c33c189b2f83414176903ce4b06ed9f8b7bcf55efbd4a7266', + '2c7ae9f865f7ce0c33c189b2f83414176903ce4b06ed9f8b7bcf55efbd4a7266' + ] + + const result = await uut.getTxOutProofBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isArray(result) + assert.equal(result.length, 2, 'Correct length of returned array') + }) + }) + describe('verifyTxOutProofSingle()', () => { + it('should throw 400 if proof is empty', async () => { + const result = await uut.verifyTxOutProofSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'proof can not be empty') + }) + + it('should throw 503 when network issues', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + req.params.proof = mockData.mockTxOutProof + + const result = await uut.verifyTxOutProofSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + req.params.proof = + '000000202ed2723e7590e2b937f6821a99d6764cb8799bf30f8e300000000000000000001d311c02df9a1e3f57b8dbdcf97ec8dbc3109a26779724c63e560b29ad9ea501e2af955d286403183049e39c1e000000067139f230701a2819a76795564bd2f67ded7eeae68596f368eddb3dd5bc54e59320e896f71d61cfc3ae3d4a90fca08b1aa35ba91256d8939d2cad11e638c0081f66724abdef55cf7b8b9fed064bce0369171434f8b289c1330ccef765f8e97a2c0d794d81aafb535855f7daa6bb51e40f77c6b59d7af7f62d0eb726a4fc4df82353d56fcbda7c7ea6bd935d61af8fb3b295637e6f323b10231135b3f10a034cfb238f635830c0595e52c6c31247cf677b555f7a287076e20cd0e1d3cc9af7260f02b700' + + const result = await uut.verifyTxOutProofSingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + req.params.proof = + '000000202ed2723e7590e2b937f6821a99d6764cb8799bf30f8e300000000000000000001d311c02df9a1e3f57b8dbdcf97ec8dbc3109a26779724c63e560b29ad9ea501e2af955d286403183049e39c1e000000067139f230701a2819a76795564bd2f67ded7eeae68596f368eddb3dd5bc54e59320e896f71d61cfc3ae3d4a90fca08b1aa35ba91256d8939d2cad11e638c0081f66724abdef55cf7b8b9fed064bce0369171434f8b289c1330ccef765f8e97a2c0d794d81aafb535855f7daa6bb51e40f77c6b59d7af7f62d0eb726a4fc4df82353d56fcbda7c7ea6bd935d61af8fb3b295637e6f323b10231135b3f10a034cfb238f635830c0595e52c6c31247cf677b555f7a287076e20cd0e1d3cc9af7260f02b700' + + const result = await uut.verifyTxOutProofSingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + it('should GET /verifyTxOutProof', async () => { + const expected = + '2c7ae9f865f7ce0c33c189b2f83414176903ce4b06ed9f8b7bcf55efbd4a7266' + + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: [expected] } }) + } + + req.params.proof = + '000000202ed2723e7590e2b937f6821a99d6764cb8799bf30f8e300000000000000000001d311c02df9a1e3f57b8dbdcf97ec8dbc3109a26779724c63e560b29ad9ea501e2af955d286403183049e39c1e000000067139f230701a2819a76795564bd2f67ded7eeae68596f368eddb3dd5bc54e59320e896f71d61cfc3ae3d4a90fca08b1aa35ba91256d8939d2cad11e638c0081f66724abdef55cf7b8b9fed064bce0369171434f8b289c1330ccef765f8e97a2c0d794d81aafb535855f7daa6bb51e40f77c6b59d7af7f62d0eb726a4fc4df82353d56fcbda7c7ea6bd935d61af8fb3b295637e6f323b10231135b3f10a034cfb238f635830c0595e52c6c31247cf677b555f7a287076e20cd0e1d3cc9af7260f02b700' + + const result = await uut.verifyTxOutProofSingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isArray(result) + assert.isString(result[0]) + assert.equal(result[0], expected) + }) + }) + describe('#verifyTxOutProofBulk', () => { + it('should throw an error for an empty body', async () => { + req.body = {} + + const result = await uut.verifyTxOutProofBulk(req, res) + + assert.equal(res.statusCode, 400, 'HTTP status code 400 expected.') + assert.include( + result.error, + 'proofs needs to be an array', + 'Proper error message' + ) + }) + + it('should error on non-array single txid', async () => { + req.body.proofs = mockData.mockTxOutProof + + const result = await uut.verifyTxOutProofBulk(req, res) + + assert.equal(res.statusCode, 400, 'HTTP status code 400 expected.') + assert.include( + result.error, + 'proofs 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.proofs = testArray + + const result = await uut.verifyTxOutProofBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Array too large') + }) + + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + req.body.proofs = [ + '000000202ed2723e7590e2b937f6821a99d6764cb8799bf30f8e300000000000000000001d311c02df9a1e3f57b8dbdcf97ec8dbc3109a26779724c63e560b29ad9ea501e2af955d286403183049e39c1e000000067139f230701a2819a76795564bd2f67ded7eeae68596f368eddb3dd5bc54e59320e896f71d61cfc3ae3d4a90fca08b1aa35ba91256d8939d2cad11e638c0081f66724abdef55cf7b8b9fed064bce0369171434f8b289c1330ccef765f8e97a2c0d794d81aafb535855f7daa6bb51e40f77c6b59d7af7f62d0eb726a4fc4df82353d56fcbda7c7ea6bd935d61af8fb3b295637e6f323b10231135b3f10a034cfb238f635830c0595e52c6c31247cf677b555f7a287076e20cd0e1d3cc9af7260f02b700' + ] + const result = await uut.verifyTxOutProofBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + req.body.proofs = [ + '000000202ed2723e7590e2b937f6821a99d6764cb8799bf30f8e300000000000000000001d311c02df9a1e3f57b8dbdcf97ec8dbc3109a26779724c63e560b29ad9ea501e2af955d286403183049e39c1e000000067139f230701a2819a76795564bd2f67ded7eeae68596f368eddb3dd5bc54e59320e896f71d61cfc3ae3d4a90fca08b1aa35ba91256d8939d2cad11e638c0081f66724abdef55cf7b8b9fed064bce0369171434f8b289c1330ccef765f8e97a2c0d794d81aafb535855f7daa6bb51e40f77c6b59d7af7f62d0eb726a4fc4df82353d56fcbda7c7ea6bd935d61af8fb3b295637e6f323b10231135b3f10a034cfb238f635830c0595e52c6c31247cf677b555f7a287076e20cd0e1d3cc9af7260f02b700' + ] + const result = await uut.verifyTxOutProofBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should get single proof', async () => { + const expected = + '2c7ae9f865f7ce0c33c189b2f83414176903ce4b06ed9f8b7bcf55efbd4a7266' + + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: [expected] } }) + } + + req.body.proofs = [ + '000000202ed2723e7590e2b937f6821a99d6764cb8799bf30f8e300000000000000000001d311c02df9a1e3f57b8dbdcf97ec8dbc3109a26779724c63e560b29ad9ea501e2af955d286403183049e39c1e000000067139f230701a2819a76795564bd2f67ded7eeae68596f368eddb3dd5bc54e59320e896f71d61cfc3ae3d4a90fca08b1aa35ba91256d8939d2cad11e638c0081f66724abdef55cf7b8b9fed064bce0369171434f8b289c1330ccef765f8e97a2c0d794d81aafb535855f7daa6bb51e40f77c6b59d7af7f62d0eb726a4fc4df82353d56fcbda7c7ea6bd935d61af8fb3b295637e6f323b10231135b3f10a034cfb238f635830c0595e52c6c31247cf677b555f7a287076e20cd0e1d3cc9af7260f02b700' + ] + + const result = await uut.verifyTxOutProofBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isArray(result) + assert.isString(result[0]) + assert.equal(result[0], expected) + }) + + it('should get multiple proofs', async () => { + const expected = + '2c7ae9f865f7ce0c33c189b2f83414176903ce4b06ed9f8b7bcf55efbd4a7266' + + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: [expected] } }) + } + + req.body.proofs = [ + '000000202ed2723e7590e2b937f6821a99d6764cb8799bf30f8e300000000000000000001d311c02df9a1e3f57b8dbdcf97ec8dbc3109a26779724c63e560b29ad9ea501e2af955d286403183049e39c1e000000067139f230701a2819a76795564bd2f67ded7eeae68596f368eddb3dd5bc54e59320e896f71d61cfc3ae3d4a90fca08b1aa35ba91256d8939d2cad11e638c0081f66724abdef55cf7b8b9fed064bce0369171434f8b289c1330ccef765f8e97a2c0d794d81aafb535855f7daa6bb51e40f77c6b59d7af7f62d0eb726a4fc4df82353d56fcbda7c7ea6bd935d61af8fb3b295637e6f323b10231135b3f10a034cfb238f635830c0595e52c6c31247cf677b555f7a287076e20cd0e1d3cc9af7260f02b700', + '000000202ed2723e7590e2b937f6821a99d6764cb8799bf30f8e300000000000000000001d311c02df9a1e3f57b8dbdcf97ec8dbc3109a26779724c63e560b29ad9ea501e2af955d286403183049e39c1e000000067139f230701a2819a76795564bd2f67ded7eeae68596f368eddb3dd5bc54e59320e896f71d61cfc3ae3d4a90fca08b1aa35ba91256d8939d2cad11e638c0081f66724abdef55cf7b8b9fed064bce0369171434f8b289c1330ccef765f8e97a2c0d794d81aafb535855f7daa6bb51e40f77c6b59d7af7f62d0eb726a4fc4df82353d56fcbda7c7ea6bd935d61af8fb3b295637e6f323b10231135b3f10a034cfb238f635830c0595e52c6c31247cf677b555f7a287076e20cd0e1d3cc9af7260f02b700' + ] + + const result = await uut.verifyTxOutProofBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isArray(result) + assert.isString(result[0]) + assert.equal(result[0], expected) + assert.equal(result.length, 2) + }) + }) + + describe('#getBlock()', () => { + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + req.body.blockhash = + '000000202ed2723e7590e2b937f6821a99d6764cb8799bf30f8e300000000000000000001d311c02df9a1e3f57b8dbdcf97ec8dbc3109a26779724c63e560b29ad9ea501e2af955d286403183049e39c1e000000067139f230701a2819a76795564bd2f67ded7eeae68596f368eddb3dd5bc54e59320e896f71d61cfc3ae3d4a90fca08b1aa35ba91256d8939d2cad11e638c0081f66724abdef55cf7b8b9fed064bce0369171434f8b289c1330ccef765f8e97a2c0d794d81aafb535855f7daa6bb51e40f77c6b59d7af7f62d0eb726a4fc4df82353d56fcbda7c7ea6bd935d61af8fb3b295637e6f323b10231135b3f10a034cfb238f635830c0595e52c6c31247cf677b555f7a287076e20cd0e1d3cc9af7260f02b700' + + const result = await uut.getBlock(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + req.body.blockhash = + '000000202ed2723e7590e2b937f6821a99d6764cb8799bf30f8e300000000000000000001d311c02df9a1e3f57b8dbdcf97ec8dbc3109a26779724c63e560b29ad9ea501e2af955d286403183049e39c1e000000067139f230701a2819a76795564bd2f67ded7eeae68596f368eddb3dd5bc54e59320e896f71d61cfc3ae3d4a90fca08b1aa35ba91256d8939d2cad11e638c0081f66724abdef55cf7b8b9fed064bce0369171434f8b289c1330ccef765f8e97a2c0d794d81aafb535855f7daa6bb51e40f77c6b59d7af7f62d0eb726a4fc4df82353d56fcbda7c7ea6bd935d61af8fb3b295637e6f323b10231135b3f10a034cfb238f635830c0595e52c6c31247cf677b555f7a287076e20cd0e1d3cc9af7260f02b700' + const result = await uut.getBlock(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should throw 400 if blockhash is empty', async () => { + const result = await uut.getBlock(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'blockhash can not be empty') + }) + + it('should return block info with verbosity 0', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockBlockInfo.verbosity0 } }) + } + + req.body.blockhash = + '0000000000000000008e8d83cba6d45a9314bc2ef4538d4e0577c6bed8593536' + req.body.verbosity = 0 + + const result = await uut.getBlock(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isString(result) + }) + + it('should return block info with verbosity 1', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockBlockInfo.verbosity1 } }) + } + + req.body.blockhash = + '0000000000000000008e8d83cba6d45a9314bc2ef4538d4e0577c6bed8593536' + req.body.verbosity = 1 + + const result = await uut.getBlock(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'hash', 'hash property expected') + assert.property( + result, + 'confirmations', + 'confirmations property expected' + ) + assert.property(result, 'size', 'size property expected') + assert.property(result, 'height', 'height property expected') + assert.property(result, 'version', 'version property expected') + assert.property(result, 'versionHex', 'versionHex property expected') + assert.property(result, 'merkleroot', 'merkleroot property expected') + assert.property(result, 'tx', 'tx property expected') + assert.property(result, 'time', 'time property expected') + assert.property(result, 'mediantime', 'mediantime property expected') + assert.property(result, 'nonce', 'nonce property expected') + assert.property(result, 'bits', 'bits property expected') + assert.property(result, 'difficulty', 'difficulty property expected') + assert.property(result, 'chainwork', 'chainwork property expected') + assert.property(result, 'nTx', 'nTx property expected') + assert.property( + result, + 'previousblockhash', + 'previousblockhash property expected' + ) + assert.property( + result, + 'nextblockhash', + 'nextblockhash property expected' + ) + }) + + it('should return block info with verbosity 2', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockBlockInfo.verbosity1 } }) + } + + req.body.blockhash = + '0000000000000000008e8d83cba6d45a9314bc2ef4538d4e0577c6bed8593536' + req.body.verbosity = 2 + + const result = await uut.getBlock(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'hash', 'hash property expected') + assert.property( + result, + 'confirmations', + 'confirmations property expected' + ) + assert.property(result, 'size', 'size property expected') + assert.property(result, 'height', 'height property expected') + assert.property(result, 'version', 'version property expected') + assert.property(result, 'versionHex', 'versionHex property expected') + assert.property(result, 'merkleroot', 'merkleroot property expected') + assert.property(result, 'tx', 'tx property expected') + assert.property(result, 'time', 'time property expected') + assert.property(result, 'mediantime', 'mediantime property expected') + assert.property(result, 'nonce', 'nonce property expected') + assert.property(result, 'bits', 'bits property expected') + assert.property(result, 'difficulty', 'difficulty property expected') + assert.property(result, 'chainwork', 'chainwork property expected') + assert.property(result, 'nTx', 'nTx property expected') + assert.property( + result, + 'previousblockhash', + 'previousblockhash property expected' + ) + assert.property( + result, + 'nextblockhash', + 'nextblockhash property expected' + ) + }) + + it('should return block info without verbosity especified', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockBlockInfo.verbosity1 } }) + } + + req.body.blockhash = + '0000000000000000008e8d83cba6d45a9314bc2ef4538d4e0577c6bed8593536' + + const result = await uut.getBlock(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'hash', 'hash property expected') + assert.property( + result, + 'confirmations', + 'confirmations property expected' + ) + assert.property(result, 'size', 'size property expected') + assert.property(result, 'height', 'height property expected') + assert.property(result, 'version', 'version property expected') + assert.property(result, 'versionHex', 'versionHex property expected') + assert.property(result, 'merkleroot', 'merkleroot property expected') + assert.property(result, 'tx', 'tx property expected') + assert.property(result, 'time', 'time property expected') + assert.property(result, 'mediantime', 'mediantime property expected') + assert.property(result, 'nonce', 'nonce property expected') + assert.property(result, 'bits', 'bits property expected') + assert.property(result, 'difficulty', 'difficulty property expected') + assert.property(result, 'chainwork', 'chainwork property expected') + assert.property(result, 'nTx', 'nTx property expected') + assert.property( + result, + 'previousblockhash', + 'previousblockhash property expected' + ) + assert.property( + result, + 'nextblockhash', + 'nextblockhash property expected' + ) + }) + }) +}) diff --git a/test/v5/control.js b/test/v5/control.js new file mode 100644 index 0000000..b5bc020 --- /dev/null +++ b/test/v5/control.js @@ -0,0 +1,164 @@ +/* + TESTS FOR THE CONTROL.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. + +*/ + +'use strict' + +const chai = require('chai') +const assert = chai.assert +const ControlRoute = require('../../src/routes/v4/full-node/control') +const uut = new ControlRoute() +const sinon = require('sinon') + +let originalEnvVars // Used during transition from integration to unit tests. + +// Mocking data. +const { mockReq, mockRes } = require('./mocks/express-mocks') +const mockData = require('./mocks/control-mock') + +// Used for debugging. +const util = require('util') +util.inspect.defaultOptions = { depth: 1 } + +describe('#ControlRouter', () => { + let req, res + let sandbox + + 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. + }) + + // 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 = {} + + sandbox = sinon.createSandbox() + }) + + afterEach(() => { + // Restore Sandbox + sandbox.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 = controlRoute.testableComponents.root + + it('should respond to GET for base route', async () => { + const result = uut.root(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(result.status, 'control', 'Returns static string') + }) + }) + + describe('#GetNetworkInfo', () => { + // const getNetworkInfo = controlRoute.testableComponents.getNetworkInfo + + it('should throw 500 when network issues', async () => { + // Save the existing RPC URL. + const savedUrl = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + await uut.getNetworkInfo(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl + + assert.isAbove( + res.statusCode, + 499, + 'HTTP status code 500 or greater expected.' + ) + // assert.include(result.error, "ENOTFOUND", "Error message expected") + }) + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + const result = await uut.getNetworkInfo(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.getNetworkInfo(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should get info on the full node', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockGetNetworkInfo } }) + } + + const result = await uut.getNetworkInfo(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAnyKeys(result, [ + 'version', + 'subversion', + 'protocolversion', + 'localservices', + 'localrelay', + 'timeoffset', + 'networkactive', + 'connections', + 'networks', + 'relayfee', + 'excessutxocharge', + 'localaddresses', + 'warnings' + ]) + }) + }) +}) diff --git a/test/v5/e2e/address.js b/test/v5/e2e/address.js new file mode 100644 index 0000000..8656761 --- /dev/null +++ b/test/v5/e2e/address.js @@ -0,0 +1,60 @@ +/* + End-to-end tests for the Address endpoint. + These tests assume that the repo is running locally and pointed at TESTNET +*/ + +'use strict' + +const rp = require('request-promise') + +// const rawtransactions = require('../../../dist/routes/v2/address') + +/* + This unconfirmed utxo test needs to be expanded in the future to automatically + create a transaction. For now, that is done manually and the address provided + as a constant. + + 1. Start rest running locally, pointed at TESTNET. + 2. Fund each address with a small amount of tBCH + 3. Run this program with `node address.js` + 4. If successful, it will return the unconfirmed UTXOs. +*/ +const addr1 = 'bchtest:qpdcp5pv7qphu5tsjfgwezld9n9aq9ue6swgqpf45c' +const addr2 = 'bchtest:qzpvx999fagau0xqmvu3xvll9evapge7u5hcgeknzv' + +async function testSingleUnconfirmed () { + try { + const options = { + method: 'GET', + uri: `http://localhost:3000/v2/address/unconfirmed/${addr1}`, + resolveWithFullResponse: true, + json: true + } + + await rp(options) + // console.log(`result.body: ${JSON.stringify(result.body, null, 2)}`) + } catch (err) { + // console.log(`Error: `, err) + } +} +testSingleUnconfirmed() + +async function testDoubleUnconfirmed () { + try { + const options = { + method: 'POST', + uri: 'http://localhost:3000/v2/address/unconfirmed', + resolveWithFullResponse: true, + json: true, + body: { + addresses: [addr1, addr2] + } + } + + await rp(options) + // console.log(`result.body: ${JSON.stringify(result.body, null, 2)}`) + } catch (err) { + // console.log(`Error: `, err) + } +} +testDoubleUnconfirmed() diff --git a/test/v5/encryption.js b/test/v5/encryption.js new file mode 100644 index 0000000..735baba --- /dev/null +++ b/test/v5/encryption.js @@ -0,0 +1,146 @@ +/* + TESTS FOR THE ENCRYPTION.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: +*/ + +'use strict' + +const chai = require('chai') +const assert = chai.assert + +const sinon = require('sinon') + +// Set default environment variables for unit tests. +if (!process.env.TEST) process.env.TEST = 'unit' + +// Only load blockbook library after setting BLOCKBOOK_URL env var. +const EncryptionRoute = require('../../src/routes/v4/encryption') +const encryptionRoute = new EncryptionRoute() + +// Mocking data. +const { mockReq, mockRes } = require('./mocks/express-mocks') +const mockData = require('./mocks/encryption-mocks') + +// Used for debugging. +const util = require('util') +util.inspect.defaultOptions = { depth: 1 } + +describe('#Encryption Router', () => { + let req, res + let sandbox + before(() => { + // console.log(`Testing type is: ${process.env.TEST}`) + + if (!process.env.NETWORK) process.env.NETWORK = 'testnet' + }) + + // 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 = {} + + sandbox = sinon.createSandbox() + }) + + afterEach(() => { + sandbox.restore() + }) + + after(() => {}) + + describe('#root', () => { + // root route handler. + const root = encryptionRoute.root + + it('should respond to GET for base route', async () => { + const result = root(req, res) + + assert.equal(result.status, 'encryption', 'Returns static string') + }) + }) + + describe('#getPublicKey', () => { + it('should get public key from blockchain', async () => { + req.params.address = + 'bitcoincash:qrehqueqhw629p6e57994436w730t4rzasnly00ht0' + + // Mock the Insight URL for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(encryptionRoute.bchjs.Electrumx, 'transactions') + .resolves(mockData.mockFulcrumTxHistory) + sandbox + .stub(encryptionRoute.bchjs.RawTransactions, 'getRawTransaction') + .resolves([mockData.mockTxDetails2]) + } + + const result = await encryptionRoute.getPublicKey(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'success') + assert.equal(result.success, true) + + assert.property(result, 'publicKey') + assert.equal( + result.publicKey, + '044eb40b025df18409f2a5197b010dd62a9e65d9a74e415e5b10367721a9c4baa7ebfee22d14b8ece1c9bd70c0d9e5e8b00b61b81b88a1b5ce6f24eac6b8a34b2c' + ) + }) + + it('should return false for address with no tx history', async () => { + req.params.address = + 'bitcoincash:qqwfpk04ecf69wuprj9yjys9rla5mk7rj5j8uthqel' + + // Mock the Insight URL for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(encryptionRoute.bchjs.Electrumx, 'transactions') + .resolves(mockData.mockFulcrumNoTxHistory) + } + + const result = await encryptionRoute.getPublicKey(req, res) + console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'success') + assert.equal(result.success, false) + + assert.property(result, 'error') + assert.include(result.error, 'No transaction history') + }) + + it('should return false for address with no send history', async () => { + req.params.address = + 'bitcoincash:qq78nwj5x97yh6wtlfd27dtlwjuh70vkjc59h8tgtg' + + // Mock the Insight URL for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(encryptionRoute.bchjs.Electrumx, 'transactions') + .resolves(mockData.mockFulcrumNoSendBalance) + sandbox + .stub(encryptionRoute.bchjs.RawTransactions, 'getRawTransaction') + .resolves([mockData.mockNoSendTx]) + } + + const result = await encryptionRoute.getPublicKey(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'success') + assert.equal(result.success, false) + + assert.property(result, 'publicKey') + assert.include(result.publicKey, 'not found') + }) + }) +}) diff --git a/test/v5/helpers/panda.js b/test/v5/helpers/panda.js new file mode 100644 index 0000000..40c584f --- /dev/null +++ b/test/v5/helpers/panda.js @@ -0,0 +1,29 @@ +'use strict' + +/** + * Read more about panda here: https://panda-suite.github.io/ + */ +const panda = require('pandacash-core') + +const runLocalNode = done => { + const server = panda.server({ + // always the same mnemonic + // mnemonic: "cigar magnet ocean purchase travel damp snack alone theme budget wagon wrong", + seedAccounts: true, + enableLogs: false, + debug: false + }) + + server.listen({ + port: 48332, + walletPort: 48333 + }, (err, pandaCashCore) => { + if (err) return console.error(err) + + done() + }) +} + +module.exports = { + runLocalNode +} diff --git a/test/v5/integration/README.md b/test/v5/integration/README.md new file mode 100644 index 0000000..3bd22f8 --- /dev/null +++ b/test/v5/integration/README.md @@ -0,0 +1,7 @@ +This directory contains integration tests. To run them requires that +a local copy of rest.bitcoin.com is running. The tests in this directory are +exactly the same calls that end users would use for their app to interact +directly with rest.bitcoin.com. + +At present, this directory focuses on raw-transations. But more integration tests +will be added. diff --git a/test/v5/integration/nft.js b/test/v5/integration/nft.js new file mode 100644 index 0000000..563b4f2 --- /dev/null +++ b/test/v5/integration/nft.js @@ -0,0 +1,114 @@ +/* + These integration tests need to be run against a live SLPDB. They query + against a live SLPDB and test the results against known token stats. + */ + +'use strict' + +const chai = require('chai') +const assert = chai.assert +// const axios = require('axios') + +// Used for debugging. +const util = require('util') +util.inspect.defaultOptions = { depth: 1 } + +// Exit if SLPDB URL is not defined. +if (!process.env.SLPDB_URL) { + throw new Error( + 'SLPDB_URL and SLPDB_PASS must be defined in order to run these tests.' + ) +} + +const SLP = require('../../../src/routes/v4/slp') +const slp = new SLP() + +const { mockReq, mockRes } = require('../mocks/express-mocks') + +describe('#nft', () => { + let req, res + + beforeEach(() => { + // Mock the req and res objects used by Express routes. + req = mockReq + res = mockRes + }) + + describe('#getNftChildren', () => { + it('should return error on non-existing NFT group token', async () => { + req.params.tokenId = + '68cd33ecd909068fbea318ae5ff1d6207cf754e53b191327d6d73b6916424c0b' + const result = await slp.getNftChildren(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.include( + result.error, + 'NFT group does not exists', + 'Error message expected' + ) + }) + it('should return error on non-group NFT token', async () => { + req.params.tokenId = + '45a30085691d6ea586e3ec2aa9122e9b0e0d6c3c1fd357decccc15d8efde48a9' + + const result = await slp.getNftChildren(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.include( + result.error, + 'NFT group does not exists', + 'Error message expected' + ) + }) + it('should get NFT children list', async () => { + req.params.tokenId = + '68cd33ecd909068fbea318ae5ff1d6207cf754e53b191327d6d73b6916424c0a' + + const result = await slp.getNftChildren(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result.nftChildren) + assert.equal(result.nftChildren.length, 2) + assert.equal( + result.nftChildren[0], + '45a30085691d6ea586e3ec2aa9122e9b0e0d6c3c1fd357decccc15d8efde48a9' + ) + assert.equal( + result.nftChildren[1], + '928ce61fe1006b1325a0ba0dce700bf83986a6f0691ba26e121c9ac035d12a55' + ) + }) + }) + + describe('#getNftGroup', () => { + it('should return error on non-existing NFT child token', async () => { + req.params.tokenId = + '45a30085691d6ea586e3ec2aa9122e9b0e0d6c3c1fd357decccc15d8efde48a8' + const result = await slp.getNftGroup(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.include( + result.error, + 'NFT child does not exists', + 'Error message expected' + ) + }) + it('should get NFT group token info', async () => { + req.params.tokenId = + '45a30085691d6ea586e3ec2aa9122e9b0e0d6c3c1fd357decccc15d8efde48a9' + // req.params.tokenId = '928ce61fe1006b1325a0ba0dce700bf83986a6f0691ba26e121c9ac035d12a55' + const result = await slp.getNftGroup(req, res) + // console.log(`result: ${util.inspect(result)}`) + assert.property(result, 'nftGroup') + assert.property(result.nftGroup, 'id') + assert.property(result.nftGroup, 'versionType') + assert.property(result.nftGroup, 'symbol') + assert.equal( + result.nftGroup.id, + '68cd33ecd909068fbea318ae5ff1d6207cf754e53b191327d6d73b6916424c0a' + ) + assert.equal(result.nftGroup.versionType, 129) + assert.equal(result.nftGroup.symbol, 'PSF.TEST.GROUP') + }) + }) +}) diff --git a/test/v5/integration/price.js b/test/v5/integration/price.js new file mode 100644 index 0000000..0b96988 --- /dev/null +++ b/test/v5/integration/price.js @@ -0,0 +1,53 @@ +/* + Integration tests for the price library. + */ + +'use strict' + +const assert = require('chai').assert +// const axios = require('axios') + +// Used for debugging. +const util = require('util') +util.inspect.defaultOptions = { depth: 1 } + +const Price = require('../../../src/routes/v4/price') +const price = new Price() + +const { mockReq, mockRes } = require('../mocks/express-mocks') + +describe('#price', () => { + let req, res + + beforeEach(() => { + // Mock the req and res objects used by Express routes. + req = mockReq + res = mockRes + }) + + describe('#getUSD', () => { + it('should get the USD price', async () => { + const result = await price.getUSD(req, res) + console.log(`result: ${util.inspect(result)}`) + + assert.isNumber(result.usd) + }) + }) + + describe('#getBCHAUSD', () => { + it('should get the USD price of BCHA', async () => { + const result = await price.getBCHAUSD(req, res) + console.log(`result: ${util.inspect(result)}`) + + assert.isNumber(result.usd) + }) + }) + describe('#getBCHUSD', () => { + it('should get the USD price of BCH', async () => { + const result = await price.getBCHUSD(req, res) + console.log(`result: ${util.inspect(result)}`) + + assert.isNumber(result.usd) + }) + }) +}) diff --git a/test/v5/integration/rate-limits.js b/test/v5/integration/rate-limits.js new file mode 100644 index 0000000..0a6a4ab --- /dev/null +++ b/test/v5/integration/rate-limits.js @@ -0,0 +1,164 @@ +/* + These tests have been deprecated. To test bch-api rate limits, run the e2e + tests in the bch-js repository. + */ + +'use strict' + +const chai = require('chai') +const assert = chai.assert +const axios = require('axios') + +// Used for debugging. +const util = require('util') +util.inspect.defaultOptions = { depth: 1 } + +// const SERVER = `http://192.168.0.36:12400/v4/` +const SERVER = 'http://localhost:3000/v4/' +// const SERVER = 'https://api.fullstack.cash/v4/' +// +// const TEST_JWT = +// 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjVlODhhY2JmMDIyMWMxMDAxMmFkOTNmZiIsImVtYWlsIjoiY2hyaXMudHJvdXRuZXJAZ21haWwuY29tIiwiYXBpTGV2ZWwiOjQwLCJyYXRlTGltaXQiOjMsImlhdCI6MTYwMDYyODk1MSwiZXhwIjoxNjAzMjIwOTUxfQ.JPXDJQsxJFtCGZjHOd-hRfJuY41Ef_FQ4ET06CtYdNk' + +describe('#JWT rate limits', () => { + it('should get control/getNetworkInfo() with no auth', async () => { + const options = { + method: 'GET', + url: `${SERVER}control/getNetworkInfo` + } + + const result = await axios(options) + // console.log(`result.status: ${result.status}`) + // console.log(`result.data: ${util.inspect(result.data)}`) + + assert.equal(result.status, 200) + assert.hasAnyKeys(result.data, ['version']) + }) + + it('should trigger rate-limit handler if rate limits exceeds 20 request per minute', async () => { + try { + const options = { + method: 'GET', + url: `${SERVER}control/getNetworkInfo` + } + + const promises = [] + for (let i = 0; i < 30; i++) { + const promise = axios(options) + promises.push(promise) + } + + await Promise.all(promises) + + assert.fail('Unexpected result!') + } catch (err) { + console.log('err: ', err) + + assert.equal(err.response.status, 429) + assert.include(err.response.data.error, 'Too many requests') + } + }) + + // it('should not trigger rate-limit handler if correct pro-tier password is used', async () => { + // try { + // const username = 'BITBOX' + // + // // Pro-tier is accessed by using the right password. + // const password = 'BITBOX' + // // const password = "something" + // + // const combined = `${username}:${password}` + // const base64Credential = Buffer.from(combined).toString('base64') + // const readyCredential = `Basic ${base64Credential}` + // + // const options = { + // method: 'GET', + // url: `${SERVER}control/getNetworkInfo`, + // headers: { Authorization: readyCredential } + // } + // + // const promises = [] + // for (let i = 0; i < 30; i++) { + // const promise = axios(options) + // promises.push(promise) + // } + // + // await Promise.all(promises) + // + // assert.equal(true, true, 'Not throwing an error is a pass!') + // } catch (err) { + // // console.log(`err.response: ${util.inspect(err.response)}`) + // + // assert.equal( + // true, + // false, + // 'This error handler should not have been triggered. Is the password correct?' + // ) + // } + // }) + + // it('should trigger rate-limit handler if rate limits exceeds pro-tier limit', async () => { + // try { + // const username = 'BITBOX' + // + // // Pro-tier is accessed by using the right password. + // const password = 'BITBOX' + // // const password = "something" + // + // const combined = `${username}:${password}` + // const base64Credential = Buffer.from(combined).toString('base64') + // const readyCredential = `Basic ${base64Credential}` + // + // // Actual rate limit is 60 per minute X 4 nodes = 240 rpm. + // const options = { + // method: 'GET', + // url: `${SERVER}control/getNetworkInfo`, + // headers: { Authorization: readyCredential } + // } + // + // const promises = [] + // for (let i = 0; i < 80; i++) { + // const promise = axios(options) + // promises.push(promise) + // } + // + // await Promise.all(promises) + // + // assert.equal(true, false, 'Unexpected result!') + // } catch (err) { + // // console.log(`err.response: ${util.inspect(err.response)}`) + // + // assert.equal(err.response.status, 429) + // assert.include(err.response.data.error, 'Too many requests') + // } + // }) + + // it('should unlock pro-tier for a valid JWT token', async () => { + // try { + // // Actual rate limit is 60 per minute X 4 nodes = 240 rpm. + // const options = { + // method: 'GET', + // url: `${SERVER}control/`, + // headers: { + // Authorization: `Token ${TEST_JWT}` + // } + // } + // + // const promises = [] + // for (let i = 0; i < 60; i++) { + // const promise = axios(options) + // promises.push(promise) + // } + // + // await Promise.all(promises) + // + // // assert.equal(true, false, "Unexpected result!") + // assert.equal(true, true, 'Not throwing an error is a pass!') + // } catch (err) { + // console.log(`err.response: ${util.inspect(err.response)}`) + // + // assert.equal(true, false, 'Unexpected result!') + // } + // // Override default timeout for this test. + // }).timeout(20000) +}) diff --git a/test/v5/integration/raw-transactions.js b/test/v5/integration/raw-transactions.js new file mode 100644 index 0000000..730444d --- /dev/null +++ b/test/v5/integration/raw-transactions.js @@ -0,0 +1,193 @@ +/* + These integration tests are intended to be run against a live local copy of + rest.bitcoin.com. They exercise the endpoints in the same way the SDK or + end-user application would. These tests were created to replace the parts + removed from the swagger UI, that otherwise would have excersiced these endpoints. + + TODO: + -/rawtransactions/sendRawTransaction is more appropropriate for an e2e test, + so it is omitted here. + - Replace request-promise with axios. +*/ + +'use strict' + +// const chai = require('chai') +// const assert = chai.assert +// const rawtransactions = require('../../../dist/routes/v2/rawtransactions') +// const rp = require('request-promise') + +// Used for debugging. +const util = require('util') +util.inspect.defaultOptions = { depth: 1 } + +// const mockData = require('../mocks/raw-transactions-mocks') +/* +describe('#Raw-Transactions', () => { + describe('#root', () => { + it('should return root', async () => { + const options = { + method: 'GET', + uri: 'http://localhost:3000/v2/rawtransactions/', + resolveWithFullResponse: true, + json: true + } + + const result = await rp(options) + // console.log(`result: ${JSON.stringify(result, null, 0)}`) + + assert.equal(result.body.status, 'rawtransactions') + }) + }) + + describe('#whCreateTx', () => { + it('should return tx hex', async () => { + const minIn = { + txid: + 'f7ed9cf23dee85910f6269c9a101a75fcfd2f3c6fc81f17fad824ff7aaf99ab2', + vout: 1 + } + + const options = { + method: 'PUT', + uri: 'http://localhost:3000/v2/rawtransactions/create', + resolveWithFullResponse: true, + json: true, + body: { + // inputs: [mockData.mockWHCreateInput], + inputs: [minIn], + outputs: {} + } + } + + const result = await rp(options) + // console.log(`result.body: ${JSON.stringify(result.body, null, 0)}`) + + assert.equal( + result.body, + '0200000001b29af9aaf74f82ad7ff181fcc6f3d2cf5fa701a1c969620f9185ee3df29cedf70100000000ffffffff0000000000' + ) + }) + }) + + describe('#whOpReturn', () => { + it('should return tx hex', async () => { + const options = { + method: 'PUT', + uri: 'http://localhost:3000/v2/rawtransactions/opreturn', + resolveWithFullResponse: true, + json: true, + body: { + rawtx: '01000000000000000000', + payload: '00000000000000020000000006dac2c0' + } + } + + const result = await rp(options) + // console.log(`result.body: ${JSON.stringify(result.body, null, 0)}`) + + assert.equal( + result.body, + '0100000000010000000000000000166a140877686300000000000000020000000006dac2c000000000' + ) + }) + }) + + describe('#whReference', () => { + it('should return tx hex', async () => { + const options = { + method: 'PUT', + uri: 'http://localhost:3000/v2/rawtransactions/reference', + resolveWithFullResponse: true, + json: true, + body: { + rawtx: + '0100000001a7a9402ecd77f3c9f745793c9ec805bfa2e14b89877581c734c774864247e6f50400000000ffffffff03aa0a0000000000001976a9146d18edfe073d53f84dd491dae1379f8fb0dfe5d488ac5c0d0000000000004751210252ce4bdd3ce38b4ebbc5a6e1343608230da508ff12d23d85b58c964204c4cef3210294cc195fc096f87d0f813a337ae7e5f961b1c8a18f1f8604a909b3a5121f065b52aeaa0a0000000000001976a914946cb2e08075bcbaf157e47bcb67eb2b2339d24288ac00000000', + destination: 'bitcoincash:qrn60nerx5zug4u4hal06atep3lzhtecvy4pxk75lf', + amount: 0.005 + } + } + + const result = await rp(options) + // console.log(`result.body: ${JSON.stringify(result.body, null, 0)}`) + + assert.isString(result.body) + }) + }) + + describe('#whChangeOutput', () => { + it('should return tx hex', async () => { + const options = { + method: 'PUT', + uri: 'http://localhost:3000/v2/rawtransactions/change', + resolveWithFullResponse: true, + json: true, + body: { + rawtx: + '0100000001b15ee60431ef57ec682790dec5a3c0d83a0c360633ea8308fbf6d5fc10a779670400000000ffffffff025c0d00000000000047512102f3e471222bb57a7d416c82bf81c627bfcd2bdc47f36e763ae69935bba4601ece21021580b888ff56feb27f17f08802ebed26258c23697d6a462d43fc13b565fda2dd52aeaa0a0000000000001976a914946cb2e08075bcbaf157e47bcb67eb2b2339d24288ac00000000', + destination: 'bitcoincash:qrn60nerx5zug4u4hal06atep3lzhtecvy4pxk75lf', + prevtxs: [ + { + txid: + '6779a710fcd5f6fb0883ea3306360c3ad8c0a3c5de902768ec57ef3104e65eb1', + vout: 4, + scriptPubKey: + '76a9147b25205fd98d462880a3e5b0541235831ae959e588ac', + value: 0.00068257 + } + ], + fee: 0.000035 + } + } + + const result = await rp(options) + // console.log(`result.body: ${JSON.stringify(result.body, null, 0)}`) + + assert.isString(result.body) + }) + }) + + describe('#whInput', () => { + it('should return tx hex', async () => { + const options = { + method: 'PUT', + uri: 'http://localhost:3000/v2/rawtransactions/input', + resolveWithFullResponse: true, + json: true, + body: { + txid: + 'b006729017df05eda586df9ad3f8ccfee5be340aadf88155b784d1fc0e8342ee', + n: 0 + } + } + + const result = await rp(options) + // console.log(`result.body: ${JSON.stringify(result.body, null, 0)}`) + + assert.isString(result.body) + }) + }) + + describe('#getRawTransaction', () => { + it('should return tx hex', async () => { + const options = { + method: 'POST', + uri: 'http://localhost:3000/v2/rawtransactions/getRawTransaction', + resolveWithFullResponse: true, + json: true, + body: { + txids: [ + '0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098' + ], + verbose: true + } + } + + const result = await rp(options) + // console.log(`result.body: ${util.inspect(result.body)}`) + + assert.isArray(result.body) + }) + }) +}) +*/ diff --git a/test/v5/integration/slp.js b/test/v5/integration/slp.js new file mode 100644 index 0000000..94c8e77 --- /dev/null +++ b/test/v5/integration/slp.js @@ -0,0 +1,461 @@ +/* + These integration tests need to be run against a live SLPDB. They query + against a live SLPDB and test the results against known token stats. + */ + +'use strict' + +const assert = require('chai').assert +// const axios = require('axios') + +// Used for debugging. +const util = require('util') +util.inspect.defaultOptions = { depth: 1 } + +// Exit if SLPDB URL is not defined. +if (!process.env.SLPDB_URL) { + throw new Error( + 'SLPDB_URL and SLPDB_PASS must be defined in order to run these tests.' + ) +} + +const SLP = require('../../../src/routes/v4/slp') +const slp = new SLP() + +const BCHJS = require('@psf/bch-js') +const bchjs = new BCHJS() + +const { mockReq, mockRes } = require('../mocks/express-mocks') + +describe('#slp', () => { + let req, res + + beforeEach(() => { + // Mock the req and res objects used by Express routes. + req = mockReq + res = mockRes + }) + + describe('#tokenStats', () => { + it('should get token stats for token with no mint baton', async () => { + req.params.tokenId = + '497291b8a1dfe69c8daea50677a3d31a5ef0e9484d8bebb610dac64bbc202fb7' + // 'a4fb5c2da1aa064e25018a43f9165040071d9e984ba190c222a7f59053af84b2' + + const result = await slp.tokenStats(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Assert that expected properties exist. + assert.property(result, 'decimals') + assert.property(result, 'timestamp') + assert.property(result, 'versionType') + assert.property(result, 'documentUri') + assert.property(result, 'symbol') + assert.property(result, 'name') + assert.property(result, 'containsBaton') + assert.property(result, 'id') + assert.property(result, 'documentHash') + assert.property(result, 'initialTokenQty') + assert.property(result, 'blockCreated') + assert.property(result, 'blockLastActiveSend') + assert.property(result, 'blockLastActiveMint') + assert.property(result, 'txnsSinceGenesis') + assert.property(result, 'validAddresses') + assert.property(result, 'mintingBatonStatus') + assert.property(result, 'timestampUnix') + assert.property(result, 'totalMinted') + assert.property(result, 'totalBurned') + assert.property(result, 'circulatingSupply') + + // baton was never created. + assert.equal(result.containsBaton, false) + }) + + it('should get token stats for token with a mint baton', async () => { + req.params.tokenId = + 'a4fb5c2da1aa064e25018a43f9165040071d9e984ba190c222a7f59053af84b2' + + const result = await slp.tokenStats(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Assert that expected properties exist. + assert.property(result, 'decimals') + assert.property(result, 'timestamp') + assert.property(result, 'versionType') + assert.property(result, 'documentUri') + assert.property(result, 'symbol') + assert.property(result, 'name') + assert.property(result, 'containsBaton') + assert.property(result, 'id') + assert.property(result, 'documentHash') + assert.property(result, 'initialTokenQty') + assert.property(result, 'blockCreated') + assert.property(result, 'blockLastActiveSend') + assert.property(result, 'blockLastActiveMint') + assert.property(result, 'txnsSinceGenesis') + assert.property(result, 'validAddresses') + assert.property(result, 'mintingBatonStatus') + assert.property(result, 'timestampUnix') + assert.property(result, 'totalMinted') + assert.property(result, 'totalBurned') + assert.property(result, 'circulatingSupply') + + // baton was created. + assert.equal(result.containsBaton, true) + }) + }) + + describe('#generateSendOpReturn', () => { + it('should return OP_RETURN script', async () => { + req.body.tokenUtxos = [ + { + tokenId: + '38e97c5d7d3585a2cbf3f9580c82ca33985f9cb0845d4dcce220cb709f9538b0', + decimals: 8, + tokenQty: 2 + } + ] + req.body.sendQty = 1.5 + + const result = await slp.generateSendOpReturn(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['script', 'outputs']) + assert.isNumber(result.outputs) + }) + }) + + describe('#hydrateUtxos', () => { + it('should return utxo details', async () => { + const utxos = [ + { + utxos: [ + { + txid: + 'd56a2b446d8149c39ca7e06163fe8097168c3604915f631bc58777d669135a56', + vout: 3, + value: '6816', + height: 606848, + confirmations: 13, + satoshis: 6816 + }, + { + txid: + 'd56a2b446d8149c39ca7e06163fe8097168c3604915f631bc58777d669135a56', + vout: 2, + value: '546', + height: 606848, + confirmations: 13, + satoshis: 546 + } + ] + } + ] + + req.body.utxos = utxos + const result = await slp.hydrateUtxos(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + // Test the general structure of the output. + assert.isArray(result.slpUtxos) + assert.equal(result.slpUtxos.length, 1) + assert.equal(result.slpUtxos[0].utxos.length, 2) + + // Test the non-slp UTXO. + assert.property(result.slpUtxos[0].utxos[0], 'txid') + assert.property(result.slpUtxos[0].utxos[0], 'vout') + assert.property(result.slpUtxos[0].utxos[0], 'value') + assert.property(result.slpUtxos[0].utxos[0], 'height') + assert.property(result.slpUtxos[0].utxos[0], 'confirmations') + assert.property(result.slpUtxos[0].utxos[0], 'satoshis') + assert.property(result.slpUtxos[0].utxos[0], 'isValid') + assert.equal(result.slpUtxos[0].utxos[0].isValid, false) + + // Test the slp UTXO. + assert.property(result.slpUtxos[0].utxos[1], 'txid') + assert.property(result.slpUtxos[0].utxos[1], 'vout') + assert.property(result.slpUtxos[0].utxos[1], 'value') + assert.property(result.slpUtxos[0].utxos[1], 'height') + assert.property(result.slpUtxos[0].utxos[1], 'confirmations') + assert.property(result.slpUtxos[0].utxos[1], 'satoshis') + assert.property(result.slpUtxos[0].utxos[1], 'isValid') + assert.equal(result.slpUtxos[0].utxos[1].isValid, true) + assert.property(result.slpUtxos[0].utxos[1], 'transactionType') + assert.property(result.slpUtxos[0].utxos[1], 'tokenId') + assert.property(result.slpUtxos[0].utxos[1], 'tokenTicker') + assert.property(result.slpUtxos[0].utxos[1], 'tokenName') + assert.property(result.slpUtxos[0].utxos[1], 'tokenDocumentUrl') + assert.property(result.slpUtxos[0].utxos[1], 'tokenDocumentHash') + assert.property(result.slpUtxos[0].utxos[1], 'decimals') + assert.property(result.slpUtxos[0].utxos[1], 'tokenType') + assert.property(result.slpUtxos[0].utxos[1], 'tokenQty') + }) + + it('should process data directly from Electrumx', async () => { + const addrs = [ + 'bitcoincash:qq6mvsm7l92d77zpymmltvaw09p5uzghyuyx7spygg', + 'bitcoincash:qpjdrs8qruzh8xvusdfmutjx62awcepnhyperm3g89', + 'bitcoincash:qzygn28zpgeemnptkn26xzyuzzfu9l8f9vfvq7kptk' + ] + + const utxos = await bchjs.Electrumx.utxo(addrs) + // console.log(`utxos: ${JSON.stringify(utxos, null, 2)}`) + + req.body.utxos = utxos.utxos + const result = await slp.hydrateUtxos(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + // Test the general structure of the output. + assert.isArray(result.slpUtxos) + assert.equal(result.slpUtxos.length, 3) + assert.equal(result.slpUtxos[0].utxos.length, 1) + assert.equal(result.slpUtxos[1].utxos.length, 1) + assert.equal(result.slpUtxos[2].utxos.length, 2) + }) + }) + + describe('#hydrateUtxosWL', () => { + it('should return utxo details', async () => { + const utxos = [ + { + utxos: [ + { + tx_hash: + '89b3f0c84efe8b01b24e2d7ac08636de5781f31dbb84478e3de868ca0a7ed93a', + tx_pos: 1, + value: 546 + } + ] + } + ] + + req.body.utxos = utxos + const result = await slp.hydrateUtxosWL(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + // Test the general structure of the output. + assert.isArray(result.slpUtxos) + assert.equal(result.slpUtxos.length, 1) + assert.equal(result.slpUtxos[0].utxos.length, 1) + + // Test the non-slp UTXO. + assert.property(result.slpUtxos[0].utxos[0], 'tx_hash') + assert.property(result.slpUtxos[0].utxos[0], 'tx_pos') + assert.property(result.slpUtxos[0].utxos[0], 'value') + assert.property(result.slpUtxos[0].utxos[0], 'isValid') + assert.equal(result.slpUtxos[0].utxos[0].isValid, true) + }) + }) + + describe('#validate2Single', () => { + it('should invalidate a known invalid TXID', async () => { + const txid = + 'f7e5199ef6669ad4d078093b3ad56e355b6ab84567e59ad0f08a5ad0244f783a' + + req.params.txid = txid + const result = await slp.validate2Single(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.equal(result.txid, txid) + assert.equal(result.isValid, false) + }) + + it('should validate a known valid TXID', async () => { + const txid = + '3a4b628cbcc183ab376d44ce5252325f042268307ffa4a53443e92b6d24fb488' + + req.params.txid = txid + const result = await slp.validate2Single(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.equal(result.txid, txid) + assert.equal(result.isValid, true) + }) + + // CT 10-11-2020: This test is valid, but because of the cacheing built + // into slp-validate, it will not consistently pass or fail. To manually + // Run this test, re-start slp-api or find a token txid with a long DAG. + // it('should cancel if validation takes too long', async () => { + // const txid = + // 'eacb1085dfa296fef6d4ae2c0f4529a1bef096dd2325bdcc6dcb5241b3bdb579' + // + // req.params.txid = txid + // const result = await slp.validate2Single(req, res) + // // console.log(`result: ${JSON.stringify(result, null, 2)}`) + // + // assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + // assert.include( + // result.error, + // 'Could not communicate with full node', + // 'Error message expected' + // ) + // }) + }) + + describe('#validateBulk', () => { + it('should handle a mix of valid, invalid, and non-SLP txs', async () => { + const txids = [ + // Malformed SLP tx + 'f7e5199ef6669ad4d078093b3ad56e355b6ab84567e59ad0f08a5ad0244f783a', + // Normal TX (non-SLP) + '01cdaec2f8b311fc2d6ecc930247bd45fa696dc204ab684596e281fe1b06c1f0', + // Valid PSF SLP tx + 'daf4d8b8045e7a90b7af81bfe2370178f687da0e545511bce1c9ae539eba5ffd', + // Valid SLP token not in whitelist + '3a4b628cbcc183ab376d44ce5252325f042268307ffa4a53443e92b6d24fb488', + // Token send on BCHN network. + '402c663379d9699b6e2dd38737061e5888c5a49fca77c97ab98e79e08959e019', + // Token send on ABC network. + '336bfe2168aac4c3303508a9e8548a0d33797a83b85b76a12d845c8d6674f79d', + // Known invalid SLP token send of PSF tokens. + '2bf691ad3679d928fef880b8a45b93b233f8fa0d0a92cf792313dbe77b1deb74' + ] + + req.body.txids = txids + const result = await slp.validateBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + // BCHN expected results + if (process.env.ISBCHN) { + assert.equal(result[0].txid, txids[0]) + assert.equal(result[0].valid, null) + + assert.equal(result[1].txid, txids[1]) + assert.equal(result[1].valid, null) + + assert.equal(result[2].txid, txids[2]) + assert.equal(result[2].valid, true) + + assert.equal(result[3].txid, txids[3]) + assert.equal(result[3].valid, true) + + // Note: This should change from null to true once SLPDB finishes indexing. + assert.equal(result[4].txid, txids[4]) + assert.equal(result[4].valid, null) + + assert.equal(result[5].txid, txids[5]) + assert.equal(result[5].valid, null) + + assert.equal(result[6].txid, txids[6]) + assert.equal(result[6].valid, false) + assert.include( + result[6].invalidReason, + 'Token outputs are greater than valid token inputs' + ) + } else { + assert.equal(result[0].txid, txids[0]) + assert.equal(result[0].valid, null) + + assert.equal(result[1].txid, txids[1]) + assert.equal(result[1].valid, null) + + assert.equal(result[2].txid, txids[2]) + assert.equal(result[2].valid, true) + + assert.equal(result[3].txid, txids[3]) + assert.equal(result[3].valid, true) + + assert.equal(result[4].txid, txids[4]) + assert.equal(result[4].valid, null) + + assert.equal(result[5].txid, txids[5]) + assert.equal(result[5].valid, true) + + assert.equal(result[6].txid, txids[6]) + assert.equal(result[6].valid, false) + assert.include( + result[6].invalidReason, + 'Token outputs are greater than valid token inputs' + ) + } + }) + }) + + describe('#validate3Bulk', () => { + it('should handle a mix of valid, invalid, and non-SLP txs', async () => { + const txids = [ + // Malformed SLP tx + 'f7e5199ef6669ad4d078093b3ad56e355b6ab84567e59ad0f08a5ad0244f783a', + // Normal TX (non-SLP) + '01cdaec2f8b311fc2d6ecc930247bd45fa696dc204ab684596e281fe1b06c1f0', + // Valid PSF SLP tx + 'daf4d8b8045e7a90b7af81bfe2370178f687da0e545511bce1c9ae539eba5ffd', + // Valid SLP token not in whitelist + '3a4b628cbcc183ab376d44ce5252325f042268307ffa4a53443e92b6d24fb488', + // Token send on BCHN network. + '402c663379d9699b6e2dd38737061e5888c5a49fca77c97ab98e79e08959e019', + // Token send on ABC network. + '336bfe2168aac4c3303508a9e8548a0d33797a83b85b76a12d845c8d6674f79d', + // Known invalid SLP token send of PSF tokens. + '2bf691ad3679d928fef880b8a45b93b233f8fa0d0a92cf792313dbe77b1deb74' + ] + + req.body.txids = txids + const result = await slp.validate3Bulk(req, res) + console.log(`result: ${JSON.stringify(result, null, 2)}`) + + // BCHN expected results + if (process.env.ISBCHN) { + assert.equal(result[0].txid, txids[0]) + assert.equal(result[0].valid, null) + + assert.equal(result[1].txid, txids[1]) + assert.equal(result[1].valid, null) + + assert.equal(result[2].txid, txids[2]) + assert.equal(result[2].valid, true) + + assert.equal(result[3].txid, txids[3]) + assert.equal(result[3].valid, null) + + // Note: This should change from null to true once SLPDB finishes indexing. + assert.equal(result[4].txid, txids[4]) + assert.equal(result[4].valid, null) + + assert.equal(result[5].txid, txids[5]) + assert.equal(result[5].valid, null) + + assert.equal(result[6].txid, txids[6]) + assert.equal(result[6].valid, false) + assert.include( + result[6].invalidReason, + 'Token outputs are greater than valid token inputs' + ) + } else { + assert.equal(result[0].txid, txids[0]) + assert.equal(result[0].valid, null) + + assert.equal(result[1].txid, txids[1]) + assert.equal(result[1].valid, null) + + assert.equal(result[2].txid, txids[2]) + assert.equal(result[2].valid, true) + + assert.equal(result[3].txid, txids[3]) + assert.equal(result[3].valid, true) + + assert.equal(result[4].txid, txids[4]) + assert.equal(result[4].valid, null) + + assert.equal(result[5].txid, txids[5]) + assert.equal(result[5].valid, true) + + assert.equal(result[6].txid, txids[6]) + assert.equal(result[6].valid, false) + assert.include( + result[6].invalidReason, + 'Token outputs are greater than valid token inputs' + ) + } + }) + }) + + describe('#getStatus', () => { + it('should get the SLPDB status', async () => { + const result = await slp.getStatus(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'bchBlockHeight') + }) + }) +}) diff --git a/test/v5/integration/slpdb.js b/test/v5/integration/slpdb.js new file mode 100644 index 0000000..03b5b33 --- /dev/null +++ b/test/v5/integration/slpdb.js @@ -0,0 +1,31 @@ +/* + These integration tests need to be run against a live SLPDB. They query + against a live SLPDB and test the results against known token stats. + */ + +'use strict' + +// const chai = require('chai') +// const assert = chai.assert +// const axios = require('axios') + +// Used for debugging. +const util = require('util') +util.inspect.defaultOptions = { depth: 1 } + +// Exit if SLPDB URL is not defined. +if (!process.env.SLPDB_URL) { + throw new Error('SLPDB_URL and SLPDB_PASS must be defined in order to run these tests.') +} + +const SLPDB = require('../../../src/routes/v4/services/slpdb') +const slpdb = new SLPDB() + +describe('#slpdb', () => { + describe('#getTotalCirculating', () => { + it('should get circulating supply', async () => { + const result = await slpdb.getTotalCirculating('b10677aef051b73e6b170c1c0824da33a3e0680ab5a01cd8d76aa77840fccfb4') + console.log('result: ', result) + }) + }) +}) diff --git a/test/v5/mining.js b/test/v5/mining.js new file mode 100644 index 0000000..1c323ac --- /dev/null +++ b/test/v5/mining.js @@ -0,0 +1,224 @@ +/* + TESTS FOR THE MINING.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. +*/ + +'use strict' + +const chai = require('chai') +const assert = chai.assert +const MiningRoute = require('../../src/routes/v4/full-node/mining') +const uut = new MiningRoute() + +// const nock = require('nock') // HTTP mocking +const sinon = require('sinon') + +let originalEnvVars // Used during transition from integration to unit tests. + +// Mocking data. +const { mockReq, mockRes } = require('./mocks/express-mocks') +const mockData = require('./mocks/mining-mocks') + +// Used for debugging. +const util = require('util') +util.inspect.defaultOptions = { depth: 1 } + +describe('#Mining', () => { + let req, res + let sandbox + 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 = {} + + sandbox = sinon.createSandbox() + }) + + afterEach(() => { + // Restore Sandbox + sandbox.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. + it('should respond to GET for base route', async () => { + const result = uut.root(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(result.status, 'mining', 'Returns static string') + }) + }) + + describe('#getMiningInfo', async () => { + it('should throw 503 when network issues', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + await uut.getMiningInfo(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + + 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") + }) + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + const result = await uut.getMiningInfo(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.getMiningInfo(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should GET mining information', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockMiningInfo } }) + } + + const result = await uut.getMiningInfo(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.property(result, 'blocks') + assert.property(result, 'difficulty') + assert.property(result, 'networkhashps') + assert.property(result, 'pooledtx') + assert.property(result, 'chain') + assert.property(result, 'warnings') + }) + }) + + describe('#getNetworkHashPS', async () => { + it('should throw 503 when network issues', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + await uut.getNetworkHashPS(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + + 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") + }) + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + const result = await uut.getNetworkHashPS(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.getNetworkHashPS(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should GET Network Hash per second', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: 517604755.6648782 } }) + } + + const result = await uut.getNetworkHashPS(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isNumber(result) + }) + }) +}) diff --git a/test/v5/mocks/address-mock.js b/test/v5/mocks/address-mock.js new file mode 100644 index 0000000..a2666a2 --- /dev/null +++ b/test/v5/mocks/address-mock.js @@ -0,0 +1,160 @@ +/* + This library contains mocking data for running unit tests on the address route. +*/ + +'use strict' + +const mockAddressDetails = { + addrStr: '1Fg4r9iDrEkCcDmHTy2T79EusNfhyQpu7W', + balance: 0.00126419, + balanceSat: 126419, + totalReceived: 0.02175868, + totalReceivedSat: 2175868, + totalSent: 0.02049449, + totalSentSat: 2049449, + unconfirmedBalance: 0, + unconfirmedBalanceSat: 0, + unconfirmedTxApperances: 0, + txApperances: 3, + transactions: [ + '2dc053f55a666a3d2a08b1c680b704d62a55506d14ad884add87edcc56b9277d', + '544c15ce35c0f2e808d28f29d6587f1ec9276233e29856b7f2938cf0daef0026', + '81039b1d7b855b133f359f9dc65f776bd105650153a941675fedc504228ddbd3' + ], + legacyAddress: '1Fg4r9iDrEkCcDmHTy2T79EusNfhyQpu7W', + cashAddress: 'bitcoincash:qzs02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c' +} + +const mockUtxoDetails = [ + { + txid: '15f6a584080b04911121fbaca7bfcf3dd64ef2bfa5a01daf31e05a296c3e5e9e', + vout: 286, + amount: 0.00001, + satoshis: 1000, + height: 546083, + confirmations: 3490 + }, + { + txid: '15f6a584080b04911121fbaca7bfcf3dd64ef2bfa5a01daf31e05a296c3e5e9e', + vout: 287, + amount: 0.00001, + satoshis: 1000, + height: 546083, + confirmations: 3490 + }, + { + txid: '15f6a584080b04911121fbaca7bfcf3dd64ef2bfa5a01daf31e05a296c3e5e9e', + vout: 288, + amount: 0.00001, + satoshis: 1000, + height: 546083, + confirmations: 3490 + } +] + +const mockUnconfirmed = [ + { + address: '1EzdL6TBbkNhnB2fYiBaKmcs5fxaoqwdAp', + txid: '000c00a90fb5031da6e02f7625df2f8b35a4c16e6feb9fc72293e67e5ff75786', + vout: 0, + scriptPubKey: '76a914997fabcd94a1e2aaa13a7664362e5e7b96c169a988ac', + amount: 0.00999626, + satoshis: 999626, + confirmations: 0, + ts: 1537989425 + } +] + +const mockTransactions = { + pagesTotal: 1, + txs: [ + { + txid: '000c00a90fb5031da6e02f7625df2f8b35a4c16e6feb9fc72293e67e5ff75786', + version: 2, + locktime: 549565, + vin: [ + { + txid: + '45c891c6d44619fc85716ba1b593aa83ebc1e500fe611b1ab98531ea203a0f21', + vout: 209, + sequence: 4294967294, + n: 0, + scriptSig: { + hex: + '47304402205b136f348bedae61a87c0500979d47ab02c76f0e4aba866b94c4931fccb5d7dc0220757a141660475b0cd4cb8b1f54b1ace95105f5bea775e817b115fb589fe4477541210246daec651c506f353daa7468714399c773df42ac4d663022d0e446a3331ac64a', + asm: + '304402205b136f348bedae61a87c0500979d47ab02c76f0e4aba866b94c4931fccb5d7dc0220757a141660475b0cd4cb8b1f54b1ace95105f5bea775e817b115fb589fe44775[ALL|FORKID] 0246daec651c506f353daa7468714399c773df42ac4d663022d0e446a3331ac64a' + }, + addr: '14TdovsQUL69xL5g3zSzhLpmDb93d9rU9m', + valueSat: 1979534, + value: 0.01979534, + doubleSpentTxID: null + }, + { + txid: + '6e5fa79547112a912adf6082c975dc80e282045193512ea369adb6bac7420674', + vout: 147, + sequence: 4294967294, + n: 1, + scriptSig: { + hex: + '473044022056674ac83a37d54ffec2c6d33cfc2ae2256f821b8bd818e1b6783356b49f3d28022046d3dd147c8fc51f2da5d16996a3cea1d7314e327aa1ae1017418737ed20136a4121038f23af565f68d8455e07e5c33c5cfc5114de2f478f035db23eb02f8ff5fe7f6e', + asm: + '3044022056674ac83a37d54ffec2c6d33cfc2ae2256f821b8bd818e1b6783356b49f3d28022046d3dd147c8fc51f2da5d16996a3cea1d7314e327aa1ae1017418737ed20136a[ALL|FORKID] 038f23af565f68d8455e07e5c33c5cfc5114de2f478f035db23eb02f8ff5fe7f6e' + }, + addr: '1CEtC2fjELvmAzTd6MWuHb1gvuSq8x3Xpb', + valueSat: 10466, + value: 0.00010466, + doubleSpentTxID: null + } + ], + vout: [ + { + value: '0.00999626', + n: 0, + scriptPubKey: { + hex: '76a914997fabcd94a1e2aaa13a7664362e5e7b96c169a988ac', + asm: + 'OP_DUP OP_HASH160 997fabcd94a1e2aaa13a7664362e5e7b96c169a9 OP_EQUALVERIFY OP_CHECKSIG', + addresses: ['1EzdL6TBbkNhnB2fYiBaKmcs5fxaoqwdAp'], + type: 'pubkeyhash' + }, + spentTxId: null, + spentIndex: null, + spentHeight: null + }, + { + value: '0.00990000', + n: 1, + scriptPubKey: { + hex: '76a914e3335e4d6babe61ea58311293c1c6bfe802801cb88ac', + asm: + 'OP_DUP OP_HASH160 e3335e4d6babe61ea58311293c1c6bfe802801cb OP_EQUALVERIFY OP_CHECKSIG', + addresses: ['1MiKvgaQTFCTEZbSDMZgw9ahaB52Cr4ofb'], + type: 'pubkeyhash' + }, + spentTxId: null, + spentIndex: null, + spentHeight: null + } + ], + blockhash: + '00000000000000000158b1a2883688873ec5ea076e3b0f576bcdfe1fd277880f', + blockheight: 549601, + confirmations: 5, + time: 1537990026, + blocktime: 1537990026, + valueOut: 0.01989626, + size: 372, + valueIn: 0.0199, + fees: 0.00000374 + } + ] +} + +module.exports = { + mockAddressDetails, + mockUtxoDetails, + mockUnconfirmed, + mockTransactions +} diff --git a/test/v5/mocks/bitcore-mock.js b/test/v5/mocks/bitcore-mock.js new file mode 100644 index 0000000..8a0e32a --- /dev/null +++ b/test/v5/mocks/bitcore-mock.js @@ -0,0 +1,35 @@ +/* + This library contains mocking data for running unit tests on the bitcore route. +*/ + +'use strict' + +const mockBalance = { + confirmed: 10000000, + unconfirmed: 0, + balance: 10000000 +} + +const mockUtxos = [ + { + _id: '5cf2c31a33bd46a95ec7e730', + chain: 'BCH', + network: 'testnet', + coinbase: false, + mintIndex: 1, + spentTxid: '', + mintTxid: + '5fe9b74056319a8c87f45cc745030715a6180758b94938dbf90d639d55652392', + mintHeight: 1265275, + spentHeight: -2, + address: 'qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4', + script: '76a9140e5b4ad9008bb9a027b7e2d0ef958914e12db20788ac', + value: 10000000, + confirmations: -1 + } +] + +module.exports = { + mockBalance, + mockUtxos +} diff --git a/test/v5/mocks/block-mock.js b/test/v5/mocks/block-mock.js new file mode 100644 index 0000000..616ad41 --- /dev/null +++ b/test/v5/mocks/block-mock.js @@ -0,0 +1,42 @@ +/* + This library contains mocking data for running unit tests on the address route. +*/ + +'use strict' + +const mockBlockDetails = { + hash: '00000000000000645dec6503d3f5eafb0d2537a7a28f181d721dec7c44154c79', + size: 1319, + height: 1267544, + version: 536870912, + merkleroot: + 'c2cd01ec2cf149acc7631385f89ae103d1a2ad212ab810c862d9680a811618ce', + tx: [ + '52bfa89d449fef8070ec33e7a61f5ec8b5417ff62b050cd6a144ebce9557e0fa', + '8988e8742d2523f667c2d1374861919e3a82903e059fec75c10795da2303b93b', + '41def004f22b196d675566a6983ecf97611e72c48375b8ba779983d5ccd369d7', + '8ca8efb06abb94395f8331f87e101504dcf66aee828f2111922f056971fa7502', + '9ab409cbb203be7cf22bd6598d787fcdd5b417d885b72207ecc7253470770649' + ], + time: 1542048973, + nonce: 3936284753, + bits: '1a03c148', + difficulty: 4467892.99177529, + chainwork: '00000000000000000000000000000000000000000000003ee27b503a064045bd', + confirmations: 3, + previousblockhash: + '00000000000001891202cbe18729a02a8763cf04da0ca7aded6e6c7d9b500785', + nextblockhash: + '000000000000039b93b3f3403a0eb21150904b15229d998cf3788fd8bf192cc2', + reward: 0.78125, + isMainChain: true, + poolInfo: {} +} + +const mockBlockHash = + '00000000000000645dec6503d3f5eafb0d2537a7a28f181d721dec7c44154c79' + +module.exports = { + mockBlockDetails, + mockBlockHash +} diff --git a/test/v5/mocks/blockbook-mock.js b/test/v5/mocks/blockbook-mock.js new file mode 100644 index 0000000..05edf53 --- /dev/null +++ b/test/v5/mocks/blockbook-mock.js @@ -0,0 +1,75 @@ +/* + This library contains mocking data for running unit tests on the blockbook route. +*/ + +'use strict' + +const mockBalance = { + page: 1, + totalPages: 1, + itemsOnPage: 1000, + address: 'bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf', + balance: '10000000', + totalReceived: '10000000', + totalSent: '0', + unconfirmedBalance: '0', + unconfirmedTxs: 0, + txs: 1, + txids: ['5fe9b74056319a8c87f45cc745030715a6180758b94938dbf90d639d55652392'] +} + +const mockUtxos = [ + { + txid: '5fe9b74056319a8c87f45cc745030715a6180758b94938dbf90d639d55652392', + vout: 1, + value: '10000000', + height: 1265275, + confirmations: 42704 + } +] + +const mockTx = { + txid: '5fe9b74056319a8c87f45cc745030715a6180758b94938dbf90d639d55652392', + version: 2, + vin: [ + { + txid: '85ddb8215fc3701a493cf1c450644c5ef32c55aaa2f48ae2d008944394f3e4d3', + sequence: 4294967295, + n: 0, + addresses: ['bchtest:qqmd9unmhkpx4pkmr6fkrr8rm6y77vckjvqe8aey35'], + value: '16983000648', + hex: + '47304402202378e55f4d02bb932498deef22dfc1f7a4984858c3b55017e225dd567172252e0220373d27710b5d42a72ac9725959f1605a912fee0ae86a7ad36e7d6d796f14ca29412103c346eee77a77a8d3e073dacc0532ca7a5b9747bc06d88bf091cac9f4bc8bb792' + } + ], + vout: [ + { + value: '16973000422', + n: 0, + spent: true, + hex: '76a91436d2f27bbd826a86db1e93618ce3de89ef33169388ac', + addresses: ['bchtest:qqmd9unmhkpx4pkmr6fkrr8rm6y77vckjvqe8aey35'] + }, + { + value: '10000000', + n: 1, + hex: '76a9140e5b4ad9008bb9a027b7e2d0ef958914e12db20788ac', + addresses: ['bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4'] + } + ], + blockHash: '00000000005242edac4635ac2375a454e801cc1be8b131b622328089731e5e30', + blockHeight: 1265275, + confirmations: 65402, + blockTime: 1540912733, + value: '16983000422', + valueIn: '16983000648', + fees: '226', + hex: + '0200000001d3e4f394439408d0e28af4a2aa552cf35e4c6450c4f13c491a70c35f21b8dd85000000006a47304402202378e55f4d02bb932498deef22dfc1f7a4984858c3b55017e225dd567172252e0220373d27710b5d42a72ac9725959f1605a912fee0ae86a7ad36e7d6d796f14ca29412103c346eee77a77a8d3e073dacc0532ca7a5b9747bc06d88bf091cac9f4bc8bb792ffffffff02e66eabf3030000001976a91436d2f27bbd826a86db1e93618ce3de89ef33169388ac80969800000000001976a9140e5b4ad9008bb9a027b7e2d0ef958914e12db20788ac00000000' +} + +module.exports = { + mockBalance, + mockUtxos, + mockTx +} diff --git a/test/v5/mocks/blockchain-mock.js b/test/v5/mocks/blockchain-mock.js new file mode 100644 index 0000000..af9eed0 --- /dev/null +++ b/test/v5/mocks/blockchain-mock.js @@ -0,0 +1,380 @@ +/* + This library contains mocking data for running unit tests on the address route. +*/ + +'use strict' + +const mockBlockHash = + '00000000000000645dec6503d3f5eafb0d2537a7a28f181d721dec7c44154c79' + +const mockBlockchainInfo = { + chain: 'test', + blocks: 1267694, + headers: 1267694, + bestblockhash: + '000000000000013eaf80d1e157e32804c36a58ad0bb26ca59833880c80298780', + difficulty: 4105763.969035785, + mediantime: 1542131305, + verificationprogress: 0.9999968911571566, + chainwork: '00000000000000000000000000000000000000000000003f0443b0b5f02ce255', + pruned: false, + softforks: [ + { id: 'bip34', version: 2, reject: { status: true } }, + { id: 'bip66', version: 3, reject: { status: true } }, + { id: 'bip65', version: 4, reject: { status: true } } + ], + bip9_softforks: { + csv: { + status: 'active', + startTime: 1456790400, + timeout: 1493596800, + since: 770112 + } + } +} + +const mockChainTips = [ + { + height: 1267696, + hash: '000000000000035bfc43a642ebbff8cfc1e88b1d564de8b0a6c7e2797eeafb21', + branchlen: 0, + status: 'active' + }, + { + height: 1267581, + hash: '000000000001bd12e4124207682563135b21353ca3087bc6b0c409f7f9e1da91', + branchlen: 1, + status: 'valid-fork' + }, + { + height: 1267375, + hash: '00000000702036979df70236bcc45dfc72d43d5d0e6834007afa1fa627e49587', + branchlen: 1036, + status: 'headers-only' + }, + { + height: 1266979, + hash: '00000000e851abbde2174ccdc5c2508b909f81f23c4b7abeac864eee1a4891c7', + branchlen: 1, + status: 'valid-fork' + }, + { + height: 1266973, + hash: '000000007e1324d2900ed70947f89ab8fd4a267e87c9244c333215659a7370d5', + branchlen: 1, + status: 'valid-fork' + }, + { + height: 1266967, + hash: '000000000f37f12843d4800f50ec4de44dce0432e4d448242d43ff04a7a2948d', + branchlen: 1, + status: 'valid-fork' + }, + { + height: 1266963, + hash: '00000000ae11ec99e88898cbd72567cb4cb79e7fc13243357bef79632769deb4', + branchlen: 1, + status: 'valid-fork' + }, + { + height: 1266962, + hash: '000000004f97851259b4460a049e44fa5bd4beadee04a85bf81bd2e90c4f24f9', + branchlen: 1, + status: 'valid-fork' + }, + { + height: 1266950, + hash: '00000000d12a0e4a827f73ecaadb563df7e99817294939f2be8e943079dc60b2', + branchlen: 1, + status: 'valid-fork' + }, + { + height: 1266944, + hash: '00000000612d16af273217818e8e7ac5014a19f68ed04e1bc897b1be0a9c744f', + branchlen: 1, + status: 'valid-fork' + }, + { + height: 1266941, + hash: '0000000000002b91cdb15cacf3368da2c9ffce511d64092193b507dad75f4a27', + branchlen: 1, + status: 'valid-fork' + }, + { + height: 1266896, + hash: '000000000002745f67a66617b1f9f65c2dfb4c4b8a9ed9b1320b98d3ca46cbe5', + branchlen: 1, + status: 'valid-fork' + }, + { + height: 1266481, + hash: '0000000000036babee3ba7654cddb29a9fe5e85c9fbeb44bb53d5ffb694b9670', + branchlen: 1, + status: 'valid-fork' + }, + { + height: 1266452, + hash: '00000000502c3ab1490e0839780a4b441bbe21507c454545941e6adcde73c2ff', + branchlen: 1, + status: 'valid-fork' + }, + { + height: 1266336, + hash: '000000001c9a0b7cd9eb2210f69d5a9a3f4546ba3707deb40993d729190582d7', + branchlen: 10, + status: 'headers-only' + }, + { + height: 1266097, + hash: '000000000028ac0fbd18afe6f001f70cb7549bd2b3082d754dde88ee236368a2', + branchlen: 1, + status: 'valid-fork' + }, + { + height: 1265522, + hash: '00000000000000c6df7a2063030e6ce51399e470cc46e4bf6d67aa3f0feca98d', + branchlen: 1, + status: 'valid-fork' + }, + { + height: 1265470, + hash: '0000000000168e6442a595a02340fccf8dc2b0e8912c632d39e1f870685f6d7c', + branchlen: 2, + status: 'valid-fork' + }, + { + height: 1265275, + hash: '000000008bc2b37ae6af1b886b52e7e1c1122c12badc4ae6c13df64f789267cf', + branchlen: 11027, + status: 'headers-only' + }, + { + height: 1265257, + hash: '00000000005bc3e9973d7273211eab02ae02549c7ebae48764c0bd648d4d7bbe', + branchlen: 114, + status: 'valid-fork' + }, + { + height: 1265254, + hash: '0000000000e5b909d3541857315e9ee103b042d9ba661f25cc3e8fb25a96c8ce', + branchlen: 111, + status: 'valid-fork' + }, + { + height: 1265245, + hash: '000000000066eb8832f6f990baceb379536f437216e22475a6c1b9133d250cb8', + branchlen: 102, + status: 'valid-fork' + }, + { + height: 1264455, + hash: '00000000000001befc1bb17a23208d0b77b2449e37093ce8fbb5e346b19cafcc', + branchlen: 1, + status: 'valid-headers' + }, + { + height: 1264373, + hash: '00000000000001ed3747e04fb95054cac0358fb4d8009d1999fc3ea34413e84a', + branchlen: 175, + status: 'valid-fork' + }, + { + height: 1263912, + hash: '00000000000004635aecbcb97edcd6f5396483338838cb40da804eb354c68bde', + branchlen: 1, + status: 'valid-fork' + }, + { + height: 1262906, + hash: '00000000b31643d92a3e7c38e9755844fc8607e3f1055580a8ff28854e8a8ece', + branchlen: 1, + status: 'valid-fork' + }, + { + height: 1255749, + hash: '000000001dde5b015a99137f4cba87871370f4b6fcfbcc8b126547e6c33177da', + branchlen: 129, + status: 'headers-only' + }, + { + height: 1188789, + hash: '00000000af942ce4eb60b3213cbcb7c98a7330f1f8f1adb4b6376f5e822e15b2', + branchlen: 92, + status: 'headers-only' + } +] + +const mockMempoolInfo = { + size: 87, + bytes: 16816, + usage: 66408, + maxmempool: 300000000, + mempoolminfee: 0 +} + +const mockRawMempool = [ + 'db045bc3bd1088fa91f5ebb05c35cb9e2a91a22377f79b465cc6920b9893123c', + '6b3df7febf2b9834f1409155f88b866dd516b36376eae00e2b455df82e290405' +] + +const mockBlockHeaderConcise = + '000000202ed2723e7590e2b937f6821a99d6764cb8799bf30f8e300000000000000000001d311c02df9a1e3f57b8dbdcf97ec8dbc3109a26779724c63e560b29ad9ea501e2af955d286403183049e39c' + +const mockBlockHeader = { + hash: '00000000000008c3679777df34f1a09565f98b2400a05b7c8da72525fdca3900', + confirmations: 7, + height: 1272859, + version: 2147418112, + versionHex: '7fff0000', + merkleroot: + '846f45604ec6cde2528933305dba80971e9ff0874f74d2811c7c690b2c32706d', + time: 1544207139, + mediantime: 1544202884, + nonce: 641041470, + bits: '1a09faea', + difficulty: 1681035.704111868, + chainwork: '00000000000000000000000000000000000000000000003fc4752e608403be04', + previousblockhash: + '0000000000003e467ebcb7e906645676c2f71b9ac520d6508bea45789b7c217d', + nextblockhash: + '00000000000006899041cdfd6c0b73a97730c362346dde479b77414ad7f25ace', + nTx: 'some value' +} + +const mockTxOut = { + bestblock: '00000000003a7f19730dd9f172d7466658a5c8833dd03ed8f4ba36e3d857b5e7', + confirmations: 10, + value: 0.0001, + scriptPubKey: { + asm: + 'OP_DUP OP_HASH160 0ee020c07f39526ac5505c54fa1ab98490979b83 OP_EQUALVERIFY OP_CHECKSIG', + hex: '76a9140ee020c07f39526ac5505c54fa1ab98490979b8388ac', + reqSigs: 1, + type: 'pubkeyhash', + addresses: ['bchtest:qq8wqgxq0uu4y6k92pw9f7s6hxzfp9umsvtg39pzqf'] + }, + coinbase: false +} + +const mockTxOutProof = + '000000200798039affceb7a381e15dc62443c286efe7cae852393b656807000000000000cf9a7d6c654fd1c4558229a619a550ff749b373fa0f0027eed68b1abf6175d5c07c50f5cffff001d0452403b34000000070d0765da76980d542b5670d27ccda9e717073270f8921ad017deb7ee83fe6c4c22830a48cc8392197f1742a3d81805bd2aa4eb7469f38c74f674429d682cb87efcff1713fd0c388a50f0d11adf05363397ef79d16cdb786d47c941c2cb89f2588f42aced9c05f4203b4440616a9e4266ec47909b9580a643f402b5d4e82cc3cddd73df05a33abf6af3975d973d3033d10c4168a73d58d01f685a96d5a2519cd0de9c77faf3f8725ddf155cbdc8ab8102f173e2d0a0d74767f3bff22f588158d6d5bc71c079c7659cc864819c43877635007650502eb4060fcc9feec3280a41d602ad0a' + +const mockAncestors = [ + '070504de968fba8a23a1362fd7fc0546a420663f2bcc18099d894a41ecf9cce5', + '20af6b7fcd21a3f38bb65d32fe2d66fb1ae554ed9c00eaf1b17d7e81362381a9', + '227e08663a812cf44d91106b09fc8493fe1a2b7c146f30ca09ebd86c9b84b2a5', + '228833be79d4a833165ea199be160efcaeb9325e71e9455f424b27abc9688ba8', + '2de27df02001e7243435cef6d41ea2312b94a5964912eb21466b936852c0b822', + '2ef76ae690d6f6c9559e20358a75a0ce02250b9b8e80e0aa6f782576239f292a', + '3e314678f52d52694726e6da88b94c9f0ea086d2f9af297b01d014b62247839f', + '4773f840d5255f16c0e5effce48316d8b8824a1d3ddd5da275c5fd99e7b3ee42', + '4804b97112da07e2c187516a915dd4cbd4a3814f3c900b8d08eb1d508ab0ac47', + '4ca69f31081a256cb4711315336fe4358d8f04b67792ca5ee17b5f4089a2446a', + '6ac17dce10ae3a3d1d8e6f42546ad9a4bb981e62cba8d48bab03e01b9ea38ae3', + '6ce71182ef341c481a90d8b081a99d9788bdd91a917f9a6594fdfad2c2024a14', + '8413343ac973fb0968ae1809faa8f0ddf18252975481daa683e88b04afa66ccd', + '9430300f3d7ecdabe39671b6fd0e9c36f763f01a202e619c070554ddc5e8713e', + '94f82b5a2cd1647d8168807ccfdebe41931ea1ab789a050d615f08000a63b624', + '9848db6a7d2835002ca7e1097a685d75eb629f44df94007674635b45d0e97199', + '98d809993353df6c4d55fda5e7b0ed2e383601cef1180a9aa9c87e67e8f4dd94', + '9f5513a35ae8fad3dd061865a97d8127c914e3464527641c089993c06d092535', + 'a7201ac90abd2aa42112faa809181c45047b6988a2f649a70a1d5bfb95f46884', + 'a9f1284710c655357412a062b01efaeb174820613784ccbfe44b33b86a8bdbc4', + 'b814f44c9c8ee24a218b9f295a8c1831106b70c15ffe8aeb7534e10d99935fd0', + 'e130ccdcc94dc5eaa4ae37fa50e3ee96283b0e157616bc6e28454b64bae34f48', + 'e68dad4a7292105cfa84fcaef7f99e5d4f2ece9613ca625d4d2ebf61efa84118', + 'fe94caf5da672be3772d2304a6272eb8bc3d3f5cb4a886f39b7981e1485cf74b' +] +const mockBlockInfo = { + verbosity0: '20000000', + verbosity1: { + hash: '00000000c937983704a73af28acdec37b049d214adbda81d7e2a3dd146f6ed09', + confirmations: 1, + size: 3725, + strippedsize: 3725, + weight: 3725, + height: 6725, + version: 1, + versionHex: '00000000', + merkleroot: 'xxxx', + tx: [ + '2afb8264508e2bf3e5288ccad01ed2ab766745b6b9747666b519d59212012c01', + '18f40b1ae56bba3fa1934b737fbe46ed8d5ca40fa9aed95073eeb5a119530cd3', + '349720d878547752607a69eb19e330592fee271fb5376cdfd811bee423558ed8', + '35571c80e7d0e9247b467454ef147d1d5833c775bc2d4164b1bebd4c1f69164f', + '480937e8efacdafeeb97d401ff0dd9ea8e8ddb27244cefa67a03621315bdb0e6', + '523327469d0b90c0de9a905c2fe6e227278fc5b55b9d9911ca151e1c26647065', + '61de4af971d94dbc741762f21dcef08b74b62863a56a1cb3496becdd8d47a858', + '69f70a288403b5bba23030ccd05d2e5cb00394620fdae3b050cff9432cc590ce', + '94472a90fbdba2eb2cba68308415181faedf3585c66b89efcbdb927a0c10ba23', + 'bc6f781f9e2f2df460f89995c5e1c7224e48ccbe1aa2a575961f3f5330259864', + 'f2d945a79bec5454a9ab4d570a150d81124daa308f1c81106a977d6413476944' + ], + time: 111, + mediantime: 111, + nonce: 111, + bits: '1d00ffff', + difficulty: 99.999, + chainwork: 'xxxx', + nTx: 1, + previousblockhash: + '00000000c937983704a73af28acdec37b049d214adbda81d7e2a3dd146f6ed09', + nextblockhash: + '00000000c937983704a73af28acdec37b049d214adbda81d7e2a3dd146f6ed09' + }, + verbosity2: { + hash: '00000000c937983704a73af28acdec37b049d214adbda81d7e2a3dd146f6ed09', + confirmations: 1, + size: 3725, + strippedsize: 3725, + weight: 3725, + height: 6725, + version: 1, + versionHex: '00000000', + merkleroot: 'xxxx', + tx: [ + { + hex: + '01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff0704ffff001d0104ffffffff0100f2052a0100000043410496b538e853519c726a2c91e61ec11600ae1390813a627c66fb8be7947be63c52da7589379515d4e0a604f8141781e62294721166bf621e73a82cbf2342c858eeac00000000', + txid: + '0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098', + hash: + '0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098', + size: 134, + version: 1, + locktime: 0, + vin: [], + vout: [], + blockhash: + '00000000839a8e6886ab5951d76f411475428afc90947ee320161bbf18eb6048', + confirmations: 581882, + time: 1231469665, + blocktime: 1231469665 + } + ], + time: 111, + mediantime: 111, + nonce: 111, + bits: '1d00ffff', + difficulty: 99.999, + chainwork: 'xxxx', + nTx: 1, + previousblockhash: + '00000000c937983704a73af28acdec37b049d214adbda81d7e2a3dd146f6ed09', + nextblockhash: + '00000000c937983704a73af28acdec37b049d214adbda81d7e2a3dd146f6ed09' + } +} +module.exports = { + mockBlockHash, + mockBlockchainInfo, + mockChainTips, + mockMempoolInfo, + mockRawMempool, + mockBlockHeaderConcise, + mockBlockHeader, + mockTxOut, + mockTxOutProof, + mockAncestors, + mockBlockInfo +} diff --git a/test/v5/mocks/control-mock.js b/test/v5/mocks/control-mock.js new file mode 100644 index 0000000..7e44cf0 --- /dev/null +++ b/test/v5/mocks/control-mock.js @@ -0,0 +1,45 @@ +/* + This library contains mocking data for running unit tests on the address route. +*/ + +'use strict' + +const mockGetInfo = { + version: 170200, + protocolversion: 70015, + walletversion: 160300, + balance: 0, + blocks: 1266726, + timeoffset: 0, + connections: 8, + proxy: '', + difficulty: 1, + testnet: true, + keypoololdest: 1536331195, + keypoolsize: 2000, + paytxfee: 0, + relayfee: 0.00001, + errors: 'Warning: unknown new rules activated (versionbit 28)' +} + +const mockGetNetworkInfo = { + version: 190700, + subversion: '/Bitcoin ABC:0.19.7(EB32.0)/', + protocolversion: 70015, + localservices: '0000000000000425', + localrelay: true, + timeoffset: 0, + networkactive: true, + connections: 23, + networks: [{}, {}, {}], + relayfee: 0.00001, + excessutxocharge: 0, + localaddresses: [], + warnings: + "Warning: Unknown block versions being mined! It's possible unknown rules are in effect" +} + +module.exports = { + mockGetInfo, + mockGetNetworkInfo +} diff --git a/test/v5/mocks/electrumx-mock.js b/test/v5/mocks/electrumx-mock.js new file mode 100644 index 0000000..41c96a1 --- /dev/null +++ b/test/v5/mocks/electrumx-mock.js @@ -0,0 +1,122 @@ +/* + Mocking data for electrumx unit tests +*/ + +'use strict' + +const utxos = [ + { + height: 604392, + tx_hash: '7774e449c5a3065144cefbc4c0c21e6b69c987f095856778ef9f45ddd8ae1a41', + tx_pos: 0, + value: 1000 + }, + { + height: 630834, + tx_hash: '4fe60a51e0d8f5134bfd8e5f872d6e502d7f01b28a6afebb27f4438a4f638d53', + tx_pos: 0, + value: 6000 + } +] + +const balance = { + confirmed: 7000, + unconfirmed: 0 +} + +const txHistory = [ + { + height: 601861, + tx_hash: '6181c669614fa18039a19b23eb06806bfece1f7514ab457c3bb82a40fe171a6d' + } +] + +const mempool = [ + { + tx_hash: '45381031132c57b2ff1cbe8d8d3920cf9ed25efd9a0beb764bdb2f24c7d1c7e3', + height: 0, + fee: 24310 + } +] + +const txDetails = { + blockhash: '0000000000000000002aaf94953da3b487317508ebd1003a1d75d6d6ec2e75cc', + blocktime: 1578327094, + confirmations: 31861, + hash: '4db095f34d632a4daf942142c291f1f2abb5ba2e1ccac919d85bdc2f671fb251', + hex: '020000000265d13ef402840c8a51f39779afb7ae4d49e4b0a3c24a3d0e7742038f2c679667010000006441dd1dd72770cadede1a7fd0363574846c48468a398ddfa41a9677c74cac8d2652b682743725a3b08c6c2021a629011e11a264d9036e9d5311e35b5f4937ca7b4e4121020797d8fd4d2fa6fd7cdeabe2526bfea2b90525d6e8ad506ec4ee3c53885aa309ffffffff65d13ef402840c8a51f39779afb7ae4d49e4b0a3c24a3d0e7742038f2c679667000000006441347d7f218c11c04487c1ad8baac28928fb10e5054cd4494b94d078cfa04ccf68e064fb188127ff656c0b98e9ce87f036d183925d0d0860605877d61e90375f774121028a53f95eb631b460854fc836b2e5d31cad16364b4dc3d970babfbdcc3f2e4954ffffffff035ac355000000000017a914189ce02e332548f4804bac65cba68202c9dbf822878dfd0800000000001976a914285bb350881b21ac89724c6fb6dc914d096cd53b88acf9ef3100000000001976a91445f1f1c4a9b9419a5088a3e9c24a293d7a150e6488ac00000000', + locktime: 0, + size: 392, + time: 1578327094, + txid: '4db095f34d632a4daf942142c291f1f2abb5ba2e1ccac919d85bdc2f671fb251', + version: 2, + vin: [ + { + scriptSig: { + asm: 'dd1dd72770cadede1a7fd0363574846c48468a398ddfa41a9677c74cac8d2652b682743725a3b08c6c2021a629011e11a264d9036e9d5311e35b5f4937ca7b4e[ALL|FORKID] 020797d8fd4d2fa6fd7cdeabe2526bfea2b90525d6e8ad506ec4ee3c53885aa309', + hex: '41dd1dd72770cadede1a7fd0363574846c48468a398ddfa41a9677c74cac8d2652b682743725a3b08c6c2021a629011e11a264d9036e9d5311e35b5f4937ca7b4e4121020797d8fd4d2fa6fd7cdeabe2526bfea2b90525d6e8ad506ec4ee3c53885aa309' + }, + sequence: 4294967295, + txid: '6796672c8f0342770e3d4ac2a3b0e4494daeb7af7997f3518a0c8402f43ed165', + vout: 1 + }, + { + scriptSig: { + asm: '347d7f218c11c04487c1ad8baac28928fb10e5054cd4494b94d078cfa04ccf68e064fb188127ff656c0b98e9ce87f036d183925d0d0860605877d61e90375f77[ALL|FORKID] 028a53f95eb631b460854fc836b2e5d31cad16364b4dc3d970babfbdcc3f2e4954', + hex: '41347d7f218c11c04487c1ad8baac28928fb10e5054cd4494b94d078cfa04ccf68e064fb188127ff656c0b98e9ce87f036d183925d0d0860605877d61e90375f774121028a53f95eb631b460854fc836b2e5d31cad16364b4dc3d970babfbdcc3f2e4954' + }, + sequence: 4294967295, + txid: '6796672c8f0342770e3d4ac2a3b0e4494daeb7af7997f3518a0c8402f43ed165', + vout: 0 + } + ], + vout: [ + { + n: 0, + scriptPubKey: { + addresses: ['bitcoincash: pqvfecpwxvj53ayqfwkxtjaxsgpvnklcyg8xewk9hl'], + asm: 'OP_HASH160 189ce02e332548f4804bac65cba68202c9dbf822 OP_EQUAL', + hex: 'a914189ce02e332548f4804bac65cba68202c9dbf82287', + reqSigs: 1, + type: 'scripthash' + }, + value: 0.0562057 + }, + { + n: 1, + scriptPubKey: { + addresses: ['bitcoincash: qq59hv6s3qdjrtyfwfxxldkuj9xsjmx48vrz882knz'], + asm: 'OP_DUP OP_HASH160 285bb350881b21ac89724c6fb6dc914d096cd53b OP_EQUALVERIFY OP_CHECKSIG', + hex: '76a914285bb350881b21ac89724c6fb6dc914d096cd53b88ac', + reqSigs: 1, + type: 'pubkeyhash' + }, + value: 0.00589197 + }, + { + n: 2, + scriptPubKey: { + addresses: ['bitcoincash: qpzlruwy4xu5rxjs3z37nsj29y7h59gwvsu4ddp0u4'], + asm: 'OP_DUP OP_HASH160 45f1f1c4a9b9419a5088a3e9c24a293d7a150e64 OP_EQUALVERIFY OP_CHECKSIG', + hex: '76a91445f1f1c4a9b9419a5088a3e9c24a293d7a150e6488ac', + reqSigs: 1, + type: 'pubkeyhash' + }, + value: 0.03272697 + } + ] +} + +const blockHeaders = [ + '010000008b52bbd72c2f49569059f559c1b1794de5192e4f7d6d2b03c7482bad0000000083e4f8a9d502ed0c419075c1abb5d56f878a2e9079e5612bfb76a2dc37d9c42741dd6849ffff001d2b909dd6', + '01000000f528fac1bcb685d0cd6c792320af0300a5ce15d687c7149548904e31000000004e8985a786d864f21e9cbb7cbdf4bc9265fe681b7a0893ac55a8e919ce035c2f85de6849ffff001d385ccb7c' +] + +module.exports = { + utxos, + balance, + txHistory, + mempool, + txDetails, + blockHeaders +} diff --git a/test/v5/mocks/encryption-mocks.js b/test/v5/mocks/encryption-mocks.js new file mode 100644 index 0000000..bac2147 --- /dev/null +++ b/test/v5/mocks/encryption-mocks.js @@ -0,0 +1,195 @@ +/* + Mock data for the encryption test library +*/ + +const mockNoSendTx = { + txid: 'a3b62cd4f4c56ba52139179db14bffd4ab22a2e077f3c62bd5cf0541bfcaf023', + hash: 'a3b62cd4f4c56ba52139179db14bffd4ab22a2e077f3c62bd5cf0541bfcaf023', + version: 2, + size: 226, + locktime: 0, + vin: [ + { + txid: '681cc1d392975a7e78cbd490b8a940d7eb029c554f2cee332e3a7119d77fb189', + vout: 1, + scriptSig: { + asm: + '30450221008a7c86ce3a9f5765573440143a00631bb5afa9a58b24674a7fa794bb11ffffc30220397dd4c672c3e0dbe0c4eb6390b5727c5fdc3a42cf3ae275ac13b44d27bb234f[ALL|FORKID] 02bc9fc26ae2bd9a9a14e9baeda4b4fdd95a00bcbf375732c647488b3ce82a2fd7', + hex: + '4830450221008a7c86ce3a9f5765573440143a00631bb5afa9a58b24674a7fa794bb11ffffc30220397dd4c672c3e0dbe0c4eb6390b5727c5fdc3a42cf3ae275ac13b44d27bb234f412102bc9fc26ae2bd9a9a14e9baeda4b4fdd95a00bcbf375732c647488b3ce82a2fd7' + }, + sequence: 4294967295 + } + ], + vout: [ + { + value: 0.000006, + n: 0, + scriptPubKey: { + asm: + 'OP_DUP OP_HASH160 3c79ba54317c4be9cbfa5aaf357f74b97f3d9696 OP_EQUALVERIFY OP_CHECKSIG', + hex: '76a9143c79ba54317c4be9cbfa5aaf357f74b97f3d969688ac', + reqSigs: 1, + type: 'pubkeyhash', + addresses: ['bitcoincash:qq78nwj5x97yh6wtlfd27dtlwjuh70vkjc59h8tgtg'] + } + }, + { + value: 0.00003664, + n: 1, + scriptPubKey: { + asm: + 'OP_DUP OP_HASH160 58502a73888e0a386fc69b44850af4b2a86ac9a6 OP_EQUALVERIFY OP_CHECKSIG', + hex: '76a91458502a73888e0a386fc69b44850af4b2a86ac9a688ac', + reqSigs: 1, + type: 'pubkeyhash', + addresses: ['bitcoincash:qpv9q2nn3z8q5wr0c6d5fpg27je2s6kf5cavp4ney0'] + } + } + ], + hex: + '020000000189b17fd719713a2e33ee2c4f559c02ebd740a9b890d4cb787e5a9792d3c11c68010000006b4830450221008a7c86ce3a9f5765573440143a00631bb5afa9a58b24674a7fa794bb11ffffc30220397dd4c672c3e0dbe0c4eb6390b5727c5fdc3a42cf3ae275ac13b44d27bb234f412102bc9fc26ae2bd9a9a14e9baeda4b4fdd95a00bcbf375732c647488b3ce82a2fd7ffffffff0258020000000000001976a9143c79ba54317c4be9cbfa5aaf357f74b97f3d969688ac500e0000000000001976a91458502a73888e0a386fc69b44850af4b2a86ac9a688ac00000000' +} + +const mockFulcrumTxHistory = { + success: true, + transactions: [ + { + transactions: [ + { + height: 511463, + tx_hash: + 'eff00a9538487ff44243c75fb13de19b5783454c42c81b9aff9afbfd09cbaec3' + }, + { + height: 511464, + tx_hash: + '7e9aa7a74de2b30200a2d6fc748ff35a0c753221444194f720bb7f61ef1d9153' + }, + { + height: 513373, + tx_hash: + '6960255abe64893073921e96bf3c053c82686e0fc22a565494fbe2a31e766975' + }, + { + height: 513373, + tx_hash: + '9ea667bcfc9cd337bd6c5583d8094c1b1942bd2015d95b54189deac5070eeff0' + }, + { + height: 560481, + tx_hash: + 'ecc1b51bac767880382bf3190ff17abf78d0936843a022a943d871116ed50368' + }, + { + height: 560615, + tx_hash: + 'b3792d28377b975560e1b6f09e48aeff8438d4c6969ca578bd406393bd50bd7d' + }, + { + height: 561568, + tx_hash: + '8bc2134c7e48e56e1769b3d7c4c1e3a0acc68e1e58160eee6fa67f3208c07262' + }, + { + height: 561569, + tx_hash: + 'ceb0cab0e37b59caf3ca29e1a698d19ff47f2827dd09cb2f3b91b9100b1dad1c' + }, + { + height: 561572, + tx_hash: + '0f9b49cafeb9ae1d741cdb12137c92816aa8470944c270a78ba2e610bd59190d' + }, + { + height: 561582, + tx_hash: + 'e4a0ac48ff3f42fc342717a2a3d34248e5e85bae79d59bd20e1b60e61b1c500f' + }, + { + height: 562106, + tx_hash: + '1afcc63b244182647909539ebe3f4a44b8ea4120a95edb8d9eebe5347b9491bb' + }, + { + height: 562106, + tx_hash: + 'c42f8f16d3baa2ee343ea89ef110dfe094992379d08edd30887b8ca7ee671c9a' + } + ] + } + ] +} + +const mockTxDetails2 = { + txid: '7e9aa7a74de2b30200a2d6fc748ff35a0c753221444194f720bb7f61ef1d9153', + hash: '7e9aa7a74de2b30200a2d6fc748ff35a0c753221444194f720bb7f61ef1d9153', + version: 1, + size: 221, + locktime: 0, + vin: [ + { + txid: 'eff00a9538487ff44243c75fb13de19b5783454c42c81b9aff9afbfd09cbaec3', + vout: 0, + scriptSig: { + asm: + '30440220150e7c9b646fe1f7f576456c9808c89a710e8fbb753e9f5ca6ed2d39a0b69ba602202212968163d8dfe35eec5975fce38440082029cb2691a3e0ad1714f9c412c47a[ALL|FORKID] 044eb40b025df18409f2a5197b010dd62a9e65d9a74e415e5b10367721a9c4baa7ebfee22d14b8ece1c9bd70c0d9e5e8b00b61b81b88a1b5ce6f24eac6b8a34b2c', + hex: + '4730440220150e7c9b646fe1f7f576456c9808c89a710e8fbb753e9f5ca6ed2d39a0b69ba602202212968163d8dfe35eec5975fce38440082029cb2691a3e0ad1714f9c412c47a4141044eb40b025df18409f2a5197b010dd62a9e65d9a74e415e5b10367721a9c4baa7ebfee22d14b8ece1c9bd70c0d9e5e8b00b61b81b88a1b5ce6f24eac6b8a34b2c' + }, + sequence: 4294967295 + } + ], + vout: [ + { + value: 0.01266023, + n: 0, + scriptPubKey: { + asm: 'OP_HASH160 91bacd6785e3f8c78d5f29b4020b94c753444130 OP_EQUAL', + hex: 'a91491bacd6785e3f8c78d5f29b4020b94c75344413087', + reqSigs: 1, + type: 'scripthash', + addresses: ['bitcoincash:pzgm4nt8sh3l33udtu5mgqstjnr4x3zpxqrrrndnw5'] + } + } + ], + hex: + '0100000001c3aecb09fdfb9aff9a1bc8424c4583579be13db15fc74342f47f4838950af0ef000000008a4730440220150e7c9b646fe1f7f576456c9808c89a710e8fbb753e9f5ca6ed2d39a0b69ba602202212968163d8dfe35eec5975fce38440082029cb2691a3e0ad1714f9c412c47a4141044eb40b025df18409f2a5197b010dd62a9e65d9a74e415e5b10367721a9c4baa7ebfee22d14b8ece1c9bd70c0d9e5e8b00b61b81b88a1b5ce6f24eac6b8a34b2cffffffff01675113000000000017a91491bacd6785e3f8c78d5f29b4020b94c7534441308700000000', + blockhash: '000000000000000001aab6818b4ad0379a3f7a13940d9b3d4838b3dea1d0a083', + confirmations: 149222, + time: 1515088493, + blocktime: 1515088493 +} + +const mockFulcrumNoTxHistory = { + success: true, + transactions: [ + { + transactions: [], + address: 'bitcoincash:qrgqqkky28jdkv3w0ctrah0mz3jcsnsklc34gtukrh' + } + ] +} + +const mockFulcrumNoSendBalance = { + success: true, + transactions: [ + { + transactions: [ + { + height: 633578, + tx_hash: + 'a3b62cd4f4c56ba52139179db14bffd4ab22a2e077f3c62bd5cf0541bfcaf023' + } + ] + } + ] +} + +module.exports = { + mockNoSendTx, + mockFulcrumTxHistory, + mockTxDetails2, + mockFulcrumNoTxHistory, + mockFulcrumNoSendBalance +} diff --git a/test/v5/mocks/express-mocks.js b/test/v5/mocks/express-mocks.js new file mode 100644 index 0000000..d4753e7 --- /dev/null +++ b/test/v5/mocks/express-mocks.js @@ -0,0 +1,89 @@ +/* + Contains mocks of Express req and res objects. +*/ + +'use strict' + +const sinon = require('sinon') + +// Inspect JS Objects. +const util = require('util') +util.inspect.defaultOptions = { + showHidden: true, + colors: true +} + +// mock for res.send() +function fakeSend (arg) { + // console.log(`res.send: ${util.inspect(arg)}`); + mockRes.output = arg + return arg +} + +// mock for res.json() +function fakeJson (arg) { + // console.log(`res.json: ${util.inspect(arg)}`); + mockRes.output = arg + return arg +} + +// mock for res.setStatus(num) +const setStatusCode = arg => { + mockRes.statusCode = arg +} + +const mockReq = { + accepts: sinon.stub().returns({}), + acceptsCharsets: sinon.stub().returns({}), + acceptsEncodings: sinon.stub().returns({}), + acceptsLanguages: sinon.stub().returns({}), + body: {}, + flash: sinon.stub().returns({}), + get: sinon.stub().returns({}), + is: sinon.stub().returns({}), + params: {}, + query: {}, + session: {}, + locals: {}, + headers: {} +} + +const mockRes = { + append: sinon.stub().returns({}), + attachement: sinon.stub().returns({}), + clearCookie: sinon.stub().returns({}), + cookie: sinon.stub().returns({}), + download: sinon.stub().returns({}), + end: sinon.stub().returns({}), + get: sinon.stub().returns({}), + headersSent: sinon.stub().returns({}), + json: sinon.stub().callsFake(fakeJson), + jsonp: sinon.stub().returns({}), + links: sinon.stub().returns({}), + locals: {}, + location: sinon.stub().returns({}), + output: null, // Used for retrieving output data. + redirect: sinon.stub().returns({}), + render: sinon.stub().returns({}), + send: sinon.stub().callsFake(fakeSend), + sendFile: sinon.stub().returns({}), + sendStatus: sinon.stub().returns({}), + set: sinon.stub().returns({}), + status: sinon.stub().callsFake(setStatusCode), + statusCode: null, // Default value before calling stats(); + type: sinon.stub().returns({}), + vary: sinon.stub().returns({}), + write: sinon.stub().returns({}), + setHeader: sinon.stub().returns({}), + format: sinon.stub().returns({}) +} + +// Dev-Note on Rate Limits: Since next() is mocked, I can call the Sinon untility +// functions on it, like called(), to see if this stub was called. +const mockNext = sinon.stub().returns() + +module.exports = { + mockReq, + mockRes, + mockNext +} diff --git a/test/v5/mocks/mining-mocks.js b/test/v5/mocks/mining-mocks.js new file mode 100644 index 0000000..d8618d7 --- /dev/null +++ b/test/v5/mocks/mining-mocks.js @@ -0,0 +1,21 @@ +/* + This library contains mocking data for running unit tests on the address route. +*/ + +'use strict' + +const mockMiningInfo = { + blocks: 1270185, + currentblocksize: 0, + currentblocktx: 0, + difficulty: 1, + warnings: + "Warning: Unknown block versions being mined! It's possible unknown rules are in effect", + networkhashps: 517410290.9365583, + pooledtx: 5, + chain: 'test' +} + +module.exports = { + mockMiningInfo +} diff --git a/test/v5/mocks/price-mock.js b/test/v5/mocks/price-mock.js new file mode 100644 index 0000000..5bf3569 --- /dev/null +++ b/test/v5/mocks/price-mock.js @@ -0,0 +1,241 @@ +/* + This library contains mocking data for running unit tests on the address route. +*/ + +'use strict' + +const mockCoinbaseFeed = { + data: { + currency: 'BCH', + rates: { + AED: '918.11634', + AFN: '19208.6584998', + ALGO: '823.28722002635056355', + ALL: '26365.4673517', + AMD: '120379.9761886', + ANG: '448.6632494', + AOA: '162813.68075', + ARS: '19392.84240565', + ATOM: '45.3670932026499700525', + AUD: '354.4385981', + AWG: '449.91', + AZN: '425.539875', + BAL: '18.824215193276753072', + BAM: '415.45514235', + BAND: '40.7768732564399590085', + BAT: '1168.9711697428450012', + BBD: '499.9', + BCH: '1.0', + BDT: '21194.1523216', + BGN: '415.36691', + BHD: '94.2501462', + BIF: '483670.78174295', + BMD: '249.95', + BND: '339.227141', + BOB: '1725.8732563', + BRL: '1401.494645', + BSD: '249.95', + BSV: '1.567361207606150942625', + BTC: '0.021275', + BTN: '18323.91073475', + BWP: '2859.81217315', + BYN: '640.5913561', + BYR: '6405913.561', + BZD: '503.8107177', + CAD: '329.6765515', + CDF: '490377.5463717', + CGLD: '121.6805004503079241715', + CHF: '227.5939721', + CLF: '7.13432285', + CLP: '196860.63474705', + CNH: '1669.50978125', + CNY: '1670.21589', + COMP: '2.429765723728978241', + COP: '961722.29431455', + CRC: '150790.4338804', + CUC: '250.01273745', + CVE: '23620.275', + CZK: '5790.09175', + DAI: '247.842348666936355635', + DASH: '3.338230383973288798', + DJF: '44495.8985401', + DKK: '1580.608815', + DOP: '14597.4179324', + DZD: '32214.830745', + EEK: '3652.69206545', + EGP: '3924.839875', + EOS: '96.8423091824874092665', + ERN: '3749.25274945', + ETB: '9313.6508972', + ETC: '45.04821122825989111', + ETH: '0.658777328255340453815', + EUR: '212.33', + FJD: '534.9054975', + FKP: '193.01413945', + GBP: '193.115', + GEL: '807.3385', + GGP: '193.01413945', + GHS: '1453.70845015', + GIP: '193.01413945', + GMD: '12934.9125', + GNF: '2447970.79115335', + GTQ: '1944.576007', + GYD: '52260.2868518', + HKD: '1937.122498', + HNL: '6129.328889', + HRK: '1610.802775', + HTG: '15746.55080985', + HUF: '77512.6058777', + IDR: '3675351.03275', + ILS: '846.1232415', + IMP: '193.01413945', + INR: '18345.655135', + IQD: '298009.02802165', + ISK: '34745.5495', + JEP: '193.01413945', + JMD: '36409.6696094', + JOD: '177.21455', + JPY: '26360.601825', + KES: '27184.562', + KGS: '20308.0755724', + KHR: '1025880.7533059', + KMF: '105029.03124175', + KNC: '274.68542227594924615', + KRW: '284915.23080495', + KWD: '76.4532063', + KYD: '208.2943328', + KZT: '107014.42658325', + LAK: '2310183.71158185', + LBP: '378370.54450325', + LINK: '22.894634759817007489', + LKR: '46090.95971405', + LRC: '1457.8594342373869446', + LRD: '48852.72175115', + LSL: '4122.3008749', + LTC: '5.2062070401999589123', + LTL: '806.05000775', + LVL: '164.03243695', + LYD: '341.5926678', + MAD: '2298.47346335', + MDL: '4228.5806147', + MGA: '982179.7557538', + MKD: '13087.73767885', + MKR: '0.43764476902003812305', + MMK: '322679.7621378', + MNT: '709349.8756452', + MOP: '1995.2583685', + MRO: '89232.15', + MTL: '170.9003131', + MUR: '9974.21650765', + MVR: '3849.23', + MWK: '188665.67006785', + MXN: '5298.265135', + MYR: '1036.517655', + MZN: '18229.3548997', + NAD: '4134.173', + NGN: '95481.03472305', + NIO: '8698.6554209', + NMR: '8.624556609112112958', + NOK: '2334.203066', + NPR: '29318.1806909', + NZD: '379.2351378', + OMG: '74.464123456422320125', + OMR: '96.23549905', + OXT: '1033.27821413807369045', + PAB: '249.95', + PEN: '896.6071427', + PGK: '874.90073485', + PHP: '12136.85639315', + PKR: '40613.9575836', + PLN: '971.640633', + PYG: '1756126.21474795', + QAR: '910.1304375', + REN: '769.6689761354887733', + REP: '18.2912550311013566725', + REPV2: '18.1582473904586151205', + RON: '1035.54285', + RSD: '24943.76025', + RUB: '19402.093805', + RWF: '244237.3512583', + SAR: '937.65668115', + SBD: '2019.805958', + SCR: '4576.6749819', + SEK: '2207.698372', + SGD: '339.57582125', + SHP: '193.01413945', + SLL: '2490751.74625075', + SOS: '145044.39506805', + SRD: '3537.7923', + SSP: '32558.487', + STD: '5250063.0293496', + SVC: '2187.04525345', + SZL: '4118.75383445', + THB: '7800.314625', + TJS: '2579.48624955', + TMT: '874.825', + TND: '688.2998125', + TOP: '579.953986', + TRY: '1969.70598', + TTD: '1696.3476626', + TWD: '7179.16412995', + TZS: '579879.84308155', + UAH: '7090.3476468', + UGX: '935325.80066935', + UMA: '29.276720351390922145', + UNI: '78.76161966283282792', + USD: '249.95', + USDC: '249.95', + UYU: '10724.30446005', + UZS: '2592145.88136715', + VEF: '62109486.17813795', + VES: '113073216.80555545', + VND: '5792476.0820382', + VUV: '28582.2184128', + WST: '655.5403657', + XAF: '139318.05656485', + XAG: '10.233577875', + XAU: '0.1313462255', + XCD: '675.5023725', + XDR: '177.0940741', + XLM: '2935.3219224332811736', + XOF: '139318.05656485', + XPD: '0.1065011955', + XPF: '25344.75028595', + XPT: '0.290981792', + XRP: '1016.6768354687817832', + XTZ: '114.010080507218287957', + YER: '62574.97275195', + YFI: '0.0181099566723181033515', + ZAR: '4130.57372', + ZEC: '3.88966697790227161905', + ZMK: '1313006.15998725', + ZMW: '5048.6210738', + ZRX: '642.804422350408735165', + ZWL: '80483.9' + } + } +} + +const mockCoinexFeed = { + code: 0, + data: { + date: 1605649499848, + ticker: { + vol: '26717.39062407', + low: '10.4000', + open: '11.8000', + high: '19.0029', + last: '18.5000', + buy: '18.0100', + buy_amount: '200.00000000', + sell: '18.5000', + sell_amount: '47.89580474' + } + }, + message: 'OK' +} + +module.exports = { + mockCoinbaseFeed, + mockCoinexFeed +} diff --git a/test/v5/mocks/raw-transactions-mocks.js b/test/v5/mocks/raw-transactions-mocks.js new file mode 100644 index 0000000..1649d52 --- /dev/null +++ b/test/v5/mocks/raw-transactions-mocks.js @@ -0,0 +1,157 @@ +/* + This library contains mocking data for running unit tests. +*/ + +'use strict' + +const mockDecodeRawTransaction = { + txid: 'a332237d82a2543af1b0e1ae3c8cea1610c290ebcaf084a7e9894a61de0be988', + hash: 'a332237d82a2543af1b0e1ae3c8cea1610c290ebcaf084a7e9894a61de0be988', + size: 226, + version: 2, + locktime: 0, + vin: [ + { + txid: '21cced645eab150585ed7ca7c96edebab5793cc0a3b3b286c42fd7d6d798b5b9', + vout: 1, + scriptSig: { + asm: + '3045022100a7b1b08956abb8d6f322aa709d8583c8ea492ba0585f1a6f4f9983520af74a5a0220411aee4a9a54effab617b0508c504c31681b15f9b187179b4874257badd41390[ALL|FORKID] 0360cfc66fdacb650bc4c83b4e351805181ee696b7d5ab4667c57b2786f51c413d', + hex: + '483045022100a7b1b08956abb8d6f322aa709d8583c8ea492ba0585f1a6f4f9983520af74a5a0220411aee4a9a54effab617b0508c504c31681b15f9b187179b4874257badd4139041210360cfc66fdacb650bc4c83b4e351805181ee696b7d5ab4667c57b2786f51c413d' + }, + sequence: 4294967295 + } + ], + vout: [ + { + value: 0.0001, + n: 0, + scriptPubKey: { + asm: + 'OP_DUP OP_HASH160 eb4b180def88e3f5625b2d8ae2c098ff7d85f664 OP_EQUALVERIFY OP_CHECKSIG', + hex: '76a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac', + reqSigs: 1, + type: 'pubkeyhash', + addresses: [Array] + } + }, + { + value: 0.09989752, + n: 1, + scriptPubKey: { + asm: + 'OP_DUP OP_HASH160 eb4b180def88e3f5625b2d8ae2c098ff7d85f664 OP_EQUALVERIFY OP_CHECKSIG', + hex: '76a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac', + reqSigs: 1, + type: 'pubkeyhash', + addresses: [Array] + } + } + ] +} + +const mockDecodeScript = { + asm: + '0 0 -57 OP_NOP6 OP_LSHIFT OP_UNKNOWN OP_UNKNOWN OP_UNKNOWN c486b2b3a3c03c79b5bade6ec9a77ced850515ab5e64edcc21010000006b483045022100a7b1b08956abb8d6f322aa OP_2OVER OP_NUMEQUALVERIFY OP_OR OP_INVERT OP_UNKNOWN OP_UNKNOWN 2ba0585f1a6f4f9983520af74a5a0220411aee4a9a54effab617b0508c504c31681b15f9b187179b4874257badd4139041210360cfc66fdacb650bc4c83b4e351805181ee696b7d5ab 67c57b2786f51c413dffffffff0210270000000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac786e9800000000001976a914eb4b180def88e3f5625b [error]', + type: 'nonstandard', + p2sh: 'bchtest:pzy6dwfy6yf373w0dr05a6flfqksurjhwcl3awhdvm' +} + +const mockRawTransactionConcise = + '02000000014e6b52500110b1c30315b85805fb274f0f4afceffc1589f889b27709e59e987d000000006a473044022052762770baa71c1a0b9544ad0f1ea343d32c22aa87c5f8397b6852f464c15b1e02201f4390745cb470e21e0e3c14229f39fef55ea0643a4c997f99d9f3501eae09b7412103c346eee77a77a8d3e073dacc0532ca7a5b9747bc06d88bf091cac9f4bc8bb792ffffffff02d1778f950a0000001976a91436d2f27bbd826a86db1e93618ce3de89ef33169388ac80969800000000001976a914152ea3cd65f18cb8fa9146c84ea1a97af8f051de88ac00000000' + +const mockRawTransactionVerbose = { + hex: + '02000000014e6b52500110b1c30315b85805fb274f0f4afceffc1589f889b27709e59e987d000000006a473044022052762770baa71c1a0b9544ad0f1ea343d32c22aa87c5f8397b6852f464c15b1e02201f4390745cb470e21e0e3c14229f39fef55ea0643a4c997f99d9f3501eae09b7412103c346eee77a77a8d3e073dacc0532ca7a5b9747bc06d88bf091cac9f4bc8bb792ffffffff02d1778f950a0000001976a91436d2f27bbd826a86db1e93618ce3de89ef33169388ac80969800000000001976a914152ea3cd65f18cb8fa9146c84ea1a97af8f051de88ac00000000', + txid: 'bd320377db7026a3dd5c7ec444596c0ee18fc25c4f34ee944adc03e432ce1971', + hash: 'bd320377db7026a3dd5c7ec444596c0ee18fc25c4f34ee944adc03e432ce1971', + size: 225, + version: 2, + locktime: 0, + vin: [ + { + txid: '7d989ee50977b289f88915fceffc4a0f4f27fb0558b81503c3b1100150526b4e', + vout: 0, + scriptSig: { + asm: + '3044022052762770baa71c1a0b9544ad0f1ea343d32c22aa87c5f8397b6852f464c15b1e02201f4390745cb470e21e0e3c14229f39fef55ea0643a4c997f99d9f3501eae09b7[ALL|FORKID] 03c346eee77a77a8d3e073dacc0532ca7a5b9747bc06d88bf091cac9f4bc8bb792', + hex: + '473044022052762770baa71c1a0b9544ad0f1ea343d32c22aa87c5f8397b6852f464c15b1e02201f4390745cb470e21e0e3c14229f39fef55ea0643a4c997f99d9f3501eae09b7412103c346eee77a77a8d3e073dacc0532ca7a5b9747bc06d88bf091cac9f4bc8bb792' + }, + sequence: 4294967295 + } + ], + vout: [ + { + value: 454.58880465, + n: 0, + scriptPubKey: { + asm: + 'OP_DUP OP_HASH160 36d2f27bbd826a86db1e93618ce3de89ef331693 OP_EQUALVERIFY OP_CHECKSIG', + hex: '76a91436d2f27bbd826a86db1e93618ce3de89ef33169388ac', + reqSigs: 1, + type: 'pubkeyhash', + addresses: ['bchtest:qqmd9unmhkpx4pkmr6fkrr8rm6y77vckjvqe8aey35'] + } + }, + { + value: 0.1, + n: 1, + scriptPubKey: { + asm: + 'OP_DUP OP_HASH160 152ea3cd65f18cb8fa9146c84ea1a97af8f051de OP_EQUALVERIFY OP_CHECKSIG', + hex: '76a914152ea3cd65f18cb8fa9146c84ea1a97af8f051de88ac', + reqSigs: 1, + type: 'pubkeyhash', + addresses: ['bchtest:qq2jag7dvhccew86j9rvsn4p49a03uz3mcpw3d6aca'] + } + } + ], + blockhash: '000000000000026fa244de975ca89ea08008aa566564ce2e8ebb3144361b601b', + confirmations: 125, + time: 1542646373, + blocktime: 1542646373 +} + +const mockWHDecode = { + txid: 'f8a9857fe3b8a288b5fcafb1b0fc196731f433add6d962f77acd7c10b970ff89', + fee: '500', + sendingaddress: 'bchtest:qzjtnzcvzxx7s0na88yrg3zl28wwvfp97538sgrrmr', + referenceaddress: 'bchtest:qzjtnzcvzxx7s0na88yrg3zl28wwvfp97538sgrrmr', + ismine: false, + version: 0, + type_int: 0, + type: 'Simple Send', + propertyid: 368, + precision: '8', + amount: '10.00000000', + valid: true, + blockhash: '0000000046ba0bcef78caaa4492622176bbc563cf249ab52340a0449bc8e26f6', + blocktime: 1542814183, + positioninblock: 57, + block: 1269008, + confirmations: 2 +} + +const mockWHCreateInput = { + txid: 'f7ed9cf23dee85910f6269c9a101a75fcfd2f3c6fc81f17fad824ff7aaf99ab2', + vout: 1, + scriptPubKey: '76a914a4b98b0c118de83e7d39c834445f51dce62425f588ac', + amount: 0.09984138, + satoshis: 9984138, + height: 1269011, + confirmations: 4, + legacyAddress: 'mvXwPH74hW2yVTWwDwzsjGoaUAqJvWk7ZJ', + cashAddress: 'bchtest:qzjtnzcvzxx7s0na88yrg3zl28wwvfp97538sgrrmr', + value: 0.09984138 +} + +module.exports = { + mockDecodeRawTransaction, + mockDecodeScript, + mockRawTransactionConcise, + mockRawTransactionVerbose, + mockWHDecode, + mockWHCreateInput +} diff --git a/test/v5/mocks/slp-mocks.js b/test/v5/mocks/slp-mocks.js new file mode 100644 index 0000000..1ab3327 --- /dev/null +++ b/test/v5/mocks/slp-mocks.js @@ -0,0 +1,1107 @@ +/* + This library contains mocking data for running unit tests. +*/ + +'use strict' + +const mockList = { + t: [ + { + tokenDetails: { + tokenIdHex: + 'df808a41672a0a0ae6475b44f272a107bc9961b90f29dc918d71301f24fe92fb', + documentUri: '', + documentSha256: '', + symbol: 'NAKAMOTO', + name: 'NAKAMOTO', + decimals: 8 + }, + tokenStats: { + qty_valid_txns_since_genesis: 241, + qty_valid_token_utxos: 151, + qty_valid_token_addresses: 113, + qty_token_circulating_supply: '20995990', + qty_token_burned: '4010', + qty_token_minted: '21000000', + qty_satoshis_locked_up: 81900 + } + } + ] +} + +const mockSingleToken = { + t: [ + { + tokenDetails: { + decimals: 0, + tokenIdHex: + '38e97c5d7d3585a2cbf3f9580c82ca33985f9cb0845d4dcce220cb709f9538b0', + timestamp: '2019-06-14 06:31:37', + timestamp_unix: 1560493897, + transactionType: 'GENESIS', + versionType: 1, + documentUri: 'ot@ot.com', + documentSha256Hex: null, + symbol: 'OT', + name: 'Test First SLP oasis Token', + batonVout: 2, + containsBaton: true, + genesisOrMintQuantity: '10000', + sendOutputs: null + }, + tokenStats: { + block_created: 1308634, + block_last_active_send: 1308655, + block_last_active_mint: 1308636, + qty_valid_txns_since_genesis: 11, + qty_valid_token_utxos: 9, + qty_valid_token_addresses: 2, + qty_token_minted: '20000', + qty_token_burned: '0', + qty_token_circulating_supply: '20000', + qty_satoshis_locked_up: 4914, + minting_baton_status: 'ALIVE' + } + } + ] +} + +const mockNftGroup = { + decimals: 0, + timestamp: '2021-05-03 10:36:01', + timestamp_unix: 1620038161, + versionType: 129, + documentUri: 'psfoundation.cash', + symbol: 'PSF.TEST.GROUP', + name: 'PSF Test NFT Group', + containsBaton: true, + id: '68cd33ecd909068fbea318ae5ff1d6207cf754e53b191327d6d73b6916424c0a', + documentHash: null, + initialTokenQty: 1000000, + blockCreated: 686117, + totalMinted: null, + totalBurned: null, + circulatingSupply: null +} + +const mockNftChildren = [ + { + decimals: 0, + timestamp: '2021-05-03 11:59:30', + timestamp_unix: 1620043170, + versionType: 65, + documentUri: 'psfoundation.cash', + symbol: 'PSF.TEST.CHILD.1', + name: 'PSF Test NFT Child #1', + containsBaton: false, + id: '45a30085691d6ea586e3ec2aa9122e9b0e0d6c3c1fd357decccc15d8efde48a9', + documentHash: null, + initialTokenQty: 1, + nftParentId: '68cd33ecd909068fbea318ae5ff1d6207cf754e53b191327d6d73b6916424c0a', + blockCreated: 686130, + totalMinted: null, + totalBurned: null, + circulatingSupply: null, + // only in axios.data.t + transactionType: 'GENESIS', + tokenIdHex: '45a30085691d6ea586e3ec2aa9122e9b0e0d6c3c1fd357decccc15d8efde48a9' + }, + { + decimals: 0, + timestamp: '2021-05-03 11:59:30', + timestamp_unix: 1620043170, + versionType: 65, + documentUri: 'psfoundation.cash', + symbol: 'PSF.TEST.CHILD.2', + name: 'PSF Test NFT Child #2', + containsBaton: false, + id: '928ce61fe1006b1325a0ba0dce700bf83986a6f0691ba26e121c9ac035d12a55', + documentHash: null, + initialTokenQty: 1, + nftParentId: '68cd33ecd909068fbea318ae5ff1d6207cf754e53b191327d6d73b6916424c0a', + blockCreated: 686130, + totalMinted: null, + totalBurned: null, + circulatingSupply: null, + // only in axios.data.t + transactionType: 'GENESIS', + tokenIdHex: '928ce61fe1006b1325a0ba0dce700bf83986a6f0691ba26e121c9ac035d12a55' + } +] + +const mockSingleTokenError = { + t: [] +} + +const mockSingleAddress = { + g: [ + { + _id: 'f05faf13a29c7f5e54ab921750aafb6afaa953db863bd2cf432e918661d4132f', + balanceString: '0.002382', + slpAddress: 'simpleledger:qpujxqra3jmdlzzapwmmt7uspr7q0c9ff5me5fdrdn' + } + ], + t: [ + { + tokenDetails: { + decimals: 6, + tokenIdHex: + 'f05faf13a29c7f5e54ab921750aafb6afaa953db863bd2cf432e918661d4132f' + } + } + ] +} + +const mockTx = { + txid: '57b3082a2bf269b3d6f40fee7fb9c664e8256a88ca5ee2697c05b9457822d446', + version: 2, + locktime: 0, + vin: [ + { + txid: '61e71554a3dc18158f30d9e8f5c9b6641a789690b32302899f81cbea9fe3bb49', + vout: 2, + sequence: 4294967295, + n: 0, + scriptSig: { + hex: + '4730440220409e79fec552f01203f41d3d621ae3db89c720af261c8268ce5f0453de009f5d022001e7ffefeba7b0716d32ea55cb6ace267b6ee9cbcc8a017bb9c3b6acf7889418412103c87f0ec048a0771bdb60533d45cac88c6974afeb055a65edd663c2f947335585', + asm: + '30440220409e79fec552f01203f41d3d621ae3db89c720af261c8268ce5f0453de009f5d022001e7ffefeba7b0716d32ea55cb6ace267b6ee9cbcc8a017bb9c3b6acf7889418[ALL|FORKID] 03c87f0ec048a0771bdb60533d45cac88c6974afeb055a65edd663c2f947335585' + }, + value: 546, + legacyAddress: 'mw22g57T9YA7MZQQu5eBDKj3PKTdt99oDL', + cashAddress: 'bchtest:qz4qnxcxwvmacgye8wlakhz0835x0w3vtvaga95c09' + }, + { + txid: '61e71554a3dc18158f30d9e8f5c9b6641a789690b32302899f81cbea9fe3bb49', + vout: 1, + sequence: 4294967295, + n: 1, + scriptSig: { + hex: + '483045022100a743bee56c99bd103be48a78fa4c7342100815d9d2448dbe6e1d338c3a13b241022066728b5279fc22eef5cd019582ff34771e29175835fc98aa3168a1548fd78ac8412103c87f0ec048a0771bdb60533d45cac88c6974afeb055a65edd663c2f947335585', + asm: + '3045022100a743bee56c99bd103be48a78fa4c7342100815d9d2448dbe6e1d338c3a13b241022066728b5279fc22eef5cd019582ff34771e29175835fc98aa3168a1548fd78ac8[ALL|FORKID] 03c87f0ec048a0771bdb60533d45cac88c6974afeb055a65edd663c2f947335585' + }, + value: 546, + legacyAddress: 'mw22g57T9YA7MZQQu5eBDKj3PKTdt99oDL', + cashAddress: 'bchtest:qz4qnxcxwvmacgye8wlakhz0835x0w3vtvaga95c09' + }, + { + txid: '61e71554a3dc18158f30d9e8f5c9b6641a789690b32302899f81cbea9fe3bb49', + vout: 3, + sequence: 4294967295, + n: 2, + scriptSig: { + hex: + '483045022100821473902eec5f1ce7d43b1ba7f9ec453bfe8b8dfc3de3e0723c883ab109922f02206162960e80618531fab2c16aee260fddd7979bab62471c8686af7de75f8732ec412103c87f0ec048a0771bdb60533d45cac88c6974afeb055a65edd663c2f947335585', + asm: + '3045022100821473902eec5f1ce7d43b1ba7f9ec453bfe8b8dfc3de3e0723c883ab109922f02206162960e80618531fab2c16aee260fddd7979bab62471c8686af7de75f8732ec[ALL|FORKID] 03c87f0ec048a0771bdb60533d45cac88c6974afeb055a65edd663c2f947335585' + }, + value: 9997521, + legacyAddress: 'mw22g57T9YA7MZQQu5eBDKj3PKTdt99oDL', + cashAddress: 'bchtest:qz4qnxcxwvmacgye8wlakhz0835x0w3vtvaga95c09' + } + ], + vout: [ + { + value: '0.00000000', + n: 0, + scriptPubKey: { + hex: + '6a04534c500001010453454e44207ac7f4bb50b019fe0f5c81e3fc13fc0720e130282ea460768cafb49785eb2796080000000049504f80080000001c71e64280', + asm: + 'OP_RETURN 5262419 1 1145980243 7ac7f4bb50b019fe0f5c81e3fc13fc0720e130282ea460768cafb49785eb2796 0000000049504f80 0000001c71e64280' + }, + spentTxId: null, + spentIndex: null, + spentHeight: null + }, + { + value: '0.00000546', + n: 1, + scriptPubKey: { + hex: '76a914396b8e57ad0cb58d30e2992f22047b3c20377aa688ac', + asm: + 'OP_DUP OP_HASH160 396b8e57ad0cb58d30e2992f22047b3c20377aa6 OP_EQUALVERIFY OP_CHECKSIG', + addresses: ['mkkZf7T3fU3vHSzNPy51HBmM46ghN1gnN9'], + type: 'pubkeyhash' + }, + spentTxId: null, + spentIndex: null, + spentHeight: null + }, + { + value: '0.00000546', + n: 2, + scriptPubKey: { + hex: '76a914aa099b067337dc20993bbfdb5c4f3c6867ba2c5b88ac', + asm: + 'OP_DUP OP_HASH160 aa099b067337dc20993bbfdb5c4f3c6867ba2c5b OP_EQUALVERIFY OP_CHECKSIG', + addresses: ['mw22g57T9YA7MZQQu5eBDKj3PKTdt99oDL'], + type: 'pubkeyhash' + }, + spentTxId: null, + spentIndex: null, + spentHeight: null + }, + { + value: '0.09996891', + n: 3, + scriptPubKey: { + hex: '76a914aa099b067337dc20993bbfdb5c4f3c6867ba2c5b88ac', + asm: + 'OP_DUP OP_HASH160 aa099b067337dc20993bbfdb5c4f3c6867ba2c5b OP_EQUALVERIFY OP_CHECKSIG', + addresses: ['mw22g57T9YA7MZQQu5eBDKj3PKTdt99oDL'], + type: 'pubkeyhash' + }, + spentTxId: null, + spentIndex: null, + spentHeight: null + } + ], + blockhash: '000000000000ce978accc64a6bb567acf0c653c202309a0f8e220149bf0c6968', + blockheight: 1287490, + confirmations: 746, + time: 1550855104, + blocktime: 1550855104, + valueOut: 0.09997983, + size: 628, + valueIn: 0.09998613, + fees: 0.0000063, + tokenInfo: { + versionType: 1, + transactionType: 'SEND', + tokenIdHex: + '7ac7f4bb50b019fe0f5c81e3fc13fc0720e130282ea460768cafb49785eb2796', + sendOutputs: ['0', '1230000000', '122170000000'] + }, + tokenIsValid: true +} + +const mockConvert = { + slpAddress: 'slptest:qz35h5mfa8w2pqma2jq06lp7dnv5fxkp2shlcycvd5', + cashAddress: 'bchtest:qz35h5mfa8w2pqma2jq06lp7dnv5fxkp2svtllzmlf', + legacyAddress: 'mvQPGnzRT6gMWASZBMg7NcT3vmvsSKSQtf' +} + +const mockTokenDetails = { + tokenIdHex: + 'df808a41672a0a0ae6475b44f272a107bc9961b90f29dc918d71301f24fe92fb', + documentUri: '', + symbol: 'NAKAMOTO', + name: 'NAKAMOTO', + decimals: 8, + timestamp: '', + containsBaton: true, + versionType: 1 +} + +const mockTokenStats = { + qty_valid_txns_since_genesis: 241, + qty_valid_token_utxos: 151, + qty_valid_token_addresses: 113, + qty_token_circulating_supply: '20995990', + qty_token_burned: '4010', + qty_token_minted: '21000000', + qty_satoshis_locked_up: 81900 +} + +const mockBalance = { + _id: 'simpleledger:qp9d8mn8ypryfvea2mev0ggc3wg6plpn4suuaeuss3', + token_balance: '1000' +} + +const mockTransactions = [ + { + txid: 'a302f045be8efa1cd982833a7f187ff4fac8baac36da0c887eb2787d8b45e2af', + tokenDetails: { + valid: true, + detail: { + decimals: null, + tokenIdHex: + '495322b37d6b2eae81f045eda612b95870a0c2b6069c58f70cf8ef4e6a9fd43a', + timestamp: null, + transactionType: 'MINT', + versionType: 1, + documentUri: null, + documentSha256Hex: null, + symbol: null, + name: null, + batonVout: 2, + containsBaton: true, + genesisOrMintQuantity: { + $numberDecimal: '1000' + }, + sendOutputs: null + }, + invalidReason: null, + schema_version: 30 + } + } +] + +const mockFoobar = { + c: [], + u: [] +} + +const mockSingleValidTxid = { + c: [ + { + _id: '5d965fc27f1cf2184ca2fe73', + tx: { + h: '77872738b6bddee6c0cbdb9509603de20b15d4f6b26602f629417aec2f5d5e8d' + }, + slp: { + valid: true, + invalidReason: null + } + } + ], + u: [] +} + +const mockTwoValidTxid = { + c: [ + { + _id: '5d965fc27f1cf2184ca2fe73', + tx: { + h: '77872738b6bddee6c0cbdb9509603de20b15d4f6b26602f629417aec2f5d5e8d' + }, + slp: { + valid: true, + invalidReason: null + } + }, + { + _id: '5d71e69758380a002c492a90', + tx: { + h: '552112f9e458dc7d1d8b328b0a6685e8af74a64b60b6846e7c86407f27f47e42' + }, + slp: { + valid: true, + invalidReason: null + } + } + ], + u: [] +} + +const mockTwoRedundentTxid = { + c: [ + { + _id: '5db99c72a391ae2afd604bde', + tx: { + h: 'd56a2b446d8149c39ca7e06163fe8097168c3604915f631bc58777d669135a56' + }, + slp: { + valid: true, + invalidReason: null + } + } + ], + u: [] +} + +const mockPsfToken = { + c: [ + { + _id: '5fcaf6152898f9887902986c', + tx: { + h: 'cdfed769ef7b69e09be06d2821b88598d9a5d711b8f9bd369763c78b7a578fbc' + }, + slp: { + valid: true, + invalidReason: null + } + } + ], + u: [] +} + +const mockTxHistory = [ + { + tx: { + h: '3a7646b3976a8745928c7192c8dde989bfa275fa6d7bce950180ab8002cf6cef' + }, + in: [ + { + i: 0, + e: { + h: '7bdd586ebd1e5f3dfd5295ab6e896c48b25855ab77a72c035ce1e7aacc965c2d', + i: 1, + s: + 'RzBEAiAsu5o9EnxgZChjxKygxhGLhlfxJJbQfd4PP/vp2od3fAIgPpMqg+Sp4YpiInQZ6weJwELq8LEIrHXJpueBz7yF5gRBIQLsyL3809mRphc/+tPAcTQO5bi0kqZRFKpKIHXE3qN8Rw==', + a: 'simpleledger:qrrrpqmdkggpnw0czwg0jgcjd7yhu25jy5zxh2gqdq' + } + }, + { + i: 1, + e: { + h: 'df49feff24dc34a10a44a0cbd7c908d964801ec7218512c84574fbce698535f0', + i: 3, + s: + 'SDBFAiEAhW3zbKTlPrOXD2E2oEcNof6vCMPGYIg8vOVSE9c8IakCIFLg/gxydG9eL8HMAzkScGElKWJRnfhVMpMHU3evNcrZQSEDRS7F+pSC8OxSldsT4FctJLZBU7f2+FDiE05ae1xqtN0=', + a: 'simpleledger:qpkpeqfslejw5pptzcy25h2jxhsc9k0vts43n26up0' + } + } + ], + out: [ + { + e: { + v: 0, + i: 0, + s: + 'agRTTFAAAQEEU0VORCA46XxdfTWFosvz+VgMgsozmF+csIRdTcziIMtwn5U4sAgAAAAAAJiWgAgAAAAAJHfU+g==' + } + }, + { + e: { + v: 546, + i: 1, + s: 'dqkUqo4lVohqOK6rDd7nIIkFQ6Uf41aIrA==', + a: 'simpleledger:qz4guf2k3p4r3t4tph0wwgyfq4p628lr2c0cvqplza' + } + }, + { + e: { + v: 546, + i: 2, + s: 'dqkUgYSfxf90wVkERCiOMyh16iB4d92IrA==', + a: 'simpleledger:qzqcf879la6vzkgygs5guvegwh4zq7rhm5nu0pljjl' + } + }, + { + e: { + v: 21849, + i: 3, + s: 'dqkUxjCDbbIQGbn4E5D5IxJviX4qkiWIrA==', + a: 'simpleledger:qrrrpqmdkggpnw0czwg0jgcjd7yhu25jy5zxh2gqdq' + } + } + ], + slp: { + detail: { + decimals: 8, + tokenIdHex: + '38e97c5d7d3585a2cbf3f9580c82ca33985f9cb0845d4dcce220cb709f9538b0', + transactionType: 'SEND', + versionType: 1, + documentUri: 'psfoundation.cash', + documentSha256Hex: null, + symbol: 'PSF', + name: 'Permissionless Software Foundation', + txnBatonVout: null, + txnContainsBaton: false, + outputs: [ + { + address: 'simpleledger:qz4guf2k3p4r3t4tph0wwgyfq4p628lr2c0cvqplza', + amount: '0.1' + }, + { + address: 'simpleledger:qzqcf879la6vzkgygs5guvegwh4zq7rhm5nu0pljjl', + amount: '6.11833082' + } + ] + } + }, + blk: { + h: '000000000000000001a47bf337ca211c1e7218234d0d70117c64d6f213f5dacb', + i: 634833, + t: 1589300214 + } + }, + { + tx: { + h: '3b11b48cab1e7c8384facf482b3f6bfe659a58245ab4aa4147b42b5cb2a5fac5' + }, + in: [ + { + i: 0, + e: { + h: 'e4e1e1f6b502cbd42f69b919634cea3e37fc8595e6ca800e0a7d8f6dcdfb249e', + i: 3, + s: + 'SDBFAiEAjCGRmU28x24LMTwg5XqC+fLf3zGhTYCsSWpmF8Eshq0CIAJhxAKMbwKWC4ASmjWzplpno2ch+hGNUD6HNWOdbd4SQSECeRsZo5Fl29g0A9bfJo1E/WIdowWBsLblyxWnEB7ViFE=', + a: 'simpleledger:qpvsg9vl9a5mlf37a7n3yce6pktdctn73qznkmw3s7' + } + }, + { + i: 1, + e: { + h: '438420345dd8b7fb4ea74aaf2e3090f44899bf3c71662243946a441de6dae720', + i: 2, + s: + 'SDBFAiEAhmLYL4QY3mvZo3/i0XG9PJAYMruw3MaGOLJ3Z4si7hwCIAaaGwRnj/cZK3L/KKFBoMtItpGRZ10GowNN//vgn6PKQSECeRsZo5Fl29g0A9bfJo1E/WIdowWBsLblyxWnEB7ViFE=', + a: 'simpleledger:qpvsg9vl9a5mlf37a7n3yce6pktdctn73qznkmw3s7' + } + } + ], + out: [ + { + e: { + v: 0, + i: 0, + s: + 'agRTTFAAAQEEU0VORCCk+1wtoaoGTiUBikP5FlBABx2emEuhkMIip/WQU6+EsggAAAAAAA9CQAgAAAAXQnHEwA==' + } + }, + { + e: { + v: 546, + i: 1, + s: 'dqkUqo4lVohqOK6rDd7nIIkFQ6Uf41aIrA==', + a: 'simpleledger:qz4guf2k3p4r3t4tph0wwgyfq4p628lr2c0cvqplza' + } + }, + { + e: { + v: 546, + i: 2, + s: 'dqkUWQQVny9pv6Y+76cSYzoNltwufoiIrA==', + a: 'simpleledger:qpvsg9vl9a5mlf37a7n3yce6pktdctn73qznkmw3s7' + } + }, + { + e: { + v: 43074, + i: 3, + s: 'dqkUWQQVny9pv6Y+76cSYzoNltwufoiIrA==', + a: 'simpleledger:qpvsg9vl9a5mlf37a7n3yce6pktdctn73qznkmw3s7' + } + } + ], + slp: { + detail: { + decimals: 2, + tokenIdHex: + 'a4fb5c2da1aa064e25018a43f9165040071d9e984ba190c222a7f59053af84b2', + transactionType: 'SEND', + versionType: 1, + documentUri: 'troutsblog.com', + documentSha256Hex: null, + symbol: 'TROUT', + name: "Trout's test token", + txnBatonVout: null, + txnContainsBaton: false, + outputs: [ + { + address: 'simpleledger:qz4guf2k3p4r3t4tph0wwgyfq4p628lr2c0cvqplza', + amount: '10000' + }, + { + address: 'simpleledger:qpvsg9vl9a5mlf37a7n3yce6pktdctn73qznkmw3s7', + amount: '998990000' + } + ] + } + }, + blk: { + h: '000000000000000002a4ee2ed6fe8a764ffef0b2556f545ca0b3ef39ecd2b7c4', + i: 634832, + t: 1589297145 + } + } +] + +const mockValidateBulk = { + c: [ + { + _id: '5fcc1ae2aff6379ca5bbb213', + tx: { + h: '336bfe2168aac4c3303508a9e8548a0d33797a83b85b76a12d845c8d6674f79d' + }, + slp: { + valid: true, + invalidReason: null + } + }, + { + _id: '5fc9b4db4d54eece25b52762', + tx: { + h: 'daf4d8b8045e7a90b7af81bfe2370178f687da0e545511bce1c9ae539eba5ffd' + }, + slp: { + valid: true, + invalidReason: null + } + }, + { + _id: '5fc989a14d54eece25af88a6', + tx: { + h: '2bf691ad3679d928fef880b8a45b93b233f8fa0d0a92cf792313dbe77b1deb74' + }, + slp: { + valid: false, + invalidReason: 'Token outputs are greater than valid token inputs.' + } + }, + { + _id: '5fc974d14d54eece25ad5df2', + tx: { + h: '3a4b628cbcc183ab376d44ce5252325f042268307ffa4a53443e92b6d24fb488' + }, + slp: { + valid: true, + invalidReason: null + } + } + ], + u: [] +} + +const mockValidate3Bulk = { + c: [ + { + _id: '5fcaf60b2898f98879029754', + tx: { + h: 'daf4d8b8045e7a90b7af81bfe2370178f687da0e545511bce1c9ae539eba5ffd' + }, + slp: { + valid: true, + invalidReason: null + } + }, + { + _id: '5fcaf5382898f988790275d0', + tx: { + h: '2bf691ad3679d928fef880b8a45b93b233f8fa0d0a92cf792313dbe77b1deb74' + }, + slp: { + valid: false, + invalidReason: 'Token outputs are greater than valid token inputs.' + } + } + ], + u: [] +} + +const mockStatus = { + _id: '5fe408b7cd2bd2000f7fdd0a', + version: '1.0.0-beta-rc13', + versionHash: 'bb4a805610b9e4d67c1595b716df36190c75e8b1', + deplVersionHash: null, + startCmd: 'node index run', + context: 'SLPDB', + lastStatusUpdate: { + utc: 'Thu, 24 Dec 2020 03:19:19 GMT', + unix: 1608779959 + }, + lastIncomingTxnZmq: { + utc: 'Thu, 24 Dec 2020 03:19:16 GMT', + unix: 1608779956 + }, + lastIncomingBlockZmq: { + utc: 'Thu, 24 Dec 2020 03:00:27 GMT', + unix: 1608778827 + }, + lastOutgoingTxnZmq: null, + lastOutgoingBlockZmq: null, + state: 'RUNNING', + stateHistory: [ + { + utc: 'Mon, 07 Dec 2020 15:26:23 GMT', + state: 'STARTUP_BLOCK_SYNC' + }, + { + utc: 'Mon, 07 Dec 2020 15:52:20 GMT', + state: 'RUNNING' + }, + { + utc: 'Tue, 08 Dec 2020 14:42:29 GMT', + state: 'PRE_STARTUP' + }, + { + utc: 'Tue, 08 Dec 2020 14:42:29 GMT', + state: 'STARTUP_BLOCK_SYNC' + }, + { + utc: 'Tue, 08 Dec 2020 14:59:53 GMT', + state: 'RUNNING' + }, + { + utc: 'Tue, 08 Dec 2020 19:35:36 GMT', + state: 'EXITED_ON_ERROR' + }, + { + utc: 'Tue, 08 Dec 2020 19:35:36 GMT', + state: 'EXITED_ON_ERROR' + }, + { + utc: 'Tue, 08 Dec 2020 19:35:49 GMT', + state: 'PRE_STARTUP' + }, + { + utc: 'Tue, 08 Dec 2020 19:35:49 GMT', + state: 'STARTUP_BLOCK_SYNC' + }, + { + utc: 'Tue, 08 Dec 2020 19:53:04 GMT', + state: 'RUNNING' + } + ], + network: 'mainnet', + bchBlockHeight: 667203, + bchBlockHash: + '0000000000000000035f9c26b9ef3db37eba8c9351218c67c92d80444b867858', + slpProcessedBlockHeight: 667203, + mempoolInfoBch: { + loaded: true, + size: 441, + bytes: 192651, + usage: 663056, + maxmempool: 300000000, + mempoolminfee: 0.00001, + minrelaytxfee: 0.00001 + }, + mempoolSizeSlp: 68, + tokensCount: 77879, + pastStackTraces: [ + '[Tue, 08 Dec 2020 19:35:36 GMT] MongoServerSelectionError: connection to 172.17.0.1:12301 timed out\n at Timeout._onTimeout (/home/safeuser/SLPDB/node_modules/mongodb/lib/core/sdam/topology.js:438:30)\n at listOnTimeout (internal/timers.js:554:17)\n at processTimers (internal/timers.js:497:7)', + '[Tue, 08 Dec 2020 19:35:36 GMT] MongoServerSelectionError: connection to 172.17.0.1:12301 timed out\n at Timeout._onTimeout (/home/safeuser/SLPDB/node_modules/mongodb/lib/core/sdam/topology.js:438:30)\n at listOnTimeout (internal/timers.js:554:17)\n at processTimers (internal/timers.js:497:7)' + ], + doubleSpends: [ + { + txo: + '3e377f67e9d065f02e47633d847cdb04b0679d51baccdc0feea5297529e330b5:820', + details: { + originalTxid: + 'dc148149d25a546fffe0b207c62b192811c288481df5f43f1f76f67b6004f98c', + current: + 'de12db60e253c378eeebb1df85153168a998db8267ec54b38589cc31348b2b8d', + time: { + utc: 'Sat, 19 Dec 2020 02:25:37 GMT', + unix: 1608344737 + } + } + }, + { + txo: '4b7e94fc59a685bce31ee9444d20ebb0ae0478111a8bfa7ec9b5c381e2657191:2', + details: { + originalTxid: + 'a4e00c025c07a770874ecc6b2d1004fe3c561f2aceb0a8b95996e26709df1a57', + current: + '33ccab4a971331897bb3ff3978366e3575c54d83ae1a35984e50d93cffe66b95', + time: { + utc: 'Sat, 19 Dec 2020 02:33:28 GMT', + unix: 1608345208 + } + } + }, + { + txo: 'bd5ac3d02652038cc888922e50c9b57db0dfdbb5b26bb28a2e85fd28493e85e1:2', + details: { + originalTxid: + '53032355ab142ac050f1e37445ecb19ad22c5331185df7b3173e88563194ba18', + current: + '0b3ab983c5abb1ceb6f60035ff869e159e27ab5d2a4173d0d6ce027348d4dbdd', + time: { + utc: 'Sat, 19 Dec 2020 04:21:27 GMT', + unix: 1608351687 + } + } + }, + { + txo: + '8d91432598943cc22b93d10df6808abccba4baebdf2273b375ce3616a2001c07:573', + details: { + originalTxid: + '53032355ab142ac050f1e37445ecb19ad22c5331185df7b3173e88563194ba18', + current: + '7ddc241b62625ddc1d134ae17e74bf2b6f63a4c28e3c3a4f11d833270bdebcdb', + time: { + utc: 'Sat, 19 Dec 2020 04:26:12 GMT', + unix: 1608351972 + } + } + }, + { + txo: 'ff3a9268952408217f60f1689aa1137e929e6572413e429554996ec3e9b223b5:2', + details: { + originalTxid: + '342adc882eff630aa7b50098adaace3626c1e60acbdf937e41832c4508bbdb4b', + current: + '01538a27e7e164bd52a9fd3df36cd724846f44a4c26aea8764ee982486b580ff', + time: { + utc: 'Sat, 19 Dec 2020 12:58:36 GMT', + unix: 1608382716 + } + } + }, + { + txo: + '9f7de7ea735d46fbb9214353fec0b250f48a24340e0909eecd83f53817b6aaa3:314', + details: { + originalTxid: + '342adc882eff630aa7b50098adaace3626c1e60acbdf937e41832c4508bbdb4b', + current: + '01538a27e7e164bd52a9fd3df36cd724846f44a4c26aea8764ee982486b580ff', + time: { + utc: 'Sat, 19 Dec 2020 12:58:36 GMT', + unix: 1608382716 + } + } + }, + { + txo: 'ff28ad4063faa250797fa39c823b919d157ecae82b8bf68199eb0b9babbb0c12:2', + details: { + originalTxid: + 'eced12b6e07e4d830edf6d3244055ba22e953065070985755c9e7f9ea7626d1d', + current: + '512073e40e033106c45af47acfd05092b0206ac276bda459f76dfa019a0e257c', + time: { + utc: 'Sat, 19 Dec 2020 12:58:38 GMT', + unix: 1608382718 + } + } + }, + { + txo: + '9f7de7ea735d46fbb9214353fec0b250f48a24340e0909eecd83f53817b6aaa3:316', + details: { + originalTxid: + '53cffad1ffd9de20355f89ee0310cd39876c21e2d2a9d5b09548286de1e78565', + current: + 'a988c9f46f97de27eccd4f50e1bba751cdc9c49a6cac31de2f4dbf3763fb4ab3', + time: { + utc: 'Sat, 19 Dec 2020 12:58:45 GMT', + unix: 1608382725 + } + } + }, + { + txo: + '1a57786c472034b2d3d944a3cad69447c30ed5de46bd708ddead33f946ea2578:10', + details: { + originalTxid: + 'eced12b6e07e4d830edf6d3244055ba22e953065070985755c9e7f9ea7626d1d', + current: + '8ef975bd11d82c3dfb6645cc15eeaecebde23423b6506daa5145d3b20c02fab5', + time: { + utc: 'Sat, 19 Dec 2020 13:01:30 GMT', + unix: 1608382890 + } + } + }, + { + txo: + '9f7de7ea735d46fbb9214353fec0b250f48a24340e0909eecd83f53817b6aaa3:317', + details: { + originalTxid: + '5bf0b00b41a9cc1068d8573f0fb65e875743ce3ea5a3e521fff960da9223d508', + current: + '4f3bf7452f5409964cdb9f68a493b2c1f89dd16fc6eb1cc6d2b1dc753c7bfb59', + time: { + utc: 'Sat, 19 Dec 2020 13:04:46 GMT', + unix: 1608383086 + } + } + }, + { + txo: + '1a57786c472034b2d3d944a3cad69447c30ed5de46bd708ddead33f946ea2578:11', + details: { + originalTxid: + 'f1973c96dabf923f7f1e65630366921da9a806316f3e4f2478cbd754d52f6c70', + current: + '76493aaa8bdcb21bb45452250f9518674ea94b3e70fb890252278c9f8bfe3cef', + time: { + utc: 'Sat, 19 Dec 2020 13:06:31 GMT', + unix: 1608383191 + } + } + }, + { + txo: + '1a57786c472034b2d3d944a3cad69447c30ed5de46bd708ddead33f946ea2578:12', + details: { + originalTxid: + '15b22f6c97a3390c13ae79a61eb9beb34ab87d2245bf8e63d91d7b2477ccdf00', + current: + '6e529eeecd268b1763467152ff76a427c80a26750229de3ad0edbfb2b40ee43a', + time: { + utc: 'Sat, 19 Dec 2020 13:07:37 GMT', + unix: 1608383257 + } + } + }, + { + txo: 'c75126b129584314d7657ce7140f4aef64ff410053a3d9017a13307b778d4e16:2', + details: { + originalTxid: + 'cf2234e2a71a5ae7cf525c2ced332c16bde86734d841649913b4ce698a43506d', + current: + 'f98a430d9772962353cb15a58d447d3de056abde41730b92d9301d7510d1d04c', + time: { + utc: 'Sat, 19 Dec 2020 14:52:28 GMT', + unix: 1608389548 + } + } + }, + { + txo: + '9f7de7ea735d46fbb9214353fec0b250f48a24340e0909eecd83f53817b6aaa3:343', + details: { + originalTxid: + 'cf2234e2a71a5ae7cf525c2ced332c16bde86734d841649913b4ce698a43506d', + current: + 'f98a430d9772962353cb15a58d447d3de056abde41730b92d9301d7510d1d04c', + time: { + utc: 'Sat, 19 Dec 2020 14:52:28 GMT', + unix: 1608389548 + } + } + }, + { + txo: + '22fa91e7961388ec071254c65cccf824d7923a0291af0967394fc7bffb6185c1:25', + details: { + originalTxid: + '5ed601fb5b2c6fee6eb1298671471108cf656f5d19186db484c2a6711f0867c8', + current: + 'a30535841e874fe7ddefe6285bc44e5f932d1866c343b3298448985c428049a1', + time: { + utc: 'Sat, 19 Dec 2020 18:41:13 GMT', + unix: 1608403273 + } + } + }, + { + txo: + '22fa91e7961388ec071254c65cccf824d7923a0291af0967394fc7bffb6185c1:26', + details: { + originalTxid: + 'a7015340be3ff27b1773208bf7c4501811e9adfb11363c11a7ac080946fb1cd2', + current: + 'b9959428becca1c0850409a960153a55fa8cacac3ad5c7b0ba67c57e46814baf', + time: { + utc: 'Sat, 19 Dec 2020 18:42:13 GMT', + unix: 1608403333 + } + } + }, + { + txo: + '22fa91e7961388ec071254c65cccf824d7923a0291af0967394fc7bffb6185c1:27', + details: { + originalTxid: + '72435b1d8234d5ca00c6653beec318c3d40f4e902f0fec892f769125bdfc0987', + current: + '43277c2fffa2a7ff632fd2bdd0a2be950523816c42012fa00efa860ade428127', + time: { + utc: 'Sat, 19 Dec 2020 18:53:26 GMT', + unix: 1608404006 + } + } + }, + { + txo: 'b46d46d2d8b081cf4dd3fbf4bb1d36c8f22954e92f999a07c730be52006303ee:2', + details: { + originalTxid: + '93aca660517a8ea847ca5bb39dfa1b26ae72f2eabfedc7cb3ef6e804c293c697', + current: + '377c89f649de123c52ef23a1f0ec844aa6ac032c4ec1242b59ca15e1b64a883d', + time: { + utc: 'Sat, 19 Dec 2020 20:33:14 GMT', + unix: 1608409994 + } + } + }, + { + txo: + '9f7de7ea735d46fbb9214353fec0b250f48a24340e0909eecd83f53817b6aaa3:409', + details: { + originalTxid: + '4f355dfeed4a71ec0fd10688ddb3c03ea10db7629e844adfb033a859f12dc87f', + current: + '377c89f649de123c52ef23a1f0ec844aa6ac032c4ec1242b59ca15e1b64a883d', + time: { + utc: 'Sat, 19 Dec 2020 20:33:14 GMT', + unix: 1608409994 + } + } + }, + { + txo: '4bd177144edbf495f614b839c5828ec6c2d21b4a5a478b6324d681b7ecdce976:2', + details: { + originalTxid: + '4f355dfeed4a71ec0fd10688ddb3c03ea10db7629e844adfb033a859f12dc87f', + current: + '9c480d23e99fc3a31558ffb8a402bc819bf0906ca21351749db4d5fceca263b6', + time: { + utc: 'Sat, 19 Dec 2020 20:33:16 GMT', + unix: 1608409996 + } + } + }, + { + txo: + '190c84d8cb06fe750fcbf0bd11c62e23fa60cd4f7c970a1079ac746e270982be:155', + details: { + originalTxid: + '93aca660517a8ea847ca5bb39dfa1b26ae72f2eabfedc7cb3ef6e804c293c697', + current: + '75e3759d5167395aa1dca327b9b042eb1b86e893bdffc66c2480e8194c2cdc61', + time: { + utc: 'Sat, 19 Dec 2020 20:45:04 GMT', + unix: 1608410704 + } + } + } + ], + reorgs: [], + mongoDbStats: { + db: 'slpdb', + collections: 5, + views: 0, + objects: 2495075, + avgObjSize: 3188.4621913168944, + dataSize: 7586.910535812378, + storageSize: 3353.39453125, + indexes: 71, + indexSize: 1490.08203125, + totalSize: 4843.4765625, + scaleFactor: 1048576, + fsUsedSize: 74352.5859375, + fsTotalSize: 153676.98046875, + ok: 1 + }, + publicUrl: 'fullstack--bchn-02', + telemetryHash: null, + system: { + loadAvg1: 0.04, + loadAvg5: 0.03, + loadAvg15: 0, + platform: 'linux', + cpuCount: 8, + freeMem: 5832.546875, + totalMem: 31360.8828125, + uptime: 1603692, + processUptime: 1323812.053445836 + } +} + +module.exports = { + mockList, + mockSingleToken, + mockConvert, + mockTokenDetails, + mockTokenStats, + mockTx, + mockBalance, + mockTransactions, + mockSingleTokenError, + mockSingleAddress, + mockFoobar, + mockSingleValidTxid, + mockTwoValidTxid, + mockTwoRedundentTxid, + mockTxHistory, + mockPsfToken, + mockValidateBulk, + mockValidate3Bulk, + mockStatus, + mockNftGroup, + mockNftChildren +} diff --git a/test/v5/mocks/slpjs-mocks.js b/test/v5/mocks/slpjs-mocks.js new file mode 100644 index 0000000..e1294f6 --- /dev/null +++ b/test/v5/mocks/slpjs-mocks.js @@ -0,0 +1,71 @@ +/* + Mocks used for unit tests that interact with slpjs. +*/ + +'use strict' + +// const sinon = require('sinon') +// const proxyquire = require('proxyquire') +const BigNumber = require('bignumber.js') +const slpMocks = require('./slp-mocks') + +// Mock the BitboxNetwork class. +class BitboxNetwork { + // constructor () {} + + async getAllSlpBalancesAndUtxos (address) { + return { + satoshis_available_bch: 9996891, + satoshis_in_slp_baton: 546, + satoshis_in_slp_token: 546, + satoshis_in_invalid_token_dag: 0, + satoshis_in_invalid_baton_dag: 0, + slpTokenBalances: { + '7ac7f4bb50b019fe0f5c81e3fc13fc0720e130282ea460768cafb49785eb2796': new BigNumber( + 123400000000 + ) + }, + slpTokenUtxos: { + '7ac7f4bb50b019fe0f5c81e3fc13fc0720e130282ea460768cafb49785eb2796': [] + }, + slpBatonUtxos: { + '7ac7f4bb50b019fe0f5c81e3fc13fc0720e130282ea460768cafb49785eb2796': [] + }, + nonSlpUtxos: [{}], + invalidTokenUtxos: [], + invalidBatonUtxos: [] + } + } + + async getTokenInformation (txid) { + BigNumber.set({ DECIMAL_PLACES: 8, ROUNDING_MODE: 4 }) + + const obj = { + versionType: 1, + transactionType: 0, + symbol: 'SLPSDK', + name: 'SLP SDK example using BITBOX', + documentUri: 'developer.bitcoin.com', + documentSha256: null, + decimals: 8, + batonVout: 2, + containsBaton: true, + genesisOrMintQuantity: new BigNumber(123400000000) + } + + return obj + } + + async getTransactionDetails (txid) { + return slpMocks.mockTx + } +} + +// Mock the slpjs library. +const slpjs = { + BitboxNetwork, + slp: {}, + validator: {} +} + +module.exports = slpjs diff --git a/test/v5/mocks/transaction-mocks.js b/test/v5/mocks/transaction-mocks.js new file mode 100644 index 0000000..89d67bf --- /dev/null +++ b/test/v5/mocks/transaction-mocks.js @@ -0,0 +1,74 @@ +/* + This library contains mocking data for running unit tests on the address route. +*/ + +'use strict' + +/* +const mockDetails = { + txid: "6f235bd3a689f03c11969cd649ccad592462ca958bc519a30194e7a67b349a40", + version: 2, + locktime: 0, + vin: [Array], + vout: [Array], + blockhash: "00000000e7232ff12462dedf9c11985f5b54202515277c337ccc59812758f28b", + blockheight: 1270188, + confirmations: 2, + time: 1543436253, + blocktime: 1543436253, + valueOut: 450.78867333, + size: 226, + valueIn: 450.78867559, + fees: 0.00000226 +} +*/ + +const mockDetails = { + txid: '6f235bd3a689f03c11969cd649ccad592462ca958bc519a30194e7a67b349a40', + version: 2, + locktime: 0, + vin: [ + { + txid: '273d616d1c48f4b075c497f36ffdc79da5c8d6ed75485808b3599aac504f8525', + vout: 0, + sequence: 4294967295, + n: 0, + scriptSig: [{}], + addr: 'bchtest:qqmd9unmhkpx4pkmr6fkrr8rm6y77vckjvqe8aey35', + valueSat: 45078867559, + value: 450.78867559, + doubleSpentTxID: null + } + ], + vout: [ + { + value: '450.68867333', + n: 0, + scriptPubKey: [{}], + spentTxId: null, + spentIndex: null, + spentHeight: null + }, + { + value: '0.10000000', + n: 1, + scriptPubKey: [{}], + spentTxId: null, + spentIndex: null, + spentHeight: null + } + ], + blockhash: '00000000e7232ff12462dedf9c11985f5b54202515277c337ccc59812758f28b', + blockheight: 1270188, + confirmations: 3, + time: 1543436253, + blocktime: 1543436253, + valueOut: 450.78867333, + size: 226, + valueIn: 450.78867559, + fees: 0.00000226 +} + +module.exports = { + mockDetails +} diff --git a/test/v5/mocks/util-mocks.js b/test/v5/mocks/util-mocks.js new file mode 100644 index 0000000..bb45273 --- /dev/null +++ b/test/v5/mocks/util-mocks.js @@ -0,0 +1,125 @@ +/* + This library contains mocking data for running unit tests on the address route. +*/ + +'use strict' + +const mockAddress = { + isvalid: true, + address: 'bchtest:qqqk4y6lsl5da64sg5qc3xezmplyu5kmpyz2ysaa5y', + scriptPubKey: '76a914016a935f87e8deeab04501889b22d87e4e52db0988ac', + ismine: false, + iswatchonly: false, + isscript: false +} + +// const mockBalance = { +// page: 1, +// totalPages: 1, +// itemsOnPage: 1000, +// address: 'bitcoincash:qzp7gdl52edm24xlpkyqnza9rv43u3mdxyc77j3u6k', +// balance: '2546', +// totalReceived: '2546', +// totalSent: '0', +// unconfirmedBalance: '0', +// unconfirmedTxs: 0, +// txs: 2, +// txids: [ +// 'e190d13b88578132608ab912a4d2be3e55aa2792d6042d481ae21d700639de56', +// '44e1f48c4093fc61db1a8fa206aa402fc34e482b3f788cb38c123ca0e1a35db6' +// ] +// } + +const mockBalance = { + confirmed: 2546, + unconfirmed: 0 +} + +const mockUtxos = [ + { + tx_hash: 'e190d13b88578132608ab912a4d2be3e55aa2792d6042d481ae21d700639de56', + tx_pos: 0, + value: 2000, + height: 605873 + }, + { + tx_hash: '44e1f48c4093fc61db1a8fa206aa402fc34e482b3f788cb38c123ca0e1a35db6', + tx_pos: 1, + value: 546, + height: 605873 + } +] + +const mockThreeUtxos = [ + { + tx_hash: 'e190d13b88578132608ab912a4d2be3e55aa2792d6042d481ae21d700639de56', + tx_pos: 0, + value: 2000, + height: 605873 + }, + { + tx_hash: '44e1f48c4093fc61db1a8fa206aa402fc34e482b3f788cb38c123ca0e1a35db6', + tx_pos: 1, + value: 546, + height: 605873 + }, + { + tx_hash: '44e1f48c4093fc61db1a8fa206aa402fc34e482b3f788cb38c123ca0e1a35db6', + tx_pos: 1, + value: 546, + height: 605873 + } +] + +const mockIsTokenUtxos = [ + false, + { + tx_hash: '44e1f48c4093fc61db1a8fa206aa402fc34e482b3f788cb38c123ca0e1a35db6', + tx_out: 1, + value: 546, + height: 605873, + confirmations: 298, + satoshis: 546, + utxoType: 'token', + transactionType: 'send', + tokenId: '497291b8a1dfe69c8daea50677a3d31a5ef0e9484d8bebb610dac64bbc202fb7', + tokenTicker: 'TOK-CH', + tokenName: 'TokyoCash', + tokenDocumentUrl: '', + tokenDocumentHash: '', + decimals: 8, + tokenQty: 2 + } +] + +const tapUtxo = { + txid: '2e030df12390186baf817fa2760540b886511e04bc520e88f6b4c2124cc2a7d4', + vout: 1, + value: '546', + height: 606564, + confirmations: 3, + satoshis: 546, + utxoType: 'token', + transactionType: 'send', + tokenId: 'dd84ca78db4d617221b58eabc6667af8fe2f7eadbfcc213d35be9f1b419beb8d', + tokenTicker: 'TAP', + tokenName: 'Thoughts and Prayers', + tokenDocumentUrl: '', + tokenDocumentHash: '', + decimals: 0, + tokenQty: 1 +} + +const tokensOnly = [mockIsTokenUtxos[1], tapUtxo] + +const multipleTokens = [false, mockIsTokenUtxos[1], tapUtxo] + +module.exports = { + mockAddress, + mockBalance, + mockUtxos, + mockThreeUtxos, + mockIsTokenUtxos, + tokensOnly, + multipleTokens +} diff --git a/test/v5/price.js b/test/v5/price.js new file mode 100644 index 0000000..b9615bb --- /dev/null +++ b/test/v5/price.js @@ -0,0 +1,346 @@ +/* + TESTS FOR THE PRICE.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. + +*/ + +'use strict' + +const chai = require('chai') +const assert = chai.assert +const sinon = require('sinon') + +const Price = require('../../src/routes/v4/price') +let uut + +// Mocking data. +const { mockReq, mockRes } = require('./mocks/express-mocks') +const mockData = require('./mocks/price-mock') + +// Used for debugging. +const util = require('util') +util.inspect.defaultOptions = { depth: 1 } + +describe('#PriceRouter', () => { + let req, res + let sandbox + + before(() => { + // Set default environment variables for unit tests. + if (!process.env.TEST) process.env.TEST = 'unit' + }) + + // 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 = {} + + sandbox = sinon.createSandbox() + + uut = new Price() + }) + + afterEach(() => { + // Restore Sandbox + sandbox.restore() + }) + + after(() => {}) + + describe('#root', async () => { + // root route handler. + // const root = controlRoute.testableComponents.root + + it('should respond to GET for base route', async () => { + const result = uut.root(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(result.status, 'price', 'Returns static string') + }) + }) + + describe('#getUSD', () => { + // const getNetworkInfo = controlRoute.testableComponents.getNetworkInfo + + it('should throw 500 when network issues', async () => { + uut.priceUrl = 'http://fakeurl/api/' + + await uut.getUSD(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isAbove( + res.statusCode, + 499, + 'HTTP status code 500 or greater expected.' + ) + // console.log(res) + assert.include( + res.output.error, + 'Network error: Could not communicate with full node or other external service' + ) + }) + + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + const result = await uut.getUSD(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.getUSD(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should get the USD price of BCH', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: mockData.mockCoinbaseFeed }) + } + + const result = await uut.getUSD(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isNumber(result.usd) + }) + }) + + describe('#getBCHRate', () => { + it('should throw 500 when network issues', async () => { + uut.priceUrl = 'http://fakeurl/api/' + + await uut.getBCHRate(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isAbove( + res.statusCode, + 499, + 'HTTP status code 500 or greater expected.' + ) + // console.log(res) + assert.include( + res.output.error, + 'Network error: Could not communicate with full node or other external service' + ) + }) + + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + const result = await uut.getBCHRate(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.getBCHRate(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should get several rates for BCH', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: mockData.mockCoinbaseFeed }) + } + + const result = await uut.getBCHRate(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // assert.isNumber(result) + assert.property(result, 'USD') + assert.property(result, 'CAD') + }) + }) + + describe('#errorHandler', () => { + it('should handle unexpected errors', () => { + sandbox.stub(uut.routeUtils, 'decodeError').returns({ msg: false }) + + const result = uut.errorHandler(new Error('test error'), res) + // console.log('result: ', result) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.property(result, 'error') + }) + }) + + describe('#getBCHAUSD', () => { + // const getNetworkInfo = controlRoute.testableComponents.getNetworkInfo + + it('should throw 500 when network issues', async () => { + uut.coinexPriceUrl = 'http://fakeurl/api/' + + await uut.getBCHAUSD(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isAbove( + res.statusCode, + 499, + 'HTTP status code 500 or greater expected.' + ) + // console.log(res) + assert.include( + res.output.error, + 'Network error: Could not communicate with full node or other external service' + ) + }) + + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + const result = await uut.getBCHAUSD(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.getBCHAUSD(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should get the USD price of BCH', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: mockData.mockCoinexFeed }) + } + + const result = await uut.getBCHAUSD(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isNumber(result.usd) + }) + }) + + describe('#getBCHUSD', () => { + // const getNetworkInfo = controlRoute.testableComponents.getNetworkInfo + + it('should throw 500 when network issues', async () => { + uut.bchCoinexPriceUrl = 'http://fakeurl/api/' + + await uut.getBCHUSD(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isAbove( + res.statusCode, + 499, + 'HTTP status code 500 or greater expected.' + ) + // console.log(res) + assert.include( + res.output.error, + 'Network error: Could not communicate with full node or other external service' + ) + }) + + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + const result = await uut.getBCHUSD(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.getBCHUSD(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should get the USD price of BCH', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: mockData.mockCoinexFeed }) + } + + const result = await uut.getBCHUSD(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isNumber(result.usd) + }) + }) +}) diff --git a/test/v5/rate-limit-unit.js b/test/v5/rate-limit-unit.js new file mode 100644 index 0000000..2b3dd6d --- /dev/null +++ b/test/v5/rate-limit-unit.js @@ -0,0 +1,506 @@ +/* + Unit tests for the route-ratelimit2.js middleware. +*/ + +'use strict' + +// Public npm libraries. +const assert = require('chai').assert +const sinon = require('sinon') +const cloneDeep = require('lodash.clonedeep') + +const config = require('../../config') + +// Mocking data. +const { mockReq, mockRes, mockNext } = require('./mocks/express-mocks') + +// Libraries under test +const RateLimits = require('../../src/middleware/route-ratelimit') +let uut = new RateLimits() + +let req, res, next + +describe('#rate-routelimit', () => { + let sandbox + + before(async () => { + if (!process.env.JWT_AUTH_SERVER) { + process.env.JWT_AUTH_SERVER = 'http://fakeurl.com/' + } + + // Wipe the Redis DB, which prevents false negatives when running integration + // tests back-to-back. + await uut.wipeRedis() + }) + + // Setup the mocks before each test. + beforeEach(() => { + // Mock the req and res objects used by Express routes. + req = cloneDeep(mockReq) + res = cloneDeep(mockRes) + next = mockNext + + // Explicitly reset the parmas and body. + req.params = {} + req.body = {} + req.query = {} + req.locals = {} + + sandbox = sinon.createSandbox() + + uut = new RateLimits() + }) + + afterEach(() => { + sandbox.restore() + }) + + after(() => { + uut.closeRedis() + }) + + describe('#checkInternalIp', () => { + it('should return true for a request from localhost', () => { + req.ip = '::ffff:127.0.0.1' + + const result = uut.checkInternalIp(req) + + assert.equal(result, true) + }) + + it('should return true for a request from a Docker container', () => { + req.ip = '172.17.0.3' + + const result = uut.checkInternalIp(req) + + assert.equal(result, true) + }) + + it('should return false for a random ip address', () => { + req.ip = '123.456.7.8' + + const result = uut.checkInternalIp(req) + + assert.equal(result, false) + }) + + it('should return false when an error is encountered', () => { + req.ip = 4 + + const result = uut.checkInternalIp(req) + + assert.equal(result, false) + }) + }) + + describe('#isInWhitelist', () => { + it('should return false when no argument is passed in', () => { + const result = uut.isInWhitelist() + + assert.equal(result, false) + }) + + it('should return false when origin is not in the whitelist', () => { + req.origin = 'blah.com' + req.get = sandbox.stub().returns(req.origin) + + const result = uut.isInWhitelist(req) + + assert.equal(result, false) + + // Used to appease linter. Remove these. + res.blah = 4 + next() + }) + + it('should return true when origin is in the whitelist', () => { + req.origin = 'message.fullstack.cash' + req.get = sandbox.stub().returns(req.origin) + + const result = uut.isInWhitelist(req) + + assert.equal(result, true) + }) + }) + + describe('#decodeJwtToken', () => { + it('should return the default JWT payload if decoding fails', () => { + const jwt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjVlODhhY2JmMDIyMWMxMDAxMmFkOTNmZiIsImVtYWlsIjoiY2hyaXMudHJvdXRuZXJAZ21haWwuY29tIiwiYXBpTGV2ZWwiOjQwLCJyYXRlTGltaXQiOjMsImlhdCI6MTYxNTE1NzA4NywiZXhwIjoxNjE3NzQ5MDg3fQ.RLNGuYAa-CcLdhTGD27tDeaxT6-GIdeR8T4JWZZLDZA' + + const result = uut.decodeJwtToken(jwt) + // console.log('result: ', result) + + assert.property(result, 'id') + // assert.equal(result.id, '123.456.789.10') + assert.property(result, 'email') + // assert.equal(result.email, 'test@bchtest.net') + // assert.property(result, 'pointsToConsume') + // assert.equal(result.pointsToConsume, config.anonRateLimit) + // assert.property(result, 'duration') + // assert.equal(result.duration, 30) + assert.property(result, 'exp') + }) + + it('should return the default JWT payload if no input is given', () => { + const result = uut.decodeJwtToken() + // console.log('result: ', result) + + assert.property(result, 'id') + assert.equal(result.id, '123.456.789.10') + assert.property(result, 'email') + assert.equal(result.email, 'test@bchtest.net') + assert.property(result, 'pointsToConsume') + assert.equal(result.pointsToConsume, config.anonRateLimit) + assert.property(result, 'duration') + assert.equal(result.duration, 30) + assert.property(result, 'exp') + }) + + it('should correctly decode a JWT token', () => { + // Generate a new JWT token for the test. + const jwtPayload = { + id: '5dade3f5739e6c0ff034b9a1', + pointsToConsume: 10, + email: 'gooduser@test.com', + apiLevel: 40, + rateLimit: 100, + duration: 30 + } + const jwtToken = uut.generateJwtToken(jwtPayload) + + const result = uut.decodeJwtToken(jwtToken) + // console.log('result: ', result) + + assert.property(result, 'id') + assert.equal(result.id, jwtPayload.id) + assert.property(result, 'email') + assert.equal(result.email, jwtPayload.email) + assert.property(result, 'pointsToConsume') + assert.equal(result.pointsToConsume, jwtPayload.pointsToConsume) + assert.property(result, 'duration') + assert.equal(result.duration, jwtPayload.duration) + assert.property(result, 'exp') + }) + + it('should return the default payload if there is an unhandled error', () => { + // Force an error. + sandbox.stub(uut, 'generateJwtToken').throws(new Error('test error')) + + const result = uut.decodeJwtToken() + // console.log('result: ', result) + + assert.property(result, 'id') + assert.equal(result.id, '123.456.789.10') + assert.property(result, 'email') + assert.equal(result.email, 'test@bchtest.net') + assert.property(result, 'pointsToConsume') + assert.equal(result.pointsToConsume, config.anonRateLimit) + assert.property(result, 'duration') + assert.equal(result.duration, 30) + assert.property(result, 'exp') + }) + }) + + describe('#trackRateLimits', () => { + it('should apply anonymous rate limits if no JWT token is provided', async () => { + req.ip = '127.0.0.1' + + const result = await uut.trackRateLimits(req, res) + // console.log(`result: `, result) + + // console.log('res.locals.pointsToConsume: ', res.locals.pointsToConsume) + + assert.equal(result, false, 'Rate limits not exceeded') + assert.equal( + res.locals.pointsToConsume, + config.anonRateLimit, + 'Anonymous rate limits applied' + ) + }) + + it('should apply 100 RPM rate limits when JWT token is provided', async () => { + // Generate a new JWT token for the test. + const jwtPayload = { + id: '5dade3f5739e6c0ff034b9a1', + pointsToConsume: 10 + } + const jwtToken = uut.generateJwtToken(jwtPayload) + + const result = await uut.trackRateLimits(req, res, jwtToken) + // console.log(`result: `, result) + + // console.log('res.locals.pointsToConsume: ', res.locals.pointsToConsume) + + assert.equal(result, false, 'Rate limits not exceeded') + assert.equal(res.locals.pointsToConsume, 10, '100 RPM limits applied') + }) + }) + + describe('#applyRateLimits', () => { + it('should skip rate limits if basic auth token is used', async () => { + req.locals.proLimit = true + + // console.log('next.callCount: ', next.callCount) + const startCallCount = next.callCount + + await uut.applyRateLimits(req, res, next) + + // console.log('next.callCount: ', next.callCount) + const endCallCount = next.callCount + + assert.isAbove( + endCallCount, + startCallCount, + 'Expecting next() to be called' + ) + }) + + it('should skip rate limits if internal call passes basic auth token', async () => { + req.ip = '127.0.0.1' + req.body.usrObj = { + proLimit: true + } + + // console.log('next.callCount: ', next.callCount) + const startCallCount = next.callCount + + await uut.applyRateLimits(req, res, next) + + // console.log('next.callCount: ', next.callCount) + const endCallCount = next.callCount + + assert.isAbove( + endCallCount, + startCallCount, + 'Expecting next() to be called' + ) + }) + + it('should apply rate limits to anonymous users', async () => { + req.ip = '123.456.7.8' + + // console.log('next.callCount: ', next.callCount) + const startCallCount = next.callCount + + await uut.applyRateLimits(req, res, next) + + // console.log('next.callCount: ', next.callCount) + const endCallCount = next.callCount + + assert.isAbove( + endCallCount, + startCallCount, + 'Expecting next() to be called' + ) + + assert.equal( + res.locals.pointsToConsume, + config.anonRateLimit, + 'Anonymous rate limits applied' + ) + }) + + it('should return 429 error when anonymous users exceed rate limit', async () => { + req.ip = '123.456.7.8' + + // force req.locals.jwtToken to be empty. + req.locals.jwtToken = undefined + + let val + for (let i = 0; i < 25; i++) { + // console.log('req.locals: ', req.locals) + val = await uut.applyRateLimits(req, res, next) + } + // console.log('val: ', val) + + assert.property(val, 'error') + assert.include( + val.error, + 'Too many requests. Your limits are currently 20 requests per minute.' + ) + + assert.equal(res.locals.rateLimitTriggered, true, 'Rate limits triggered') + + assert.equal( + res.locals.pointsToConsume, + config.anonRateLimit, + 'Anonymous rate limits applied' + ) + }) + + it('should apply rate limits when JWT token is provided', async () => { + // Generate a new JWT token for the test. + const jwtPayload = { + id: '5dade3f5739e6c0ff034b9a1', + pointsToConsume: 10 + } + const jwtToken = uut.generateJwtToken(jwtPayload) + + req.ip = '123.456.7.8' + req.locals.jwtToken = jwtToken + + // console.log('next.callCount: ', next.callCount) + const startCallCount = next.callCount + + await uut.applyRateLimits(req, res, next) + + // console.log('next.callCount: ', next.callCount) + const endCallCount = next.callCount + + assert.isAbove( + endCallCount, + startCallCount, + 'Expecting next() to be called' + ) + + assert.equal( + res.locals.pointsToConsume, + 10, + 'Anonymous rate limits applied' + ) + }) + + it('should apply internal rate limits to internal calls', async () => { + req.ip = '127.0.0.1' + + // console.log('next.callCount: ', next.callCount) + const startCallCount = next.callCount + + await uut.applyRateLimits(req, res, next) + + // console.log('next.callCount: ', next.callCount) + const endCallCount = next.callCount + + assert.isAbove( + endCallCount, + startCallCount, + 'Expecting next() to be called' + ) + + assert.equal( + res.locals.pointsToConsume, + 10, + 'Internal rate limits applied' + ) + }) + + it('should return 429 error when internal calls exceed interal rate limit', async () => { + req.ip = '127.0.0.1' + + let val + for (let i = 0; i < 1025; i++) { + val = await uut.applyRateLimits(req, res, next) + } + + assert.property(val, 'error') + assert.include( + val.error, + 'Too many requests. Your limits are currently 1000 requests per minute.' + ) + + assert.equal(res.locals.rateLimitTriggered, true, 'Rate limits triggered') + + assert.equal( + res.locals.pointsToConsume, + 10, + 'Internal rate limits applied' + ) + }) + + it('should apply JWT rate limits to internal calls when JWT passes through', async () => { + // Generate a new JWT token for the test. + const jwtPayload = { + id: '5dade3f5739e6c0ff034b9a1', + pointsToConsume: 10 + } + const jwtToken = uut.generateJwtToken(jwtPayload) + + req.ip = '127.0.0.1' + req.body.usrObj = { + jwtToken + } + + // console.log('next.callCount: ', next.callCount) + const startCallCount = next.callCount + + await uut.applyRateLimits(req, res, next) + + // console.log('next.callCount: ', next.callCount) + const endCallCount = next.callCount + + assert.isAbove( + endCallCount, + startCallCount, + 'Expecting next() to be called' + ) + + assert.equal( + res.locals.pointsToConsume, + 10, + 'User JWT rate limits applied' + ) + }) + + it('should return 429 error when internal calls using JWT pass-through exceeds rate limit', async () => { + // Generate a new JWT token for the test. + const jwtPayload = { + id: '5dade3f5739e6c0ff034b9a1', + pointsToConsume: 100 + } + const jwtToken = uut.generateJwtToken(jwtPayload) + + req.ip = '127.0.0.1' + req.body.usrObj = { + jwtToken + } + + try { + let val + for (let i = 0; i < 120; i++) { + val = await uut.applyRateLimits(req, res, next) + } + // console.log('val: ', val) + + assert.property(val, 'error') + assert.include( + val.error, + 'Too many requests. Your limits are currently 100 requests per minute.' + ) + + assert.equal( + res.locals.pointsToConsume, + 100, + 'User JWT rate limits applied' + ) + } catch (err) { + console.log('err: ', err) + assert.fail('Unexpected result') + } + }) + + it('should move to the next middleware when encountering an unexpected internal error', async () => { + // Force the creation of the res and req locals property. Covers an + // otherwise untested code path. + req.locals = undefined + res.locals = undefined + + // Force an error + sandbox.stub(uut, 'checkInternalIp').throws(new Error('test error')) + + // console.log('next.callCount: ', next.callCount) + const startCallCount = next.callCount + + await uut.applyRateLimits(req, res, next) + + // console.log('next.callCount: ', next.callCount) + const endCallCount = next.callCount + + assert.isAbove( + endCallCount, + startCallCount, + 'Expecting next() to be called' + ) + }) + }) +}) diff --git a/test/v5/raw-transactions.js b/test/v5/raw-transactions.js new file mode 100644 index 0000000..587a12c --- /dev/null +++ b/test/v5/raw-transactions.js @@ -0,0 +1,1181 @@ +/* + TESTS FOR THE RAWTRANSACTIONS.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: + -Create e2e test for sendRawTransaction. + +*/ + +'use strict' + +const chai = require('chai') +const assert = chai.assert +const Rawtransactions = require('../../src/routes/v4/full-node/rawtransactions') +const uut = new Rawtransactions() + +// const nock = require('nock') // HTTP mocking + +const sinon = require('sinon') +let originalEnvVars // Used during transition from integration to unit tests. + +// Mocking data. +// delete require.cache[require.resolve("./mocks/express-mocks")] // Fixes bug +// const { mockReq, mockRes, mockNext } = require('./mocks/express-mocks') +const { mockReq, mockRes } = require('./mocks/express-mocks') +const mockData = require('./mocks/raw-transactions-mocks') + +// Used for debugging. +const util = require('util') +util.inspect.defaultOptions = { depth: 5 } + +describe('#Raw-Transactions', () => { + // let req, res, next + let req, res + let sandbox + 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 + // next = mockNext + + // Explicitly reset the parmas and body. + req.params = {} + req.body = {} + req.query = {} + req.locals = {} + + sandbox = sinon.createSandbox() + }) + + afterEach(() => { + // Restore Sandbox + sandbox.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 () => { + it('should respond to GET for base route', async () => { + const result = uut.root(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.equal(result.status, 'rawtransactions', 'Returns static string') + }) + }) + + describe('decodeRawTransactionSingle()', () => { + it('should throw error if hex is missing', async () => { + const result = await uut.decodeRawTransactionSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'hex can not be empty') + }) + + it('should throw 503 when network issues', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + req.params.hex = + '0200000001b9b598d7d6d72fc486b2b3a3c03c79b5bade6ec9a77ced850515ab5e64edcc21010000006b483045022100a7b1b08956abb8d6f322aa709d8583c8ea492ba0585f1a6f4f9983520af74a5a0220411aee4a9a54effab617b0508c504c31681b15f9b187179b4874257badd4139041210360cfc66fdacb650bc4c83b4e351805181ee696b7d5ab4667c57b2786f51c413dffffffff0210270000000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac786e9800000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac00000000' + + await uut.decodeRawTransactionSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + + 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") + }) + it('returns proper error when downstream service stalls', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + req.params.hex = + '0200000001b9b598d7d6d72fc486b2b3a3c03c79b5bade6ec9a77ced850515ab5e64edcc21010000006b483045022100a7b1b08956abb8d6f322aa709d8583c8ea492ba0585f1a6f4f9983520af74a5a0220411aee4a9a54effab617b0508c504c31681b15f9b187179b4874257badd4139041210360cfc66fdacb650bc4c83b4e351805181ee696b7d5ab4667c57b2786f51c413dffffffff0210270000000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac786e9800000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac00000000' + + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + const result = await uut.decodeRawTransactionSingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + }) + + it('returns proper error when downstream service is down', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + req.params.hex = + '0200000001b9b598d7d6d72fc486b2b3a3c03c79b5bade6ec9a77ced850515ab5e64edcc21010000006b483045022100a7b1b08956abb8d6f322aa709d8583c8ea492ba0585f1a6f4f9983520af74a5a0220411aee4a9a54effab617b0508c504c31681b15f9b187179b4874257badd4139041210360cfc66fdacb650bc4c83b4e351805181ee696b7d5ab4667c57b2786f51c413dffffffff0210270000000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac786e9800000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac00000000' + + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.decodeRawTransactionSingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + }) + + it('should GET /decodeRawTransaction', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockDecodeRawTransaction } }) + } + + req.params.hex = + '0200000001b9b598d7d6d72fc486b2b3a3c03c79b5bade6ec9a77ced850515ab5e64edcc21010000006b483045022100a7b1b08956abb8d6f322aa709d8583c8ea492ba0585f1a6f4f9983520af74a5a0220411aee4a9a54effab617b0508c504c31681b15f9b187179b4874257badd4139041210360cfc66fdacb650bc4c83b4e351805181ee696b7d5ab4667c57b2786f51c413dffffffff0210270000000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac786e9800000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac00000000' + + const result = await uut.decodeRawTransactionSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAnyKeys(result, [ + 'txid', + 'hash', + 'size', + 'version', + 'locktime', + 'vin', + 'vout' + ]) + assert.isArray(result.vin) + assert.isArray(result.vout) + }) + }) + + describe('decodeRawTransactionBulk()', () => { + it('should throw 400 error if hexes array is missing', async () => { + const result = await uut.decodeRawTransactionBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'hexes must be an array') + }) + + it('should throw 400 error if hexes array is too large', async () => { + const testArray = [] + for (var i = 0; i < 25; i++) testArray.push('') + + req.body.hexes = testArray + + const result = await uut.decodeRawTransactionBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Array too large') + }) + + it('should throw 400 error if hexes is empty', async () => { + req.body.hexes = [''] + + const result = await uut.decodeRawTransactionBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Encountered empty hex') + }) + + it('should error on non-array single hex', async () => { + req.body.hexes = + '0200000001b9b598d7d6d72fc486b2b3a3c03c79b5bade6ec9a77ced850515ab5e64edcc21010000006b483045022100a7b1b08956abb8d6f322aa709d8583c8ea492ba0585f1a6f4f9983520af74a5a0220411aee4a9a54effab617b0508c504c31681b15f9b187179b4874257badd4139041210360cfc66fdacb650bc4c83b4e351805181ee696b7d5ab4667c57b2786f51c413dffffffff0210270000000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac786e9800000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac00000000' + + const result = await uut.decodeRawTransactionBulk(req, res) + + assert.equal(res.statusCode, 400, 'HTTP status code 400 expected.') + assert.include( + result.error, + 'hexes must be an array', + 'Proper error message' + ) + }) + + it('returns proper error when downstream service stalls', async () => { + req.body.hexes = [ + '0200000001b9b598d7d6d72fc486b2b3a3c03c79b5bade6ec9a77ced850515ab5e64edcc21010000006b483045022100a7b1b08956abb8d6f322aa709d8583c8ea492ba0585f1a6f4f9983520af74a5a0220411aee4a9a54effab617b0508c504c31681b15f9b187179b4874257badd4139041210360cfc66fdacb650bc4c83b4e351805181ee696b7d5ab4667c57b2786f51c413dffffffff0210270000000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac786e9800000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac00000000' + ] + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + const result = await uut.decodeRawTransactionBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + req.body.hexes = [ + '0200000001b9b598d7d6d72fc486b2b3a3c03c79b5bade6ec9a77ced850515ab5e64edcc21010000006b483045022100a7b1b08956abb8d6f322aa709d8583c8ea492ba0585f1a6f4f9983520af74a5a0220411aee4a9a54effab617b0508c504c31681b15f9b187179b4874257badd4139041210360cfc66fdacb650bc4c83b4e351805181ee696b7d5ab4667c57b2786f51c413dffffffff0210270000000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac786e9800000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac00000000' + ] + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.decodeRawTransactionBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should decode an array with a single hex', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockDecodeRawTransaction } }) + } + + req.body.hexes = [ + '0200000001b9b598d7d6d72fc486b2b3a3c03c79b5bade6ec9a77ced850515ab5e64edcc21010000006b483045022100a7b1b08956abb8d6f322aa709d8583c8ea492ba0585f1a6f4f9983520af74a5a0220411aee4a9a54effab617b0508c504c31681b15f9b187179b4874257badd4139041210360cfc66fdacb650bc4c83b4e351805181ee696b7d5ab4667c57b2786f51c413dffffffff0210270000000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac786e9800000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac00000000' + ] + + const result = await uut.decodeRawTransactionBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.hasAnyKeys(result[0], [ + 'txid', + 'hash', + 'size', + 'version', + 'locktime', + 'vin', + 'vout' + ]) + assert.isArray(result[0].vin) + assert.isArray(result[0].vout) + }) + + it('should decode an array with multiple hexes', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockDecodeRawTransaction } }) + } + + req.body.hexes = [ + '0200000001b9b598d7d6d72fc486b2b3a3c03c79b5bade6ec9a77ced850515ab5e64edcc21010000006b483045022100a7b1b08956abb8d6f322aa709d8583c8ea492ba0585f1a6f4f9983520af74a5a0220411aee4a9a54effab617b0508c504c31681b15f9b187179b4874257badd4139041210360cfc66fdacb650bc4c83b4e351805181ee696b7d5ab4667c57b2786f51c413dffffffff0210270000000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac786e9800000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac00000000', + '0200000001b9b598d7d6d72fc486b2b3a3c03c79b5bade6ec9a77ced850515ab5e64edcc21010000006b483045022100a7b1b08956abb8d6f322aa709d8583c8ea492ba0585f1a6f4f9983520af74a5a0220411aee4a9a54effab617b0508c504c31681b15f9b187179b4874257badd4139041210360cfc66fdacb650bc4c83b4e351805181ee696b7d5ab4667c57b2786f51c413dffffffff0210270000000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac786e9800000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac00000000' + ] + + const result = await uut.decodeRawTransactionBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.hasAnyKeys(result[0], [ + 'txid', + 'hash', + 'size', + 'version', + 'locktime', + 'vin', + 'vout' + ]) + assert.isArray(result[0].vin) + assert.isArray(result[0].vout) + }) + }) + + describe('decodeScriptSingle()', () => { + it('should throw error if hex is missing', async () => { + const result = await uut.decodeScriptSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'hex can not be empty') + }) + + it('should throw 503 when network issues', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + req.params.hex = + '0200000001b9b598d7d6d72fc486b2b3a3c03c79b5bade6ec9a77ced850515ab5e64edcc21010000006b483045022100a7b1b08956abb8d6f322aa709d8583c8ea492ba0585f1a6f4f9983520af74a5a0220411aee4a9a54effab617b0508c504c31681b15f9b187179b4874257badd4139041210360cfc66fdacb650bc4c83b4e351805181ee696b7d5ab4667c57b2786f51c413dffffffff0210270000000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac786e9800000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac00000000' + + await uut.decodeScriptSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + + 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") + }) + it('returns proper error when downstream service stalls', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + req.params.hex = + '0200000001b9b598d7d6d72fc486b2b3a3c03c79b5bade6ec9a77ced850515ab5e64edcc21010000006b483045022100a7b1b08956abb8d6f322aa709d8583c8ea492ba0585f1a6f4f9983520af74a5a0220411aee4a9a54effab617b0508c504c31681b15f9b187179b4874257badd4139041210360cfc66fdacb650bc4c83b4e351805181ee696b7d5ab4667c57b2786f51c413dffffffff0210270000000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac786e9800000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac00000000' + + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + const result = await uut.decodeScriptSingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + }) + + it('returns proper error when downstream service is down', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + req.params.hex = + '0200000001b9b598d7d6d72fc486b2b3a3c03c79b5bade6ec9a77ced850515ab5e64edcc21010000006b483045022100a7b1b08956abb8d6f322aa709d8583c8ea492ba0585f1a6f4f9983520af74a5a0220411aee4a9a54effab617b0508c504c31681b15f9b187179b4874257badd4139041210360cfc66fdacb650bc4c83b4e351805181ee696b7d5ab4667c57b2786f51c413dffffffff0210270000000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac786e9800000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac00000000' + + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.decodeScriptSingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + }) + it('should GET /decodeScriptSingle', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockDecodeScript } }) + } + + req.params.hex = + '0200000001b9b598d7d6d72fc486b2b3a3c03c79b5bade6ec9a77ced850515ab5e64edcc21010000006b483045022100a7b1b08956abb8d6f322aa709d8583c8ea492ba0585f1a6f4f9983520af74a5a0220411aee4a9a54effab617b0508c504c31681b15f9b187179b4874257badd4139041210360cfc66fdacb650bc4c83b4e351805181ee696b7d5ab4667c57b2786f51c413dffffffff0210270000000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac786e9800000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac00000000' + + const result = await uut.decodeScriptSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['asm', 'type', 'p2sh']) + }) + }) + + describe('decodeScriptBulk()', () => { + it('should throw 400 error if hexes array is missing', async () => { + const result = await uut.decodeScriptBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'hexes must be an array') + }) + + it('should throw 400 error if hexes array is too large', async () => { + const testArray = [] + for (var i = 0; i < 25; i++) testArray.push('') + + req.body.hexes = testArray + + const result = await uut.decodeScriptBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Array too large') + }) + + it('should throw 400 error if hexes is empty', async () => { + req.body.hexes = [''] + + const result = await uut.decodeScriptBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Encountered empty hex') + }) + + it('should error on non-array single hex', async () => { + req.body.hexes = + '0200000001b9b598d7d6d72fc486b2b3a3c03c79b5bade6ec9a77ced850515ab5e64edcc21010000006b483045022100a7b1b08956abb8d6f322aa709d8583c8ea492ba0585f1a6f4f9983520af74a5a0220411aee4a9a54effab617b0508c504c31681b15f9b187179b4874257badd4139041210360cfc66fdacb650bc4c83b4e351805181ee696b7d5ab4667c57b2786f51c413dffffffff0210270000000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac786e9800000000001976a914eb4b180def88e3f5625b2d8ae2c098ff7d85f66488ac00000000' + + const result = await uut.decodeScriptBulk(req, res) + + assert.equal(res.statusCode, 400, 'HTTP status code 400 expected.') + assert.include( + result.error, + 'hexes must be an array', + 'Proper error message' + ) + }) + it('returns proper error when downstream service stalls', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + req.body.hexes = [ + '4830450221009a51e00ec3524a7389592bc27bea4af5104a59510f5f0cfafa64bbd5c164ca2e02206c2a8bbb47eabdeed52f17d7df668d521600286406930426e3a9415fe10ed592012102e6e1423f7abde8b70bca3e78a7d030e5efabd3eb35c19302542b5fe7879c1a16' + ] + + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + const result = await uut.decodeScriptBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + }) + + it('returns proper error when downstream service is down', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + req.body.hexes = [ + '4830450221009a51e00ec3524a7389592bc27bea4af5104a59510f5f0cfafa64bbd5c164ca2e02206c2a8bbb47eabdeed52f17d7df668d521600286406930426e3a9415fe10ed592012102e6e1423f7abde8b70bca3e78a7d030e5efabd3eb35c19302542b5fe7879c1a16' + ] + + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.decodeScriptBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + }) + + it('should decode an array with a single hex', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockDecodeScript } }) + } + + req.body.hexes = [ + '4830450221009a51e00ec3524a7389592bc27bea4af5104a59510f5f0cfafa64bbd5c164ca2e02206c2a8bbb47eabdeed52f17d7df668d521600286406930426e3a9415fe10ed592012102e6e1423f7abde8b70bca3e78a7d030e5efabd3eb35c19302542b5fe7879c1a16' + ] + + const result = await uut.decodeScriptBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.hasAllKeys(result[0], ['asm', 'type', 'p2sh']) + }) + + it('should decode an array with a multiple hexes', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockDecodeScript } }) + } + + req.body.hexes = [ + '4830450221009a51e00ec3524a7389592bc27bea4af5104a59510f5f0cfafa64bbd5c164ca2e02206c2a8bbb47eabdeed52f17d7df668d521600286406930426e3a9415fe10ed592012102e6e1423f7abde8b70bca3e78a7d030e5efabd3eb35c19302542b5fe7879c1a16', + '4830450221009a51e00ec3524a7389592bc27bea4af5104a59510f5f0cfafa64bbd5c164ca2e02206c2a8bbb47eabdeed52f17d7df668d521600286406930426e3a9415fe10ed592012102e6e1423f7abde8b70bca3e78a7d030e5efabd3eb35c19302542b5fe7879c1a16' + ] + + const result = await uut.decodeScriptBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.equal(result.length, 2) + assert.hasAllKeys(result[0], ['asm', 'type', 'p2sh']) + }) + }) + + describe('getRawTransactionBulk()', () => { + it('should throw 400 error if txids array is missing', async () => { + const result = await uut.getRawTransactionBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'txids must be an array') + }) + + it('should throw 400 error if txids array is too large', async () => { + const testArray = [] + for (var i = 0; i < 25; i++) testArray.push('') + + req.body.txids = testArray + + const result = await uut.getRawTransactionBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Array too large') + }) + + it('should throw 400 error if txid is empty', async () => { + req.body.txids = [''] + + const result = await uut.getRawTransactionBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Encountered empty TXID') + }) + + it('should throw 400 error if txid is invalid', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .rejects('parameter 1 must be of length 64 (not 6)') + } + + req.body.txids = ['abc123'] + + const result = await uut.getRawTransactionBulk(req, res) + + assert.hasAllKeys(result, ['error']) + assert.equal(res.statusCode, 400, 'HTTP status code 400 expected.') + assert.include(result.error, 'parameter 1 must be of length 64 (not 6)') + }) + + it('returns proper error when downstream service stalls', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + req.body.txids = [ + '2c7ae9f865f7ce0c33c189b2f83414176903ce4b06ed9f8b7bcf55efbd4a7266' + ] + + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + const result = await uut.getRawTransactionBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + }) + + it('returns proper error when downstream service is down', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + req.body.txids = [ + '2c7ae9f865f7ce0c33c189b2f83414176903ce4b06ed9f8b7bcf55efbd4a7266' + ] + + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.getRawTransactionBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + }) + + it('should get concise transaction data', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockRawTransactionConcise } }) + } + + req.body.txids = [ + '2c7ae9f865f7ce0c33c189b2f83414176903ce4b06ed9f8b7bcf55efbd4a7266' + ] + + const result = await uut.getRawTransactionBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.isString(result[0]) + }) + + it('should get verbose transaction data', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockRawTransactionVerbose } }) + } + + req.body.txids = [ + '2c7ae9f865f7ce0c33c189b2f83414176903ce4b06ed9f8b7bcf55efbd4a7266' + ] + req.body.verbose = true + + const result = await uut.getRawTransactionBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.hasAnyKeys(result[0], [ + 'hex', + 'txid', + 'hash', + 'size', + 'version', + 'locktime', + 'vin', + 'vout', + 'blockhash', + 'confirmations', + 'time', + 'blocktime' + ]) + assert.isArray(result[0].vin) + assert.isArray(result[0].vout) + }) + }) + + describe('getRawTransactionSingle()', () => { + it('should throw 400 error if txid is missing', async () => { + const result = await uut.getRawTransactionSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'txid can not be empty') + }) + + it('should throw 400 error if txid is invalid', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .rejects('parameter 1 must be of length 64 (not 6)') + } + + req.params.txid = 'abc123' + + const result = await uut.getRawTransactionSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + // console.log(`res.statusCode: ${res.statusCode}`) + + assert.hasAllKeys(result, ['error']) + assert.equal(res.statusCode, 400, 'HTTP status code 400 expected.') + assert.include(result.error, 'parameter 1 must be of length 64 (not 6)') + }) + it('returns proper error when downstream service stalls', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + req.params.txid = + '2c7ae9f865f7ce0c33c189b2f83414176903ce4b06ed9f8b7bcf55efbd4a7266' + + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + const result = await uut.getRawTransactionSingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + }) + + it('returns proper error when downstream service is down', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + req.params.txid = + '2c7ae9f865f7ce0c33c189b2f83414176903ce4b06ed9f8b7bcf55efbd4a7266' + + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.getRawTransactionSingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + }) + + it('should get concise transaction data', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockRawTransactionConcise } }) + } + + req.params.txid = + '2c7ae9f865f7ce0c33c189b2f83414176903ce4b06ed9f8b7bcf55efbd4a7266' + + const result = await uut.getRawTransactionSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isString(result) + }) + + it('should get verbose transaction data', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'request') + .resolves({ data: { result: mockData.mockRawTransactionVerbose } }) + } + + req.params.txid = + '2c7ae9f865f7ce0c33c189b2f83414176903ce4b06ed9f8b7bcf55efbd4a7266' + req.query.verbose = 'true' + + const result = await uut.getRawTransactionSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAnyKeys(result, [ + 'hex', + 'txid', + 'hash', + 'size', + 'version', + 'locktime', + 'vin', + 'vout', + 'blockhash', + 'confirmations', + 'time', + 'blocktime' + ]) + assert.isArray(result.vin) + assert.isArray(result.vout) + }) + }) + + describe('sendRawTransactionBulk()', () => { + it('should throw 400 error if hexs array is missing', async () => { + const result = await uut.sendRawTransactionBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'hex must be an array') + }) + + it('should throw 400 error if hexs array is too large', async () => { + const testArray = [] + for (var i = 0; i < 25; i++) testArray.push('') + + req.body.hexes = testArray + + const result = await uut.sendRawTransactionBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Array too large') + }) + + it('should throw 400 error if hex array element is empty', async () => { + req.body.hexes = [''] + + const result = await uut.sendRawTransactionBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Encountered empty hex') + }) + + it('should throw 400 error if hex is invalid', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox.stub(uut.axios, 'request').rejects({ + response: { + data: { error: { code: -22, message: 'TX decode failed' } } + } + }) + } + + req.body.hexes = ['abc123'] + + const result = await uut.sendRawTransactionBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.equal(res.statusCode, 400, 'HTTP status code 400 expected.') + assert.include(result.error, 'TX decode failed') + }) + + it('returns proper error when downstream service stalls', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + req.body.hexes = [ + '020000000136697692fed77bc4f5b6885295d0c56d1d0280fb578f445ce42be4eb6db381f2010000006a4730440220473adba0e7da14f0abf4817bbd591741ecb8da6544b998f10341f6704f5f05280220405221c626cb7edcf333367ebd469aff3f5a2169e37ee58eebb811ffc2fbc9e0412102202ff86325c5d903171fa5a2895c4efb3765105115460dc96f113048ddb69b47feffffff027a621b00000000001976a914e0a8ffc3b91e35f46618d6db90f66397989abf0588ac38041300000000001976a914a741f282af390bc7ea8c4375a3a56401d668564288ac2c330900' + ] + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + const result = await uut.sendRawTransactionBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + }) + + it('returns proper error when downstream service is down', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + req.body.hexes = [ + '020000000136697692fed77bc4f5b6885295d0c56d1d0280fb578f445ce42be4eb6db381f2010000006a4730440220473adba0e7da14f0abf4817bbd591741ecb8da6544b998f10341f6704f5f05280220405221c626cb7edcf333367ebd469aff3f5a2169e37ee58eebb811ffc2fbc9e0412102202ff86325c5d903171fa5a2895c4efb3765105115460dc96f113048ddb69b47feffffff027a621b00000000001976a914e0a8ffc3b91e35f46618d6db90f66397989abf0588ac38041300000000001976a914a741f282af390bc7ea8c4375a3a56401d668564288ac2c330900' + ] + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.sendRawTransactionBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + }) + + it('should submit hex encoded transaction', async () => { + // This is a difficult test to run as transaction hex is invalid after a + // block confirmation. So the unit tests simulates what the output 'should' + // be, but the integration asserts an expected failure. + + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox.stub(uut.axios, 'request').resolves({ + data: { + result: + 'aef8848396e67532b42008b9d75b5a5a3459a6717740f31f0553b74102b4b118' + } + }) + } + + req.body.hexes = [ + '020000000136697692fed77bc4f5b6885295d0c56d1d0280fb578f445ce42be4eb6db381f2010000006a4730440220473adba0e7da14f0abf4817bbd591741ecb8da6544b998f10341f6704f5f05280220405221c626cb7edcf333367ebd469aff3f5a2169e37ee58eebb811ffc2fbc9e0412102202ff86325c5d903171fa5a2895c4efb3765105115460dc96f113048ddb69b47feffffff027a621b00000000001976a914e0a8ffc3b91e35f46618d6db90f66397989abf0588ac38041300000000001976a914a741f282af390bc7ea8c4375a3a56401d668564288ac2c330900' + ] + + const result = await uut.sendRawTransactionBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + if (process.env.TEST === 'unit') { + assert.isArray(result) + assert.isString(result[0]) + + // Integration test + } else { + if (process.env.ISBCHN) { + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Missing inputs') + } else { + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'bad-txns-inputs-missingorspent') + } + } + }) + }) + + describe('sendRawTransactionSingle()', () => { + it('should throw an error for an empty hex', async () => { + req.params.hex = '' + + const result = await uut.sendRawTransactionSingle(req, res) + + assert.equal(res.statusCode, 400, 'HTTP status code 400 expected.') + assert.include( + result.error, + 'Encountered empty hex', + 'Proper error message' + ) + }) + + it('should throw an error for a non-string', async () => { + req.params.hex = 456 + + const result = await uut.sendRawTransactionSingle(req, res) + + assert.equal(res.statusCode, 400, 'HTTP status code 400 expected.') + assert.include( + result.error, + 'hex must be a string', + '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 + const savedUrl3 = process.env.RPC_SENDURL + + // Manipulate the URL to cause a 500 network error. + process.env.BITCOINCOM_BASEURL = 'http://fakeurl/api/' + process.env.RPC_BASEURL = 'http://fakeurl/api/' + process.env.RPC_SENDURL = 'http://fakeurl/api/' + + req.params.hex = + '020000000136697692fed77bc4f5b6885295d0c56d1d0280fb578f445ce42be4eb6db381f2010000006a4730440220473adba0e7da14f0abf4817bbd591741ecb8da6544b998f10341f6704f5f05280220405221c626cb7edcf333367ebd469aff3f5a2169e37ee58eebb811ffc2fbc9e0412102202ff86325c5d903171fa5a2895c4efb3765105115460dc96f113048ddb69b47feffffff027a621b00000000001976a914e0a8ffc3b91e35f46618d6db90f66397989abf0588ac38041300000000001976a914a741f282af390bc7ea8c4375a3a56401d668564288ac2c330900' + await uut.sendRawTransactionSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.BITCOINCOM_BASEURL = savedUrl + process.env.RPC_BASEURL = savedUrl2 + process.env.RPC_SENDURL = savedUrl3 + + assert.isAbove( + res.statusCode, + 499, + 'HTTP status code 500 or great expected.' + ) + }) + + it('should throw an error for invalid hex', async () => { + req.params.hex = 'abc123' + + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox.stub(uut.axios, 'request').rejects({ + response: { + data: { error: { code: -22, message: 'TX decode failed' } } + } + }) + } + + const result = await uut.sendRawTransactionSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.equal(res.statusCode, 400, 'HTTP status code 400 expected.') + assert.include(result.error, 'TX decode failed') + }) + + it('returns proper error when downstream service stalls', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + req.params.hex = + '020000000136697692fed77bc4f5b6885295d0c56d1d0280fb578f445ce42be4eb6db381f2010000006a4730440220473adba0e7da14f0abf4817bbd591741ecb8da6544b998f10341f6704f5f05280220405221c626cb7edcf333367ebd469aff3f5a2169e37ee58eebb811ffc2fbc9e0412102202ff86325c5d903171fa5a2895c4efb3765105115460dc96f113048ddb69b47feffffff027a621b00000000001976a914e0a8ffc3b91e35f46618d6db90f66397989abf0588ac38041300000000001976a914a741f282af390bc7ea8c4375a3a56401d668564288ac2c330900' + + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNABORTED' }) + + const result = await uut.sendRawTransactionSingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + }) + + it('returns proper error when downstream service is down', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + req.params.hex = + '020000000136697692fed77bc4f5b6885295d0c56d1d0280fb578f445ce42be4eb6db381f2010000006a4730440220473adba0e7da14f0abf4817bbd591741ecb8da6544b998f10341f6704f5f05280220405221c626cb7edcf333367ebd469aff3f5a2169e37ee58eebb811ffc2fbc9e0412102202ff86325c5d903171fa5a2895c4efb3765105115460dc96f113048ddb69b47feffffff027a621b00000000001976a914e0a8ffc3b91e35f46618d6db90f66397989abf0588ac38041300000000001976a914a741f282af390bc7ea8c4375a3a56401d668564288ac2c330900' + + // Mock the timeout error. + sandbox.stub(uut.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.sendRawTransactionSingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + }) + + it('should GET /sendRawTransaction/:hex', async () => { + // This is a difficult test to run as transaction hex is invalid after a + // block confirmation. So the unit tests simulates what the output 'should' + // be, but the integration asserts an expected failure. + + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox.stub(uut.axios, 'request').resolves({ + data: { + result: + 'aef8848396e67532b42008b9d75b5a5a3459a6717740f31f0553b74102b4b118' + } + }) + } + + req.params.hex = + '020000000136697692fed77bc4f5b6885295d0c56d1d0280fb578f445ce42be4eb6db381f2010000006a4730440220473adba0e7da14f0abf4817bbd591741ecb8da6544b998f10341f6704f5f05280220405221c626cb7edcf333367ebd469aff3f5a2169e37ee58eebb811ffc2fbc9e0412102202ff86325c5d903171fa5a2895c4efb3765105115460dc96f113048ddb69b47feffffff027a621b00000000001976a914e0a8ffc3b91e35f46618d6db90f66397989abf0588ac38041300000000001976a914a741f282af390bc7ea8c4375a3a56401d668564288ac2c330900' + + const result = await uut.sendRawTransactionSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + if (process.env.TEST === 'unit') { + assert.isString(result) + + // Integration test + } else { + if (process.env.ISBCHN) { + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Missing inputs') + } else { + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'bad-txns-inputs-missingorspent') + } + } + }) + }) +}) diff --git a/test/v5/route-utils.js b/test/v5/route-utils.js new file mode 100644 index 0000000..ba25cfd --- /dev/null +++ b/test/v5/route-utils.js @@ -0,0 +1,34 @@ +/* + Unit tests for the route-utils.js library. +*/ + +const assert = require('chai').assert + +const RouteUtils = require('../../src/util/route-utils.js') + +describe('#route-utils', () => { + let uut + + beforeEach(() => { + uut = new RouteUtils() + }) + + describe('#decodeError', () => { + it('should decode a 429 error from nginx', () => { + const err = { + error: '\r\n429 Too Many Requests\r\n\r\n

429 Too Many Requests

\r\n
nginx/1.18.0 (Ubuntu)
\r\n\r\n\r\n', + level: 'error', + message: 'Error in slp.js/hydrateUtxos().', + timestamp: '2021-03-31T04:13:36.662Z' + } + + const result = uut.decodeError(err) + // console.log('result: ', result) + + assert.property(result, 'msg') + assert.equal(result.msg, '429 Too Many Requests') + assert.property(result, 'status') + assert.equal(result.status, 429) + }) + }) +}) diff --git a/test/v5/slp.js b/test/v5/slp.js new file mode 100644 index 0000000..3cb48e5 --- /dev/null +++ b/test/v5/slp.js @@ -0,0 +1,2548 @@ +/* + TESTS FOR THE SLP.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 listSingleToken() tests. +*/ + +'use strict' + +const chai = require('chai') +const assert = chai.assert +const sinon = require('sinon') +// const proxyquire = require('proxyquire').noPreserveCache() +// const axios = require('axios') + +// Save existing environment variables. +// Used during transition from integration to unit tests. + +// eslint-disable-next-line no-unused-vars +let mockServerUrl +const originalEnvVars = { + BITDB_URL: process.env.BITDB_URL, + BITCOINCOM_BASEURL: process.env.BITCOINCOM_BASEURL, + SLPDB_URL: process.env.SLPDB_URL +} + +// Set default environment variables for unit tests. +if (!process.env.TEST) process.env.TEST = 'unit' + +// Block network connections for unit tests. +if (process.env.TEST === 'unit') { + process.env.BITDB_URL = 'http://fakeurl/' + process.env.BITCOINCOM_BASEURL = 'http://fakeurl/' + process.env.SLPDB_URL = 'http://fakeurl/' + mockServerUrl = 'http://fakeurl' +} + +// Prepare the slpRoute for stubbing dependcies on slpjs. +const SlpRoute = require('../../src/routes/v4/slp') +const slpRoute = new SlpRoute() + +// const pathStub = {} // Used to stub methods within slpjs. +// const slpRouteStub = proxyquire('../../src/routes/v4/slp', { slpjs: pathStub }) + +// Mocking data. +const { mockReq, mockRes } = require('./mocks/express-mocks') +const mockData = require('./mocks/slp-mocks') +// const slpjsMock = require('./mocks/slpjs-mocks') + +// Used for debugging. +const util = require('util') +util.inspect.defaultOptions = { depth: 1 } + +describe('#SLP', () => { + let req, res + let sandbox + + before(() => {}) + + // 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 = {} + req.locals = {} + + sandbox = sinon.createSandbox() + }) + + afterEach(() => { + sandbox.restore() + }) + + after(() => { + // Restore any pre-existing environment variables. + process.env.BITDB_URL = originalEnvVars.BITDB_URL + process.env.BITCOINCOM_BASEURL = originalEnvVars.BITCOINCOM_BASEURL + process.env.SLPDB_URL = originalEnvVars.SLPDB_URL + }) + + describe('#root', async () => { + // root route handler. + const root = slpRoute.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, 'slp', 'Returns static string') + }) + }) + + describe('balancesForAddress()', () => { + const balancesForAddress = slpRoute.balancesForAddress + + it('should throw 400 if address is empty', async () => { + const result = await balancesForAddress(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'address can not be empty') + }) + + it('should throw 400 if address is invalid', async () => { + req.params.address = 'badAddress' + + const result = await balancesForAddress(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Invalid BCH address.') + }) + + it('should throw 400 if address network mismatch', async () => { + req.params.address = 'slptest:qzcvpw3ah7r880d49wsqzrsl90pg0rqjjurmj3g4nk' + + const result = await balancesForAddress(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Invalid') + }) + + it('should throw 5XX error when network issues', async () => { + // Save the existing SLPDB_URL. + const savedUrl2 = process.env.SLPDB_URL + + // Manipulate the URL to cause a 500 network error. + process.env.SLPDB_URL = 'http://fakeurl/api/' + + req.params.address = + 'simpleledger:qpujxqra3jmdlzzapwmmt7uspr7q0c9ff5me5fdrdn' + + const result = await balancesForAddress(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.SLPDB_URL = savedUrl2 + + assert.isAbove( + res.statusCode, + 499, + 'HTTP status code 500 or greater expected.' + ) + assert.include( + result.error, + 'Network error: Could not communicate', + 'Error message expected' + ) + }) + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(slpRoute.axios, 'request').throws({ code: 'ECONNABORTED' }) + + req.params.address = + 'simpleledger:qpujxqra3jmdlzzapwmmt7uspr7q0c9ff5me5fdrdn' + + const result = await balancesForAddress(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(slpRoute.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + req.params.address = + 'simpleledger:qpujxqra3jmdlzzapwmmt7uspr7q0c9ff5me5fdrdn' + + const result = await balancesForAddress(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should get token balance for an address', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox.stub(slpRoute.axios, 'request').resolves({ + data: mockData.mockSingleAddress + }) + } + + req.params.address = + 'simpleledger:qpujxqra3jmdlzzapwmmt7uspr7q0c9ff5me5fdrdn' + + const result = await balancesForAddress(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + + assert.property(result[0], 'tokenId') + assert.property(result[0], 'balanceString') + assert.property(result[0], 'slpAddress') + assert.property(result[0], 'balance') + assert.property(result[0], 'decimalCount') + + assert.isNumber(result[0].balance) + assert.isNumber(result[0].decimalCount) + }) + }) + + describe('balancesForAddressBulk()', () => { + const balancesForAddressBulk = slpRoute.balancesForAddressBulk + + it('should throw 400 if addresses is empty', async () => { + const result = await balancesForAddressBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'addresses needs to be an array') + }) + + it('should throw 400 if address is invalid', async () => { + req.body.addresses = ['badAddress'] + + const result = await balancesForAddressBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Invalid BCH address.') + }) + + it('should throw 400 if address network mismatch', async () => { + req.body.addresses = [ + 'slptest:qzcvpw3ah7r880d49wsqzrsl90pg0rqjjurmj3g4nk' + ] + + const result = await balancesForAddressBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Invalid') + }) + + it('should throw 5XX error when network issues', async () => { + // Save the existing SLPDB_URL. + const savedUrl2 = process.env.SLPDB_URL + + // Manipulate the URL to cause a 500 network error. + process.env.SLPDB_URL = 'http://fakeurl/api/' + + req.body.addresses = [ + 'simpleledger:qpujxqra3jmdlzzapwmmt7uspr7q0c9ff5me5fdrdn' + ] + + const result = await balancesForAddressBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.SLPDB_URL = savedUrl2 + + assert.isAbove( + res.statusCode, + 499, + 'HTTP status code 500 or greater expected.' + ) + assert.include( + result.error, + 'Network error: Could not communicate', + 'Error message expected' + ) + }) + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(slpRoute.axios, 'request').throws({ code: 'ECONNABORTED' }) + + req.body.addresses = [ + 'simpleledger:qpujxqra3jmdlzzapwmmt7uspr7q0c9ff5me5fdrdn' + ] + const result = await balancesForAddressBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(slpRoute.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + req.body.addresses = [ + 'simpleledger:qpujxqra3jmdlzzapwmmt7uspr7q0c9ff5me5fdrdn' + ] + const result = await balancesForAddressBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + // Only run as an integration test. Too complex to stub accurately. + if (process.env.TEST !== 'unit') { + it('should get token balance for an address', async () => { + req.body.addresses = [ + 'simpleledger:qpujxqra3jmdlzzapwmmt7uspr7q0c9ff5me5fdrdn' + ] + + const result = await balancesForAddressBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isArray(result) + assert.isArray(result[0]) + assert.hasAnyKeys(result[0][0], [ + 'tokenId', + 'balance', + 'balanceString', + 'slpAddress', + 'decimalCount' + ]) + }) + } + }) + + describe('#validate2Single', () => { + it('should throw 400 if txid is empty', async () => { + req.params.txid = '' + const result = await slpRoute.validate2Single(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'txid can not be empty') + }) + + it('should invalidate a known invalid TXID', async () => { + if (process.env.TEST === 'unit') { + // Mock to prevent live network connection. + sandbox + .stub(slpRoute.axios, 'request') + .resolves({ data: { isValid: false } }) + } + + const txid = + 'f7e5199ef6669ad4d078093b3ad56e355b6ab84567e59ad0f08a5ad0244f783a' + + req.params.txid = txid + const result = await slpRoute.validate2Single(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.equal(result.txid, txid) + assert.equal(result.isValid, false) + }) + + it('should validate a known valid TXID', async () => { + if (process.env.TEST === 'unit') { + // Mock to prevent live network connection. + sandbox + .stub(slpRoute.axios, 'request') + .resolves({ data: { isValid: true } }) + } + + const txid = + '3a4b628cbcc183ab376d44ce5252325f042268307ffa4a53443e92b6d24fb488' + + req.params.txid = txid + const result = await slpRoute.validate2Single(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.equal(result.txid, txid) + assert.equal(result.isValid, true) + }) + + // This test can only be run as a mocked unit test. It's too inconsistent + // to run as an integration test, due to the caching built into slp-validate. + if (process.env.TEST === 'unit') { + it('should cancel if validation takes too long', async () => { + // Mock the timeout error. + sandbox.stub(slpRoute.axios, 'request').throws({ + code: 'ECONNABORTED' + }) + + const txid = + 'eacb1085dfa296fef6d4ae2c0f4529a1bef096dd2325bdcc6dcb5241b3bdb579' + + req.params.txid = txid + const result = await slpRoute.validate2Single(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + } + }) + + describe('#validateSingle', () => { + it('should throw 400 if txid is empty', async () => { + req.params.txid = '' + const result = await slpRoute.validateSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'txid can not be empty') + }) + + it('should invalidate a known invalid TXID', async () => { + if (process.env.TEST === 'unit') { + // Mock to prevent live network connection. + sandbox.stub(slpRoute.axios, 'request').resolves({ + data: { + c: [], + u: [] + } + }) + } + + const txid = + 'f7e5199ef6669ad4d078093b3ad56e355b6ab84567e59ad0f08a5ad0244f783a' + + req.params.txid = txid + const result = await slpRoute.validateSingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.equal(result.txid, txid) + assert.equal(result.valid, null) + }) + + it('should validate a known valid TXID', async () => { + if (process.env.TEST === 'unit') { + // Mock to prevent live network connection. + sandbox + .stub(slpRoute.axios, 'request') + .resolves({ data: mockData.mockSingleValidTxid }) + } + + const txid = + '77872738b6bddee6c0cbdb9509603de20b15d4f6b26602f629417aec2f5d5e8d' + + req.params.txid = txid + const result = await slpRoute.validateSingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.equal(result.txid, txid) + assert.equal(result.valid, true) + }) + + if (process.env.TEST === 'unit') { + it('should cancel if validation takes too long', async () => { + // Mock the timeout error. + sandbox.stub(slpRoute.axios, 'request').throws({ + code: 'ECONNABORTED' + }) + + const txid = + 'eacb1085dfa296fef6d4ae2c0f4529a1bef096dd2325bdcc6dcb5241b3bdb579' + + req.params.txid = txid + const result = await slpRoute.validateSingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + } + }) + + describe('#validate3Single', () => { + it('should throw 400 if txid is empty', async () => { + req.params.txid = '' + const result = await slpRoute.validate3Single(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'txid can not be empty') + }) + + it('should invalidate a known invalid TXID', async () => { + if (process.env.TEST === 'unit') { + // Mock to prevent live network connection. + sandbox.stub(slpRoute.axios, 'request').resolves({ + data: { + c: [], + u: [] + } + }) + } + + const txid = + 'f7e5199ef6669ad4d078093b3ad56e355b6ab84567e59ad0f08a5ad0244f783a' + + req.params.txid = txid + const result = await slpRoute.validate3Single(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.equal(result.txid, txid) + assert.equal(result.valid, null) + }) + + it('should validate a known valid TXID', async () => { + if (process.env.TEST === 'unit') { + // Mock to prevent live network connection. + sandbox + .stub(slpRoute.axios, 'request') + .resolves({ data: mockData.mockSingleValidTxid }) + } + + const txid = + 'cdfed769ef7b69e09be06d2821b88598d9a5d711b8f9bd369763c78b7a578fbc' + + req.params.txid = txid + const result = await slpRoute.validate3Single(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + // assert.equal(result.txid, txid) + assert.equal(result.valid, true) + }) + + if (process.env.TEST === 'unit') { + it('should cancel if validation takes too long', async () => { + // Mock the timeout error. + sandbox.stub(slpRoute.axios, 'request').throws({ + code: 'ECONNABORTED' + }) + + const txid = + 'eacb1085dfa296fef6d4ae2c0f4529a1bef096dd2325bdcc6dcb5241b3bdb579' + + req.params.txid = txid + const result = await slpRoute.validate3Single(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + } + }) + + describe('#validateBulk()', () => { + const validateBulk = slpRoute.validateBulk + + it('should throw 400 if txid array is empty', async () => { + const result = await validateBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'txids needs to be an array') + assert.equal(res.statusCode, 400) + }) + + it('should throw 400 error if array is too large', async () => { + const testArray = [] + for (var i = 0; i < 25; i++) testArray.push('') + + req.body.txids = testArray + + const result = await validateBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Array too large') + }) + + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(slpRoute.axios, 'request').throws({ code: 'ECONNABORTED' }) + + req.body.txids = [ + '77872738b6bddee6c0cbdb9509603de20b15d4f6b26602f629417aec2f5d5e8d' + ] + const result = await validateBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(slpRoute.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + req.body.txids = [ + '77872738b6bddee6c0cbdb9509603de20b15d4f6b26602f629417aec2f5d5e8d' + ] + const result = await validateBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should validate array with single element', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox.stub(slpRoute.axios, 'request').resolves({ + data: mockData.mockSingleValidTxid + }) + } + + req.body.txids = [ + '77872738b6bddee6c0cbdb9509603de20b15d4f6b26602f629417aec2f5d5e8d' + ] + + const result = await validateBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.hasAllKeys(result[0], ['txid', 'valid']) + }) + + it('should validate array with two elements', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox.stub(slpRoute.axios, 'request').resolves({ + data: mockData.mockTwoValidTxid + }) + } + + req.body.txids = [ + '77872738b6bddee6c0cbdb9509603de20b15d4f6b26602f629417aec2f5d5e8d', + '552112f9e458dc7d1d8b328b0a6685e8af74a64b60b6846e7c86407f27f47e42' + ] + + const result = await validateBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.hasAllKeys(result[0], ['txid', 'valid']) + assert.equal(result.length, 2) + }) + + // Captures a regression bug that went out to production, captured in this + // GitHub Issue: https://github.com/Bitcoin-com/rest.bitcoin.com/issues/518 + it('should return two elements if given two elements', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox.stub(slpRoute.axios, 'request').resolves({ + data: mockData.mockTwoRedundentTxid + }) + } + + req.body.txids = [ + 'd56a2b446d8149c39ca7e06163fe8097168c3604915f631bc58777d669135a56', + 'd56a2b446d8149c39ca7e06163fe8097168c3604915f631bc58777d669135a56' + ] + + const result = await validateBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.hasAllKeys(result[0], ['txid', 'valid']) + assert.equal(result.length, 2) + }) + + it('should handle a mix of valid, invalid, and non-SLP txs', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox.stub(slpRoute.axios, 'request').resolves({ + data: mockData.mockValidateBulk + }) + } + + const txids = [ + // Malformed SLP tx + 'f7e5199ef6669ad4d078093b3ad56e355b6ab84567e59ad0f08a5ad0244f783a', + // Normal TX (non-SLP) + '01cdaec2f8b311fc2d6ecc930247bd45fa696dc204ab684596e281fe1b06c1f0', + // Valid PSF SLP tx + 'daf4d8b8045e7a90b7af81bfe2370178f687da0e545511bce1c9ae539eba5ffd', + // Valid SLP token not in whitelist + '3a4b628cbcc183ab376d44ce5252325f042268307ffa4a53443e92b6d24fb488', + // Token send on BCHN network. + '402c663379d9699b6e2dd38737061e5888c5a49fca77c97ab98e79e08959e019', + // Token send on ABC network. + '336bfe2168aac4c3303508a9e8548a0d33797a83b85b76a12d845c8d6674f79d', + // Known invalid SLP token send of PSF tokens. + '2bf691ad3679d928fef880b8a45b93b233f8fa0d0a92cf792313dbe77b1deb74' + ] + + req.body.txids = txids + + const result = await validateBulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + // BCHN expected results + if (process.env.ISBCHN) { + assert.equal(result[0].txid, txids[0]) + assert.equal(result[0].valid, null) + + assert.equal(result[1].txid, txids[1]) + assert.equal(result[1].valid, null) + + assert.equal(result[2].txid, txids[2]) + assert.equal(result[2].valid, true) + + assert.equal(result[3].txid, txids[3]) + assert.equal(result[3].valid, true) + + // Note: This should change from null to true once SLPDB finishes indexing. + assert.equal(result[4].txid, txids[4]) + assert.equal(result[4].valid, true) + + assert.equal(result[5].txid, txids[5]) + assert.equal(result[5].valid, null) + + assert.equal(result[6].txid, txids[6]) + assert.equal(result[6].valid, false) + assert.include( + result[6].invalidReason, + 'Token outputs are greater than valid token inputs' + ) + } else { + assert.equal(result[0].txid, txids[0]) + assert.equal(result[0].valid, null) + + assert.equal(result[1].txid, txids[1]) + assert.equal(result[1].valid, null) + + assert.equal(result[2].txid, txids[2]) + assert.equal(result[2].valid, true) + + assert.equal(result[3].txid, txids[3]) + assert.equal(result[3].valid, true) + + assert.equal(result[4].txid, txids[4]) + assert.equal(result[4].valid, null) + + assert.equal(result[5].txid, txids[5]) + assert.equal(result[5].valid, true) + + assert.equal(result[6].txid, txids[6]) + assert.equal(result[6].valid, false) + assert.include( + result[6].invalidReason, + 'Token outputs are greater than valid token inputs' + ) + } + }) + }) + + describe('#validate3Bulk', () => { + const validate3Bulk = slpRoute.validate3Bulk + + it('should throw 400 if txid array is empty', async () => { + const result = await validate3Bulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'txids needs to be an array') + assert.equal(res.statusCode, 400) + }) + + it('should throw 400 error if array is too large', async () => { + const testArray = [] + for (var i = 0; i < 25; i++) testArray.push('') + + req.body.txids = testArray + + const result = await validate3Bulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Array too large') + }) + + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(slpRoute.axios, 'request').throws({ code: 'ECONNABORTED' }) + + req.body.txids = [ + '77872738b6bddee6c0cbdb9509603de20b15d4f6b26602f629417aec2f5d5e8d' + ] + const result = await validate3Bulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(slpRoute.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + req.body.txids = [ + '77872738b6bddee6c0cbdb9509603de20b15d4f6b26602f629417aec2f5d5e8d' + ] + const result = await validate3Bulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should validate array with single element', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox.stub(slpRoute.axios, 'request').resolves({ + data: mockData.mockPsfToken + }) + } + + req.body.txids = [ + 'cdfed769ef7b69e09be06d2821b88598d9a5d711b8f9bd369763c78b7a578fbc' + ] + + const result = await validate3Bulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.hasAllKeys(result[0], ['txid', 'valid']) + }) + + it('should validate array with two elements', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox.stub(slpRoute.axios, 'request').resolves({ + data: mockData.mockPsfToken + }) + } + + req.body.txids = [ + 'cdfed769ef7b69e09be06d2821b88598d9a5d711b8f9bd369763c78b7a578fbc', + 'cdfed769ef7b69e09be06d2821b88598d9a5d711b8f9bd369763c78b7a578fbc' + ] + + const result = await validate3Bulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.hasAllKeys(result[0], ['txid', 'valid']) + assert.equal(result.length, 2) + }) + + // Captures a regression bug that went out to production, captured in this + // GitHub Issue: https://github.com/Bitcoin-com/rest.bitcoin.com/issues/518 + it('should return two elements if given two elements', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox.stub(slpRoute.axios, 'request').resolves({ + data: mockData.mockPsfToken + }) + } + + req.body.txids = [ + 'cdfed769ef7b69e09be06d2821b88598d9a5d711b8f9bd369763c78b7a578fbc', + 'cdfed769ef7b69e09be06d2821b88598d9a5d711b8f9bd369763c78b7a578fbc' + ] + + const result = await validate3Bulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.hasAllKeys(result[0], ['txid', 'valid']) + assert.equal(result.length, 2) + }) + + if (process.env.TEST === 'unit') { + // This is a unit-test only test, as the results change depending on if + // its tested against the BCHN or ABC networks. There are integration tests + // for this test case in the ../integration/slp.js file. + it('should handle a mix of valid, invalid, and non-SLP txs', async () => { + // Mock the RPC call for unit tests. + + sandbox.stub(slpRoute.axios, 'request').resolves({ + data: mockData.mockValidate3Bulk + }) + + const txids = [ + // Malformed SLP tx + 'f7e5199ef6669ad4d078093b3ad56e355b6ab84567e59ad0f08a5ad0244f783a', + // Normal TX (non-SLP) + '01cdaec2f8b311fc2d6ecc930247bd45fa696dc204ab684596e281fe1b06c1f0', + // Valid PSF SLP tx + 'daf4d8b8045e7a90b7af81bfe2370178f687da0e545511bce1c9ae539eba5ffd', + // Valid SLP token not in whitelist + '3a4b628cbcc183ab376d44ce5252325f042268307ffa4a53443e92b6d24fb488', + // Token send on BCHN network. + '402c663379d9699b6e2dd38737061e5888c5a49fca77c97ab98e79e08959e019', + // Token send on ABC network. + '336bfe2168aac4c3303508a9e8548a0d33797a83b85b76a12d845c8d6674f79d', + // Known invalid SLP token send of PSF tokens. + '2bf691ad3679d928fef880b8a45b93b233f8fa0d0a92cf792313dbe77b1deb74' + ] + + req.body.txids = txids + const result = await validate3Bulk(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.equal(result[0].txid, txids[0]) + assert.equal(result[0].valid, null) + + assert.equal(result[1].txid, txids[1]) + assert.equal(result[1].valid, null) + + assert.equal(result[2].txid, txids[2]) + assert.equal(result[2].valid, true) + + assert.equal(result[3].txid, txids[3]) + assert.equal(result[3].valid, null) + + assert.equal(result[4].txid, txids[4]) + assert.equal(result[4].valid, null) + + assert.equal(result[5].txid, txids[5]) + assert.equal(result[5].valid, null) + + assert.equal(result[6].txid, txids[6]) + assert.equal(result[6].valid, false) + assert.include( + result[6].invalidReason, + 'Token outputs are greater than valid token inputs' + ) + }) + } + }) + + describe('tokenStats()', () => { + it('should throw 400 if tokenID is empty', async () => { + req.params.tokenId = '' + const result = await slpRoute.tokenStats(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'tokenId can not be empty') + }) + + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox + .stub(slpRoute.slpdb, 'getTokenStats') + .throws({ code: 'ECONNABORTED' }) + + req.params.tokenId = + '497291b8a1dfe69c8daea50677a3d31a5ef0e9484d8bebb610dac64bbc202fb7' + + const result = await slpRoute.tokenStats(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox + .stub(slpRoute.slpdb, 'getTokenStats') + .throws({ code: 'ECONNREFUSED' }) + + req.params.tokenId = + '497291b8a1dfe69c8daea50677a3d31a5ef0e9484d8bebb610dac64bbc202fb7' + + const result = await slpRoute.tokenStats(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + }) + + describe('balancesForTokenSingle()', () => { + const balancesForTokenSingle = slpRoute.balancesForTokenSingle + + it('should throw 400 if tokenID is empty', async () => { + req.params.tokenId = '' + const result = await balancesForTokenSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'tokenId can not be empty') + }) + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(slpRoute.axios, 'request').throws({ code: 'ECONNABORTED' }) + + req.params.tokenId = + '497291b8a1dfe69c8daea50677a3d31a5ef0e9484d8bebb610dac64bbc202fb7' + + const result = await balancesForTokenSingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(slpRoute.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + req.params.tokenId = + '497291b8a1dfe69c8daea50677a3d31a5ef0e9484d8bebb610dac64bbc202fb7' + + const result = await balancesForTokenSingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + it('should get balances for tokenId', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox.stub(slpRoute.axios, 'request').resolves({ + data: { + g: [mockData.mockBalance] + } + }) + } + + req.params.tokenId = + '497291b8a1dfe69c8daea50677a3d31a5ef0e9484d8bebb610dac64bbc202fb7' + + const result = await balancesForTokenSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.property(result[0], 'tokenId') + assert.property(result[0], 'slpAddress') + assert.property(result[0], 'tokenBalanceString') + }) + }) + + describe('txDetails()', () => { + const txDetails = slpRoute.txDetails + + it('should throw 400 if txid is empty', async () => { + const result = await txDetails(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'txid can not be empty') + }) + + it('should throw 400 for malformed txid', async () => { + req.params.txid = + '57b3082a2bf269b3d6f40fee7fb9c664e8256a88ca5ee2697c05b9457' + + const result = await txDetails(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'This is not a txid') + }) + + it('should throw 400 for non-existant txid', async () => { + // Integration test + if (process.env.TEST !== 'unit') { + req.params.txid = + '57b3082a2bf269b3d6f40fee7fb9c664e8256a88ca5ee2697c05b94578223333' + + const result = await txDetails(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'TXID not found') + } + }) + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(slpRoute.axios, 'request').throws({ code: 'ECONNABORTED' }) + + req.params.txid = + '57b3082a2bf269b3d6f40fee7fb9c664e8256a88ca5ee2697c05b94578223333' + + const result = await txDetails(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(slpRoute.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + req.params.txid = + '57b3082a2bf269b3d6f40fee7fb9c664e8256a88ca5ee2697c05b94578223333' + + const result = await txDetails(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + if (process.env.TEST === 'integration') { + it('should get tx details with token info', async () => { + // TODO: add mocking for unit testing. How do I mock reponse form SLPDB + // since it's not an object? + + // if (process.env.TEST === "unit") { + // // Mock the slpjs library for unit tests. + // pathStub.BitboxNetwork = slpjsMock.BitboxNetwork + // txDetails = slpRouteStub.testableComponents.txDetails + // } + + req.params.txid = + '497291b8a1dfe69c8daea50677a3d31a5ef0e9484d8bebb610dac64bbc202fb7' + + const result = await txDetails(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.hasAnyKeys(result, ['tokenIsValid', 'tokenInfo']) + }) + } + }) + + describe('txsTokenIdAddressSingle()', () => { + const txsTokenIdAddressSingle = slpRoute.txsTokenIdAddressSingle + + it('should throw 400 if tokenId is empty', async () => { + req.params.tokenId = '' + const result = await txsTokenIdAddressSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'tokenId can not be empty') + }) + + it('should throw 400 if address is empty', async () => { + req.params.tokenId = + '495322b37d6b2eae81f045eda612b95870a0c2b6069c58f70cf8ef4e6a9fd43a' + req.params.address = '' + const result = await txsTokenIdAddressSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'address can not be empty') + }) + + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(slpRoute.axios, 'request').throws({ code: 'ECONNABORTED' }) + + req.params.tokenId = + '7ac7f4bb50b019fe0f5c81e3fc13fc0720e130282ea460768cafb49785eb2796' + req.params.address = 'slptest:qpwa35xq0q0cnmdu0rwzkct369hddzsqpsqdzw6h9h' + + const result = await txsTokenIdAddressSingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(slpRoute.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + req.params.tokenId = + '7ac7f4bb50b019fe0f5c81e3fc13fc0720e130282ea460768cafb49785eb2796' + req.params.address = 'slptest:qpwa35xq0q0cnmdu0rwzkct369hddzsqpsqdzw6h9h' + + const result = await txsTokenIdAddressSingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + }) + + describe('txsByAddressSingle()', () => { + const txsByAddressSingle = slpRoute.txsByAddressSingle + + it('should throw 400 if address is missing', async () => { + const result = await txsByAddressSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'address can not be empty') + }) + + it('should throw 400 if address is empty', async () => { + req.params.address = '' + const result = await txsByAddressSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'address can not be empty') + }) + + it('should throw 400 if address is invalid', async () => { + req.params.address = 'badAddress' + + const result = await txsByAddressSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Invalid BCH address.') + }) + + it('should throw 400 if address network mismatch', async () => { + req.params.address = 'slptest:qr83cu3p7yg9yac7qthwm0nul2ev2kukvsqmes3vl0' + + const result = await txsByAddressSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Invalid') + }) + + it('should get tx history', async () => { + if (process.env.TEST === 'unit') { + sandbox + .stub(slpRoute.slpdb, 'getHistoricalSlpTransactions') + .resolves(mockData.mockTxHistory) + } + + // req.params.address = 'simpleledger:qr5agtachyxvrwxu76vzszan5pnvuzy8duhv4lxrsk' + req.params.address = + 'simpleledger:qz4guf2k3p4r3t4tph0wwgyfq4p628lr2c0cvqplza' + + const result = await slpRoute.txsByAddressSingle(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isArray(result) + }) + }) + + describe('#generateSendOpReturn()', () => { + const generateSendOpReturn = slpRoute.generateSendOpReturn + // Validate tokenUtxos input + it('should throw 400 if tokenUtxos is missing', async () => { + const result = await generateSendOpReturn(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'tokenUtxos needs to be an array.') + }) + + it('should throw 400 if tokenUtxos is empty', async () => { + req.body.tokenUtxos = '' + const result = await generateSendOpReturn(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'tokenUtxos needs to be an array.') + }) + + it('should throw 400 if tokenUtxos is not array', async () => { + req.body.tokenUtxos = 'tokenUtxos' + + const result = await generateSendOpReturn(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'tokenUtxos needs to be an array.') + }) + + it('should throw 400 if tokenUtxos is empty array', async () => { + req.body.tokenUtxos = [] + + const result = await generateSendOpReturn(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'tokenUtxos array can not be empty.') + }) + + // Validate sendQty input + it('should throw 400 if sendQty is missing', async () => { + req.body.tokenUtxos = [{}, {}, {}] + const result = await generateSendOpReturn(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'sendQty must be a number') + }) + + it('should throw 400 if sendQty is empty', async () => { + req.body.tokenUtxos = [{}, {}, {}] + req.body.sendQty = '' + + const result = await generateSendOpReturn(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'sendQty must be a number') + }) + + it('should throw 400 if sendQty is not a number', async () => { + req.body.tokenUtxos = [{}, {}, {}] + req.body.sendQty = 'sendQty' + + const result = await generateSendOpReturn(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'sendQty must be a number') + }) + + it('should return OP_RETURN script', async () => { + req.body.tokenUtxos = [ + { + tokenId: + '38e97c5d7d3585a2cbf3f9580c82ca33985f9cb0845d4dcce220cb709f9538b0', + decimals: 8, + tokenQty: 2 + } + ] + req.body.sendQty = 1.5 + + if (process.env.TEST === 'unit') { + sandbox + .stub(slpRoute.bchjs.SLP.TokenType1, 'generateSendOpReturn') + .resolves({ script: ' { + it('should throw error if input is not an array.', async () => { + req.body.utxos = 'test' + + const result = await slpRoute.hydrateUtxos(req, res) + // console.log(`result: `, result) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Input must be an array') + }) + + it('should throw error if Array is empty', async () => { + req.body.utxos = [] + + const result = await slpRoute.hydrateUtxos(req, res) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Array should not be empty') + }) + + it('should throw error if Array is too long', async () => { + const utxo = { + txid: + 'bd158c564dd4ef54305b14f44f8e94c44b649f246dab14bcb42fb0d0078b8a90', + vout: 3, + amount: 0.00002015, + satoshis: 2015, + height: 594892, + confirmations: 5 + } + + const utxos = [] + + // Populate array with 21 utxos + for (let i = 0; i < 21; i++) { + utxos.push(utxo) + } + + req.body.utxos = utxos + + const result = await slpRoute.hydrateUtxos(req, res) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Array too long, max length is 20') + }) + + it('should return utxo details', async () => { + const utxos = [ + { + utxos: [ + { + txid: + 'd56a2b446d8149c39ca7e06163fe8097168c3604915f631bc58777d669135a56', + vout: 3, + value: '6816', + height: 606848, + confirmations: 13, + satoshis: 6816 + }, + { + txid: + 'd56a2b446d8149c39ca7e06163fe8097168c3604915f631bc58777d669135a56', + vout: 2, + value: '546', + height: 606848, + confirmations: 13, + satoshis: 546 + } + ] + } + ] + + // Mock the external network call. + sandbox.stub(slpRoute.bchjs.SLP.Utils, 'tokenUtxoDetails').resolves([ + { + txid: + 'd56a2b446d8149c39ca7e06163fe8097168c3604915f631bc58777d669135a56', + vout: 3, + value: '6816', + height: 606848, + confirmations: 13, + satoshis: 6816, + isValid: false + }, + { + txid: + 'd56a2b446d8149c39ca7e06163fe8097168c3604915f631bc58777d669135a56', + vout: 2, + value: '546', + height: 606848, + confirmations: 13, + satoshis: 546, + utxoType: 'token', + transactionType: 'send', + tokenId: + 'dd84ca78db4d617221b58eabc6667af8fe2f7eadbfcc213d35be9f1b419beb8d', + tokenTicker: 'TAP', + tokenName: 'Thoughts and Prayers', + tokenDocumentUrl: '', + tokenDocumentHash: '', + decimals: 0, + tokenType: 1, + tokenQty: 5, + isValid: true + } + ]) + + req.body.utxos = utxos + const result = await slpRoute.hydrateUtxos(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + // Test the general structure of the output. + assert.isArray(result.slpUtxos) + assert.equal(result.slpUtxos.length, 1) + assert.equal(result.slpUtxos[0].utxos.length, 2) + + // Test the non-slp UTXO. + assert.property(result.slpUtxos[0].utxos[0], 'txid') + assert.property(result.slpUtxos[0].utxos[0], 'vout') + assert.property(result.slpUtxos[0].utxos[0], 'value') + assert.property(result.slpUtxos[0].utxos[0], 'height') + assert.property(result.slpUtxos[0].utxos[0], 'confirmations') + assert.property(result.slpUtxos[0].utxos[0], 'satoshis') + assert.property(result.slpUtxos[0].utxos[0], 'isValid') + assert.equal(result.slpUtxos[0].utxos[0].isValid, false) + + // Test the slp UTXO. + assert.property(result.slpUtxos[0].utxos[1], 'txid') + assert.property(result.slpUtxos[0].utxos[1], 'vout') + assert.property(result.slpUtxos[0].utxos[1], 'value') + assert.property(result.slpUtxos[0].utxos[1], 'height') + assert.property(result.slpUtxos[0].utxos[1], 'confirmations') + assert.property(result.slpUtxos[0].utxos[1], 'satoshis') + assert.property(result.slpUtxos[0].utxos[1], 'isValid') + assert.equal(result.slpUtxos[0].utxos[1].isValid, true) + assert.property(result.slpUtxos[0].utxos[1], 'transactionType') + assert.property(result.slpUtxos[0].utxos[1], 'tokenId') + assert.property(result.slpUtxos[0].utxos[1], 'tokenTicker') + assert.property(result.slpUtxos[0].utxos[1], 'tokenName') + assert.property(result.slpUtxos[0].utxos[1], 'tokenDocumentUrl') + assert.property(result.slpUtxos[0].utxos[1], 'tokenDocumentHash') + assert.property(result.slpUtxos[0].utxos[1], 'decimals') + assert.property(result.slpUtxos[0].utxos[1], 'tokenType') + assert.property(result.slpUtxos[0].utxos[1], 'tokenQty') + }) + + it('should throw error for missing properties', async () => { + const utxos = [ + { + height: 639443, + tx_hash: + '30707fffb9b295a06a68d217f49c198e9e1dbe1edc3874a0928ca1905f1709df', + tx_pos: 0, + value: 6000 + }, + { + height: 639443, + tx_hash: + '8962566e413501224d178a02effc89be5ac0d8e4195f617415d443dc4c38fe50', + tx_pos: 1, + value: 546 + } + ] + + req.body.utxos = utxos + const result = await slpRoute.hydrateUtxos(req, res) + + assert.hasAllKeys(result, ['error']) + assert.include( + result.error, + 'Each element in array should have a utxos property' + ) + }) + }) + + describe('#hydrateUtxosWL', () => { + it('should throw error if input is not an array.', async () => { + req.body.utxos = 'test' + + const result = await slpRoute.hydrateUtxosWL(req, res) + // console.log(`result: `, result) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Input must be an array') + }) + + it('should throw error if Array is empty', async () => { + req.body.utxos = [] + + const result = await slpRoute.hydrateUtxosWL(req, res) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Array should not be empty') + }) + + it('should throw error if Array is too long', async () => { + const utxo = { + txid: + 'bd158c564dd4ef54305b14f44f8e94c44b649f246dab14bcb42fb0d0078b8a90', + vout: 3, + amount: 0.00002015, + satoshis: 2015, + height: 594892, + confirmations: 5 + } + + const utxos = [] + + // Populate array with 21 utxos + for (let i = 0; i < 21; i++) { + utxos.push(utxo) + } + + req.body.utxos = utxos + + const result = await slpRoute.hydrateUtxosWL(req, res) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Array too long, max length is 20') + }) + + it('should return utxo details', async () => { + const utxos = [ + { + utxos: [ + { + txid: + 'd56a2b446d8149c39ca7e06163fe8097168c3604915f631bc58777d669135a56', + vout: 3, + value: '6816', + height: 606848, + confirmations: 13, + satoshis: 6816 + }, + { + txid: + 'd56a2b446d8149c39ca7e06163fe8097168c3604915f631bc58777d669135a56', + vout: 2, + value: '546', + height: 606848, + confirmations: 13, + satoshis: 546 + } + ] + } + ] + + // Mock the external network call. + sandbox.stub(slpRoute.bchjs.SLP.Utils, 'tokenUtxoDetailsWL').resolves([ + { + txid: + 'd56a2b446d8149c39ca7e06163fe8097168c3604915f631bc58777d669135a56', + vout: 3, + value: '6816', + height: 606848, + confirmations: 13, + satoshis: 6816, + isValid: false + }, + { + txid: + 'd56a2b446d8149c39ca7e06163fe8097168c3604915f631bc58777d669135a56', + vout: 2, + value: '546', + height: 606848, + confirmations: 13, + satoshis: 546, + utxoType: 'token', + transactionType: 'send', + tokenId: + 'dd84ca78db4d617221b58eabc6667af8fe2f7eadbfcc213d35be9f1b419beb8d', + tokenTicker: 'TAP', + tokenName: 'Thoughts and Prayers', + tokenDocumentUrl: '', + tokenDocumentHash: '', + decimals: 0, + tokenType: 1, + tokenQty: 5, + isValid: true + } + ]) + + req.body.utxos = utxos + const result = await slpRoute.hydrateUtxosWL(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + // Test the general structure of the output. + assert.isArray(result.slpUtxos) + assert.equal(result.slpUtxos.length, 1) + assert.equal(result.slpUtxos[0].utxos.length, 2) + + // Test the non-slp UTXO. + assert.property(result.slpUtxos[0].utxos[0], 'txid') + assert.property(result.slpUtxos[0].utxos[0], 'vout') + assert.property(result.slpUtxos[0].utxos[0], 'value') + assert.property(result.slpUtxos[0].utxos[0], 'height') + assert.property(result.slpUtxos[0].utxos[0], 'confirmations') + assert.property(result.slpUtxos[0].utxos[0], 'satoshis') + assert.property(result.slpUtxos[0].utxos[0], 'isValid') + assert.equal(result.slpUtxos[0].utxos[0].isValid, false) + + // Test the slp UTXO. + assert.property(result.slpUtxos[0].utxos[1], 'txid') + assert.property(result.slpUtxos[0].utxos[1], 'vout') + assert.property(result.slpUtxos[0].utxos[1], 'value') + assert.property(result.slpUtxos[0].utxos[1], 'height') + assert.property(result.slpUtxos[0].utxos[1], 'confirmations') + assert.property(result.slpUtxos[0].utxos[1], 'satoshis') + assert.property(result.slpUtxos[0].utxos[1], 'isValid') + assert.equal(result.slpUtxos[0].utxos[1].isValid, true) + assert.property(result.slpUtxos[0].utxos[1], 'transactionType') + assert.property(result.slpUtxos[0].utxos[1], 'tokenId') + assert.property(result.slpUtxos[0].utxos[1], 'tokenTicker') + assert.property(result.slpUtxos[0].utxos[1], 'tokenName') + assert.property(result.slpUtxos[0].utxos[1], 'tokenDocumentUrl') + assert.property(result.slpUtxos[0].utxos[1], 'tokenDocumentHash') + assert.property(result.slpUtxos[0].utxos[1], 'decimals') + assert.property(result.slpUtxos[0].utxos[1], 'tokenType') + assert.property(result.slpUtxos[0].utxos[1], 'tokenQty') + }) + + it('should throw error for missing properties', async () => { + const utxos = [ + { + height: 639443, + tx_hash: + '30707fffb9b295a06a68d217f49c198e9e1dbe1edc3874a0928ca1905f1709df', + tx_pos: 0, + value: 6000 + }, + { + height: 639443, + tx_hash: + '8962566e413501224d178a02effc89be5ac0d8e4195f617415d443dc4c38fe50', + tx_pos: 1, + value: 546 + } + ] + + req.body.utxos = utxos + const result = await slpRoute.hydrateUtxosWL(req, res) + + assert.hasAllKeys(result, ['error']) + assert.include( + result.error, + 'Each element in array should have a utxos property' + ) + }) + }) + + describe('#getStatus', () => { + it('should get the SLPDB status', async () => { + if (process.env.TEST === 'unit') { + // Mock to prevent live network connection. + sandbox + .stub(slpRoute.axios, 'request') + .resolves({ data: { s: [mockData.mockStatus] } }) + } + + const result = await slpRoute.getStatus(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.property(result, 'bchBlockHeight') + }) + + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(slpRoute.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + slpRoute.getStatus(req, res) + // const result = slpRoute.getStatus(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + }) + }) + + describe('#getNftChildren', () => { + it('should throw 400 if tokenID is empty', async () => { + req.params.tokenId = '' + const result = await slpRoute.getNftChildren(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'tokenId can not be empty') + }) + + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(slpRoute.axios, 'request').throws({ code: 'ECONNABORTED' }) + + req.params.tokenId = + '68cd33ecd909068fbea318ae5ff1d6207cf754e53b191327d6d73b6916424c0a' + + const result = await slpRoute.getNftChildren(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(slpRoute.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + req.params.tokenId = + '68cd33ecd909068fbea318ae5ff1d6207cf754e53b191327d6d73b6916424c0a' + + const result = await slpRoute.getNftChildren(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should return error on non-existing NFT group token', async () => { + if (process.env.TEST === 'unit') { + sandbox.stub(slpRoute, 'lookupToken').resolves({ id: 'not found' }) + } + + req.params.tokenId = + '68cd33ecd909068fbea318ae5ff1d6207cf754e53b191327d6d73b6916424c0b' + + const result = await slpRoute.getNftChildren(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.include( + result.error, + 'NFT group does not exists', + 'Error message expected' + ) + }) + + it('should return error on non-group NFT token', async () => { + if (process.env.TEST === 'unit') { + sandbox + .stub(slpRoute, 'lookupToken') + .resolves(mockData.mockNftChildren[0]) + } + + req.params.tokenId = + '68cd33ecd909068fbea318ae5ff1d6207cf754e53b191327d6d73b6916424c0b' + + const result = await slpRoute.getNftChildren(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + assert.include( + result.error, + 'NFT group does not exists', + 'Error message expected' + ) + }) + + if (process.env.TEST === 'unit') { + it('should return error on invalid NFT group data', async () => { + sandbox.stub(slpRoute, 'lookupToken').resolves(mockData.mockNftGroup) + sandbox.stub(slpRoute.axios, 'request').resolves({ + data: { u: 'invalid' } + }) + + req.params.tokenId = + '68cd33ecd909068fbea318ae5ff1d6207cf754e53b191327d6d73b6916424c0a' + + const result = await slpRoute.getNftChildren(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + assert.include( + result.error, + 'No children data in the group', + 'Error message expected' + ) + }) + } + + if (process.env.ISBCHN) { + it('should get NFT children IDs in given NFT group', async () => { + if (process.env.TEST === 'unit') { + sandbox.stub(slpRoute, 'lookupToken').resolves(mockData.mockNftGroup) + sandbox.stub(slpRoute.axios, 'request').resolves({ + data: { + t: [ + { + tokenDetails: mockData.mockNftChildren[0], + nftParentId: mockData.mockNftGroup.id + }, + { + tokenDetails: mockData.mockNftChildren[1], + nftParentId: mockData.mockNftGroup.id + } + ] + } + }) + } + + req.params.tokenId = + '68cd33ecd909068fbea318ae5ff1d6207cf754e53b191327d6d73b6916424c0a' + + const result = await slpRoute.getNftChildren(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + assert.isArray(result.nftChildren) + assert.equal(result.nftChildren.length, 2) + assert.equal( + result.nftChildren[0], + '45a30085691d6ea586e3ec2aa9122e9b0e0d6c3c1fd357decccc15d8efde48a9' + ) + assert.equal( + result.nftChildren[1], + '928ce61fe1006b1325a0ba0dce700bf83986a6f0691ba26e121c9ac035d12a55' + ) + }) + } + }) + + describe('#getNftGroup', () => { + it('should throw 400 if tokenID is empty', async () => { + req.params.tokenId = '' + const result = await slpRoute.getNftGroup(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'tokenId can not be empty') + }) + + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(slpRoute.axios, 'request').throws({ code: 'ECONNABORTED' }) + + req.params.tokenId = + '45a30085691d6ea586e3ec2aa9122e9b0e0d6c3c1fd357decccc15d8efde48a9' + + const result = await slpRoute.getNftGroup(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('returns proper error when downstream service is down', async () => { + // Mock the timeout error. + sandbox.stub(slpRoute.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + req.params.tokenId = + '45a30085691d6ea586e3ec2aa9122e9b0e0d6c3c1fd357decccc15d8efde48a9' + + const result = await slpRoute.getNftGroup(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isAbove(res.statusCode, 499, 'HTTP status code 503 expected.') + assert.include( + result.error, + 'Could not communicate with full node', + 'Error message expected' + ) + }) + + it('should return error on non-existing NFT child token', async () => { + if (process.env.TEST === 'unit') { + sandbox.stub(slpRoute, 'lookupToken').resolves({ id: 'not found' }) + } + + req.params.tokenId = + '45a30085691d6ea586e3ec2aa9122e9b0e0d6c3c1fd357decccc15d8efde48a8' + + const result = await slpRoute.getNftGroup(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + assert.include( + result.error, + 'NFT child does not exists', + 'Error message expected' + ) + }) + + it('should return error on invalid NFT child token', async () => { + if (process.env.TEST === 'unit') { + sandbox.stub(slpRoute, 'lookupToken').resolves(mockData.mockNftGroup) + } + + req.params.tokenId = + '45a30085691d6ea586e3ec2aa9122e9b0e0d6c3c1fd357decccc15d8efde48a8' + + const result = await slpRoute.getNftGroup(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + assert.include( + result.error, + 'NFT child does not exists', + 'Error message expected' + ) + }) + + if (process.env.TEST === 'unit') { + it('should return error on invalid parent', async () => { + req.params.tokenId = + '45a30085691d6ea586e3ec2aa9122e9b0e0d6c3c1fd357decccc15d8efde48a9' + + const callback = sandbox.stub(slpRoute, 'lookupToken') + callback + .withArgs(req.params.tokenId) + .resolves(mockData.mockNftChildren[0]) + // parent is non-valid NFT group (type != 129) + callback + .withArgs(mockData.mockNftGroup.id) + .resolves(mockData.mockNftChildren[0]) + + const result = await slpRoute.getNftGroup(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + assert.include( + result.error, + 'NFT group does not exists', + 'Error message expected' + ) + }) + } + + if (process.env.ISBCHN) { + it('should get NFT group information for tokenId', async () => { + req.params.tokenId = + '45a30085691d6ea586e3ec2aa9122e9b0e0d6c3c1fd357decccc15d8efde48a9' + + if (process.env.TEST === 'unit') { + const callback = sandbox.stub(slpRoute, 'lookupToken') + callback + .withArgs(req.params.tokenId) + .resolves(mockData.mockNftChildren[0]) + callback + .withArgs(mockData.mockNftGroup.id) + .resolves(mockData.mockNftGroup) + } + const result = await slpRoute.getNftGroup(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + assert.property(result, 'nftGroup') + assert.property(result.nftGroup, 'id') + assert.equal(result.nftGroup.id, mockData.mockNftGroup.id) + assert.property(result.nftGroup, 'versionType') + assert.equal(result.nftGroup.versionType, 129) + assert.property(result.nftGroup, 'symbol') + assert.property(result.nftGroup, 'initialTokenQty') + }) + } + }) +}) + +/* + describe("listSingleToken()", () => { + const listSingleToken = slpRoute.testableComponents.listSingleToken + + it("should throw 400 if tokenId is empty", async () => { + const result = await listSingleToken(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ["error"]) + assert.include(result.error, "tokenId can not be empty") + }) + + it("should throw 503 when network issues", async () => { + // Save the existing BITDB_URL. + const savedUrl2 = process.env.SLPDB_URL + + // Manipulate the URL to cause a 500 network error. + process.env.SLPDB_URL = "http://fakeurl/api/" + + req.params.tokenId = + "650dea14c77f4d749608e36e375450c9ac91deb8b1b53e50cb0de2059a52d19a" + + const result = await listSingleToken(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.SLPDB_URL = savedUrl2 + + 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") + }) + + it("should return 'not found' for testnet txid on mainnet", async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === "unit") { + nock(mockServerUrl) + .get(uri => uri.includes("/")) + .reply(200, mockData.mockSingleToken) + } + + req.params.tokenId = + // testnet + "d284e71227ec89f713b964d8eda595be6392bebd2fac46082bc5a9ce6fb7b33e" + // mainnet + //"259908ae44f46ef585edef4bcc1e50dc06e4c391ac4be929fae27235b8158cf1" + + const result = await listSingleToken(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ["id"]) + assert.include(result.id, "not found") + }) + + it("should get token information", async () => { + // testnet + const tokenIdToTest = + "38e97c5d7d3585a2cbf3f9580c82ca33985f9cb0845d4dcce220cb709f9538b0" + + // console.log(`mockServerUrl: ${mockServerUrl}`) + + // Mock the RPC call for unit tests. + if (process.env.TEST === "unit") { + nock(mockServerUrl) + .get(uri => uri.includes("/")) + .reply(200, mockData.mockSingleToken) + // sandbox.stub(axios, "get").resolves(mockData.mockSingleToken) + } + + req.params.tokenId = tokenIdToTest + + const result = await listSingleToken(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, [ + "id", + "blockCreated", + "blockLastActiveMint", + "blockLastActiveSend", + "circulatingSupply", + "containsBaton", + "mintingBatonStatus", + "txnsSinceGenesis", + "versionType", + "timestamp", + "symbol", + "name", + "documentUri", + "documentHash", + "decimals", + "initialTokenQty", + "totalBurned", + "totalMinted", + "validAddresses", + "timestamp_unix" + ]) + }) + }) + + describe("listBulkToken()", () => { + const listBulkToken = slpRoute.testableComponents.listBulkToken + + it("should throw 400 if tokenIds array is empty", async () => { + const result = await listBulkToken(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ["error"]) + assert.include(result.error, "tokenIds needs to be an array") + assert.equal(res.statusCode, 400) + }) + + it("should throw 400 error if array is too large", async () => { + const testArray = [] + for (var i = 0; i < 25; i++) testArray.push("") + + req.body.tokenIds = testArray + + const result = await listBulkToken(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ["error"]) + assert.include(result.error, "Array too large") + }) + + it("should throw 400 if tokenId is empty", async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === "unit") { + nock(mockServerUrl) + .get(uri => uri.includes("/")) + .reply(200, mockData.mockEmptyTokenId) + } + req.body.tokenIds = "" + + const result = await listBulkToken(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ["error"]) + assert.include( + result.error, + "tokenIds needs to be an array. Use GET for single tokenId." + ) + }) + + it("should throw 503 when network issues", async () => { + // Save the existing BITDB_URL. + const savedUrl2 = process.env.SLPDB_URL + + // Manipulate the URL to cause a 500 network error. + process.env.SLPDB_URL = "http://fakeurl/api/" + + req.body.tokenIds = [ + "650dea14c77f4d749608e36e375450c9ac91deb8b1b53e50cb0de2059a52d19a" + ] + + const result = await listBulkToken(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.SLPDB_URL = savedUrl2 + + 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") + }) + + it("should return 'not found' for testnet txid on mainnet", async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === "unit") { + nock(mockServerUrl) + .get(uri => uri.includes("/")) + .reply(200, mockData.mockSingleTokenError) + } + + req.body.tokenIds = + // testnet + ["d284e71227ec89f713b964d8eda595be6392bebd2fac46082bc5a9ce6fb7b33e"] + // mainnet + // ["0b314bc2b2905b8844222871c6b665ae3494117c83b11302824561bb904efb6b"] + + const result = await listBulkToken(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.hasAllKeys(result[0], ["id", "valid"]) + assert.strictEqual(result[0].valid, false) + }) + + it("should get token information for single token ID", async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === "unit") { + nock(mockServerUrl) + .get(uri => uri.includes("/")) + .reply(200, mockData.mockSingleToken) + } + + req.body.tokenIds = [ + "38e97c5d7d3585a2cbf3f9580c82ca33985f9cb0845d4dcce220cb709f9538b0" + ] + + const result = await listBulkToken(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.hasAllKeys(result[0], [ + "id", + "blockCreated", + "blockLastActiveMint", + "blockLastActiveSend", + "circulatingSupply", + "containsBaton", + "mintingBatonStatus", + "txnsSinceGenesis", + "versionType", + "timestamp", + "symbol", + "name", + "documentUri", + "documentHash", + "decimals", + "initialTokenQty", + "totalBurned", + "totalMinted", + "validAddresses", + "timestamp_unix" + ]) + }) + + it("should get token information for multiple token IDs", async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === "unit") { + nock(mockServerUrl) + .get(uri => uri.includes("/")) + .times(2) + .reply(200, mockData.mockSingleToken) + } + + req.body.tokenIds = [ + "38e97c5d7d3585a2cbf3f9580c82ca33985f9cb0845d4dcce220cb709f9538b0", + "38e97c5d7d3585a2cbf3f9580c82ca33985f9cb0845d4dcce220cb709f9538b0" + ] + + const result = await listBulkToken(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.hasAllKeys(result[0], [ + "blockCreated", + "blockLastActiveMint", + "blockLastActiveSend", + "circulatingSupply", + "containsBaton", + "mintingBatonStatus", + "txnsSinceGenesis", + "versionType", + "timestamp", + "symbol", + "name", + "documentUri", + "documentHash", + "decimals", + "initialTokenQty", + "id", + "totalBurned", + "totalMinted", + "validAddresses", + "timestamp_unix" + ]) + }) + }) + + describe("balancesForAddressByTokenID()", () => { + const balancesForAddressByTokenID = + slpRoute.testableComponents.balancesForAddressByTokenID + + it("should throw 400 if address is empty", async () => { + req.params.address = "" + req.params.tokenId = + "650dea14c77f4d749608e36e375450c9ac91deb8b1b53e50cb0de2059a52d19a" + const result = await balancesForAddressByTokenID(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ["error"]) + assert.include(result.error, "address can not be empty") + }) + + it("should throw 400 if tokenId is empty", async () => { + req.params.address = + "simpleledger:qr5agtachyxvrwxu76vzszan5pnvuzy8duhv4lxrsk" + req.params.tokenId = "" + const result = await balancesForAddressByTokenID(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ["error"]) + assert.include(result.error, "tokenId can not be empty") + }) + + it("should throw 400 if address is invalid", async () => { + req.params.address = "badAddress" + req.params.tokenId = + "650dea14c77f4d749608e36e375450c9ac91deb8b1b53e50cb0de2059a52d19a" + + const result = await balancesForAddressByTokenID(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ["error"]) + assert.include(result.error, "Invalid BCH address.") + }) + + it("should throw 400 if address network mismatch", async () => { + req.params.address = "slptest:qzcvpw3ah7r880d49wsqzrsl90pg0rqjjurmj3g4nk" + + const result = await balancesForAddressByTokenID(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ["error"]) + assert.include(result.error, "Invalid") + }) + + it("should throw 5XX error when network issues", async () => { + // Save the existing SLPDB_URL. + const savedUrl2 = process.env.SLPDB_URL + + // Manipulate the URL to cause a 500 network error. + process.env.SLPDB_URL = "http://fakeurl/api/" + + req.params.address = + "simpleledger:qpujxqra3jmdlzzapwmmt7uspr7q0c9ff5me5fdrdn" + req.params.tokenId = + "497291b8a1dfe69c8daea50677a3d31a5ef0e9484d8bebb610dac64bbc202fb7" + + const result = await balancesForAddressByTokenID(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.SLPDB_URL = savedUrl2 + + assert.isAbove( + res.statusCode, + 499, + "HTTP status code 500 or greater expected." + ) + assert.include( + result.error, + "Network error: Could not communicate", + "Error message expected" + ) + }) + + it("should get token information", async () => { + if (process.env.TEST === "unit") { + nock(mockServerUrl) + .get(uri => uri.includes("/")) + .times(2) + .reply(200, mockData.mockSingleAddress) + } + + req.params.address = + "simpleledger:qpujxqra3jmdlzzapwmmt7uspr7q0c9ff5me5fdrdn" + req.params.tokenId = + "497291b8a1dfe69c8daea50677a3d31a5ef0e9484d8bebb610dac64bbc202fb7" + + const result = await balancesForAddressByTokenID(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // TODO - add decimalCount + // assert.hasAllKeys(result, ["tokenId", "balance", "decimalCount"]) + assert.hasAllKeys(result, ["tokenId", "balance"]) + }) + }) + + describe("convertAddressSingle()", () => { + const convertAddressSingle = + slpRoute.testableComponents.convertAddressSingle + + it("should throw 400 if address is empty", async () => { + req.params.address = "" + const result = await convertAddressSingle(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ["error"]) + assert.include(result.error, "address can not be empty") + }) + // + it("should convert address", async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === "unit") { + nock(`${process.env.SLPDB_URL}`) + .post(uri => uri.includes("/")) + .reply(200, { result: mockData.mockConvert }) + } + + req.params.address = + "simpleledger:qpujxqra3jmdlzzapwmmt7uspr7q0c9ff5me5fdrdn" + + const result = await convertAddressSingle(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ["cashAddress", "legacyAddress", "slpAddress"]) + }) + }) + + describe("convertAddressBulk()", () => { + const convertAddressBulk = slpRoute.testableComponents.convertAddressBulk + + it("should throw 400 if addresses array is empty", async () => { + const result = await convertAddressBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ["error"]) + assert.include(result.error, "addresses needs to be an array") + assert.equal(res.statusCode, 400) + }) + + it("should throw 400 error if array is too large", async () => { + const testArray = [] + for (var i = 0; i < 25; i++) testArray.push("") + + req.body.addresses = testArray + + const result = await convertAddressBulk(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ["error"]) + assert.include(result.error, "Array too large") + }) + + it("should error on malformed address", async () => { + try { + req.body.addresses = ["bitcoincash:qzs02v05l7qs5s5dwuj0cx5ehjm2c"] + + await convertAddressBulk(req, res) + + assert.equal(true, false, "Unexpected result!") + } catch (err) { + // console.log(`err.message: ${util.inspect(err.message)}`) + + assert.include( + err.message, + `Invalid BCH address. Double check your address is valid` + ) + } + }) + + it("should validate array with single element", async () => { + req.body.addresses = [ + "bitcoincash:qzs02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c" + ] + + const result = await convertAddressBulk(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.hasAllKeys(result[0], [ + "slpAddress", + "cashAddress", + "legacyAddress" + ]) + }) + + it("should validate array with multiple elements", async () => { + req.body.addresses = [ + "bitcoincash:qzs02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c", + "bitcoincash:qrehqueqhw629p6e57994436w730t4rzasnly00ht0" + ] + + const result = await convertAddressBulk(req, res) + //console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.hasAllKeys(result[0], [ + "slpAddress", + "cashAddress", + "legacyAddress" + ]) + }) + }) + + describe('txsTokenIdAddressSingle()', () => { + const txsTokenIdAddressSingle = + slpRoute.txsTokenIdAddressSingle + + it("should get tx details with tokenId and address", async () => { + if (process.env.TEST === "unit") { + nock(`${process.env.SLPDB_URL}`) + .get(uri => uri.includes("/")) + .reply(200, { + c: mockData.mockTransactions + }) + } + + //req.params.tokenId = + // "37279c7dc81ceb34d12f03344b601c582e931e05d0e552c29c428bfa39d39af3" + //req.params.address = "slptest:qr83cu3p7yg9yac7qthwm0nul2ev2kukvsqmes3vl0" + + req.params.tokenId = + "7ac7f4bb50b019fe0f5c81e3fc13fc0720e130282ea460768cafb49785eb2796" + req.params.address = "slptest:qpwa35xq0q0cnmdu0rwzkct369hddzsqpsqdzw6h9h" + + const result = await txsTokenIdAddressSingle(req, res) + console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.hasAnyKeys(result[0], ["txid", "tokenDetails"]) + }) + +}) + +*/ diff --git a/test/v5/util.js b/test/v5/util.js new file mode 100644 index 0000000..939936b --- /dev/null +++ b/test/v5/util.js @@ -0,0 +1,532 @@ +/* + TESTS FOR THE UTIL.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. + + These tests use this private key: + L1wAGEN721LHDoiN8pLwwBb87bYrU6Gs21UPcCRR7LjKypQyVaCq + Which corresponds to this address: + bitcoincash:qp2g4cnekxsjspccmtvh5k73mczz6273js4mjr353r +*/ + +'use strict' + +// Public npm libraries +const assert = require('chai').assert +const nock = require('nock') // HTTP mocking +const sinon = require('sinon') + +// Local libraries +const Electrumx = require('../../src/routes/v4/electrumx') +const UtilRoute = require('../../src/routes/v4/util') + +let originalEnvVars // Used during transition from integration to unit tests. + +// Mocking data. +const { mockReq, mockRes } = require('./mocks/express-mocks') +const mockData = require('./mocks/util-mocks') + +const util = require('util') +util.inspect.defaultOptions = { depth: 1 } + +// const UtilRoute = utilRoute.UtilRoute +// const electrumx = new Electrumx() +const utilRouteInst = new UtilRoute({ electrumx: {} }) + +describe('#Util', () => { + let req, res + let sandbox + let electrumx + let utilRoute + + before(async () => { + // 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' + } + + electrumx = new Electrumx() + // await electrumx.connect() + }) + + // 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() + + sandbox = sinon.createSandbox() + + utilRoute = new UtilRoute({ electrumx }) + }) + + afterEach(() => { + // Clean up HTTP mocks. + nock.cleanAll() // clear interceptor list. + nock.restore() + + sandbox.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 = utilRouteInst.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, 'util', 'Returns static string') + }) + }) + + describe('#validateAddressSingle', async () => { + const validateAddress = utilRouteInst.validateAddressSingle + + it('should throw an error for an empty address', async () => { + const result = await validateAddress(req, res) + + assert.equal(res.statusCode, 400, 'HTTP status code 400 expected.') + assert.include( + result.error, + 'address can not be empty', + 'Proper error message' + ) + }) + + it('should throw 503 when network issues', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + req.params.address = 'bchtest:qqqk4y6lsl5da64sg5qc3xezmplyu5kmpyz2ysaa5y' + + await validateAddress(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + + 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") + }) + + it('should validate address', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + nock(`${process.env.RPC_BASEURL}`) + .post((uri) => uri.includes('/')) + .reply(200, { result: mockData.mockAddress }) + } + + req.params.address = + 'bitcoincash:qpujxqra3jmdlzzapwmmt7uspr7q0c9ff5hzljcrnd' + + const result = await validateAddress(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAnyKeys(result, [ + 'isvalid', + 'address', + 'scriptPubKey', + 'ismine', + 'iswatchonly', + 'isscript' + ]) + }) + }) + + describe('#validateAddressBulk', async () => { + const validateAddressBulk = utilRouteInst.validateAddressBulk + + it('should throw an error for an empty body', async () => { + const result = await validateAddressBulk(req, res) + + assert.equal(res.statusCode, 400, 'HTTP status code 400 expected.') + assert.include( + result.error, + 'addresses needs to be an array. Use GET for single address.', + 'Proper error message' + ) + }) + + it('should error on non-array single address', async () => { + req.body = { + addresses: 'bchtest:qqqk4y6lsl5da64sg5qc3xezmplyu5kmpyz2ysaa5y' + } + + const result = await validateAddressBulk(req, res) + + assert.equal(res.statusCode, 400, 'HTTP status code 400 expected.') + assert.include( + result.error, + 'addresses needs to be an array. Use GET for single 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 validateAddressBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Array too large') + }) + + it('should error on invalid address', async () => { + req.body = { + addresses: ['bchtest:qqqk4y6lsl5da64sg5qc3xezmpl'] + } + + const result = await validateAddressBulk(req, res) + + assert.equal(res.statusCode, 400, 'HTTP status code 400 expected.') + assert.include( + result.error, + 'Invalid BCH address. Double check your address is valid', + 'Proper error message' + ) + }) + + it('should error on mainnet address when using testnet', async () => { + req.body = { + addresses: ['bchtest:qrls6vzjkkxlds7aqv9075u0fttwc7u9jvczn5fdt9'] + } + + const result = await validateAddressBulk(req, res) + + assert.equal(res.statusCode, 400, 'HTTP status code 400 expected.') + assert.include( + result.error, + 'Invalid network. Trying to use a testnet address on mainnet, or vice versa.', + 'Proper error message' + ) + }) + + it('should throw 503 when network issues', async () => { + // Save the existing RPC URL. + const savedUrl2 = process.env.RPC_BASEURL + + // Manipulate the URL to cause a 500 network error. + process.env.RPC_BASEURL = 'http://fakeurl/api/' + + req.body.addresses = [ + 'bitcoincash:qpujxqra3jmdlzzapwmmt7uspr7q0c9ff5hzljcrnd' + ] + + await validateAddressBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.RPC_BASEURL = savedUrl2 + + assert.isAbove( + res.statusCode, + 499, + 'HTTP status code 500 or greater expected.' + ) + }) + + it('should validate a single address', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + nock(`${process.env.RPC_BASEURL}`) + .post((uri) => uri.includes('/')) + .reply(200, { result: mockData.mockAddress }) + } + + req.body.addresses = [ + 'bitcoincash:qpujxqra3jmdlzzapwmmt7uspr7q0c9ff5hzljcrnd' + ] + + const result = await validateAddressBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.hasAnyKeys(result[0], [ + 'isvalid', + 'address', + 'scriptPubKey', + 'ismine', + 'iswatchonly', + 'isscript' + ]) + }) + + it('should validate a multiple addresses', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + nock(`${process.env.RPC_BASEURL}`) + .post((uri) => uri.includes('/')) + .times(2) + .reply(200, { result: mockData.mockAddress }) + } + + req.body.addresses = [ + 'bitcoincash:qpujxqra3jmdlzzapwmmt7uspr7q0c9ff5hzljcrnd', + 'bitcoincash:qpujxqra3jmdlzzapwmmt7uspr7q0c9ff5hzljcrnd' + ] + + const result = await validateAddressBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.hasAnyKeys(result[0], [ + 'isvalid', + 'address', + 'scriptPubKey', + 'ismine', + 'iswatchonly', + 'isscript' + ]) + }) + }) + + describe('#sweepWif', () => { + it('should throw 400 if WIF is not included', async () => { + req.body = {} + + const result = await utilRouteInst.sweepWif(req, res) + + assert.equal(res.statusCode, 400, 'HTTP status code 400 expected.') + assert.include( + result.error, + 'WIF needs to a proper compressed WIF starting with K or L', + 'Proper error message' + ) + }) + + it('should throw 400 if WIF is malformed', async () => { + req.body = { + wif: 'abc123' + } + + const result = await utilRouteInst.sweepWif(req, res) + + assert.equal(res.statusCode, 400, 'HTTP status code 400 expected.') + assert.include( + result.error, + 'WIF needs to a proper compressed WIF starting with K or L', + 'Proper error message' + ) + }) + + it('should throw 400 if destination address is not included', async () => { + req.body = { + wif: 'L5GEFg1tETLWBugmhSo9Zc4ms968qVmfmTroDxsJ982AiudAQGyt' + } + + const result = await utilRouteInst.sweepWif(req, res) + + assert.equal(res.statusCode, 400, 'HTTP status code 400 expected.') + assert.include( + result.error, + 'address can not be empty', + 'Proper error message' + ) + }) + + // Unit test only. + if (process.env.TEST === 'unit') { + it('should generate transaction for valid token sweep', async () => { + // Mock the RPC call for unit tests. + + sandbox + .stub(utilRoute.electrumx, '_balanceFromElectrumx') + .resolves(mockData.mockBalance) + + sandbox + .stub(utilRoute.electrumx, '_utxosFromElectrumx') + .resolves(mockData.mockUtxos) + + sandbox + .stub(utilRoute.bchjs.SLP.Utils, 'tokenUtxoDetails') + .resolves(mockData.mockIsTokenUtxos) + + // Mock sendRawTransaction() so that the hex does not actually get broadcast + // to the network. + sandbox + .stub(utilRoute.bchjs.RawTransactions, 'sendRawTransaction') + .resolves('test-txid') + + req.body = { + wif: 'L1wAGEN721LHDoiN8pLwwBb87bYrU6Gs21UPcCRR7LjKypQyVaCq', + toAddr: 'bitcoincash:qp2g4cnekxsjspccmtvh5k73mczz6273js4mjr353r' + } + + const result = await utilRoute.sweepWif(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.equal(result, 'test-txid') + }) + + it('should return balance if balance-only is true', async () => { + // Mock the RPC call for unit tests. + + sandbox + .stub(utilRoute.electrumx, '_balanceFromElectrumx') + .resolves(mockData.mockBalance) + + // Mock sendRawTransaction() so that the hex does not actually get broadcast + // to the network. + sandbox + .stub(utilRoute.bchjs.RawTransactions, 'sendRawTransaction') + .resolves('test-txid') + + req.body = { + wif: 'L5GEFg1tETLWBugmhSo9Zc4ms968qVmfmTroDxsJ982AiudAQGyt', + balanceOnly: true + } + + const result = await utilRoute.sweepWif(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isNumber(result) + }) + + it('should generate transaction for valid BCH-only sweep', async () => { + sandbox + .stub(utilRoute.electrumx, '_balanceFromElectrumx') + .resolves(mockData.mockBalance) + + sandbox + .stub(utilRoute.electrumx, '_utxosFromElectrumx') + .resolves(mockData.mockUtxos) + + // Force token utxo to appear as regular BCH utxo. + sandbox + .stub(utilRoute.bchjs.SLP.Utils, 'tokenUtxoDetails') + .resolves([false, false]) + + // Mock sendRawTransaction() so that the hex does not actually get broadcast + // to the network. + sandbox + .stub(utilRoute.bchjs.RawTransactions, 'sendRawTransaction') + .resolves('test-txid') + + req.body = { + wif: 'L5GEFg1tETLWBugmhSo9Zc4ms968qVmfmTroDxsJ982AiudAQGyt', + toAddr: 'bitcoincash:qz2qn6zt4qmacf4r6c0e2pdcqsgnkxaa3ql2xpee6p' + } + + const result = await utilRoute.sweepWif(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.equal(result, 'test-txid') + }) + + it('should throw 422 error if no non-token UTXOs', async () => { + sandbox + .stub(utilRoute.electrumx, '_balanceFromElectrumx') + .resolves(mockData.mockBalance) + + sandbox + .stub(utilRoute.electrumx, '_utxosFromElectrumx') + .resolves(mockData.mockUtxos) + + // Force token utxo to appear as regular BCH utxo. + sandbox + .stub(utilRoute.bchjs.SLP.Utils, 'tokenUtxoDetails') + .resolves(mockData.tokensOnly) + + // Mock sendRawTransaction() so that the hex does not actually get broadcast + // to the network. + sandbox + .stub(utilRoute.bchjs.RawTransactions, 'sendRawTransaction') + .resolves('test-txid') + + req.body = { + wif: 'L5GEFg1tETLWBugmhSo9Zc4ms968qVmfmTroDxsJ982AiudAQGyt', + toAddr: 'bitcoincash:qz2qn6zt4qmacf4r6c0e2pdcqsgnkxaa3ql2xpee6p' + } + + const result = await utilRoute.sweepWif(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.equal(res.statusCode, 422) + assert.property(result, 'error') + assert.include( + result.error, + 'Tokens found, but no BCH UTXOs found. Add BCH to wallet to move tokens' + ) + }) + + it('should detect and throw error for multiple token classes', async () => { + sandbox + .stub(utilRoute.electrumx, '_balanceFromElectrumx') + .resolves(mockData.mockBalance) + + sandbox + .stub(utilRoute.electrumx, '_utxosFromElectrumx') + .resolves(mockData.mockThreeUtxos) + + // Force token utxo to appear as regular BCH utxo. + sandbox + .stub(utilRoute.bchjs.SLP.Utils, 'tokenUtxoDetails') + .resolves(mockData.multipleTokens) + + // Mock sendRawTransaction() so that the hex does not actually get broadcast + // to the network. + sandbox + .stub(utilRoute.bchjs.RawTransactions, 'sendRawTransaction') + .resolves('test-txid') + + req.body = { + wif: 'L5GEFg1tETLWBugmhSo9Zc4ms968qVmfmTroDxsJ982AiudAQGyt', + toAddr: 'bitcoincash:qz2qn6zt4qmacf4r6c0e2pdcqsgnkxaa3ql2xpee6p' + } + + const result = await utilRoute.sweepWif(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.equal(res.statusCode, 422) + assert.property(result, 'error') + assert.include( + result.error, + 'Multiple token classes detected. This function only supports a single class of token' + ) + }) + } + }) +}) diff --git a/test/v5/xpub.js b/test/v5/xpub.js new file mode 100644 index 0000000..c5723d4 --- /dev/null +++ b/test/v5/xpub.js @@ -0,0 +1,143 @@ +/* + TESTS FOR THE XPUB.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. +*/ + +'use strict' + +const chai = require('chai') +const assert = chai.assert +const xpubRoute = require('../../src/routes/v4/xpub') +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('#XPUBRouter', () => { + let req, res + + before(() => { + // 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 + }) + + // 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 = xpubRoute.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('#FromXPubSingle', () => { + // details route handler. + const fromXPubSingle = xpubRoute.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) + }) + }) +})