diff --git a/lib/expressapp.js b/lib/expressapp.js index cb60767..bdfffc5 100644 --- a/lib/expressapp.js +++ b/lib/expressapp.js @@ -404,10 +404,12 @@ ExpressApp.prototype.start = function(opts, cb) { router.get('/v1/sendmaxinfo/', function(req, res) { getServerWithAuth(req, res, function(server) { + var q = req.query; var opts = {}; - opts.feePerKb = +req.query.feePerKb; - if (req.query.excludeUnconfirmedUtxos == '1') opts.excludeUnconfirmedUtxos = true; - if (req.query.returnInputs == '1') opts.returnInputs = true; + if (q.feePerKb) opts.feePerKb = +q.feePerKb; + if (q.feeLevel) opts.feeLevel = q.feeLevel; + if (q.excludeUnconfirmedUtxos == '1') opts.excludeUnconfirmedUtxos = true; + if (q.returnInputs == '1') opts.returnInputs = true; server.getSendMaxInfo(opts, function(err, info) { if (err) return returnError(err, res, req); res.json(info); diff --git a/lib/server.js b/lib/server.js index cdc5fdf..2c95164 100644 --- a/lib/server.js +++ b/lib/server.js @@ -1150,7 +1150,8 @@ WalletService.prototype.getBalance = function(opts, cb) { /** * Return info needed to send all funds in the wallet * @param {Object} opts - * @param {string} opts.feePerKb - The fee per KB used to compute the TX. + * @param {number} opts.feeLevel[='normal'] - Optional. Specify the fee level for this TX ('priority', 'normal', 'economy', 'superEconomy') as defined in Defaults.FEE_LEVELS. + * @param {number} opts.feePerKb - Optional. Specify the fee per KB for this TX (in satoshi). * @param {string} opts.excludeUnconfirmedUtxos[=false] - Optional. Do not use UTXOs of unconfirmed transactions as inputs * @param {string} opts.returnInputs[=false] - Optional. Return the list of UTXOs that would be included in the tx. * @returns {Object} sendMaxInfo @@ -1161,7 +1162,26 @@ WalletService.prototype.getSendMaxInfo = function(opts, cb) { opts = opts || {}; - if (!checkRequired(opts, ['feePerKb'], cb)) return; + var feeArgs = !!opts.feeLevel + _.isNumber(opts.feePerKb); + if (feeArgs > 1) + return cb(new ClientError('Only one of feeLevel/feePerKb can be specified')); + + if (feeArgs == 0) { + log.debug('No fee provided, using "normal" fee level'); + opts.feeLevel = 'normal'; + } + + if (opts.feeLevel) { + if (!_.any(Defaults.FEE_LEVELS, { + name: opts.feeLevel + })) + return cb(new ClientError('Invalid fee level. Valid values are ' + _.pluck(Defaults.FEE_LEVELS, 'name').join(', '))); + } + + if (_.isNumber(opts.feePerKb)) { + if (opts.feePerKb < Defaults.MIN_FEE_PER_KB || opts.feePerKb > Defaults.MAX_FEE_PER_KB) + return cb(new ClientError('Invalid fee per KB')); + } self.getWallet({}, function(err, wallet) { if (err) return cb(err); @@ -1173,6 +1193,7 @@ WalletService.prototype.getSendMaxInfo = function(opts, cb) { size: 0, amount: 0, fee: 0, + feePerKb: 0, inputs: [], utxosBelowFee: 0, amountBelowFee: 0, @@ -1190,47 +1211,53 @@ WalletService.prototype.getSendMaxInfo = function(opts, cb) { if (_.isEmpty(inputs)) return cb(null, info); - var txp = Model.TxProposal.create({ - walletId: self.walletId, - network: wallet.network, - walletM: wallet.m, - walletN: wallet.n, - feePerKb: opts.feePerKb, - }); + self._getFeePerKb(wallet, opts, function(err, feePerKb) { + if (err) return cb(err); - var baseTxpSize = txp.getEstimatedSize(); - var baseTxpFee = baseTxpSize * txp.feePerKb / 1000.; - var sizePerInput = txp.getEstimatedSizeForSingleInput(); - var feePerInput = sizePerInput * txp.feePerKb / 1000.; + info.feePerKb = feePerKb; - var partitionedByAmount = _.partition(inputs, function(input) { - return input.satoshis > feePerInput; - }); + var txp = Model.TxProposal.create({ + walletId: self.walletId, + network: wallet.network, + walletM: wallet.m, + walletN: wallet.n, + feePerKb: feePerKb, + }); - info.utxosBelowFee = partitionedByAmount[1].length; - info.amountBelowFee = _.sum(partitionedByAmount[1], 'satoshis'); - inputs = partitionedByAmount[0]; + var baseTxpSize = txp.getEstimatedSize(); + var baseTxpFee = baseTxpSize * txp.feePerKb / 1000.; + var sizePerInput = txp.getEstimatedSizeForSingleInput(); + var feePerInput = sizePerInput * txp.feePerKb / 1000.; - _.each(inputs, function(input, i) { - var sizeInKb = (baseTxpSize + (i + 1) * sizePerInput) / 1000.; - if (sizeInKb > Defaults.MAX_TX_SIZE_IN_KB) { - info.utxosAboveMaxSize = inputs.length - i; - info.amountAboveMaxSize = _.sum(_.slice(inputs, i), 'satoshis'); - return false; + var partitionedByAmount = _.partition(inputs, function(input) { + return input.satoshis > feePerInput; + }); + + info.utxosBelowFee = partitionedByAmount[1].length; + info.amountBelowFee = _.sum(partitionedByAmount[1], 'satoshis'); + inputs = partitionedByAmount[0]; + + _.each(inputs, function(input, i) { + var sizeInKb = (baseTxpSize + (i + 1) * sizePerInput) / 1000.; + if (sizeInKb > Defaults.MAX_TX_SIZE_IN_KB) { + info.utxosAboveMaxSize = inputs.length - i; + info.amountAboveMaxSize = _.sum(_.slice(inputs, i), 'satoshis'); + return false; + } + txp.inputs.push(input); + }); + + if (_.isEmpty(txp.inputs)) return cb(null, info); + + info.size = txp.getEstimatedSize(); + info.fee = txp.getEstimatedFee(); + info.amount = _.sum(txp.inputs, 'satoshis') - info.fee; + if (opts.returnInputs) { + info.inputs = _.shuffle(txp.inputs); } - txp.inputs.push(input); + + return cb(null, info); }); - - if (_.isEmpty(txp.inputs)) return cb(null, info); - - info.size = txp.getEstimatedSize(); - info.fee = txp.getEstimatedFee(); - info.amount = _.sum(txp.inputs, 'satoshis') - info.fee; - if (opts.returnInputs) { - info.inputs = _.shuffle(txp.inputs); - } - - return cb(null, info); }); }); }; @@ -1704,7 +1731,7 @@ WalletService.prototype._validateAndSanitizeTxOpts = function(wallet, opts, cb) return next(new ClientError('Invalid fee per KB')); } - if (_.isNumber(opts.fee) && !opts.inputs) + if (_.isNumber(opts.fee) && _.isEmpty(opts.inputs)) return next(new ClientError('fee can only be set when inputs are specified')); next(); @@ -1745,6 +1772,27 @@ WalletService.prototype._validateAndSanitizeTxOpts = function(wallet, opts, cb) ], cb); }; +WalletService.prototype._getFeePerKb = function(wallet, opts, cb) { + var self = this; + + if (_.isNumber(opts.feePerKb)) return cb(null, opts.feePerKb); + self.getFeeLevels({ + network: wallet.network + }, function(err, levels) { + if (err) return cb(err); + var level = _.find(levels, { + level: opts.feeLevel + }); + if (!level) { + var msg = 'Could not compute fee for "' + opts.feeLevel + '" level'; + log.error(msg); + return cb(new ClientError(msg)); + } + return cb(null, level.feePerKb); + }); +}; + + /** * Creates a new transaction proposal. * @param {Object} opts @@ -1791,26 +1839,6 @@ WalletService.prototype.createTx = function(opts, cb) { } }; - - function getFeePerKb(wallet, cb) { - if (opts.inputs && _.isNumber(opts.fee)) return cb(); - if (_.isNumber(opts.feePerKb)) return cb(null, opts.feePerKb); - self.getFeeLevels({ - network: wallet.network - }, function(err, levels) { - if (err) return cb(err); - var level = _.find(levels, { - level: opts.feeLevel - }); - if (!level) { - var msg = 'Could not compute fee for "' + opts.feeLevel + '" level'; - log.error(msg); - return cb(new ClientError(msg)); - } - return cb(null, level.feePerKb); - }); - }; - function checkTxpAlreadyExists(txProposalId, cb) { if (!txProposalId) return cb(); self.storage.fetchTx(self.walletId, txProposalId, cb); @@ -1847,7 +1875,8 @@ WalletService.prototype.createTx = function(opts, cb) { }); }, function(next) { - getFeePerKb(wallet, function(err, fee) { + if (_.isNumber(opts.fee) && !_.isEmpty(opts.inputs)) return next(); + self._getFeePerKb(wallet, opts, function(err, fee) { feePerKb = fee; next(); }); diff --git a/package.json b/package.json index 5eab358..a9929a1 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "bitcore-wallet-service", "description": "A service for Mutisig HD Bitcoin Wallets", "author": "BitPay Inc", - "version": "1.10.0", + "version": "1.11.0", "keywords": [ "bitcoin", "copay", diff --git a/test/integration/server.js b/test/integration/server.js index 705fdce..18dc51a 100644 --- a/test/integration/server.js +++ b/test/integration/server.js @@ -4184,6 +4184,57 @@ describe('Wallet service', function() { }); }); }); + describe('Fee level', function() { + it('should correctly get send max info using feeLevel', function(done) { + helpers.stubFeeLevels({ + 1: 400e2, + 2: 200e2, + 6: 180e2, + 24: 90e2, + }); + helpers.stubUtxos(server, wallet, [0.1, 0.2, 0.3, 0.4], function() { + server.getSendMaxInfo({ + feeLevel: 'economy', + returnInputs: true, + }, function(err, info) { + should.not.exist(err); + should.exist(info); + info.feePerKb.should.equal(180e2); + info.fee.should.equal(info.size * 180e2 / 1000.); + sendTx(info, done); + }); + }); + }); + it('should assume "normal" fee level if not specified', function(done) { + helpers.stubFeeLevels({ + 1: 400e2, + 2: 200e2, + 6: 180e2, + 24: 90e2, + }); + helpers.stubUtxos(server, wallet, [0.1, 0.2, 0.3, 0.4], function() { + server.getSendMaxInfo({}, function(err, info) { + should.not.exist(err); + should.exist(info); + info.feePerKb.should.equal(200e2); + info.fee.should.equal(info.size * 200e2 / 1000.); + done(); + }); + }); + }); + it('should fail on invalid fee level', function(done) { + helpers.stubUtxos(server, wallet, [0.1, 0.2, 0.3, 0.4], function() { + server.getSendMaxInfo({ + feeLevel: 'madeUpLevel', + }, function(err, info) { + should.exist(err); + should.not.exist(info); + err.toString().should.contain('Invalid fee level'); + done(); + }); + }); + }); + }); it('should return inputs in random order', function(done) { // NOTE: this test has a chance of failing of 1 in 1'073'741'824 :P helpers.stubUtxos(server, wallet, _.range(1, 31), function(utxos) {