'use strict'; var _ = require('lodash'); var async = require('async'); var chai = require('chai'); var sinon = require('sinon'); var should = chai.should(); var levelup = require('levelup'); var memdown = require('memdown'); var Bitcore = require('bitcore'); var Utils = require('../../lib/utils'); var WalletUtils = require('../../lib/walletutils'); var Storage = require('../../lib/storage'); var Wallet = require('../../lib/model/wallet'); var Address = require('../../lib/model/address'); var Copayer = require('../../lib/model/copayer'); var CopayServer = require('../../lib/server'); var TestData = require('../testdata'); var helpers = {}; helpers.getAuthServer = function(copayerId, cb) { var signatureStub = sinon.stub(CopayServer.prototype, '_verifySignature'); signatureStub.returns(true); CopayServer.getInstanceWithAuth({ copayerId: copayerId, message: 'dummy', signature: 'dummy', }, function(err, server) { if (err || !server) throw new Error('Could not login as copayerId ' + copayerId); signatureStub.restore(); return cb(server); }); }; helpers.createAndJoinWallet = function(m, n, cb) { var server = new CopayServer(); var copayerIds = []; var offset = helpers.offset || 0; var walletOpts = { name: 'a wallet', m: m, n: n, pubKey: TestData.keyPair.pub, }; server.createWallet(walletOpts, function(err, walletId) { if (err) return cb(err); async.each(_.range(n), function(i, cb) { var copayerOpts = { walletId: walletId, name: 'copayer ' + (i + 1), xPubKey: TestData.copayers[i + offset].xPubKey, xPubKeySignature: TestData.copayers[i + offset].xPubKeySignature, }; server.joinWallet(copayerOpts, function(err, result) { should.not.exist(err); copayerIds.push(result.copayerId); return cb(err); }); }, function(err) { if (err) return new Error('Could not generate wallet'); helpers.getAuthServer(copayerIds[0], function(s) { s.getWallet({}, function(err, w) { cb(s, w); }); }); }); }); }; helpers.randomTXID = function() { return Bitcore.crypto.Hash.sha256(new Buffer(Math.random() * 100000)).toString('hex');; }; helpers.toSatoshi = function(btc) { if (_.isArray(btc)) { return _.map(btc, helpers.toSatoshi); } else { return Utils.strip(btc * 1e8); } }; // Amounts in satoshis helpers.createUtxos = function(server, wallet, amounts, cb) { var addresses = []; async.each(amounts, function(a, next) { server.createAddress({}, function(err, address) { addresses.push(address); next(err); }); }, function(err) { amounts = [].concat(amounts); var i = 0; var utxos = _.map(amounts, function(amount) { return { txid: helpers.randomTXID(), vout: Math.floor((Math.random() * 10) + 1), satoshis: helpers.toSatoshi(amount).toString(), scriptPubKey: addresses[i].getScriptPubKey(wallet.m).toBuffer().toString('hex'), address: addresses[i++].address, }; }); return cb(utxos); }); }; helpers.stubBlockExplorer = function(server, inUtxos, txid) { var utxos = _.map(inUtxos, function(x) { x.toObject = function() { return this; }; return x; }); var bc = sinon.stub(); bc.getUnspentUtxos = sinon.stub().callsArgWith(1, null, utxos); if (txid) { bc.broadcast = sinon.stub().callsArgWith(1, null, txid); } else { bc.broadcast = sinon.stub().callsArgWith(1, 'broadcast error'); } server._getBlockExplorer = sinon.stub().returns(bc); }; helpers.clientSign = function(tx, xprivHex) { //Derive proper key to sign, for each input var privs = [], derived = {}; var xpriv = new Bitcore.HDPrivateKey(xprivHex); _.each(tx.inputs, function(i) { if (!derived[i.path]) { derived[i.path] = xpriv.derive(i.path).privateKey; } privs.push(derived[i.path]); }); var t = new Bitcore.Transaction(); _.each(tx.inputs, function(i) { t.from(i, i.publicKeys, tx.requiredSignatures); }); t.to(tx.toAddress, tx.amount) .change(tx.changeAddress) .sign(privs); var signatures = []; _.each(privs, function(p) { var s = t.getSignatures(p)[0].signature.toDER().toString('hex'); signatures.push(s); }); // return signatures; }; helpers.createProposalOpts = function(toAddress, amount, message, signingKey) { var opts = { toAddress: toAddress, amount: helpers.toSatoshi(amount), message: message, proposalSignature: null, }; var hash = WalletUtils.getProposalHash(opts.toAddress, opts.amount, opts.message); try { opts.proposalSignature = WalletUtils.signMessage(hash, signingKey); } catch (ex) {} return opts; }; var db, storage; describe('Copay server', function() { beforeEach(function() { db = levelup(memdown, { valueEncoding: 'json' }); storage = new Storage({ db: db }); CopayServer.initialize({ storage: storage }); helpers.offset = 0; }); describe('#getInstanceWithAuth', function() { beforeEach(function() {}); it('should get server instance for existing copayer', function(done) { helpers.createAndJoinWallet(1, 2, function(s, wallet) { var xpriv = TestData.copayers[0].xPrivKey; var priv = Bitcore.HDPrivateKey .fromString(xpriv) .derive('m/1/0') .privateKey .toString(); var message = 'hola'; var sig = WalletUtils.signMessage(message, priv); CopayServer.getInstanceWithAuth({ copayerId: wallet.copayers[0].id, message: message, signature: sig, }, function(err, server) { should.not.exist(err); done(); }); }); }); it('should fail when requesting for non-existent copayer', function(done) { CopayServer.getInstanceWithAuth({ copayerId: 'ads', message: TestData.message.text, signature: TestData.message.signature, }, function(err, server) { err.code.should.equal('NOTAUTHORIZED'); err.message.should.contain('Copayer not found'); done(); }); }); it('should fail when message signature cannot be verified', function(done) { helpers.createAndJoinWallet(1, 2, function(s, wallet) { CopayServer.getInstanceWithAuth({ copayerId: wallet.copayers[0].id, message: 'dummy', signature: 'dummy', }, function(err, server) { err.code.should.equal('NOTAUTHORIZED'); err.message.should.contain('Invalid signature'); done(); }); }); }); }); describe('#createWallet', function() { var server; beforeEach(function() { server = new CopayServer(); }); it('should create and store wallet', function(done) { var opts = { name: 'my wallet', m: 2, n: 3, pubKey: TestData.keyPair.pub, }; server.createWallet(opts, function(err, walletId) { should.not.exist(err); server.storage.fetchWallet(walletId, function(err, wallet) { should.not.exist(err); wallet.id.should.equal(walletId); wallet.name.should.equal('my wallet'); done(); }); }); }); it('should fail to create wallet with no name', function(done) { var opts = { name: '', m: 2, n: 3, pubKey: TestData.keyPair.pub, }; server.createWallet(opts, function(err, walletId) { should.not.exist(walletId); err.should.exist; err.message.should.contain('name'); done(); }); }); it('should fail to create wallet with invalid copayer pairs', function(done) { var invalidPairs = [{ m: 0, n: 0 }, { m: 0, n: 2 }, { m: 2, n: 1 }, { m: 0, n: 10 }, { m: 1, n: 20 }, { m: 10, n: 10 }, ]; var opts = { id: '123', name: 'my wallet', pubKey: TestData.keyPair.pub, }; async.each(invalidPairs, function(pair, cb) { opts.m = pair.m; opts.n = pair.n; server.createWallet(opts, function(err) { should.exist(err); err.message.should.equal('Invalid combination of required copayers / total copayers'); return cb(); }); }, function(err) { done(); }); }); }); describe('#joinWallet', function() { var server, walletId; beforeEach(function(done) { server = new CopayServer(); var walletOpts = { name: 'my wallet', m: 2, n: 3, pubKey: TestData.keyPair.pub, }; server.createWallet(walletOpts, function(err, wId) { should.not.exist(err); should.exist.walletId; walletId = wId; done(); }); }); it('should join existing wallet', function(done) { var copayerOpts = { walletId: walletId, name: 'me', xPubKey: TestData.copayers[0].xPubKey, xPubKeySignature: TestData.copayers[0].xPubKeySignature, }; server.joinWallet(copayerOpts, function(err, result) { should.not.exist(err); var copayerId = result.copayerId; helpers.getAuthServer(copayerId, function(server) { server.getWallet({}, function(err, wallet) { wallet.id.should.equal(walletId); wallet.copayers.length.should.equal(1); var copayer = wallet.copayers[0]; copayer.name.should.equal('me'); copayer.id.should.equal(copayerId); done(); }); }); }); }); it('should fail to join with no name', function(done) { var copayerOpts = { walletId: walletId, name: '', xPubKey: TestData.copayers[0].xPubKey, xPubKeySignature: TestData.copayers[0].xPubKeySignature, }; server.joinWallet(copayerOpts, function(err, result) { should.not.exist(result); err.should.exist; err.message.should.contain('name'); done(); }); }); it('should fail to join non-existent wallet', function(done) { var copayerOpts = { walletId: '123', name: 'me', xPubKey: 'dummy', xPubKeySignature: 'dummy', }; server.joinWallet(copayerOpts, function(err) { should.exist(err); done(); }); }); it('should fail to join full wallet', function(done) { helpers.createAndJoinWallet(1, 1, function(s, wallet) { var copayerOpts = { walletId: wallet.id, name: 'me', xPubKey: TestData.copayers[1].xPubKey, xPubKeySignature: TestData.copayers[1].xPubKeySignature, }; server.joinWallet(copayerOpts, function(err) { should.exist(err); err.code.should.equal('WFULL'); err.message.should.equal('Wallet full'); done(); }); }); }); it('should fail to re-join wallet', function(done) { var copayerOpts = { walletId: walletId, name: 'me', xPubKey: TestData.copayers[0].xPubKey, xPubKeySignature: TestData.copayers[0].xPubKeySignature, }; server.joinWallet(copayerOpts, function(err) { should.not.exist(err); server.joinWallet(copayerOpts, function(err) { should.exist(err); err.code.should.equal('CINWALLET'); err.message.should.equal('Copayer already in wallet'); done(); }); }); }); it('should fail to join with bad formated signature', function(done) { var copayerOpts = { walletId: walletId, name: 'me', xPubKey: TestData.copayers[0].xPubKey, xPubKeySignature: 'bad sign', }; server.joinWallet(copayerOpts, function(err) { err.message.should.equal('Bad request'); done(); }); }); it('should fail to join with null signature', function(done) { var copayerOpts = { walletId: walletId, name: 'me', xPubKey: TestData.copayers[0].xPubKey[0], }; server.joinWallet(copayerOpts, function(err) { err.should.exist; err.message.should.contain('argument missing'); done(); }); }); it('should fail to join with wrong signature', function(done) { var copayerOpts = { walletId: walletId, name: 'me', xPubKey: TestData.copayers[0].xPubKey, xPubKeySignature: TestData.copayers[1].xPubKeySignature, }; server.joinWallet(copayerOpts, function(err) { err.message.should.equal('Bad request'); done(); }); }); it('should set pkr and status = complete on last copayer joining (2-3)', function(done) { helpers.createAndJoinWallet(2, 3, function(server) { server.getWallet({}, function(err, wallet) { should.not.exist(err); wallet.status.should.equal('complete'); wallet.publicKeyRing.length.should.equal(3); done(); }); }); }); }); describe('#verifyMessageSignature', function() { var server, wallet; beforeEach(function(done) { helpers.createAndJoinWallet(2, 3, function(s, w) { server = s; wallet = w; done(); }); }); it('should successfully verify message signature', function(done) { var opts = { message: TestData.message.text, signature: TestData.message.signature, }; server.verifyMessageSignature(opts, function(err, isValid) { should.not.exist(err); isValid.should.equal(true); done(); }); }); it('should fail to verify message signature for different copayer', function(done) { var opts = { message: TestData.message.text, signature: TestData.message.signature, }; helpers.getAuthServer(wallet.copayers[1].id, function(server) { server.verifyMessageSignature(opts, function(err, isValid) { should.not.exist(err); isValid.should.be.false; done(); }); }); }); }); describe('#createAddress', function() { var server, wallet; beforeEach(function(done) { helpers.createAndJoinWallet(2, 2, function(s, w) { server = s; wallet = w; done(); }); }); it('should create address', function(done) { server.createAddress({}, function(err, address) { should.not.exist(err); address.should.exist; address.address.should.equal('38Jf1QE7ddXscW76ACgJrNkMWBwDAgMm6M'); address.path.should.equal('m/2147483647/0/0'); done(); }); }); it('should create many addresses on simultaneous requests', function(done) { var N = 5; async.map(_.range(N), function(i, cb) { server.createAddress({}, cb); }, function(err, addresses) { addresses.length.should.equal(N); _.each(_.range(N), function(i) { addresses[i].path.should.equal('m/2147483647/0/' + i); }); // No two identical addresses _.uniq(_.pluck(addresses, 'address')).length.should.equal(N); done(); }); }); it('should not create address if unable to store it', function(done) { sinon.stub(server.storage, 'storeAddressAndWallet').yields('dummy error'); server.createAddress({}, function(err, address) { err.should.exist; should.not.exist(address); server.getAddresses({}, function(err, addresses) { addresses.length.should.equal(0); server.storage.storeAddressAndWallet.restore(); server.createAddress({}, function(err, address) { should.not.exist(err); address.should.exist; address.address.should.equal('38Jf1QE7ddXscW76ACgJrNkMWBwDAgMm6M'); address.path.should.equal('m/2147483647/0/0'); done(); }); }); }); }); }); describe('Wallet not complete tests', function() { it('should fail to create address when wallet is not complete', function(done) { var server = new CopayServer(); var walletOpts = { name: 'my wallet', m: 2, n: 3, pubKey: TestData.keyPair.pub, }; server.createWallet(walletOpts, function(err, walletId) { should.not.exist(err); var copayerOpts = { walletId: walletId, name: 'me', xPubKey: TestData.copayers[0].xPubKey, xPubKeySignature: TestData.copayers[0].xPubKeySignature, }; server.joinWallet(copayerOpts, function(err, result) { should.not.exist(err); helpers.getAuthServer(result.copayerId, function(server) { server.createAddress({}, function(err, address) { should.not.exist(address); err.should.exist; err.message.should.contain('not complete'); done(); }); }); }); }); }); it('should fail to create tx when wallet is not complete', function(done) { var server = new CopayServer(); var walletOpts = { name: 'my wallet', m: 2, n: 3, pubKey: TestData.keyPair.pub, }; server.createWallet(walletOpts, function(err, walletId) { should.not.exist(err); var copayerOpts = { walletId: walletId, name: 'me', xPubKey: TestData.copayers[0].xPubKey, xPubKeySignature: TestData.copayers[0].xPubKeySignature, }; server.joinWallet(copayerOpts, function(err, result) { should.not.exist(err); helpers.getAuthServer(result.copayerId, function(server, wallet) { var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 80, null, TestData.copayers[0].privKey); server.createTx(txOpts, function(err, tx) { should.not.exist(tx); err.should.exist; err.message.should.contain('not complete'); done(); }); }); }); }); }); }); describe('#createTx', function() { var server, wallet; beforeEach(function(done) { helpers.createAndJoinWallet(2, 3, function(s, w) { server = s; wallet = w; server.createAddress({}, function(err, address) { done(); }); }); }); it('should create a tx', function(done) { helpers.createUtxos(server, wallet, [100, 200], function(utxos) { helpers.stubBlockExplorer(server, utxos); var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 80, 'some message', TestData.copayers[0].privKey); server.createTx(txOpts, function(err, tx) { should.not.exist(err); tx.should.exist; tx.message.should.equal('some message'); tx.isAccepted().should.equal.false; tx.isRejected().should.equal.false; server.getPendingTxs({}, function(err, txs) { should.not.exist(err); txs.length.should.equal(1); server.getBalance({}, function(err, balance) { should.not.exist(err); balance.totalAmount.should.equal(helpers.toSatoshi(300)); balance.lockedAmount.should.equal(helpers.toSatoshi(100)); done(); }); }); }); }); }); it('should fail to create tx with invalid proposal signature', function(done) { helpers.createUtxos(server, wallet, [100, 200], function(utxos) { helpers.stubBlockExplorer(server, utxos); var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 80, null, 'dummy'); server.createTx(txOpts, function(err, tx) { should.not.exist(tx); err.should.exist; err.message.should.equal('Invalid proposal signature'); done(); }); }); }); it('should fail to create tx with proposal signed by another copayer', function(done) { helpers.createUtxos(server, wallet, [100, 200], function(utxos) { helpers.stubBlockExplorer(server, utxos); var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 80, null, TestData.copayers[1].privKey); server.createTx(txOpts, function(err, tx) { should.not.exist(tx); err.should.exist; err.message.should.equal('Invalid proposal signature'); done(); }); }); }); it('should fail to create tx for invalid address', function(done) { helpers.createUtxos(server, wallet, [100, 200], function(utxos) { helpers.stubBlockExplorer(server, utxos); var txOpts = helpers.createProposalOpts('invalid address', 80, null, TestData.copayers[0].privKey); server.createTx(txOpts, function(err, tx) { should.not.exist(tx); err.should.exist; err.code.should.equal('INVALIDADDRESS'); err.message.should.equal('Invalid address'); done(); }); }); }); it('should fail to create tx for address of different network', function(done) { helpers.createUtxos(server, wallet, [100, 200], function(utxos) { helpers.stubBlockExplorer(server, utxos); var txOpts = helpers.createProposalOpts('myE38JHdxmQcTJGP1ZiX4BiGhDxMJDvLJD', 80, null, TestData.copayers[0].privKey); server.createTx(txOpts, function(err, tx) { should.not.exist(tx); should.exist(err); err.code.should.equal('INVALIDADDRESS'); err.message.should.equal('Incorrect address network'); done(); }); }); }); it('should fail to create tx when insufficient funds', function(done) { helpers.createUtxos(server, wallet, [100], function(utxos) { helpers.stubBlockExplorer(server, utxos); var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 120, null, TestData.copayers[0].privKey); server.createTx(txOpts, function(err, tx) { should.exist(err); err.code.should.equal('INSUFFICIENTFUNDS'); err.message.should.equal('Insufficient funds'); server.getPendingTxs({}, function(err, txs) { should.not.exist(err); txs.length.should.equal(0); server.getBalance({}, function(err, balance) { should.not.exist(err); balance.lockedAmount.should.equal(0); balance.totalAmount.should.equal(10000000000); done(); }); }); }); }); }); it('should fail to create tx when insufficient funds for fee', function(done) { helpers.createUtxos(server, wallet, [100], function(utxos) { helpers.stubBlockExplorer(server, utxos); var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 100, null, TestData.copayers[0].privKey); server.createTx(txOpts, function(err, tx) { should.exist(err); err.code.should.equal('INSUFFICIENTFUNDS'); err.message.should.equal('Insufficient funds'); done(); }); }); }); it('should fail to create tx for dust amount', function(done) { helpers.createUtxos(server, wallet, [1], function(utxos) { helpers.stubBlockExplorer(server, utxos); var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 0.00000001, null, TestData.copayers[0].privKey); server.createTx(txOpts, function(err, tx) { should.exist(err); err.code.should.equal('DUSTAMOUNT'); err.message.should.equal('Amount below dust threshold'); done(); }); }); }); it('should create tx when there is a pending tx and enough UTXOs', function(done) { helpers.createUtxos(server, wallet, [10.1, 10.2, 10.3], function(utxos) { helpers.stubBlockExplorer(server, utxos); var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 12, null, TestData.copayers[0].privKey); server.createTx(txOpts, function(err, tx) { should.not.exist(err); tx.should.exist; var txOpts2 = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 8, null, TestData.copayers[0].privKey); server.createTx(txOpts2, function(err, tx) { should.not.exist(err); tx.should.exist; server.getPendingTxs({}, function(err, txs) { should.not.exist(err); txs.length.should.equal(2); server.getBalance({}, function(err, balance) { should.not.exist(err); balance.totalAmount.should.equal(3060000000); balance.lockedAmount.should.equal(3060000000); done(); }); }); }); }); }); }); it('should fail to create tx when there is a pending tx and not enough UTXOs', function(done) { helpers.createUtxos(server, wallet, [10.1, 10.2, 10.3], function(utxos) { helpers.stubBlockExplorer(server, utxos); var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 12, null, TestData.copayers[0].privKey); server.createTx(txOpts, function(err, tx) { should.not.exist(err); tx.should.exist; var txOpts2 = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 24, null, TestData.copayers[0].privKey); server.createTx(txOpts2, function(err, tx) { err.code.should.equal('INSUFFICIENTFUNDS'); err.message.should.equal('Insufficient funds'); should.not.exist(tx); server.getPendingTxs({}, function(err, txs) { should.not.exist(err); txs.length.should.equal(1); server.getBalance({}, function(err, balance) { should.not.exist(err); balance.totalAmount.should.equal(helpers.toSatoshi(30.6)); balance.lockedAmount.should.equal(helpers.toSatoshi(20.3)); done(); }); }); }); }); }); }); it('should create tx using different UTXOs for simultaneous requests', function(done) { var N = 5; helpers.createUtxos(server, wallet, _.times(N, function() { return 100; }), function(utxos) { helpers.stubBlockExplorer(server, utxos); server.getBalance({}, function(err, balance) { should.not.exist(err); balance.totalAmount.should.equal(helpers.toSatoshi(N * 100)); balance.lockedAmount.should.equal(helpers.toSatoshi(0)); var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 80, null, TestData.copayers[0].privKey); async.map(_.range(N), function(i, cb) { server.createTx(txOpts, function(err, tx) { cb(err, tx); }); }, function(err) { server.getPendingTxs({}, function(err, txs) { should.not.exist(err); txs.length.should.equal(N); _.uniq(_.pluck(txs, 'changeAddress')).length.should.equal(N); server.getBalance({}, function(err, balance) { should.not.exist(err); balance.totalAmount.should.equal(helpers.toSatoshi(N * 100)); balance.lockedAmount.should.equal(balance.totalAmount); done(); }); }); }); }); }); }); }); describe('#rejectTx', function() { var server, wallet, txid; beforeEach(function(done) { helpers.createAndJoinWallet(2, 3, function(s, w) { server = s; wallet = w; server.createAddress({}, function(err, address) { helpers.createUtxos(server, wallet, _.range(1, 9), function(utxos) { helpers.stubBlockExplorer(server, utxos); var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 10, null, TestData.copayers[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, reason: 'some reason', }, 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(wallet.copayers[0].id); var action = tx.getActionBy(wallet.copayers[0].id); action.type.should.equal('reject'); action.comment.should.equal('some reason'); done(); }); }); }); }); }); describe('#signTx', function() { var server, wallet, txid; beforeEach(function(done) { helpers.createAndJoinWallet(2, 3, function(s, w) { server = s; wallet = w; server.createAddress({}, function(err, address) { helpers.createUtxos(server, wallet, _.range(1, 9), function(utxos) { helpers.stubBlockExplorer(server, utxos); var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 10, null, TestData.copayers[0].privKey); server.createTx(txOpts, function(err, tx) { should.not.exist(err); should.exist(tx); txid = tx.id; done(); }); }); }); }); }); it('should sign a TX with multiple inputs, different paths', 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) { 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(wallet.copayers[0].id); tx.getActionBy(wallet.copayers[0].id).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(); }); }); }); it('should fail if one signature is broken', 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); signatures[0] = 1; server.signTx({ txProposalId: txid, signatures: signatures, }, function(err) { err.message.should.contain('signatures'); done(); }); }); }); it('should fail on invalid signature', function(done) { server.getPendingTxs({}, function(err, txs) { var tx = txs[0]; tx.id.should.equal(txid); var signatures = ['11', '22', '33', '44', '55']; server.signTx({ txProposalId: txid, signatures: signatures, }, function(err) { should.exist(err); err.message.should.contain('Bad signatures'); done(); }); }); }); it('should fail on wrong number of invalid signatures', function(done) { server.getPendingTxs({}, function(err, txs) { var tx = txs[0]; tx.id.should.equal(txid); var signatures = _.take(helpers.clientSign(tx, TestData.copayers[0].xPrivKey), 2); server.signTx({ txProposalId: txid, signatures: signatures, }, function(err) { should.exist(err); err.message.should.contain('Bad signatures'); done(); }); }); }); 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(); }); }); }); }); }); describe('#signTx and broadcast', function() { var server, wallet, utxos; beforeEach(function(done) { helpers.createAndJoinWallet(1, 1, function(s, w) { server = s; wallet = w; server.createAddress({}, function(err, address) { helpers.createUtxos(server, wallet, _.range(1, 9), function(inutxos) { utxos = inutxos; done(); }); }); }); }); it('should sign and broadcast a tx', function(done) { helpers.stubBlockExplorer(server, utxos, '1122334455'); var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 10, null, TestData.copayers[0].privKey); server.createTx(txOpts, function(err, txp) { should.not.exist(err); txp.should.exist; var txpid = txp.id; server.getPendingTxs({}, function(err, txps) { var txp = txps[0]; txp.id.should.equal(txpid); var signatures = helpers.clientSign(txp, TestData.copayers[0].xPrivKey); server.signTx({ txProposalId: txpid, signatures: signatures, }, function(err, txp) { should.not.exist(err); txp.status.should.equal('broadcasted'); txp.txid.should.equal('1122334455'); server.getTx({ id: txp.id }, function(err, txp) { var actions = _.values(txp.actions); actions.length.should.equal(1); actions[0].copayerId.should.equal(wallet.copayers[0].id); actions[0].copayerName.should.equal(wallet.copayers[0].name); done(); }); }); }); }); }); it('should keep tx as *accepted* if unable to broadcast it', function(done) { helpers.stubBlockExplorer(server, utxos); var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 10, null, TestData.copayers[0].privKey); server.createTx(txOpts, function(err, txp) { should.not.exist(err); txp.should.exist; var txpid = txp.id; server.getPendingTxs({}, function(err, txps) { var txp = txps[0]; txp.id.should.equal(txpid); var signatures = helpers.clientSign(txp, TestData.copayers[0].xPrivKey); server.signTx({ txProposalId: txpid, signatures: signatures, }, function(err, txp) { err.should.contain('broadcast'); server.getPendingTxs({}, function(err, txps) { should.not.exist(err); txps.length.should.equal(1); var txp = txps[0]; txp.status.should.equal('accepted'); should.not.exist(txp.txid); done(); }); }); }); }); }); }); describe('Tx proposal workflow', function() { var server, wallet, utxos; beforeEach(function(done) { helpers.createAndJoinWallet(2, 3, function(s, w) { server = s; wallet = w; server.createAddress({}, function(err, address) { helpers.createUtxos(server, wallet, _.range(1, 9), function(inUtxos) { utxos = inUtxos; helpers.stubBlockExplorer(server, utxos, '999'); done(); }); }); }); }); it('other copayers should see pending proposal created by one copayer', function(done) { var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 10, 'some message', TestData.copayers[0].privKey); server.createTx(txOpts, function(err, txp) { should.not.exist(err); should.exist.txp; helpers.getAuthServer(wallet.copayers[1].id, function(server2, wallet) { server2.getPendingTxs({}, function(err, txps) { should.not.exist(err); txps.length.should.equal(1); txps[0].id.should.equal(txp.id); txps[0].message.should.equal('some message'); done(); }); }); }); }); it('tx proposals should not be broadcast until quorum is reached', function(done) { var txpId; async.waterfall([ function(next) { var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 10, 'some message', TestData.copayers[0].privKey); server.createTx(txOpts, function(err, txp) { txpId = txp.id; should.not.exist(err); should.exist.txp; next(); }); }, function(next) { server.getPendingTxs({}, function(err, txps) { should.not.exist(err); txps.length.should.equal(1); var txp = txps[0]; _.keys(txp.actions).should.be.empty; next(null, txp); }); }, function(txp, next) { var signatures = helpers.clientSign(txp, TestData.copayers[0].xPrivKey); server.signTx({ txProposalId: txpId, signatures: signatures, }, function(err) { should.not.exist(err); next(); }); }, function(next) { server.getPendingTxs({}, function(err, txps) { should.not.exist(err); txps.length.should.equal(1); var txp = txps[0]; txp.isPending().should.be.true; txp.isRejected().should.be.false; txp.isAccepted().should.be.false; _.keys(txp.actions).length.should.equal(1); var action = txp.actions[wallet.copayers[0].id]; action.type.should.equal('accept'); next(null, txp); }); }, function(txp, next) { helpers.getAuthServer(wallet.copayers[1].id, function(server, wallet) { helpers.stubBlockExplorer(server, utxos, '999'); var signatures = helpers.clientSign(txp, TestData.copayers[1].xPrivKey); server.signTx({ txProposalId: txpId, signatures: signatures, }, function(err) { should.not.exist(err); next(); }); }); }, function(next) { server.getPendingTxs({}, function(err, txps) { should.not.exist(err); txps.length.should.equal(0); next(); }); }, function(next) { server.getTx({ id: txpId }, function(err, txp) { should.not.exist(err); txp.isPending().should.be.false; txp.isRejected().should.be.false; txp.isAccepted().should.be.true; txp.isBroadcasted().should.be.true; txp.txid.should.equal('999'); _.keys(txp.actions).length.should.equal(2); done(); }); }, ]); }); it('tx proposals should accept as many rejections as possible without finally rejecting', function(done) { var txpId; async.waterfall([ function(next) { var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 10, 'some message', TestData.copayers[0].privKey); server.createTx(txOpts, function(err, txp) { txpId = txp.id; should.not.exist(err); should.exist.txp; next(); }); }, function(next) { server.getPendingTxs({}, function(err, txps) { should.not.exist(err); txps.length.should.equal(1); var txp = txps[0]; _.keys(txp.actions).should.be.empty; next(); }); }, function(next) { server.rejectTx({ txProposalId: txpId, reason: 'just because' }, function(err) { should.not.exist(err); next(); }); }, function(next) { server.getPendingTxs({}, function(err, txps) { should.not.exist(err); txps.length.should.equal(1); var txp = txps[0]; txp.isPending().should.be.true; txp.isRejected().should.be.false; txp.isAccepted().should.be.false; _.keys(txp.actions).length.should.equal(1); var action = txp.actions[wallet.copayers[0].id]; action.type.should.equal('reject'); action.comment.should.equal('just because'); next(); }); }, function(next) { helpers.getAuthServer(wallet.copayers[1].id, function(server, wallet) { helpers.stubBlockExplorer(server, utxos, '999'); server.rejectTx({ txProposalId: txpId, reason: 'some other reason' }, function(err) { should.not.exist(err); next(); }); }); }, function(next) { server.getPendingTxs({}, function(err, txps) { should.not.exist(err); txps.length.should.equal(0); next(); }); }, function(next) { server.getTx({ id: txpId }, function(err, txp) { should.not.exist(err); txp.isPending().should.be.false; txp.isRejected().should.be.true; txp.isAccepted().should.be.false; _.keys(txp.actions).length.should.equal(2); done(); }); }, ]); }); }); describe('#getTxs', function() { var server, wallet, clock; beforeEach(function(done) { if (server) return done(); this.timeout(5000); console.log('\tCreating TXS...'); clock = sinon.useFakeTimers(); helpers.createAndJoinWallet(1, 1, function(s, w) { server = s; wallet = w; server.createAddress({}, function(err, address) { helpers.createUtxos(server, wallet, _.range(10), function(utxos) { helpers.stubBlockExplorer(server, utxos); var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 0.1, null, TestData.copayers[0].privKey); async.eachSeries(_.range(10), function(i, next) { clock.tick(10000); server.createTx(txOpts, function(err, tx) { next(); }); }, function(err) { return done(err); }); }); }); }); }); afterEach(function() { clock.restore(); }); it('should pull 4 txs, down to to time 60', function(done) { server.getTxs({ minTs: 60, limit: 8 }, function(err, txps) { should.not.exist(err); var times = _.pluck(txps, 'createdOn'); times.should.deep.equal([90, 80, 70, 60]); done(); }); }); it('should pull the first 5 txs', function(done) { server.getTxs({ maxTs: 50, limit: 5 }, function(err, txps) { should.not.exist(err); var times = _.pluck(txps, 'createdOn'); times.should.deep.equal([50, 40, 30, 20, 10]); done(); }); }); it('should pull the last 4 txs', function(done) { server.getTxs({ limit: 4 }, function(err, txps) { should.not.exist(err); var times = _.pluck(txps, 'createdOn'); times.should.deep.equal([90, 80, 70, 60]); done(); }); }); it('should pull all txs', function(done) { server.getTxs({}, function(err, txps) { should.not.exist(err); var times = _.pluck(txps, 'createdOn'); times.should.deep.equal([90, 80, 70, 60, 50, 40, 30, 20, 10]); done(); }); }); it('should txs from times 50 to 70', function(done) { server.getTxs({ minTs: 50, maxTs: 70, }, function(err, txps) { should.not.exist(err); var times = _.pluck(txps, 'createdOn'); times.should.deep.equal([70, 60, 50]); done(); }); }); }); describe('Notifications', function() { var server, wallet; beforeEach(function(done) { helpers.createAndJoinWallet(1, 1, function(s, w) { server = s; wallet = w; server.createAddress({}, function(err, address) { helpers.createUtxos(server, wallet, helpers.toSatoshi(_.range(4)), function(utxos) { helpers.stubBlockExplorer(server, utxos); var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 0.01, null, TestData.copayers[0].privKey); async.eachSeries(_.range(3), function(i, next) { server.createTx(txOpts, function(err, tx) { should.not.exist(err); next(); }); }, function(err) { return done(err); }); }); }); }); }); it('should pull the last 5 notifications after 3 TXs', function(done) { server.getNotifications({ limit: 5, reverse: true, }, function(err, notifications) { should.not.exist(err); var types = _.pluck(notifications, 'type'); types.should.deep.equal(['NewTxProposal', 'NewTxProposal', 'NewTxProposal', 'NewAddress', 'NewAddress']); done(); }); }); it('should pull the last 5 notifications, using now', function(done) { server.getNotifications({ limit: 5, reverse: true, maxTs: Date.now() / 1000, minTs: Date.now() / 1000 - 1000, }, function(err, notifications) { should.not.exist(err); var types = _.pluck(notifications, 'type'); types.should.deep.equal(['NewTxProposal', 'NewTxProposal', 'NewTxProposal', 'NewAddress', 'NewAddress']); done(); }); }); it('should pull the first 5 notifications after wallet creation', function(done) { server.getNotifications({ minTs: 0, limit: 5 }, function(err, notifications) { should.not.exist(err); var types = _.pluck(notifications, 'type'); types.should.deep.equal(['NewCopayer', 'NewAddress', 'NewAddress', 'NewAddress', 'NewAddress']); done(); }); }); it('should notify sign and acceptance', function(done) { server.getPendingTxs({}, function(err, txs) { var tx = txs[0]; var signatures = helpers.clientSign(tx, TestData.copayers[0].xPrivKey); server.signTx({ txProposalId: tx.id, signatures: signatures, }, function(err) { server.getNotifications({ limit: 3, reverse: true, }, function(err, notifications) { should.not.exist(err); var types = _.pluck(notifications, 'type'); types.should.deep.equal(['TxProposalFinallyAccepted', 'TxProposalAcceptedBy', 'NewTxProposal']); done(); }); }); }); }); it('should notify rejection', function(done) { server.getPendingTxs({}, function(err, txs) { var tx = txs[1]; server.rejectTx({ txProposalId: tx.id, }, function(err) { should.not.exist(err); server.getNotifications({ limit: 2, reverse: true, }, function(err, notifications) { should.not.exist(err); var types = _.pluck(notifications, 'type'); types.should.deep.equal(['TxProposalFinallyRejected', 'TxProposalRejectedBy']); done(); }); }); }); }); it('should notify sign, acceptance, and broadcast, and emit', function(done) { server.getPendingTxs({}, function(err, txs) { var tx = txs[2]; var signatures = helpers.clientSign(tx, TestData.copayers[0].xPrivKey); helpers.stubBlockExplorer(server, [], '1122334455'); sinon.spy(server, 'emit'); server.signTx({ txProposalId: tx.id, signatures: signatures, }, function(err) { server.getNotifications({ limit: 3, reverse: true, }, function(err, notifications) { should.not.exist(err); var types = _.pluck(notifications, 'type'); types.should.deep.equal(['NewOutgoingTx', 'TxProposalFinallyAccepted', 'TxProposalAcceptedBy']); // Check also events server.emit.getCall(0).args[0].type.should.equal('TxProposalAcceptedBy'); server.emit.getCall(1).args[0].type.should.equal('TxProposalFinallyAccepted');; server.emit.getCall(2).args[0].type.should.equal('NewOutgoingTx'); done(); }); }); }); }); }); describe('#removeWallet', function() { var server, wallet, clock; beforeEach(function(done) { helpers.createAndJoinWallet(1, 1, function(s, w) { server = s; wallet = w; server.createAddress({}, function(err, address) { helpers.createUtxos(server, wallet, _.range(2), function(utxos) { helpers.stubBlockExplorer(server, utxos); var txOpts = { toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', amount: helpers.toSatoshi(0.1), }; async.eachSeries(_.range(2), function(i, next) { server.createTx(txOpts, function(err, tx) { next(); }); }, done); }); }); }); }); it('should delete a wallet', function(done) { var i = 0; var count = function() { return ++i; }; server.storage._dump(function() { i.should.above(1); server.removeWallet({}, function(err) { i = 0; server.storage._dump(function() { server.storage._dump(); i.should.equal(0); done(); }, count); }); }, count); }); // creates 2 wallet, and deletes only 1. it('should delete a wallet, and only that wallet', function(done) { var i = 0; var db = []; var cat = function(data) { db.push(data); }; server.storage._dump(function() { var before = _.clone(db); db.length.should.above(1); helpers.offset = 1; helpers.createAndJoinWallet(2, 3, function(s, w) { server = s; wallet = w; server.createAddress({}, function(err, address) { helpers.createUtxos(server, wallet, _.range(2), function(utxos) { helpers.stubBlockExplorer(server, utxos); var txOpts = { toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', amount: helpers.toSatoshi(0.1), }; async.eachSeries(_.range(2), function(i, next) { server.createTx(txOpts, function(err, tx) { next(); }); }, function() { server.removeWallet({}, function(err) { db = []; server.storage._dump(function() { var after = _.clone(db); after.should.deep.equal(before); done(); }, cat); }); }, cat); }); }); }); }, cat); }); }); describe('#removePendingTx', function() { var server, wallet, txp; beforeEach(function(done) { helpers.createAndJoinWallet(2, 3, function(s, w) { server = s; wallet = w; server.createAddress({}, function(err, address) { helpers.createUtxos(server, wallet, [100, 200], function(utxos) { helpers.stubBlockExplorer(server, utxos); var txOpts = helpers.createProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 80, 'some message', TestData.copayers[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({ txProposalId: 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({ txProposalId: 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({ txProposalId: txp.id }, function(err) { err.message.should.contain('creators'); 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({ txProposalId: txp.id }, function(err) { err.message.should.contain('other copayers'); done(); }); }); }); }); }); });