diff --git a/src/routes/v3/electrumx.js b/src/routes/v3/electrumx.js index 9cdb111..586b1fb 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.get('/balance/:address', _this.getBalance) } // Initializes a connection to electrum servers. @@ -124,39 +125,6 @@ class Electrum { 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 @@ -226,6 +194,109 @@ class Electrum { } } + /** + * @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/v3/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) + } + } + + // 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 + } + } + // 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 eb6c984..7ddc945 100644 --- a/test/v3/a01-electrumx.js +++ b/test/v3/a01-electrumx.js @@ -106,6 +106,64 @@ describe('#ElectrumX Router', () => { }) }) + 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 400 if address is empty', async () => { const result = await electrumxRoute.getUtxos(req, res) @@ -220,64 +278,6 @@ describe('#ElectrumX Router', () => { }) }) - 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('#_balanceFromElectrumx', () => { it('should throw error for invalid address', async () => { try { @@ -344,6 +344,116 @@ describe('#ElectrumX Router', () => { }) }) + 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, 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.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, 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.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 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.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, 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('#_transactionsFromElectrumx', () => { it('should throw error for invalid address', async () => { try {