diff --git a/lib/model/txproposal.js b/lib/model/txproposal.js index 0e8f0b8..0ec513f 100644 --- a/lib/model/txproposal.js +++ b/lib/model/txproposal.js @@ -109,6 +109,10 @@ TxProposal.fromObj = function(obj) { return x; }; +TxProposal.prototype.setInputs = function(inputs) { + this.inputs = inputs; + this.inputPaths = _.pluck(inputs, 'path'); +}; TxProposal.prototype._updateStatus = function() { if (this.status != 'pending') return; diff --git a/lib/server.js b/lib/server.js index f73b727..4cf3db7 100644 --- a/lib/server.js +++ b/lib/server.js @@ -602,7 +602,9 @@ WalletService.prototype._getUtxos = function(cb) { return cb(new ClientError('BLOCKCHAINERROR', 'Could not fetch unspent outputs')); } var utxos = _.map(inutxos, function(utxo) { - return _.pick(utxo, ['txid', 'vout', 'address', 'scriptPubKey', 'amount', 'satoshis']); + var u = _.pick(utxo, ['txid', 'vout', 'address', 'scriptPubKey', 'amount', 'satoshis']); + u.locked = false; + return u; }); self.getPendingTxs({}, function(err, txps) { if (err) return cb(err); @@ -657,6 +659,31 @@ WalletService.prototype._totalizeUtxos = function(utxos) { }; +WalletService.prototype._computeKbToSendMax = function(utxos, amount, cb) { + var self = this; + + var unlockedUtxos = _.filter(utxos, { + locked: false + }); + if (_.isEmpty(unlockedUtxos)) return cb(null, 0); + + self.getWallet({}, function(err, wallet) { + if (err) return cb(err); + + var t = WalletUtils.newBitcoreTransaction(); + try { + _.each(unlockedUtxos, function(i) { + t.from(i, i.publicKeys, wallet.m); + }); + t.to(utxos[0].address, amount); + var sizeInKb = Math.ceil(t._estimateSize() / 1000); + return cb(null, sizeInKb); + } catch (ex) { + return cb(ex); + } + }); +}; + /** * Creates a new transaction proposal. * @param {Object} opts @@ -686,7 +713,13 @@ WalletService.prototype.getBalance = function(opts, cb) { balance.byAddress = _.values(byAddress); - return cb(null, balance); + self._computeKbToSendMax(utxos, balance.totalAmount - balance.lockedAmount, function(err, sizeInKb) { + if (err) { + log.error('Could not compute fees needed to transfer max amount', err); + } + balance.totalKbToSendMax = sizeInKb || 0; + return cb(null, balance); + }); }); }; @@ -720,13 +753,12 @@ WalletService.prototype._selectTxInputs = function(txp, cb) { if (total >= txp.getTotalAmount()) { try { - txp.inputs = selected; + txp.setInputs(selected); bitcoreTx = txp.getBitcoreTx(); bitcoreError = bitcoreTx.getSerializationError({ disableIsFullySigned: true, }); if (!bitcoreError) { - txp.inputPaths = _.pluck(txp.inputs, 'path'); txp.fee = bitcoreTx.getFee(); return cb(); } diff --git a/package.json b/package.json index c5d6a4f..1881518 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": "0.0.35", + "version": "0.0.36", "keywords": [ "bitcoin", "copay", diff --git a/test/integration/server.js b/test/integration/server.js index 066bd2d..7d19661 100644 --- a/test/integration/server.js +++ b/test/integration/server.js @@ -1144,6 +1144,7 @@ describe('Wallet service', function() { should.exist(balance); balance.totalAmount.should.equal(helpers.toSatoshi(6)); balance.lockedAmount.should.equal(0); + balance.totalKbToSendMax.should.equal(1); should.exist(balance.byAddress); balance.byAddress.length.should.equal(2); balance.byAddress[0].amount.should.equal(helpers.toSatoshi(4)); @@ -1163,6 +1164,7 @@ describe('Wallet service', function() { should.exist(balance); balance.totalAmount.should.equal(0); balance.lockedAmount.should.equal(0); + balance.totalKbToSendMax.should.equal(0); should.exist(balance.byAddress); balance.byAddress.length.should.equal(0); done(); @@ -1177,6 +1179,7 @@ describe('Wallet service', function() { should.exist(balance); balance.totalAmount.should.equal(0); balance.lockedAmount.should.equal(0); + balance.totalKbToSendMax.should.equal(0); should.exist(balance.byAddress); balance.byAddress.length.should.equal(0); done(); @@ -1197,6 +1200,18 @@ describe('Wallet service', function() { }); }); }); + it('should return correct kb to send max', function(done) { + helpers.stubUtxos(server, wallet, _.range(1, 10, 0), function() { + server.getBalance({}, function(err, balance) { + should.not.exist(err); + should.exist(balance); + balance.totalAmount.should.equal(helpers.toSatoshi(9)); + balance.lockedAmount.should.equal(0); + balance.totalKbToSendMax.should.equal(2); + done(); + }); + }); + }); it('should fail gracefully when blockchain is unreachable', function(done) { blockchainExplorer.getUnspentUtxos = sinon.stub().callsArgWith(1, 'dummy error'); server.createAddress({}, function(err, address) { @@ -1688,6 +1703,57 @@ describe('Wallet service', function() { }); }); }); + + it('should be able to send max amount', function(done) { + helpers.stubUtxos(server, wallet, _.range(1, 10, 0), function() { + server.getBalance({}, function(err, balance) { + should.not.exist(err); + balance.totalAmount.should.equal(helpers.toSatoshi(9)); + balance.lockedAmount.should.equal(0); + balance.totalKbToSendMax.should.equal(3); + var max = (balance.totalAmount - balance.lockedAmount) - (balance.totalKbToSendMax * 10000); + var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', max / 1e8, null, TestData.copayers[0].privKey_1H_0); + server.createTx(txOpts, function(err, tx) { + should.not.exist(err); + should.exist(tx); + tx.amount.should.equal(max); + tx.fee.should.equal(3 * 10000); + server.getBalance({}, function(err, balance) { + should.not.exist(err); + balance.lockedAmount.should.equal(helpers.toSatoshi(9)); + done(); + }); + }); + }); + }); + }); + it('should be able to send max non-locked amount', function(done) { + helpers.stubUtxos(server, wallet, _.range(1, 10, 0), function() { + var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 3.5, null, TestData.copayers[0].privKey_1H_0); + server.createTx(txOpts, function(err, tx) { + should.not.exist(err); + server.getBalance({}, function(err, balance) { + should.not.exist(err); + balance.totalAmount.should.equal(helpers.toSatoshi(9)); + balance.lockedAmount.should.equal(helpers.toSatoshi(4)); + balance.totalKbToSendMax.should.equal(2); + var max = (balance.totalAmount - balance.lockedAmount) - (balance.totalKbToSendMax * 2000); + var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', max / 1e8, null, TestData.copayers[0].privKey_1H_0, 2000); + server.createTx(txOpts, function(err, tx) { + should.not.exist(err); + should.exist(tx); + tx.amount.should.equal(max); + tx.fee.should.equal(2 * 2000); + server.getBalance({}, function(err, balance) { + should.not.exist(err); + balance.lockedAmount.should.equal(helpers.toSatoshi(9)); + done(); + }); + }); + }); + }); + }); + }); }); describe('#createTx backoff time', function(done) {