From 1e53a42efd0b176825bc052f1ca969dd6ff4d69d Mon Sep 17 00:00:00 2001 From: Stoyan Zhekov Date: Tue, 28 Jul 2020 23:39:16 +0900 Subject: [PATCH 1/3] GET and POST endpoints for unconfirmed utxos --- src/routes/v3/electrumx.js | 193 +++++++++++++++++++++ test/v3/a01-electrumx.js | 294 ++++++++++++++++++++++++++++++++ test/v3/mocks/electrumx-mock.js | 11 +- 3 files changed, 497 insertions(+), 1 deletion(-) diff --git a/src/routes/v3/electrumx.js b/src/routes/v3/electrumx.js index de0ae3b..5274517 100644 --- a/src/routes/v3/electrumx.js +++ b/src/routes/v3/electrumx.js @@ -708,6 +708,199 @@ class Electrum { } } + // 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/mempool/{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/v3/electrumx/mempool/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/mempool 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/v3/electrumx/mempool" -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(429) // 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, + mempool: 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 { diff --git a/test/v3/a01-electrumx.js b/test/v3/a01-electrumx.js index 17d539d..dcb588f 100644 --- a/test/v3/a01-electrumx.js +++ b/test/v3/a01-electrumx.js @@ -999,4 +999,298 @@ describe('#ElectrumX Router', () => { 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) + } + + // 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, 400, 'Expect 400 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, 400, 'Expect 400 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) + } + + // 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) + } + + // 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, 'mempool') + assert.isArray(result.mempool) + + assert.property(result.mempool[0], 'address') + assert.property(result.mempool[0], 'utxos') + + assert.isArray(result.mempool[0].utxos) + assert.property(result.mempool[0].utxos[0], 'height') + assert.property(result.mempool[0].utxos[0], 'tx_hash') + assert.property(result.mempool[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) + } + + // 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.mempool) + assert.isArray(result.mempool[0].utxos) + assert.equal(result.mempool.length, 2, '2 outputs for 2 inputs') + }) + }) }) diff --git a/test/v3/mocks/electrumx-mock.js b/test/v3/mocks/electrumx-mock.js index 65baf09..9aaf10c 100644 --- a/test/v3/mocks/electrumx-mock.js +++ b/test/v3/mocks/electrumx-mock.js @@ -31,8 +31,17 @@ const txHistory = [ } ] +const mempool = [ + { + tx_hash: '45381031132c57b2ff1cbe8d8d3920cf9ed25efd9a0beb764bdb2f24c7d1c7e3', + height: 0, + fee: 24310 + } +] + module.exports = { utxos, balance, - txHistory + txHistory, + mempool } From 74112a1ed86311f3c108812771c876f73aba2dab Mon Sep 17 00:00:00 2001 From: Stoyan Zhekov Date: Tue, 28 Jul 2020 23:46:02 +0900 Subject: [PATCH 2/3] Routes added --- src/routes/v3/electrumx.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/routes/v3/electrumx.js b/src/routes/v3/electrumx.js index 5274517..19a2bd4 100644 --- a/src/routes/v3/electrumx.js +++ b/src/routes/v3/electrumx.js @@ -54,6 +54,8 @@ class Electrum { _this.router.post('/balance', _this.balanceBulk) _this.router.get('/transactions/:address', _this.getTransactions) _this.router.post('/transactions', _this.transactionsBulk) + _this.router.get('/mempool/:address', _this.getMempool) + _this.router.post('/mempool', _this.mempoolBulk) } // Initializes a connection to electrum servers. From 14ac642bdbb2abf8351fb02a2e15a6478ed60f83 Mon Sep 17 00:00:00 2001 From: Stoyan Zhekov Date: Wed, 29 Jul 2020 00:10:26 +0900 Subject: [PATCH 3/3] Bulk mempool response format fixed --- src/routes/v3/electrumx.js | 6 +++--- test/v3/a01-electrumx.js | 22 +++++++++++----------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/routes/v3/electrumx.js b/src/routes/v3/electrumx.js index 19a2bd4..f69e958 100644 --- a/src/routes/v3/electrumx.js +++ b/src/routes/v3/electrumx.js @@ -54,8 +54,8 @@ class Electrum { _this.router.post('/balance', _this.balanceBulk) _this.router.get('/transactions/:address', _this.getTransactions) _this.router.post('/transactions', _this.transactionsBulk) - _this.router.get('/mempool/:address', _this.getMempool) - _this.router.post('/mempool', _this.mempoolBulk) + _this.router.get('/unconfirmed/:address', _this.getMempool) + _this.router.post('/unconfirmed', _this.mempoolBulk) } // Initializes a connection to electrum servers. @@ -894,7 +894,7 @@ class Electrum { res.status(200) return res.json({ success: true, - mempool: result + utxos: result }) } catch (err) { wlogger.error('Error in electrumx.js/mempoolBulk().', err) diff --git a/test/v3/a01-electrumx.js b/test/v3/a01-electrumx.js index dcb588f..035ccde 100644 --- a/test/v3/a01-electrumx.js +++ b/test/v3/a01-electrumx.js @@ -1252,16 +1252,16 @@ describe('#ElectrumX Router', () => { assert.property(result, 'success') assert.equal(result.success, true) - assert.property(result, 'mempool') - assert.isArray(result.mempool) + assert.property(result, 'utxos') + assert.isArray(result.utxos) - assert.property(result.mempool[0], 'address') - assert.property(result.mempool[0], 'utxos') + assert.property(result.utxos[0], 'address') + assert.property(result.utxos[0], 'utxos') - assert.isArray(result.mempool[0].utxos) - assert.property(result.mempool[0].utxos[0], 'height') - assert.property(result.mempool[0].utxos[0], 'tx_hash') - assert.property(result.mempool[0].utxos[0], 'fee') + 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 () => { @@ -1288,9 +1288,9 @@ describe('#ElectrumX Router', () => { assert.property(result, 'success') assert.equal(result.success, true) - assert.isArray(result.mempool) - assert.isArray(result.mempool[0].utxos) - assert.equal(result.mempool.length, 2, '2 outputs for 2 inputs') + assert.isArray(result.utxos) + assert.isArray(result.utxos[0].utxos) + assert.equal(result.utxos.length, 2, '2 outputs for 2 inputs') }) }) })