diff --git a/src/routes/v3/blockbook.js b/src/routes/v3/blockbook.js index 8ddb68c..7b5bd07 100644 --- a/src/routes/v3/blockbook.js +++ b/src/routes/v3/blockbook.js @@ -192,7 +192,7 @@ class Blockbook { } // Enforce array size rate limits - if (!routeUtils.validateArraySize(req, addresses)) { + if (!_this.routeUtils.validateArraySize(req, addresses)) { res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 return res.json({ error: 'Array too large.' diff --git a/src/routes/v3/electrumx.js b/src/routes/v3/electrumx.js index f7c4c2e..976705a 100644 --- a/src/routes/v3/electrumx.js +++ b/src/routes/v3/electrumx.js @@ -49,6 +49,7 @@ class Electrum { _this.router = router _this.router.get('/', _this.root) _this.router.get('/utxos/:address', _this.getUtxos) + _this.router.post('/utxos', _this.utxosBulk) _this.router.get('/balance/:address', _this.getBalance) _this.router.get('/transactions/:address', _this.getTransactions) } @@ -228,6 +229,97 @@ class Electrum { } } + /** + * @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/v3/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(429) // 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 a balance for an address. Expects input // to be a cash address, and input validation to have already been done by // parent, calling function. diff --git a/test/v3/a01-electrumx.js b/test/v3/a01-electrumx.js index ed9ca4b..2099211 100644 --- a/test/v3/a01-electrumx.js +++ b/test/v3/a01-electrumx.js @@ -278,6 +278,140 @@ describe('#ElectrumX Router', () => { }) }) + 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('#_balanceFromElectrumx', () => { it('should throw error for invalid address', async () => { try { diff --git a/test/v3/blockbook.js b/test/v3/blockbook.js index eab7e70..90a970e 100644 --- a/test/v3/blockbook.js +++ b/test/v3/blockbook.js @@ -105,8 +105,7 @@ describe('#Blockbook Router', () => { }) it('should throw an error for an invalid address', async () => { - req.params.address = - '02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c' + req.params.address = '02v05l7qs5s24srqju498qu55dwuj0cx5ehjm2c' const result = await blockbookRoute.balanceSingle(req, res) @@ -119,8 +118,7 @@ describe('#Blockbook Router', () => { }) it('should detect a network mismatch', async () => { - req.params.address = - 'bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4' + req.params.address = 'bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4' const result = await blockbookRoute.balanceSingle(req, res) @@ -458,8 +456,7 @@ describe('#Blockbook Router', () => { }) it('should detect a network mismatch', async () => { - req.params.address = - 'bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4' + req.params.address = 'bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4' const result = await utxosSingle(req, res) @@ -654,6 +651,7 @@ describe('#Blockbook Router', () => { process.env.BLOCKBOOK_URL = savedUrl } }) + it('returns proper error when downstream service stalls', async () => { req.body = { addresses: ['bitcoincash:qp3sn6vlwz28ntmf3wmyra7jqttfx7z6zgtkygjhc7'] @@ -674,6 +672,7 @@ describe('#Blockbook Router', () => { 'Error message expected' ) }) + it('returns proper error when downstream service is down', async () => { req.body = { addresses: ['bitcoincash:qp3sn6vlwz28ntmf3wmyra7jqttfx7z6zgtkygjhc7'] @@ -694,6 +693,7 @@ describe('#Blockbook Router', () => { 'Error message expected' ) }) + it('should get details for a single address', async () => { req.body = { addresses: ['bitcoincash:qp3sn6vlwz28ntmf3wmyra7jqttfx7z6zgtkygjhc7'] @@ -787,7 +787,8 @@ describe('#Blockbook Router', () => { const savedUrl = process.env.BLOCKBOOK_URL try { - req.params.txid = '6181c669614fa18039a19b23eb06806bfece1f7514ab457c3bb82a40fe171a6d' + req.params.txid = + '6181c669614fa18039a19b23eb06806bfece1f7514ab457c3bb82a40fe171a6d' // Switch the Insight URL to something that will error out. process.env.BLOCKBOOK_URL = 'http://fakeurl/api/'