diff --git a/lib/model/txproposal.js b/lib/model/txproposal.js index b822999..171850e 100644 --- a/lib/model/txproposal.js +++ b/lib/model/txproposal.js @@ -93,6 +93,32 @@ TxProposal.prototype.getRawTx = function() { }; +/** + * getActors + * + * @return {String[]} copayerIds that performed actions in this proposal (accept / reject) + */ +TxProposal.prototype.getActors = function() { + return _.keys(this.actions); +}; + + +/** + * getActionBy + * + * @param {String} copayerId + * @return {Object} type / createdOn + */ +TxProposal.prototype.getActionBy = function(copayerId) { + var a = this.actions[copayerId]; + if (!a) return null; + + return { + type: a.type, + createdOn: a.createdOn, + }; +}; + TxProposal.prototype.addAction = function(copayerId, type, signatures) { var action = new TxProposalAction({ copayerId: copayerId, diff --git a/lib/server.js b/lib/server.js index ef4e0c1..6ac2dd3 100644 --- a/lib/server.js +++ b/lib/server.js @@ -1,5 +1,4 @@ 'use strict'; - var _ = require('lodash'); var $ = require('preconditions').singleton(); var async = require('async'); @@ -482,6 +481,46 @@ CopayServer.prototype.removeWallet = function(opts, cb) { }); }; +/** + * removePendingTx + * + * @param opts + * @param {string} opts.id - The tx id. + * @return {undefined} + */ +CopayServer.prototype.removePendingTx = function(opts, cb) { + var self = this; + + if (!Utils.checkRequired(opts, ['id'])) + return cb(new ClientError('Required argument missing')); + + Utils.runLocked(self.walletId, cb, function(cb) { + + self.storage.fetchTx(self.walletId, opts.id, function(err, txp) { + if (err) return cb(err); + if (!txp) + return cb(new ClientError('Transaction proposal not found')); + + if (!txp.isPending()) + return cb(new ClientError('Transaction proposal not pending')); + + + if (txp.creatorId !== self.copayerId) + return cb(new ClientError('Not allowed to erase this TX')); + + var actors = txp.getActors(); + + if (actors.length > 1) + return cb(new ClientError('Not allowed to erase this TX')); + + if (actors.length == 1 && actors[0] !== self.copayerId) + return cb(new ClientError('Not allowed to erase this TX')); + + self.storage.removeTx(self.walletId, opts.id, cb); + }); + }); +}; + CopayServer.prototype._broadcastTx = function(txp, cb) { var raw = txp.getRawTx(); @@ -510,8 +549,9 @@ CopayServer.prototype.signTx = function(opts, cb) { }, 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 + copayerId: self.copayerId }); if (action) return cb(new ClientError('CVOTED', 'Copayer already voted on this transaction proposal')); diff --git a/lib/storage.js b/lib/storage.js index e637516..fcba017 100644 --- a/lib/storage.js +++ b/lib/storage.js @@ -190,11 +190,10 @@ Storage.prototype.storeTx = function(walletId, txp, cb) { Storage.prototype.removeTx = function(walletId, txProposalId, cb) { var ops = [{ type: 'del', - key: KEY.TXP(walletId, txp.id), + key: KEY.TXP(walletId, txProposalId), }, { type: 'del', - key: KEY.PENDING_TXP(walletId, txp.id), - value: txp, + key: KEY.PENDING_TXP(walletId, txProposalId), }]; this.db.batch(ops, cb); diff --git a/test/integration.js b/test/integration.js index b548f17..1f632a3 100644 --- a/test/integration.js +++ b/test/integration.js @@ -66,7 +66,7 @@ helpers.createAndJoinWallet = function(m, n, cb) { helpers.getAuthServer(copayerIds[0], function(s) { s.getWallet({}, function(err, w) { - cb(s, w, _.take(TestData.copayers, w.n)); + cb(s, w, _.take(TestData.copayers, w.n), copayerIds); }); }); }); @@ -130,11 +130,11 @@ helpers.stubBlockExplorer = function(server, utxos, txid) { -helpers.clientSign = function(tx, xpriv, n) { +helpers.clientSign = function(tx, xprivHex) { //Derive proper key to sign, for each input var privs = [], derived = {}; - var xpriv = new Bitcore.HDPrivateKey(TestData.copayers[0].xPrivKey); + var xpriv = new Bitcore.HDPrivateKey(xprivHex); _.each(tx.inputs, function(i) { if (!derived[i.path]) { @@ -146,7 +146,7 @@ helpers.clientSign = function(tx, xpriv, n) { var t = new Bitcore.Transaction(); _.each(tx.inputs, function(i) { - t.from(i, i.publicKeys, n); + t.from(i, i.publicKeys, tx.requiredSignatures); }); t.to(tx.toAddress, tx.amount) @@ -435,7 +435,7 @@ describe('Copay server', function() { describe('#verifyMessageSignature', function() { var server, wallet; beforeEach(function(done) { - helpers.createAndJoinWallet(2, 2, function(s, w) { + helpers.createAndJoinWallet(2, 3, function(s, w) { server = s; wallet = w; done(); @@ -559,7 +559,7 @@ describe('Copay server', function() { describe('#createTx', function() { var server, wallet, copayerPriv; beforeEach(function(done) { - helpers.createAndJoinWallet(2, 2, function(s, w, c) { + helpers.createAndJoinWallet(2, 3, function(s, w, c) { server = s; wallet = w; copayerPriv = c; @@ -765,14 +765,66 @@ describe('Copay server', function() { }); }); - describe('#signTx', function() { - var server, wallet, copayerPriv, txid; + + describe('#rejectTx', function() { + var server, wallet, copayerPriv, txid, copayerIds; beforeEach(function(done) { - helpers.createAndJoinWallet(2, 2, function(s, w, c) { + helpers.createAndJoinWallet(2, 3, function(s, w, c, ids) { server = s; wallet = w; copayerPriv = c; + copayerIds = ids; + server.createAddress({}, function(err, address) { + helpers.createUtxos(server, wallet, helpers.toSatoshi([1, 2, 3, 4, 5, 6, 7, 8]), function(utxos) { + helpers.stubBlockExplorer(server, utxos); + var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 10, null, copayerPriv[0].privKey); + server.createTx(txOpts, function(err, tx) { + should.not.exist(err); + tx.should.exist; + txid = tx.id; + done(); + }); + }); + }); + }); + }); + + it('should reject a TX', function(done) { + server.getPendingTxs({}, function(err, txs) { + var tx = txs[0]; + tx.id.should.equal(txid); + + server.rejectTx({ + txProposalId: txid, + }, function(err) { + should.not.exist(err); + server.getPendingTxs({}, function(err, txs) { + should.not.exist(err); + var tx = txs[0]; + tx.id.should.equal(txid); + + var actors = tx.getActors(); + actors.length.should.equal(1); + actors[0].should.equal(copayerIds[0]); + tx.getActionBy(copayerIds[0]).type.should.equal('reject'); + + done(); + }); + }); + }); + }); + }); + + describe('#signTx', function() { + var server, wallet, copayerPriv, txid, copayerIds; + + beforeEach(function(done) { + helpers.createAndJoinWallet(2, 3, function(s, w, c, ids) { + server = s; + wallet = w; + copayerPriv = c; + copayerIds = ids; server.createAddress({}, function(err, address) { helpers.createUtxos(server, wallet, helpers.toSatoshi([1, 2, 3, 4, 5, 6, 7, 8]), function(utxos) { helpers.stubBlockExplorer(server, utxos); @@ -793,12 +845,39 @@ describe('Copay server', function() { var tx = txs[0]; tx.id.should.equal(txid); - var signatures = helpers.clientSign(tx, TestData.copayers[0].xPrivKey, wallet.n); + var signatures = helpers.clientSign(tx, TestData.copayers[0].xPrivKey); server.signTx({ txProposalId: txid, signatures: signatures, }, function(err) { should.not.exist(err); + server.getPendingTxs({}, function(err, txs) { + should.not.exist(err); + var tx = txs[0]; + tx.id.should.equal(txid); + + var actors = tx.getActors(); + actors.length.should.equal(1); + actors[0].should.equal(copayerIds[0]); + tx.getActionBy(copayerIds[0]).type.should.equal('accept'); + + done(); + }); + }); + }); + }); + + + it('should fail to sign with a xpriv from other copayer', function(done) { + server.getPendingTxs({}, function(err, txs) { + var tx = txs[0]; + tx.id.should.equal(txid); + var signatures = helpers.clientSign(tx, TestData.copayers[1].xPrivKey); + server.signTx({ + txProposalId: txid, + signatures: signatures, + }, function(err) { + err.code.should.contain('BADSIG'); done(); }); }); @@ -809,7 +888,7 @@ describe('Copay server', function() { var tx = txs[0]; tx.id.should.equal(txid); - var signatures = helpers.clientSign(tx, TestData.copayers[0].xPrivKey, wallet.n); + var signatures = helpers.clientSign(tx, TestData.copayers[0].xPrivKey); signatures[0] = 1; server.signTx({ @@ -821,6 +900,7 @@ describe('Copay server', function() { }); }); }); + it('should fail on invalid signature', function(done) { server.getPendingTxs({}, function(err, txs) { var tx = txs[0]; @@ -836,6 +916,47 @@ describe('Copay server', function() { }); }); }); + + it('should fail when signing a TX previously rejected', function(done) { + server.getPendingTxs({}, function(err, txs) { + var tx = txs[0]; + tx.id.should.equal(txid); + + var signatures = helpers.clientSign(tx, TestData.copayers[0].xPrivKey); + server.signTx({ + txProposalId: txid, + signatures: signatures, + }, function(err) { + server.rejectTx({ + txProposalId: txid, + }, function(err) { + err.code.should.contain('CVOTED'); + done(); + }); + }); + }); + }); + + it('should fail when rejected a previously signed TX', function(done) { + server.getPendingTxs({}, function(err, txs) { + var tx = txs[0]; + tx.id.should.equal(txid); + + server.rejectTx({ + txProposalId: txid, + }, function(err) { + var signatures = helpers.clientSign(tx, TestData.copayers[0].xPrivKey); + server.signTx({ + txProposalId: txid, + signatures: signatures, + }, function(err) { + err.code.should.contain('CVOTED'); + done(); + }); + }); + }); + }); + }); @@ -866,7 +987,7 @@ describe('Copay server', function() { server.getPendingTxs({}, function(err, txps) { var txp = txps[0]; txp.id.should.equal(txpid); - var signatures = helpers.clientSign(txp, TestData.copayers[0].xPrivKey, wallet.n); + var signatures = helpers.clientSign(txp, TestData.copayers[0].xPrivKey); server.signTx({ txProposalId: txpid, signatures: signatures, @@ -892,7 +1013,7 @@ describe('Copay server', function() { server.getPendingTxs({}, function(err, txps) { var txp = txps[0]; txp.id.should.equal(txpid); - var signatures = helpers.clientSign(txp, TestData.copayers[0].xPrivKey, wallet.n); + var signatures = helpers.clientSign(txp, TestData.copayers[0].xPrivKey); server.signTx({ txProposalId: txpid, signatures: signatures, @@ -1130,4 +1251,90 @@ describe('Copay server', function() { }, cat); }); }); + + + describe('#removePendingTx', function() { + var server, wallet, copayerPriv, txp; + beforeEach(function(done) { + helpers.createAndJoinWallet(2, 3, function(s, w, c) { + server = s; + wallet = w; + copayerPriv = c; + server.createAddress({}, function(err, address) { + helpers.createUtxos(server, wallet, helpers.toSatoshi([100, 200]), function(utxos) { + helpers.stubBlockExplorer(server, utxos); + var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 80, 'some message', copayerPriv[0].privKey); + server.createTx(txOpts, function(err, tx) { + server.getPendingTxs({}, function(err, txs) { + txp = txs[0]; + done(); + }); + }); + }); + }); + }); + }); + + it('should allow creator to remove an unsigned TX', function(done) { + server.removePendingTx({ + id: txp.id + }, function(err) { + should.not.exist(err); + server.getPendingTxs({}, function(err, txs) { + txs.length.should.equal(0); + done(); + }); + }); + }); + + it('should allow creator to remove an signed TX by himself', function(done) { + var signatures = helpers.clientSign(txp, TestData.copayers[0].xPrivKey); + server.signTx({ + txProposalId: txp[0], + signatures: signatures, + }, function(err) { + server.removePendingTx({ + id: txp.id + }, function(err) { + should.not.exist(err); + server.getPendingTxs({}, function(err, txs) { + txs.length.should.equal(0); + done(); + }); + }); + }); + }); + + it('should not allow non-creator copayer to remove an unsigned TX ', function(done) { + helpers.getAuthServer(wallet.copayers[1].id, function(server2) { + server2.removePendingTx({ + id: txp.id + }, function(err) { + err.message.should.contain('Not allowed'); + server2.getPendingTxs({}, function(err, txs) { + txs.length.should.equal(1); + done(); + }); + }); + }); + }); + + it('should not allow creator copayer to remove an TX signed by other copayer', function(done) { + helpers.getAuthServer(wallet.copayers[1].id, function(server2) { + var signatures = helpers.clientSign(txp, TestData.copayers[1].xPrivKey); + server2.signTx({ + txProposalId: txp.id, + signatures: signatures, + }, function(err) { + should.not.exist(err); + server.removePendingTx({ + id: txp.id + }, function(err) { + err.message.should.contain('Not allowed'); + done(); + }); + }); + }); + }); + }); });