diff --git a/package.json b/package.json index d2e68f3..b928f09 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "lint": "standard --env mocha --fix", "test-v3": "export NETWORK=mainnet && nyc --reporter=text mocha --timeout 60000 test/v3/", "test:temp:integration": "export NETWORK=mainnet && export TEST=integration && mocha --timeout 25000 test/v3/integration/price.js", - "test:temp:unit": "export NETWORK=mainnet && mocha -g '#validateSingle' --exit test/v3/", + "test:temp:unit": "export NETWORK=mainnet && mocha -g '#validate3Bulk' --exit test/v3/", "test:integration": "mocha test/v3/integration", "test:integration:slpdb": "mocha --timeout 25000 -g '#validate2Single' test/v3/integration/slp*.js", "coverage": "nyc report --reporter=text-lcov | coveralls", diff --git a/src/routes/v3/slp.js b/src/routes/v3/slp.js index 94a176c..0a85637 100644 --- a/src/routes/v3/slp.js +++ b/src/routes/v3/slp.js @@ -75,6 +75,7 @@ class Slp { _this.router.get('/validateTxid/:txid', _this.validateSingle) _this.router.get('/validateTxid2/:txid', _this.validate2Single) _this.router.get('/validateTxid3/:txid', _this.validate3Single) + _this.router.post('/validateTxid3', _this.validate3Bulk) _this.router.get('/whitelist', _this.getSlpWhitelist) _this.router.get('/txDetails/:txid', _this.txDetails) _this.router.get('/tokenStats/:tokenId', _this.tokenStats) @@ -1340,6 +1341,129 @@ class Slp { } } + /** + * @api {post} /slp/validateTxid3/ Validate multiple SLP transactions by txid. + * @apiName Validate multiple SLP transactions by txid. + * @apiGroup SLP + * @apiDescription Alternative validation for tokens on the whitelist + * This endpoint is exactly the same as /slp/validateTxid but it uses + * a different SLPDB. This server only indexes the SLP tokens that are on the + * whitelist. You can see which tokens are on the whitelist by calling the + * /slp/whitelist endpoint. + * + * @apiExample Example usage: + * curl -X POST "https://api.fullstack.cash/v3/slp/validateTxid3" -H "accept:application/json" -H "Content-Type: application/json" -d '{"txids":["f7e5199ef6669ad4d078093b3ad56e355b6ab84567e59ad0f08a5ad0244f783a","fb0eeaa501a6e1acb721669c62a3f70741f48ae0fd7f4b8e1d72088785c51952"]}' + * + * + */ + async validate3Bulk (req, res, next) { + try { + const txids = req.body.txids + + // Reject if txids is not an array. + if (!Array.isArray(txids)) { + res.status(400) + return res.json({ error: 'txids needs to be an array' }) + } + + // Enforce array size rate limits + if (!_this.routeUtils.validateArraySize(req, txids)) { + res.status(429) // https://github.com/Bitcoin-com/rest.bitcoin.com/issues/330 + return res.json({ + error: 'Array too large.' + }) + } + + wlogger.debug('Executing slp/validate with these txids: ', txids) + + const query = { + v: 3, + q: { + db: ['c', 'u'], + find: { + 'tx.h': { $in: txids } + }, + limit: 300, + project: { 'slp.valid': 1, 'tx.h': 1, 'slp.invalidReason': 1 } + } + } + const s = JSON.stringify(query) + const b64 = Buffer.from(s).toString('base64') + const url = `${process.env.SLPDB_WHITELIST_URL}q/${b64}` + + const options = _this.generateCredentials() + + // Get data from SLPDB. + const opt = { + method: 'get', + baseURL: url, + headers: options.headers, + timeout: options.timeout + } + const tokenRes = await _this.axios.request(opt) + // console.log(`tokenRes.data: ${JSON.stringify(tokenRes.data, null, 2)}`) + + let formattedTokens = [] + + // Combine the arrays. Why? Generally there is nothing in the u array. + const concatArray = tokenRes.data.c.concat(tokenRes.data.u) + + const tokenIds = [] + if (concatArray.length > 0) { + concatArray.forEach((token) => { + tokenIds.push(token.tx.h) // txid + + const validationResult = { + txid: token.tx.h, + valid: token.slp.valid + } + + // If the txid is invalid, add the reason it's invalid. + if (!validationResult.valid) { + validationResult.invalidReason = token.slp.invalidReason + } + + formattedTokens.push(validationResult) + }) + + // If a user-provided txid doesn't exist in the data, add it with + // valid:false property. + txids.forEach((txid) => { + if (!tokenIds.includes(txid)) { + formattedTokens.push({ + txid: txid, + valid: false + }) + } + }) + } + + // Catch a corner case of repeated txids. SLPDB will remove redundent TXIDs, + // which will cause the output array to be smaller than the input array. + if (txids.length > formattedTokens.length) { + const newOutput = [] + for (let i = 0; i < txids.length; i++) { + const thisTxid = txids[i] + + // Find the element that matches the current txid. + const elem = formattedTokens.filter((x) => x.txid === thisTxid) + + newOutput.push(elem[0]) + } + + // Replace the original output object with the new output object. + formattedTokens = newOutput + } + + res.status(200) + return res.json(formattedTokens) + } catch (err) { + wlogger.error('Error in slp.ts/validateBulk().', err) + + return _this.errorHandler(err, res) + } + } + /** * @api {get} /slp/txDetails/{txid} SLP transaction details. * @apiName SLP transaction details. diff --git a/test/v3/mocks/slp-mocks.js b/test/v3/mocks/slp-mocks.js index 198e9b7..736c2f2 100644 --- a/test/v3/mocks/slp-mocks.js +++ b/test/v3/mocks/slp-mocks.js @@ -341,6 +341,22 @@ const mockTwoRedundentTxid = { u: [] } +const mockPsfToken = { + c: [ + { + _id: '5fcaf6152898f9887902986c', + tx: { + h: 'cdfed769ef7b69e09be06d2821b88598d9a5d711b8f9bd369763c78b7a578fbc' + }, + slp: { + valid: true, + invalidReason: null + } + } + ], + u: [] +} + const mockTxHistory = [ { tx: { @@ -541,5 +557,6 @@ module.exports = { mockSingleValidTxid, mockTwoValidTxid, mockTwoRedundentTxid, - mockTxHistory + mockTxHistory, + mockPsfToken } diff --git a/test/v3/slp.js b/test/v3/slp.js index c5f57f2..9f6d6b7 100644 --- a/test/v3/slp.js +++ b/test/v3/slp.js @@ -687,6 +687,131 @@ describe('#SLP', () => { }) }) + describe('#validate3Bulk', () => { + const validate3Bulk = slpRoute.validate3Bulk + + it('should throw 400 if txid array is empty', async () => { + const result = await validate3Bulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'txids needs to be an array') + assert.equal(res.statusCode, 400) + }) + + it('should throw 400 error if array is too large', async () => { + const testArray = [] + for (var i = 0; i < 25; i++) testArray.push('') + + req.body.txids = testArray + + const result = await validate3Bulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error']) + assert.include(result.error, 'Array too large') + }) + + it('returns proper error when downstream service stalls', async () => { + // Mock the timeout error. + sandbox.stub(slpRoute.axios, 'request').throws({ code: 'ECONNABORTED' }) + + req.body.txids = [ + '77872738b6bddee6c0cbdb9509603de20b15d4f6b26602f629417aec2f5d5e8d' + ] + const result = await validate3Bulk(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(slpRoute.axios, 'request').throws({ code: 'ECONNREFUSED' }) + + req.body.txids = [ + '77872738b6bddee6c0cbdb9509603de20b15d4f6b26602f629417aec2f5d5e8d' + ] + const result = await validate3Bulk(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 validate array with single element', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox.stub(slpRoute.axios, 'request').resolves({ + data: mockData.mockPsfToken + }) + } + + req.body.txids = [ + 'cdfed769ef7b69e09be06d2821b88598d9a5d711b8f9bd369763c78b7a578fbc' + ] + + const result = await validate3Bulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.hasAllKeys(result[0], ['txid', 'valid']) + }) + + it('should validate array with two elements', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox.stub(slpRoute.axios, 'request').resolves({ + data: mockData.mockPsfToken + }) + } + + req.body.txids = [ + 'cdfed769ef7b69e09be06d2821b88598d9a5d711b8f9bd369763c78b7a578fbc', + 'cdfed769ef7b69e09be06d2821b88598d9a5d711b8f9bd369763c78b7a578fbc' + ] + + const result = await validate3Bulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.hasAllKeys(result[0], ['txid', 'valid']) + assert.equal(result.length, 2) + }) + + // Captures a regression bug that went out to production, captured in this + // GitHub Issue: https://github.com/Bitcoin-com/rest.bitcoin.com/issues/518 + it('should return two elements if given two elements', async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox.stub(slpRoute.axios, 'request').resolves({ + data: mockData.mockPsfToken + }) + } + + req.body.txids = [ + 'cdfed769ef7b69e09be06d2821b88598d9a5d711b8f9bd369763c78b7a578fbc', + 'cdfed769ef7b69e09be06d2821b88598d9a5d711b8f9bd369763c78b7a578fbc' + ] + + const result = await validate3Bulk(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.isArray(result) + assert.hasAllKeys(result[0], ['txid', 'valid']) + assert.equal(result.length, 2) + }) + }) + describe('tokenStats()', () => { it('should throw 400 if tokenID is empty', async () => { req.params.tokenId = ''