diff --git a/package-lock.json b/package-lock.json index 37b5fda..b278ecd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10130,6 +10130,22 @@ "is-fullwidth-code-point": "^2.0.0" } }, + "slp-mdm": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/slp-mdm/-/slp-mdm-0.0.6.tgz", + "integrity": "sha512-fbjlIg/o8OtzgK2JydC6POJp3Qup/rLgy4yB5hoLgxWRlERyJyE29ScwS3r9TTwPxe12qK55pyivAdNOZZXL0A==", + "requires": { + "bignumber.js": "^9.0.0" + } + }, + "slp-parser": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/slp-parser/-/slp-parser-0.0.4.tgz", + "integrity": "sha512-AvbslJumkzGfMGWNvuE2pWx2nyHEk/VgQ7l119kDKIFRTuRUWOkyOULLauw5laGRQsBRThg6NCx/TsR3grX6GA==", + "requires": { + "bignumber.js": "^9.0.0" + } + }, "socket.io": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-2.3.0.tgz", diff --git a/package.json b/package.json index d36109b..d955b6b 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,8 @@ "repl.history": "^0.1.4", "safe-buffer": "^5.1.2", "satoshi-bitcoin": "^1.0.4", + "slp-mdm": "0.0.6", + "slp-parser": "0.0.4", "socket.io": "^2.1.1", "socket.io-client": "^2.1.1", "touch": "^3.1.0", diff --git a/src/slp/utils.js b/src/slp/utils.js index ea5bc86..f1f2575 100644 --- a/src/slp/utils.js +++ b/src/slp/utils.js @@ -1,4 +1,5 @@ const axios = require("axios") +const slpParser = require("slp-parser") const Script = require("../script") const scriptLib = new Script() @@ -11,6 +12,7 @@ class Utils { constructor(config) { this.restURL = config.restURL this.apiToken = config.apiToken + this.slpParser = slpParser // Add JWT token to the authorization header. this.axiosOptions = { @@ -939,6 +941,78 @@ class Utils { } } + /** + * @api SLP.Utils.decodeOpReturn2() decodeOpReturn2() - Read the OP_RETURN data from an SLP transaction. + * @apiName decodeOpReturn2 + * @apiGroup SLP + * @apiDescription Retrieves transactions data from a txid and decodes the SLP OP_RETURN data. + * + * Similar to decodeOpReturn(), except decodeOpReturn2() uses the slp-parser + * library maintained by JT Freeman. Outputs have slightly different format. + * + * Throws an error if given a non-SLP txid. + * + */ + // Reimplementation of decodeOpReturn() using slp-parser. + async decodeOpReturn2(txid) { + try { + // Validate the txid input. + if (!txid || txid === "" || typeof txid !== "string") + throw new Error(`txid string must be included.`) + + // Retrieve the transaction object from the full node. + const path = `${this.restURL}rawtransactions/getRawTransaction/${txid}?verbose=true` + const response = await axios.get(path, _this.axiosOptions) + const txDetails = response.data + // console.log(`txDetails: ${JSON.stringify(txDetails, null, 2)}`) + + // SLP spec expects OP_RETURN to be the first output of the transaction. + const opReturn = txDetails.vout[0].scriptPubKey.hex + // console.log(`opReturn hex: ${opReturn}`) + + const parsedData = _this.slpParser.parseSLP(Buffer.from(opReturn, "hex")) + // console.log(`parsedData: ${JSON.stringify(parsedData, null, 2)}`) + + // Convert Buffer data to hex strings or utf8 strings. + let tokenData = {} + if (parsedData.transactionType === "SEND") { + tokenData = { + tokenType: parsedData.tokenType, + txType: parsedData.transactionType, + tokenId: parsedData.data.tokenId.toString("hex"), + amounts: parsedData.data.amounts + } + } else if (parsedData.transactionType === "GENESIS") { + tokenData = { + tokenType: parsedData.tokenType, + txType: parsedData.transactionType, + ticker: parsedData.data.ticker.toString(), + name: parsedData.data.name.toString(), + tokenId: txid, + documentUri: parsedData.data.documentUri.toString(), + documentHash: parsedData.data.documentHash.toString(), + decimals: parsedData.data.decimals, + mintBatonVout: parsedData.data.mintBatonVout, + qty: parsedData.data.qty + } + } else if (parsedData.transactionType === "MINT") { + tokenData = { + tokenType: parsedData.tokenType, + txType: parsedData.transactionType, + tokenId: parsedData.data.tokenId.toString("hex"), + mintBatonVout: parsedData.data.mintBatonVout, + qty: parsedData.data.qty + } + } + // console.log(`tokenData: ${JSON.stringify(tokenData, null, 2)}`) + + return tokenData + } catch (error) { + if (error.response && error.response.data) throw error.response.data + throw error + } + } + /** * @api SLP.Utils.isTokenUtxo() isTokenUtxo() - Determine if UTXO belongs to an SLP transaction. * @apiName isTokenUtxo diff --git a/test/integration/slp.js b/test/integration/slp.js index 69aacad..9f3a179 100644 --- a/test/integration/slp.js +++ b/test/integration/slp.js @@ -75,6 +75,156 @@ describe(`#SLP`, () => { "spendData" ]) }) + + it("should decode the OP_RETURN for a GENESIS txid", async () => { + const txid = + "497291b8a1dfe69c8daea50677a3d31a5ef0e9484d8bebb610dac64bbc202fb7" + + const result = await bchjs.SLP.Utils.decodeOpReturn(txid) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.hasAllKeys(result, [ + "tokenType", + "transactionType", + "ticker", + "name", + "documentUrl", + "documentHash", + "decimals", + "mintBatonVout", + "initialQty", + "tokensSentTo", + "batonHolder" + ]) + }) + + it("should decode the OP_RETURN for a MINT txid", async () => { + const txid = + "65f21bbfcd545e5eb515e38e861a9dfe2378aaa2c4e458eb9e59e4d40e38f3a4" + + const result = await bchjs.SLP.Utils.decodeOpReturn(txid) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.hasAllKeys(result, [ + "tokenType", + "transactionType", + "tokenId", + "mintBatonVout", + "batonStillExists", + "quantity", + "tokensSentTo", + "batonHolder" + ]) + }) + + it("should throw an error for a non-SLP transaction", async () => { + try { + const txid = + "3793d4906654f648e659f384c0f40b19c8f10c1e9fb72232a9b8edd61abaa1ec" + + await bchjs.SLP.Utils.decodeOpReturn(txid) + + assert.equal(true, false, "Unexpected result") + } catch (err) { + // console.log(`err: `, err) + assert.include(err.message, "Not an OP_RETURN") + } + }) + }) + + describe("#decodeOpReturn2", () => { + it("should decode the OP_RETURN for a SEND txid", async () => { + const txid = + "266844d53e46bbd7dd37134688dffea6e54d944edff27a0add63dd0908839bc1" + + const result = await bchjs.SLP.Utils.decodeOpReturn2(txid) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.hasAllKeys(result, ["tokenType", "txType", "tokenId", "amounts"]) + assert.equal( + result.tokenId, + "497291b8a1dfe69c8daea50677a3d31a5ef0e9484d8bebb610dac64bbc202fb7" + ) + + // Verify outputs + assert.equal(result.amounts.length, 2) + assert.equal(result.amounts[0], "100000000") + assert.equal(result.amounts[1], "99883300000000") + }) + + it("should decode the OP_RETURN for a GENESIS txid", async () => { + const txid = + "497291b8a1dfe69c8daea50677a3d31a5ef0e9484d8bebb610dac64bbc202fb7" + + const result = await bchjs.SLP.Utils.decodeOpReturn2(txid) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.hasAllKeys(result, [ + "tokenType", + "txType", + "tokenId", + "ticker", + "name", + "documentUri", + "documentHash", + "decimals", + "mintBatonVout", + "qty" + ]) + assert.equal( + result.tokenId, + "497291b8a1dfe69c8daea50677a3d31a5ef0e9484d8bebb610dac64bbc202fb7" + ) + assert.equal(result.txType, "GENESIS") + assert.equal(result.ticker, "TOK-CH") + assert.equal(result.name, "TokyoCash") + }) + + it("should decode the OP_RETURN for a MINT txid", async () => { + const txid = + "65f21bbfcd545e5eb515e38e861a9dfe2378aaa2c4e458eb9e59e4d40e38f3a4" + + const result = await bchjs.SLP.Utils.decodeOpReturn2(txid) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.hasAllKeys(result, [ + "tokenType", + "txType", + "tokenId", + "mintBatonVout", + "qty" + ]) + }) + + it("should throw an error for a non-SLP transaction", async () => { + try { + const txid = + "3793d4906654f648e659f384c0f40b19c8f10c1e9fb72232a9b8edd61abaa1ec" + + await bchjs.SLP.Utils.decodeOpReturn2(txid) + + assert.equal(true, false, "Unexpected result") + } catch (err) { + // console.log(`err: `, err) + assert.include(err.message, "scriptpubkey not op_return") + } + }) + + // Note: This TX is interpreted as valid by the original decodeOpReturn(). + // Fixing this issue and related issues was the reason for creating the + // decodeOpReturn2() method using the slp-parser library. + it("should throw error for invalid SLP transaction", async () => { + try { + const txid = + "a60a522cc11ad7011b74e57fbabbd99296e4b9346bcb175dcf84efb737030415" + + await bchjs.SLP.Utils.decodeOpReturn2(txid) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + } catch (err) { + // console.log(`err: `, err) + assert.include(err.message, "amount string size not 8 bytes") + } + }) }) describe("#tokenUtxoDetails", () => { diff --git a/test/unit/fixtures/slp/mock-utils.js b/test/unit/fixtures/slp/mock-utils.js index 8259fbc..ea1b233 100644 --- a/test/unit/fixtures/slp/mock-utils.js +++ b/test/unit/fixtures/slp/mock-utils.js @@ -944,6 +944,93 @@ const mockDualOpData = { ] } +const mockInvalidSlpSend = { + txid: "a60a522cc11ad7011b74e57fbabbd99296e4b9346bcb175dcf84efb737030415", + hash: "a60a522cc11ad7011b74e57fbabbd99296e4b9346bcb175dcf84efb737030415", + version: 2, + size: 473, + locktime: 0, + vin: [ + { + txid: "3ad621d46ddb7bdccb6e5e7b6505ee639d9327a2b2bdaa2937b8e3aa55c4f2a7", + vout: 0, + scriptSig: { + asm: + "3045022100f962fdc585261007f114f0470828bbe9ac6197cefc4d0897fe4a7dd1d4cb2f5402202e104b617a1c14fc293c0ace9d4fd5e1cfa819a68b4ff976b4c3c199feda5227[ALL|FORKID] 0351c450ded2d7747dcad69982c9d954f3f785bab018d89fa09723bba7397e91fb", + hex: + "483045022100f962fdc585261007f114f0470828bbe9ac6197cefc4d0897fe4a7dd1d4cb2f5402202e104b617a1c14fc293c0ace9d4fd5e1cfa819a68b4ff976b4c3c199feda522741210351c450ded2d7747dcad69982c9d954f3f785bab018d89fa09723bba7397e91fb" + }, + sequence: 4294967295 + }, + { + txid: "a31c3167686f892d835727bc46b74bb11a46d74a8a6b08dc6cd82abb6e987b43", + vout: 1, + scriptSig: { + asm: + "3044022027dcd4ef752819ba4e61b892039ea0cb8d3ab0ffaeb78384dce9cf43dd76967e0220594fdf41e3d26cd207b6df939e3d655f6e09b01f05948edeb096aa644e1b6ec4[ALL|FORKID] 0351c450ded2d7747dcad69982c9d954f3f785bab018d89fa09723bba7397e91fb", + hex: + "473044022027dcd4ef752819ba4e61b892039ea0cb8d3ab0ffaeb78384dce9cf43dd76967e0220594fdf41e3d26cd207b6df939e3d655f6e09b01f05948edeb096aa644e1b6ec441210351c450ded2d7747dcad69982c9d954f3f785bab018d89fa09723bba7397e91fb" + }, + sequence: 4294967295 + } + ], + vout: [ + { + value: 0, + n: 0, + scriptPubKey: { + asm: + "OP_RETURN 5262419 1 1145980243 091c80cee60cc3a6dd7b8c6c04cf0cf2d8103ba2d75daa105dcf1aaa53551fe8 1 fffffffffffffffe", + hex: + "6a04534c500001010453454e4420091c80cee60cc3a6dd7b8c6c04cf0cf2d8103ba2d75daa105dcf1aaa53551fe8010108fffffffffffffffe", + type: "nulldata" + } + }, + { + value: 0.00000546, + n: 1, + scriptPubKey: { + asm: + "OP_DUP OP_HASH160 c0c53d84b5420c9bfe4015d1ae69017c3f1ddef8 OP_EQUALVERIFY OP_CHECKSIG", + hex: "76a914c0c53d84b5420c9bfe4015d1ae69017c3f1ddef888ac", + reqSigs: 1, + type: "pubkeyhash", + addresses: ["bitcoincash:qrqv20vyk4pqexl7gq2artnfq97r78w7lqj5548mny"] + } + }, + { + value: 0.00000546, + n: 2, + scriptPubKey: { + asm: + "OP_DUP OP_HASH160 b505afc357a7e911b207332f13e8e674a72099c3 OP_EQUALVERIFY OP_CHECKSIG", + hex: "76a914b505afc357a7e911b207332f13e8e674a72099c388ac", + reqSigs: 1, + type: "pubkeyhash", + addresses: ["bitcoincash:qz6stt7r27n7jydjquej7ylgue62wgyecvs9zm4gff"] + } + }, + { + value: 0.00044091, + n: 3, + scriptPubKey: { + asm: + "OP_DUP OP_HASH160 b505afc357a7e911b207332f13e8e674a72099c3 OP_EQUALVERIFY OP_CHECKSIG", + hex: "76a914b505afc357a7e911b207332f13e8e674a72099c388ac", + reqSigs: 1, + type: "pubkeyhash", + addresses: ["bitcoincash:qz6stt7r27n7jydjquej7ylgue62wgyecvs9zm4gff"] + } + } + ], + hex: + "0200000002a7f2c455aae3b83729aabdb2a227939d63ee05657b5e6ecbdc7bdb6dd421d63a000000006b483045022100f962fdc585261007f114f0470828bbe9ac6197cefc4d0897fe4a7dd1d4cb2f5402202e104b617a1c14fc293c0ace9d4fd5e1cfa819a68b4ff976b4c3c199feda522741210351c450ded2d7747dcad69982c9d954f3f785bab018d89fa09723bba7397e91fbffffffff437b986ebb2ad86cdc086b8a4ad7461ab14bb746bc2757832d896f6867311ca3010000006a473044022027dcd4ef752819ba4e61b892039ea0cb8d3ab0ffaeb78384dce9cf43dd76967e0220594fdf41e3d26cd207b6df939e3d655f6e09b01f05948edeb096aa644e1b6ec441210351c450ded2d7747dcad69982c9d954f3f785bab018d89fa09723bba7397e91fbffffffff040000000000000000396a04534c500001010453454e4420091c80cee60cc3a6dd7b8c6c04cf0cf2d8103ba2d75daa105dcf1aaa53551fe8010108fffffffffffffffe22020000000000001976a914c0c53d84b5420c9bfe4015d1ae69017c3f1ddef888ac22020000000000001976a914b505afc357a7e911b207332f13e8e674a72099c388ac3bac0000000000001976a914b505afc357a7e911b207332f13e8e674a72099c388ac00000000", + blockhash: "000000000000000000915bd33a7241f34800b190f7cf51f90b42b2b05d2b7ed8", + confirmations: 90327, + time: 1535577007, + blocktime: 1535577007 +} + module.exports = { mockList, mockToken, @@ -966,5 +1053,6 @@ module.exports = { txDetailsSLPSendAlt, mockTxDetails, mockDualValidation, - mockDualOpData + mockDualOpData, + mockInvalidSlpSend } diff --git a/test/unit/slp-tokentype1.js b/test/unit/slp-tokentype1.js index 21aae9e..53f19e2 100644 --- a/test/unit/slp-tokentype1.js +++ b/test/unit/slp-tokentype1.js @@ -99,7 +99,8 @@ describe("#SLP TokenType1", () => { 5000 ) // console.log(`result: ${JSON.stringify(result, null, 2)}`) - // console.log(`result.script: `, result.script) + console.log(`result.script: `, result.script) + console.log(`result.script: `, result.script.toString("hex")) // This transaction failed due to a floating point error. This is expressed // by the script[6] being length 2 (incorrect) instead of 8 (correct). diff --git a/test/unit/slp-utils.js b/test/unit/slp-utils.js index 6083e48..115a407 100644 --- a/test/unit/slp-utils.js +++ b/test/unit/slp-utils.js @@ -525,6 +525,187 @@ describe("#SLP Utils", () => { }) }) + describe("#decodeOpReturn2", () => { + it("should throw an error for a non-string input", async () => { + try { + const txid = 53423 // Not a string. + + await slp.Utils.decodeOpReturn2(txid) + + assert2.equal(true, false, "Unexpected result.") + } catch (err) { + //console.log(`err: ${util.inspect(err)}`) + assert2.include(err.message, `txid string must be included`) + } + }) + + it("should throw an error for non-SLP transaction", async () => { + try { + // Mock the call to the REST API + if (process.env.TEST === "unit") { + sandbox + .stub(axios, "get") + .resolves({ data: mockData.nonSLPTxDetailsWithoutOpReturn }) + } + + const txid = + "3793d4906654f648e659f384c0f40b19c8f10c1e9fb72232a9b8edd61abaa1ec" + + await slp.Utils.decodeOpReturn2(txid) + + assert2.equal(true, false, "Unexpected result.") + } catch (err) { + // console.log(`err: ${util.inspect(err)}`) + assert2.include(err.message, `scriptpubkey not op_return`) + } + }) + + it("should throw an error for non-SLP transaction with OP_RETURN", async () => { + try { + // Mock the call to the REST API + if (process.env.TEST === "unit") { + sandbox + .stub(axios, "get") + .resolves({ data: mockData.nonSLPTxDetailsWithOpReturn }) + } + + const txid = + "2ff74c48a5d657cf45f699601990bffbbe7a2a516d5480674cbf6c6a4497908f" + + await slp.Utils.decodeOpReturn2(txid) + + assert2.equal(true, false, "Unexpected result.") + } catch (err) { + // console.log(`err: ${util.inspect(err)}`) + assert2.include(err.message, `SLP not in first chunk`) + } + }) + + it("should decode a genesis transaction", async () => { + // Mock the call to the REST API + if (process.env.TEST === "unit") { + sandbox + .stub(axios, "get") + .resolves({ data: mockData.txDetailsSLPGenesis }) + } + + const txid = + "bd158c564dd4ef54305b14f44f8e94c44b649f246dab14bcb42fb0d0078b8a90" + + const result = await slp.Utils.decodeOpReturn2(txid) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert2.hasAllKeys(result, [ + "tokenType", + "txType", + "tokenId", + "ticker", + "name", + "documentUri", + "documentHash", + "decimals", + "mintBatonVout", + "qty" + ]) + }) + + it("should decode a mint transaction", async () => { + // Mock the call to the REST API + if (process.env.TEST === "unit") + sandbox.stub(axios, "get").resolves({ data: mockData.txDetailsSLPMint }) + + const txid = + "65f21bbfcd545e5eb515e38e861a9dfe2378aaa2c4e458eb9e59e4d40e38f3a4" + + const result = await slp.Utils.decodeOpReturn2(txid) + //console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert2.hasAllKeys(result, [ + "tokenType", + "txType", + "tokenId", + "mintBatonVout", + "qty" + ]) + }) + + it("should decode a send transaction", async () => { + // Mock the call to the REST API + if (process.env.TEST === "unit") + sandbox.stub(axios, "get").resolves({ data: mockData.txDetailsSLPSend }) + + const txid = + "4f922565af664b6fdf0a1ba3924487344be721b3d8815c62cafc8a51e04a8afa" + + const result = await slp.Utils.decodeOpReturn2(txid) + //console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert2.hasAllKeys(result, ["tokenType", "txType", "tokenId", "amounts"]) + }) + + it("should properly decode a Genesis transaction with no minting baton", async () => { + // Mock the call to the REST API. + if (process.env.TEST === "unit") { + sandbox + .stub(axios, "get") + .resolves({ data: mockData.txDetailsSLPGenesisNoBaton }) + } + + const txid = + "497291b8a1dfe69c8daea50677a3d31a5ef0e9484d8bebb610dac64bbc202fb7" + + const data = await slp.Utils.decodeOpReturn2(txid) + // console.log(`data: ${JSON.stringify(data, null, 2)}`) + + assert2.equal(data.mintBatonVout, 0) + }) + + it("should decode a send transaction with alternate encoding", async () => { + // Mock the call to rest.bitcoin.com + if (process.env.TEST === "unit") { + sandbox + .stub(axios, "get") + .resolves({ data: mockData.txDetailsSLPSendAlt }) + } + + const txid = + "d94357179775425ebc59c93173bd6dc9854095f090a2eb9dcfe9797398bc8eae" + + const data = await slp.Utils.decodeOpReturn2(txid) + // console.log(`data: ${JSON.stringify(data, null, 2)}`) + + assert2.hasAnyKeys(data, [ + "transactionType", + "txType", + "tokenId", + "amounts" + ]) + }) + + // Note: This TX is interpreted as valid by the original decodeOpReturn(). + // Fixing this issue and related issues was the reason for creating the + // decodeOpReturn2() method using the slp-parser library. + it("should throw error for invalid SLP transaction", async () => { + try { + // Mock the call to rest.bitcoin.com + if (process.env.TEST === "unit") { + sandbox + .stub(axios, "get") + .resolves({ data: mockData.mockInvalidSlpSend }) + } + + const txid = + "a60a522cc11ad7011b74e57fbabbd99296e4b9346bcb175dcf84efb737030415" + + await slp.Utils.decodeOpReturn2(txid) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + } catch (err) { + // console.log(`err: `, err) + assert2.include(err.message, "amount string size not 8 bytes") + } + }) + }) + describe("#isTokenUtxo", () => { it("should throw error if input is not an array.", async () => { try { @@ -1277,4 +1458,14 @@ describe("#SLP Utils", () => { ]) }) }) + + // describe("#decodeOpReturn2", () => { + // it("should do something", async () => { + // const txid = + // "4f922565af664b6fdf0a1ba3924487344be721b3d8815c62cafc8a51e04a8afa" + // + // await slp.Utils.decodeOpReturn2(txid) + // // console.log(`result: ${JSON.stringify(result, null, 2)}`) + // }) + // }) })