diff --git a/src/psf-slp-indexer.js b/src/psf-slp-indexer.js index 194b622..ac89dbc 100644 --- a/src/psf-slp-indexer.js +++ b/src/psf-slp-indexer.js @@ -132,6 +132,7 @@ class PsfSlpIndexer { */ async balance (address) { try { + console.log('balance() address: ', address) // Handle single address. if (typeof address === 'string') { const response = await axios.post( diff --git a/src/utxo.js b/src/utxo.js index 6982ec6..ad21d57 100644 --- a/src/utxo.js +++ b/src/utxo.js @@ -8,19 +8,21 @@ // Local libraries const Electrumx = require('./electrumx') const Slp = require('./slp/slp') +const PsfSlpIndexer = require('./psf-slp-indexer') class UTXO { - constructor (config) { + constructor (config = {}) { // Encapsulate dependencies for easier mocking. this.electrumx = new Electrumx(config) this.slp = new Slp(config) + this.psfSlpIndexer = new PsfSlpIndexer(config) } /** - * @api Utxo.get() get() - * @apiName get + * @api Utxo.getOld() getOld() + * @apiName getOld * @apiGroup UTXO - * @apiDescription Get UTXOs for an address + * @apiDescription Get UTXOs for an address (from SLPDB) * * Given an address, this function will return an object with thre following * properties: @@ -45,7 +47,7 @@ class UTXO { * @apiExample Example usage: * (async () => { * try { - * let utxos = await bchjs.Utxo.get('simpleledger:qrm0c67wwqh0w7wjxua2gdt2xggnm90xwsr5k22euj'); + * let utxos = await bchjs.Utxo.getOld('simpleledger:qrm0c67wwqh0w7wjxua2gdt2xggnm90xwsr5k22euj'); * console.log(utxos); * } catch(error) { * console.error(error) @@ -181,7 +183,7 @@ class UTXO { * * */ - async get (address, useWhitelist = false) { + async getOld (address, useWhitelist = false) { try { if (!address) { throw new Error('Address must be an array or a string') @@ -286,6 +288,148 @@ class UTXO { } } + /** + * @api Utxo.get() get() + * @apiName get + * @apiGroup UTXO + * @apiDescription Get UTXOs for an address (from psf-slp-indexer) + * + * Given an address, this function will return an object with thre following + * properties: + * - address: "" - the address these UTXOs are associated with + * - bchUtxos: [] - UTXOs confirmed to be spendable as normal BCH + * - nullUtxo: [] - UTXOs that did not pass SLP validation. Should be ignored and + * not spent, to be safe. + * - slpUtxos: {} - UTXOs confirmed to be colored as valid SLP tokens + * - type1: {} + * - tokens: [] - SLP token Type 1 tokens. + * - mintBatons: [] - SLP token Type 1 mint batons. + * - nft: {} + * - tokens: [] - NFT tokens + * - groupTokens: [] - NFT Group tokens, used to create NFT tokens. + * - groupMintBatons: [] - Minting baton to create more NFT Group tokens. + * + * Note: You can pass in an optional second Boolean argument. The default + * `false` will use the normal waterfall validation method. Set to `true`, + * SLP UTXOs will be validated with the whitelist filtered SLPDB. This will + * result is many more UTXOs in the `nullUtxos` array. + * + * @apiExample Example usage: + * (async () => { + * try { + * let utxos = await bchjs.Utxo.get('simpleledger:qrm0c67wwqh0w7wjxua2gdt2xggnm90xwsr5k22euj'); + * console.log(utxos); + * } catch(error) { + * console.error(error) + * } + * })() + * + * // returns + * [ + * { + * "address": "bitcoincash:qrm0c67wwqh0w7wjxua2gdt2xggnm90xws00a3lezv", + * "bchUtxos": [ + * { + * "height": 674513, + * "tx_hash": "705bcc442e5a2770e560b528f52a47b1dcc9ce9ab6a8de9dfdefa55177f00d04", + * "tx_pos": 3, + * "value": 38134, + * "txid": "705bcc442e5a2770e560b528f52a47b1dcc9ce9ab6a8de9dfdefa55177f00d04", + * "vout": 3, + * "isValid": false + * } + * ], + */ + // This version of get() uses the psf-slp-indexer. It will replace the older + // get() function that uses SLPDB. + // TODO: NFT UTXOs are identified as non-token UTXOs, which will cause a wallet + // to burn them. The psf-slp-indexer needs to be updated to mark these UTXOs. + async get (address) { + try { + // Convert address to an array if it is a string. + if (typeof address !== 'string') { + throw new Error('address input must be a string') + } + + // Ensure the address is a BCH address. + 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)}`) + const utxos = utxoData.utxos + + // Get SLP UTXOs from the psf-slp-indexer + const slpUtxoData = await this.psfSlpIndexer.balance(addr) + // console.log(`slpUtxoData: ${JSON.stringify(slpUtxoData, null, 2)}`) + const slpUtxos = slpUtxoData.balance.utxos + + // Loop through the Fulcrum UTXOs. + for (let i = 0; i < utxos.length; i++) { + const thisUtxo = utxos[i] + + // Loop through the UTXOs from psf-slp-indexer. + for (let j = 0; j < slpUtxos.length; j++) { + const thisSlpUtxo = slpUtxos[j] + + // If the non-hydrated UTXO matches the SLP UTXO, then combine the data + // and mark the UTXO as an SLP token. + if ( + thisUtxo.tx_hash === thisSlpUtxo.txid && + thisUtxo.tx_pos === thisSlpUtxo.vout + ) { + thisUtxo.txid = thisUtxo.tx_hash + thisUtxo.vout = thisUtxo.tx_pos + thisUtxo.isSlp = true + thisUtxo.type = thisSlpUtxo.type + thisUtxo.qty = thisSlpUtxo.qty + thisUtxo.tokenId = thisSlpUtxo.tokenId + thisUtxo.address = thisSlpUtxo.address + + break + } + } + + // If there was no match, then this is a normal BCH UTXO. Mark it as such. + if (!thisUtxo.isSlp) { + thisUtxo.txid = thisUtxo.tx_hash + thisUtxo.vout = thisUtxo.tx_pos + thisUtxo.isSlp = false + thisUtxo.address = addr + } + } + + const bchUtxos = utxos.filter(x => x.isSlp === false) + const type1TokenUtxos = utxos.filter( + x => x.isSlp === true && x.type === 'token' + ) + const type1BatonUtxos = utxos.filter( + x => x.isSlp === true && x.type === 'baton' + ) + const nullUtxos = utxos.filter(x => x.isSlp === null) + + const outObj = { + address: addr, + bchUtxos, + slpUtxos: { + type1: { + tokens: type1TokenUtxos, + mintBatons: type1BatonUtxos + }, + nft: {} // Allocated for future support of NFT spec. + }, + nullUtxos + } + + return outObj + } catch (err) { + // console.error('Error in bchjs.utxo.get2(): ', err) + + if (err.error) throw new Error(err.error) + throw err + } + } + /** * @api Utxo.findBiggestUtxo() findBiggestUtxo() * @apiName findBiggestUtxo diff --git a/test/integration/chains/bchn/utxo-integration.js b/test/integration/chains/bchn/utxo-integration.js index bcb5a14..1a2e3a0 100644 --- a/test/integration/chains/bchn/utxo-integration.js +++ b/test/integration/chains/bchn/utxo-integration.js @@ -13,7 +13,7 @@ describe('#UTXO', () => { if (process.env.IS_USING_FREE_TIER) await sleep(3000) }) - + /* describe('#get', () => { it('should get hydrated and filtered UTXOs for an address', async () => { // const addr = 'bitcoincash:qqh793x9au6ehvh7r2zflzguanlme760wuzehgzjh9' @@ -90,7 +90,40 @@ describe('#UTXO', () => { assert.isAbove(result[0].nullUtxos.length, 1) }) }) +*/ + describe('#get', () => { + it('should hydrate address with BCH and SLP UTXOs', async () => { + const addr = 'simpleledger:qzv3zz2trz0xgp6a96lu4m6vp2nkwag0kvyucjzqt9' + const result = await bchjs.Utxo.get(addr) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + // Assert expected properties exist. + assert.property(result, 'address') + assert.property(result, 'bchUtxos') + assert.property(result, 'slpUtxos') + assert.property(result.slpUtxos, 'type1') + assert.property(result.slpUtxos, 'nft') + assert.property(result, 'nullUtxos') + + assert.isAbove(result.bchUtxos.length, 0) + assert.isAbove(result.slpUtxos.type1.tokens.length, 0) + assert.equal(result.slpUtxos.type1.mintBatons.length, 0) + }) + + // TODO: NFTs are currently not identified as different than normal BCH UTXOs. + // The psf-slp-indexer needs to be updated to fix this issue. + it('should handle NFTs and minting batons', async () => { + const addr = 'simpleledger:qrm0c67wwqh0w7wjxua2gdt2xggnm90xwsr5k22euj' + + const result = await bchjs.Utxo.get(addr) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + // Assert that minting batons are correctly identified. + assert.isAbove(result.slpUtxos.type1.mintBatons.length, 0) + }) + }) + /* describe('#findBiggestUtxo', () => { it('should sort UTXOs from Electrumx', async () => { const addr = 'bitcoincash:qq54fgjn3hz0357n8a6guy4demw9xfkjk5jcj0xr0z' @@ -118,6 +151,7 @@ describe('#UTXO', () => { assert.equal(result.satoshis, 800) }) }) +*/ }) function sleep (ms) { diff --git a/test/unit/fixtures/utxo-mocks.js b/test/unit/fixtures/utxo-mocks.js index 72094c5..df49eae 100644 --- a/test/unit/fixtures/utxo-mocks.js +++ b/test/unit/fixtures/utxo-mocks.js @@ -280,10 +280,100 @@ const electrumxUtxos = { ] } +const fulcrumUtxos01 = { + success: true, + utxos: [ + { + height: 674512, + tx_hash: + 'acbb0d3ceef55aa3e5fafc19335ae4bf2f8edba3c0567547dfd402391db32230', + tx_pos: 1, + value: 546 + }, + { + height: 674512, + tx_hash: + 'acbb0d3ceef55aa3e5fafc19335ae4bf2f8edba3c0567547dfd402391db32230', + tx_pos: 2, + value: 546 + }, + { + height: 674512, + tx_hash: + 'eeddccc4d716f04157ea132ac93a48040fea34a6b57f3d8f0cccb7d1a731ab2b', + tx_pos: 1, + value: 546 + }, + { + height: 674513, + tx_hash: + '705bcc442e5a2770e560b528f52a47b1dcc9ce9ab6a8de9dfdefa55177f00d04', + tx_pos: 1, + value: 546 + }, + { + height: 674513, + tx_hash: + '705bcc442e5a2770e560b528f52a47b1dcc9ce9ab6a8de9dfdefa55177f00d04', + tx_pos: 2, + value: 546 + }, + { + height: 674513, + tx_hash: + '705bcc442e5a2770e560b528f52a47b1dcc9ce9ab6a8de9dfdefa55177f00d04', + tx_pos: 3, + value: 38134 + } + ] +} + +const psfSlpIndexerUtxos01 = { + balance: { + utxos: [ + { + txid: + 'acbb0d3ceef55aa3e5fafc19335ae4bf2f8edba3c0567547dfd402391db32230', + vout: 1, + type: 'token', + qty: '10000000000', + tokenId: + 'acbb0d3ceef55aa3e5fafc19335ae4bf2f8edba3c0567547dfd402391db32230', + address: 'bitcoincash:qrm0c67wwqh0w7wjxua2gdt2xggnm90xws00a3lezv' + }, + { + txid: + 'acbb0d3ceef55aa3e5fafc19335ae4bf2f8edba3c0567547dfd402391db32230', + vout: 2, + type: 'baton', + tokenId: + 'acbb0d3ceef55aa3e5fafc19335ae4bf2f8edba3c0567547dfd402391db32230', + address: 'bitcoincash:qrm0c67wwqh0w7wjxua2gdt2xggnm90xws00a3lezv' + } + ], + txs: [ + { + txid: + 'acbb0d3ceef55aa3e5fafc19335ae4bf2f8edba3c0567547dfd402391db32230', + height: 674512 + } + ], + balances: [ + { + tokenId: + 'acbb0d3ceef55aa3e5fafc19335ae4bf2f8edba3c0567547dfd402391db32230', + qty: '10000000000' + } + ] + } +} + module.exports = { mockUtxoData, mockHydratedUtxos, mockTwoHydratedAddrs, mockEveryUtxoType, - electrumxUtxos + electrumxUtxos, + fulcrumUtxos01, + psfSlpIndexerUtxos01 } diff --git a/test/unit/utxo-unit.js b/test/unit/utxo-unit.js index 3992f65..e4d3b63 100644 --- a/test/unit/utxo-unit.js +++ b/test/unit/utxo-unit.js @@ -15,7 +15,7 @@ describe('#utxo', () => { beforeEach(() => (sandbox = sinon.createSandbox())) afterEach(() => sandbox.restore()) - describe('#get', () => { + describe('#getOld', () => { it('should get hydrated and filtered UTXOs for an address', async () => { // Mock dependencies. sandbox.stub(bchjs.Utxo.electrumx, 'utxo').resolves(mockData.mockUtxoData) @@ -25,7 +25,7 @@ describe('#utxo', () => { const addr = 'simpleledger:qzv3zz2trz0xgp6a96lu4m6vp2nkwag0kvyucjzqt9' - const result = await bchjs.Utxo.get(addr) + const result = await bchjs.Utxo.getOld(addr) // console.log(`result: ${JSON.stringify(result, null, 2)}`) assert.isArray(result) @@ -46,7 +46,7 @@ describe('#utxo', () => { const addr = 'simpleledger:qzv3zz2trz0xgp6a96lu4m6vp2nkwag0kvyucjzqt9' - await bchjs.Utxo.get(addr) + await bchjs.Utxo.getOld(addr) assert.fail('Unexpected code path') } catch (err) { @@ -66,7 +66,7 @@ describe('#utxo', () => { 'bitcoincash:qqh793x9au6ehvh7r2zflzguanlme760wuzehgzjh9' ] - const result = await bchjs.Utxo.get(addr) + const result = await bchjs.Utxo.getOld(addr) // console.log(`result: ${JSON.stringify(result, null, 2)}`) assert.isArray(result) @@ -88,7 +88,7 @@ describe('#utxo', () => { addrs.push(addr) } - await bchjs.Utxo.get(addrs) + await bchjs.Utxo.getOld(addrs) assert.fail('Unexpected code path') } catch (err) { @@ -105,7 +105,7 @@ describe('#utxo', () => { const addr = 'simpleledger:qrm0c67wwqh0w7wjxua2gdt2xggnm90xwsr5k22euj' - const result = await bchjs.Utxo.get(addr) + const result = await bchjs.Utxo.getOld(addr) // console.log(`result: ${JSON.stringify(result, null, 2)}`) assert.isArray(result) @@ -133,7 +133,7 @@ describe('#utxo', () => { const addr = 'simpleledger:qzv3zz2trz0xgp6a96lu4m6vp2nkwag0kvyucjzqt9' const useWhitelist = true - const result = await bchjs.Utxo.get(addr, useWhitelist) + const result = await bchjs.Utxo.getOld(addr, useWhitelist) // console.log(`result: ${JSON.stringify(result, null, 2)}`) assert.isArray(result) @@ -188,4 +188,48 @@ describe('#utxo', () => { assert.equal(result.satoshis, 800) }) }) + + describe('#get', () => { + it('should throw an error if input is not a string', async () => { + try { + const addr = 123 + + await bchjs.Utxo.get(addr) + + assert.fail('Unexpected code path') + } catch (err) { + assert.include(err.message, 'address input must be a string') + } + }) + + it('should return UTXO information', async () => { + // mock dependencies + sandbox + .stub(bchjs.Utxo.electrumx, 'utxo') + .resolves(mockData.fulcrumUtxos01) + sandbox + .stub(bchjs.Utxo.psfSlpIndexer, 'balance') + .resolves(mockData.psfSlpIndexerUtxos01) + + const addr = 'simpleledger:qrm0c67wwqh0w7wjxua2gdt2xggnm90xwsr5k22euj' + + const result = await bchjs.Utxo.get(addr) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + // Assert expected properties exist + assert.property(result, 'address') + assert.property(result, 'bchUtxos') + assert.property(result, 'slpUtxos') + assert.property(result.slpUtxos, 'type1') + assert.property(result.slpUtxos.type1, 'tokens') + assert.property(result.slpUtxos.type1, 'mintBatons') + assert.property(result.slpUtxos, 'nft') + assert.property(result, 'nullUtxos') + + assert.equal(result.bchUtxos.length, 4) + assert.equal(result.slpUtxos.type1.tokens.length, 1) + assert.equal(result.slpUtxos.type1.mintBatons.length, 1) + assert.equal(result.nullUtxos.length, 0) + }) + }) })