diff --git a/src/routes/v5/psf-slp-indexer.js b/src/routes/v5/psf-slp-indexer.js index e418358..2630d50 100644 --- a/src/routes/v5/psf-slp-indexer.js +++ b/src/routes/v5/psf-slp-indexer.js @@ -12,6 +12,9 @@ const util = require('util') const RouteUtils = require('../../util/route-utils') const routeUtils = new RouteUtils() +const BCHJS = require('@psf/bch-js') +const bchjs = new BCHJS() + // Local libraries // const wlogger = require('../../../util/winston-logging') const config = require('../../../config') @@ -25,12 +28,12 @@ class PsfSlpIndexer { this.router = router this.routeUtils = routeUtils this.config = config - + this.bchjs = bchjs // Define routes this.router.get('/', this.root) - // this.router.get('/status', this.getStatus) - // this.router.post('/address', this.getAddress) - // this.router.post('/txid', this.getTxid) + this.router.get('/status', this.getStatus) + this.router.post('/address', this.getAddress) + this.router.post('/txid', this.getTxid) this.router.post('/token', this.getTokenStats) // _this = this @@ -41,6 +44,142 @@ class PsfSlpIndexer { return res.json({ status: 'psf-slp-indexer' }) } + /** + * @api {get} /psf/slp/status/ Indexer Status. + * @apiName SLP indexer status. + * @apiGroup PSF SLP + * @apiDescription Return SLP indexer status + * + * + * @apiExample Example usage: + * curl -H "Content-Type: application/json" -X GET localhost:3000/v5/psf/slp/status + * + * + */ + async getStatus (req, res, next) { + try { + // Verify env var is set for interacting with the indexer. + this.checkEnvVar() + + const response = await this.axios.get(`${this.psfSlpIndexerApi}slp/status/`) + + res.status(200) + return res.json(response.data) + } catch (err) { + return this.errorHandler(err, res) + } + } + + /** + * @api {post} /psf/slp/address/ SLP balance for address. + * @apiName SLP balance for address. + * @apiGroup PSF SLP + * @apiDescription Return SLP balance for address + * + * + * @apiExample Example usage: + * curl -H "Content-Type: application/json" -X POST -d '{ "address": "bitcoincash:qzmd5vxgh9m22m6fgvm57yd6kjnjl9qnwywsf3583n" }' localhost:3000/v5/psf/slp/address + * + * + */ + async getAddress (req, res, next) { + try { + // Verify env var is set for interacting with the indexer. + this.checkEnvVar() + + // Validate the input data. + const address = req.body.address + if (!address || address === '') { + res.status(400) + return res.json({ + success: false, + 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({ + success: false, + 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({ + success: false, + error: + 'Invalid network. Trying to use a testnet address on mainnet, or vice versa.' + }) + } + + const response = await this.axios.post( + `${this.psfSlpIndexerApi}slp/address/`, + { address } + ) + + res.status(200) + return res.json(response.data) + } catch (err) { + // console.log('err', err) + return this.errorHandler(err, res) + } + } + + /** + * @api {post} /psf/slp/txid/ SLP transaction data. + * @apiName SLP transaction data. + * @apiGroup PSF SLP + * @apiDescription Return slp transaction data. + * + * + * @apiExample Example usage: + * curl -H "Content-Type: application/json" -X POST -d '{ "txid": "f3e14cd871402a766e85045dc552f2c1e87857dd3ea1b15efab6334ccef5e315" }' localhost:3000/v5/psf/slp/txid + * + * + */ + async getTxid (req, res, next) { + try { + // Verify env var is set for interacting with the indexer. + this.checkEnvVar() + + const txid = req.body.txid + if (!txid || txid === '') { + res.status(400) + return res.json({ + success: false, + error: 'txid can not be empty' + }) + } + + if (txid.length !== 64) { + res.status(400) + return res.json({ + success: false, + error: 'This is not a txid' + }) + } + + const response = await this.axios.post( + `${this.psfSlpIndexerApi}slp/tx/`, + { txid } + ) + + res.status(200) + return res.json(response.data) + } catch (err) { + // console.log('err', err) + return this.errorHandler(err, res) + } + } + /** * @api {post} /psf/slp/tokenStats/ List stats for a single slp token. * @apiName List stats for a single slp token. diff --git a/test/v5/mocks/psf-slp-indexer-mocks.js b/test/v5/mocks/psf-slp-indexer-mocks.js index 29080d1..76c830b 100644 --- a/test/v5/mocks/psf-slp-indexer-mocks.js +++ b/test/v5/mocks/psf-slp-indexer-mocks.js @@ -30,6 +30,101 @@ const tokenStats = { } } -module.exports = { - tokenStats +const txData = { + txData: { + txid: 'a4fb5c2da1aa064e25018a43f9165040071d9e984ba190c222a7f59053af84b2', + hash: 'a4fb5c2da1aa064e25018a43f9165040071d9e984ba190c222a7f59053af84b2', + version: 2, + size: 339, + locktime: 0, + vin: [ + { + txid: '8370db30d94761ab9a11b71ecd22541151bf6125c8c613f0f6fab8ab794565a7', + vout: 0, + scriptSig: { + asm: '304402207e9631c53dfc8a9a793d1916469628c6b7c5780c01c2f676d51ef21b0ba4926f022069feb471ec869a49f8d108d0aaba04e7cd36e60a7500109d86537f55698930d4[ALL|FORKID] 02791b19a39165dbd83403d6df268d44fd621da30581b0b6e5cb15a7101ed58851', + hex: '47304402207e9631c53dfc8a9a793d1916469628c6b7c5780c01c2f676d51ef21b0ba4926f022069feb471ec869a49f8d108d0aaba04e7cd36e60a7500109d86537f55698930d4412102791b19a39165dbd83403d6df268d44fd621da30581b0b6e5cb15a7101ed58851' + }, + sequence: 4294967295, + address: 'bitcoincash:qpvsg9vl9a5mlf37a7n3yce6pktdctn73qwgaqm3wq', + value: 0.00051303, + tokenQty: 0, + tokenQtyStr: '0', + tokenId: null + } + ], + vout: [ + { + value: 0, + n: 0, + scriptPubKey: { + asm: 'OP_RETURN 5262419 1 47454e45534953 54524f5554 54726f75742773207465737420746f6b656e 74726f757473626c6f672e636f6d 0 2 2 000000174876e800', + hex: '6a04534c500001010747454e455349530554524f55541254726f75742773207465737420746f6b656e0e74726f757473626c6f672e636f6d4c000102010208000000174876e800', + type: 'nulldata' + }, + tokenQtyStr: '0', + tokenQty: 0 + } + ], + hex: '0200000001a7654579abb8faf6f013c6c82561bf51115422cd1eb7119aab6147d930db7083000000006a47304402207e9631c53dfc8a9a793d1916469628c6b7c5780c01c2f676d51ef21b0ba4926f022069feb471ec869a49f8d108d0aaba04e7cd36e60a7500109d86537f55698930d4412102791b19a39165dbd83403d6df268d44fd621da30581b0b6e5cb15a7101ed58851ffffffff040000000000000000476a04534c500001010747454e455349530554524f55541254726f75742773207465737420746f6b656e0e74726f757473626c6f672e636f6d4c000102010208000000174876e80022020000000000001976a914db4d39ceb7794ffe5d06855f249e1d3a7f1b024088ac22020000000000001976a914db4d39ceb7794ffe5d06855f249e1d3a7f1b024088accec20000000000001976a9145904159f2f69bfa63eefa712633a0d96dc2e7e8888ac00000000', + blockhash: '0000000000000000009f65225a3e12e23a7ea057c869047e0f36563a1f410267', + confirmations: 97398, + time: 1581773131, + blocktime: 1581773131, + blockheight: 622414, + isSlpTx: true, + tokenTxType: 'GENESIS', + tokenId: 'a4fb5c2da1aa064e25018a43f9165040071d9e984ba190c222a7f59053af84b2', + tokenType: 1, + tokenTicker: 'TROUT', + tokenName: "Trout's test token", + tokenDecimals: 2, + tokenUri: 'troutsblog.com', + tokenDocHash: '', + isValidSlp: true + } +} +const balance = { + balance: { + utxos: [ + { + txid: 'a24a6a4abf06fabd799ecea4f8fac6a9ff21e6a8dd6169a3c2ebc03665329db9', + vout: 1, + type: 'token', + qty: '1800', + tokenId: 'a4fb5c2da1aa064e25018a43f9165040071d9e984ba190c222a7f59053af84b2', + address: 'bitcoincash:qrqy3kj7r822ps6628vwqq5k8hyjl6ey3y4eea2m4s' + } + ], + txs: [ + { + txid: '078b2c48ed1db0d5d5996f2889b8d847a49200d0a781f6aa6752f740f312688f', + height: 717796 + }, + { + txid: 'a24a6a4abf06fabd799ecea4f8fac6a9ff21e6a8dd6169a3c2ebc03665329db9', + height: 717832 + } + ], + balances: [ + { + tokenId: 'a4fb5c2da1aa064e25018a43f9165040071d9e984ba190c222a7f59053af84b2', + qty: '1800' + } + ] + } +} + +const status = { + status: { + startBlockHeight: 543376, + syncedBlockHeight: 722860, + chainBlockHeight: 722679 + } +} +module.exports = { + tokenStats, + txData, + balance, + status } diff --git a/test/v5/psf-slp-indexer.js b/test/v5/psf-slp-indexer.js index 3751ad0..efc5050 100644 --- a/test/v5/psf-slp-indexer.js +++ b/test/v5/psf-slp-indexer.js @@ -179,4 +179,333 @@ describe('#PsfSlpIndexer', () => { assert.property(tokenData, 'txs') }) }) + + describe('#getTxid', async () => { + it('should throw 400 error if txid is missing', async () => { + const result = await uut.getTxid(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error', 'success']) + assert.isFalse(result.success) + assert.include(result.error, 'txid can not be empty') + }) + it('should throw 400 error if txid is invalid format', async () => { + req.body.txid = 'txid' + + const result = await uut.getTxid(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error', 'success']) + assert.isFalse(result.success) + assert.include(result.error, 'This is not a txid') + }) + it('should throw 503 when network issues', async () => { + req.body.txid = + 'f3e14cd871402a766e85045dc552f2c1e87857dd3ea1b15efab6334ccef5e315' + + // 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.getTxid(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 () => { + req.body.txid = + 'f3e14cd871402a766e85045dc552f2c1e87857dd3ea1b15efab6334ccef5e315' + + // Mock the timeout error. + sandbox.stub(uut.axios, 'post').throws({ code: 'ECONNABORTED' }) + + const result = await uut.getTxid(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.txid = + 'f3e14cd871402a766e85045dc552f2c1e87857dd3ea1b15efab6334ccef5e315' + + // Mock the timeout error. + sandbox.stub(uut.axios, 'post').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.getTxid(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 tx data', async function () { + req.body.txid = + 'f3e14cd871402a766e85045dc552f2c1e87857dd3ea1b15efab6334ccef5e315' + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'post') + .resolves({ data: mockData.txData }) + } else { + return this.skip() + } + + const result = await uut.getTxid(req, res) + const txData = result.txData + assert.property(txData, 'txid') + assert.property(txData, 'hash') + assert.property(txData, 'version') + assert.property(txData, 'size') + assert.property(txData, 'locktime') + assert.property(txData, 'vin') + assert.property(txData, 'vout') + assert.property(txData, 'hex') + assert.property(txData, 'blockhash') + assert.property(txData, 'confirmations') + assert.property(txData, 'time') + assert.property(txData, 'blocktime') + assert.property(txData, 'blockheight') + assert.property(txData, 'isSlpTx') + assert.property(txData, 'tokenTxType') + assert.property(txData, 'tokenId') + assert.property(txData, 'tokenType') + assert.property(txData, 'tokenTicker') + assert.property(txData, 'tokenName') + assert.property(txData, 'tokenDecimals') + assert.property(txData, 'tokenUri') + assert.property(txData, 'tokenDocHash') + assert.property(txData, 'isValidSlp') + }) + }) + + describe('#getAddress', async () => { + it('should throw 400 error if address is missing', async () => { + const result = await uut.getAddress(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error', 'success']) + assert.isFalse(result.success) + assert.include(result.error, 'address can not be empty') + }) + it('should throw 400 error if address is invalid format', async () => { + req.body.address = 'address' + + const result = await uut.getAddress(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error', 'success']) + assert.isFalse(result.success) + assert.include(result.error, 'Invalid BCH address. Double check your address is valid') + }) + it('should throw 400 error for invalid network address', async () => { + req.body.address = 'bchtest:qq89kjkeqz9mngp8kl3dpmu43y2wztdjqu500gn4c4' + + const result = await uut.getAddress(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error', 'success']) + assert.isFalse(result.success) + assert.include(result.error, 'Invalid network. Trying to use a testnet address on mainnet, or vice versa.') + }) + + it('should throw 503 when network issues', async () => { + req.body.address = 'bitcoincash:qzmd5vxgh9m22m6fgvm57yd6kjnjl9qnwywsf3583n' + + // 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.getAddress(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 () => { + req.body.address = 'bitcoincash:qzmd5vxgh9m22m6fgvm57yd6kjnjl9qnwywsf3583n' + + // Mock the timeout error. + sandbox.stub(uut.axios, 'post').throws({ code: 'ECONNABORTED' }) + + const result = await uut.getAddress(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.address = 'bitcoincash:qzmd5vxgh9m22m6fgvm57yd6kjnjl9qnwywsf3583n' + + // Mock the timeout error. + sandbox.stub(uut.axios, 'post').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.getAddress(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 address data', async function () { + req.body.address = 'bitcoincash:qzmd5vxgh9m22m6fgvm57yd6kjnjl9qnwywsf3583n' + + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'post') + .resolves({ data: mockData.balance }) + } else { + return this.skip() + } + + const result = await uut.getAddress(req, res) + const balance = result.balance + assert.property(balance, 'utxos') + assert.property(balance, 'txs') + assert.property(balance, 'balances') + assert.isArray(balance.utxos) + assert.isArray(balance.txs) + assert.isArray(balance.balances) + }) + }) + + describe('#getStatus', 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.getStatus(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, 'get').throws({ code: 'ECONNABORTED' }) + + const result = await uut.getStatus(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, 'get').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.getStatus(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 indexer status', async function () { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox + .stub(uut.axios, 'get') + .resolves({ data: mockData.status }) + } else { + return this.skip() + } + + const result = await uut.getStatus(req, res) + const status = result.status + assert.property(status, 'startBlockHeight') + assert.property(status, 'syncedBlockHeight') + assert.property(status, 'chainBlockHeight') + }) + }) + 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.equal(res.statusCode, 400, 'HTTP status code 400 expected.') + assert.property(result, 'error') + }) + it('should handle unknow errors', () => { + sandbox.stub(uut.routeUtils, 'decodeError').returns({ msg: false }) + + const result = uut.errorHandler({}, res) + // console.log('result: ', result) + assert.equal(res.statusCode, 500, 'HTTP status code 500 expected.') + assert.property(result, 'error') + }) + }) + describe('#checkEnvVar', () => { + it('should throw errors if SLP_INDEXER_API env var is not provided', () => { + const savedSlpIndexerUrl = process.env.SLP_INDEXER_API + try { + process.env.SLP_INDEXER_API = '' + uut.checkEnvVar() + assert.fail('unexpected code path') + } catch (error) { + assert.include( + error.message, + 'SLP_INDEXER_API env var not set. Can not connect to PSF SLP indexer.', + 'Error message expected' + ) + } + process.env.SLP_INDEXER_API = savedSlpIndexerUrl + }) + }) })