diff --git a/package.json b/package.json index db86e77..335032a 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,8 @@ "test:integration:local:abc": "export RESTURL=http://localhost:3000/v4/ && mocha --timeout 30000 test/integration && mocha --timeout 30000 test/integration/chains/abc/", "test:integration:local:bchn": "export 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/chains/testnet", - "test:temp": "export RESTURL=http://localhost:3000/v4/ && mocha --timeout 30000 -g '#hydrateUtxos' test/integration/", - "test:temp2": "mocha --timeout=30000 -g '#tokenUtxoDetailsWL' test/unit/", - "test:temp3": "RESTURL=http://localhost:3000/v4/ mocha --timeout 30000 test/integration/chains/abc/", + "test:temp": "export RESTURL=https://bchn.fullstack.cash/v4/ && mocha --timeout 30000 -g '#UTXO' test/integration/chains/bchn/", + "test:temp2": "mocha --timeout=30000 -g '#utxo' 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/bch-js.js b/src/bch-js.js index 6dbb615..2bfaa53 100644 --- a/src/bch-js.js +++ b/src/bch-js.js @@ -31,6 +31,7 @@ const Schnorr = require('./schnorr') const SLP = require('./slp/slp') const IPFS = require('./ipfs') const Encryption = require('./encryption') +const Utxo = require('./utxo') // Indexers const Ninsight = require('./ninsight') @@ -113,6 +114,7 @@ class BCHJS { this.SLP.HDNode = this.HDNode this.IPFS = new IPFS() + this.Utxo = new Utxo(libConfig) } } diff --git a/src/utxo.js b/src/utxo.js new file mode 100644 index 0000000..85bd058 --- /dev/null +++ b/src/utxo.js @@ -0,0 +1,109 @@ +/* + High-level functions for working with UTXOs + + TODO: + - Make a getWL() clone of get(), but uses hydrateUtxosWL() +*/ + +// Local libraries +const Electrumx = require('./electrumx') +const Slp = require('./slp/slp') + +class UTXO { + constructor (config) { + // Encapsulate dependencies for easier mocking. + this.electrumx = new Electrumx(config) + this.slp = new Slp(config) + } + + /** + * @api Utxo.get() get() + * @apiName get + * @apiGroup UTXO + * @apiDescription Get UTXOs for an address + * + * Given an address, this function will return an object with three arrays: + * - bchUtxos - UTXOs confirmed to be spendable as normal BCH + * - slpUtxos - UTXOs confirmed to be colored as valid SLP tokens + * - nullUtxo - UTXOs that did not pass SLP validation. Should be ignored and + * not spent, to be safe. + * + * @apiExample Example usage: + * (async () => { + * try { + * let utxos = await bchjs.Utxo.get('simpleledger:qzv3zz2trz0xgp6a96lu4m6vp2nkwag0kvyucjzqt9'); + * console.log(utxos); + * } catch(error) { + * console.error(error) + * } + * })() + * + * // returns + * { + * "bchUtxos": [ + * { + * "height": 674331, + * "tx_hash": "5e86cd911110e4f5db0cc3e8f459d5e8850b49adf57059a71daee674b2867b31", + * "tx_pos": 0, + * "value": 1000, + * "txid": "5e86cd911110e4f5db0cc3e8f459d5e8850b49adf57059a71daee674b2867b31", + * "vout": 0, + * "isValid": false + * } + * ], + * "slpUtxos": [ + * { + * "height": 569108, + * "tx_hash": "384e1b8197e8de7d38f98317af2cf5f6bcb50007c46943b3498a6fab6e8aeb7c", + * "tx_pos": 1, + * "value": 546, + * "txid": "384e1b8197e8de7d38f98317af2cf5f6bcb50007c46943b3498a6fab6e8aeb7c", + * "vout": 1, + * "utxoType": "token", + * "transactionType": "send", + * "tokenId": "a436c8e1b6bee3d701c6044d190f76f774be83c36de8d34a988af4489e86dd37", + * "tokenTicker": "sleven", + * "tokenName": "sleven", + * "tokenDocumentUrl": "sleven", + * "tokenDocumentHash": "", + * "decimals": 7, + * "tokenType": 1, + * "tokenQty": "1", + * "isValid": true + * } + * ], + * "nullUtxos": [] + * } + * + * + */ + async get (address) { + try { + const addr = this.slp.Address.toCashAddress(address) + + // Get the UTXOs associated with the address. + const utxoData = await this.electrumx.utxo([addr]) + // console.log(`utxoData: ${JSON.stringify(utxoData, null, 2)}`) + + // Hydate the utxos with token information. + const hydratedUtxos = await this.slp.Utils.hydrateUtxos(utxoData.utxos) + // console.log(`hydratedUtxos: ${JSON.stringify(hydratedUtxos, null, 2)}`) + + const retObj = {} // return object + + // Filter out the different types of UTXOs. + retObj.bchUtxos = hydratedUtxos.slpUtxos[0].utxos.filter(elem => elem.isValid === false) + retObj.slpUtxos = hydratedUtxos.slpUtxos[0].utxos.filter(elem => elem.isValid === true) + retObj.nullUtxos = hydratedUtxos.slpUtxos[0].utxos.filter(elem => elem.isValid === null) + // Note: true, false, and null should be only values. An element with + // isValid set to any other value should be ignored. + + return retObj + } catch (err) { + console.error('Error in bchjs.utxo.get()') + throw err + } + } +} + +module.exports = UTXO diff --git a/test/integration/chains/abc/utxo-integration.js b/test/integration/chains/abc/utxo-integration.js new file mode 100644 index 0000000..ab856fc --- /dev/null +++ b/test/integration/chains/abc/utxo-integration.js @@ -0,0 +1,34 @@ +/* + Integration tests for the utxo.js library. +*/ + +const assert = require('chai').assert + +const BCHJS = require('../../../../src/bch-js') +const bchjs = new BCHJS() + +describe('#UTXO', () => { + beforeEach(async () => { + // sandbox = sinon.createSandbox() + + if (process.env.IS_USING_FREE_TIER) await sleep(1000) + }) + + describe('#get', () => { + it('should get hydrated and filtered UTXOs for an address', async () => { + // const addr = 'bitcoincash:qqh793x9au6ehvh7r2zflzguanlme760wuzehgzjh9' + const addr = 'simpleledger:qzv3zz2trz0xgp6a96lu4m6vp2nkwag0kvyucjzqt9' + + const result = await bchjs.Utxo.get(addr) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isArray(result.bchUtxos) + assert.isArray(result.slpUtxos) + assert.isArray(result.nullUtxos) + }) + }) +}) + +function sleep (ms) { + return new Promise(resolve => setTimeout(resolve, ms)) +} diff --git a/test/integration/chains/bchn/utxo-integration.js b/test/integration/chains/bchn/utxo-integration.js new file mode 100644 index 0000000..ab856fc --- /dev/null +++ b/test/integration/chains/bchn/utxo-integration.js @@ -0,0 +1,34 @@ +/* + Integration tests for the utxo.js library. +*/ + +const assert = require('chai').assert + +const BCHJS = require('../../../../src/bch-js') +const bchjs = new BCHJS() + +describe('#UTXO', () => { + beforeEach(async () => { + // sandbox = sinon.createSandbox() + + if (process.env.IS_USING_FREE_TIER) await sleep(1000) + }) + + describe('#get', () => { + it('should get hydrated and filtered UTXOs for an address', async () => { + // const addr = 'bitcoincash:qqh793x9au6ehvh7r2zflzguanlme760wuzehgzjh9' + const addr = 'simpleledger:qzv3zz2trz0xgp6a96lu4m6vp2nkwag0kvyucjzqt9' + + const result = await bchjs.Utxo.get(addr) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isArray(result.bchUtxos) + assert.isArray(result.slpUtxos) + assert.isArray(result.nullUtxos) + }) + }) +}) + +function sleep (ms) { + return new Promise(resolve => setTimeout(resolve, ms)) +} diff --git a/test/unit/control.js b/test/unit/control.js index 2c8f866..bbe557e 100644 --- a/test/unit/control.js +++ b/test/unit/control.js @@ -5,11 +5,11 @@ const bchjs = new BCHJS() const sinon = require('sinon') describe('#Control', () => { - describe('#getNetworkInfo', () => { - let sandbox - beforeEach(() => (sandbox = sinon.createSandbox())) - afterEach(() => sandbox.restore()) + let sandbox + beforeEach(() => (sandbox = sinon.createSandbox())) + afterEach(() => sandbox.restore()) + describe('#getNetworkInfo', () => { it('should get info', done => { const data = { version: 170000, @@ -36,10 +36,6 @@ describe('#Control', () => { }) describe('#getMemoryInfo', () => { - let sandbox - beforeEach(() => (sandbox = sinon.createSandbox())) - afterEach(() => sandbox.restore()) - it('should get memory info', done => { const data = { locked: { diff --git a/test/unit/fixtures/utxo-mocks.js b/test/unit/fixtures/utxo-mocks.js new file mode 100644 index 0000000..994a74d --- /dev/null +++ b/test/unit/fixtures/utxo-mocks.js @@ -0,0 +1,72 @@ +const mockUtxoData = { + success: true, + utxos: [ + { + utxos: [ + { + height: 569108, + tx_hash: + '384e1b8197e8de7d38f98317af2cf5f6bcb50007c46943b3498a6fab6e8aeb7c', + tx_pos: 1, + value: 546 + }, + { + height: 674331, + tx_hash: + '5e86cd911110e4f5db0cc3e8f459d5e8850b49adf57059a71daee674b2867b31', + tx_pos: 0, + value: 1000 + } + ], + address: 'bitcoincash:qzv3zz2trz0xgp6a96lu4m6vp2nkwag0kvg8nfhq4m' + } + ] +} + +const mockHydratedUtxos = { + slpUtxos: [ + { + utxos: [ + { + height: 569108, + tx_hash: + '384e1b8197e8de7d38f98317af2cf5f6bcb50007c46943b3498a6fab6e8aeb7c', + tx_pos: 1, + value: 546, + txid: + '384e1b8197e8de7d38f98317af2cf5f6bcb50007c46943b3498a6fab6e8aeb7c', + vout: 1, + utxoType: 'token', + transactionType: 'send', + tokenId: + 'a436c8e1b6bee3d701c6044d190f76f774be83c36de8d34a988af4489e86dd37', + tokenTicker: 'sleven', + tokenName: 'sleven', + tokenDocumentUrl: 'sleven', + tokenDocumentHash: '', + decimals: 7, + tokenType: 1, + tokenQty: '1', + isValid: true + }, + { + height: 674331, + tx_hash: + '5e86cd911110e4f5db0cc3e8f459d5e8850b49adf57059a71daee674b2867b31', + tx_pos: 0, + value: 1000, + txid: + '5e86cd911110e4f5db0cc3e8f459d5e8850b49adf57059a71daee674b2867b31', + vout: 0, + isValid: false + } + ], + address: 'bitcoincash:qzv3zz2trz0xgp6a96lu4m6vp2nkwag0kvg8nfhq4m' + } + ] +} + +module.exports = { + mockUtxoData, + mockHydratedUtxos +} diff --git a/test/unit/utxo-unit.js b/test/unit/utxo-unit.js new file mode 100644 index 0000000..579788f --- /dev/null +++ b/test/unit/utxo-unit.js @@ -0,0 +1,49 @@ +/* + Unit tests for the utxo.js library. +*/ + +const sinon = require('sinon') +const assert = require('chai').assert + +const BCHJS = require('../../src/bch-js') +const bchjs = new BCHJS() + +const mockData = require('./fixtures/utxo-mocks') + +describe('#utxo', () => { + let sandbox + beforeEach(() => (sandbox = sinon.createSandbox())) + afterEach(() => sandbox.restore()) + + describe('#get', () => { + it('should get hydrated and filtered UTXOs for an address', async () => { + // Mock dependencies. + sandbox.stub(bchjs.Utxo.electrumx, 'utxo').resolves(mockData.mockUtxoData) + sandbox.stub(bchjs.Utxo.slp.Utils, 'hydrateUtxos').resolves(mockData.mockHydratedUtxos) + + const addr = 'simpleledger:qzv3zz2trz0xgp6a96lu4m6vp2nkwag0kvyucjzqt9' + + const result = await bchjs.Utxo.get(addr) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.isArray(result.bchUtxos) + assert.isArray(result.slpUtxos) + assert.isArray(result.nullUtxos) + }) + + it('should catch and throw an error', async () => { + try { + // Force an error + sandbox.stub(bchjs.Utxo.electrumx, 'utxo').rejects(new Error('test error')) + + const addr = 'simpleledger:qzv3zz2trz0xgp6a96lu4m6vp2nkwag0kvyucjzqt9' + + await bchjs.Utxo.get(addr) + + assert.fail('Unexpected code path') + } catch (err) { + assert.include(err.message, 'test error') + } + }) + }) +})