diff --git a/config.js b/config.js index c0479c3..58b9711 100644 --- a/config.js +++ b/config.js @@ -43,6 +43,7 @@ var config = { testnet: { provider: 'insight', url: 'https://test-insight.bitpay.com:443', + // url: 'http://localhost:3001', // Multiple servers (in priority order) // url: ['http://a.b.c', 'https://test-insight.bitpay.com:443'], }, diff --git a/lib/common/defaults.js b/lib/common/defaults.js index 362363b..8b8de41 100644 --- a/lib/common/defaults.js +++ b/lib/common/defaults.js @@ -49,4 +49,18 @@ Defaults.FIAT_RATE_MAX_LOOK_BACK_TIME = 120; // In minutes Defaults.HISTORY_LIMIT = 100; +// The maximum amount of an UTXO to be considered too big to be used in the tx before exploring smaller +// alternatives (proportinal to tx amount). +Defaults.UTXO_SELECTION_MAX_SINGLE_UTXO_FACTOR = 2; + +// The minimum amount an UTXO need to contribute proportional to tx amount. +Defaults.UTXO_SELECTION_MIN_TX_AMOUNT_VS_UTXO_FACTOR = 0.1; + +// The maximum threshold to consider fees non-significant in relation to tx amount. +Defaults.UTXO_SELECTION_MAX_FEE_VS_TX_AMOUNT_FACTOR = 0.05; + +// The maximum amount to pay for using small inputs instead of one big input +// when fees are significant (proportional to how much we would pay for using that big input only). +Defaults.UTXO_SELECTION_MAX_FEE_VS_SINGLE_UTXO_FEE_FACTOR = 5; + module.exports = Defaults; diff --git a/lib/common/utils.js b/lib/common/utils.js index 01f1420..a6234df 100644 --- a/lib/common/utils.js +++ b/lib/common/utils.js @@ -23,7 +23,7 @@ Utils.checkRequired = function(obj, args) { * @return {number} */ Utils.strip = function(number) { - return (parseFloat(number.toPrecision(12))); + return parseFloat(number.toPrecision(12)); } /* TODO: It would be nice to be compatible with bitcoind signmessage. How @@ -66,6 +66,11 @@ Utils.formatAmount = function(satoshis, unit, opts) { maxDecimals: 0, minDecimals: 0, }, + sat: { + toSatoshis: 1, + maxDecimals: 0, + minDecimals: 0, + } }; $.shouldBeNumber(satoshis); @@ -88,10 +93,33 @@ Utils.formatAmount = function(satoshis, unit, opts) { opts = opts || {}; - var u = UNITS[unit]; + var u = _.assign(UNITS[unit], opts); var amount = (satoshis / u.toSatoshis).toFixed(u.maxDecimals); return addSeparators(amount, opts.thousandsSeparator || ',', opts.decimalSeparator || '.', u.minDecimals); }; +Utils.formatAmountInBtc = function(amount) { + return Utils.formatAmount(amount, 'btc', { + minDecimals: 8, + maxDecimals: 8, + }) + 'btc'; +}; + +Utils.formatUtxos = function(utxos) { + if (_.isEmpty(utxos)) return 'none'; + return _.map([].concat(utxos), function(i) { + var amount = Utils.formatAmountInBtc(i.satoshis); + var confirmations = i.confirmations ? i.confirmations + 'c' : 'u'; + return amount + '/' + confirmations; + }).join(', '); +}; + +Utils.formatRatio = function(ratio) { + return (ratio * 100.).toFixed(4) + '%'; +}; + +Utils.formatSize = function(size) { + return (size / 1000.).toFixed(4) + 'kB'; +}; module.exports = Utils; diff --git a/lib/model/txproposal.js b/lib/model/txproposal.js index 0498702..6f5d782 100644 --- a/lib/model/txproposal.js +++ b/lib/model/txproposal.js @@ -33,7 +33,6 @@ TxProposal.create = function(opts) { x.message = opts.message; x.payProUrl = opts.payProUrl; x.changeAddress = opts.changeAddress; - x.setInputs(opts.inputs); x.outputs = _.map(opts.outputs, function(output) { return _.pick(output, ['amount', 'toAddress', 'message']); }); @@ -44,7 +43,6 @@ TxProposal.create = function(opts) { x.requiredRejections = Math.min(x.walletM, x.walletN - x.walletM + 1), x.status = 'temporary'; x.actions = []; - x.fee = null; x.feePerKb = opts.feePerKb; x.excludeUnconfirmedUtxos = opts.excludeUnconfirmedUtxos; @@ -59,6 +57,9 @@ TxProposal.create = function(opts) { } catch (ex) {} $.checkState(_.contains(_.values(Constants.NETWORKS), x.network)); + x.setInputs(opts.inputs); + x.fee = opts.fee; + return x; }; @@ -137,6 +138,7 @@ TxProposal.prototype._buildTx = function() { switch (self.addressType) { case Constants.SCRIPT_TYPES.P2SH: _.each(self.inputs, function(i) { + $.checkState(i.publicKeys, 'Inputs should include public keys'); t.from(i, i.publicKeys, self.requiredSignatures); }); break; @@ -158,7 +160,13 @@ TxProposal.prototype._buildTx = function() { }); t.fee(self.fee); - t.change(self.changeAddress.address); + + var totalInputs = _.sum(self.inputs, 'satoshis'); + var totalOutputs = _.sum(self.outputs, 'satoshis'); + + if (totalInputs - totalOutputs - self.fee > 0) { + t.change(self.changeAddress.address); + } // Shuffle outputs for improved privacy if (t.outputs.length > 1) { @@ -173,8 +181,8 @@ TxProposal.prototype._buildTx = function() { }); } - // Validate inputs vs outputs independently of Bitcore - var totalInputs = _.sum(self.inputs, 'satoshis'); + // Validate actual inputs vs outputs independently of Bitcore + var totalInputs = _.sum(t.inputs, 'satoshis'); var totalOutputs = _.sum(t.outputs, 'satoshis'); $.checkState(totalInputs - totalOutputs <= Defaults.MAX_TX_FEE); @@ -219,13 +227,22 @@ TxProposal.prototype.getRawTx = function() { return t.uncheckedSerialize(); }; +TxProposal.prototype.getEstimatedSizeForSingleInput = function() { + switch (this.addressType) { + case Constants.SCRIPT_TYPES.P2PKH: + return 147; + default: + case Constants.SCRIPT_TYPES.P2SH: + return this.requiredSignatures * 72 + this.walletN * 36 + 44; + } +}; + TxProposal.prototype.getEstimatedSize = function() { // Note: found empirically based on all multisig P2SH inputs and within m & n allowed limits. - var safetyMargin = 0.05; - var walletM = this.requiredSignatures; + var safetyMargin = 0.02; var overhead = 4 + 4 + 9 + 9; - var inputSize = walletM * 72 + this.walletN * 36 + 44; + var inputSize = this.getEstimatedSizeForSingleInput(); var outputSize = 34; var nbInputs = this.inputs.length; var nbOutputs = (_.isArray(this.outputs) ? Math.max(1, this.outputs.length) : 1) + 1; diff --git a/lib/model/txproposal_legacy.js b/lib/model/txproposal_legacy.js index a2af008..da39d8f 100644 --- a/lib/model/txproposal_legacy.js +++ b/lib/model/txproposal_legacy.js @@ -273,16 +273,19 @@ TxProposal.prototype.getRawTx = function() { return t.uncheckedSerialize(); }; +TxProposal.prototype.getEstimatedSizeForSingleInput = function() { + return this.requiredSignatures * 72 + this.walletN * 36 + 44; +}; + TxProposal.prototype.getEstimatedSize = function() { // Note: found empirically based on all multisig P2SH inputs and within m & n allowed limits. var safetyMargin = 0.05; - var walletM = this.requiredSignatures; var overhead = 4 + 4 + 9 + 9; - var inputSize = walletM * 72 + this.walletN * 36 + 44; + var inputSize = this.getEstimatedSizeForSingleInput(); var outputSize = 34; var nbInputs = this.inputs.length; - var nbOutputs = (_.isArray(this.outputs) ? this.outputs.length : 1) + 1; + var nbOutputs = (_.isArray(this.outputs) ? Math.max(1, this.outputs.length) : 1) + 1; var size = overhead + inputSize * nbInputs + outputSize * nbOutputs; diff --git a/lib/server.js b/lib/server.js index b2b1759..1eefa57 100644 --- a/lib/server.js +++ b/lib/server.js @@ -1230,7 +1230,11 @@ WalletService.prototype.getFeeLevels = function(opts, cb) { }); }; -WalletService.prototype._checkTxAndEstimateFee = function(txp) { +WalletService.prototype._estimateFee = function(txp) { + txp.estimateFee(); +}; + +WalletService.prototype._checkTx = function(txp) { var bitcoreError; var serializationOpts = { @@ -1241,7 +1245,8 @@ WalletService.prototype._checkTxAndEstimateFee = function(txp) { serializationOpts.disableLargeFees = true; } - txp.estimateFee(); + if (txp.getEstimatedSize() / 1000 > Defaults.MAX_TX_SIZE_IN_KB) + return Errors.TX_MAX_SIZE_EXCEEDED; try { var bitcoreTx = txp.getBitcoreTx(); @@ -1264,40 +1269,164 @@ WalletService.prototype._checkTxAndEstimateFee = function(txp) { WalletService.prototype._selectTxInputs = function(txp, utxosToExclude, cb) { var self = this; - //todo: check inputs are ours and has enough value - if (txp.inputs && txp.inputs.length > 0) { - return cb(self._checkTxAndEstimateFee(txp)); - } - function sortUtxos(utxos) { - var list = _.map(utxos, function(utxo) { - var order; - if (utxo.confirmations == 0) { - order = 0; - } else if (utxo.confirmations < 6) { - order = -1; - } else { - order = -2; - } - return { - order: order, - utxo: utxo - }; - }); - return _.pluck(_.sortBy(list, 'order'), 'utxo'); - }; + //todo: check inputs are ours and have enough value + if (txp.inputs && !_.isEmpty(txp.inputs)) { + if (!_.isNumber(txp.fee)) + self._estimateFee(txp); + return cb(self._checkTx(txp)); + } - self._getUtxosForCurrentWallet(null, function(err, utxos) { - if (err) return cb(err); + var txpAmount = txp.getTotalAmount(); + var baseTxpSize = txp.getEstimatedSize(); + var baseTxpFee = baseTxpSize * txp.feePerKb / 1000.; + var sizePerInput = txp.getEstimatedSizeForSingleInput(); + var feePerInput = sizePerInput * txp.feePerKb / 1000.; + function sanitizeUtxos(utxos) { var excludeIndex = _.reduce(utxosToExclude, function(res, val) { res[val] = val; return res; }, {}); - utxos = _.reject(utxos, function(utxo) { - return excludeIndex[utxo.txid + ":" + utxo.vout]; + return _.filter(utxos, function(utxo) { + if (utxo.locked) return false; + if (utxo.satoshis <= feePerInput) return false; + if (txp.excludeUnconfirmedUtxos && !utxo.confirmations) return false; + if (excludeIndex[utxo.txid + ":" + utxo.vout]) return false; + return true; }); + }; + + function partitionUtxos(utxos) { + return _.groupBy(utxos, function(utxo) { + if (utxo.confirmations == 0) return '0' + if (utxo.confirmations < 6) return '<6'; + return '6+'; + }); + }; + + function select(utxos, cb) { + var totalValueInUtxos = _.sum(utxos, 'satoshis'); + var netValueInUtxos = totalValueInUtxos - baseTxpFee - (utxos.length * feePerInput); + + if (totalValueInUtxos < txpAmount) { + log.debug('Total value in all utxos (' + Utils.formatAmountInBtc(totalValueInUtxos) + ') is insufficient to cover for txp amount (' + Utils.formatAmountInBtc(txpAmount) + ')'); + return cb(Errors.INSUFFICIENT_FUNDS); + } + if (netValueInUtxos < txpAmount) { + log.debug('Value after fees in all utxos (' + Utils.formatAmountInBtc(netValueInUtxos) + ') is insufficient to cover for txp amount (' + Utils.formatAmountInBtc(txpAmount) + ')'); + return cb(Errors.INSUFFICIENT_FUNDS_FOR_FEE); + } + + var bigInputThreshold = txpAmount * Defaults.UTXO_SELECTION_MAX_SINGLE_UTXO_FACTOR + (baseTxpFee + feePerInput); + log.debug('Big input threshold ' + Utils.formatAmountInBtc(bigInputThreshold)); + + var partitions = _.partition(utxos, function(utxo) { + return utxo.satoshis > bigInputThreshold; + }); + + var bigInputs = _.sortBy(partitions[0], 'satoshis'); + var smallInputs = _.sortBy(partitions[1], function(utxo) { + return -utxo.satoshis; + }); + + log.debug('Considering ' + bigInputs.length + ' big inputs (' + Utils.formatUtxos(bigInputs) + ')'); + log.debug('Considering ' + smallInputs.length + ' small inputs (' + Utils.formatUtxos(smallInputs) + ')'); + + var total = 0; + var netTotal = -baseTxpFee; + var selected = []; + var fee; + var error; + + _.each(smallInputs, function(input, i) { + log.debug('Input #' + i + ': ' + Utils.formatUtxos(input)); + + var netInputAmount = input.satoshis - feePerInput; + + log.debug('The input contributes ' + Utils.formatAmountInBtc(netInputAmount)); + + selected.push(input); + + total += input.satoshis; + netTotal += netInputAmount; + + var txpSize = baseTxpSize + selected.length * sizePerInput; + fee = Math.round(baseTxpFee + selected.length * feePerInput); + + log.debug('Tx size: ' + Utils.formatSize(txpSize) + ', Tx fee: ' + Utils.formatAmountInBtc(fee)); + + var feeVsAmountRatio = fee / txpAmount; + var amountVsUtxoRatio = netInputAmount / txpAmount; + + log.debug('Fee/Tx amount: ' + Utils.formatRatio(feeVsAmountRatio) + ' (max: ' + Utils.formatRatio(Defaults.UTXO_SELECTION_MAX_FEE_VS_TX_AMOUNT_FACTOR) + ')'); + log.debug('Tx amount/Input amount:' + Utils.formatRatio(amountVsUtxoRatio) + ' (min: ' + Utils.formatRatio(Defaults.UTXO_SELECTION_MIN_TX_AMOUNT_VS_UTXO_FACTOR) + ')'); + + if (txpSize / 1000. > Defaults.MAX_TX_SIZE_IN_KB) { + log.debug('Breaking because tx size (' + Utils.formatSize(txpSize) + ') is too big (max: ' + Utils.formatSize(Defaults.MAX_TX_SIZE_IN_KB * 1000.) + ')'); + error = Errors.TX_MAX_SIZE_EXCEEDED; + return false; + } + + if (!_.isEmpty(bigInputs)) { + if (amountVsUtxoRatio < Defaults.UTXO_SELECTION_MIN_TX_AMOUNT_VS_UTXO_FACTOR) { + log.debug('Breaking because utxo is too small compared to tx amount'); + return false; + } + + if (feeVsAmountRatio > Defaults.UTXO_SELECTION_MAX_FEE_VS_TX_AMOUNT_FACTOR) { + var feeVsSingleInputFeeRatio = fee / (baseTxpFee + feePerInput); + log.debug('Fee/Single-input fee: ' + Utils.formatRatio(feeVsSingleInputFeeRatio) + ' (max: ' + Utils.formatRatio(Defaults.UTXO_SELECTION_MAX_FEE_VS_SINGLE_UTXO_FEE_FACTOR) + ')' + ' loses wrt single-input tx: ' + Utils.formatAmountInBtc((selected.length - 1) * feePerInput)); + if (feeVsSingleInputFeeRatio > Defaults.UTXO_SELECTION_MAX_FEE_VS_SINGLE_UTXO_FEE_FACTOR) { + log.debug('Breaking because fee is too significant compared to tx amount and it is too expensive compared to using single input'); + return false; + } + } + } + + log.debug('Cumuled total so far: ' + Utils.formatAmountInBtc(total) + ', Net total so far: ' + Utils.formatAmountInBtc(netTotal)); + + if (netTotal >= txpAmount) { + var changeAmount = Math.round(total - txpAmount - fee); + log.debug('Tx change: ', Utils.formatAmountInBtc(changeAmount)); + + if (changeAmount > 0 && changeAmount <= Bitcore.Transaction.DUST_AMOUNT) { + log.debug('Change below dust amount (' + Utils.formatAmountInBtc(Bitcore.Transaction.DUST_AMOUNT) + ')'); + // Remove dust change by incrementing fee + fee += changeAmount; + } + + return false; + } + }); + + if (netTotal < txpAmount) { + log.debug('Could not reach Txp total (' + Utils.formatAmountInBtc(txpAmount) + '), still missing: ' + Utils.formatAmountInBtc(txpAmount - netTotal)); + + selected = []; + if (!_.isEmpty(bigInputs)) { + var input = _.first(bigInputs); + log.debug('Using big input: ', Utils.formatUtxos(input)); + total = input.satoshis; + fee = Math.round(baseTxpFee + feePerInput); + netTotal = total - fee; + selected = [input]; + } + } + + if (_.isEmpty(selected)) { + log.debug('Could not find enough funds within this utxo subset'); + return cb(error || Errors.INSUFFICIENT_FUNDS_FOR_FEE); + } + + return cb(null, selected, fee); + }; + + log.debug('Selecting inputs for a ' + Utils.formatAmountInBtc(txp.getTotalAmount()) + ' txp'); + + self._getUtxosForCurrentWallet(null, function(err, utxos) { + if (err) return cb(err); var totalAmount; var availableAmount; @@ -1314,36 +1443,72 @@ WalletService.prototype._selectTxInputs = function(txp, utxosToExclude, cb) { if (totalAmount < txp.getTotalAmount()) return cb(Errors.INSUFFICIENT_FUNDS); if (availableAmount < txp.getTotalAmount()) return cb(Errors.LOCKED_FUNDS); - // Prepare UTXOs list - utxos = _.reject(utxos, 'locked'); - if (txp.excludeUnconfirmedUtxos) { - utxos = _.filter(utxos, 'confirmations'); - } + utxos = sanitizeUtxos(utxos); + + log.debug('Considering ' + utxos.length + ' utxos (' + Utils.formatUtxos(utxos) + ')'); + var groups = [6, 1]; + if (!txp.excludeUnconfirmedUtxos) groups.push(0); + + var inputs = []; + var fee; + var selectionError; var i = 0; - var total = 0; - var selected = []; - var inputs = sortUtxos(utxos); + var lastGroupLength; + async.whilst(function() { + return i < groups.length && _.isEmpty(inputs); + }, function(next) { + var group = groups[i++]; - var bitcoreTx, bitcoreError; + var candidateUtxos = _.filter(utxos, function(utxo) { + return utxo.confirmations >= group; + }); - function select() { - if (i >= inputs.length) return cb(bitcoreError || new Error('Could not select tx inputs')); + log.debug('Group >= ' + group); - var input = inputs[i++]; - selected.push(input); - total += input.satoshis; - if (total >= txp.getTotalAmount()) { - txp.setInputs(selected); - bitcoreError = self._checkTxAndEstimateFee(txp); - if (!bitcoreError) return cb(); - if (txp.getEstimatedSize() / 1000 > Defaults.MAX_TX_SIZE_IN_KB) - return cb(Errors.TX_MAX_SIZE_EXCEEDED); + // If this group does not have any new elements, skip it + if (lastGroupLength === candidateUtxos.length) { + log.debug('This group is identical to the one already explored'); + return next(); } - setTimeout(select, 0); - }; - select(); + log.debug('Candidate utxos: ' + Utils.formatUtxos(candidateUtxos)); + + lastGroupLength = candidateUtxos.length; + + select(candidateUtxos, function(err, selectedInputs, selectedFee) { + if (err) { + log.debug('No inputs selected on this group: ', err); + selectionError = err; + return next(); + } + + selectionError = null; + inputs = selectedInputs; + fee = selectedFee; + + log.debug('Selected inputs from this group: ' + Utils.formatUtxos(inputs)); + log.debug('Fee for this selection: ' + Utils.formatAmountInBtc(fee)); + + return next(); + }); + }, function(err) { + if (err) return cb(err); + if (selectionError || _.isEmpty(inputs)) return cb(selectionError || new Error('Could not select tx inputs')); + + txp.setInputs(_.shuffle(inputs)); + txp.fee = fee; + + var err = self._checkTx(txp); + + if (!err) { + log.debug('Successfully built transaction. Total fees: ' + Utils.formatAmountInBtc(txp.fee) + ', total change: ' + Utils.formatAmountInBtc(_.sum(txp.inputs, 'satoshis') - txp.fee)); + } else { + log.warn('Error building transaction', err); + } + + return cb(err); + }); }); }; @@ -1559,21 +1724,28 @@ WalletService.prototype.createTxLegacy = function(opts, cb) { * @param {number} opts.outputs[].amount - Amount to transfer in satoshi. * @param {string} opts.outputs[].message - A message to attach to this output. * @param {string} opts.message - A message to attach to this transaction. - * @param {Array} opts.inputs - Optional. Inputs for this TX - * @param {string} opts.feePerKb - The fee per kB to use for this TX. + * @param {number} opts.feePerKb - The fee per kB to use for this TX. * @param {string} opts.payProUrl - Optional. Paypro URL for peers to verify TX * @param {string} opts.excludeUnconfirmedUtxos[=false] - Optional. Do not use UTXOs of unconfirmed transactions as inputs * @param {string} opts.validateOutputs[=true] - Optional. Perform validation on outputs. + * @param {Array} opts.inputs - Optional. Inputs for this TX + * @param {number} opts.fee - Optional. The fee to use for this TX (used only when opts.inputs is specified). * @returns {TxProposal} Transaction proposal. */ WalletService.prototype.createTx = function(opts, cb) { var self = this; - if (!Utils.checkRequired(opts, ['outputs', 'feePerKb'])) + if (!Utils.checkRequired(opts, ['outputs'])) + return cb(new ClientError('Required argument missing')); + + // feePerKb is required unless inputs & fee are specified + if (!_.isNumber(opts.feePerKb) && !(opts.inputs && _.isNumber(opts.fee))) return cb(new ClientError('Required argument missing')); - if (opts.feePerKb < Defaults.MIN_FEE_PER_KB || opts.feePerKb > Defaults.MAX_FEE_PER_KB) - return cb(new ClientError('Invalid fee per KB')); + 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._runLocked(cb, function(cb) { self.getWallet({}, function(err, wallet) { @@ -1594,7 +1766,6 @@ WalletService.prototype.createTx = function(opts, cb) { var txOpts = { walletId: self.walletId, creatorId: self.copayerId, - inputs: opts.inputs, outputs: opts.outputs, message: opts.message, changeAddress: wallet.createAddress(true), @@ -1606,6 +1777,8 @@ WalletService.prototype.createTx = function(opts, cb) { validateOutputs: !opts.validateOutputs, addressType: wallet.addressType, customData: opts.customData, + inputs: opts.inputs, + fee: opts.inputs && !_.isNumber(opts.feePerKb) ? opts.fee : null, }; var txp = Model.TxProposal.create(txOpts); diff --git a/test/integration/helpers.js b/test/integration/helpers.js index e612662..e53d38b 100644 --- a/test/integration/helpers.js +++ b/test/integration/helpers.js @@ -212,6 +212,40 @@ helpers.toSatoshi = function(btc) { } }; +helpers._parseAmount = function(str) { + var result = { + amount: +0, + confirmations: _.random(6, 100), + }; + + if (_.isNumber(str)) str = str.toString(); + + var re = /^((?:\d+c)|u)?\s*([\d\.]+)\s*(btc|bit|sat)?$/; + var match = str.match(re); + + if (!match) throw new Error('Could not parse amount ' + str); + + if (match[1]) { + if (match[1] == 'u') result.confirmations = 0; + if (_.endsWith(match[1], 'c')) result.confirmations = +match[1].slice(0, -1); + } + + switch (match[3]) { + default: + case 'btc': + result.amount = Utils.strip(+match[2] * 1e8); + break; + case 'bit': + result.amount = Utils.strip(+match[2] * 1e2); + break + case 'sat': + result.amount = Utils.strip(+match[2]); + break; + }; + + return result; +}; + helpers.stubUtxos = function(server, wallet, amounts, opts, cb) { if (_.isFunction(opts)) { cb = opts; @@ -233,14 +267,9 @@ helpers.stubUtxos = function(server, wallet, amounts, opts, cb) { addresses.should.not.be.empty; var utxos = _.compact(_.map([].concat(amounts), function(amount, i) { - var confirmations; - if (_.isString(amount) && _.startsWith(amount, 'u')) { - amount = parseFloat(amount.substring(1)); - confirmations = 0; - } else { - confirmations = Math.floor(Math.random() * 100 + 1); - } - if (amount <= 0) return null; + var parsed = helpers._parseAmount(amount); + + if (parsed.amount <= 0) return null; var address = addresses[i % addresses.length]; @@ -257,11 +286,12 @@ helpers.stubUtxos = function(server, wallet, amounts, opts, cb) { return { txid: helpers.randomTXID(), - vout: Math.floor(Math.random() * 10 + 1), - satoshis: helpers.toSatoshi(amount), + vout: _.random(0, 10), + satoshis: parsed.amount, scriptPubKey: scriptPubKey.toBuffer().toString('hex'), address: address.address, - confirmations: confirmations + confirmations: parsed.confirmations, + publicKeys: address.publicKeys, }; })); diff --git a/test/integration/server.js b/test/integration/server.js index 2009952..c9f1832 100644 --- a/test/integration/server.js +++ b/test/integration/server.js @@ -239,368 +239,372 @@ describe('Wallet service', function() { }); describe('#joinWallet', function() { - var server, walletId; - beforeEach(function(done) { - server = new WalletService(); - var walletOpts = { - name: 'my wallet', - m: 1, - n: 2, - pubKey: TestData.keyPair.pub, - }; - server.createWallet(walletOpts, function(err, wId) { - should.not.exist(err); - walletId = wId; - should.exist(walletId); - done(); - }); - }); + describe('New clients', function() { - it('should join existing wallet', function(done) { - var copayerOpts = helpers.getSignedCopayerOpts({ - walletId: walletId, - name: 'me', - xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, - requestPubKey: TestData.copayers[0].pubKey_1H_0, - customData: 'dummy custom data', + var server, walletId; + beforeEach(function(done) { + server = new WalletService(); + var walletOpts = { + name: 'my wallet', + m: 1, + n: 2, + pubKey: TestData.keyPair.pub, + }; + server.createWallet(walletOpts, function(err, wId) { + should.not.exist(err); + walletId = wId; + should.exist(walletId); + done(); + }); }); - server.joinWallet(copayerOpts, function(err, result) { - should.not.exist(err); - var copayerId = result.copayerId; - helpers.getAuthServer(copayerId, function(server) { - server.getWallet({}, function(err, wallet) { - wallet.id.should.equal(walletId); - wallet.copayers.length.should.equal(1); - var copayer = wallet.copayers[0]; - copayer.name.should.equal('me'); - copayer.id.should.equal(copayerId); - copayer.customData.should.equal('dummy custom data'); - server.getNotifications({}, function(err, notifications) { - should.not.exist(err); - var notif = _.find(notifications, { - type: 'NewCopayer' - }); - should.exist(notif); - notif.data.walletId.should.equal(walletId); - notif.data.copayerId.should.equal(copayerId); - notif.data.copayerName.should.equal('me'); - notif = _.find(notifications, { - type: 'WalletComplete' + it('should join existing wallet', function(done) { + var copayerOpts = helpers.getSignedCopayerOpts({ + walletId: walletId, + name: 'me', + xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, + requestPubKey: TestData.copayers[0].pubKey_1H_0, + customData: 'dummy custom data', + }); + server.joinWallet(copayerOpts, function(err, result) { + should.not.exist(err); + var copayerId = result.copayerId; + helpers.getAuthServer(copayerId, function(server) { + server.getWallet({}, function(err, wallet) { + wallet.id.should.equal(walletId); + wallet.copayers.length.should.equal(1); + var copayer = wallet.copayers[0]; + copayer.name.should.equal('me'); + copayer.id.should.equal(copayerId); + copayer.customData.should.equal('dummy custom data'); + server.getNotifications({}, function(err, notifications) { + should.not.exist(err); + var notif = _.find(notifications, { + type: 'NewCopayer' + }); + should.exist(notif); + notif.data.walletId.should.equal(walletId); + notif.data.copayerId.should.equal(copayerId); + notif.data.copayerName.should.equal('me'); + + notif = _.find(notifications, { + type: 'WalletComplete' + }); + should.not.exist(notif); + done(); }); - should.not.exist(notif); - done(); }); }); }); }); - }); - it('should fail to join with no name', function(done) { - var copayerOpts = helpers.getSignedCopayerOpts({ - walletId: walletId, - name: '', - xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, - requestPubKey: TestData.copayers[0].pubKey_1H_0, + it('should fail to join with no name', function(done) { + var copayerOpts = helpers.getSignedCopayerOpts({ + walletId: walletId, + name: '', + xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, + requestPubKey: TestData.copayers[0].pubKey_1H_0, + }); + server.joinWallet(copayerOpts, function(err, result) { + should.not.exist(result); + should.exist(err); + err.message.should.contain('name'); + done(); + }); }); - server.joinWallet(copayerOpts, function(err, result) { - should.not.exist(result); - should.exist(err); - err.message.should.contain('name'); - done(); + + it('should fail to join non-existent wallet', function(done) { + var copayerOpts = { + walletId: '123', + name: 'me', + xPubKey: 'dummy', + requestPubKey: 'dummy', + copayerSignature: 'dummy', + }; + server.joinWallet(copayerOpts, function(err) { + should.exist(err); + done(); + }); }); - }); - it('should fail to join non-existent wallet', function(done) { - var copayerOpts = { - walletId: '123', - name: 'me', - xPubKey: 'dummy', - requestPubKey: 'dummy', - copayerSignature: 'dummy', - }; - server.joinWallet(copayerOpts, function(err) { - should.exist(err); - done(); + it('should fail to join full wallet', function(done) { + helpers.createAndJoinWallet(1, 1, function(s, wallet) { + var copayerOpts = helpers.getSignedCopayerOpts({ + walletId: wallet.id, + name: 'me', + xPubKey: TestData.copayers[1].xPubKey_44H_0H_0H, + requestPubKey: TestData.copayers[1].pubKey_1H_0, + }); + server.joinWallet(copayerOpts, function(err) { + should.exist(err); + err.code.should.equal('WALLET_FULL'); + err.message.should.equal('Wallet full'); + done(); + }); + }); + }); + + it('should return copayer in wallet error before full wallet', function(done) { + helpers.createAndJoinWallet(1, 1, function(s, wallet) { + var copayerOpts = helpers.getSignedCopayerOpts({ + walletId: wallet.id, + name: 'me', + xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, + requestPubKey: TestData.copayers[0].pubKey_1H_0, + }); + server.joinWallet(copayerOpts, function(err) { + should.exist(err); + err.code.should.equal('COPAYER_IN_WALLET'); + done(); + }); + }); }); - }); - it('should fail to join full wallet', function(done) { - helpers.createAndJoinWallet(1, 1, function(s, wallet) { + it('should fail to re-join wallet', function(done) { var copayerOpts = helpers.getSignedCopayerOpts({ - walletId: wallet.id, + walletId: walletId, name: 'me', - xPubKey: TestData.copayers[1].xPubKey_44H_0H_0H, - requestPubKey: TestData.copayers[1].pubKey_1H_0, + xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, + requestPubKey: TestData.copayers[0].pubKey_1H_0, }); server.joinWallet(copayerOpts, function(err) { - should.exist(err); - err.code.should.equal('WALLET_FULL'); - err.message.should.equal('Wallet full'); - done(); + should.not.exist(err); + server.joinWallet(copayerOpts, function(err) { + should.exist(err); + err.code.should.equal('COPAYER_IN_WALLET'); + err.message.should.equal('Copayer already in wallet'); + done(); + }); + }); + }); + + it('should be able to get wallet info without actually joining', function(done) { + var copayerOpts = helpers.getSignedCopayerOpts({ + walletId: walletId, + name: 'me', + xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, + requestPubKey: TestData.copayers[0].pubKey_1H_0, + customData: 'dummy custom data', + dryRun: true, + }); + server.joinWallet(copayerOpts, function(err, result) { + should.not.exist(err); + should.exist(result); + should.not.exist(result.copayerId); + result.wallet.id.should.equal(walletId); + result.wallet.m.should.equal(1); + result.wallet.n.should.equal(2); + result.wallet.copayers.should.be.empty; + server.storage.fetchWallet(walletId, function(err, wallet) { + should.not.exist(err); + wallet.id.should.equal(walletId); + wallet.copayers.should.be.empty; + done(); + }); }); }); - }); - it('should return copayer in wallet error before full wallet', function(done) { - helpers.createAndJoinWallet(1, 1, function(s, wallet) { + it('should fail to join two wallets with same xPubKey', function(done) { var copayerOpts = helpers.getSignedCopayerOpts({ - walletId: wallet.id, + walletId: walletId, name: 'me', xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, requestPubKey: TestData.copayers[0].pubKey_1H_0, }); server.joinWallet(copayerOpts, function(err) { - should.exist(err); - err.code.should.equal('COPAYER_IN_WALLET'); + should.not.exist(err); + + var walletOpts = { + name: 'my other wallet', + m: 1, + n: 1, + pubKey: TestData.keyPair.pub, + }; + server.createWallet(walletOpts, function(err, walletId) { + should.not.exist(err); + copayerOpts = helpers.getSignedCopayerOpts({ + walletId: walletId, + name: 'me', + xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, + requestPubKey: TestData.copayers[0].pubKey_1H_0, + }); + server.joinWallet(copayerOpts, function(err) { + should.exist(err); + err.code.should.equal('COPAYER_REGISTERED'); + err.message.should.equal('Copayer ID already registered on server'); + done(); + }); + }); + }); + }); + + it('should fail to join with bad formated signature', function(done) { + var copayerOpts = { + walletId: walletId, + name: 'me', + xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, + requestPubKey: TestData.copayers[0].pubKey_1H_0, + copayerSignature: 'bad sign', + }; + server.joinWallet(copayerOpts, function(err) { + err.message.should.equal('Bad request'); done(); }); }); - }); - it('should fail to re-join wallet', function(done) { - var copayerOpts = helpers.getSignedCopayerOpts({ - walletId: walletId, - name: 'me', - xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, - requestPubKey: TestData.copayers[0].pubKey_1H_0, + it('should fail to join with invalid xPubKey', function(done) { + var copayerOpts = helpers.getSignedCopayerOpts({ + walletId: walletId, + name: 'copayer 1', + xPubKey: 'invalid', + requestPubKey: TestData.copayers[0].pubKey_1H_0, + }); + server.joinWallet(copayerOpts, function(err, result) { + should.not.exist(result); + should.exist(err); + err.message.should.contain('extended public key'); + done(); + }); }); - server.joinWallet(copayerOpts, function(err) { - should.not.exist(err); + + it('should fail to join with null signature', function(done) { + var copayerOpts = { + walletId: walletId, + name: 'me', + xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, + requestPubKey: TestData.copayers[0].pubKey_1H_0, + }; server.joinWallet(copayerOpts, function(err) { should.exist(err); - err.code.should.equal('COPAYER_IN_WALLET'); - err.message.should.equal('Copayer already in wallet'); + err.message.should.contain('argument missing'); done(); }); }); - }); - it('should be able to get wallet info without actually joining', function(done) { - var copayerOpts = helpers.getSignedCopayerOpts({ - walletId: walletId, - name: 'me', - xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, - requestPubKey: TestData.copayers[0].pubKey_1H_0, - customData: 'dummy custom data', - dryRun: true, - }); - server.joinWallet(copayerOpts, function(err, result) { - should.not.exist(err); - should.exist(result); - should.not.exist(result.copayerId); - result.wallet.id.should.equal(walletId); - result.wallet.m.should.equal(1); - result.wallet.n.should.equal(2); - result.wallet.copayers.should.be.empty; - server.storage.fetchWallet(walletId, function(err, wallet) { - should.not.exist(err); - wallet.id.should.equal(walletId); - wallet.copayers.should.be.empty; + it('should fail to join with wrong signature', function(done) { + var copayerOpts = helpers.getSignedCopayerOpts({ + walletId: walletId, + name: 'me', + xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, + requestPubKey: TestData.copayers[0].pubKey_1H_0, + }); + copayerOpts.name = 'me2'; + server.joinWallet(copayerOpts, function(err) { + err.message.should.equal('Bad request'); done(); }); }); + + it('should set pkr and status = complete on last copayer joining (2-3)', function(done) { + helpers.createAndJoinWallet(2, 3, function(server) { + server.getWallet({}, function(err, wallet) { + should.not.exist(err); + wallet.status.should.equal('complete'); + wallet.publicKeyRing.length.should.equal(3); + server.getNotifications({}, function(err, notifications) { + should.not.exist(err); + var notif = _.find(notifications, { + type: 'WalletComplete' + }); + should.exist(notif); + notif.data.walletId.should.equal(wallet.id); + done(); + }); + }); + }); + }); + + it('should not notify WalletComplete if 1-of-1', function(done) { + helpers.createAndJoinWallet(1, 1, function(server) { + server.getNotifications({}, function(err, notifications) { + should.not.exist(err); + var notif = _.find(notifications, { + type: 'WalletComplete' + }); + should.not.exist(notif); + done(); + }); + }); + }); }); - it('should fail to join two wallets with same xPubKey', function(done) { - var copayerOpts = helpers.getSignedCopayerOpts({ - walletId: walletId, - name: 'me', - xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, - requestPubKey: TestData.copayers[0].pubKey_1H_0, + describe('Interaction new/legacy clients', function() { + var server; + beforeEach(function() { + server = new WalletService(); }); - server.joinWallet(copayerOpts, function(err) { - should.not.exist(err); + it('should fail to join legacy wallet from new client', function(done) { var walletOpts = { - name: 'my other wallet', + name: 'my wallet', m: 1, - n: 1, + n: 2, pubKey: TestData.keyPair.pub, + supportBIP44AndP2PKH: false, }; server.createWallet(walletOpts, function(err, walletId) { should.not.exist(err); - copayerOpts = helpers.getSignedCopayerOpts({ + should.exist(walletId); + var copayerOpts = helpers.getSignedCopayerOpts({ walletId: walletId, name: 'me', xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, requestPubKey: TestData.copayers[0].pubKey_1H_0, }); - server.joinWallet(copayerOpts, function(err) { + server.joinWallet(copayerOpts, function(err, result) { should.exist(err); - err.code.should.equal('COPAYER_REGISTERED'); - err.message.should.equal('Copayer ID already registered on server'); + err.message.should.contain('The wallet you are trying to join was created with an older version of the client app'); done(); }); }); }); - }); - - it('should fail to join with bad formated signature', function(done) { - var copayerOpts = { - walletId: walletId, - name: 'me', - xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, - requestPubKey: TestData.copayers[0].pubKey_1H_0, - copayerSignature: 'bad sign', - }; - server.joinWallet(copayerOpts, function(err) { - err.message.should.equal('Bad request'); - done(); - }); - }); - - it('should fail to join with invalid xPubKey', function(done) { - var copayerOpts = helpers.getSignedCopayerOpts({ - walletId: walletId, - name: 'copayer 1', - xPubKey: 'invalid', - requestPubKey: TestData.copayers[0].pubKey_1H_0, - }); - server.joinWallet(copayerOpts, function(err, result) { - should.not.exist(result); - should.exist(err); - err.message.should.contain('extended public key'); - done(); - }); - }); - - it('should fail to join with null signature', function(done) { - var copayerOpts = { - walletId: walletId, - name: 'me', - xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, - requestPubKey: TestData.copayers[0].pubKey_1H_0, - }; - server.joinWallet(copayerOpts, function(err) { - should.exist(err); - err.message.should.contain('argument missing'); - done(); - }); - }); - - it('should fail to join with wrong signature', function(done) { - var copayerOpts = helpers.getSignedCopayerOpts({ - walletId: walletId, - name: 'me', - xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, - requestPubKey: TestData.copayers[0].pubKey_1H_0, - }); - copayerOpts.name = 'me2'; - server.joinWallet(copayerOpts, function(err) { - err.message.should.equal('Bad request'); - done(); - }); - }); - it('should set pkr and status = complete on last copayer joining (2-3)', function(done) { - helpers.createAndJoinWallet(2, 3, function(server) { - server.getWallet({}, function(err, wallet) { + it('should fail to join new wallet from legacy client', function(done) { + var walletOpts = { + name: 'my wallet', + m: 1, + n: 2, + pubKey: TestData.keyPair.pub, + }; + server.createWallet(walletOpts, function(err, walletId) { should.not.exist(err); - wallet.status.should.equal('complete'); - wallet.publicKeyRing.length.should.equal(3); - server.getNotifications({}, function(err, notifications) { - should.not.exist(err); - var notif = _.find(notifications, { - type: 'WalletComplete' - }); - should.exist(notif); - notif.data.walletId.should.equal(wallet.id); - done(); + should.exist(walletId); + var copayerOpts = helpers.getSignedCopayerOpts({ + walletId: walletId, + name: 'me', + xPubKey: TestData.copayers[0].xPubKey_45H, + requestPubKey: TestData.copayers[0].pubKey_1H_0, + supportBIP44AndP2PKH: false, }); - }); - }); - }); - - it('should not notify WalletComplete if 1-of-1', function(done) { - helpers.createAndJoinWallet(1, 1, function(server) { - server.getNotifications({}, function(err, notifications) { - should.not.exist(err); - var notif = _.find(notifications, { - type: 'WalletComplete' + server.joinWallet(copayerOpts, function(err, result) { + should.exist(err); + err.code.should.equal('UPGRADE_NEEDED'); + done(); }); - should.not.exist(notif); - done(); }); }); }); }); - describe('#joinWallet new/legacy clients', function() { + describe('Address derivation strategy', function() { var server; beforeEach(function() { - server = new WalletService(); + server = WalletService.getInstance(); }); - - it('should fail to join legacy wallet from new client', function(done) { + it('should use BIP44 & P2PKH for 1-of-1 wallet if supported', function(done) { var walletOpts = { name: 'my wallet', m: 1, - n: 2, + n: 1, pubKey: TestData.keyPair.pub, - supportBIP44AndP2PKH: false, }; - server.createWallet(walletOpts, function(err, walletId) { + server.createWallet(walletOpts, function(err, wid) { should.not.exist(err); - should.exist(walletId); - var copayerOpts = helpers.getSignedCopayerOpts({ - walletId: walletId, - name: 'me', - xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, - requestPubKey: TestData.copayers[0].pubKey_1H_0, - }); - server.joinWallet(copayerOpts, function(err, result) { - should.exist(err); - err.message.should.contain('The wallet you are trying to join was created with an older version of the client app'); - done(); - }); - }); - }); - it('should fail to join new wallet from legacy client', function(done) { - var walletOpts = { - name: 'my wallet', - m: 1, - n: 2, - pubKey: TestData.keyPair.pub, - }; - server.createWallet(walletOpts, function(err, walletId) { - should.not.exist(err); - should.exist(walletId); - var copayerOpts = helpers.getSignedCopayerOpts({ - walletId: walletId, - name: 'me', - xPubKey: TestData.copayers[0].xPubKey_45H, - requestPubKey: TestData.copayers[0].pubKey_1H_0, - supportBIP44AndP2PKH: false, - }); - server.joinWallet(copayerOpts, function(err, result) { - should.exist(err); - err.code.should.equal('UPGRADE_NEEDED'); - done(); - }); - }); - }); - }); - - describe('Address derivation strategy', function() { - var server; - beforeEach(function() { - server = WalletService.getInstance(); - }); - it('should use BIP44 & P2PKH for 1-of-1 wallet if supported', function(done) { - var walletOpts = { - name: 'my wallet', - m: 1, - n: 1, - pubKey: TestData.keyPair.pub, - }; - server.createWallet(walletOpts, function(err, wid) { - should.not.exist(err); - server.storage.fetchWallet(wid, function(err, wallet) { - should.not.exist(err); - wallet.derivationStrategy.should.equal('BIP44'); - wallet.addressType.should.equal('P2PKH'); - done(); + server.storage.fetchWallet(wid, function(err, wallet) { + should.not.exist(err); + wallet.derivationStrategy.should.equal('BIP44'); + wallet.addressType.should.equal('P2PKH'); + done(); }); }); }); @@ -2128,6 +2132,7 @@ describe('Wallet service', function() { }); it('should use unconfirmed utxos only when no more confirmed utxos are available', function(done) { + // log.level = 'debug'; helpers.stubUtxos(server, wallet, [1.3, 'u0.5', 'u0.1', 1.2], function(utxos) { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 2.55, TestData.copayers[0].privKey_1H_0, { message: 'some message' @@ -2350,10 +2355,11 @@ describe('Wallet service', function() { }); it('should fail to create a tx exceeding max size in kb', function(done) { + // log.level = 'debug'; + var _oldDefault = Defaults.MAX_TX_SIZE_IN_KB; + Defaults.MAX_TX_SIZE_IN_KB = 1; helpers.stubUtxos(server, wallet, _.range(1, 10, 0), function() { - var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 9, TestData.copayers[0].privKey_1H_0); - var _oldDefault = Defaults.MAX_TX_SIZE_IN_KB; - Defaults.MAX_TX_SIZE_IN_KB = 1; + var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 8, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx) { should.exist(err); err.code.should.equal('TX_MAX_SIZE_EXCEEDED'); @@ -2375,19 +2381,18 @@ describe('Wallet service', function() { }); }); - it('should fail to create tx that would return change for dust amount', function(done) { + it('should modify fee if tx would return change for dust amount', function(done) { helpers.stubUtxos(server, wallet, [1], function() { - var fee = 4095 / 1e8; // The exact fee of the resulting tx - var change = 100 / 1e8; // Below dust - var amount = 1 - fee - change; + var fee = 4095; // The exact fee of the resulting tx (based exclusively on feePerKB && size) + var change = 100; // Below dust + var amount = (1e8 - fee - change) / 1e8; var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', amount, TestData.copayers[0].privKey_1H_0, { feePerKb: 10000 }); server.createTxLegacy(txOpts, function(err, tx) { - should.exist(err); - err.code.should.equal('DUST_AMOUNT'); - err.message.should.equal('Amount below dust threshold'); + should.not.exist(err); + tx.fee.should.equal(fee + change); done(); }); }); @@ -2885,282 +2890,695 @@ describe('Wallet service', function() { var txOpts = { outputs: [{ toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 0.8 * 1e8, + amount: 0.8 * 1e8, + }], + feePerKb: 100e2, + message: 'some message', + }; + server.createTx(txOpts, function(err, txp) { + should.not.exist(err); + should.exist(txp); + server.publishTx({ + txProposalId: txp.id, + proposalSignature: 'dummy' + }, function(err) { + should.exist(err); + err.message.should.contain('Invalid proposal signature'); + done(); + }); + }); + }); + }); + + it('should fail to publish tx proposal not signed by the creator', function(done) { + helpers.stubUtxos(server, wallet, [1, 2], function() { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 0.8 * 1e8, + }], + feePerKb: 100e2, + message: 'some message', + }; + server.createTx(txOpts, function(err, txp) { + should.not.exist(err); + should.exist(txp); + + var publishOpts = { + txProposalId: txp.id, + proposalSignature: helpers.signMessage(txp.getRawTx(), TestData.copayers[1].privKey_1H_0), + } + + server.publishTx(publishOpts, function(err) { + should.exist(err); + err.message.should.contain('Invalid proposal signature'); + done(); + }); + }); + }); + }); + + it('should accept a tx proposal signed with a custom key', function(done) { + var reqPrivKey = new Bitcore.PrivateKey(); + var reqPubKey = reqPrivKey.toPublicKey().toString(); + + var xPrivKey = TestData.copayers[0].xPrivKey_44H_0H_0H; + + var accessOpts = { + copayerId: TestData.copayers[0].id44, + requestPubKey: reqPubKey, + signature: helpers.signRequestPubKey(reqPubKey, xPrivKey), + }; + + server.addAccess(accessOpts, function(err) { + should.not.exist(err); + + helpers.stubUtxos(server, wallet, [1, 2], function() { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 0.8 * 1e8, + }], + message: 'some message', + feePerKb: 100e2, + }; + server.createTx(txOpts, function(err, txp) { + should.not.exist(err); + should.exist(txp); + + var publishOpts = { + txProposalId: txp.id, + proposalSignature: helpers.signMessage(txp.getRawTx(), reqPrivKey), + } + + server.publishTx(publishOpts, function(err) { + should.not.exist(err); + server.getTx({ + txProposalId: txp.id + }, function(err, x) { + should.not.exist(err); + x.proposalSignature.should.equal(publishOpts.proposalSignature); + x.proposalSignaturePubKey.should.equal(accessOpts.requestPubKey); + x.proposalSignaturePubKeySig.should.equal(accessOpts.signature); + done(); + }); + }); + }); + }); + }); + }); + + it('should fail to publish a temporary tx proposal if utxos are unavailable', function(done) { + var txp1, txp2; + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 0.8 * 1e8, + }], + message: 'some message', + feePerKb: 100e2, + }; + + async.waterfall([ + + function(next) { + helpers.stubUtxos(server, wallet, [1, 2], function() { + next(); + }); + }, + function(next) { + server.createTx(txOpts, next); + }, + function(txp, next) { + txp1 = txp; + server.createTx(txOpts, next); + }, + function(txp, next) { + txp2 = txp; + should.exist(txp1); + should.exist(txp2); + var publishOpts = helpers.getProposalSignatureOpts(txp1, TestData.copayers[0].privKey_1H_0); + server.publishTx(publishOpts, next); + }, + function(txp, next) { + var publishOpts = helpers.getProposalSignatureOpts(txp2, TestData.copayers[0].privKey_1H_0); + server.publishTx(publishOpts, function(err) { + should.exist(err); + err.code.should.equal('UNAVAILABLE_UTXOS'); + next(); + }); + }, + function(next) { + server.getPendingTxs({}, function(err, txs) { + should.not.exist(err); + txs.length.should.equal(1); + next(); + }); + }, + function(next) { + // A new tx proposal should use the next available UTXO + server.createTx(txOpts, next); + }, + function(txp3, next) { + should.exist(txp3); + var publishOpts = helpers.getProposalSignatureOpts(txp3, TestData.copayers[0].privKey_1H_0); + server.publishTx(publishOpts, next); + }, + function(txp, next) { + server.getPendingTxs({}, function(err, txs) { + should.not.exist(err); + txs.length.should.equal(2); + next(); + }); + }, + ], function(err) { + should.not.exist(err); + done(); + }); + }); + + it('should fail to list pending proposals from legacy client', function(done) { + helpers.stubUtxos(server, wallet, [1, 2], function() { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 0.8 * 1e8, + }], + message: 'some message', + customData: 'some custom data', + feePerKb: 100e2, + }; + server.createTx(txOpts, function(err, txp) { + should.not.exist(err); + should.exist(txp); + var publishOpts = helpers.getProposalSignatureOpts(txp, TestData.copayers[0].privKey_1H_0); + server.publishTx(publishOpts, function(err) { + should.not.exist(err); + server.getPendingTxs({}, function(err, txs) { + should.not.exist(err); + txs.length.should.equal(1); + + server._setClientVersion('bwc-1.1.8'); + server.getPendingTxs({}, function(err, txs) { + should.exist(err); + err.code.should.equal('UPGRADE_NEEDED'); + done(); + }); + }); + }); + }); + }); + }); + + it('should be able to specify inputs & absolute fee', function(done) { + helpers.stubUtxos(server, wallet, [1, 2], function(utxos) { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 0.8e8, + }], + inputs: utxos, + fee: 1000e2, + }; + server.createTx(txOpts, function(err, tx) { + should.not.exist(err); + should.exist(tx); + tx.amount.should.equal(helpers.toSatoshi(0.8)); + should.not.exist(tx.feePerKb); + tx.fee.should.equal(1000e2); + var t = tx.getBitcoreTx(); + t.getFee().should.equal(1000e2); + t.getChangeOutput().satoshis.should.equal(3e8 - 0.8e8 - 1000e2); + done(); + }); + }); + }); + }); + + describe('Backoff time', function(done) { + var server, wallet, txid; + + beforeEach(function(done) { + helpers.createAndJoinWallet(2, 2, function(s, w) { + server = s; + wallet = w; + helpers.stubUtxos(server, wallet, _.range(2, 6), function() { + done(); + }); + }); + }); + + it('should follow backoff time after consecutive rejections', function(done) { + async.series([ + + function(next) { + async.each(_.range(3), function(i, next) { + var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 1, TestData.copayers[0].privKey_1H_0); + server.createTxLegacy(txOpts, function(err, tx) { + should.not.exist(err); + server.rejectTx({ + txProposalId: tx.id, + reason: 'some reason', + }, next); + }); + }, + next); + }, + function(next) { + // Allow a 4th tx + var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 1, TestData.copayers[0].privKey_1H_0); + server.createTxLegacy(txOpts, function(err, tx) { + server.rejectTx({ + txProposalId: tx.id, + reason: 'some reason', + }, next); + }); + }, + function(next) { + // Do not allow before backoff time + var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 1, TestData.copayers[0].privKey_1H_0); + server.createTxLegacy(txOpts, function(err, tx) { + should.exist(err); + err.code.should.equal('TX_CANNOT_CREATE'); + next(); + }); + }, + function(next) { + var clock = sinon.useFakeTimers(Date.now() + (Defaults.BACKOFF_TIME + 2) * 60 * 1000, 'Date'); + var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 1, TestData.copayers[0].privKey_1H_0); + server.createTxLegacy(txOpts, function(err, tx) { + clock.restore(); + server.rejectTx({ + txProposalId: tx.id, + reason: 'some reason', + }, next); + }); + }, + function(next) { + // Do not allow a 5th tx before backoff time + var clock = sinon.useFakeTimers(Date.now() + (Defaults.BACKOFF_TIME + 2) * 60 * 1000 + 1, 'Date'); + var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 1, TestData.copayers[0].privKey_1H_0); + server.createTxLegacy(txOpts, function(err, tx) { + clock.restore(); + should.exist(err); + err.code.should.equal('TX_CANNOT_CREATE'); + next(); + }); + }, + ], function(err) { + should.not.exist(err); + done(); + }); + }); + }); + + describe('UTXO Selection', function() { + var server, wallet; + beforeEach(function(done) { + // log.level = 'debug'; + helpers.createAndJoinWallet(2, 3, function(s, w) { + server = s; + wallet = w; + done(); + }); + }); + afterEach(function() { + log.level = 'info'; + }); + + it('should select a single utxo if within thresholds relative to tx amount', function(done) { + helpers.stubUtxos(server, wallet, [1, '350bit', '100bit', '100bit', '100bit'], function() { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 200e2, + }], + feePerKb: 10e2, + }; + server.createTx(txOpts, function(err, txp) { + should.not.exist(err); + should.exist(txp); + txp.inputs.length.should.equal(1); + txp.inputs[0].satoshis.should.equal(35000); + + 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) { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: _.sum(utxos, 'satoshis') - 0.5e8, + }], + feePerKb: 100e2, + }; + server.createTx(txOpts, function(err, txp) { + should.not.exist(err); + should.exist(txp); + var amounts = _.pluck(txp.inputs, 'satoshis'); + amounts.length.should.equal(30); + _.all(amounts, function(amount, i) { + if (i == 0) return true; + return amount < amounts[i - 1]; + }).should.be.false; + done(); + }); + }); + }); + it('should select a confirmed utxos if within thresholds relative to tx amount', function(done) { + helpers.stubUtxos(server, wallet, [1, 'u 350bit', '100bit', '100bit', '100bit'], function() { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 200e2, + }], + feePerKb: 10e2, + }; + server.createTx(txOpts, function(err, txp) { + should.not.exist(err); + should.exist(txp); + txp.inputs.length.should.equal(3); + txp.inputs[0].satoshis.should.equal(10000); + + done(); + }); + }); + }); + + it('should select smaller utxos if within fee constraints', function(done) { + helpers.stubUtxos(server, wallet, [1, '800bit', '800bit', '800bit'], function() { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 2000e2, + }], + feePerKb: 10e2, + }; + server.createTx(txOpts, function(err, txp) { + should.not.exist(err); + should.exist(txp); + txp.inputs.length.should.equal(3); + _.all(txp.inputs, function(input) { + return input == 100e2; + }); + done(); + }); + }); + }); + it('should select smallest big utxo if small utxos are insufficient', function(done) { + helpers.stubUtxos(server, wallet, [3, 1, 2, '100bit', '100bit', '100bit'], function() { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 300e2, + }], + feePerKb: 10e2, + }; + server.createTx(txOpts, function(err, txp) { + should.not.exist(err); + should.exist(txp); + txp.inputs.length.should.equal(1); + txp.inputs[0].satoshis.should.equal(1e8); + done(); + }); + }); + }); + it('should account for fee when selecting smallest big utxo', function(done) { + // log.level = 'debug'; + var _old = Defaults.UTXO_SELECTION_MAX_SINGLE_UTXO_FACTOR; + Defaults.UTXO_SELECTION_MAX_SINGLE_UTXO_FACTOR = 2; + // The 605 bits input cannot be selected even if it is > 2 * tx amount + // because it cannot cover for fee on its own. + helpers.stubUtxos(server, wallet, [1, '605bit', '100bit', '100bit', '100bit'], function() { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 300e2, + }], + feePerKb: 1200e2, + }; + server.createTx(txOpts, function(err, txp) { + should.not.exist(err); + should.exist(txp); + txp.inputs.length.should.equal(1); + txp.inputs[0].satoshis.should.equal(1e8); + Defaults.UTXO_SELECTION_MAX_SINGLE_UTXO_FACTOR = _old; + done(); + }); + }); + }); + it('should select smallest big utxo if small utxos exceed maximum fee', function(done) { + helpers.stubUtxos(server, wallet, [3, 1, 2].concat(_.times(20, function() { + return '1000bit'; + })), function() { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 12000e2, + }], + feePerKb: 20e2, + }; + server.createTx(txOpts, function(err, txp) { + should.not.exist(err); + should.exist(txp); + txp.inputs.length.should.equal(1); + txp.inputs[0].satoshis.should.equal(1e8); + + done(); + }); + }); + }); + it('should select smallest big utxo if small utxos are below accepted ratio of txp amount', function(done) { + helpers.stubUtxos(server, wallet, [9, 1, 1, 0.5, 0.2, 0.2, 0.2], function() { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 3e8, + }], + feePerKb: 10e2, + }; + server.createTx(txOpts, function(err, txp) { + should.not.exist(err); + should.exist(txp); + txp.inputs.length.should.equal(1); + txp.inputs[0].satoshis.should.equal(9e8); + done(); + }); + }); + }); + it('should not fail with tx exceeded max size if there is at least 1 big input', function(done) { + var _old1 = Defaults.UTXO_SELECTION_MIN_TX_AMOUNT_VS_UTXO_FACTOR; + var _old2 = Defaults.MAX_TX_SIZE_IN_KB; + Defaults.UTXO_SELECTION_MIN_TX_AMOUNT_VS_UTXO_FACTOR = 0.0001; + Defaults.MAX_TX_SIZE_IN_KB = 3; + + helpers.stubUtxos(server, wallet, [100].concat(_.range(1, 20, 0)), function() { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 15e8, + }], + feePerKb: 120e2, + }; + server.createTx(txOpts, function(err, txp) { + should.not.exist(err); + should.exist(txp); + txp.inputs.length.should.equal(1); + txp.inputs[0].satoshis.should.equal(100e8); + Defaults.UTXO_SELECTION_MIN_TX_AMOUNT_VS_UTXO_FACTOR = _old1; + Defaults.MAX_TX_SIZE_IN_KB = _old2; + done(); + }); + }); + }); + it('should ignore utxos not contributing enough to cover increase in fee', function(done) { + helpers.stubUtxos(server, wallet, ['100bit', '100bit', '100bit'], function() { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 200e2, + }], + feePerKb: 80e2, + }; + server.createTx(txOpts, function(err, txp) { + should.not.exist(err); + should.exist(txp); + txp.inputs.length.should.equal(3); + txOpts.feePerKb = 120e2; + server.createTx(txOpts, function(err, txp) { + should.exist(err); + should.not.exist(txp); + done(); + }); + }); + }); + }); + it('should fail to select utxos if not enough to cover tx amount', function(done) { + helpers.stubUtxos(server, wallet, ['100bit', '100bit', '100bit'], function() { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 400e2, + }], + feePerKb: 10e2, + }; + server.createTx(txOpts, function(err, txp) { + should.exist(err); + should.not.exist(txp); + err.code.should.equal('INSUFFICIENT_FUNDS'); + done(); + }); + }); + }); + it('should fail to select utxos if not enough to cover fees', function(done) { + helpers.stubUtxos(server, wallet, ['100bit', '100bit', '100bit'], function() { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 299e2, + }], + feePerKb: 10e2, + }; + server.createTx(txOpts, function(err, txp) { + should.exist(err); + should.not.exist(txp); + err.code.should.equal('INSUFFICIENT_FUNDS_FOR_FEE'); + done(); + }); + }); + }); + it('should prefer a higher fee (breaking all limits) if inputs have 6+ confirmations', function(done) { + helpers.stubUtxos(server, wallet, ['2c 2000bit'].concat(_.times(20, function() { + return '100bit'; + })), function() { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 1500e2, }], - feePerKb: 100e2, - message: 'some message', + feePerKb: 10e2, }; server.createTx(txOpts, function(err, txp) { should.not.exist(err); should.exist(txp); - server.publishTx({ - txProposalId: txp.id, - proposalSignature: 'dummy' - }, function(err) { - should.exist(err); - err.message.should.contain('Invalid proposal signature'); - done(); + _.all(txp.inputs, function(input) { + return input == 100e2; }); + done(); }); }); }); - - it('should fail to publish tx proposal not signed by the creator', function(done) { - helpers.stubUtxos(server, wallet, [1, 2], function() { + it('should select unconfirmed utxos if not enough confirmed utxos', function(done) { + // log.level = 'debug'; + helpers.stubUtxos(server, wallet, ['u 1btc', '0.5btc'], function() { var txOpts = { outputs: [{ toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 0.8 * 1e8, + amount: 0.8e8, }], feePerKb: 100e2, - message: 'some message', }; server.createTx(txOpts, function(err, txp) { should.not.exist(err); should.exist(txp); - - var publishOpts = { - txProposalId: txp.id, - proposalSignature: helpers.signMessage(txp.getRawTx(), TestData.copayers[1].privKey_1H_0), - } - - server.publishTx(publishOpts, function(err) { - should.exist(err); - err.message.should.contain('Invalid proposal signature'); - done(); - }); + txp.inputs.length.should.equal(1); + txp.inputs[0].satoshis.should.equal(1e8); + done(); }); }); }); - - it('should accept a tx proposal signed with a custom key', function(done) { - var reqPrivKey = new Bitcore.PrivateKey(); - var reqPubKey = reqPrivKey.toPublicKey().toString(); - - var xPrivKey = TestData.copayers[0].xPrivKey_44H_0H_0H; - - var accessOpts = { - copayerId: TestData.copayers[0].id44, - requestPubKey: reqPubKey, - signature: helpers.signRequestPubKey(reqPubKey, xPrivKey), - }; - - server.addAccess(accessOpts, function(err) { - should.not.exist(err); - - helpers.stubUtxos(server, wallet, [1, 2], function() { - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 0.8 * 1e8, - }], - message: 'some message', - feePerKb: 100e2, - }; - server.createTx(txOpts, function(err, txp) { - should.not.exist(err); - should.exist(txp); - - var publishOpts = { - txProposalId: txp.id, - proposalSignature: helpers.signMessage(txp.getRawTx(), reqPrivKey), - } - - server.publishTx(publishOpts, function(err) { - should.not.exist(err); - server.getTx({ - txProposalId: txp.id - }, function(err, x) { - should.not.exist(err); - x.proposalSignature.should.equal(publishOpts.proposalSignature); - x.proposalSignaturePubKey.should.equal(accessOpts.requestPubKey); - x.proposalSignaturePubKeySig.should.equal(accessOpts.signature); - done(); - }); - }); - }); + it('should ignore utxos too small to pay for fee', function(done) { + helpers.stubUtxos(server, wallet, ['1c200bit', '200bit'].concat(_.times(20, function() { + return '1bit'; + })), function() { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 200e2, + }], + feePerKb: 90e2, + }; + server.createTx(txOpts, function(err, txp) { + should.not.exist(err); + should.exist(txp); + txp.inputs.length.should.equal(2); + done(); }); }); }); - - it('should fail to publish a temporary tx proposal if utxos are unavailable', function(done) { - var txp1, txp2; - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 0.8 * 1e8, - }], - message: 'some message', - feePerKb: 100e2, - }; - - async.waterfall([ - - function(next) { - helpers.stubUtxos(server, wallet, [1, 2], function() { - next(); - }); - }, - function(next) { - server.createTx(txOpts, next); - }, - function(txp, next) { - txp1 = txp; - server.createTx(txOpts, next); - }, - function(txp, next) { - txp2 = txp; - should.exist(txp1); - should.exist(txp2); - var publishOpts = helpers.getProposalSignatureOpts(txp1, TestData.copayers[0].privKey_1H_0); - server.publishTx(publishOpts, next); - }, - function(txp, next) { - var publishOpts = helpers.getProposalSignatureOpts(txp2, TestData.copayers[0].privKey_1H_0); - server.publishTx(publishOpts, function(err) { - should.exist(err); - err.code.should.equal('UNAVAILABLE_UTXOS'); - next(); - }); - }, - function(next) { - server.getPendingTxs({}, function(err, txs) { - should.not.exist(err); - txs.length.should.equal(1); - next(); - }); - }, - function(next) { - // A new tx proposal should use the next available UTXO - server.createTx(txOpts, next); - }, - function(txp3, next) { - should.exist(txp3); - var publishOpts = helpers.getProposalSignatureOpts(txp3, TestData.copayers[0].privKey_1H_0); - server.publishTx(publishOpts, next); - }, - function(txp, next) { - server.getPendingTxs({}, function(err, txs) { - should.not.exist(err); - txs.length.should.equal(2); - next(); - }); - }, - ], function(err) { - should.not.exist(err); - done(); - }); - }); - - it('should fail to list pending proposals from legacy client', function(done) { - helpers.stubUtxos(server, wallet, [1, 2], function() { + it('should use small utxos if fee is low', function(done) { + helpers.stubUtxos(server, wallet, [].concat(_.times(10, function() { + return '30bit'; + })), function() { var txOpts = { outputs: [{ toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 0.8 * 1e8, + amount: 200e2, }], - message: 'some message', - customData: 'some custom data', - feePerKb: 100e2, + feePerKb: 10e2, }; server.createTx(txOpts, function(err, txp) { should.not.exist(err); should.exist(txp); - var publishOpts = helpers.getProposalSignatureOpts(txp, TestData.copayers[0].privKey_1H_0); - server.publishTx(publishOpts, function(err) { - should.not.exist(err); - server.getPendingTxs({}, function(err, txs) { - should.not.exist(err); - txs.length.should.equal(1); - - server._setClientVersion('bwc-1.1.8'); - server.getPendingTxs({}, function(err, txs) { - should.exist(err); - err.code.should.equal('UPGRADE_NEEDED'); - done(); - }); - }); - }); + txp.inputs.length.should.equal(8); + done(); }); }); }); - - }); - }); - - describe('#createTx backoff time', function(done) { - var server, wallet, txid; - - beforeEach(function(done) { - helpers.createAndJoinWallet(2, 2, function(s, w) { - server = s; - wallet = w; - helpers.stubUtxos(server, wallet, _.range(2, 6), function() { - done(); + it('should correct fee if resulting change would be below dust', function(done) { + helpers.stubUtxos(server, wallet, ['200bit', '500sat'], function() { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 200e2, + }], + feePerKb: 400, + }; + server.createTx(txOpts, function(err, txp) { + should.not.exist(err); + (_.sum(txp.inputs, 'satoshis') - txp.outputs[0].amount - txp.fee).should.equal(0); + var changeOutput = txp.getBitcoreTx().getChangeOutput(); + should.not.exist(changeOutput); + done(); + }); }); }); - }); - - it('should follow backoff time after consecutive rejections', function(done) { - async.series([ - - function(next) { - async.each(_.range(3), function(i, next) { - var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 1, TestData.copayers[0].privKey_1H_0); - server.createTxLegacy(txOpts, function(err, tx) { - should.not.exist(err); - server.rejectTx({ - txProposalId: tx.id, - reason: 'some reason', - }, next); - }); - }, - next); - }, - function(next) { - // Allow a 4th tx - var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 1, TestData.copayers[0].privKey_1H_0); - server.createTxLegacy(txOpts, function(err, tx) { - server.rejectTx({ - txProposalId: tx.id, - reason: 'some reason', - }, next); - }); - }, - function(next) { - // Do not allow before backoff time - var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 1, TestData.copayers[0].privKey_1H_0); - server.createTxLegacy(txOpts, function(err, tx) { + it('should ignore small utxos if fee is higher', function(done) { + helpers.stubUtxos(server, wallet, [].concat(_.times(10, function() { + return '30bit'; + })), function() { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 200e2, + }], + feePerKb: 50e2, + }; + server.createTx(txOpts, function(err, txp) { should.exist(err); - err.code.should.equal('TX_CANNOT_CREATE'); - next(); - }); - }, - function(next) { - var clock = sinon.useFakeTimers(Date.now() + (Defaults.BACKOFF_TIME + 2) * 60 * 1000, 'Date'); - var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 1, TestData.copayers[0].privKey_1H_0); - server.createTxLegacy(txOpts, function(err, tx) { - clock.restore(); - server.rejectTx({ - txProposalId: tx.id, - reason: 'some reason', - }, next); + err.code.should.equal('INSUFFICIENT_FUNDS_FOR_FEE'); + done(); }); - }, - function(next) { - // Do not allow a 5th tx before backoff time - var clock = sinon.useFakeTimers(Date.now() + (Defaults.BACKOFF_TIME + 2) * 60 * 1000 + 1, 'Date'); - var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 1, TestData.copayers[0].privKey_1H_0); - server.createTxLegacy(txOpts, function(err, tx) { - clock.restore(); - should.exist(err); - err.code.should.equal('TX_CANNOT_CREATE'); - next(); + }); + }); + it('should always select inputs as long as there are sufficient funds', function(done) { + helpers.stubUtxos(server, wallet, [80, '50bit', '50bit', '50bit', '50bit', '50bit'], function() { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 101e2, + }], + feePerKb: 100e2, + }; + server.createTx(txOpts, function(err, txp) { + should.not.exist(err); + should.exist(txp); + done(); }); - }, - ], function(err) { - should.not.exist(err); - done(); + }); }); }); }); @@ -5023,7 +5441,6 @@ describe('Wallet service', function() { done(); }); }); - }); describe('#scan', function() { @@ -5686,5 +6103,4 @@ describe('Wallet service', function() { }); }); }); - }); diff --git a/test/models/txproposal.js b/test/models/txproposal.js index 28d0cb7..6a8e35a 100644 --- a/test/models/txproposal.js +++ b/test/models/txproposal.js @@ -56,7 +56,7 @@ describe('TxProposal', function() { describe('#getEstimatedSize', function() { it('should return estimated size in bytes', function() { var x = TxProposal.fromObj(aTXP()); - x.getEstimatedSize().should.equal(407); + x.getEstimatedSize().should.equal(396); }); });