diff --git a/package-lock.json b/package-lock.json index 376097e..78c9689 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bch-api", - "version": "1.16.0", + "version": "2.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "bch-api", - "version": "1.16.0", + "version": "2.0.0", "license": "MIT", "dependencies": { "@psf/bch-js": "^4.20.7", diff --git a/src/routes/v5/psf-slp-indexer.js b/src/routes/v5/psf-slp-indexer.js index 491a55f..e990100 100644 --- a/src/routes/v5/psf-slp-indexer.js +++ b/src/routes/v5/psf-slp-indexer.js @@ -35,6 +35,7 @@ class PsfSlpIndexer { this.router.post('/address', this.getAddress) this.router.post('/txid', this.getTxid) this.router.post('/token', this.getTokenStats) + this.router.post('/token/data', this.getTokenData) _this = this } @@ -224,6 +225,188 @@ class PsfSlpIndexer { } } + // Get mutable and immutable data for a token, if the token was created with + // such data. + // + // Example: + // curl -H "Content-Type: application/json" -X POST -d '{ "tokenId": "afca62f07560b4c72c2ed6c9c3995315f964ccdfc37dded316d182640b42d88a" }' localhost:3000/v5/psf/slp/token/data + async getTokenData (req, res, next) { + try { + // Verify env var is set for interacting with the indexer. + _this.checkEnvVar() + const tokenData = {} + + const tokenId = req.body.tokenId + if (!tokenId || tokenId === '') { + res.status(400) + return res.json({ + success: false, + error: 'tokenId can not be empty' + }) + } + + // get token stats from the Genesis TX of the token. + const withTxHistory = false + const response = await _this.axios.post( + `${_this.psfSlpIndexerApi}slp/token/`, + { tokenId, withTxHistory } + ) + // console.log('response', response.data) + + const tokenStats = response.data.tokenData + + tokenData.genesisData = tokenStats + + // try to get immutable data + try { + const immutableData = await _this.getCIDData(tokenStats.documentUri) + tokenData.immutableData = immutableData + } catch (error) { + tokenData.immutableData = '' + } + + // try to get mutable data + try { + const mutableData = await _this.getMutableData(tokenStats.documentHash) + tokenData.mutableData = mutableData + } catch (error) { + tokenData.mutableData = '' + } + + res.status(200) + return res.json(tokenData) + } catch (err) { + return _this.errorHandler(err, res) + } + } + + // Retrieves the mutable data associated with a token document hash. + async getMutableData (documentHash) { + try { + if (!documentHash || typeof documentHash !== 'string') { + throw new Error( + 'documentHash string required when calling mutableData().' + ) + } + + // Get the OP_RETURN data and decode it. + const mutableData = await _this.decodeOpReturn(documentHash) + const jsonData = JSON.parse(mutableData) + + const mspAddress = jsonData.mspAddress + + // Gets the mutable data address (MDA) transaction history. + const transactions = await _this.bchjs.Electrumx.transactions(mspAddress) + + const mspTxs = transactions.transactions + // console.log(`mspTxs: ${JSON.stringify(mspTxs, null, 2)}`) + + let data = false + // Maps each transaction of the mspAddress + // if finds an OP_RETURN decode is and closes the loop + // Start with the newest TXID entry and scan the history to find the first + // entry with an IPFS CID. + for (let i = mspTxs.length - 1; i > -1; i--) { + const tx = mspTxs[i] + const txid = tx.tx_hash + console.log(`Retrieving and decoding txid ${txid}`) + + data = await _this.decodeOpReturn(txid) + // console.log('data: ', data) + + // Try parse the OP_RETURN data to a JSON object. + if (data) { + try { + // console.log('Mutable Data : ', data) + + // Convert the OP_RETURN data to a JSON object. + const obj = JSON.parse(data) + console.log(`obj: ${JSON.stringify(obj, null, 2)}`) + + // Keep searching if this TX does not have a cid value. + if (!obj.cid) continue + + // TODO: Ensure data was generated by the MSP address and not an + // update from a different address. + + break + } catch (error) { + continue + } + } + } + if (!data) { + console.log('Mutable Data Not found ') + } + + // Get the CID: + const obj = JSON.parse(data) + const cid = obj.cid + if (!cid) { + throw new Error('CID could not be found in OP_RETURN data') + } + + const result = await _this.getCIDData(cid) + // console.log(`response.data: ${JSON.stringify(response.data, null, 2)}`) + + return result + } catch (err) { + console.log('Error in getMutableData().') + throw err + } + } + + // Decodes the OP_RETURN of a transaction if this exists + async decodeOpReturn (txid) { + try { + if (!txid || typeof txid !== 'string') { + throw new Error('txid must be a string.') + } + + // get transaction data + const txData = await _this.bchjs.Electrumx.txData(txid) + let data = false + + // Maps the vout of the transaction in search of an OP_RETURN + for (let i = 0; i < txData.details.vout.length; i++) { + const vout = txData.details.vout[i] + + const script = _this.bchjs.Script.toASM( + Buffer.from(vout.scriptPubKey.hex, 'hex') + ).split(' ') + + // Exit on the first OP_RETURN found. + if (script[0] === 'OP_RETURN') { + data = Buffer.from(script[1], 'hex').toString('ascii') + break + } + } + + return data + } catch (error) { + console.log('Error in decodeOpReturn().') + throw error + } + } + + // Get the immutable data stored in the documentUrl field of the token. + async getCIDData (cid) { + try { + if (!cid || typeof cid !== 'string') { + throw new Error('cid must be a string.') + } + const dataUrl = `https://${cid}.ipfs.dweb.link/data.json` + + const response = await _this.axios.get(dataUrl) + // console.log(`response.data: ${JSON.stringify(response.data, null, 2)}`) + + return response.data + } catch (error) { + console.log('Error in getCIDData().') + throw error + } + } + // Check the the environment variable is set correctly. checkEnvVar () { _this.psfSlpIndexerApi = process.env.SLP_INDEXER_API diff --git a/test/v5/mocks/psf-slp-indexer-mocks.js b/test/v5/mocks/psf-slp-indexer-mocks.js index 76c830b..b5cd757 100644 --- a/test/v5/mocks/psf-slp-indexer-mocks.js +++ b/test/v5/mocks/psf-slp-indexer-mocks.js @@ -114,6 +114,30 @@ const balance = { ] } } +const transactions = { + success: true, + transactions: [ + { + height: 734439, + tx_hash: '0bd2a8a72108659cd39a59bde89c45fff7d51c334531f036b1e102ab4b62f33f' + }, + { + height: 734441, + tx_hash: '4f2837b7fff325c0442b550863de3470e016df234561d9151cbb6949fe43ac17' + }, + { + height: 735564, + tx_hash: '1bfa83355839c0ef0f974463415fa983466e0286d7bcdfdd825f244e357ad1e5' + }, + { + height: 735564, + tx_hash: '6eba09627175af4b50ac75fa8b3a15a9015df8a19b5b28a992f26044f4fd8891' + }, + { + height: 735564, + tx_hash: 'c37ba29f40ecc61662ea56324fdb72a5f1e66add2078854c2144765b9030358a' + }] +} const status = { status: { @@ -122,9 +146,26 @@ const status = { chainBlockHeight: 722679 } } +const immutableData = { + payloadCid: 'QmY3EaRaUcc5bNuqDfc7TaeNPThBGUefbqJeJVDjCqxqFZ', + about: 'This is a placeholder' +} +const mutableData = { + tokenIcon: 'https://gateway.ipfs.io/ipfs/bafybeiehitanirn5gmhqjg44xrmdtomn4n5lu5yjoepsvgpswk5mggaw6i/LP_logo-1.png', + about: 'Mutable data managed with npm package: https://www.npmjs.com/package/slp-mutable-data' +} + +const decodedOpReturn = JSON.stringify({ + mspAdddress: 'bitcoincash:qrg77j4jf2pl7azgvzrz2z567ls464gkuuhplt30dp', + cid: 'bafybeie6t5uyupddc7azms737xg4hxrj7i5t5ov3lb5g2qeehaujj6ak64' +}) module.exports = { tokenStats, txData, balance, - status + status, + transactions, + immutableData, + mutableData, + decodedOpReturn } diff --git a/test/v5/psf-slp-indexer.js b/test/v5/psf-slp-indexer.js index eca0d68..b2b2613 100644 --- a/test/v5/psf-slp-indexer.js +++ b/test/v5/psf-slp-indexer.js @@ -505,4 +505,340 @@ describe('#PsfSlpIndexer', () => { process.env.SLP_INDEXER_API = savedSlpIndexerUrl }) }) + describe('#getCIDData', () => { + it('should throw errors if cid is not provided', async () => { + try { + await uut.getCIDData() + assert.fail('unexpected code path') + } catch (error) { + assert.include( + error.message, + 'cid must be a string.', + 'Error message expected' + ) + } + }) + it('should handle axios error', async () => { + try { + sandbox.stub(uut.axios, 'get').throws(new Error('test error')) + const cid = 'bafybeigp3bfmj6woms7pywb7s7r6npcdudvsabvzne2chyspxtdendrwmy' + await uut.getCIDData(cid) + assert.fail('unexpected code path') + } catch (error) { + assert.include( + error.message, + 'test error', + 'Error message expected' + ) + } + }) + it('should return cid object data', async () => { + sandbox.stub(uut.axios, 'get').resolves({ data: mockData.immutableData }) + const cid = 'bafybeigp3bfmj6woms7pywb7s7r6npcdudvsabvzne2chyspxtdendrwmy' + const result = await uut.getCIDData(cid) + assert.isObject(result) + }) + }) + + describe('#decodeOpReturn', () => { + it('should throw errors if txid is not provided', async () => { + try { + await uut.decodeOpReturn() + assert.fail('unexpected code path') + } catch (error) { + assert.include( + error.message, + 'txid must be a string.', + 'Error message expected' + ) + } + }) + it('should handle bchjs error', async () => { + try { + sandbox.stub(uut.bchjs.Electrumx, 'txData').throws(new Error('test error')) + const txid = 'c37ba29f40ecc61662ea56324fdb72a5f1e66add2078854c2144765b9030358a' + await uut.decodeOpReturn(txid) + assert.fail('unexpected code path') + } catch (error) { + assert.include( + error.message, + 'test error', + 'Error message expected' + ) + } + }) + it('should return data', async () => { + sandbox.stub(uut.bchjs.Electrumx, 'txData').resolves({ details: mockData.txData.txData }) + const txid = 'c37ba29f40ecc61662ea56324fdb72a5f1e66add2078854c2144765b9030358a' + const result = await uut.decodeOpReturn(txid) + assert.isString(result) + }) + it('should return false if data is not found', async () => { + sandbox.stub(uut.bchjs.Electrumx, 'txData').resolves({ details: { vout: [] } }) + const txid = 'c37ba29f40ecc61662ea56324fdb72a5f1e66add2078854c2144765b9030358a' + const result = await uut.decodeOpReturn(txid) + assert.isFalse(result) + }) + }) + describe('#getTokenData', async () => { + it('should throw 400 error if tokenId is missing', async () => { + const result = await uut.getTokenData(req, res) + // console.log(`result: ${util.inspect(result)}`) + + assert.hasAllKeys(result, ['error', 'success']) + assert.isFalse(result.success) + assert.include(result.error, 'tokenId can not be empty') + }) + + it('should throw 503 when network issues', async () => { + req.body.tokenId = + 'a4fb5c2da1aa064e25018a43f9165040071d9e984ba190c222a7f59053af84b2' + + // Save the existing RPC URL. + const savedUrl2 = process.env.SLP_INDEXER_API + + // Manipulate the URL to cause a 500 network error. + process.env.SLP_INDEXER_API = 'http://fakeurl/api/' + + await uut.getTokenData(req, res) + // console.log(`result: ${util.inspect(result)}`) + + // Restore the saved URL. + process.env.SLP_INDEXER_API = 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.tokenId = + 'a4fb5c2da1aa064e25018a43f9165040071d9e984ba190c222a7f59053af84b2' + + // Mock the timeout error. + sandbox.stub(uut.axios, 'post').throws({ code: 'ECONNABORTED' }) + + const result = await uut.getTokenData(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.tokenId = + 'a4fb5c2da1aa064e25018a43f9165040071d9e984ba190c222a7f59053af84b2' + + // Mock the timeout error. + sandbox.stub(uut.axios, 'post').throws({ code: 'ECONNREFUSED' }) + + const result = await uut.getTokenData(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 tokens data', async function () { + req.body.tokenId = + 'a4fb5c2da1aa064e25018a43f9165040071d9e984ba190c222a7f59053af84b2' + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox.stub(uut.axios, 'post').resolves({ data: mockData.tokenStats }) + sandbox.stub(uut, 'getCIDData').resolves(mockData.immutableData) + sandbox.stub(uut, 'getMutableData').resolves(mockData.mutableData) + } else { + return this.skip() + } + + const result = await uut.getTokenData(req, res) + assert.property(result, 'genesisData') + assert.property(result, 'immutableData') + assert.property(result, 'mutableData') + + assert.isObject(result.genesisData) + assert.isObject(result.immutableData) + assert.isObject(result.mutableData) + + const genesisData = result.genesisData + assert.property(genesisData, 'ticker') + assert.property(genesisData, 'name') + assert.property(genesisData, 'type') + assert.property(genesisData, 'tokenId') + assert.property(genesisData, 'documentUri') + assert.property(genesisData, 'documentHash') + assert.property(genesisData, 'decimals') + assert.property(genesisData, 'mintBatonIsActive') + assert.property(genesisData, 'tokensInCirculationBN') + assert.property(genesisData, 'tokensInCirculationStr') + assert.property(genesisData, 'blockCreated') + assert.property(genesisData, 'totalBurned') + assert.property(genesisData, 'totalMinted') + assert.property(genesisData, 'txs') + }) + it('should GET tokens data if immutableData data not found', async function () { + req.body.tokenId = + 'a4fb5c2da1aa064e25018a43f9165040071d9e984ba190c222a7f59053af84b2' + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox.stub(uut.axios, 'post').resolves({ data: mockData.tokenStats }) + sandbox.stub(uut, 'getCIDData').throws(new Error('test error')) + sandbox.stub(uut, 'getMutableData').resolves(mockData.mutableData) + } else { + return this.skip() + } + + const result = await uut.getTokenData(req, res) + assert.property(result, 'genesisData') + assert.property(result, 'immutableData') + assert.property(result, 'mutableData') + + assert.isObject(result.genesisData) + assert.isObject(result.mutableData) + assert.equal(result.immutableData, '') + + const genesisData = result.genesisData + assert.property(genesisData, 'ticker') + assert.property(genesisData, 'name') + assert.property(genesisData, 'type') + assert.property(genesisData, 'tokenId') + assert.property(genesisData, 'documentUri') + assert.property(genesisData, 'documentHash') + assert.property(genesisData, 'decimals') + assert.property(genesisData, 'mintBatonIsActive') + assert.property(genesisData, 'tokensInCirculationBN') + assert.property(genesisData, 'tokensInCirculationStr') + assert.property(genesisData, 'blockCreated') + assert.property(genesisData, 'totalBurned') + assert.property(genesisData, 'totalMinted') + assert.property(genesisData, 'txs') + }) + it('should GET tokens data if mutable data not found', async function () { + req.body.tokenId = + 'a4fb5c2da1aa064e25018a43f9165040071d9e984ba190c222a7f59053af84b2' + // Mock the RPC call for unit tests. + if (process.env.TEST === 'unit') { + sandbox.stub(uut.axios, 'post').resolves({ data: mockData.tokenStats }) + sandbox.stub(uut, 'getCIDData').resolves(mockData.immutableData) + sandbox.stub(uut, 'getMutableData').throws(new Error('test error')) + } else { + return this.skip() + } + + const result = await uut.getTokenData(req, res) + assert.property(result, 'genesisData') + assert.property(result, 'immutableData') + assert.property(result, 'mutableData') + + assert.isObject(result.genesisData) + assert.isObject(result.immutableData) + assert.equal(result.mutableData, '') + + const genesisData = result.genesisData + assert.property(genesisData, 'ticker') + assert.property(genesisData, 'name') + assert.property(genesisData, 'type') + assert.property(genesisData, 'tokenId') + assert.property(genesisData, 'documentUri') + assert.property(genesisData, 'documentHash') + assert.property(genesisData, 'decimals') + assert.property(genesisData, 'mintBatonIsActive') + assert.property(genesisData, 'tokensInCirculationBN') + assert.property(genesisData, 'tokensInCirculationStr') + assert.property(genesisData, 'blockCreated') + assert.property(genesisData, 'totalBurned') + assert.property(genesisData, 'totalMinted') + assert.property(genesisData, 'txs') + }) + }) + describe('#getMutableData', () => { + it('should throw errors if documentHash is not provided', async () => { + try { + await uut.getMutableData() + assert.fail('unexpected code path') + } catch (error) { + assert.include( + error.message, + 'documentHash string required when calling mutableData().', + 'Error message expected' + ) + } + }) + it('should throw errors if cid is not provided in OP Return', async () => { + try { + sandbox.stub(uut, 'decodeOpReturn').resolves('{}') + sandbox.stub(uut.bchjs.Electrumx, 'transactions').resolves(mockData.transactions) + sandbox.stub(uut, 'getCIDData').resolves(mockData.mutableData) + + const documentHash = 'c37ba29f40ecc61662ea56324fdb72a5f1e66add2078854c2144765b9030358a' + await uut.getMutableData(documentHash) + assert.fail('unexpected code path') + } catch (error) { + assert.include( + error.message, + 'CID could not be found in OP_RETURN data', + 'Error message expected' + ) + } + }) + + it('should throw errors if data is not found', async () => { + try { + sandbox.stub(uut, 'decodeOpReturn').resolves(mockData.decodedOpReturn) + sandbox.stub(uut.bchjs.Electrumx, 'transactions').resolves({ transactions: [] }) + sandbox.stub(uut, 'getCIDData').resolves(mockData.mutableData) + + const documentHash = 'c37ba29f40ecc61662ea56324fdb72a5f1e66add2078854c2144765b9030358a' + await uut.getMutableData(documentHash) + assert.fail('unexpected code path') + } catch (error) { + assert.include( + error.message, + 'CID could not be found in OP_RETURN data', + 'Error message expected' + ) + } + }) + it('should handle bchjs error', async () => { + try { + sandbox.stub(uut, 'decodeOpReturn').resolves(mockData.decodedOpReturn) + sandbox.stub(uut.bchjs.Electrumx, 'transactions').throws(new Error('test error')) + + const documentHash = 'c37ba29f40ecc61662ea56324fdb72a5f1e66add2078854c2144765b9030358a' + await uut.getMutableData(documentHash) + assert.fail('unexpected code path') + } catch (error) { + assert.include( + error.message, + 'test error', + 'Error message expected' + ) + } + }) + it('should return mutable data', async () => { + sandbox.stub(uut, 'decodeOpReturn') + .onFirstCall().resolves(mockData.decodedOpReturn) + .onSecondCall().resolves(JSON.parse(mockData.decodedOpReturn)) // data to force an error while parsing the JSON + .onThirdCall().resolves(mockData.decodedOpReturn) + + sandbox.stub(uut.bchjs.Electrumx, 'transactions').resolves(mockData.transactions) + sandbox.stub(uut, 'getCIDData').resolves(mockData.mutableData) + + const documentHash = 'c37ba29f40ecc61662ea56324fdb72a5f1e66add2078854c2144765b9030358a' + + const result = await uut.getMutableData(documentHash) + assert.isObject(result) + }) + }) }) diff --git a/test/v5/rate-limit-unit.js b/test/v5/rate-limit-unit.js index ab9a0e6..55a1298 100644 --- a/test/v5/rate-limit-unit.js +++ b/test/v5/rate-limit-unit.js @@ -326,7 +326,7 @@ describe('#rate-routelimit', () => { assert.property(val, 'error') assert.include( val.error, - 'Too many requests. Your limits are currently 10 requests per minute.' + 'Too many requests. Your limits are currently' ) assert.equal(res.locals.rateLimitTriggered, true, 'Rate limits triggered')