feat(endpoint): Added getTokenData() method to bch-api

This commit is contained in:
Daniel Gonzalez
2022-04-15 18:25:12 -04:00
parent 88231cb303
commit dbc4e3e731
4 changed files with 551 additions and 3 deletions
+2 -2
View File
@@ -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",
+171
View File
@@ -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,176 @@ class PsfSlpIndexer {
}
}
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
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().'
)
}
// Gets the OP_RETURN data and decodes it
const mutableData = await _this.decodeOpReturn(documentHash)
const jsonData = JSON.parse(mutableData)
const mspAddress = jsonData.mspAddress
// Gets the mspAddress transactions
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(' ')
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
}
}
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
+42 -1
View File
@@ -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
}
+336
View File
@@ -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)
})
})
})