diff --git a/js/controllers/send.js b/js/controllers/send.js index e4f378c1c..d170dbae2 100644 --- a/js/controllers/send.js +++ b/js/controllers/send.js @@ -4,32 +4,6 @@ var preconditions = require('preconditions').singleton(); angular.module('copayApp.controllers').controller('SendController', function($scope, $rootScope, $window, $timeout, $anchorScroll, $modal, isMobile, notification, controllerUtils, rateService) { - controllerUtils.redirIfNotComplete(); - - var w = $rootScope.wallet; - preconditions.checkState(w); - preconditions.checkState(w.settings.unitToSatoshi); - - $rootScope.title = 'Send'; - $scope.loading = false; - var satToUnit = 1 / w.settings.unitToSatoshi; - $scope.defaultFee = bitcore.TransactionBuilder.FEE_PER_1000B_SAT * satToUnit; - $scope.unitToBtc = w.settings.unitToSatoshi / bitcore.util.COIN; - $scope.unitToSatoshi = w.settings.unitToSatoshi; - - $scope.alternativeName = w.settings.alternativeName; - $scope.alternativeIsoCode = w.settings.alternativeIsoCode; - - $scope.isRateAvailable = false; - $scope.rateService = rateService; - - - - rateService.whenAvailable(function() { - $scope.isRateAvailable = true; - $scope.$digest(); - }); - /** * Setting the two related amounts as properties prevents an infinite * recursion for watches while preserving the original angular updates @@ -103,8 +77,7 @@ angular.module('copayApp.controllers').controller('SendController', $scope.submitForm = function(form) { if (form.$invalid) { - var message = 'Unable to send transaction proposal'; - notification.error('Error', message); + $scope.error = 'Unable to send transaction proposal'; return; } @@ -116,8 +89,15 @@ angular.module('copayApp.controllers').controller('SendController', function done(err, ntxid, merchantData) { if (err) { - var message = 'The transaction' + (w.isShared() ? ' proposal' : '') + ' could not be created'; - notification.error('Error', message); + copay.logger.error(err); + + var msg = err.toString(); + + if (msg.match('BIG')) + msg = 'The transaction have too many inputs. Try creating many transactions for smaller amounts.' + + var message = 'The transaction' + (w.isShared() ? ' proposal' : '') + ' could not be created: ' + msg; + $scope.error = message; $scope.loading = false; $scope.loadTxs(); return; @@ -161,7 +141,7 @@ angular.module('copayApp.controllers').controller('SendController', } notification.success('Transaction broadcasted', message); } else { - notification.error('Error', 'There was an error sending the transaction'); + $scope.error = 'There was an error sending the transaction'; } $scope.loading = false; $scope.loadTxs(); @@ -396,13 +376,16 @@ angular.module('copayApp.controllers').controller('SendController', }; $scope.getAvailableAmount = function() { - var amount = ((($rootScope.availableBalance * w.settings.unitToSatoshi).toFixed(0) - bitcore.TransactionBuilder.FEE_PER_1000B_SAT) / w.settings.unitToSatoshi); + if (!$rootScope.safeUnspentCount) return null; + + var estimatedFee = copay.Wallet.estimatedFee($rootScope.safeUnspentCount); + var amount = ((($rootScope.availableBalance * w.settings.unitToSatoshi).toFixed(0) - estimatedFee) / w.settings.unitToSatoshi); + return amount > 0 ? amount : 0; }; $scope.topAmount = function(form) { $scope.amount = $scope.getAvailableAmount(); - form.amount.$pristine = false; }; @@ -411,7 +394,7 @@ angular.module('copayApp.controllers').controller('SendController', $rootScope.txAlertCount = 0; w.sendTx(ntxid, function(txid, merchantData) { if (!txid) { - notification.error('Error', 'There was an error sending the transaction'); + $scope.error = 'There was an error sending the transaction'; } else { if (!merchantData) { notification.success('Transaction broadcasted', 'Transaction id: ' + txid); @@ -435,7 +418,7 @@ angular.module('copayApp.controllers').controller('SendController', $scope.loading = true; w.sign(ntxid, function(ret) { if (!ret) { - notification.error('Error', 'There was an error signing the transaction'); + $scope.error = 'There was an error signing the transaction'; $scope.loadTxs(); } else { var p = w.txProposals.getTxProposal(ntxid); @@ -615,4 +598,31 @@ angular.module('copayApp.controllers').controller('SendController', }); }; + controllerUtils.redirIfNotComplete(); + + var w = $rootScope.wallet; + preconditions.checkState(w); + preconditions.checkState(w.settings.unitToSatoshi); + + $rootScope.title = 'Send'; + $scope.loading = false; + var satToUnit = 1 / w.settings.unitToSatoshi; + $scope.defaultFee = bitcore.TransactionBuilder.FEE_PER_1000B_SAT * satToUnit; + $scope.unitToBtc = w.settings.unitToSatoshi / bitcore.util.COIN; + $scope.unitToSatoshi = w.settings.unitToSatoshi; + + $scope.alternativeName = w.settings.alternativeName; + $scope.alternativeIsoCode = w.settings.alternativeIsoCode; + + $scope.isRateAvailable = false; + $scope.rateService = rateService; + $scope.availableBalance = $scope.getAvailableAmount(); + + rateService.whenAvailable(function() { + $scope.isRateAvailable = true; + $scope.$digest(); + }); + + + }); diff --git a/js/models/Insight.js b/js/models/Insight.js index 2a07a56d7..439aced7f 100644 --- a/js/models/Insight.js +++ b/js/models/Insight.js @@ -5,6 +5,7 @@ var async = require('async'); var request = require('request'); var bitcore = require('bitcore'); var io = require('socket.io-client'); +var _ = require('lodash'); var log = require('../log'); var EventEmitter = require('events').EventEmitter; @@ -255,7 +256,7 @@ Insight.prototype.broadcast = function(rawtx, cb) { this.requestPost('/api/tx/send', { rawtx: rawtx }, function(err, res, body) { - if (err || res.status != 200) cb(err || res); + if (err || res.statusCode != 200) cb(err || body); cb(null, body ? body.txid : null); }); }; @@ -310,9 +311,15 @@ Insight.prototype.getUnspent = function(addresses, cb) { this.requestPost('/api/addrs/utxo', { addrs: addresses.join(',') - }, function(err, res, body) { + }, function(err, res, unspentRaw) { if (err || res.statusCode != 200) return cb(err || res); - cb(null, body); + + // This filter out possible broken unspent, as reported on + // https://github.com/bitpay/copay/issues/1585 + // and later gitter conversation. + + var unspent = _.filter(unspentRaw, 'scriptPubKey'); + cb(null, unspent); }); }; diff --git a/js/models/Wallet.js b/js/models/Wallet.js index ac3f1981d..f7d68e65c 100644 --- a/js/models/Wallet.js +++ b/js/models/Wallet.js @@ -30,6 +30,8 @@ var Async = require('./Async'); var Insight = module.exports.Insight = require('./Insight'); var copayConfig = require('../../config'); +var TX_MAX_SIZE_KB = 50; +var TX_MAX_INS = 70; /** * @desc * Wallet manages a private key for Copay, network, storage of the wallet for @@ -1381,14 +1383,17 @@ Wallet.prototype.sendTx = function(ntxid, cb) { var self = this; this.blockchain.broadcast(txHex, function(err, txid) { - log.debug('Wallet:' + self.id + ' BITCOIND txid:', txid); + if (err) + log.error('Error sending TX:',err); + if (txid) { + log.debug('Wallet:' + self.getName() + ' Broadcasted TX. BITCOIND txid:', txid); self.txProposals.get(ntxid).setSent(txid); self.sendTxProposal(ntxid); self.emitAndKeepAlive('txProposalsUpdated'); return cb(txid); } else { - log.debug('Wallet:' + self.id + ' Sent failed. Checking if the TX was sent already'); + log.info('Wallet:' + self.getName() + '. Sent failed. Checking if the TX was sent already'); self._checkSentTx(ntxid, function(txid) { if (txid) self.emitAndKeepAlive('txProposalsUpdated'); @@ -1879,15 +1884,9 @@ Wallet.prototype.createPaymentTxSync = function(options, merchantData, unspent) merchantData.total = merchantData.total.toString(10); - var b; - try { - b = new Builder(opts) - .setUnspent(unspent) - .setOutputs(outs); - } catch (e) { - log.debug(e.message); - return; - }; + var b = new Builder(opts) + .setUnspent(unspent) + .setOutputs(outs); merchantData.pr.pd.outputs.forEach(function(output, i) { var script = { @@ -2155,12 +2154,23 @@ Wallet.prototype.addressIsOwn = function(addrStr, opts) { }; +/* + * Estimate a tx fee in satoshis given its input count + * only for spending all wallet funds + */ +Wallet.estimatedFee = function(unspentCount) { + preconditions.checkArgument(_.isNumber(unspentCount)); + var estimatedSizeKb = Math.ceil( ( 500 + unspentCount * 250) / 1024 ); + return parseInt( estimatedSizeKb * bitcore.TransactionBuilder.FEE_PER_1000B_SAT); +}; + /** * @callback {getBalanceCallback} * @param {string=} err - an error, if any * @param {number} balance - total number of satoshis for all addresses * @param {Object} balanceByAddr - maps string addresses to satoshis * @param {number} safeBalance - total number of satoshis in UTXOs that are not part of any TxProposal + * @param {number} safeUnspentCount - total number of safe unspent Outputs that make this balance. */ /** * @desc Returns the balances for all addresses in Satoshis @@ -2191,14 +2201,16 @@ Wallet.prototype.getBalance = function(cb) { balance = parseInt(balance.toFixed(0), 10); - for (var i = 0; i < safeUnspent.length; i++) { + var safeUnspentCount = safeUnspent.length; + + for (var i = 0; i < safeUnspentCount; i++) { var u = safeUnspent[i]; var amt = u.amount * COIN; safeBalance += amt; } safeBalance = parseInt(safeBalance.toFixed(0), 10); - return cb(null, balance, balanceByAddr, safeBalance); + return cb(null, balance, balanceByAddr, safeBalance, safeUnspentCount); }); }; @@ -2320,7 +2332,14 @@ Wallet.prototype.createTx = function(toAddress, amountSatStr, comment, opts, cb) this.getUnspent(function(err, safeUnspent) { if (err) return cb(new Error('Could not get list of UTXOs')); - var ntxid = self.createTxSync(toAddress, amountSatStr, comment, safeUnspent, opts); + var ntxid; + try { + ntxid = self.createTxSync(toAddress, amountSatStr, comment, safeUnspent, opts); + log.debug('TX Created: ntxid', ntxid); //TODO + } catch (e) { + return cb(e); + } + if (!ntxid) { return cb(new Error('Error creating the transaction')); } @@ -2365,21 +2384,21 @@ Wallet.prototype.createTxSync = function(toAddress, amountSatStr, comment, utxos opts[k] = Wallet.builderOpts[k]; } - var b; + var b = new Builder(opts) + .setUnspent(utxos) + .setOutputs([{ + address: toAddress.data, + amountSatStr: amountSatStr, + }]); - try { - b = new Builder(opts) - .setUnspent(utxos) - .setOutputs([{ - address: toAddress.data, - amountSatStr: amountSatStr, - }]); - } catch (e) { - log.debug(e.message); - return; - }; + log.debug('Creating TX: Builder ready'); var selectedUtxos = b.getSelectedUnspent(); + + if (selectedUtxos.length > TX_MAX_INS) + throw new Error('BIG: Resulting TX is too big:' + selectedUtxos.length + ' inputs. Aborting'); + + var inputChainPaths = selectedUtxos.map(function(utxo) { return pkr.pathForAddress(utxo.address); }); @@ -2396,6 +2415,12 @@ Wallet.prototype.createTxSync = function(toAddress, amountSatStr, comment, utxos if (!tx.countInputSignatures(0)) throw new Error('Could not sign generated tx'); + var txSize = tx.getSize(); + + if (txSize/1024 > TX_MAX_SIZE_KB) + throw new Error('BIG: Resulting TX is too big ' + txSize + ' bytes. Aborting'); + + var me = {}; me[myId] = now; diff --git a/js/services/controllerUtils.js b/js/services/controllerUtils.js index 94e672817..a106f8d1f 100644 --- a/js/services/controllerUtils.js +++ b/js/services/controllerUtils.js @@ -241,18 +241,19 @@ angular.module('copayApp.services') }; - root._computeBalance = function(w, cb) { + root._fetchBalance = function(w, cb) { cb = cb || function() {}; var satToUnit = 1 / w.settings.unitToSatoshi; var COIN = bitcore.util.COIN; - w.getBalance(function(err, balanceSat, balanceByAddrSat, safeBalanceSat) { + w.getBalance(function(err, balanceSat, balanceByAddrSat, safeBalanceSat, safeUnspentCount) { if (err) return cb(err); var r = {}; r.totalBalance = balanceSat * satToUnit; r.totalBalanceBTC = (balanceSat / COIN); r.availableBalance = safeBalanceSat * satToUnit; + r.safeUnspentCount = safeUnspentCount; r.availableBalanceBTC = (safeBalanceSat / COIN); r.lockedBalance = (balanceSat - safeBalanceSat) * satToUnit; @@ -276,22 +277,10 @@ angular.module('copayApp.services') }); }; - root._updateScope = function(w, data, $scope, cb) { - $scope.totalBalance = data.totalBalance; - $scope.totalBalanceBTC = data.totalBalanceBTC; - $scope.availableBalance = data.availableBalance; - $scope.availableBalanceBTC = data.availableBalanceBTC; - - $scope.lockedBalance = data.lockedBalance; - $scope.lockedBalanceBTC = data.lockedBalanceBTC; - - $scope.balanceByAddr = data.balanceByAddr; - - $scope.totalBalanceAlternative = data.totalBalanceAlternative; - $scope.alternativeIsoCode = data.alternativeIsoCode; - $scope.lockedBalanceAlternative = data.lockedBalanceAlternative; - $scope.alternativeConversionRate = data.alternativeConversionRate; - + root._updateScope = function(w, data, scope, cb) { + _.each(data, function(v, k) { + scope[k] = data[k]; + }) if (cb) return cb(); }; @@ -320,7 +309,7 @@ angular.module('copayApp.services') scope.updatingBalance = true; } - root._computeBalance(w, function(err, res) { + root._fetchBalance(w, function(err, res) { if (err) throw err; _balanceCache[wid] = res; root._updateScope(w, _balanceCache[wid], scope, function() { diff --git a/test/Wallet.js b/test/Wallet.js index 7090943ac..cdfa81822 100644 --- a/test/Wallet.js +++ b/test/Wallet.js @@ -1005,6 +1005,18 @@ describe('Wallet model', function() { }); }); + describe('#estimatedFee', function() { + it('should calculate estimated fee', function() { + var COIN = 100000000; + Wallet.estimatedFee(1).should.equal(0.0001 * COIN); + Wallet.estimatedFee(2).should.equal(0.0001 * COIN); + Wallet.estimatedFee(3).should.equal(0.0002 * COIN); + Wallet.estimatedFee(1000).should.equal(0.0245 * COIN); + }); + }); + + + describe('#send', function() { it('should call this.network.send', function() { var w = cachedCreateW2(); diff --git a/test/blockchain.Insight.js b/test/blockchain.Insight.js index 244bde8ee..386e16bbf 100644 --- a/test/blockchain.Insight.js +++ b/test/blockchain.Insight.js @@ -128,7 +128,7 @@ describe('Insight model', function() { sinon.stub(blockchain, "requestPost", function(url, data, cb) { url.should.be.equal('/api/tx/send'); - var res = {status: 200}; + var res = {statusCode: 200}; var body = {txid: 1234}; setTimeout(function() { cb(null, res, body); diff --git a/test/unit/controllers/controllersSpec.js b/test/unit/controllers/controllersSpec.js index 654484485..dfc539e55 100644 --- a/test/unit/controllers/controllersSpec.js +++ b/test/unit/controllers/controllersSpec.js @@ -42,6 +42,7 @@ describe("Unit: Controllers", function() { beforeEach(inject(function($controller, $rootScope) { scope = $rootScope.$new(); $rootScope.iden = sinon.stub(); + $rootScope.safeUnspentCount = 1; var w = {}; w.isReady = sinon.stub().returns(true); @@ -467,6 +468,7 @@ describe("Unit: Controllers", function() { expect(form.amount.$pristine).to.equal(false); }); it('should return available amount', function() { + form.amount.$setViewValue(123356); var amount = scope.getAvailableAmount(); expect(amount).to.equal(123356); }); diff --git a/util/swipeWallet.js b/util/swipeWallet.js index 6185dcc18..94186193f 100755 --- a/util/swipeWallet.js +++ b/util/swipeWallet.js @@ -1,8 +1,8 @@ #!/usr/bin/env node - 'use strict'; + + var copay = require('../copay'); -var program = require('commander'); var _ = require('lodash'); var config = require('../config'); var version = require('../version').version; @@ -10,6 +10,22 @@ var sinon = require('sinon'); var bitcore = require('bitcore'); var readline = require('readline'); var async = require('async'); +var program = require('commander'); + +function list(val) { + return val.split(','); +} + +program + .version('0.0.1') + .usage('-d n2kMqQ8Si9GndzQ6FrJxcwHMKacK2rCEpK -n 2 -k tprv8ZgxMBicQKsPem5BuuDT6xY9etUC2RohpUoyzoa1MEkkZyAHhszaHPZTmgDheN31hSP1r6bRwpj2JC66r1CPpftwaRrhz') + .option('-d, --destination ', 'Destination Address') + .option('-n, --required ', 'Required number of signatures', parseInt) + .option('-k, --keys ', 'master private keys, separated by , ', list) + .option('-a, --amount ', 'Optional, amount to transfer, in Satoshis. If not provided, will wipe all funds', parseInt) + .option('-f, --fee [n]', 'Optional, fee in BTC (default 0.0001 BTC), only if amount is not provided', parseFloat) + .parse(process.argv); + var rl = readline.createInterface({ input: process.stdin, @@ -17,26 +33,39 @@ var rl = readline.createInterface({ }); var args = process.argv; -var destAddr = args[2]; -var DFLT_FEE = 0.0001 * bitcore.util.COIN; +var requiredCopayers = program.required; +var extPrivKeys = program.keys; +var destAddr = program.destination; +var amount = program.amount; -if (!args[4]) { - console.log('\n\tusage: swipeWallet.js [ ...]'); - console.log('\t e.g.: ./swipeWallet.js mxBVchwitGLXBHtT4Vah7DdP8J9M23ftE6 2 tprv8ZgxMBicQKsPejj9Xpky8M7NFv7szxqszBR2VvZTEkBTCCXZLtJfQwRxhUycNCu4sqyZepx8AfT1vuJr949np1gxYbZaJK3R9qekYPCZiJz tprv8ZgxMBicQKsPdWe14mn5SPY4zjG7fJnrmhkVZgTHQfYp91Kf1Lxof38KBQJiis4xv2zvZ2pVHgLn4GFRDUd8kR2HkMxDqLDNWTmnKqp95mZ tprv8ZgxMBicQKsPdzoFwT72Lwhr6n48ZyPahTAhPNaoAP4srVA1mcfPon7GWQaiwfAWesWACHm3aCBLYNGNPVKSU3E9vr1cLiBoMkayZiARywe'); +if (amount && program.fee) { + console.log('If amount if given, fee will be automatically calculated'); process.exit(1); } -var requiredCopayers = parseInt(args[3]); -var extPrivKeys = args.slice(4); -var totalCopayers = extPrivKeys.length; +if (!requiredCopayers || !extPrivKeys || !extPrivKeys.length || !destAddr){ + program.outputHelp(); + process.exit(1); +} +// Fee to asign to the tx. Please put a bigger number if you get 'unsufficient unspent' +var fee = program.fee || 0.0001; + + +var totalCopayers = extPrivKeys.length; var addr = new bitcore.Address(destAddr); if (!addr.isValid()) { console.log('\tBad destination address'); //TODO process.exit(1); + } + + + var networkName = addr.network().name; -console.log('\tNetwork: %s\n\tDestination Address:%s\n\tRequired copayers: %d\n\tTotal copayers: %d\n\tKeys:', networkName, destAddr, requiredCopayers, totalCopayers, extPrivKeys); //TODO +console.log('\tNetwork: %s\n\tDestination Address:%s\n\tRequired copayers: %d\n\tTotal copayers: %d\n\tFee: %d\n\tKeys:', + networkName, destAddr, requiredCopayers, + totalCopayers, fee, extPrivKeys); //TODO console.log('\n ----------------------------'); if (requiredCopayers > totalCopayers) @@ -119,17 +148,30 @@ firstWallet.updateIndexes(function() { console.log('Balance per address:', balanceByAddr); //TODO if (!balance) { - console.log('Could not find any balance from the generated wallet'); //TODO - process.exit(1); + throw ('Could not find any coins in the generated wallet'); } - rl.question("\n\tShould I swipe the wallet (destination address" + destAddr + ")?\n\t(`yes` to continue)\n\t", function(answer) { - if (answer !== 'yes') - process.exit(1); - var amount = balance - DFLT_FEE; + if (amount && amount >= balance) + throw ('Not enought balance fund to fullfill ' + amount + ' satoshis'); + + rl.question("\n\tShould I swipe the wallet (destination address is:" + destAddr + " Amount: "+ amount + "Satoshis )?\n\t(`yes` to continue)\n\t", function(answer) { + if (answer !== 'yes') + process.exit(1); + + amount = amount || balance - fee * bitcore.util.COIN; + firstWallet.createTx(destAddr, amount, '', {}, function(err, ntxid) { - console.log('\n\t### Tx Proposal Created...\n\tWith copayer 0 signature.'); + if (err || !ntxid) { + if (err && err.toString().match('BIG')) { + throw new Error('Could not create tx' + err ); + } else { + throw new Error('Could not create tx' + err + '. Try a bigger fee (--fee).'); + } + } + + console.log('\n\t### Tx Proposal Created...\n\tWith copayer 0 signature.'); + if (requiredCopayers === 1) { firstWallet.sendTx(ntxid, function(txid) { console.log('\t ####### SENT TXID:', txid); @@ -161,6 +203,9 @@ firstWallet.updateIndexes(function() { var p = firstWallet.txProposals.getTxProposal(ntxid); if (p.builder.isFullySigned()) { console.log('\t FULLY SIGNED. BROADCASTING NOW....'); + var tx = p.builder.build(); + var txHex = tx.serialize().toString('hex'); + //console.log('\t RAW TX: ', txHex); firstWallet.sendTx(ntxid, function(txid) { console.log('\t ####### SENT TXID:', txid); process.exit(1); diff --git a/views/send.html b/views/send.html index 7707ce9dd..b5a2499d3 100644 --- a/views/send.html +++ b/views/send.html @@ -11,6 +11,12 @@