diff --git a/package.json b/package.json index aae710d..4eec6e2 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,8 @@ "coverage": "nyc report --reporter=text-lcov | coveralls", "coverage:report": "export NETWORK=mainnet && nyc --reporter=html mocha --timeout 25000 test/v4/", "docs": "./node_modules/.bin/apidoc -i src/routes/v4 -o docs", - "test:temp1": "export NETWORK=mainnet && export TEST=integration && mocha -g '#balanceBulk' --exit --timeout 30000 test/v5/", - "test:temp2": "export NETWORK=mainnet && mocha -g '#balanceBulk' --exit --timeout 30000 test/v5/" + "test:temp1": "export NETWORK=mainnet && export TEST=integration && mocha -g '#utxosBulk' --exit --timeout 30000 test/v5/", + "test:temp2": "export NETWORK=mainnet && mocha -g '#utxosBulk' --exit --timeout 30000 test/v5/" }, "engines": { "node": ">=10.15.1" diff --git a/src/routes/v5/electrumx.js b/src/routes/v5/electrumx.js index a78b69e..371e5c7 100644 --- a/src/routes/v5/electrumx.js +++ b/src/routes/v5/electrumx.js @@ -50,8 +50,8 @@ class Electrum { this.router.get('/', this.root) this.router.get('/balance/:address', this.getBalance) this.router.post('/balance', this.balanceBulk) - // this.router.get('/utxos/:address', this.getUtxos) - // this.router.post('/utxos', this.utxosBulk) + 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) @@ -249,62 +249,52 @@ class Electrum { * */ // 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) - // } - // } + 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 response = await _this.axios.get( + `${_this.fulcrumApi}electrumx/utxos/${address}` + ) + // console.log('response', response, _this.fulcrumApi) + + res.status(200) + return res.json(response.data) + } 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. @@ -319,83 +309,73 @@ class Electrum { * */ // 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) - // } - // } + async utxosBulk (req, res, next) { + try { + const 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({ + success: false, + 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({ + success: false, + 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({ + success: false, + 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({ + success: false, + error: `Invalid network for address ${thisAddress}. Trying to use a testnet address on mainnet, or vice versa.` + }) + } + } + + const response = await _this.axios.post( + `${_this.fulcrumApi}electrumx/utxos/`, + { addresses } + ) + + res.status(200) + return res.json(response.data) + } 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 diff --git a/test/v5/a01-electrumx.js b/test/v5/a01-electrumx.js index 3080133..2c90739 100644 --- a/test/v5/a01-electrumx.js +++ b/test/v5/a01-electrumx.js @@ -356,6 +356,258 @@ describe('#Electrumx', () => { assert.property(result.balances[0].balance, 'unconfirmed') }) }) + describe('#getUtxos', () => { + it('should throw 400 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 electrum-cash to user', async () => { + // Address has invalid checksum. + req.params.address = + 'bitcoincash:qr69kyzha07dcecrsvjwsj4s6slnlq4r8c30lxnur2' + + // 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 utxos 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.axios, 'get') + .resolves({ data: { success: true, utxos: 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 400 if addresses is empty', async () => { + const result = await electrumxRoute.utxosBulk(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, 'addresses needs to be an array') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + it('should throw 400 if input provided is not array', async () => { + req.body.addresses = 'qzs02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c' + + const result = await electrumxRoute.utxosBulk(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, 'addresses needs to be an array') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + it('should throw 400 error if addresses array is too large', async () => { + const testArray = [] + for (var i = 0; i < 25; i++) testArray.push('') + + req.body.addresses = testArray + + const result = await electrumxRoute.utxosBulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.property(result, 'error') + assert.include(result.error, 'Array too large') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + it('should throw an error for an invalid address', async () => { + req.body.addresses = ['02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c'] + + const result = await electrumxRoute.utxosBulk(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 BCH address') + + assert.property(result, 'success') + assert.equal(result.success, false) + }) + + 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, '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.body.addresses = [ + 'bitcoincash:qr69kyzha07dcecrsvjwsj4s6slnlq4r8c30lxnur2' + ] + + // 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, false) + + assert.property(result, 'error') + assert.include(result.error, 'Invalid BCH address') + }) + it('should handle error', async () => { + req.body.addresses = [ + 'bitcoincash:qp3sn6vlwz28ntmf3wmyra7jqttfx7z6zgtkygjhc7', + 'bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf' + ] + // Force error + sandbox.stub(electrumxRoute.axios, 'post').throws(new Error('Test error')) + + // 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, false) + + assert.property(result, 'error') + assert.include(result.error, 'Test error') + }) + it('should get utxos for an array of addresses', async () => { + req.body.addresses = [ + 'bitcoincash:qp3sn6vlwz28ntmf3wmyra7jqttfx7z6zgtkygjhc7', + 'bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf' + ] + + // Mock unit tests to prevent live network calls. + if (process.env.TEST === 'unit') { + electrumxRoute.isReady = true // Force flag. + + sandbox + .stub(electrumxRoute.axios, 'post') + .resolves({ data: mockData.utxosArray }) + } + + // 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.property(result.utxos[0], 'utxos') + assert.property(result.utxos[0], 'address') + + assert.isArray(result.utxos[0].utxos) + assert.isString(result.utxos[0].address) + + const firtsAddrUtxos = result.utxos[0].utxos[0] + assert.property(firtsAddrUtxos, 'height') + assert.property(firtsAddrUtxos, 'tx_hash') + assert.property(firtsAddrUtxos, 'tx_pos') + assert.property(firtsAddrUtxos, 'value') + }) + }) // describe('#_utxosFromElectrumx', () => { // it('should throw error for invalid address', async () => { diff --git a/test/v5/mocks/electrumx-mock.js b/test/v5/mocks/electrumx-mock.js index 9769942..ada1f2f 100644 --- a/test/v5/mocks/electrumx-mock.js +++ b/test/v5/mocks/electrumx-mock.js @@ -19,6 +19,43 @@ const utxos = [ } ] +const utxosArray = { + success: true, + utxos: [ + { + utxos: [ + { + height: 601861, + tx_hash: + '6181c669614fa18039a19b23eb06806bfece1f7514ab457c3bb82a40fe171a6d', + tx_pos: 0, + value: 1000 + } + ], + address: 'bitcoincash:qp3sn6vlwz28ntmf3wmyra7jqttfx7z6zgtkygjhc7' + }, + { + utxos: [ + { + height: 604392, + tx_hash: + '7774e449c5a3065144cefbc4c0c21e6b69c987f095856778ef9f45ddd8ae1a41', + tx_pos: 0, + value: 1000 + }, + { + height: 630834, + tx_hash: + '4fe60a51e0d8f5134bfd8e5f872d6e502d7f01b28a6afebb27f4438a4f638d53', + tx_pos: 0, + value: 6000 + } + ], + address: 'bitcoincash:qrdka2205f4hyukutc2g0s6lykperc8nsu5u2ddpqf' + } + ] +} + const balance = { success: true, balance: { @@ -136,6 +173,7 @@ const blockHeaders = [ module.exports = { utxos, + utxosArray, balance, txHistory, mempool,