proposal header hash and proposal amount should work with multi-output

This commit is contained in:
Gregg Zigler 2015-06-19 09:30:46 -07:00
parent ab33debdd1
commit 0a4bf8f77f
5 changed files with 215 additions and 52 deletions

View File

@ -12,10 +12,13 @@ function TxProposal() {
this.version = '1.0.1';
};
TxProposal.Types = { SIMPLE: 'simple', MULTIPLEOUTPUTS: 'multiple_outputs' };
TxProposal.Types = {
SIMPLE: 'simple',
MULTIPLEOUTPUTS: 'multiple_outputs',
};
TxProposal.prototype._isTypeSupported = function() {
return !this.type || _.contains(_.values(TxProposal.Types), this.type);
TxProposal.isTypeSupported = function(type) {
return !type || _.contains(_.values(TxProposal.Types), type);
};
TxProposal._create = {};
@ -27,6 +30,8 @@ TxProposal._create.simple = function(txp, opts) {
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));
@ -37,11 +42,11 @@ TxProposal.create = function(opts) {
opts = opts || {};
var x = new TxProposal();
x.type = opts.type;
if (!x._isTypeSupported()) {
throw new Error('Unsupported transaction proposal type');
};
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();
@ -60,7 +65,9 @@ TxProposal.create = function(opts) {
x.fee = null;
x.feePerKb = opts.feePerKb;
TxProposal._create[x.type || TxProposal.Types.SIMPLE](x, opts);
if (typeof TxProposal._create[x.type] == 'function') {
TxProposal._create[x.type](x, opts);
}
return x;
};
@ -68,11 +75,10 @@ TxProposal.create = function(opts) {
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;
if (!x._isTypeSupported()) {
throw new Error('Unsupported transaction proposal type');
};
x.version = obj.version;
x.createdOn = obj.createdOn;
x.id = obj.id;
@ -151,6 +157,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

View File

@ -698,7 +698,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'));
@ -718,7 +718,7 @@ WalletService.prototype._selectTxInputs = function(txp, cb) {
total += inputs[i].satoshis;
i++;
if (total >= txp.amount) {
if (total >= txp.getTotalAmount()) {
try {
txp.inputs = selected;
bitcoreTx = txp.getBitcoreTx();
@ -782,8 +782,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
@ -793,9 +795,33 @@ 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'));
var missing = false, unsupported = false;
_.each(opts.outputs, function(o) {
if (!Utils.checkRequired(o, ['toAddress', 'amount']))
missing = true;
_.each(_.keys(o), function(key) {
if (!_.contains(['toAddress', 'amount', 'message'], key))
unsupported = true;
});
});
if (missing)
return cb(new ClientError('Required outputs argument missing'));
if (unsupported)
return cb(new ClientError('Invalid outputs argument found'));
} 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'));
@ -812,30 +838,48 @@ 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) {
var outputs = (opts.type == Model.TxProposal.Types.MULTIPLEOUTPUTS)
? opts.outputs
: [ { toAddress: opts.toAddress, amount: opts.amount } ];
var badAddress = false,
badNetwork = false,
badAmount = false,
badDust = false;
_.each(outputs, function(output) {
var toAddress;
try {
toAddress = new Bitcore.Address(output.toAddress);
} catch (ex) {
badAddress = true;
}
if (toAddress.network != wallet.getNetworkName())
badNetwork = true;
if (output.amount <= 0)
badAmount = true;
if (output.amount < Bitcore.Transaction.DUST_AMOUNT)
badDust = true;
});
if (badAddress)
return cb(new ClientError('INVALIDADDRESS', 'Invalid address'));
}
if (toAddress.network != wallet.getNetworkName())
if (badNetwork)
return cb(new ClientError('INVALIDADDRESS', 'Incorrect address network'));
if (opts.amount <= 0)
if (badAmount)
return cb(new ClientError('Invalid amount'));
if (opts.amount < Bitcore.Transaction.DUST_AMOUNT)
if (badDust)
return cb(new ClientError('DUSTAMOUNT', 'Amount below dust threshold'));
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,
@ -847,6 +891,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);
@ -859,7 +904,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);
});
@ -1069,7 +1114,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);
});

View File

@ -20,7 +20,7 @@
"dependencies": {
"async": "^0.9.0",
"bitcore": "^0.12.9",
"bitcore-wallet-utils": "0.0.17",
"bitcore-wallet-utils": "^0.0.17",
"body-parser": "^1.11.0",
"coveralls": "^2.11.2",
"email-validator": "^1.0.1",

View File

@ -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);
@ -1357,8 +1386,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();
});
});
@ -1618,6 +1646,48 @@ 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();
});
});
});
});
describe('#createTx backoff time', function(done) {

View File

@ -6,7 +6,7 @@ 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() {
@ -23,15 +23,6 @@ describe('TXProposal', function() {
should.not.exist(txp.toAddress);
should.exist(txp.outputs);
});
it('should fail to create a TXP of unknown type', function() {
var txp;
try {
txp = TXP.create(aTxpOpts('bogus'));
} catch(e) {
should.exist(e);
}
should.not.exist(txp);
});
});
describe('#fromObj', function() {
@ -45,15 +36,6 @@ describe('TXProposal', function() {
should.exist(txp);
txp.outputs.should.deep.equal(aTXP(TXP.Types.MULTIPLEOUTPUTS).outputs);
});
it('should fail to copy a TXP of unknown type', function() {
var txp;
try {
txp = TXP.fromObj(aTxpOpts('bogus'));
} catch(e) {
should.exist(e);
}
should.not.exist(txp);
});
});
describe('#getBitcoreTx', function() {
@ -81,6 +63,36 @@ describe('TXProposal', function() {
});
});
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() {