diff --git a/src/routes/v3/util.js b/src/routes/v3/util.js index 4bbbc59..2f4b1a3 100644 --- a/src/routes/v3/util.js +++ b/src/routes/v3/util.js @@ -8,6 +8,9 @@ const routeUtils = require("./route-utils") const wlogger = require("../../util/winston-logging") const blockbook = require("./blockbook") +const util = require("util") +util.inspect.defaultOptions = { depth: 1 } + const BCHJS = require("@chris.troutner/bch-js") const bchjs = new BCHJS() const BCHJS_TESTNET = `https://testnet.bchjs.cash/v3/` @@ -219,7 +222,7 @@ class UtilRoute { const balances = await _this.blockbook.testableComponents.balanceFromBlockbook( fromAddr ) - console.log(`balances: ${JSON.stringify(balances, null, 2)}`) + // console.log(`balances: ${JSON.stringify(balances, null, 2)}`) // Total balance is the sum of the confirmed and unconfirmed balance. const totalBalance = @@ -235,6 +238,7 @@ class UtilRoute { const utxos = await _this.blockbook.testableComponents.utxosFromBlockbook( fromAddr ) + // console.log(`utxos: ${JSON.stringify(utxos, null, 2)}`) const tokenUtxos = [] const bchUtxos = [] @@ -247,7 +251,7 @@ class UtilRoute { // Figure out which UTXOs are associated with SLP tokens. const isTokenUtxo = await _this.bchjs.SLP.Utils.tokenUtxoDetails(utxos) - console.log(`isTokenUtxo: ${JSON.stringify(isTokenUtxo, null, 2)}`) + // console.log(`isTokenUtxo: ${JSON.stringify(isTokenUtxo, null, 2)}`) // Separate the bch and token UTXOs. for (let i = 0; i < utxos.length; i++) { @@ -255,6 +259,9 @@ class UtilRoute { if (!isTokenUtxo[i]) bchUtxos.push(utxos[i]) else tokenUtxos.push(isTokenUtxo[i]) } + // console.log( + // `bchUtxos.length: ${bchUtxos.length}, tokenUtxos.length: ${tokenUtxos.length}` + // ) // Throw error if no BCH to move tokens. if (bchUtxos.length === 0 && tokenUtxos.length > 0) { @@ -264,6 +271,8 @@ class UtilRoute { }) } + // console.log(`tokenUtxos: ${JSON.stringify(tokenUtxos, null, 2)}`) + const options = { ecPair, utxos, @@ -275,19 +284,20 @@ class UtilRoute { let hex - // Choose the sweeping algorithm based if there are tokens or not. + // Choose the sweeping algorithm, based on if there are tokens or not. if (tokenUtxos.length === 0) hex = await _this._sweepBCH(options) else hex = await _this._sweepTokens(options, bchUtxos, tokenUtxos) - console.log(`hex: ${hex}`) + // console.log(`hex: ${hex}`) // Throw error if there is more than one token class. // Generate a transaction to move tokens and BCH. // Broadcast the transaction. + const txid = _this.bchjs.RawTransactions.sendRawTransaction([hex]) res.status(200) - return res.json(true) + return res.json(txid) } catch (err) { // Attempt to decode the error message. const { msg, status } = routeUtils.decodeError(err) @@ -296,6 +306,16 @@ class UtilRoute { return res.json({ error: msg }) } + // Catch the specific case of multiple tokens. + if ( + err.message && + err.message.indexOf("Multiple token classes detected") > -1 + ) { + res.status(422) + return res.json({ error: err.message }) + } + + wlogger.error(`Error in util.js/sweepWif().`, err) console.error(`Error in util.js/sweepWif().`, err) res.status(500) @@ -310,6 +330,7 @@ class UtilRoute { // const toAddr = flags.address const ecPair = options.ecPair + const toAddr = options.toAddr // const fromAddr = this.BITBOX.ECPair.toCashAddress(ecPair) // @@ -329,8 +350,8 @@ class UtilRoute { // instance of transaction builder let transactionBuilder if (options.testnet) - transactionBuilder = new this.BITBOX.TransactionBuilder("testnet") - else transactionBuilder = new this.BITBOX.TransactionBuilder() + transactionBuilder = new _this.bchjs.TransactionBuilder("testnet") + else transactionBuilder = new _this.bchjs.TransactionBuilder() let originalAmount = 0 @@ -350,7 +371,7 @@ class UtilRoute { } // get byte count to calculate fee. paying 1 sat/byte - const byteCount = this.BITBOX.BitcoinCash.getByteCount( + const byteCount = _this.bchjs.BitcoinCash.getByteCount( { P2PKH: utxos.length }, { P2PKH: 1 } ) @@ -361,7 +382,7 @@ class UtilRoute { // add output w/ address and amount to send transactionBuilder.addOutput( - this.BITBOX.Address.toLegacyAddress(toAddr), + _this.bchjs.Address.toLegacyAddress(toAddr), sendAmount ) @@ -405,6 +426,8 @@ class UtilRoute { // if (flags.testnet) // this.BITBOX = new config.BCHLIB({ restURL: config.TESTNET_REST }) + // console.log(`tokenUtxos: ${JSON.stringify(tokenUtxos, null, 2)}`) + // Ensure there is only one class of token in the wallet. Throw an error if // there is more than one. const tokenId = tokenUtxos[0].tokenId diff --git a/test/v3/mocks/util-mocks.js b/test/v3/mocks/util-mocks.js index 4118358..2692702 100644 --- a/test/v3/mocks/util-mocks.js +++ b/test/v3/mocks/util-mocks.js @@ -13,6 +13,118 @@ const mockAddress = { isscript: false } -module.exports = { - mockAddress +const mockBalance = { + page: 1, + totalPages: 1, + itemsOnPage: 1000, + address: "bitcoincash:qzp7gdl52edm24xlpkyqnza9rv33u3mdxyc77j3u6k", + balance: "2546", + totalReceived: "2546", + totalSent: "0", + unconfirmedBalance: "0", + unconfirmedTxs: 0, + txs: 2, + txids: [ + "e190d13b88578132608ab912a4d2be3e55aa2792d6042d481ae21d700639de56", + "44e1f48c4093fc61db1a8fa206aa402fc34e482b3f788cb38c123ca0e1a35db6" + ] +} + +const mockUtxos = [ + { + txid: "e190d13b88578132608ab912a4d2be3e55aa2792d6042d481ae21d700639de56", + vout: 0, + value: "2000", + height: 605873, + confirmations: 298, + satoshis: 2000 + }, + { + txid: "44e1f48c4093fc61db1a8fa206aa402fc34e482b3f788cb38c123ca0e1a35db6", + vout: 1, + value: "546", + height: 605873, + confirmations: 298, + satoshis: 546 + } +] + +const mockThreeUtxos = [ + { + txid: "e190d13b88578132608ab912a4d2be3e55aa2792d6042d481ae21d700639de56", + vout: 0, + value: "2000", + height: 605873, + confirmations: 298, + satoshis: 2000 + }, + { + txid: "44e1f48c4093fc61db1a8fa206aa402fc34e482b3f788cb38c123ca0e1a35db6", + vout: 1, + value: "546", + height: 605873, + confirmations: 298, + satoshis: 546 + }, + { + txid: "44e1f48c4093fc61db1a8fa206aa402fc34e482b3f788cb38c123ca0e1a35db6", + vout: 1, + value: "546", + height: 605873, + confirmations: 298, + satoshis: 546 + } +] + +const mockIsTokenUtxos = [ + false, + { + txid: "44e1f48c4093fc61db1a8fa206aa402fc34e482b3f788cb38c123ca0e1a35db6", + vout: 1, + value: "546", + height: 605873, + confirmations: 298, + satoshis: 546, + utxoType: "token", + transactionType: "send", + tokenId: "497291b8a1dfe69c8daea50677a3d31a5ef0e9484d8bebb610dac64bbc202fb7", + tokenTicker: "TOK-CH", + tokenName: "TokyoCash", + tokenDocumentUrl: "", + tokenDocumentHash: "", + decimals: 8, + tokenQty: 2 + } +] + +const tapUtxo = { + txid: "2e030df12390186baf817fa2760540b886511e04bc520e88f6b4c2124cc2a7d4", + vout: 1, + value: "546", + height: 606564, + confirmations: 3, + satoshis: 546, + utxoType: "token", + transactionType: "send", + tokenId: "dd84ca78db4d617221b58eabc6667af8fe2f7eadbfcc213d35be9f1b419beb8d", + tokenTicker: "TAP", + tokenName: "Thoughts and Prayers", + tokenDocumentUrl: "", + tokenDocumentHash: "", + decimals: 0, + tokenQty: 1 +} + +const tokensOnly = [mockIsTokenUtxos[1], tapUtxo] + +const multipleTokens = [false, mockIsTokenUtxos[1], tapUtxo] + +module.exports = { + mockAddress, + mockBalance, + mockUtxos, + mockThreeUtxos, + mockIsTokenUtxos, + tokensOnly, + multipleTokens } diff --git a/test/v3/util.js b/test/v3/util.js index 7038e68..51d649d 100644 --- a/test/v3/util.js +++ b/test/v3/util.js @@ -12,6 +12,7 @@ const chai = require("chai") const assert = chai.assert const utilRoute = require("../../src/routes/v3/util") const nock = require("nock") // HTTP mocking +const sinon = require("sinon") let originalEnvVars // Used during transition from integration to unit tests. @@ -19,11 +20,15 @@ let originalEnvVars // Used during transition from integration to unit tests. const { mockReq, mockRes } = require("./mocks/express-mocks") const mockData = require("./mocks/util-mocks") +const util = require("util") +util.inspect.defaultOptions = { depth: 1 } + const UtilRoute = utilRoute.UtilRoute const utilRouteInst = new utilRoute.UtilRoute() describe("#Util", () => { let req, res + let sandbox before(() => { // Save existing environment variables. @@ -57,12 +62,16 @@ describe("#Util", () => { // Activate nock if it's inactive. if (!nock.isActive()) nock.activate() + + sandbox = sinon.createSandbox() }) afterEach(() => { // Clean up HTTP mocks. nock.cleanAll() // clear interceptor list. nock.restore() + + sandbox.restore() }) after(() => { @@ -133,7 +142,7 @@ describe("#Util", () => { req.params.address = `bitcoincash:qpujxqra3jmdlzzapwmmt7uspr7q0c9ff5hzljcrnd` const result = await validateAddress(req, res) - //console.log(`result: ${util.inspect(result)}`) + // console.log(`result: ${util.inspect(result)}`) assert.hasAnyKeys(result, [ "isvalid", @@ -298,14 +307,210 @@ describe("#Util", () => { }) describe("#sweepWif", () => { - // const sweepWif = utilRoute.testableComponents.sweepWif - - it("should do something", async () => { - req.body.wif = "L5GEFg1tETLWBugmhSo9Zc4ms968qVmfmTroDxsJ982AiudAQGyt" - req.body.toAddr = "bitcoincash:qz2qn6zt4qmacf4r6c0e2pdcqsgnkxaa3ql2xpee6p" + it("should throw 400 if WIF is not included", async () => { + req.body = {} const result = await utilRouteInst.sweepWif(req, res) - console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "WIF needs to a proper compressed WIF starting with K or L", + "Proper error message" + ) }) + + it("should throw 400 if WIF is malformed", async () => { + req.body = { + wif: `abc123` + } + + const result = await utilRouteInst.sweepWif(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "WIF needs to a proper compressed WIF starting with K or L", + "Proper error message" + ) + }) + + it("should throw 400 if destination address is not included", async () => { + req.body = { + wif: "L5GEFg1tETLWBugmhSo9Zc4ms968qVmfmTroDxsJ982AiudAQGyt" + } + + const result = await utilRouteInst.sweepWif(req, res) + + assert.equal(res.statusCode, 400, "HTTP status code 400 expected.") + assert.include( + result.error, + "address can not be empty", + "Proper error message" + ) + }) + + it("should generate transaction for valid token sweep", async () => { + // Mock the RPC call for unit tests. + if (process.env.TEST === "unit") { + sandbox + .stub( + utilRouteInst.blockbook.testableComponents, + "balanceFromBlockbook" + ) + .resolves(mockData.mockBalance) + + sandbox + .stub( + utilRouteInst.blockbook.testableComponents, + "utxosFromBlockbook" + ) + .resolves(mockData.mockUtxos) + + sandbox + .stub(utilRouteInst.bchjs.SLP.Utils, "tokenUtxoDetails") + .resolves(mockData.mockIsTokenUtxos) + } + + // Mock sendRawTransaction() so that the hex does not actually get broadcast + // to the network. + sandbox + .stub(utilRouteInst.bchjs.RawTransactions, "sendRawTransaction") + .resolves("test-txid") + + req.body = { + wif: "L5GEFg1tETLWBugmhSo9Zc4ms968qVmfmTroDxsJ982AiudAQGyt", + toAddr: "bitcoincash:qz2qn6zt4qmacf4r6c0e2pdcqsgnkxaa3ql2xpee6p" + } + + const result = await utilRouteInst.sweepWif(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.equal(result, "test-txid") + }) + + // Unit tests only + if (process.env.TEST === "unit") { + it("should generate transaction for valid BCH-only sweep", async () => { + sandbox + .stub( + utilRouteInst.blockbook.testableComponents, + "balanceFromBlockbook" + ) + .resolves(mockData.mockBalance) + + sandbox + .stub( + utilRouteInst.blockbook.testableComponents, + "utxosFromBlockbook" + ) + .resolves(mockData.mockUtxos) + + // Force token utxo to appear as regular BCH utxo. + sandbox + .stub(utilRouteInst.bchjs.SLP.Utils, "tokenUtxoDetails") + .resolves([false, false]) + + // Mock sendRawTransaction() so that the hex does not actually get broadcast + // to the network. + sandbox + .stub(utilRouteInst.bchjs.RawTransactions, "sendRawTransaction") + .resolves("test-txid") + + req.body = { + wif: "L5GEFg1tETLWBugmhSo9Zc4ms968qVmfmTroDxsJ982AiudAQGyt", + toAddr: "bitcoincash:qz2qn6zt4qmacf4r6c0e2pdcqsgnkxaa3ql2xpee6p" + } + + const result = await utilRouteInst.sweepWif(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.equal(result, "test-txid") + }) + + it("should throw 422 error if no non-token UTXOs", async () => { + sandbox + .stub( + utilRouteInst.blockbook.testableComponents, + "balanceFromBlockbook" + ) + .resolves(mockData.mockBalance) + + sandbox + .stub( + utilRouteInst.blockbook.testableComponents, + "utxosFromBlockbook" + ) + .resolves(mockData.mockUtxos) + + // Force token utxo to appear as regular BCH utxo. + sandbox + .stub(utilRouteInst.bchjs.SLP.Utils, "tokenUtxoDetails") + .resolves(mockData.tokensOnly) + + // Mock sendRawTransaction() so that the hex does not actually get broadcast + // to the network. + sandbox + .stub(utilRouteInst.bchjs.RawTransactions, "sendRawTransaction") + .resolves("test-txid") + + req.body = { + wif: "L5GEFg1tETLWBugmhSo9Zc4ms968qVmfmTroDxsJ982AiudAQGyt", + toAddr: "bitcoincash:qz2qn6zt4qmacf4r6c0e2pdcqsgnkxaa3ql2xpee6p" + } + + const result = await utilRouteInst.sweepWif(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.equal(res.statusCode, 422) + assert.property(result, "error") + assert.include( + result.error, + "Tokens found, but no BCH UTXOs found. Add BCH to wallet to move tokens" + ) + }) + + it("should detect and throw error for multiple token classes", async () => { + sandbox + .stub( + utilRouteInst.blockbook.testableComponents, + "balanceFromBlockbook" + ) + .resolves(mockData.mockBalance) + + sandbox + .stub( + utilRouteInst.blockbook.testableComponents, + "utxosFromBlockbook" + ) + .resolves(mockData.mockThreeUtxos) + + // Force token utxo to appear as regular BCH utxo. + sandbox + .stub(utilRouteInst.bchjs.SLP.Utils, "tokenUtxoDetails") + .resolves(mockData.multipleTokens) + + // Mock sendRawTransaction() so that the hex does not actually get broadcast + // to the network. + sandbox + .stub(utilRouteInst.bchjs.RawTransactions, "sendRawTransaction") + .resolves("test-txid") + + req.body = { + wif: "L5GEFg1tETLWBugmhSo9Zc4ms968qVmfmTroDxsJ982AiudAQGyt", + toAddr: "bitcoincash:qz2qn6zt4qmacf4r6c0e2pdcqsgnkxaa3ql2xpee6p" + } + + const result = await utilRouteInst.sweepWif(req, res) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + assert.equal(res.statusCode, 422) + assert.property(result, "error") + assert.include( + result.error, + "Multiple token classes detected. This function only supports a single class of token" + ) + }) + } }) })