diff --git a/package.json b/package.json index b3b4065..35b339b 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ }, "repository": "Permissionless-Software-Foundation/ipfs-bch-wallet-consumer", "dependencies": { - "@psf/bch-js": "6.5.7", + "@psf/bch-js": "6.6.0", "axios": "0.27.2", "bcryptjs": "2.4.3", "glob": "7.1.6", diff --git a/src/adapters/bch/index.js b/src/adapters/bch/index.js index 65faf4f..a75d927 100644 --- a/src/adapters/bch/index.js +++ b/src/adapters/bch/index.js @@ -208,6 +208,58 @@ class BchAdapter { } } + // Query the UTXOs associated with an array of up to 20 addresses. + async getUtxosBulk (addrs) { + try { + // Throw an error if this IPFS node has not yet made a connection to a + // wallet service provider. + const selectedProvider = + this.ipfs.ipfsCoordAdapter.state.selectedServiceProvider + if (!selectedProvider) { + throw new Error('No BCH Wallet Service provider available yet.') + } + + // Input validation + if (!Array.isArray(addrs)) { + throw new Error('addresses parameter must be an array') + } + if (addrs.length > 20) { + throw new Error('addresses parameter must not exceed 20 elements') + } + + const rpcData = { + endpoint: 'utxosBulk', + address: addrs + } + + // Generate a UUID for the call. + const rpcId = this.uid() + + // Generate a JSON RPC command. + const cmd = this.jsonrpc.request(rpcId, 'bch', rpcData) + const cmdStr = JSON.stringify(cmd) + // console.log('cmdStr: ', cmdStr) + + // Send the RPC command to selected wallet service. + const thisNode = this.ipfs.ipfsCoordAdapter.ipfsCoord.thisNode + await this.ipfs.ipfsCoordAdapter.ipfsCoord.useCases.peer.sendPrivateMessage( + selectedProvider, + cmdStr, + thisNode + ) + + // Wait for data to come back from the wallet service. + const data = await this.waitForRPCResponse(rpcId) + // console.log(`data: ${JSON.stringify(data, null, 2)}`) + + return data + } catch (err) { + // console.log('createUser() error: ', err) + wlogger.error('Error in use-cases/bch.js/getUtxosBulk()') + throw err + } + } + async broadcast (hex) { try { // Throw an error if this IPFS node has not yet made a connection to a diff --git a/src/controllers/rest-api/bch/controller.js b/src/controllers/rest-api/bch/controller.js index e1efb70..54ff79d 100644 --- a/src/controllers/rest-api/bch/controller.js +++ b/src/controllers/rest-api/bch/controller.js @@ -234,6 +234,103 @@ class BchRESTControllerLib { } } + /** + * @api {post} /bch/utxosBulk UTXOs Bulk + * @apiName UTXOs Bulk + * @apiGroup REST BCH + * @apiDescription This endpoint returns UTXOs held at an address, hydrated + * with token information. + * @apiDescription This endpoint is the same as the /bch/utxos endpoint, but + * it allows an array of up to 20 addresses to be queried at the same time. + * This reduces the number of JSON RPC calls, and is very handy + * for HD wallets that need to quickly scan a lot of addresses. + * + * Given an address, this endpoint will return an object with thre following + * properties: + * + * - addresses: [] - the addresses 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. + * + * + * @apiExample Example usage: + * curl -H "Content-Type: application/json" -X POST -d '{ "address": "bitcoincash:qrl2nlsaayk6ekxn80pq0ks32dya8xfclyktem2mqj" }' localhost:5001/bch/utxos + * + * @apiSuccessExample {json} Success-Response: + * HTTP/1.1 200 OK + * [ + * { + * "addresses": ["bitcoincash:qrl2nlsaayk6ekxn80pq0ks32dya8xfclyktem2mqj"], + * "bchUtxos":[ + * { + * "height":631219, + * "tx_hash":"ae2daa01c8172545b5edd205ea438706bcb74e63d4084a26b9ff2a46d46dc97f", + * "tx_pos":0, + * "value":1000, + * "txid":"ae2daa01c8172545b5edd205ea438706bcb74e63d4084a26b9ff2a46d46dc97f", + * "vout":0, + * "isValid":false + * } + * ], + * "nullUtxos":[ + * + * ], + * "slpUtxos":{ + * "type1":{ + * "mintBatons":[ + * + * ], + * "tokens":[ + * + * ] + * }, + * "nft":{ + * "groupMintBatons":[ + * + * ], + * "groupTokens":[ + * + * ], + * "tokens":[ + * + * ] + * } + * } + * } + * ] + * + * @apiError UnprocessableEntity Missing required parameters + * + * @apiErrorExample {json} Error-Response: + * HTTP/1.1 422 Unprocessable Entity + * { + * "status": 422, + * "error": "Unprocessable Entity" + * } + */ + async utxosBulk (ctx) { + try { + const addrs = ctx.request.body.addresses + // console.log('address: ', address) + + const utxos = await this.adapters.bch.getUtxosBulk(addrs) + // console.log(`utxos: ${JSON.stringify(utxos, null, 2)}`) + + ctx.body = [utxos] + } catch (err) { + this.handleError(ctx, err) + } + } + /** * @api {post} /bch/broadcast Broadcast * @apiName Broadcast diff --git a/src/controllers/rest-api/bch/index.js b/src/controllers/rest-api/bch/index.js index 971235e..8a7e31f 100644 --- a/src/controllers/rest-api/bch/index.js +++ b/src/controllers/rest-api/bch/index.js @@ -55,6 +55,7 @@ class BchRouter { this.router.post('/provider', this.postProvider) this.router.post('/balance', this.postBalance) this.router.post('/utxos', this.postUtxos) + this.router.post('/utxosBulk', this.postUtxosBulk) this.router.post('/broadcast', this.postBroadcast) this.router.post('/txHistory', this.postTxHistory) this.router.post('/txData', this.postTxData) @@ -84,6 +85,10 @@ class BchRouter { await _this.bchRESTController.utxos(ctx, next) } + async postUtxosBulk (ctx, next) { + await _this.bchRESTController.utxosBulk(ctx, next) + } + async postBroadcast (ctx, next) { await _this.bchRESTController.broadcast(ctx, next) } diff --git a/test/unit/adapters/bch.adapter.unit.js b/test/unit/adapters/bch.adapter.unit.js index c348f46..02862f7 100644 --- a/test/unit/adapters/bch.adapter.unit.js +++ b/test/unit/adapters/bch.adapter.unit.js @@ -165,6 +165,82 @@ describe('#bch-use-case', () => { }) }) + describe('#getUtxos', () => { + it('should get the UTXOs of an address', async () => { + // Force connection to a wallet service + uut.ipfs.ipfsCoordAdapter.state = { + selectedServiceProvider: 'abc123' + } + + // Mock depenencies + sandbox.stub(uut, 'waitForRPCResponse').resolves({ key: 'value' }) + + const addr = 'addr' + + const result = await uut.getUtxos(addr) + // console.log('result: ', result) + + assert.equal(result.key, 'value') + }) + + it('should catch and throw an error', async () => { + try { + await uut.getUtxos() + + assert.fail('Unexpected code path') + } catch (err) { + // console.log(err) + assert.equal(err.message, 'test error') + } + }) + }) + + describe('#getUtxosBulk', () => { + it('should get the UTXOs of an address', async () => { + // Force connection to a wallet service + uut.ipfs.ipfsCoordAdapter.state = { + selectedServiceProvider: 'abc123' + } + + // Mock depenencies + sandbox.stub(uut, 'waitForRPCResponse').resolves({ key: 'value' }) + + const addrs = ['addr'] + + const result = await uut.getUtxosBulk(addrs) + // console.log('result: ', result) + + assert.equal(result.key, 'value') + }) + + it('should throw an error if addresses is not an array', async () => { + try { + await uut.getUtxosBulk() + + assert.fail('Unexpected code path') + } catch (err) { + // console.log(err) + assert.equal(err.message, 'addresses parameter must be an array') + } + }) + + it('should throw an error if addresses array is larger than 20 elements', async () => { + try { + const addrs = [] + for (let i = 0; i < 25; i++) { + addrs.push(i) + } + + await uut.getUtxosBulk(addrs) + + assert.fail('Unexpected code path') + } catch (err) { + // console.log(err) + assert.equal(err.message, 'addresses parameter must not exceed 20 elements') + } + }) + }) + describe('#broadcast', () => { it('should broadcast a transaction', async () => { // Force connection to a wallet service diff --git a/test/unit/controllers/rest-api/bch/bch.rest.controller.unit.js b/test/unit/controllers/rest-api/bch/bch.rest.controller.unit.js index c3e52ff..2344640 100644 --- a/test/unit/controllers/rest-api/bch/bch.rest.controller.unit.js +++ b/test/unit/controllers/rest-api/bch/bch.rest.controller.unit.js @@ -177,6 +177,42 @@ describe('#BCH-REST-Controller', () => { }) }) + describe('#utxosBulk', () => { + it('should return 422 status on arbitrary error', async () => { + try { + // Force an error + sandbox + .stub(uut.adapters.bch, 'getUtxosBulk') + .rejects(new Error('test error')) + + ctx.request.body = { + address: 'blah' + } + + await uut.utxosBulk(ctx) + + assert.fail('Unexpected result') + } catch (err) { + console.log('err: ', err) + assert.equal(err.status, 422) + assert.include(err.message, 'test error') + } + }) + + it('should return 200 status on success', async () => { + sandbox.stub(uut.adapters.bch, 'getUtxosBulk').resolves({ status: 200 }) + + ctx.request.body = { + address: 'blah' + } + + await uut.utxosBulk(ctx) + + // Assert the expected HTTP response + assert.equal(ctx.status, 200) + }) + }) + describe('#broadcast', () => { it('should return 422 status on arbitrary error', async () => { try { diff --git a/test/unit/mocks/adapters/index.js b/test/unit/mocks/adapters/index.js index 802ec93..d39137b 100644 --- a/test/unit/mocks/adapters/index.js +++ b/test/unit/mocks/adapters/index.js @@ -93,6 +93,10 @@ class BchUseCaseMock { return {} } + async getUtxosBulk () { + return {} + } + async broadcast () { return {} }