diff --git a/lib/model/txproposal.js b/lib/model/txproposal.js index 4998ed9..a6f736b 100644 --- a/lib/model/txproposal.js +++ b/lib/model/txproposal.js @@ -46,6 +46,7 @@ TxProposal.create = function(opts) { x.requiredRejections = Math.min(x.walletM, x.walletN - x.walletM + 1), x.status = 'temporary'; x.actions = []; + x.feeLevel = opts.feeLevel; x.feePerKb = opts.feePerKb; x.excludeUnconfirmedUtxos = opts.excludeUnconfirmedUtxos; @@ -98,6 +99,7 @@ TxProposal.fromObj = function(obj) { }); x.outputOrder = obj.outputOrder; x.fee = obj.fee; + x.feeLevel = obj.feeLevel; x.feePerKb = obj.feePerKb; x.excludeUnconfirmedUtxos = obj.excludeUnconfirmedUtxos; x.addressType = obj.addressType; diff --git a/lib/server.js b/lib/server.js index e6343af..cdc5fdf 100644 --- a/lib/server.js +++ b/lib/server.js @@ -1683,14 +1683,30 @@ WalletService.prototype._validateAndSanitizeTxOpts = function(wallet, opts, cb) async.series([ function(next) { - // feePerKb is required unless inputs & fee are specified - if (!_.isNumber(opts.feePerKb) && !(opts.inputs && _.isNumber(opts.fee))) - return next(new ClientError('Required argument missing')); + var feeArgs = !!opts.feeLevel + _.isNumber(opts.feePerKb) + _.isNumber(opts.fee); + if (feeArgs > 1) + return next(new ClientError('Only one of feeLevel/feePerKb/fee can be specified')); + + if (feeArgs == 0) { + log.debug('No fee provided, using "normal" fee level'); + opts.feeLevel = 'normal'; + } + + if (opts.feeLevel) { + if (!_.any(Defaults.FEE_LEVELS, { + name: opts.feeLevel + })) + return next(new ClientError('Invalid fee level. Valid values are ' + _.pluck(Defaults.FEE_LEVELS, 'name').join(', '))); + } if (_.isNumber(opts.feePerKb)) { if (opts.feePerKb < Defaults.MIN_FEE_PER_KB || opts.feePerKb > Defaults.MAX_FEE_PER_KB) return next(new ClientError('Invalid fee per KB')); } + + if (_.isNumber(opts.fee) && !opts.inputs) + return next(new ClientError('fee can only be set when inputs are specified')); + next(); }, function(next) { @@ -1705,7 +1721,7 @@ WalletService.prototype._validateAndSanitizeTxOpts = function(wallet, opts, cb) if (_.isNumber(opts.outputs[0].amount)) return next(new ClientError('Amount is not allowed when sendMax is specified')); if (_.isNumber(opts.fee)) - return next(new ClientError('Fee is not allowed when sendMax is specified (use feePerKb instead)')); + return next(new ClientError('Fee is not allowed when sendMax is specified (use feeLevel/feePerKb instead)')); self.getSendMaxInfo({ feePerKb: opts.feePerKb || Defaults.DEFAULT_FEE_PER_KB, @@ -1738,7 +1754,8 @@ WalletService.prototype._validateAndSanitizeTxOpts = function(wallet, 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 {number} opts.feePerKb - Use an alternative fee per KB for this TX. + * @param {number} opts.feeLevel[='normal'] - Optional. Specify the fee level for this TX ('priority', 'normal', 'economy', 'superEconomy') as defined in Defaults.FEE_LEVELS. + * @param {number} opts.feePerKb - Optional. Specify the fee per KB for this TX (in satoshi). * @param {string} opts.changeAddress - Optional. Use this address as the change address for the tx. The address should belong to the wallet. In the case of singleAddress wallets, the first main address will be used. * @param {Boolean} opts.sendMax - Optional. Send maximum amount of funds that make sense under the specified fee/feePerKb conditions. (defaults to false). * @param {string} opts.payProUrl - Optional. Paypro URL for peers to verify TX @@ -1774,13 +1791,33 @@ WalletService.prototype.createTx = function(opts, cb) { } }; + + function getFeePerKb(wallet, cb) { + if (opts.inputs && _.isNumber(opts.fee)) return cb(); + if (_.isNumber(opts.feePerKb)) return cb(null, opts.feePerKb); + self.getFeeLevels({ + network: wallet.network + }, function(err, levels) { + if (err) return cb(err); + var level = _.find(levels, { + level: opts.feeLevel + }); + if (!level) { + var msg = 'Could not compute fee for "' + opts.feeLevel + '" level'; + log.error(msg); + return cb(new ClientError(msg)); + } + return cb(null, level.feePerKb); + }); + }; + function checkTxpAlreadyExists(txProposalId, cb) { if (!txProposalId) return cb(); self.storage.fetchTx(self.walletId, txProposalId, cb); }; self._runLocked(cb, function(cb) { - var txp, changeAddress; + var txp, changeAddress, feePerKb; self.getWallet({}, function(err, wallet) { if (err) return cb(err); if (!wallet.isComplete()) return cb(Errors.WALLET_NOT_COMPLETE); @@ -1809,6 +1846,12 @@ WalletService.prototype.createTx = function(opts, cb) { next(); }); }, + function(next) { + getFeePerKb(wallet, function(err, fee) { + feePerKb = fee; + next(); + }); + }, function(next) { var txOpts = { id: opts.txProposalId, @@ -1817,7 +1860,8 @@ WalletService.prototype.createTx = function(opts, cb) { outputs: opts.outputs, message: opts.message, changeAddress: changeAddress, - feePerKb: opts.feePerKb, + feeLevel: opts.feeLevel, + feePerKb: feePerKb, payProUrl: opts.payProUrl, walletM: wallet.m, walletN: wallet.n, @@ -1853,7 +1897,6 @@ WalletService.prototype.createTx = function(opts, cb) { }); }); }; - WalletService.prototype._verifyRequestPubKey = function(requestPubKey, signature, xPubKey) { var pub = (new Bitcore.HDPublicKey(xPubKey)).derive(Constants.PATHS.REQUEST_KEY_AUTH).publicKey; return Utils.verifyMessage(requestPubKey, signature, pub.toString()); diff --git a/test/integration/server.js b/test/integration/server.js index 8068ddb..705fdce 100644 --- a/test/integration/server.js +++ b/test/integration/server.js @@ -2286,6 +2286,7 @@ describe('Wallet service', function() { tx.isTemporary().should.equal.true; tx.amount.should.equal(helpers.toSatoshi(0.8)); tx.feePerKb.should.equal(123e2); + should.not.exist(tx.feeLevel); server.getPendingTxs({}, function(err, txs) { should.not.exist(err); txs.should.be.empty; @@ -2294,91 +2295,199 @@ describe('Wallet service', function() { }); }); }); - it('should fail to create a tx without outputs', function(done) { - helpers.stubUtxos(server, wallet, [1, 2], function() { - var txOpts = { - outputs: [], - feePerKb: 123e2, - }; - server.createTx(txOpts, function(err, tx) { - should.exist(err); - should.not.exist(tx); - err.message.should.equal('No outputs were specified'); - done(); - }); - }); - }); - it('should create a tx with foreign ID', function(done) { - helpers.stubUtxos(server, wallet, 2, function() { - var txOpts = { - txProposalId: '123', - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 1e8, - }], - feePerKb: 100e2, - }; - server.createTx(txOpts, function(err, tx) { - should.not.exist(err); - should.exist(tx); - tx.id.should.equal('123'); - done(); - }); - }); - }); - it('should return already created tx if same foreign ID is specified and tx still unpublished', function(done) { - helpers.stubUtxos(server, wallet, 2, function() { - var txOpts = { - txProposalId: '123', - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 1e8, - }], - feePerKb: 100e2, - }; - server.createTx(txOpts, function(err, tx) { - should.not.exist(err); - should.exist(tx); - tx.id.should.equal('123'); + + describe('Validations', function() { + it('should fail to create a tx without outputs', function(done) { + helpers.stubUtxos(server, wallet, [1, 2], function() { + var txOpts = { + outputs: [], + feePerKb: 123e2, + }; server.createTx(txOpts, function(err, tx) { + should.exist(err); + should.not.exist(tx); + err.message.should.equal('No outputs were specified'); + done(); + }); + }); + }); + it('should fail to create tx for invalid address', function(done) { + helpers.stubUtxos(server, wallet, 1, function() { + var txOpts = { + outputs: [{ + toAddress: 'invalid address', + amount: 0.5e8 + }], + feePerKb: 100e2, + }; + server.createTx(txOpts, function(err, tx) { + should.exist(err); + should.not.exist(tx); + // may fail due to Non-base58 character, or Checksum mismatch, or other + done(); + }); + }); + }); + it('should fail to create tx for address of different network', function(done) { + helpers.stubUtxos(server, wallet, 1, function() { + var txOpts = { + outputs: [{ + toAddress: 'myE38JHdxmQcTJGP1ZiX4BiGhDxMJDvLJD', + amount: 0.5e8 + }], + feePerKb: 100e2, + }; + server.createTx(txOpts, function(err, tx) { + should.not.exist(tx); + should.exist(err); + err.code.should.equal('INCORRECT_ADDRESS_NETWORK'); + err.message.should.equal('Incorrect address network'); + done(); + }); + }); + }); + it('should fail to create tx for invalid amount', function(done) { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 0, + }], + feePerKb: 100e2, + }; + server.createTx(txOpts, function(err, tx) { + should.not.exist(tx); + should.exist(err); + err.message.should.equal('Invalid amount'); + done(); + }); + }); + it('should fail to specify both feeLevel & feePerKb', function(done) { + helpers.stubUtxos(server, wallet, 2, function() { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 1e8, + }], + feeLevel: 'normal', + feePerKb: 123e2, + }; + server.createTx(txOpts, function(err, txp) { + should.exist(err); + should.not.exist(txp); + err.toString().should.contain('Only one of feeLevel/feePerKb'); + done(); + }); + }); + }); + it('should be able to create tx with inputs argument', function(done) { + helpers.stubUtxos(server, wallet, [1, 3, 2], function(utxos) { + server.getUtxos({}, function(err, utxos) { should.not.exist(err); - should.exist(tx); - tx.id.should.equal('123'); - server.storage.fetchTxs(wallet.id, {}, function(err, txs) { + var inputs = [utxos[0], utxos[2]]; + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 2.5e8, + }], + feePerKb: 100e2, + inputs: inputs, + }; + server.createTx(txOpts, function(err, tx) { should.not.exist(err); - should.exist(txs); - txs.length.should.equal(1); + should.exist(tx); + tx.inputs.length.should.equal(2); + var txids = _.pluck(tx.inputs, 'txid'); + txids.should.contain(utxos[0].txid); + txids.should.contain(utxos[2].txid); done(); }); }); }); }); - }); - it('should return already published tx if same foreign ID is specified and tx already published', function(done) { - helpers.stubUtxos(server, wallet, [2, 2, 2], function() { - var txOpts = { - txProposalId: '123', - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 1e8, - }], - feePerKb: 100e2, - }; - server.createTx(txOpts, function(err, tx) { - should.not.exist(err); - should.exist(tx); - tx.id.should.equal('123'); - var publishOpts = helpers.getProposalSignatureOpts(tx, TestData.copayers[0].privKey_1H_0); - server.publishTx(publishOpts, function(err, tx) { + it('should be able to specify change address', function(done) { + helpers.stubUtxos(server, wallet, [1, 2], function(utxos) { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 0.8e8, + }], + feePerKb: 100e2, + changeAddress: utxos[0].address, + }; + server.createTx(txOpts, function(err, tx) { should.not.exist(err); should.exist(tx); + var t = tx.getBitcoreTx(); + t.getChangeOutput().script.toAddress().toString().should.equal(txOpts.changeAddress); + 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('Foreign ID', function() { + it('should create a tx with foreign ID', function(done) { + helpers.stubUtxos(server, wallet, 2, function() { + var txOpts = { + txProposalId: '123', + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 1e8, + }], + feePerKb: 100e2, + }; + server.createTx(txOpts, function(err, tx) { + should.not.exist(err); + should.exist(tx); + tx.id.should.equal('123'); + done(); + }); + }); + }); + it('should return already created tx if same foreign ID is specified and tx still unpublished', function(done) { + helpers.stubUtxos(server, wallet, 2, function() { + var txOpts = { + txProposalId: '123', + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 1e8, + }], + feePerKb: 100e2, + }; + server.createTx(txOpts, function(err, tx) { + should.not.exist(err); + should.exist(tx); + tx.id.should.equal('123'); server.createTx(txOpts, function(err, tx) { should.not.exist(err); should.exist(tx); tx.id.should.equal('123'); - tx.status.should.equal('pending'); server.storage.fetchTxs(wallet.id, {}, function(err, txs) { should.not.exist(err); + should.exist(txs); txs.length.should.equal(1); done(); }); @@ -2386,56 +2495,320 @@ describe('Wallet service', function() { }); }); }); - }); - it('should be able to publish a temporary tx proposal', function(done) { - helpers.stubUtxos(server, wallet, [1, 2], function() { - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 0.8 * 1e8, - }], - feePerKb: 100e2, - message: 'some message', - customData: 'some custom data', - }; - 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) { + it('should return already published tx if same foreign ID is specified and tx already published', function(done) { + helpers.stubUtxos(server, wallet, [2, 2, 2], function() { + var txOpts = { + txProposalId: '123', + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 1e8, + }], + feePerKb: 100e2, + }; + server.createTx(txOpts, function(err, tx) { should.not.exist(err); - server.getPendingTxs({}, function(err, txs) { + should.exist(tx); + tx.id.should.equal('123'); + var publishOpts = helpers.getProposalSignatureOpts(tx, TestData.copayers[0].privKey_1H_0); + server.publishTx(publishOpts, function(err, tx) { should.not.exist(err); - txs.length.should.equal(1); - should.exist(txs[0].proposalSignature); - done(); + should.exist(tx); + server.createTx(txOpts, function(err, tx) { + should.not.exist(err); + should.exist(tx); + tx.id.should.equal('123'); + tx.status.should.equal('pending'); + server.storage.fetchTxs(wallet.id, {}, function(err, txs) { + should.not.exist(err); + txs.length.should.equal(1); + done(); + }); + }); }); }); }); }); }); - it('should not be able to publish a temporary tx proposal created in a dry run', function(done) { - helpers.stubUtxos(server, wallet, [1, 2], function() { + + describe('Publishing', function() { + it('should be able to publish a temporary tx proposal', function(done) { + helpers.stubUtxos(server, wallet, [1, 2], function() { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 0.8 * 1e8, + }], + feePerKb: 100e2, + message: 'some message', + customData: 'some custom data', + }; + 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); + should.exist(txs[0].proposalSignature); + done(); + }); + }); + }); + }); + }); + it('should not be able to publish a temporary tx proposal created in a dry run', function(done) { + helpers.stubUtxos(server, wallet, [1, 2], function() { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 0.8 * 1e8, + }], + feePerKb: 100e2, + dryRun: true, + }; + 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.exist(err); + err.code.should.equal('TX_NOT_FOUND'); + server.getPendingTxs({}, function(err, txs) { + should.not.exist(err); + txs.length.should.equal(0); + done(); + }); + }); + }); + }); + }); + it('should delay NewTxProposal notification until published', 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); + server.getNotifications({}, function(err, notifications) { + should.not.exist(err); + _.pluck(notifications, 'type').should.not.contain('NewTxProposal'); + var publishOpts = helpers.getProposalSignatureOpts(txp, TestData.copayers[0].privKey_1H_0); + server.publishTx(publishOpts, function(err) { + should.not.exist(err); + server.getNotifications({}, function(err, notifications) { + should.not.exist(err); + _.pluck(notifications, 'type').should.contain('NewTxProposal'); + done(); + }); + }); + }); + }); + }); + }); + it('should fail to publish non-existent tx proposal', function(done) { + server.publishTx({ + txProposalId: 'wrong-id', + proposalSignature: 'dummy', + }, function(err) { + should.exist(err); + server.getPendingTxs({}, function(err, txs) { + should.not.exist(err); + txs.should.be.empty; + done(); + }); + }); + }); + it('should fail to publish tx proposal with wrong signature', 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); + 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 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, - dryRun: true, }; - 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.exist(err); - err.code.should.equal('TX_NOT_FOUND'); + + 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(0); - done(); + 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(); + }); + }); + }); + + describe('Fee levels', function() { + it('should create a tx specifying feeLevel', function(done) { + helpers.stubFeeLevels({ + 1: 400e2, + 2: 200e2, + 6: 180e2, + 24: 90e2, + }); + helpers.stubUtxos(server, wallet, 2, function() { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 1e8, + }], + feeLevel: 'economy', + }; + server.createTx(txOpts, function(err, txp) { + should.not.exist(err); + should.exist(txp); + txp.feePerKb.should.equal(180e2); + txp.feeLevel.should.equal('economy'); + done(); + }); + }); + }); + it('should fail if the specified fee level does not exist', function(done) { + helpers.stubUtxos(server, wallet, 2, function() { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 1e8, + }], + feeLevel: 'madeUpLevel', + }; + server.createTx(txOpts, function(err, txp) { + should.exist(err); + should.not.exist(txp); + err.toString().should.contain('Invalid fee level'); + done(); + }); + }); + }); + it('should assume "normal" fee level if no feeLevel and no feePerKb/fee is specified', function(done) { + helpers.stubFeeLevels({ + 1: 400e2, + 2: 200e2, + 6: 180e2, + 24: 90e2, + }); + helpers.stubUtxos(server, wallet, 2, function() { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 1e8, + }], + }; + server.createTx(txOpts, function(err, txp) { + should.not.exist(err); + should.exist(txp); + txp.feePerKb.should.equal(200e2); + txp.feeLevel.should.equal('normal'); + done(); }); }); }); @@ -2499,56 +2872,28 @@ describe('Wallet service', function() { }); }); }); - it('should fail to create tx for invalid address', function(done) { + it('should fail gracefully when bitcore throws exception on raw tx creation', function(done) { helpers.stubUtxos(server, wallet, 1, function() { + var bitcoreStub = sinon.stub(Bitcore, 'Transaction'); + bitcoreStub.throws({ + name: 'dummy', + message: 'dummy exception' + }); var txOpts = { outputs: [{ - toAddress: 'invalid address', - amount: 0.5e8 + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 0.5e8, }], feePerKb: 100e2, }; server.createTx(txOpts, function(err, tx) { should.exist(err); - should.not.exist(tx); - // may fail due to Non-base58 character, or Checksum mismatch, or other + err.message.should.equal('dummy exception'); + bitcoreStub.restore(); done(); }); }); }); - it('should fail to create tx for address of different network', function(done) { - helpers.stubUtxos(server, wallet, 1, function() { - var txOpts = { - outputs: [{ - toAddress: 'myE38JHdxmQcTJGP1ZiX4BiGhDxMJDvLJD', - amount: 0.5e8 - }], - feePerKb: 100e2, - }; - server.createTx(txOpts, function(err, tx) { - should.not.exist(tx); - should.exist(err); - err.code.should.equal('INCORRECT_ADDRESS_NETWORK'); - err.message.should.equal('Incorrect address network'); - done(); - }); - }); - }); - it('should fail to create tx for invalid amount', function(done) { - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 0, - }], - feePerKb: 100e2, - }; - server.createTx(txOpts, function(err, tx) { - should.not.exist(tx); - should.exist(err); - err.message.should.equal('Invalid amount'); - done(); - }); - }); it('should fail to create a tx exceeding max size in kb', function(done) { var _oldDefault = Defaults.MAX_TX_SIZE_IN_KB; Defaults.MAX_TX_SIZE_IN_KB = 1; @@ -2593,6 +2938,23 @@ describe('Wallet service', function() { }); }); }); + it('should fail to create tx for dust amount in outputs', function(done) { + helpers.stubUtxos(server, wallet, 1, function() { + var txOpts = { + outputs: [{ + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 20e2, + }], + feePerKb: 100e2, + }; + server.createTx(txOpts, function(err, tx) { + should.exist(err); + err.code.should.equal('DUST_AMOUNT'); + err.message.should.equal('Amount below dust threshold'); + done(); + }); + }); + }); it('should create tx with 0 change output', function(done) { helpers.stubUtxos(server, wallet, 1, function() { var fee = 4100; // The exact fee of the resulting tx @@ -2615,28 +2977,6 @@ describe('Wallet service', function() { }); }); }); - it('should fail gracefully when bitcore throws exception on raw tx creation', function(done) { - helpers.stubUtxos(server, wallet, 1, function() { - var bitcoreStub = sinon.stub(Bitcore, 'Transaction'); - bitcoreStub.throws({ - name: 'dummy', - message: 'dummy exception' - }); - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 0.5e8, - }], - feePerKb: 100e2, - }; - server.createTx(txOpts, function(err, tx) { - should.exist(err); - err.message.should.equal('dummy exception'); - bitcoreStub.restore(); - done(); - }); - }); - }); it('should create tx when there is a pending tx and enough UTXOs', function(done) { helpers.stubUtxos(server, wallet, [1.1, 1.2, 1.3], function() { var txOpts = { @@ -2697,124 +3037,6 @@ describe('Wallet service', function() { }); }); }); - it('should be able to create tx with inputs argument', function(done) { - helpers.stubUtxos(server, wallet, [1, 3, 2], function(utxos) { - server.getUtxos({}, function(err, utxos) { - should.not.exist(err); - var inputs = [utxos[0], utxos[2]]; - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 2.5e8, - }], - feePerKb: 100e2, - inputs: inputs, - }; - server.createTx(txOpts, function(err, tx) { - should.not.exist(err); - should.exist(tx); - tx.inputs.length.should.equal(2); - var txids = _.pluck(tx.inputs, 'txid'); - txids.should.contain(utxos[0].txid); - txids.should.contain(utxos[2].txid); - done(); - }); - }); - }); - }); - it('should delay NewTxProposal notification until published', 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); - server.getNotifications({}, function(err, notifications) { - should.not.exist(err); - _.pluck(notifications, 'type').should.not.contain('NewTxProposal'); - var publishOpts = helpers.getProposalSignatureOpts(txp, TestData.copayers[0].privKey_1H_0); - server.publishTx(publishOpts, function(err) { - should.not.exist(err); - server.getNotifications({}, function(err, notifications) { - should.not.exist(err); - _.pluck(notifications, 'type').should.contain('NewTxProposal'); - done(); - }); - }); - }); - }); - }); - }); - it('should fail to publish non-existent tx proposal', function(done) { - server.publishTx({ - txProposalId: 'wrong-id', - proposalSignature: 'dummy', - }, function(err) { - should.exist(err); - server.getPendingTxs({}, function(err, txs) { - should.not.exist(err); - txs.should.be.empty; - done(); - }); - }); - }); - it('should fail to publish tx proposal with wrong signature', 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); - 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(); @@ -2864,116 +3086,6 @@ describe('Wallet service', function() { }); }); }); - 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 be able to specify change address', function(done) { - helpers.stubUtxos(server, wallet, [1, 2], function(utxos) { - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 0.8e8, - }], - feePerKb: 100e2, - changeAddress: utxos[0].address, - }; - server.createTx(txOpts, function(err, tx) { - should.not.exist(err); - should.exist(tx); - var t = tx.getBitcoreTx(); - t.getChangeOutput().script.toAddress().toString().should.equal(txOpts.changeAddress); - 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(); - }); - }); - }); it('should be able to send max funds', function(done) { helpers.stubUtxos(server, wallet, [1, 2], function() { var txOpts = { @@ -3033,23 +3145,6 @@ describe('Wallet service', function() { }); }); }); - it('should fail to create tx for dust amount in outputs', function(done) { - helpers.stubUtxos(server, wallet, 1, function() { - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 20e2, - }], - feePerKb: 100e2, - }; - server.createTx(txOpts, function(err, tx) { - should.exist(err); - err.code.should.equal('DUST_AMOUNT'); - err.message.should.equal('Amount below dust threshold'); - done(); - }); - }); - }); }); describe('Backoff time', function(done) {