diff --git a/lib/server.js b/lib/server.js index 4240776..cc78fa0 100644 --- a/lib/server.js +++ b/lib/server.js @@ -1397,7 +1397,7 @@ WalletService.prototype._selectTxInputs2 = function(txp, utxosToExclude, cb) { return (size / 1000.).toFixed(4) + 'kB'; }; - function select(utxos) { + function select(utxos, cb) { var txpAmount = txp.getTotalAmount(); var baseTxpSize = txp.getEstimatedSize(); var baseTxpFee = baseTxpSize * txp.feePerKb / 1000.; @@ -1408,11 +1408,11 @@ WalletService.prototype._selectTxInputs2 = function(txp, utxosToExclude, cb) { var netValueInUtxos = totalValueInUtxos - baseTxpFee - (utxos.length * feePerInput); if (totalValueInUtxos < txpAmount) { log.debug('Total value in all utxos (' + formatAmount(totalValueInUtxos) + ') is insufficient to cover for txp amount (' + formatAmount(txpAmount) + ')'); - return Errors.INSUFFICIENT_FUNDS; + return cb(Errors.INSUFFICIENT_FUNDS); } if (netValueInUtxos < txpAmount) { log.debug('Value after fees in all utxos (' + formatAmount(netValueInUtxos) + ') is insufficient to cover for txp amount (' + formatAmount(txpAmount) + ')'); - return Errors.INSUFFICIENT_FUNDS_FOR_FEE; + return cb(Errors.INSUFFICIENT_FUNDS_FOR_FEE); } var bigInputThreshold = txpAmount * Defaults.UTXO_SELECTION_MAX_SINGLE_UTXO_FACTOR + (baseTxpFee + feePerInput); @@ -1499,10 +1499,10 @@ WalletService.prototype._selectTxInputs2 = function(txp, utxosToExclude, cb) { if (_.isEmpty(selected)) { log.debug('Could not find enough funds within this utxo subset'); - return error || Errors.INSUFFICIENT_FUNDS_FOR_FEE; + return cb(error || Errors.INSUFFICIENT_FUNDS_FOR_FEE); } - return selected; + return cb(null, selected); }; log.debug('Selecting inputs for a ' + formatAmount(txp.getTotalAmount()) + ' txp'); @@ -1535,11 +1535,18 @@ WalletService.prototype._selectTxInputs2 = function(txp, utxosToExclude, cb) { log.debug('Considering ' + utxos.length + ' utxos (' + formatInputs(utxos) + ')'); + var groups = [6, 1]; + if (!txp.excludeUnconfirmedUtxos) groups.push(0); + var inputs = []; - var groups = [6, 1, 0]; - var error; + var selectionError; + var i = 0; var lastGroupLength; - _.each(groups, function(group) { + async.whilst(function() { + return i < groups.length && _.isEmpty(inputs); + }, function(next) { + var group = groups[i++]; + var candidateUtxos = _.filter(utxos, function(utxo) { return utxo.confirmations >= group; }); @@ -1549,39 +1556,42 @@ WalletService.prototype._selectTxInputs2 = function(txp, utxosToExclude, cb) { // 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; + return next(); } log.debug('Candidate utxos: ' + formatInputs(candidateUtxos)); lastGroupLength = candidateUtxos.length; - var result = select(candidateUtxos); - if (result && !_.isArray(result)) { - error = result; - } else { - inputs = result; - error = null; - } + select(candidateUtxos, function(err, selected) { + if (err) { + log.debug('No inputs selected on this group: ', err); + selectionError = err; + return next(); + } - log.debug('Selected inputs from this group: ' + formatInputs(inputs)); + selectionError = null; + inputs = selected; - if (!_.isEmpty(inputs)) return false; - }); - - if (error) return cb(error); + log.debug('Selected inputs from this group: ' + formatInputs(inputs)); + 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(inputs); + txp.setInputs(inputs); - var err = self._checkTxAndEstimateFee(txp); + var err = self._checkTxAndEstimateFee(txp); - if (!err) { - log.debug('Successfully built transaction. Total fees: ', formatAmount(txp.fee)); - } else { - log.warn('Error building transaction', err); - } + if (!err) { + log.debug('Successfully built transaction. Total fees: ', formatAmount(txp.fee)); + } else { + log.warn('Error building transaction', err); + } - return cb(err); + return cb(err); + }); }); }; diff --git a/test/integration/server.js b/test/integration/server.js index 0b05fdb..3cb0053 100644 --- a/test/integration/server.js +++ b/test/integration/server.js @@ -3169,7 +3169,7 @@ describe('Wallet service', function() { }); }); - describe('UTXO selection', function() { + describe('UTXO Selection', function() { var server, wallet; beforeEach(function(done) { helpers.createAndJoinWallet(2, 3, function(s, w) { @@ -3179,35 +3179,251 @@ describe('Wallet service', function() { }); }); - it('should create a tx', function(done) { - helpers.stubUtxos(server, wallet, [1, 2], function() { + 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: 0.8 * 1e8, + amount: 200e2, }], - feePerKb: 100e2, + feePerKb: 10e2, }; - server.createTx(txOpts, function(err, tx) { + server.createTx(txOpts, function(err, txp) { + console.log('*** [server.js ln3193] err:', err); // TODO + should.not.exist(err); - should.exist(tx); - tx.walletM.should.equal(2); - tx.walletN.should.equal(3); - tx.requiredRejections.should.equal(2); - tx.requiredSignatures.should.equal(2); - tx.isAccepted().should.equal.false; - tx.isRejected().should.equal.false; - tx.isPending().should.equal.true; - tx.isTemporary().should.equal.true; - tx.amount.should.equal(helpers.toSatoshi(0.8)); - server.getPendingTxs({}, function(err, txs) { - should.not.exist(err); - txs.should.be.empty; + should.exist(txp); + txp.inputs.length.should.equal(1); + txp.inputs[0].satoshis.should.equal(35000); + + 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: 10e2, + }; + server.createTx(txOpts, function(err, txp) { + should.not.exist(err); + should.exist(txp); + _.all(txp.inputs, function(input) { + return input == 100e2; + }); + done(); + }); + }); + }); + 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.8e8, + }], + feePerKb: 100e2, + }; + server.createTx(txOpts, function(err, txp) { + console.log('*** [server.js ln3417] err:', err); // TODO + + should.not.exist(err); + should.exist(txp); + txp.inputs.length.should.equal(1); + txp.inputs[0].satoshis.should.equal(1e8); + done(); + }); + }); + }); }); }); @@ -5731,257 +5947,4 @@ describe('Wallet service', function() { }); }); }); - - describe('UTXO Selection', function() { - var server, wallet; - beforeEach(function(done) { - helpers.createAndJoinWallet(2, 3, function(s, w) { - server = s; - wallet = w; - done(); - }); - }); - - 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 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: 10e2, - }; - server.createTx(txOpts, function(err, txp) { - should.not.exist(err); - should.exist(txp); - _.all(txp.inputs, function(input) { - return input == 100e2; - }); - done(); - }); - }); - }); - it('should select unconfirmed utxos if not enough confirmed utxos', function(done) { - helpers.stubUtxos(server, wallet, ['u 1btc', '0.5btc'], function() { - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 0.8e8, - }], - feePerKb: 100e2, - }; - 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(); - }); - }); - }); - }); - });