diff --git a/package.json b/package.json index 53f36bc..0c52ac2 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,8 @@ "test:integration:local": "RESTURL=http://localhost:3000/v4/ mocha --timeout 30000 test/integration", "test:integration:local:bchn": "RESTURL=http://localhost:3000/v4/ mocha --timeout 30000 test/integration/ && mocha --timeout 30000 test/integration/chains/bchn/", "test:integration:local:testnet": "RESTURL=http://localhost:4000/v4/ mocha --timeout 30000 test/integration/testnet", - "test:temp": "export RESTURL=https://bchn.fullstack.cash/v4/ && mocha --timeout 30000 -g '#tokenUtxoDetails' test/integration/", - "test:temp2": "mocha --timeout=30000 -g '#waterfallValidateTxid' test/unit/", + "test:temp": "export RESTURL=https://bchn.fullstack.cash/v4/ && mocha --timeout 30000 -g '#tokenUtxoDetailsWL' test/integration/", + "test:temp2": "mocha --timeout=30000 -g '#tokenUtxoDetailsWL' test/unit/", "coverage": "nyc report --reporter=text-lcov | coveralls", "coverage:report": "nyc --reporter=html mocha --timeout 25000 test/unit/", "docs": "./node_modules/.bin/apidoc -i src/ -o docs", diff --git a/src/slp/utils.js b/src/slp/utils.js index ad47d57..40325e4 100644 --- a/src/slp/utils.js +++ b/src/slp/utils.js @@ -1120,6 +1120,95 @@ class Utils { } } + /** + * @api SLP.Utils.tokenUtxoDetailsWL() tokenUtxoDetailsWL() + * @apiName tokenUtxoDetailsWL + * @apiGroup SLP Utils + * @apiDescription + * + * Same as tokenUtxoDetails(), but it only uses the whitelist SLPDB to + * validate UTXOs. This will result in a lot of `isValid: null` values, + * but much more performant handling of SLP tokens. Some wallet apps prefer + * the scaling performance over the breadth of supported tokens. + * + */ + async tokenUtxoDetailsWL (utxos) { + try { + // Throw error if input is not an array. + if (!Array.isArray(utxos)) throw new Error('Input must be an array.') + + // Loop through each element in the array and validate the input before + // further processing. + for (let i = 0; i < utxos.length; i++) { + const utxo = utxos[i] + + // Ensure the UTXO has a txid or tx_hash property. + if (!utxo.txid) { + // If Electrumx, convert the tx_hash property to txid. + if (utxo.tx_hash) { + utxo.txid = utxo.tx_hash + } else { + // If there is neither a txid or tx_hash property, throw an error. + throw new Error( + `utxo ${i} does not have a txid or tx_hash property.` + ) + } + } + + // Ensure the UTXO has a vout or tx_pos property. + if (!Number.isInteger(utxo.vout)) { + if (Number.isInteger(utxo.tx_pos)) { + utxo.vout = utxo.tx_pos + } else { + throw new Error( + `utxo ${i} does not have a vout or tx_pos property.` + ) + } + } + } + + // Hydrate each UTXO with data from SLP OP_REUTRNs. + const outAry = await this._hydrateUtxo(utxos) + // console.log(`outAry: ${JSON.stringify(outAry, null, 2)}`) + + // *After* each UTXO has been hydrated with SLP data, + // validate the TXID with SLPDB. + for (let i = 0; i < outAry.length; i++) { + const utxo = outAry[i] + + // *After* the UTXO has been hydrated with SLP data, + // validate the TXID with SLPDB. + if (utxo.tokenType) { + // Only execute this block if the current UTXO has a 'tokenType' + // property. i.e. it has been successfully hydrated with SLP + // information. + + // Validate against the whitelist SLPDB. + const whitelistResult = await this.validateTxid3(utxo.txid) + // console.log( + // `whitelist-SLPDB for ${txid}: ${JSON.stringify( + // whitelistResult, + // null, + // 2 + // )}` + // ) + + let isValid = null + + // Safely retrieve the returned value. + if (whitelistResult[0] !== null) isValid = whitelistResult[0].valid + + utxo.isValid = isValid + } + } + + return outAry + } catch (error) { + if (error.response && error.response.data) throw error.response.data + throw error + } + } + // This is a private function that is called by tokenUtxoDetails(). // It loops through an array of UTXOs and tries to hydrate them with SLP // token information from the OP_RETURN data. diff --git a/test/integration/slp.js b/test/integration/slp.js index 3b03253..d77adc2 100644 --- a/test/integration/slp.js +++ b/test/integration/slp.js @@ -32,7 +32,8 @@ describe('#SLP', () => { describe('#util', () => { describe('#list', () => { it('should get information on the Spice token', async () => { - const tokenId = '4de69e374a8ed21cbddd47f2338cc0f479dc58daa2bbe11cd604ca488eca0ddf' + const tokenId = + '4de69e374a8ed21cbddd47f2338cc0f479dc58daa2bbe11cd604ca488eca0ddf' const result = await bchjs.SLP.Utils.list(tokenId) // console.log(`result: ${JSON.stringify(result, null, 2)}`) @@ -490,6 +491,116 @@ describe('#SLP', () => { }) }) + describe('#tokenUtxoDetailsWL', () => { + it('should return details for a simple SEND SLP token utxo', async () => { + const utxos = [ + { + height: 660554, + tx_hash: + '89b3f0c84efe8b01b24e2d7ac08636de5781f31dbb84478e3de868ca0a7ed93a', + tx_pos: 1, + value: 546 + } + ] + + const data = await bchjs.SLP.Utils.tokenUtxoDetailsWL(utxos) + // console.log(`data: ${JSON.stringify(data, null, 2)}`) + + assert.property(data[0], 'txid') + assert.property(data[0], 'vout') + assert.property(data[0], 'height') + assert.property(data[0], 'utxoType') + assert.property(data[0], 'tokenId') + assert.property(data[0], 'tokenTicker') + assert.property(data[0], 'tokenName') + assert.property(data[0], 'tokenDocumentUrl') + assert.property(data[0], 'tokenDocumentHash') + assert.property(data[0], 'decimals') + assert.property(data[0], 'tokenQty') + assert.property(data[0], 'isValid') + assert.equal(data[0].isValid, true) + }) + + it('should return false for BCH-only UTXOs', async () => { + const utxos = [ + { + txid: + 'a937f792c7c9eb23b4f344ce5c233d1ac0909217d0a504d71e6b1e4efb864a3b', + vout: 0, + amount: 0.00001, + satoshis: 1000, + confirmations: 0, + ts: 1578424704 + }, + { + txid: + '53fd141c2e999e080a5860887441a2c45e9cbe262027e2bd2ac998fc76e43c44', + vout: 0, + amount: 0.00001, + satoshis: 1000, + confirmations: 0, + ts: 1578424634 + } + ] + + const data = await bchjs.SLP.Utils.tokenUtxoDetailsWL(utxos) + // console.log(`data: ${JSON.stringify(data, null, 2)}`) + + assert.isArray(data) + assert.equal(data[0].isValid, false) + assert.equal(data[1].isValid, false) + }) + + it('should handle a dust attack', async () => { + // it("#dustattack", async () => { + const utxos = [ + { + height: 655965, + tx_hash: + 'a675af87dcd8d39be782737aa52e0076b52eb2f5ce355ffcb5567a64dd96b77e', + tx_pos: 151, + value: 547, + satoshis: 547, + txid: + 'a675af87dcd8d39be782737aa52e0076b52eb2f5ce355ffcb5567a64dd96b77e', + vout: 151, + address: 'bitcoincash:qq4dw3sm8qvglspy6w2qg0u2ugsy9zcfcqrpeflwww', + hdIndex: 11 + } + ] + + const data = await bchjs.SLP.Utils.tokenUtxoDetailsWL(utxos) + // console.log(`data: ${JSON.stringify(data, null, 2)}`) + + assert.equal(data[0].isValid, false) + }) + + it('should handle null SLPDB validations', async () => { + const utxos = [ + { + height: 665577, + tx_hash: + '4b89405c54d1c0bde8aa476a47561a42a6e7a5e927daa2ec69d428810eae3419', + tx_pos: 1, + value: 546 + }, + { + height: 665577, + tx_hash: + '3a4b628cbcc183ab376d44ce5252325f042268307ffa4a53443e92b6d24fb488', + tx_pos: 1, + value: 546 + } + ] + + const data = await bchjs.SLP.Utils.tokenUtxoDetailsWL(utxos) + // console.log(`data: ${JSON.stringify(data, null, 2)}`) + + assert.isArray(data) + // assert.equal(data[0].isValid, null) + }) + }) + describe('#balancesForAddress', () => { it('should fetch all balances for address: simpleledger:qzv3zz2trz0xgp6a96lu4m6vp2nkwag0kvyucjzqt9', async () => { const balances = await bchjs.SLP.Utils.balancesForAddress( @@ -738,7 +849,8 @@ describe('#SLP', () => { describe('#waterfallValidateTxid', () => { it('should validate known good txid not in whitelist', async () => { - const txid = '3a4b628cbcc183ab376d44ce5252325f042268307ffa4a53443e92b6d24fb488' + const txid = + '3a4b628cbcc183ab376d44ce5252325f042268307ffa4a53443e92b6d24fb488' const result = await bchjs.SLP.Utils.waterfallValidateTxid(txid) // console.log(`result: ${JSON.stringify(result, null, 2)}`) diff --git a/test/unit/slp-utils.js b/test/unit/slp-utils.js index 5bf0d48..4eb7b31 100644 --- a/test/unit/slp-utils.js +++ b/test/unit/slp-utils.js @@ -497,41 +497,6 @@ describe('#SLP Utils', () => { } }) - // CT 1/9/21: This test can be removed after 2/1/21 - // it('should throw error if utxo does not have satoshis or value property.', async () => { - // try { - // const utxos = [ - // { - // txid: - // 'bd158c564dd4ef54305b14f44f8e94c44b649f246dab14bcb42fb0d0078b8a90', - // vout: 3, - // amount: 0.00002015, - // satoshis: 2015, - // height: 594892, - // confirmations: 5 - // }, - // { - // txid: - // 'bd158c564dd4ef54305b14f44f8e94c44b649f246dab14bcb42fb0d0078b8a90', - // vout: 2, - // amount: 0.00000546, - // height: 594892, - // confirmations: 5 - // } - // ] - // - // await uut.Utils.tokenUtxoDetails(utxos) - // - // assert.equal(true, false, 'Unexpected result.') - // } catch (err) { - // assert.include( - // err.message, - // 'utxo 1 does not have a satoshis or value property', - // 'Expected error message.' - // ) - // } - // }) - it('should throw error if utxo does not have txid or tx_hash property.', async () => { try { const utxos = [ @@ -1596,6 +1561,75 @@ describe('#SLP Utils', () => { }) }) + describe('#tokenUtxoDetailsWL', () => { + it('should return details for a simple SEND SLP token utxo', async () => { + // Mock the call to REST API + // Stub the calls to decodeOpReturn. + sandbox + .stub(uut.Utils, 'decodeOpReturn') + .onCall(0) + .resolves({ + tokenType: 1, + txType: 'SEND', + tokenId: + '497291b8a1dfe69c8daea50677a3d31a5ef0e9484d8bebb610dac64bbc202fb7', + amounts: ['200000000', '99887500000000'] + }) + .onCall(1) + .resolves({ + tokenType: 1, + txType: 'GENESIS', + ticker: 'TOK-CH', + name: 'TokyoCash', + tokenId: + '497291b8a1dfe69c8daea50677a3d31a5ef0e9484d8bebb610dac64bbc202fb7', + documentUri: '', + documentHash: '', + decimals: 8, + mintBatonVout: 0, + qty: '2100000000000000' + }) + + // Stub the call to validateTxid + sandbox.stub(uut.Utils, 'validateTxid3').resolves([ + { + txid: + 'fde117b1f176b231e2fa9a6cb022e0f7c31c288221df6bcb05f8b7d040ca87cb', + valid: true + } + ]) + + const utxos = [ + { + txid: + 'fde117b1f176b231e2fa9a6cb022e0f7c31c288221df6bcb05f8b7d040ca87cb', + vout: 1, + amount: 0.00000546, + satoshis: 546, + height: 596089, + confirmations: 748 + } + ] + + const data = await uut.Utils.tokenUtxoDetailsWL(utxos) + // console.log(`data: ${JSON.stringify(data, null, 2)}`) + + assert.property(data[0], 'txid') + assert.property(data[0], 'vout') + assert.property(data[0], 'height') + assert.property(data[0], 'utxoType') + assert.property(data[0], 'tokenId') + assert.property(data[0], 'tokenTicker') + assert.property(data[0], 'tokenName') + assert.property(data[0], 'tokenDocumentUrl') + assert.property(data[0], 'tokenDocumentHash') + assert.property(data[0], 'decimals') + assert.property(data[0], 'tokenQty') + assert.property(data[0], 'isValid') + assert.equal(data[0].isValid, true) + }) + }) + describe('#txDetails', () => { it('should throw an error if txid is not included', async () => { try {