diff --git a/lib/model/copayer.js b/lib/model/copayer.js index 479306c..7056645 100644 --- a/lib/model/copayer.js +++ b/lib/model/copayer.js @@ -26,9 +26,16 @@ function Copayer(opts) { this.addressManager = new AddressManager({ copayerIndex: opts.copayerIndex }); }; +Copayer.prototype.getPublicKey = function(path) { + return HDPublicKey + .fromString(this.xPubKey) + .derive(path) + .publicKey + .toString(); +}; + Copayer.prototype.getSigningPubKey = function() { - if (!this.xPubKey) return null; - return HDPublicKey.fromString(this.xPubKey).derive(MESSAGE_SIGNING_PATH).publicKey.toString(); + return this.getPublicKey(MESSAGE_SIGNING_PATH); }; Copayer.fromObj = function(obj) { diff --git a/lib/model/txproposal.js b/lib/model/txproposal.js index 46a21fd..fe95ff9 100644 --- a/lib/model/txproposal.js +++ b/lib/model/txproposal.js @@ -24,10 +24,10 @@ function TxProposal(opts) { this.requiredSignatures = opts.requiredSignatures; this.maxRejections = opts.maxRejections; this.status = 'pending'; - this.actions = []; + this.actions = {}; }; -TxProposal.fromObj = function (obj) { +TxProposal.fromObj = function(obj) { var x = new TxProposal(); x.version = obj.version; @@ -44,15 +44,16 @@ TxProposal.fromObj = function (obj) { x.status = obj.status; x.txid = obj.txid; x.inputPaths = obj.inputPaths; - x.actions = _.map(obj.actions, function(action) { - return new TxProposalAction(action); + x.actions = obj.actions; + _.each(x.actions, function(action, copayerId) { + x.actions[copayerId] = new TxProposalAction(action); }); return x; }; -TxProposal.prototype._updateStatus = function () { +TxProposal.prototype._updateStatus = function() { if (this.status != 'pending') return; if (this.isRejected()) { @@ -63,52 +64,88 @@ TxProposal.prototype._updateStatus = function () { }; -TxProposal.prototype._getBitcoreTx = function () { - return new Bitcore.Transaction() - .from(this.inputs) - .to(this.toAddress, this.amount) +TxProposal.prototype._getBitcoreTx = function(n) { + var self = this; + + var t = new Bitcore.Transaction(); + _.each(this.inputs, function(i) { + t.from(i, i.publicKeys, self.requiredSignatures) + }); + + t.to(this.toAddress, this.amount) .change(this.changeAddress); + t._updateChangeOutput(); + return t; }; -TxProposal.prototype.addAction = function (copayerId, type, signature) { +TxProposal.prototype.addAction = function(copayerId, type, signatures) { var action = new TxProposalAction({ copayerId: copayerId, type: type, - signature: signature, + signatures: signatures, }); - this.actions.push(action); + this.actions[copayerId] = action; this._updateStatus(); }; +// TODO: no sure we should receive xpub or a list of pubkeys (pre derived) +TxProposal.prototype.checkSignatures = function(signatures, xpub) { + var self = this; -TxProposal.prototype._checkSignature = function (signatures) { var t = this._getBitcoreTx(); - t.applyS + if (signatures.length != this.inputs.length) + return false; + + var oks = 0, + i = 0, + x = new Bitcore.HDPublicKey(xpub); + + _.each(signatures, function(signatureHex) { + var input = self.inputs[i]; + try { + var signature = Bitcore.crypto.Signature.fromString(signatureHex); + var pub = x.derive(self.inputPaths[i]).publicKey; + var s = { + inputIndex: i, + signature: signature, + sigtype: Bitcore.crypto.Signature.SIGHASH_ALL, + publicKey: pub, + }; + i++; + + t.applySignature(s); + oks++; + } catch (e) { + // TODO only for debug now + console.log('DEBUG ONLY:',e.message); //TODO + }; + }); + return oks === t.inputs.length; }; -TxProposal.prototype.sign = function (copayerId, signatures) { - this._checkSignature(signature); - this.addAction(copayerId, 'accept', signature); + +TxProposal.prototype.sign = function(copayerId, signatures) { + this.addAction(copayerId, 'accept', signatures); }; -TxProposal.prototype.reject = function (copayerId) { +TxProposal.prototype.reject = function(copayerId) { this.addAction(copayerId, 'reject'); }; -TxProposal.prototype.isAccepted = function () { - var votes = _.countBy(this.actions, 'type'); +TxProposal.prototype.isAccepted = function() { + var votes = _.countBy(_.values(this.actions), 'type'); return votes['accept'] >= this.requiredSignatures; }; -TxProposal.prototype.isRejected = function () { - var votes = _.countBy(this.actions, 'type'); +TxProposal.prototype.isRejected = function() { + var votes = _.countBy(_.values(this.actions), 'type'); return votes['reject'] > this.maxRejections; }; -TxProposal.prototype.setBroadcasted = function (txid) { +TxProposal.prototype.setBroadcasted = function(txid) { this.txid = txid; this.status = 'broadcasted'; }; diff --git a/lib/model/txproposalaction.js b/lib/model/txproposalaction.js index 3333a29..8e0f3af 100644 --- a/lib/model/txproposalaction.js +++ b/lib/model/txproposalaction.js @@ -5,8 +5,8 @@ function TxProposalAction(opts) { this.createdOn = Math.floor(Date.now() / 1000); this.copayerId = opts.copayerId; - this.type = opts.type || (opts.signature ? 'accept' : 'reject'); - this.signature = opts.signature; + this.type = opts.type || (opts.signatures ? 'accept' : 'reject'); + this.signatures = opts.signatures; }; TxProposalAction.fromObj = function (obj) { @@ -15,7 +15,7 @@ TxProposalAction.fromObj = function (obj) { x.createdOn = obj.createdOn; x.copayerId = obj.copayerId; x.type = obj.type; - x.signature = obj.signature; + x.signatures = obj.signatures; return x; }; diff --git a/lib/model/wallet.js b/lib/model/wallet.js index 18689c3..c0320f9 100644 --- a/lib/model/wallet.js +++ b/lib/model/wallet.js @@ -100,6 +100,12 @@ Wallet.prototype._getBitcoreNetwork = function() { return this.isTestnet ? Bitcore.Networks.testnet : Bitcore.Networks.livenet; }; + +Wallet.prototype.getPublicKey = function(copayerId, path) { + var copayer = this.getCopayer(copayerId); + return copayer.getPublicKey(path); +}; + Wallet.prototype.createAddress = function(isChange) { var path = this.addressManager.getNewAddressPath(isChange); diff --git a/lib/server.js b/lib/server.js index bfa3f29..a52793b 100644 --- a/lib/server.js +++ b/lib/server.js @@ -472,37 +472,50 @@ CopayServer.prototype.signTx = function(opts, cb) { Utils.checkRequired(opts, ['walletId', 'copayerId', 'txProposalId', 'signatures']); - self.getTx({ - walletId: opts.walletId, - id: opts.txProposalId - }, function(err, txp) { + self.getWallet({ + id: opts.walletId + }, function(err, wallet) { if (err) return cb(err); - if (!txp) return cb(new ClientError('Transaction proposal not found')); - var action = _.find(txp.actions, { - copayerId: opts.copayerId - }); - if (action) return cb(new ClientError('CVOTED', 'Copayer already voted on this transaction proposal')); - if (txp.status != 'pending') return cb(new ClientError('TXNOTPENDING', 'The transaction proposal is not pending')); - txp.sign(opts.copayerId, opts.signatures); - - self.storage.storeTx(opts.walletId, txp, function(err) { + self.getTx({ + walletId: opts.walletId, + id: opts.txProposalId + }, function(err, txp) { if (err) return cb(err); + if (!txp) return cb(new ClientError('Transaction proposal not found')); + var action = _.find(txp.actions, { + copayerId: opts.copayerId + }); + if (action) + return cb(new ClientError('CVOTED', 'Copayer already voted on this transaction proposal')); + if (txp.status != 'pending') + return cb(new ClientError('TXNOTPENDING', 'The transaction proposal is not pending')); - if (txp.status == 'accepted') { - self._broadcastTx(txp.rawTx, function(err, txid) { - if (err) return cb(err); + var copayer = wallet.getCopayer(opts.copayerId); - tx.setBroadcasted(txid); - self.storage.storeTx(opts.walletId, txp, function(err) { + if (!txp.checkSignatures(opts.signatures, copayer.xPubKey)) + return cb(new ClientError('BADSIGNATURES', 'Bad signatures')); + + txp.sign(opts.copayerId, opts.signatures); + + self.storage.storeTx(opts.walletId, txp, function(err) { + if (err) return cb(err); + + if (txp.status == 'accepted') { + self._broadcastTx(txp.rawTx, function(err, txid) { if (err) return cb(err); - return cb(null, txp); + tx.setBroadcasted(txid); + self.storage.storeTx(opts.walletId, txp, function(err) { + if (err) return cb(err); + + return cb(null, txp); + }); }); - }); - } else { - return cb(null, txp); - } + } else { + return cb(null, txp); + } + }); }); }); }; diff --git a/test/integration.js b/test/integration.js index 1662b00..6036fb6 100644 --- a/test/integration.js +++ b/test/integration.js @@ -170,14 +170,11 @@ helpers.clientSign = function(tx, xpriv, n) { .sign(privs); var signatures = []; - //console.log('Bitcore Transaction:', t); //TODO _.each(privs, function(p) { - var s = t.getSignatures(p)[0].signature.toDER().toString('hex'); - // console.log('\n## Priv key:', p); - // console.log('\t\t->> signature ->>', s); //TODO - signatures.push(s); - }); - + var s = t.getSignatures(p)[0].signature.toDER().toString('hex'); + signatures.push(s); + }); + // return signatures; }; @@ -980,28 +977,67 @@ describe('Copay server', function() { }); }); - it('should sign a TX', function(done) { + it('should sign a TX with multiple inputs, different paths', function(done) { server.getPendingTxs({ walletId: '123' }, function(err, txs) { var tx = txs[0]; tx.id.should.equal(txid); - // var signatures = helpers.clientSign(tx, someXPrivKey[0], wallet.n); -console.log('[integration.js.992:signatures:]',signatures); //TODO server.signTx({ walletId: '123', copayerId: '1', txProposalId: txid, signatures: signatures, }, function(err) { + should.not.exist(err); done(); }); - }); - }); + + it('should fail if one signature is broken', function(done) { + server.getPendingTxs({ + walletId: '123' + }, function(err, txs) { + var tx = txs[0]; + tx.id.should.equal(txid); + + var signatures = helpers.clientSign(tx, someXPrivKey[0], wallet.n); + signatures[0]=1; + + server.signTx({ + walletId: '123', + copayerId: '1', + txProposalId: txid, + signatures: signatures, + }, function(err) { + err.message.should.contain('signatures'); + done(); + }); + }); + }); + it('should fail on invalids signature', function(done) { + server.getPendingTxs({ + walletId: '123' + }, function(err, txs) { + var tx = txs[0]; + tx.id.should.equal(txid); + + var signatures = ['11', '22', '33', '44']; + server.signTx({ + walletId: '123', + copayerId: '1', + txProposalId: txid, + signatures: signatures, + }, function(err) { + err.message.should.contain('signatures'); + done(); + }); + }); + }); + }); }); diff --git a/test/txproposal.js b/test/txproposal.js new file mode 100644 index 0000000..c9f8158 --- /dev/null +++ b/test/txproposal.js @@ -0,0 +1,108 @@ +'use strict'; + +var _ = require('lodash'); +var chai = require('chai'); +var sinon = require('sinon'); +var should = chai.should(); +var TXP = require('../lib/model/txproposal'); +var Bitcore = require('bitcore'); + + +describe('TXProposal', function() { + + describe('#fromObj', function() { + it('should create a TXP', function() { + var txp = TXP.fromObj(aTXP()); + should.exist(txp); + }); + }); + describe('#_getBitcoreTx', function() { + it('should create a valid bitcore TX', function() { + var txp = TXP.fromObj(aTXP()); + var t = txp._getBitcoreTx(); + should.exist(t); + }); + }); + + + describe('#sign', function() { + it('should sign 2-2', function() { + var txp = TXP.fromObj(aTXP()); + txp.sign('1', theSignatures); + txp.isAccepted().should.equal(false); + txp.isRejected().should.equal(false); + txp.sign('2', theSignatures); + txp.isAccepted().should.equal(true); + txp.isRejected().should.equal(false); + }); + }); + + describe('#reject', function() { + it('should reject 2-2', function() { + var txp = TXP.fromObj(aTXP()); + txp.reject('1'); + txp.isAccepted().should.equal(false); + txp.isRejected().should.equal(true); + }); + }); + + + describe('#reject & #sign', function() { + it('should finally reject', function() { + var txp = TXP.fromObj(aTXP()); + txp.sign('1', theSignatures); + txp.isAccepted().should.equal(false); + txp.isRejected().should.equal(false); + txp.reject('2'); + txp.isAccepted().should.equal(false); + txp.isRejected().should.equal(true); + }); + }); + + + describe('#checkSignatures', function() { + it('should check signatures', function() { + var txp = TXP.fromObj(aTXP()); + var xpriv = new Bitcore.HDPrivateKey(theXPriv); + var priv = xpriv.derive(txp.inputPaths[0]).privateKey; + + var t = txp._getBitcoreTx(); + t.sign(priv); + + var s = t.getSignatures(priv)[0].signature.toDER().toString('hex'); + var xpub = new Bitcore.HDPublicKey(xpriv); + + var res = txp.checkSignatures([s], xpub); + res.should.equal(true); + }); + }); +}); + +var theXPriv = 'xprv9s21ZrQH143K2rMHbXTJmWTuFx6ssqn1vyRoZqPkCXYchBSkp5ey8kMJe84sxfXq5uChWH4gk94rWbXZt2opN9kg4ufKGvUM7HQSLjnoh7e'; +var theSignatures = ['3045022100896aeb8db75fec22fddb5facf791927a996eb3aee23ee6deaa15471ea46047de02204c0c33f42a9d3ff93d62738712a8c8a5ecd21b45393fdd144e7b01b5a186f1f9']; + +var aTXP = function() { + return { + "version": "1.0.0", + "createdOn": 1423146231, + "id": "75c34f49-1ed6-255f-e9fd-0c71ae75ed1e", + "creatorId": "1", + "toAddress": "18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7", + "amount": 50000000, + "changeAddress": "3CauZ5JUFfmSAx2yANvCRoNXccZ3YSUjXH", + "inputs": [{ + "txid": "6ee699846d2d6605f96d20c7cc8230382e5da43342adb11b499bbe73709f06ab", + "vout": 8, + "satoshis": 100000000, + "scriptPubKey": "a914a8a9648754fbda1b6c208ac9d4e252075447f36887", + "address": "3H4pNP6J4PW4NnvdrTg37VvZ7h2QWuAwtA", + "path": "m/2147483647/0/1", + "publicKeys": ["0319008ffe1b3e208f5ebed8f46495c056763f87b07930a7027a92ee477fb0cb0f", "03b5f035af8be40d0db5abb306b7754949ab39032cf99ad177691753b37d101301"] + }], + "inputPaths": ["m/2147483647/0/1"], + "requiredSignatures": 2, + "maxRejections": 0, + "status": "pending", + "actions": [] + } +};