diff --git a/lib/model/txproposal.js b/lib/model/txproposal.js index ef120d3..0ec513f 100644 --- a/lib/model/txproposal.js +++ b/lib/model/txproposal.js @@ -9,7 +9,33 @@ var Address = Bitcore.Address; var TxProposalAction = require('./txproposalaction'); function TxProposal() { - this.version = '1.0.0'; + this.version = '1.0.1'; +}; + +TxProposal.Types = { + SIMPLE: 'simple', + MULTIPLEOUTPUTS: 'multiple_outputs', +}; + +TxProposal.isTypeSupported = function(type) { + return !type || _.contains(_.values(TxProposal.Types), type); +}; + +TxProposal._create = {}; + +TxProposal._create.simple = function(txp, opts) { + txp.toAddress = opts.toAddress; + txp.amount = opts.amount; + txp.outputOrder = _.shuffle(_.range(2)); + txp.network = Bitcore.Address(txp.toAddress).toObject().network; +}; + +TxProposal._create.undefined = TxProposal._create.simple; + +TxProposal._create.multiple_outputs = function(txp, opts) { + txp.outputs = opts.outputs; + txp.outputOrder = _.shuffle(_.range(txp.outputs.length + 1)); + txp.network = Bitcore.Address(txp.outputs[0].toAddress).toObject().network; }; TxProposal.create = function(opts) { @@ -17,13 +43,15 @@ TxProposal.create = function(opts) { var x = new TxProposal(); + if (opts.version == '1.0.0' && !opts.type) { + opts.type = TxProposal.Types.SIMPLE; + } + x.type = opts.type; var now = Date.now(); x.createdOn = Math.floor(now / 1000); x.id = _.padLeft(now, 14, '0') + Uuid.v4(); x.walletId = opts.walletId; x.creatorId = opts.creatorId; - x.toAddress = opts.toAddress; - x.amount = opts.amount; x.message = opts.message; x.payProUrl = opts.payProUrl; x.proposalSignature = opts.proposalSignature; @@ -34,22 +62,29 @@ TxProposal.create = function(opts) { x.requiredRejections = opts.requiredRejections; x.status = 'pending'; x.actions = []; - x.outputOrder = _.shuffle(_.range(2)); x.fee = null; - x.network = Bitcore.Address(x.toAddress).toObject().network; x.feePerKb = opts.feePerKb; + if (typeof TxProposal._create[x.type] == 'function') { + TxProposal._create[x.type](x, opts); + } + return x; }; TxProposal.fromObj = function(obj) { var x = new TxProposal(); + if (obj.version == '1.0.0' && !obj.type) { + obj.type = TxProposal.Types.SIMPLE; + } + x.type = obj.type; x.version = obj.version; x.createdOn = obj.createdOn; x.id = obj.id; x.walletId = obj.walletId; x.creatorId = obj.creatorId; + x.outputs = obj.outputs; x.toAddress = obj.toAddress; x.amount = obj.amount; x.message = obj.message; @@ -126,6 +161,36 @@ TxProposal.prototype.getRawTx = function() { return t.uncheckedSerialize(); }; +/** + * getHeader + * + * @return {Array} arguments for getProposalHash wallet utility method + */ +TxProposal.prototype.getHeader = function() { + if (this.type == TxProposal.Types.MULTIPLEOUTPUTS) { + return [ { + outputs: this.outputs, + message: this.message, + payProUrl: this.payProUrl + } ]; + } else { + return [ this.toAddress, this.amount, this.message, this.payProUrl ]; + } +}; + +/** + * getTotalAmount + * + * @return {Number} total amount of all outputs excluding change output + */ +TxProposal.prototype.getTotalAmount = function() { + if (this.type == TxProposal.Types.MULTIPLEOUTPUTS) { + return _.map(this.outputs, function(o) { return o.amount }) + .reduce(function(total, n) { return total + n; }); + } else { + return this.amount; + } +}; /** * getActors diff --git a/lib/server.js b/lib/server.js index dd1d114..a87ac98 100644 --- a/lib/server.js +++ b/lib/server.js @@ -731,7 +731,7 @@ WalletService.prototype._selectTxInputs = function(txp, cb) { var balance = self._totalizeUtxos(utxos); - if (balance.totalAmount < txp.amount) + if (balance.totalAmount < txp.getTotalAmount()) return cb(new ClientError('INSUFFICIENTFUNDS', 'Insufficient funds')); if ((balance.totalAmount - balance.lockedAmount) < txp.amount) return cb(new ClientError('LOCKEDFUNDS', 'Funds are locked by pending transaction proposals')); @@ -751,7 +751,7 @@ WalletService.prototype._selectTxInputs = function(txp, cb) { total += inputs[i].satoshis; i++; - if (total >= txp.amount) { + if (total >= txp.getTotalAmount()) { try { txp.setInputs(selected); bitcoreTx = txp.getBitcoreTx(); @@ -814,8 +814,10 @@ WalletService.prototype._canCreateTx = function(copayerId, cb) { /** * Creates a new transaction proposal. * @param {Object} opts - * @param {string} opts.toAddress - Destination address. - * @param {number} opts.amount - Amount to transfer in satoshi. + * @param {string} opts.type - Proposal type. + * @param {string} opts.toAddress || opts.outputs[].toAddress - Destination address. + * @param {number} opts.amount || 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 {string} opts.proposalSignature - S(toAddress|amount|message|payProUrl). Used by other copayers to verify the proposal. * @param {string} opts.feePerKb - Optional: Use an alternative fee per KB for this TX @@ -825,9 +827,39 @@ WalletService.prototype._canCreateTx = function(copayerId, cb) { WalletService.prototype.createTx = function(opts, cb) { var self = this; - if (!Utils.checkRequired(opts, ['toAddress', 'amount', 'proposalSignature'])) + if (!Utils.checkRequired(opts, ['proposalSignature'])) return cb(new ClientError('Required argument missing')); + if (!Model.TxProposal.isTypeSupported(opts.type)) + return cb(new ClientError('Invalid proposal type')); + + if (opts.type == Model.TxProposal.Types.MULTIPLEOUTPUTS) { + if (!Utils.checkRequired(opts, ['outputs'])) + return cb(new ClientError('Required argument missing')); + _.each(opts.outputs, function(o) { + o.valid = true; + if (!Utils.checkRequired(o, ['toAddress', 'amount'])) { + o.valid = false; + cb(new ClientError('Required outputs argument missing')); + return false; + } + _.each(_.keys(o), function(key) { + if (!_.contains(['toAddress', 'amount', 'message', 'valid'], key)) { + o.valid = false; + cb(new ClientError('Invalid outputs argument found')); + return false; + } + }); + if (!o.valid) return false; + }); + if (_.any(opts.outputs, 'valid', false)) return; + _.each(opts.outputs, function(o) { delete o.valid; }); + + } else { + if (!Utils.checkRequired(opts, ['toAddress', 'amount'])) + return cb(new ClientError('Required argument missing')); + } + var feePerKb = opts.feePerKb || 10000; if (feePerKb < WalletUtils.MIN_FEE_PER_KB || feePerKb > WalletUtils.MAX_FEE_PER_KB) return cb(new ClientError('Invalid fee per KB value')); @@ -844,30 +876,46 @@ WalletService.prototype.createTx = function(opts, cb) { return cb(new ClientError('NOTALLOWEDTOCREATETX', 'Cannot create TX proposal during backoff time')); var copayer = wallet.getCopayer(self.copayerId); - var hash = WalletUtils.getProposalHash(opts.toAddress, opts.amount, opts.message, opts.payProUrl); + var proposalHeader = Model.TxProposal.create(opts).getHeader(); + var hash = WalletUtils.getProposalHash.apply(WalletUtils, proposalHeader); if (!self._verifySignature(hash, opts.proposalSignature, copayer.requestPubKey)) return cb(new ClientError('Invalid proposal signature')); - var toAddress; - try { - toAddress = new Bitcore.Address(opts.toAddress); - } catch (ex) { - return cb(new ClientError('INVALIDADDRESS', 'Invalid address')); - } - if (toAddress.network != wallet.getNetworkName()) - return cb(new ClientError('INVALIDADDRESS', 'Incorrect address network')); - - if (opts.amount <= 0) - return cb(new ClientError('Invalid amount')); - - if (opts.amount < Bitcore.Transaction.DUST_AMOUNT) - return cb(new ClientError('DUSTAMOUNT', 'Amount below dust threshold')); + var outputs = (opts.type == Model.TxProposal.Types.MULTIPLEOUTPUTS) + ? opts.outputs + : [ { toAddress: opts.toAddress, amount: opts.amount } ]; + _.each(outputs, function(output) { + output.valid = false; + var toAddress = {}; + try { + toAddress = new Bitcore.Address(output.toAddress); + } catch (ex) { + cb(new ClientError('INVALIDADDRESS', 'Invalid address')); + return false; + } + if (toAddress.network != wallet.getNetworkName()) { + cb(new ClientError('INVALIDADDRESS', 'Incorrect address network')); + return false; + } + if (output.amount <= 0) { + cb(new ClientError('Invalid amount')); + return false; + } + if (output.amount < Bitcore.Transaction.DUST_AMOUNT) { + cb(new ClientError('DUSTAMOUNT', 'Amount below dust threshold')); + return false; + } + output.valid = true; + }); + if (_.any(outputs, 'valid', false)) return; var changeAddress = wallet.createAddress(true); var txp = Model.TxProposal.create({ + type: opts.type, walletId: self.walletId, creatorId: self.copayerId, + outputs: opts.outputs, toAddress: opts.toAddress, amount: opts.amount, message: opts.message, @@ -879,6 +927,7 @@ WalletService.prototype.createTx = function(opts, cb) { requiredRejections: Math.min(wallet.m, wallet.n - wallet.m + 1), }); + self._selectTxInputs(txp, function(err) { if (err) return cb(err); @@ -891,7 +940,7 @@ WalletService.prototype.createTx = function(opts, cb) { if (err) return cb(err); self._notify('NewTxProposal', { - amount: opts.amount + amount: txp.getTotalAmount() }, function() { return cb(null, txp); }); @@ -1101,7 +1150,7 @@ WalletService.prototype.broadcastTx = function(opts, cb) { self._notify('NewOutgoingTx', { txProposalId: opts.txProposalId, txid: txid, - amount: txp.amount, + amount: txp.getTotalAmount(), }, function() { return cb(null, txp); }); diff --git a/package.json b/package.json index 95f0490..1881518 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "dependencies": { "async": "^0.9.0", "bitcore": "^0.12.9", - "bitcore-wallet-utils": "0.0.16", + "bitcore-wallet-utils": "^0.0.17", "body-parser": "^1.11.0", "coveralls": "^2.11.2", "email-validator": "^1.0.1", diff --git a/test/integration/server.js b/test/integration/server.js index 5c2dc9f..13b45de 100644 --- a/test/integration/server.js +++ b/test/integration/server.js @@ -201,6 +201,35 @@ helpers.createProposalOpts = function(toAddress, amount, message, signingKey, fe return opts; }; +helpers.createProposalOptsByType = function(type, outputs, message, signingKey, feePerKb) { + var opts = { + type: type, + message: message, + proposalSignature: null, + }; + if (type == Model.TxProposal.Types.MULTIPLEOUTPUTS) { + opts.outputs = []; + _.each(outputs, function(o) { + opts.outputs.push(o); + o.amount = helpers.toSatoshi(o.amount); + }); + } else { + opts.toAddress = outputs[0].toAddress; + opts.amount = helpers.toSatoshi(outputs[0].amount); + } + if (feePerKb) opts.feePerKb = feePerKb; + + var txp = Model.TxProposal.create(opts); + var proposalHeader = txp.getHeader(); + var hash = WalletUtils.getProposalHash.apply(WalletUtils, proposalHeader); + + try { + opts.proposalSignature = WalletUtils.signMessage(hash, signingKey); + } catch (ex) {} + + return opts; +}; + helpers.createAddresses = function(server, wallet, main, change, cb) { async.map(_.range(main + change), function(i, next) { var address = wallet.createAddress(i >= main); @@ -1372,8 +1401,7 @@ describe('Wallet service', function() { server.createTx(txOpts, function(err, tx) { should.not.exist(tx); should.exist(err); - err.code.should.equal('INVALIDADDRESS'); - err.message.should.equal('Invalid address'); + // may fail due to Non-base58 character, or Checksum mismatch, or other done(); }); }); @@ -1633,6 +1661,49 @@ describe('Wallet service', function() { }); }); }); + + it('should create tx for type multiple_outputs', function(done) { + helpers.stubUtxos(server, wallet, [100, 200], function() { + var outputs = [ + { toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', amount: 75, message: 'message #1' }, + { toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', amount: 75, message: 'message #2' } + ]; + var txOpts = helpers.createProposalOptsByType(Model.TxProposal.Types.MULTIPLEOUTPUTS, outputs, 'some message', TestData.copayers[0].privKey_1H_0); + server.createTx(txOpts, function(err, tx) { + should.not.exist(err); + should.exist(tx); + done(); + }); + }); + }); + + it('should fail to create tx for type multiple_outputs with invalid output argument', function(done) { + helpers.stubUtxos(server, wallet, [100, 200], function() { + var outputs = [ + { toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', amount: 80, message: 'message #1', foo: 'bar' }, + { toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', amount: 90, message: 'message #2' } + ]; + var txOpts = helpers.createProposalOptsByType(Model.TxProposal.Types.MULTIPLEOUTPUTS, outputs, 'some message', TestData.copayers[0].privKey_1H_0); + server.createTx(txOpts, function(err, tx) { + should.exist(err); + err.message.should.contain('Invalid outputs argument'); + done(); + }); + }); + }); + + it('should fail to create tx for unsupported proposal type', function(done) { + helpers.stubUtxos(server, wallet, [100, 200], function() { + var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 80, 'some message', TestData.copayers[0].privKey_1H_0); + txOpts.type = 'bogus'; + server.createTx(txOpts, function(err, tx) { + should.exist(err); + err.message.should.contain('Invalid proposal type'); + done(); + }); + }); + }); + it('should be able to send max amount', function(done) { helpers.stubUtxos(server, wallet, _.range(1, 10, 0), function() { server.getBalance({}, function(err, balance) { diff --git a/test/models/txproposal.js b/test/models/txproposal.js index da85a5e..700e402 100644 --- a/test/models/txproposal.js +++ b/test/models/txproposal.js @@ -6,23 +6,45 @@ var sinon = require('sinon'); var should = chai.should(); var TXP = require('../../lib/model/txproposal'); var Bitcore = require('bitcore-wallet-utils').Bitcore; - +var WalletUtils = require('bitcore-wallet-utils'); describe('TXProposal', function() { - describe('#fromObj', function() { + describe('#create', function() { it('should create a TXP', function() { - var txp = TXP.fromObj(aTXP()); + var txp = TXP.create(aTxpOpts()); should.exist(txp); + should.exist(txp.toAddress); + should.not.exist(txp.outputs); + }); + it('should create a multiple-outputs TXP', function() { + var txp = TXP.create(aTxpOpts(TXP.Types.MULTIPLEOUTPUTS)); + should.exist(txp); + should.not.exist(txp.toAddress); + should.exist(txp.outputs); }); }); - describe('#_getBitcoreTx', function() { + + describe('#fromObj', function() { + it('should copy a TXP', function() { + var txp = TXP.fromObj(aTXP()); + should.exist(txp); + txp.toAddress.should.equal(aTXP().toAddress); + }); + it('should copy a multiple-outputs TXP', function() { + var txp = TXP.fromObj(aTXP(TXP.Types.MULTIPLEOUTPUTS)); + should.exist(txp); + txp.outputs.should.deep.equal(aTXP(TXP.Types.MULTIPLEOUTPUTS).outputs); + }); + }); + + describe('#getBitcoreTx', function() { it('should create a valid bitcore TX', function() { var txp = TXP.fromObj(aTXP()); var t = txp.getBitcoreTx(); should.exist(t); }); - it('should order ouputs as specified by outputOrder', function() { + it('should order outputs as specified by outputOrder', function() { var txp = TXP.fromObj(aTXP()); txp.outputOrder = [0, 1]; @@ -33,8 +55,44 @@ describe('TXProposal', function() { var t = txp.getBitcoreTx(); t.getChangeOutput().should.deep.equal(t.outputs[0]); }); + it('should create a bitcore TX with multiple outputs', function() { + var txp = TXP.fromObj(aTXP(TXP.Types.MULTIPLEOUTPUTS)); + txp.outputOrder = [0, 1, 2]; + var t = txp.getBitcoreTx(); + t.getChangeOutput().should.deep.equal(t.outputs[2]); + }); }); + describe('#getHeader', function() { + it('should be compatible with simple proposal legacy header', function() { + var x = TXP.fromObj(aTXP()); + var proposalHeader = x.getHeader(); + var pH = WalletUtils.getProposalHash.apply(WalletUtils, proposalHeader); + var uH = WalletUtils.getProposalHash(x.toAddress, x.amount, x.message, x.payProUrl); + pH.should.equal(uH); + }); + it('should handle multiple-outputs', function() { + var x = TXP.fromObj(aTXP(TXP.Types.MULTIPLEOUTPUTS)); + var proposalHeader = x.getHeader(); + should.exist(proposalHeader); + var pH = WalletUtils.getProposalHash.apply(WalletUtils, proposalHeader); + should.exist(pH); + }); + }); + + describe('#getTotalAmount', function() { + it('should be compatible with simple proposal legacy amount', function() { + var x = TXP.fromObj(aTXP()); + var total = x.getTotalAmount(); + total.should.equal(x.amount); + }); + it('should handle multiple-outputs', function() { + var x = TXP.fromObj(aTXP(TXP.Types.MULTIPLEOUTPUTS)); + var totalOutput = 0; + _.each(x.outputs, function(o) { totalOutput += o.amount }); + x.getTotalAmount().should.equal(totalOutput); + }); + }); describe('#sign', function() { it('should sign 2-2', function() { @@ -86,9 +144,36 @@ var theXPriv = 'xprv9s21ZrQH143K2rMHbXTJmWTuFx6ssqn1vyRoZqPkCXYchBSkp5ey8kMJe84s var theXPub = 'xpub661MyMwAqRbcFLRkhYzK8eQdoywNHJVsJCMQNDoMks5bZymuMcyDgYfnVQYq2Q9npnVmdTAthYGc3N3uxm5sEdnTpSqBc4YYTAhNnoSxCm9'; var theSignatures = ['3045022100896aeb8db75fec22fddb5facf791927a996eb3aee23ee6deaa15471ea46047de02204c0c33f42a9d3ff93d62738712a8c8a5ecd21b45393fdd144e7b01b5a186f1f9']; -var aTXP = function() { - return { +var aTxpOpts = function(type) { + var opts = { + type: type, + toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + amount: 50000000, + message: 'some message' + }; + if (type == TXP.Types.MULTIPLEOUTPUTS) { + opts.outputs = [ + { + toAddress: "18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7", + amount: 10000000, + message: "first message" + }, + { + toAddress: "18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7", + amount: 20000000, + message: "second message" + }, + ]; + delete opts.toAddress; + delete opts.amount; + } + return opts; +}; + +var aTXP = function(type) { + var txp = { "version": "1.0.0", + "type": type, "createdOn": 1423146231, "id": "75c34f49-1ed6-255f-e9fd-0c71ae75ed1e", "walletId": "1", @@ -123,5 +208,23 @@ var aTXP = function() { "status": "pending", "actions": [], "outputOrder": [0, 1], + }; + if (type == TXP.Types.MULTIPLEOUTPUTS) { + txp.outputs = [ + { + toAddress: "18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7", + amount: 10000000, + message: "first message" + }, + { + toAddress: "18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7", + amount: 20000000, + message: "second message" + }, + ]; + txp.outputOrder = [0, 1, 2]; + delete txp.toAddress; + delete txp.amount; } + return txp; };