'use strict'; var _ = require('lodash'); var async = require('async'); var chai = require('chai'); var sinon = require('sinon'); var should = chai.should(); var log = require('npmlog'); log.debug = log.verbose; log.level = 'info'; var Bitcore = require('bitcore-lib'); var Common = require('../../lib/common'); var Utils = Common.Utils; var Constants = Common.Constants; var Defaults = Common.Defaults; var Model = require('../../lib/model'); var WalletService = require('../../lib/server'); var TestData = require('../testdata'); var helpers = require('./helpers'); var storage, blockchainExplorer; describe('Wallet service', function() { before(function(done) { helpers.before(done); }); beforeEach(function(done) { helpers.beforeEach(function(res) { storage = res.storage; blockchainExplorer = res.blockchainExplorer; done(); }); }); after(function(done) { helpers.after(done); }); describe('#getServiceVersion', function() { it('should get version from package', function() { WalletService.getServiceVersion().should.equal('bws-' + require('../../package').version); }); }); describe('#getInstance', function() { it('should get server instance', function() { var server = WalletService.getInstance({ clientVersion: 'bwc-0.0.1', }); server.clientVersion.should.equal('bwc-0.0.1'); }); }); describe('#getInstanceWithAuth', 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 = TestData.copayers[0].privKey_1H_0; var sig = helpers.signMessage('hello world', priv); WalletService.getInstanceWithAuth({ copayerId: wallet.copayers[0].id, message: 'hello world', signature: sig, clientVersion: 'bwc-0.0.1', }, function(err, server) { should.not.exist(err); server.walletId.should.equal(wallet.id); server.copayerId.should.equal(wallet.copayers[0].id); server.clientVersion.should.equal('bwc-0.0.1'); done(); }); }); }); it('should fail when requesting for non-existent copayer', function(done) { var message = 'hello world'; var opts = { copayerId: 'dummy', message: message, signature: helpers.signMessage(message, TestData.copayers[0].privKey_1H_0), }; WalletService.getInstanceWithAuth(opts, function(err, server) { err.code.should.equal('NOT_AUTHORIZED'); 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) { WalletService.getInstanceWithAuth({ copayerId: wallet.copayers[0].id, message: 'dummy', signature: 'dummy', }, function(err, server) { err.code.should.equal('NOT_AUTHORIZED'); err.message.should.contain('Invalid signature'); done(); }); }); }); }); describe('#createWallet', function() { var server; beforeEach(function() { server = new WalletService(); }); 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 create wallet with given id', function(done) { var opts = { name: 'my wallet', m: 2, n: 3, pubKey: TestData.keyPair.pub, id: '1234', }; server.createWallet(opts, function(err, walletId) { should.not.exist(err); server.storage.fetchWallet('1234', function(err, wallet) { should.not.exist(err); wallet.id.should.equal(walletId); wallet.name.should.equal('my wallet'); done(); }); }); }); it('should fail to create wallets with same id', function(done) { var opts = { name: 'my wallet', m: 2, n: 3, pubKey: TestData.keyPair.pub, id: '1234', }; server.createWallet(opts, function(err, walletId) { server.createWallet(opts, function(err, walletId) { err.message.should.contain('Wallet already exists'); 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); should.exist(err); 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(); }); }); it('should fail to create wallet with invalid pubKey argument', function(done) { var opts = { name: 'my wallet', m: 2, n: 3, pubKey: 'dummy', }; server.createWallet(opts, function(err, walletId) { should.not.exist(walletId); should.exist(err); err.message.should.contain('Invalid public key'); done(); }); }); }); describe('#joinWallet', function() { var server, walletId; beforeEach(function(done) { server = new WalletService(); var walletOpts = { name: 'my wallet', m: 1, n: 2, pubKey: TestData.keyPair.pub, }; server.createWallet(walletOpts, function(err, wId) { should.not.exist(err); walletId = wId; should.exist(walletId); done(); }); }); it('should join existing wallet', function(done) { var copayerOpts = helpers.getSignedCopayerOpts({ walletId: walletId, name: 'me', xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, requestPubKey: TestData.copayers[0].pubKey_1H_0, customData: 'dummy custom data', }); 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); copayer.customData.should.equal('dummy custom data'); server.getNotifications({}, function(err, notifications) { should.not.exist(err); var notif = _.find(notifications, { type: 'NewCopayer' }); should.exist(notif); notif.data.walletId.should.equal(walletId); notif.data.copayerId.should.equal(copayerId); notif.data.copayerName.should.equal('me'); notif = _.find(notifications, { type: 'WalletComplete' }); should.not.exist(notif); done(); }); }); }); }); }); it('should fail to join with no name', function(done) { var copayerOpts = helpers.getSignedCopayerOpts({ walletId: walletId, name: '', xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, requestPubKey: TestData.copayers[0].pubKey_1H_0, }); server.joinWallet(copayerOpts, function(err, result) { should.not.exist(result); should.exist(err); err.message.should.contain('name'); done(); }); }); it('should fail to join non-existent wallet', function(done) { var copayerOpts = { walletId: '123', name: 'me', xPubKey: 'dummy', requestPubKey: 'dummy', copayerSignature: '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 = helpers.getSignedCopayerOpts({ walletId: wallet.id, name: 'me', xPubKey: TestData.copayers[1].xPubKey_44H_0H_0H, requestPubKey: TestData.copayers[1].pubKey_1H_0, }); server.joinWallet(copayerOpts, function(err) { should.exist(err); err.code.should.equal('WALLET_FULL'); err.message.should.equal('Wallet full'); done(); }); }); }); it('should return copayer in wallet error before full wallet', function(done) { helpers.createAndJoinWallet(1, 1, function(s, wallet) { var copayerOpts = helpers.getSignedCopayerOpts({ walletId: wallet.id, name: 'me', xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, requestPubKey: TestData.copayers[0].pubKey_1H_0, }); server.joinWallet(copayerOpts, function(err) { should.exist(err); err.code.should.equal('COPAYER_IN_WALLET'); done(); }); }); }); it('should fail to re-join wallet', function(done) { var copayerOpts = helpers.getSignedCopayerOpts({ walletId: walletId, name: 'me', xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, requestPubKey: TestData.copayers[0].pubKey_1H_0, }); server.joinWallet(copayerOpts, function(err) { should.not.exist(err); server.joinWallet(copayerOpts, function(err) { should.exist(err); err.code.should.equal('COPAYER_IN_WALLET'); err.message.should.equal('Copayer already in wallet'); done(); }); }); }); it('should be able to get wallet info without actually joining', function(done) { var copayerOpts = helpers.getSignedCopayerOpts({ walletId: walletId, name: 'me', xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, requestPubKey: TestData.copayers[0].pubKey_1H_0, customData: 'dummy custom data', dryRun: true, }); server.joinWallet(copayerOpts, function(err, result) { should.not.exist(err); should.exist(result); should.not.exist(result.copayerId); result.wallet.id.should.equal(walletId); result.wallet.m.should.equal(1); result.wallet.n.should.equal(2); result.wallet.copayers.should.be.empty; server.storage.fetchWallet(walletId, function(err, wallet) { should.not.exist(err); wallet.id.should.equal(walletId); wallet.copayers.should.be.empty; done(); }); }); }); it('should fail to join two wallets with same xPubKey', function(done) { var copayerOpts = helpers.getSignedCopayerOpts({ walletId: walletId, name: 'me', xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, requestPubKey: TestData.copayers[0].pubKey_1H_0, }); server.joinWallet(copayerOpts, function(err) { should.not.exist(err); var walletOpts = { name: 'my other wallet', m: 1, n: 1, pubKey: TestData.keyPair.pub, }; server.createWallet(walletOpts, function(err, walletId) { should.not.exist(err); copayerOpts = helpers.getSignedCopayerOpts({ walletId: walletId, name: 'me', xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, requestPubKey: TestData.copayers[0].pubKey_1H_0, }); server.joinWallet(copayerOpts, function(err) { should.exist(err); err.code.should.equal('COPAYER_REGISTERED'); err.message.should.equal('Copayer ID already registered on server'); done(); }); }); }); }); it('should fail to join with bad formated signature', function(done) { var copayerOpts = { walletId: walletId, name: 'me', xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, requestPubKey: TestData.copayers[0].pubKey_1H_0, copayerSignature: 'bad sign', }; server.joinWallet(copayerOpts, function(err) { err.message.should.equal('Bad request'); done(); }); }); it('should fail to join with invalid xPubKey', function(done) { var copayerOpts = helpers.getSignedCopayerOpts({ walletId: walletId, name: 'copayer 1', xPubKey: 'invalid', requestPubKey: TestData.copayers[0].pubKey_1H_0, }); server.joinWallet(copayerOpts, function(err, result) { should.not.exist(result); should.exist(err); err.message.should.contain('extended public key'); done(); }); }); it('should fail to join with null signature', function(done) { var copayerOpts = { walletId: walletId, name: 'me', xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, requestPubKey: TestData.copayers[0].pubKey_1H_0, }; server.joinWallet(copayerOpts, function(err) { should.exist(err); err.message.should.contain('argument missing'); done(); }); }); it('should fail to join with wrong signature', function(done) { var copayerOpts = helpers.getSignedCopayerOpts({ walletId: walletId, name: 'me', xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, requestPubKey: TestData.copayers[0].pubKey_1H_0, }); copayerOpts.name = 'me2'; 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); server.getNotifications({}, function(err, notifications) { should.not.exist(err); var notif = _.find(notifications, { type: 'WalletComplete' }); should.exist(notif); notif.data.walletId.should.equal(wallet.id); done(); }); }); }); }); it('should not notify WalletComplete if 1-of-1', function(done) { helpers.createAndJoinWallet(1, 1, function(server) { server.getNotifications({}, function(err, notifications) { should.not.exist(err); var notif = _.find(notifications, { type: 'WalletComplete' }); should.not.exist(notif); done(); }); }); }); }); describe('#joinWallet new/legacy clients', function() { var server; beforeEach(function() { server = new WalletService(); }); it('should fail to join legacy wallet from new client', function(done) { var walletOpts = { name: 'my wallet', m: 1, n: 2, pubKey: TestData.keyPair.pub, supportBIP44AndP2PKH: false, }; server.createWallet(walletOpts, function(err, walletId) { should.not.exist(err); should.exist(walletId); var copayerOpts = helpers.getSignedCopayerOpts({ walletId: walletId, name: 'me', xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H, requestPubKey: TestData.copayers[0].pubKey_1H_0, }); server.joinWallet(copayerOpts, function(err, result) { should.exist(err); err.message.should.contain('The wallet you are trying to join was created with an older version of the client app'); done(); }); }); }); it('should fail to join new wallet from legacy client', function(done) { var walletOpts = { name: 'my wallet', m: 1, n: 2, pubKey: TestData.keyPair.pub, }; server.createWallet(walletOpts, function(err, walletId) { should.not.exist(err); should.exist(walletId); var copayerOpts = helpers.getSignedCopayerOpts({ walletId: walletId, name: 'me', xPubKey: TestData.copayers[0].xPubKey_45H, requestPubKey: TestData.copayers[0].pubKey_1H_0, supportBIP44AndP2PKH: false, }); server.joinWallet(copayerOpts, function(err, result) { should.exist(err); err.code.should.equal('UPGRADE_NEEDED'); done(); }); }); }); }); describe('Address derivation strategy', function() { var server; beforeEach(function() { server = WalletService.getInstance(); }); it('should use BIP44 & P2PKH for 1-of-1 wallet if supported', function(done) { var walletOpts = { name: 'my wallet', m: 1, n: 1, pubKey: TestData.keyPair.pub, }; server.createWallet(walletOpts, function(err, wid) { should.not.exist(err); server.storage.fetchWallet(wid, function(err, wallet) { should.not.exist(err); wallet.derivationStrategy.should.equal('BIP44'); wallet.addressType.should.equal('P2PKH'); done(); }); }); }); it('should use BIP45 & P2SH for 1-of-1 wallet if not supported', function(done) { var walletOpts = { name: 'my wallet', m: 1, n: 1, pubKey: TestData.keyPair.pub, supportBIP44AndP2PKH: false, }; server.createWallet(walletOpts, function(err, wid) { should.not.exist(err); server.storage.fetchWallet(wid, function(err, wallet) { should.not.exist(err); wallet.derivationStrategy.should.equal('BIP45'); wallet.addressType.should.equal('P2SH'); done(); }); }); }); it('should use BIP44 & P2SH for shared wallet if supported', function(done) { var walletOpts = { name: 'my wallet', m: 2, n: 3, pubKey: TestData.keyPair.pub, }; server.createWallet(walletOpts, function(err, wid) { should.not.exist(err); server.storage.fetchWallet(wid, function(err, wallet) { should.not.exist(err); wallet.derivationStrategy.should.equal('BIP44'); wallet.addressType.should.equal('P2SH'); done(); }); }); }); it('should use BIP45 & P2SH for shared wallet if supported', function(done) { var walletOpts = { name: 'my wallet', m: 2, n: 3, pubKey: TestData.keyPair.pub, supportBIP44AndP2PKH: false, }; server.createWallet(walletOpts, function(err, wid) { should.not.exist(err); server.storage.fetchWallet(wid, function(err, wallet) { should.not.exist(err); wallet.derivationStrategy.should.equal('BIP45'); wallet.addressType.should.equal('P2SH'); done(); }); }); }); }); describe('#getStatus', function() { var server, wallet; beforeEach(function(done) { helpers.createAndJoinWallet(1, 2, function(s, w) { server = s; wallet = w; done(); }); }); it('should get status', function(done) { server.getStatus({}, function(err, status) { should.not.exist(err); should.exist(status); should.exist(status.wallet); status.wallet.name.should.equal(wallet.name); should.exist(status.wallet.copayers); status.wallet.copayers.length.should.equal(2); should.exist(status.balance); status.balance.totalAmount.should.equal(0); should.exist(status.preferences); should.exist(status.pendingTxps); status.pendingTxps.should.be.empty; should.not.exist(status.wallet.publicKeyRing); should.not.exist(status.wallet.pubKey); should.not.exist(status.wallet.addressManager); _.each(status.wallet.copayers, function(copayer) { should.not.exist(copayer.xPubKey); should.not.exist(copayer.requestPubKey); should.not.exist(copayer.signature); should.not.exist(copayer.requestPubKey); should.not.exist(copayer.addressManager); should.not.exist(copayer.customData); }); done(); }); }); it('should get status including extended info', function(done) { server.getStatus({ includeExtendedInfo: true }, function(err, status) { should.not.exist(err); should.exist(status); should.exist(status.wallet.publicKeyRing); should.exist(status.wallet.pubKey); should.exist(status.wallet.addressManager); should.exist(status.wallet.copayers[0].xPubKey); should.exist(status.wallet.copayers[0].requestPubKey); should.exist(status.wallet.copayers[0].signature); should.exist(status.wallet.copayers[0].requestPubKey); should.exist(status.wallet.copayers[0].customData); // Do not return other copayer's custom data _.each(_.rest(status.wallet.copayers), function(copayer) { should.not.exist(copayer.customData); }); done(); }); }); it('should get status after tx creation', function(done) { helpers.stubUtxos(server, wallet, [100, 200], function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 80, TestData.copayers[0].privKey_1H_0, { message: 'some message' }); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); should.exist(tx); server.getStatus({}, function(err, status) { should.not.exist(err); status.pendingTxps.length.should.equal(1); var balance = status.balance; balance.totalAmount.should.equal(helpers.toSatoshi(300)); balance.lockedAmount.should.equal(tx.inputs[0].satoshis); balance.availableAmount.should.equal(balance.totalAmount - balance.lockedAmount); 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 message = 'hello world'; var opts = { message: message, signature: helpers.signMessage(message, TestData.copayers[0].privKey_1H_0), }; server.verifyMessageSignature(opts, function(err, isValid) { should.not.exist(err); isValid.should.be.true; done(); }); }); it('should fail to verify message signature for different copayer', function(done) { var message = 'hello world'; var opts = { message: message, signature: helpers.signMessage(message, TestData.copayers[0].privKey_1H_0), }; 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; describe('shared wallets (BIP45)', function() { beforeEach(function(done) { helpers.createAndJoinWallet(2, 2, { supportBIP44AndP2PKH: false }, function(s, w) { server = s; wallet = w; done(); }); }); it('should create address', function(done) { server.createAddress({}, function(err, address) { should.not.exist(err); should.exist(address); address.walletId.should.equal(wallet.id); address.network.should.equal('livenet'); address.address.should.equal('3BVJZ4CYzeTtawDtgwHvWV5jbvnXtYe97i'); address.isChange.should.be.false; address.path.should.equal('m/2147483647/0/0'); address.type.should.equal('P2SH'); server.getNotifications({}, function(err, notifications) { should.not.exist(err); var notif = _.find(notifications, { type: 'NewAddress' }); should.exist(notif); notif.data.address.should.equal(address.address); done(); }); }); }); it('should protect against storing same address multiple times', function(done) { server.createAddress({}, function(err, address) { should.not.exist(err); should.exist(address); delete address._id; server.storage.storeAddressAndWallet(wallet, address, function(err) { should.not.exist(err); server.getMainAddresses({}, function(err, addresses) { should.not.exist(err); addresses.length.should.equal(1); 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(); }); }); }); describe('shared wallets (BIP44)', function() { 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); should.exist(address); address.walletId.should.equal(wallet.id); address.network.should.equal('livenet'); address.address.should.equal('36q2G5FMGvJbPgAVEaiyAsFGmpkhPKwk2r'); address.isChange.should.be.false; address.path.should.equal('m/0/0'); address.type.should.equal('P2SH'); server.getNotifications({}, function(err, notifications) { should.not.exist(err); var notif = _.find(notifications, { type: 'NewAddress' }); should.exist(notif); notif.data.address.should.equal(address.address); 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/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) { should.exist(err); should.not.exist(address); server.getMainAddresses({}, function(err, addresses) { addresses.length.should.equal(0); server.storage.storeAddressAndWallet.restore(); server.createAddress({}, function(err, address) { should.not.exist(err); should.exist(address); done(); }); }); }); }); }); describe('1-of-1 (BIP44 & P2PKH)', function() { beforeEach(function(done) { helpers.createAndJoinWallet(1, 1, function(s, w) { server = s; wallet = w; w.copayers[0].id.should.equal(TestData.copayers[0].id44); done(); }); }); it('should create address', function(done) { server.createAddress({}, function(err, address) { should.not.exist(err); should.exist(address); address.walletId.should.equal(wallet.id); address.network.should.equal('livenet'); address.address.should.equal('1L3z9LPd861FWQhf3vDn89Fnc9dkdBo2CG'); address.isChange.should.be.false; address.path.should.equal('m/0/0'); address.type.should.equal('P2PKH'); server.getNotifications({}, function(err, notifications) { should.not.exist(err); var notif = _.find(notifications, { type: 'NewAddress' }); should.exist(notif); notif.data.address.should.equal(address.address); 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/0/' + i); }); // No two identical addresses _.uniq(_.pluck(addresses, 'address')).length.should.equal(N); done(); }); }); it('should fail to create more consecutive addresses with no activity than allowed', function(done) { var MAX_MAIN_ADDRESS_GAP_old = Defaults.MAX_MAIN_ADDRESS_GAP; Defaults.MAX_MAIN_ADDRESS_GAP = 2; helpers.stubAddressActivity([]); async.map(_.range(2), function(i, next) { server.createAddress({}, next); }, function(err, addresses) { addresses.length.should.equal(2); server.createAddress({}, function(err, address) { should.exist(err); should.not.exist(address); err.code.should.equal('MAIN_ADDRESS_GAP_REACHED'); server.createAddress({ ignoreMaxGap: true }, function(err, address) { should.not.exist(err); should.exist(address); address.path.should.equal('m/0/2'); helpers.stubAddressActivity([ '1GdXraZ1gtoVAvBh49D4hK9xLm6SKgesoE', // m/0/2 ]); server.createAddress({}, function(err, address) { should.not.exist(err); should.exist(address); address.path.should.equal('m/0/3'); Defaults.MAX_MAIN_ADDRESS_GAP = MAX_MAIN_ADDRESS_GAP_old; done(); }); }); }); }); }); it('should cache address activity', function(done) { var MAX_MAIN_ADDRESS_GAP_old = Defaults.MAX_MAIN_ADDRESS_GAP; Defaults.MAX_MAIN_ADDRESS_GAP = 2; helpers.stubAddressActivity([]); async.map(_.range(2), function(i, next) { server.createAddress({}, next); }, function(err, addresses) { addresses.length.should.equal(2); helpers.stubAddressActivity([addresses[1].address]); var getAddressActivitySpy = sinon.spy(blockchainExplorer, 'getAddressActivity'); server.createAddress({}, function(err, address) { should.not.exist(err); server.createAddress({}, function(err, address) { should.not.exist(err); getAddressActivitySpy.callCount.should.equal(1); Defaults.MAX_MAIN_ADDRESS_GAP = MAX_MAIN_ADDRESS_GAP_old; done(); }); }); }); }); }); }); describe('#getMainAddresses', function() { var server, wallet; beforeEach(function(done) { helpers.createAndJoinWallet(2, 2, {}, function(s, w) { server = s; wallet = w; helpers.createAddresses(server, wallet, 5, 0, function() { done(); }); }); }); it('should get all addresses', function(done) { server.getMainAddresses({}, function(err, addresses) { should.not.exist(err); addresses.length.should.equal(5); addresses[0].path.should.equal('m/0/0'); addresses[4].path.should.equal('m/0/4'); done(); }); }); it('should get first N addresses', function(done) { server.getMainAddresses({ limit: 3 }, function(err, addresses) { should.not.exist(err); addresses.length.should.equal(3); addresses[0].path.should.equal('m/0/0'); addresses[2].path.should.equal('m/0/2'); done(); }); }); it('should get last N addresses in reverse order', function(done) { server.getMainAddresses({ limit: 3, reverse: true, }, function(err, addresses) { should.not.exist(err); addresses.length.should.equal(3); addresses[0].path.should.equal('m/0/4'); addresses[2].path.should.equal('m/0/2'); done(); }); }); }); describe('Preferences', function() { var server, wallet; beforeEach(function(done) { helpers.createAndJoinWallet(2, 2, function(s, w) { server = s; wallet = w; done(); }); }); it('should save & retrieve preferences', function(done) { server.savePreferences({ email: 'dummy@dummy.com', language: 'es', unit: 'bit', dummy: 'ignored', }, function(err) { should.not.exist(err); server.getPreferences({}, function(err, preferences) { should.not.exist(err); should.exist(preferences); preferences.email.should.equal('dummy@dummy.com'); preferences.language.should.equal('es'); preferences.unit.should.equal('bit'); should.not.exist(preferences.dummy); done(); }); }); }); it('should save preferences only for requesting copayer', function(done) { server.savePreferences({ email: 'dummy@dummy.com' }, function(err) { should.not.exist(err); helpers.getAuthServer(wallet.copayers[1].id, function(server2) { server2.getPreferences({}, function(err, preferences) { should.not.exist(err); should.not.exist(preferences.email); done(); }); }); }); }); it('should save preferences incrementally', function(done) { async.series([ function(next) { server.savePreferences({ email: 'dummy@dummy.com', }, next); }, function(next) { server.getPreferences({}, function(err, preferences) { should.not.exist(err); should.exist(preferences); preferences.email.should.equal('dummy@dummy.com'); should.not.exist(preferences.language); next(); }); }, function(next) { server.savePreferences({ language: 'es', }, next); }, function(next) { server.getPreferences({}, function(err, preferences) { should.not.exist(err); should.exist(preferences); preferences.language.should.equal('es'); preferences.email.should.equal('dummy@dummy.com'); next(); }); }, function(next) { server.savePreferences({ language: null, unit: 'bit', }, next); }, function(next) { server.getPreferences({}, function(err, preferences) { should.not.exist(err); should.exist(preferences); preferences.unit.should.equal('bit'); should.not.exist(preferences.language); preferences.email.should.equal('dummy@dummy.com'); next(); }); }, ], function(err) { should.not.exist(err); done(); }); }); it.skip('should save preferences only for requesting wallet', function(done) {}); it('should validate entries', function(done) { var invalid = [{ preferences: { email: ' ', }, expected: 'email' }, { preferences: { email: 'dummy@' + _.repeat('domain', 50), }, expected: 'email' }, { preferences: { language: 'xxxxx', }, expected: 'language' }, { preferences: { language: 123, }, expected: 'language' }, { preferences: { unit: 'xxxxx', }, expected: 'unit' }, ]; async.each(invalid, function(item, next) { server.savePreferences(item.preferences, function(err) { should.exist(err); err.message.should.contain(item.expected); next(); }); }, done); }); }); describe('#getUtxos', function() { var server, wallet; beforeEach(function(done) { helpers.createAndJoinWallet(1, 1, function(s, w) { server = s; wallet = w; done(); }); }); it('should get UTXOs for wallet addresses', function(done) { helpers.stubUtxos(server, wallet, [1, 2], function() { server.getUtxos({}, function(err, utxos) { should.not.exist(err); should.exist(utxos); utxos.length.should.equal(2); _.sum(utxos, 'satoshis').should.equal(3 * 1e8); server.getMainAddresses({}, function(err, addresses) { var utxo = utxos[0]; var address = _.find(addresses, { address: utxo.address }); should.exist(address); utxo.path.should.equal(address.path); utxo.publicKeys.should.deep.equal(address.publicKeys); done(); }); }); }); }); it('should get UTXOs for specific addresses', function(done) { helpers.stubUtxos(server, wallet, [1, 2, 3], function(utxos) { _.uniq(utxos, 'address').length.should.be.above(1); var address = utxos[0].address; var amount = _.sum(_.filter(utxos, { address: address }), 'satoshis'); server.getUtxos({ addresses: [address] }, function(err, utxos) { should.not.exist(err); should.exist(utxos); _.sum(utxos, 'satoshis').should.equal(amount); done(); }); }); }); }); describe('Multiple request Pub Keys', function() { var server, wallet; var opts, reqPrivKey, ws; var getAuthServer = function(copayerId, privKey, cb) { var msg = 'dummy'; var sig = helpers.signMessage(msg, privKey); WalletService.getInstanceWithAuth({ copayerId: copayerId, message: msg, signature: sig, clientVersion: helpers.CLIENT_VERSION, }, function(err, server) { return cb(err, server); }); }; beforeEach(function() { reqPrivKey = new Bitcore.PrivateKey(); var requestPubKey = reqPrivKey.toPublicKey(); var xPrivKey = TestData.copayers[0].xPrivKey_44H_0H_0H; var sig = helpers.signRequestPubKey(requestPubKey, xPrivKey); var copayerId = Model.Copayer._xPubToCopayerId(TestData.copayers[0].xPubKey_44H_0H_0H); opts = { copayerId: copayerId, requestPubKey: requestPubKey, signature: sig, }; ws = new WalletService(); }); describe('#addAccess 1-1', function() { beforeEach(function(done) { helpers.createAndJoinWallet(1, 1, function(s, w) { server = s; wallet = w; helpers.stubUtxos(server, wallet, 1, function() { done(); }); }); }); it('should be able to re-gain access from xPrivKey', function(done) { ws.addAccess(opts, function(err, res) { should.not.exist(err); res.wallet.copayers[0].requestPubKeys.length.should.equal(2); res.wallet.copayers[0].requestPubKeys[0].selfSigned.should.equal(true); server.getBalance(res.wallet.walletId, function(err, bal) { should.not.exist(err); bal.totalAmount.should.equal(1e8); getAuthServer(opts.copayerId, reqPrivKey, function(err, server2) { server2.getBalance(res.wallet.walletId, function(err, bal2) { should.not.exist(err); bal2.totalAmount.should.equal(1e8); done(); }); }); }); }); }); it('should fail to gain access with wrong xPrivKey', function(done) { opts.signature = 'xx'; ws.addAccess(opts, function(err, res) { err.code.should.equal('NOT_AUTHORIZED'); done(); }); }); it('should fail to access with wrong privkey after gaining access', function(done) { ws.addAccess(opts, function(err, res) { should.not.exist(err); server.getBalance(res.wallet.walletId, function(err, bal) { should.not.exist(err); var privKey = new Bitcore.PrivateKey(); (getAuthServer(opts.copayerId, privKey, function(err, server2) { err.code.should.equal('NOT_AUTHORIZED'); done(); })); }); }); }); it('should be able to create TXs after regaining access', function(done) { ws.addAccess(opts, function(err, res) { should.not.exist(err); getAuthServer(opts.copayerId, reqPrivKey, function(err, server2) { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 0.8, reqPrivKey); server2.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); done(); }); }); }); }); }); describe('#addAccess 2-2', function() { beforeEach(function(done) { helpers.createAndJoinWallet(2, 2, function(s, w) { server = s; wallet = w; helpers.stubUtxos(server, wallet, 1, function() { done(); }); }); }); it('should be able to re-gain access from xPrivKey', function(done) { ws.addAccess(opts, function(err, res) { should.not.exist(err); server.getBalance(res.wallet.walletId, function(err, bal) { should.not.exist(err); bal.totalAmount.should.equal(1e8); getAuthServer(opts.copayerId, reqPrivKey, function(err, server2) { server2.getBalance(res.wallet.walletId, function(err, bal2) { should.not.exist(err); bal2.totalAmount.should.equal(1e8); done(); }); }); }); }); }); it('TX proposals should include info to be verified', function(done) { ws.addAccess(opts, function(err, res) { should.not.exist(err); getAuthServer(opts.copayerId, reqPrivKey, function(err, server2) { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 0.8, reqPrivKey); server2.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); server2.getPendingTxs({}, function(err, txs) { should.not.exist(err); should.exist(txs[0].proposalSignaturePubKey); should.exist(txs[0].proposalSignaturePubKeySig); done(); }); }); }); }); }); }); }); describe('#getBalance', function() { var server, wallet; beforeEach(function(done) { helpers.createAndJoinWallet(1, 1, function(s, w) { server = s; wallet = w; done(); }); }); it('should get balance', function(done) { helpers.stubUtxos(server, wallet, [1, 'u2', 3], function() { server.getBalance({}, function(err, balance) { should.not.exist(err); should.exist(balance); balance.totalAmount.should.equal(helpers.toSatoshi(6)); balance.lockedAmount.should.equal(0); balance.availableAmount.should.equal(helpers.toSatoshi(6)); balance.totalBytesToSendMax.should.equal(578); balance.totalBytesToSendConfirmedMax.should.equal(418); balance.totalConfirmedAmount.should.equal(helpers.toSatoshi(4)); balance.lockedConfirmedAmount.should.equal(0); balance.availableConfirmedAmount.should.equal(helpers.toSatoshi(4)); should.exist(balance.byAddress); balance.byAddress.length.should.equal(2); balance.byAddress[0].amount.should.equal(helpers.toSatoshi(4)); balance.byAddress[1].amount.should.equal(helpers.toSatoshi(2)); server.getMainAddresses({}, function(err, addresses) { should.not.exist(err); var addresses = _.uniq(_.pluck(addresses, 'address')); _.intersection(addresses, _.pluck(balance.byAddress, 'address')).length.should.equal(2); done(); }); }); }); }); it('should get balance when there are no addresses', function(done) { server.getBalance({}, function(err, balance) { should.not.exist(err); should.exist(balance); balance.totalAmount.should.equal(0); balance.lockedAmount.should.equal(0); balance.availableAmount.should.equal(0); balance.totalBytesToSendMax.should.equal(0); should.exist(balance.byAddress); balance.byAddress.length.should.equal(0); done(); }); }); it('should get balance when there are no funds', function(done) { blockchainExplorer.getUnspentUtxos = sinon.stub().callsArgWith(1, null, []); server.createAddress({}, function(err, address) { should.not.exist(err); server.getBalance({}, function(err, balance) { should.not.exist(err); should.exist(balance); balance.totalAmount.should.equal(0); balance.lockedAmount.should.equal(0); balance.availableAmount.should.equal(0); balance.totalBytesToSendMax.should.equal(0); should.exist(balance.byAddress); balance.byAddress.length.should.equal(0); done(); }); }); }); it('should only include addresses with balance', function(done) { helpers.stubUtxos(server, wallet, 1, function(utxos) { server.createAddress({}, function(err, address) { should.not.exist(err); server.getBalance({}, function(err, balance) { should.not.exist(err); balance.byAddress.length.should.equal(1); balance.byAddress[0].amount.should.equal(helpers.toSatoshi(1)); balance.byAddress[0].address.should.equal(utxos[0].address); done(); }); }); }); }); it('should return correct kb to send max', function(done) { helpers.stubUtxos(server, wallet, _.range(1, 10, 0), function() { server.getBalance({}, function(err, balance) { should.not.exist(err); should.exist(balance); balance.totalAmount.should.equal(helpers.toSatoshi(9)); balance.lockedAmount.should.equal(0); balance.totalBytesToSendMax.should.equal(1535); done(); }); }); }); it('should fail gracefully when blockchain is unreachable', function(done) { blockchainExplorer.getUnspentUtxos = sinon.stub().callsArgWith(1, 'dummy error'); server.createAddress({}, function(err, address) { should.not.exist(err); server.getBalance({}, function(err, balance) { should.exist(err); err.toString().should.equal('dummy error'); done(); }); }); }); }); describe('#getBalance 2 steps', function() { var server, wallet, clock; var _threshold = Defaults.TWO_STEP_BALANCE_THRESHOLD; beforeEach(function(done) { clock = sinon.useFakeTimers(Date.now(), 'Date'); Defaults.TWO_STEP_BALANCE_THRESHOLD = 0; helpers.createAndJoinWallet(1, 1, function(s, w) { server = s; wallet = w; done(); }); }); afterEach(function() { clock.restore(); Defaults.TWO_STEP_BALANCE_THRESHOLD = _threshold; }); it('should get balance', function(done) { helpers.stubUtxos(server, wallet, [1, 'u2', 3], function() { server.getBalance({ twoStep: true }, function(err, balance) { should.not.exist(err); should.exist(balance); balance.totalAmount.should.equal(helpers.toSatoshi(6)); balance.lockedAmount.should.equal(0); balance.availableAmount.should.equal(helpers.toSatoshi(6)); balance.totalBytesToSendMax.should.equal(578); balance.totalConfirmedAmount.should.equal(helpers.toSatoshi(4)); balance.lockedConfirmedAmount.should.equal(0); balance.availableConfirmedAmount.should.equal(helpers.toSatoshi(4)); should.exist(balance.byAddress); balance.byAddress.length.should.equal(2); balance.byAddress[0].amount.should.equal(helpers.toSatoshi(4)); balance.byAddress[1].amount.should.equal(helpers.toSatoshi(2)); setTimeout(done, 100); }); }); }); it('should trigger notification when balance of non-prioritary addresses is updated', function(done) { var oldAddrs, newAddrs; async.series([ function(next) { helpers.createAddresses(server, wallet, 2, 0, function(addrs) { oldAddrs = addrs; next(); }); }, function(next) { clock.tick(7 * 24 * 3600 * 1000); helpers.createAddresses(server, wallet, 2, 0, function(addrs) { newAddrs = addrs; server._getActiveAddresses(function(err, active) { should.not.exist(err); should.not.exist(active); helpers.stubUtxos(server, wallet, [1, 2], { addresses: [oldAddrs[0], newAddrs[0]], }, function() { next(); }); }); }); }, function(next) { server.getBalance({ twoStep: true }, function(err, balance) { should.not.exist(err); should.exist(balance); balance.totalAmount.should.equal(helpers.toSatoshi(3)); next(); }); }, function(next) { setTimeout(next, 100); }, function(next) { server._getActiveAddresses(function(err, active) { should.not.exist(err); should.exist(active); active.length.should.equal(3); next(); }); }, function(next) { helpers.stubUtxos(server, wallet, 0.5, { addresses: oldAddrs[1], keepUtxos: true, }, function() { next(); }); }, function(next) { server.getBalance({ twoStep: true }, function(err, balance) { should.not.exist(err); should.exist(balance); balance.totalAmount.should.equal(helpers.toSatoshi(3)); next(); }); }, function(next) { setTimeout(next, 100); }, function(next) { server.getNotifications({}, function(err, notifications) { should.not.exist(err); var last = _.last(notifications); last.type.should.equal('BalanceUpdated'); var balance = last.data; balance.totalAmount.should.equal(helpers.toSatoshi(3.5)); next(); }); }, ], function(err) { should.not.exist(err); done(); }); }); it('should not trigger notification when only balance of prioritary addresses is updated', function(done) { var oldAddrs, newAddrs; async.series([ function(next) { helpers.createAddresses(server, wallet, 2, 0, function(addrs) { oldAddrs = addrs; next(); }); }, function(next) { clock.tick(7 * 24 * 3600 * 1000); helpers.createAddresses(server, wallet, 2, 0, function(addrs) { newAddrs = addrs; helpers.stubUtxos(server, wallet, [1, 2], { addresses: newAddrs, }, function() { next(); }); }); }, function(next) { server.getBalance({ twoStep: true }, function(err, balance) { should.not.exist(err); should.exist(balance); balance.totalAmount.should.equal(helpers.toSatoshi(3)); next(); }); }, function(next) { setTimeout(next, 100); }, function(next) { helpers.stubUtxos(server, wallet, 0.5, { addresses: newAddrs[0], keepUtxos: true, }, function() { next(); }); }, function(next) { server.getBalance({ twoStep: true }, function(err, balance) { should.not.exist(err); should.exist(balance); balance.totalAmount.should.equal(helpers.toSatoshi(3.5)); next(); }); }, function(next) { setTimeout(next, 100); }, function(next) { server.getNotifications({}, function(err, notifications) { should.not.exist(err); var last = _.last(notifications); last.type.should.not.equal('BalanceUpdated'); next(); }); }, ], function(err) { should.not.exist(err); done(); }); }); it('should resolve balance of new addresses immediately', function(done) { var addresses; async.series([ function(next) { helpers.createAddresses(server, wallet, 4, 0, function(addrs) { addresses = addrs; helpers.stubUtxos(server, wallet, [1, 2], { addresses: _.take(addresses, 2), }, function() { next(); }); }); }, function(next) { server.getBalance({ twoStep: true }, function(err, balance) { should.not.exist(err); should.exist(balance); balance.totalAmount.should.equal(helpers.toSatoshi(3)); next(); }); }, function(next) { server.createAddress({}, function(err, addr) { helpers.stubUtxos(server, wallet, 0.5, { addresses: addr, keepUtxos: true, }, function() { next(); }); }); }, function(next) { server.getBalance({ twoStep: true }, function(err, balance) { should.not.exist(err); should.exist(balance); balance.totalAmount.should.equal(helpers.toSatoshi(3.5)); next(); }); }, function(next) { setTimeout(next, 100); }, function(next) { server.getNotifications({}, function(err, notifications) { should.not.exist(err); var last = _.last(notifications); last.type.should.not.equal('BalanceUpdated'); next(); }); }, ], function(err) { should.not.exist(err); done(); }); }); it('should not perform 2 steps when nb of addresses below threshold', function(done) { var oldAddrs, newAddrs; Defaults.TWO_STEP_BALANCE_THRESHOLD = 5; async.series([ function(next) { helpers.createAddresses(server, wallet, 2, 0, function(addrs) { oldAddrs = addrs; next(); }); }, function(next) { clock.tick(7 * 24 * 3600 * 1000); helpers.createAddresses(server, wallet, 2, 0, function(addrs) { newAddrs = addrs; helpers.stubUtxos(server, wallet, [1, 2], { addresses: [oldAddrs[0], newAddrs[0]], }, function() { next(); }); }); }, function(next) { server.getBalance({ twoStep: true }, function(err, balance) { should.not.exist(err); should.exist(balance); balance.totalAmount.should.equal(helpers.toSatoshi(3)); next(); }); }, function(next) { setTimeout(next, 100); }, function(next) { server.getNotifications({}, function(err, notifications) { should.not.exist(err); var last = _.last(notifications); last.type.should.not.equal('BalanceUpdated'); next(); }); }, ], function(err) { should.not.exist(err); done(); }); }); }); describe('#getFeeLevels', function() { var server, wallet; beforeEach(function(done) { helpers.createAndJoinWallet(1, 1, function(s, w) { server = s; wallet = w; done(); }); }); it('should get current fee levels', function(done) { helpers.stubFeeLevels({ 1: 40000, 2: 20000, 6: 18000, }); server.getFeeLevels({}, function(err, fees) { should.not.exist(err); fees = _.zipObject(_.map(fees, function(item) { return [item.level, item]; })); fees.priority.feePerKb.should.equal(40000); fees.priority.nbBlocks.should.equal(1); fees.normal.feePerKb.should.equal(20000); fees.normal.nbBlocks.should.equal(2); fees.economy.feePerKb.should.equal(18000); fees.economy.nbBlocks.should.equal(6); done(); }); }); it('should get default fees if network cannot be accessed', function(done) { blockchainExplorer.estimateFee = sinon.stub().yields('dummy error'); server.getFeeLevels({}, function(err, fees) { should.not.exist(err); fees = _.zipObject(_.map(fees, function(item) { return [item.level, item.feePerKb]; })); fees.priority.should.equal(50000); fees.normal.should.equal(20000); fees.economy.should.equal(10000); done(); }); }); it('should get default fees if network cannot estimate (returns -1)', function(done) { helpers.stubFeeLevels({ 1: -1, 2: 18000, 6: 0, }); server.getFeeLevels({}, function(err, fees) { should.not.exist(err); fees = _.zipObject(_.map(fees, function(item) { return [item.level, item]; })); fees.priority.feePerKb.should.equal(50000); should.not.exist(fees.priority.nbBlocks); fees.normal.feePerKb.should.equal(18000); fees.normal.nbBlocks.should.equal(2); fees.economy.feePerKb.should.equal(0); fees.economy.nbBlocks.should.equal(6); done(); }); }); }); describe('Wallet not complete tests', function() { it('should fail to create address when wallet is not complete', function(done) { var server = new WalletService(); 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 = helpers.getSignedCopayerOpts({ walletId: walletId, name: 'me', xPubKey: TestData.copayers[0].xPubKey_45H, requestPubKey: TestData.copayers[0].pubKey_1H_0, }); 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); should.exist(err); err.code.should.equal('WALLET_NOT_COMPLETE'); err.message.should.equal('Wallet is not complete'); done(); }); }); }); }); }); it('should fail to create tx when wallet is not complete', function(done) { var server = new WalletService(); 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 = helpers.getSignedCopayerOpts({ walletId: walletId, name: 'me', xPubKey: TestData.copayers[0].xPubKey_45H, requestPubKey: TestData.copayers[0].pubKey_1H_0, }); server.joinWallet(copayerOpts, function(err, result) { should.not.exist(err); helpers.getAuthServer(result.copayerId, function(server, wallet) { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 80, TestData.copayers[0].privKey); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(tx); should.exist(err); err.code.should.equal('WALLET_NOT_COMPLETE'); done(); }); }); }); }); }); }); describe('#createTx', function() { describe('Legacy', function() { var server, wallet; beforeEach(function(done) { helpers.createAndJoinWallet(2, 3, function(s, w) { server = s; wallet = w; done(); }); }); it('should create a tx', function(done) { helpers.stubUtxos(server, wallet, [100, 200], function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 80, TestData.copayers[0].privKey_1H_0, { message: 'some message', customData: 'some custom data' }); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); should.exist(tx); tx.walletId.should.equal(wallet.id); tx.network.should.equal('livenet'); tx.creatorId.should.equal(wallet.copayers[0].id); tx.message.should.equal('some message'); tx.customData.should.equal('some custom data'); tx.isAccepted().should.equal.false; tx.isRejected().should.equal.false; tx.amount.should.equal(helpers.toSatoshi(80)); var estimatedFee = Defaults.DEFAULT_FEE_PER_KB * 400 / 1000; // fully signed tx should have about 400 bytes tx.fee.should.be.within(0.9 * estimatedFee, 1.1 * estimatedFee); server.getPendingTxs({}, function(err, txs) { should.not.exist(err); txs.length.should.equal(1); // creator txs[0].deleteLockTime.should.equal(0); server.getBalance({}, function(err, balance) { should.not.exist(err); balance.totalAmount.should.equal(helpers.toSatoshi(300)); balance.lockedAmount.should.equal(tx.inputs[0].satoshis); balance.lockedAmount.should.be.below(balance.totalAmount); balance.availableAmount.should.equal(balance.totalAmount - balance.lockedAmount); server.storage.fetchAddresses(wallet.id, function(err, addresses) { should.not.exist(err); var change = _.filter(addresses, { isChange: true }); change.length.should.equal(1); done(); }); }); }); }); }); }); it('should generate new change address for each created tx', function(done) { helpers.stubUtxos(server, wallet, [1, 2], function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 0.8, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx1) { should.not.exist(err); should.exist(tx1); var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 0.8, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx2) { should.not.exist(err); should.exist(tx2); tx1.changeAddress.address.should.not.equal(tx2.changeAddress.address); done(); }); }); }); }); it('should create a tx with legacy signature', function(done) { helpers.stubUtxos(server, wallet, [100, 200], function() { var txOpts = helpers.createProposalOptsLegacy('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 80, 'some message', TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); should.exist(tx); done(); }); }); }); it('should support creating a tx with no change address', function(done) { helpers.stubUtxos(server, wallet, [1, 2], function() { var max = 3 - (7200 / 1e8); // Fees for this tx at 100bits/kB = 7200 sat var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', max, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, txp) { should.not.exist(err); should.exist(txp); var t = txp.getBitcoreTx().toObject(); t.outputs.length.should.equal(1); t.outputs[0].satoshis.should.equal(max * 1e8); done(); }); }); }); it('should create a tx using confirmed utxos first', function(done) { helpers.stubUtxos(server, wallet, [1.3, 'u0.5', 'u0.1', 1.2], function(utxos) { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 1.5, TestData.copayers[0].privKey_1H_0, { message: 'some message' }); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); should.exist(tx); tx.inputs.length.should.equal(2); _.difference(_.pluck(tx.inputs, 'txid'), [utxos[0].txid, utxos[3].txid]).length.should.equal(0); done(); }); }); }); it('should use unconfirmed utxos only when no more confirmed utxos are available', function(done) { helpers.stubUtxos(server, wallet, [1.3, 'u0.5', 'u0.1', 1.2], function(utxos) { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 2.55, TestData.copayers[0].privKey_1H_0, { message: 'some message' }); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); should.exist(tx); tx.inputs.length.should.equal(3); var txids = _.pluck(tx.inputs, 'txid'); txids.should.contain(utxos[0].txid); txids.should.contain(utxos[3].txid); done(); }); }); }); it('should exclude unconfirmed utxos if specified', function(done) { helpers.stubUtxos(server, wallet, [1.3, 'u2', 'u0.1', 1.2], function(utxos) { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 3, TestData.copayers[0].privKey_1H_0, { message: 'some message' }); txOpts.excludeUnconfirmedUtxos = true; server.createTxLegacy(txOpts, function(err, tx) { should.exist(err); err.code.should.equal('INSUFFICIENT_FUNDS'); err.message.should.equal('Insufficient funds'); var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 2.5, TestData.copayers[0].privKey_1H_0, { message: 'some message' }); txOpts.excludeUnconfirmedUtxos = true; server.createTxLegacy(txOpts, function(err, tx) { should.exist(err); err.code.should.equal('INSUFFICIENT_FUNDS_FOR_FEE'); err.message.should.equal('Insufficient funds for fee'); done(); }); }); }); }); it('should use non-locked confirmed utxos when specified', function(done) { helpers.stubUtxos(server, wallet, [1.3, 'u2', 'u0.1', 1.2], function(utxos) { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 1.4, TestData.copayers[0].privKey_1H_0, { message: 'some message' }); txOpts.excludeUnconfirmedUtxos = true; server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); should.exist(tx); tx.inputs.length.should.equal(2); server.getBalance({}, function(err, balance) { should.not.exist(err); balance.lockedConfirmedAmount.should.equal(helpers.toSatoshi(2.5)); balance.availableConfirmedAmount.should.equal(0); var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 0.01, TestData.copayers[0].privKey_1H_0, { message: 'some message' }); txOpts.excludeUnconfirmedUtxos = true; server.createTxLegacy(txOpts, function(err, tx) { should.exist(err); err.code.should.equal('LOCKED_FUNDS'); done(); }); }); }); }); }); it('should fail gracefully if unable to reach the blockchain', function(done) { blockchainExplorer.getUnspentUtxos = sinon.stub().callsArgWith(1, 'dummy error'); server.createAddress({}, function(err, address) { should.not.exist(err); var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 80, TestData.copayers[0].privKey_1H_0, { message: 'some message' }); server.createTxLegacy(txOpts, function(err, tx) { should.exist(err); err.toString().should.equal('dummy error'); done(); }); }); }); it('should fail to create tx with invalid proposal signature', function(done) { helpers.stubUtxos(server, wallet, [100, 200], function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 80, 'dummy'); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(tx); should.exist(err); err.message.should.equal('Invalid proposal signature'); done(); }); }); }); it('should fail to create tx with proposal signed by another copayer', function(done) { helpers.stubUtxos(server, wallet, [100, 200], function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 80, TestData.copayers[1].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(tx); should.exist(err); err.message.should.equal('Invalid proposal signature'); done(); }); }); }); it('should fail to create tx for invalid address', function(done) { helpers.stubUtxos(server, wallet, [100, 200], function() { var txOpts = helpers.createSimpleProposalOpts('invalid address', 80, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx) { should.exist(err); should.not.exist(tx); // may fail due to Non-base58 character, or Checksum mismatch, or other done(); }); }); }); it('should fail to create tx for address of different network', function(done) { helpers.stubUtxos(server, wallet, [100, 200], function() { var txOpts = helpers.createSimpleProposalOpts('myE38JHdxmQcTJGP1ZiX4BiGhDxMJDvLJD', 80, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(tx); should.exist(err); err.code.should.equal('INCORRECT_ADDRESS_NETWORK'); err.message.should.equal('Incorrect address network'); done(); }); }); }); it('should fail to create tx for invalid amount', function(done) { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 0, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(tx); should.exist(err); err.message.should.equal('Invalid amount'); done(); }); }); it('should fail to create tx when insufficient funds', function(done) { helpers.stubUtxos(server, wallet, [100], function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 120, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx) { should.exist(err); err.code.should.equal('INSUFFICIENT_FUNDS'); 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.stubUtxos(server, wallet, 0.048222, function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 0.048200, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx) { should.exist(err); err.code.should.equal('INSUFFICIENT_FUNDS_FOR_FEE'); err.message.should.equal('Insufficient funds for fee'); done(); }); }); }); it('should scale fees according to tx size', function(done) { helpers.stubUtxos(server, wallet, [1, 1, 1, 1], function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 3.5, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); var estimatedFee = Defaults.DEFAULT_FEE_PER_KB * 1300 / 1000; // fully signed tx should have about 1300 bytes tx.fee.should.be.within(0.9 * estimatedFee, 1.1 * estimatedFee); done(); }); }); }); it('should be possible to use a smaller fee', function(done) { helpers.stubUtxos(server, wallet, 1, function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 0.99995, TestData.copayers[0].privKey_1H_0, { feePerKb: 80000 }); server.createTxLegacy(txOpts, function(err, tx) { should.exist(err); err.code.should.equal('INSUFFICIENT_FUNDS_FOR_FEE'); var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 0.99995, TestData.copayers[0].privKey_1H_0, { feePerKb: 5000 }); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); var estimatedFee = 5000 * 400 / 1000; // fully signed tx should have about 400 bytes tx.fee.should.be.within(0.9 * estimatedFee, 1.1 * estimatedFee); // Sign it to make sure Bitcore doesn't complain about the fees var signatures = helpers.clientSign(tx, TestData.copayers[0].xPrivKey_44H_0H_0H); server.signTx({ txProposalId: tx.id, signatures: signatures, }, function(err) { should.not.exist(err); done(); }); }); }); }); }); it('should fail to create tx for dust amount', function(done) { helpers.stubUtxos(server, wallet, [1], function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 0.00000001, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx) { should.exist(err); err.code.should.equal('DUST_AMOUNT'); err.message.should.equal('Amount below dust threshold'); done(); }); }); }); it('should fail to create tx that would return change for dust amount', function(done) { helpers.stubUtxos(server, wallet, [1], function() { var fee = 4095 / 1e8; // The exact fee of the resulting tx var change = 100 / 1e8; // Below dust var amount = 1 - fee - change; var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', amount, TestData.copayers[0].privKey_1H_0, { feePerKb: 10000 }); server.createTxLegacy(txOpts, function(err, tx) { should.exist(err); err.code.should.equal('DUST_AMOUNT'); err.message.should.equal('Amount below dust threshold'); done(); }); }); }); it('should fail with different error for insufficient funds and locked funds', function(done) { helpers.stubUtxos(server, wallet, [10, 10], function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 11, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); server.getBalance({}, function(err, balance) { should.not.exist(err); balance.totalAmount.should.equal(helpers.toSatoshi(20)); balance.lockedAmount.should.equal(helpers.toSatoshi(20)); txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 8, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx) { should.exist(err); err.code.should.equal('LOCKED_FUNDS'); err.message.should.equal('Funds are locked by pending transaction proposals'); done(); }); }); }); }); }); it('should create tx with 0 change output', function(done) { helpers.stubUtxos(server, wallet, [1], function() { var fee = 4100 / 1e8; // The exact fee of the resulting tx var amount = 1 - fee; var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', amount, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); should.exist(tx); var bitcoreTx = tx.getBitcoreTx(); bitcoreTx.outputs.length.should.equal(1); bitcoreTx.outputs[0].satoshis.should.equal(tx.amount); done(); }); }); }); it('should fail gracefully when bitcore throws exception on raw tx creation', function(done) { helpers.stubUtxos(server, wallet, [10], function() { var bitcoreStub = sinon.stub(Bitcore, 'Transaction'); bitcoreStub.throws({ name: 'dummy', message: 'dummy exception' }); var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 2, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx) { should.exist(err); err.message.should.equal('dummy exception'); bitcoreStub.restore(); done(); }); }); }); it('should create tx when there is a pending tx and enough UTXOs', function(done) { helpers.stubUtxos(server, wallet, [10.1, 10.2, 10.3], function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 12, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); should.exist(tx); var txOpts2 = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 8, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts2, function(err, tx) { should.not.exist(err); should.exist(tx); 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.stubUtxos(server, wallet, [10.1, 10.2, 10.3], function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 12, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); should.exist(tx); var txOpts2 = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 24, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts2, function(err, tx) { err.code.should.equal('LOCKED_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)); var amountInputs = _.sum(txs[0].inputs, 'satoshis'); balance.lockedAmount.should.equal(amountInputs); balance.lockedAmount.should.be.below(balance.totalAmount); balance.availableAmount.should.equal(balance.totalAmount - balance.lockedAmount); done(); }); }); }); }); }); }); it('should create tx using different UTXOs for simultaneous requests', function(done) { var N = 5; helpers.stubUtxos(server, wallet, _.range(100, 100 + N, 0), function(utxos) { server.getBalance({}, function(err, balance) { should.not.exist(err); balance.totalAmount.should.equal(helpers.toSatoshi(N * 100)); balance.lockedAmount.should.equal(0); var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 80, TestData.copayers[0].privKey_1H_0); async.map(_.range(N), function(i, cb) { server.createTxLegacy(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(); }); }); }); }); }); }); 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.createProposalOpts(Model.TxProposalLegacy.Types.MULTIPLEOUTPUTS, outputs, TestData.copayers[0].privKey_1H_0, { message: 'some message' }); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); should.exist(tx); tx.amount.should.equal(helpers.toSatoshi(150)); done(); }); }); }); it('should support creating a multiple output tx with no change address', function(done) { helpers.stubUtxos(server, wallet, [1, 2], function() { var max = 3 - (7560 / 1e8); // Fees for this tx at 100bits/kB = 7560 sat var outputs = [{ toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', amount: 1, message: 'message #1' }, { toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', amount: max - 1, message: 'message #2' }]; var txOpts = helpers.createProposalOpts(Model.TxProposalLegacy.Types.MULTIPLEOUTPUTS, outputs, TestData.copayers[0].privKey_1H_0, { message: 'some message' }); server.createTxLegacy(txOpts, function(err, txp) { should.not.exist(err); should.exist(txp); var t = txp.getBitcoreTx().toObject(); t.outputs.length.should.equal(2); _.sum(t.outputs, 'satoshis').should.equal(max * 1e8); done(); }); }); }); it('should fail to create tx for type multiple_outputs with missing output argument', function(done) { helpers.stubUtxos(server, wallet, [100, 200], function() { var outputs = [{ amount: 80, message: 'message #1', }, { toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', amount: 90, message: 'message #2' }]; var txOpts = helpers.createProposalOpts(Model.TxProposalLegacy.Types.MULTIPLEOUTPUTS, outputs, TestData.copayers[0].privKey_1H_0, { message: 'some message' }); server.createTxLegacy(txOpts, function(err, tx) { should.exist(err); err.message.should.contain('outputs argument missing'); done(); }); }); }); it('should fail to create tx for unsupported proposal type', function(done) { helpers.stubUtxos(server, wallet, [100, 200], function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 80, TestData.copayers[0].privKey_1H_0, { message: 'some message' }); txOpts.type = 'bogus'; server.createTxLegacy(txOpts, function(err, tx) { should.exist(err); err.message.should.contain('Invalid proposal type'); done(); }); }); }); it('should be able to create tx with inputs argument', function(done) { helpers.stubUtxos(server, wallet, [1, 3, 2], function(utxos) { server.getUtxos({}, function(err, utxos) { should.not.exist(err); var inputs = [utxos[0], utxos[2]]; var txOpts = helpers.createExternalProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 2.5, TestData.copayers[0].privKey_1H_0, inputs); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); should.exist(tx); tx.inputs.length.should.equal(2); var txids = _.pluck(tx.inputs, 'txid'); txids.should.contain(utxos[0].txid); txids.should.contain(utxos[2].txid); 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) { should.not.exist(err); balance.totalAmount.should.equal(helpers.toSatoshi(9)); balance.lockedAmount.should.equal(0); balance.availableAmount.should.equal(helpers.toSatoshi(9)); balance.totalBytesToSendMax.should.equal(2896); balance.totalBytesToSendConfirmedMax.should.equal(2896); var fee = parseInt((balance.totalBytesToSendMax * 10000 / 1000).toFixed(0)); var max = balance.availableAmount - fee; var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', max / 1e8, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); should.exist(tx); tx.amount.should.equal(max); var estimatedFee = 2896 * 10000 / 1000; tx.fee.should.be.within(0.9 * estimatedFee, 1.1 * estimatedFee); server.getBalance({}, function(err, balance) { should.not.exist(err); balance.lockedAmount.should.equal(helpers.toSatoshi(9)); balance.availableAmount.should.equal(0); done(); }); }); }); }); }); it('should be able to send max non-locked amount', function(done) { helpers.stubUtxos(server, wallet, _.range(1, 10, 0), function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 3.5, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); server.getBalance({}, function(err, balance) { should.not.exist(err); balance.totalAmount.should.equal(helpers.toSatoshi(9)); balance.lockedAmount.should.equal(helpers.toSatoshi(4)); balance.availableAmount.should.equal(helpers.toSatoshi(5)); balance.totalBytesToSendMax.should.equal(1653); balance.totalBytesToSendConfirmedMax.should.equal(1653); var fee = parseInt((balance.totalBytesToSendMax * 2000 / 1000).toFixed(0)); var max = balance.availableAmount - fee; var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', max / 1e8, TestData.copayers[0].privKey_1H_0, { feePerKb: 2000 }); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); should.exist(tx); tx.amount.should.equal(max); var estimatedFee = 1653 * 2000 / 1000; tx.fee.should.be.within(0.9 * estimatedFee, 1.1 * estimatedFee); server.getBalance({}, function(err, balance) { should.not.exist(err); balance.lockedAmount.should.equal(helpers.toSatoshi(9)); done(); }); }); }); }); }); }); it('should be able to send max confirmed', function(done) { helpers.stubUtxos(server, wallet, [1, 1, 'u1', 'u1'], function() { server.getBalance({}, function(err, balance) { should.not.exist(err); balance.totalAmount.should.equal(helpers.toSatoshi(4)); balance.totalConfirmedAmount.should.equal(helpers.toSatoshi(2)); balance.lockedAmount.should.equal(0); balance.availableAmount.should.equal(helpers.toSatoshi(4)); balance.availableConfirmedAmount.should.equal(helpers.toSatoshi(2)); balance.totalBytesToSendMax.should.equal(1342); balance.totalBytesToSendConfirmedMax.should.equal(720); var fee = parseInt((balance.totalBytesToSendConfirmedMax * 10000 / 1000).toFixed(0)); var max = balance.availableConfirmedAmount - fee; var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', max / 1e8, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); should.exist(tx); tx.amount.should.equal(max); var estimatedFee = 720 * 10000 / 1000; tx.fee.should.be.within(0.9 * estimatedFee, 1.1 * estimatedFee); server.getBalance({}, function(err, balance) { should.not.exist(err); balance.lockedAmount.should.equal(helpers.toSatoshi(2)); balance.availableConfirmedAmount.should.equal(0); balance.availableAmount.should.equal(helpers.toSatoshi(2)); done(); }); }); }); }); }); it('should not use UTXO provided in utxosToExclude option', function(done) { helpers.stubUtxos(server, wallet, [1, 2, 3], function(utxos) { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 4.5, TestData.copayers[0].privKey_1H_0); txOpts.utxosToExclude = [utxos[1].txid + ':' + utxos[1].vout]; server.createTxLegacy(txOpts, function(err, tx) { should.exist(err); err.code.should.equal('INSUFFICIENT_FUNDS'); err.message.should.equal('Insufficient funds'); done(); }); }); }); it('should use non-excluded UTXOs', function(done) { helpers.stubUtxos(server, wallet, [1, 2], function(utxos) { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 0.5, TestData.copayers[0].privKey_1H_0); txOpts.utxosToExclude = [utxos[0].txid + ':' + utxos[0].vout]; server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); tx.inputs.length.should.equal(1); tx.inputs[0].txid.should.equal(utxos[1].txid); tx.inputs[0].vout.should.equal(utxos[1].vout); done(); }); }); }); }); describe('New', function() { var server, wallet; beforeEach(function(done) { helpers.createAndJoinWallet(2, 3, function(s, w) { server = s; wallet = w; done(); }); }); it('should create a tx', function(done) { helpers.stubUtxos(server, wallet, [1, 2], function() { var txOpts = helpers.createProposalOpts2([{ toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', amount: 0.8 }], { message: 'some message', customData: 'some custom data', }); server.createTx(txOpts, function(err, tx) { should.not.exist(err); should.exist(tx); tx.walletM.should.equal(2); tx.walletN.should.equal(3); tx.requiredRejections.should.equal(2); tx.requiredSignatures.should.equal(2); tx.isAccepted().should.equal.false; tx.isRejected().should.equal.false; tx.isPending().should.equal.true; tx.isTemporary().should.equal.true; tx.amount.should.equal(helpers.toSatoshi(0.8)); server.getPendingTxs({}, function(err, txs) { should.not.exist(err); txs.should.be.empty; done(); }); }); }); }); it('should be able to send a temporary tx proposal', function(done) { helpers.stubUtxos(server, wallet, [1, 2], function() { var txOpts = helpers.createProposalOpts2([{ toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', amount: 0.8 }], { message: 'some message', customData: 'some custom data', }); server.createTx(txOpts, function(err, txp) { should.not.exist(err); should.exist(txp); var publishOpts = helpers.getProposalSignatureOpts(txp, TestData.copayers[0].privKey_1H_0); server.publishTx(publishOpts, function(err) { should.not.exist(err); server.getPendingTxs({}, function(err, txs) { should.not.exist(err); txs.length.should.equal(1); done(); }); }); }); }); }); it('should fail to send non-existent tx proposal', function(done) { server.publishTx({ txProposalId: 'wrong-id', proposalSignature: 'dummy', }, function(err) { should.exist(err); server.getPendingTxs({}, function(err, txs) { should.not.exist(err); txs.should.be.empty; done(); }); }); }); it('should fail to send tx proposal with wrong signature', function(done) { helpers.stubUtxos(server, wallet, [1, 2], function() { var txOpts = helpers.createProposalOpts2([{ toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', amount: 0.8 }], { message: 'some message', }); server.createTx(txOpts, function(err, txp) { should.not.exist(err); should.exist(txp); server.publishTx({ txProposalId: txp.id, proposalSignature: 'dummy' }, function(err) { should.exist(err); err.message.should.contain('Invalid proposal signature'); done(); }); }); }); }); it('should fail to send tx proposal not signed by the creator', function(done) { helpers.stubUtxos(server, wallet, [1, 2], function() { var txOpts = helpers.createProposalOpts2([{ toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', amount: 0.8 }], { message: 'some message', }); server.createTx(txOpts, function(err, txp) { should.not.exist(err); should.exist(txp); var raw = txp.getRawTx(); var proposalSignature = helpers.signMessage(raw, TestData.copayers[0].privKey_1H_0); var pubKey = new Bitcore.PrivateKey(TestData.copayers[0].privKey_1H_0).toPublicKey().toString(); var pubKeySig = helpers.signMessage(pubKey, TestData.copayers[1].privKey_1H_0); var publishOpts = { txProposalId: txp.id, proposalSignature: proposalSignature, proposalSignaturePubKey: pubKey, proposalSignaturePubKeySig: pubKeySig, } server.publishTx(publishOpts, function(err) { should.exist(err); err.message.should.contain('Invalid proposal signing key'); done(); }); }); }); }); it('should accept a tx proposal signed with a custom key', function(done) { var reqPrivKey = new Bitcore.PrivateKey(); var reqPubKey = reqPrivKey.toPublicKey(); var xPrivKey = TestData.copayers[0].xPrivKey_44H_0H_0H; var sig = helpers.signRequestPubKey(reqPubKey, xPrivKey); var opts = { copayerId: TestData.copayers[0].id44, requestPubKey: reqPubKey, signature: sig, }; server.addAccess(opts, function(err) { should.not.exist(err); helpers.stubUtxos(server, wallet, [1, 2], function() { var txOpts = helpers.createProposalOpts2([{ toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', amount: 0.8 }], { message: 'some message', }); server.createTx(txOpts, function(err, txp) { should.not.exist(err); should.exist(txp); var publishOpts = { txProposalId: txp.id, proposalSignature: helpers.signMessage(txp.getRawTx(), reqPrivKey), proposalSignaturePubKey: reqPubKey, proposalSignaturePubKeySig: sig, } server.publishTx(publishOpts, function(err) { should.exist(err); err.message.should.contain('Invalid proposal signing key'); done(); }); }); }); }); }); it('should fail to send a temporary tx proposal if utxos are unavailable', function(done) { var txp1, txp2; var txOpts = helpers.createProposalOpts2([{ toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', amount: 0.8 }], { message: 'some message', }); async.waterfall([ function(next) { helpers.stubUtxos(server, wallet, [1, 2], function() { next(); }); }, function(next) { server.createTx(txOpts, next); }, function(txp, next) { txp1 = txp; server.createTx(txOpts, next); }, function(txp, next) { txp2 = txp; should.exist(txp1); should.exist(txp2); var publishOpts = helpers.getProposalSignatureOpts(txp1, TestData.copayers[0].privKey_1H_0); server.publishTx(publishOpts, next); }, function(next) { var publishOpts = helpers.getProposalSignatureOpts(txp2, TestData.copayers[0].privKey_1H_0); server.publishTx(publishOpts, function(err) { should.exist(err); err.code.should.equal('UNAVAILABLE_UTXOS'); next(); }); }, function(next) { server.getPendingTxs({}, function(err, txs) { should.not.exist(err); txs.length.should.equal(1); next(); }); }, function(next) { // A new tx proposal should use the next available UTXO server.createTx(txOpts, next); }, function(txp3, next) { should.exist(txp3); var publishOpts = helpers.getProposalSignatureOpts(txp3, TestData.copayers[0].privKey_1H_0); server.publishTx(publishOpts, next); }, function(next) { server.getPendingTxs({}, function(err, txs) { should.not.exist(err); txs.length.should.equal(2); next(); }); }, ], function(err) { should.not.exist(err); done(); }); }); it('should fail to list pending proposals from legacy client', function(done) { helpers.stubUtxos(server, wallet, [1, 2], function() { var txOpts = helpers.createProposalOpts2([{ toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', amount: 0.8 }], { message: 'some message', customData: 'some custom data', }); server.createTx(txOpts, function(err, txp) { should.not.exist(err); should.exist(txp); var publishOpts = helpers.getProposalSignatureOpts(txp, TestData.copayers[0].privKey_1H_0); server.publishTx(publishOpts, function(err) { should.not.exist(err); server.getPendingTxs({}, function(err, txs) { should.not.exist(err); txs.length.should.equal(1); server._setClientVersion('bwc-1.1.8'); server.getPendingTxs({}, function(err, txs) { should.exist(err); err.code.should.equal('UPGRADE_NEEDED'); done(); }); }); }); }); }); }); }); }); describe('#createTx backoff time', function(done) { var server, wallet, txid; beforeEach(function(done) { helpers.createAndJoinWallet(2, 2, function(s, w) { server = s; wallet = w; helpers.stubUtxos(server, wallet, _.range(2, 6), function() { done(); }); }); }); it('should follow backoff time after consecutive rejections', function(done) { async.series([ function(next) { async.each(_.range(3), function(i, next) { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 1, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); server.rejectTx({ txProposalId: tx.id, reason: 'some reason', }, next); }); }, next); }, function(next) { // Allow a 4th tx var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 1, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx) { server.rejectTx({ txProposalId: tx.id, reason: 'some reason', }, next); }); }, function(next) { // Do not allow before backoff time var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 1, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx) { should.exist(err); err.code.should.equal('TX_CANNOT_CREATE'); next(); }); }, function(next) { var clock = sinon.useFakeTimers(Date.now() + (Defaults.BACKOFF_TIME + 2) * 60 * 1000, 'Date'); var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 1, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx) { clock.restore(); server.rejectTx({ txProposalId: tx.id, reason: 'some reason', }, next); }); }, function(next) { // Do not allow a 5th tx before backoff time var clock = sinon.useFakeTimers(Date.now() + (Defaults.BACKOFF_TIME + 2) * 60 * 1000 + 1, 'Date'); var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 1, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx) { clock.restore(); should.exist(err); err.code.should.equal('TX_CANNOT_CREATE'); next(); }); }, ], function(err) { should.not.exist(err); done(); }); }); }); describe('#rejectTx', function() { var server, wallet, txid; beforeEach(function(done) { helpers.createAndJoinWallet(2, 2, function(s, w) { server = s; wallet = w; helpers.stubUtxos(server, wallet, _.range(1, 9), function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 10, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); should.exist(tx); 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); txs.should.be.empty; server.getTx({ txProposalId: txid }, function(err, tx) { 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(); }); }); }); }); }); it('should fail to reject non-pending TX', function(done) { async.waterfall([ function(next) { server.getPendingTxs({}, function(err, txs) { var tx = txs[0]; tx.id.should.equal(txid); next(); }); }, function(next) { server.rejectTx({ txProposalId: txid, reason: 'some reason', }, function(err) { should.not.exist(err); next(); }); }, function(next) { server.getPendingTxs({}, function(err, txs) { should.not.exist(err); txs.should.be.empty; next(); }); }, function(next) { helpers.getAuthServer(wallet.copayers[1].id, function(server) { server.rejectTx({ txProposalId: txid, reason: 'some other reason', }, function(err) { should.exist(err); err.code.should.equal('TX_NOT_PENDING'); done(); }); }); }, ]); }); }); describe('#signTx', function() { describe('1-of-1 (BIP44 & P2PKH)', function() { var server, wallet, txid; beforeEach(function(done) { helpers.createAndJoinWallet(1, 1, function(s, w) { server = s; wallet = w; helpers.stubUtxos(server, wallet, [1, 2], function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 2.5, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); should.exist(tx); tx.addressType.should.equal('P2PKH'); txid = tx.id; done(); }); }); }); }); it('should sign a TX with multiple inputs, different paths, and return raw', function(done) { blockchainExplorer.getTransaction = sinon.stub().callsArgWith(1, null, null); server.getPendingTxs({}, function(err, txs) { var tx = txs[0]; tx.id.should.equal(txid); var signatures = helpers.clientSign(tx, TestData.copayers[0].xPrivKey_44H_0H_0H); should.not.exist(tx.raw); server.signTx({ txProposalId: txid, signatures: signatures, }, function(err, txp) { should.not.exist(err); txp.status.should.equal('accepted'); // The raw Tx should contain the Signatures. txp.raw.should.contain(signatures[0]); // Get pending should also contains the raw TX server.getPendingTxs({}, function(err, txs) { var tx = txs[0]; should.not.exist(err); tx.status.should.equal('accepted'); tx.raw.should.contain(signatures[0]); done(); }); }); }); }); }); describe('Multisig', function() { var server, wallet, txid; beforeEach(function(done) { helpers.createAndJoinWallet(2, 3, function(s, w) { server = s; wallet = w; helpers.stubUtxos(server, wallet, _.range(1, 9), function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 20, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(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_44H_0H_0H); server.signTx({ txProposalId: txid, signatures: signatures, }, function(err, txp) { should.not.exist(err); should.not.exist(tx.raw); 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_44H_0H_0H); server.signTx({ txProposalId: txid, signatures: signatures, }, function(err) { err.code.should.equal('BAD_SIGNATURES'); 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_44H_0H_0H); 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_44H_0H_0H), tx.inputs.length - 1); 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_44H_0H_0H); server.signTx({ txProposalId: txid, signatures: signatures, }, function(err) { server.rejectTx({ txProposalId: txid, }, function(err) { err.code.should.contain('COPAYER_VOTED'); 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_44H_0H_0H); server.signTx({ txProposalId: txid, signatures: signatures, }, function(err) { err.code.should.contain('COPAYER_VOTED'); done(); }); }); }); }); it('should fail to sign a non-pending TX', function(done) { async.waterfall([ function(next) { server.rejectTx({ txProposalId: txid, reason: 'some reason', }, function(err) { should.not.exist(err); next(); }); }, function(next) { helpers.getAuthServer(wallet.copayers[1].id, function(server) { server.rejectTx({ txProposalId: txid, reason: 'some reason', }, function(err) { should.not.exist(err); next(); }); }); }, function(next) { server.getPendingTxs({}, function(err, txs) { should.not.exist(err); txs.should.be.empty; next(); }); }, function(next) { helpers.getAuthServer(wallet.copayers[2].id, function(server) { server.getTx({ txProposalId: txid }, function(err, tx) { should.not.exist(err); var signatures = helpers.clientSign(tx, TestData.copayers[2].xPrivKey_44H_0H_0H); server.signTx({ txProposalId: txid, signatures: signatures, }, function(err) { should.exist(err); err.code.should.equal('TX_NOT_PENDING'); done(); }); }); }); }, ]); }); }); }); describe('#broadcastTx & #broadcastRawTx', function() { var server, wallet, txpid, txid; beforeEach(function(done) { helpers.createAndJoinWallet(1, 1, function(s, w) { server = s; wallet = w; helpers.stubUtxos(server, wallet, [10, 10], function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 9, TestData.copayers[0].privKey_1H_0, { message: 'some message' }); server.createTxLegacy(txOpts, function(err, txp) { should.not.exist(err); should.exist(txp); var signatures = helpers.clientSign(txp, TestData.copayers[0].xPrivKey_44H_0H_0H); server.signTx({ txProposalId: txp.id, signatures: signatures, }, function(err, txp) { should.not.exist(err); should.exist(txp); txp.isAccepted().should.be.true; txp.isBroadcasted().should.be.false; txid = txp.txid; txpid = txp.id; done(); }); }); }); }); }); it('should broadcast a tx', function(done) { var clock = sinon.useFakeTimers(1234000, 'Date'); helpers.stubBroadcast(); server.broadcastTx({ txProposalId: txpid }, function(err) { should.not.exist(err); server.getTx({ txProposalId: txpid }, function(err, txp) { should.not.exist(err); should.not.exist(txp.raw); txp.txid.should.equal(txid); txp.isBroadcasted().should.be.true; txp.broadcastedOn.should.equal(1234); clock.restore(); done(); }); }); }); it('should broadcast a raw tx', function(done) { helpers.stubBroadcast(); server.broadcastRawTx({ network: 'testnet', rawTx: 'raw tx', }, function(err, txid) { should.not.exist(err); should.exist(txid); done(); }); }); it('should fail to brodcast a tx already marked as broadcasted', function(done) { helpers.stubBroadcast(); server.broadcastTx({ txProposalId: txpid }, function(err) { should.not.exist(err); server.broadcastTx({ txProposalId: txpid }, function(err) { should.exist(err); err.code.should.equal('TX_ALREADY_BROADCASTED'); done(); }); }); }); it('should auto process already broadcasted txs', function(done) { helpers.stubBroadcast(); server.getPendingTxs({}, function(err, txs) { should.not.exist(err); txs.length.should.equal(1); blockchainExplorer.getTransaction = sinon.stub().callsArgWith(1, null, { txid: 999 }); server.getPendingTxs({}, function(err, txs) { should.not.exist(err); txs.length.should.equal(0); done(); }); }); }); it('should process only broadcasted txs', function(done) { helpers.stubBroadcast(); var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 9, TestData.copayers[0].privKey_1H_0, { message: 'some message 2' }); server.createTxLegacy(txOpts, function(err, txp) { should.not.exist(err); server.getPendingTxs({}, function(err, txs) { should.not.exist(err); txs.length.should.equal(2); blockchainExplorer.getTransaction = sinon.stub().callsArgWith(1, null, { txid: 999 }); server.getPendingTxs({}, function(err, txs) { should.not.exist(err); txs.length.should.equal(1); txs[0].status.should.equal('pending'); should.not.exist(txs[0].txid); done(); }); }); }); }); it('should fail to brodcast a not yet accepted tx', function(done) { helpers.stubBroadcast(); var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 9, TestData.copayers[0].privKey_1H_0, { message: 'some message' }); server.createTxLegacy(txOpts, function(err, txp) { should.not.exist(err); should.exist(txp); server.broadcastTx({ txProposalId: txp.id }, function(err) { should.exist(err); err.code.should.equal('TX_NOT_ACCEPTED'); done(); }); }); }); it('should keep tx as accepted if unable to broadcast it', function(done) { blockchainExplorer.broadcast = sinon.stub().callsArgWith(1, 'broadcast error'); blockchainExplorer.getTransaction = sinon.stub().callsArgWith(1, null, null); server.broadcastTx({ txProposalId: txpid }, function(err) { should.exist(err); err.toString().should.equal('broadcast error'); server.getTx({ txProposalId: txpid }, function(err, txp) { should.not.exist(err); should.exist(txp.txid); txp.isBroadcasted().should.be.false; should.not.exist(txp.broadcastedOn); txp.isAccepted().should.be.true; done(); }); }); }); it('should mark tx as broadcasted if accepted but already in blockchain', function(done) { blockchainExplorer.broadcast = sinon.stub().callsArgWith(1, 'broadcast error'); blockchainExplorer.getTransaction = sinon.stub().callsArgWith(1, null, { txid: '999' }); server.broadcastTx({ txProposalId: txpid }, function(err) { should.not.exist(err); server.getTx({ txProposalId: txpid }, function(err, txp) { should.not.exist(err); should.exist(txp.txid); txp.isBroadcasted().should.be.true; should.exist(txp.broadcastedOn); done(); }); }); }); it('should keep tx as accepted if broadcast fails and cannot check tx in blockchain', function(done) { blockchainExplorer.broadcast = sinon.stub().callsArgWith(1, 'broadcast error'); blockchainExplorer.getTransaction = sinon.stub().callsArgWith(1, 'bc check error'); server.broadcastTx({ txProposalId: txpid }, function(err) { should.exist(err); err.toString().should.equal('bc check error'); server.getTx({ txProposalId: txpid }, function(err, txp) { should.not.exist(err); should.exist(txp.txid); txp.isBroadcasted().should.be.false; should.not.exist(txp.broadcastedOn); txp.isAccepted().should.be.true; done(); }); }); }); }); describe('Tx proposal workflow', function() { var server, wallet; beforeEach(function(done) { helpers.createAndJoinWallet(2, 3, function(s, w) { server = s; wallet = w; helpers.stubUtxos(server, wallet, _.range(1, 9), function() { helpers.stubBroadcast(); done(); }); }); }); it('other copayers should see pending proposal created by one copayer', function(done) { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 10, TestData.copayers[0].privKey_1H_0, { message: 'some message' }); server.createTxLegacy(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 finally accepted until quorum is reached', function(done) { var txpId; async.waterfall([ function(next) { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 10, TestData.copayers[0].privKey_1H_0, { message: 'some message' }); server.createTxLegacy(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]; txp.actions.should.be.empty; next(null, txp); }); }, function(txp, next) { var signatures = helpers.clientSign(txp, TestData.copayers[0].xPrivKey_44H_0H_0H); 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.isAccepted().should.be.false; txp.isRejected().should.be.false; txp.isBroadcasted().should.be.false; txp.actions.length.should.equal(1); var action = txp.getActionBy(wallet.copayers[0].id); action.type.should.equal('accept'); server.getNotifications({}, function(err, notifications) { should.not.exist(err); var last = _.last(notifications); last.type.should.not.equal('TxProposalFinallyAccepted'); next(null, txp); }); }); }, function(txp, next) { helpers.getAuthServer(wallet.copayers[1].id, function(server, wallet) { var signatures = helpers.clientSign(txp, TestData.copayers[1].xPrivKey_44H_0H_0H); 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.isAccepted().should.be.true; txp.isBroadcasted().should.be.false; should.exist(txp.txid); txp.actions.length.should.equal(2); server.getNotifications({}, function(err, notifications) { should.not.exist(err); var last = _.last(notifications); last.type.should.equal('TxProposalFinallyAccepted'); last.walletId.should.equal(wallet.id); last.creatorId.should.equal(wallet.copayers[1].id); last.data.txProposalId.should.equal(txp.id); 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.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 10, TestData.copayers[0].privKey_1H_0, { message: 'some message' }); server.createTxLegacy(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]; 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; txp.actions.length.should.equal(1); var action = txp.getActionBy(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) { 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({ txProposalId: txpId }, function(err, txp) { should.not.exist(err); txp.isPending().should.be.false; txp.isRejected().should.be.true; txp.isAccepted().should.be.false; txp.actions.length.should.equal(2); done(); }); }, ]); }); }); describe('#getTx', function() { var server, wallet, txpid; beforeEach(function(done) { helpers.createAndJoinWallet(2, 3, function(s, w) { server = s; wallet = w; helpers.stubUtxos(server, wallet, 10, function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 9, TestData.copayers[0].privKey_1H_0, { message: 'some message' }); server.createTxLegacy(txOpts, function(err, txp) { should.not.exist(err); should.exist(txp); txpid = txp.id; done(); }); }); }); }); it('should get own transaction proposal', function(done) { server.getTx({ txProposalId: txpid }, function(err, txp) { should.not.exist(err); should.exist(txp); txp.id.should.equal(txpid); done(); }); }); it('should get someone elses transaction proposal', function(done) { helpers.getAuthServer(wallet.copayers[1].id, function(server2, wallet) { server2.getTx({ txProposalId: txpid }, function(err, res) { should.not.exist(err); res.id.should.equal(txpid); done(); }); }); }); it('should fail to get non-existent transaction proposal', function(done) { server.getTx({ txProposalId: 'dummy' }, function(err, txp) { should.exist(err); should.not.exist(txp); err.code.should.equal('TX_NOT_FOUND') err.message.should.equal('Transaction proposal not found'); done(); }); }); it.skip('should get accepted/rejected transaction proposal', function(done) {}); it.skip('should get broadcasted transaction proposal', function(done) {}); }); describe('#getTxs', function() { var server, wallet, clock; beforeEach(function(done) { this.timeout(5000); clock = sinon.useFakeTimers('Date'); helpers.createAndJoinWallet(1, 1, function(s, w) { server = s; wallet = w; helpers.stubUtxos(server, wallet, _.range(1, 11), function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 0.1, TestData.copayers[0].privKey_1H_0); async.eachSeries(_.range(10), function(i, next) { clock.tick(10 * 1000); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); next(); }); }, function(err) { clock.restore(); 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([100, 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([100, 90, 80, 70]); 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([100, 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('#getNotifications', function() { var clock; var server, wallet; beforeEach(function(done) { clock = sinon.useFakeTimers(10 * 1000, 'Date'); helpers.createAndJoinWallet(1, 1, function(s, w) { server = s; wallet = w; helpers.stubUtxos(server, wallet, _.range(4), function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 0.01, TestData.copayers[0].privKey_1H_0); async.eachSeries(_.range(3), function(i, next) { clock.tick(25 * 1000); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); next(); }); }, function(err) { clock.tick(20 * 1000); return done(err); }); }); }); }); afterEach(function() { clock.restore(); }); it('should pull all notifications', function(done) { server.getNotifications({}, function(err, notifications) { should.not.exist(err); var types = _.pluck(notifications, 'type'); types.should.deep.equal(['NewCopayer', 'NewAddress', 'NewAddress', 'NewTxProposal', 'NewTxProposal', 'NewTxProposal']); var walletIds = _.uniq(_.pluck(notifications, 'walletId')); walletIds.length.should.equal(1); walletIds[0].should.equal(wallet.id); var creators = _.uniq(_.compact(_.pluck(notifications, 'creatorId'))); creators.length.should.equal(1); creators[0].should.equal(wallet.copayers[0].id); done(); }); }); it('should pull new block notifications along with wallet notifications in the last 60 seconds', function(done) { // Simulate new block notification server.walletId = 'livenet'; server._notify('NewBlock', { hash: 'dummy hash', }, { isGlobal: true }, function(err) { should.not.exist(err); server.walletId = 'testnet'; server._notify('NewBlock', { hash: 'dummy hash', }, { isGlobal: true }, function(err) { should.not.exist(err); server.walletId = wallet.id; server.getNotifications({ minTs: +Date.now() - (60 * 1000), }, function(err, notifications) { should.not.exist(err); var types = _.pluck(notifications, 'type'); types.should.deep.equal(['NewTxProposal', 'NewTxProposal', 'NewBlock']); var walletIds = _.uniq(_.pluck(notifications, 'walletId')); walletIds.length.should.equal(1); walletIds[0].should.equal(wallet.id); done(); }); }); }); }); it('should pull notifications in the last 60 seconds', function(done) { server.getNotifications({ minTs: +Date.now() - (60 * 1000), }, function(err, notifications) { should.not.exist(err); var types = _.pluck(notifications, 'type'); types.should.deep.equal(['NewTxProposal', 'NewTxProposal']); done(); }); }); it('should pull notifications after a given notification id', function(done) { server.getNotifications({}, function(err, notifications) { should.not.exist(err); var from = _.first(_.takeRight(notifications, 2)).id; // second to last server.getNotifications({ notificationId: from, minTs: +Date.now() - (60 * 1000), }, function(err, res) { should.not.exist(err); res.length.should.equal(1); res[0].id.should.equal(_.first(_.takeRight(notifications)).id); done(); }); }); }); it('should return empty if no notifications found after a given id', function(done) { server.getNotifications({}, function(err, notifications) { should.not.exist(err); var from = _.first(_.takeRight(notifications)).id; // last one server.getNotifications({ notificationId: from, }, function(err, res) { should.not.exist(err); res.length.should.equal(0); done(); }); }); }); it('should return empty if no notifications exist in the given timespan', function(done) { clock.tick(100 * 1000); server.getNotifications({ minTs: +Date.now() - (60 * 1000), }, function(err, res) { should.not.exist(err); res.length.should.equal(0); done(); }); }); it('should contain walletId & creatorId on NewCopayer', function(done) { server.getNotifications({}, function(err, notifications) { should.not.exist(err); var newCopayer = notifications[0]; newCopayer.type.should.equal('NewCopayer'); newCopayer.walletId.should.equal(wallet.id); newCopayer.creatorId.should.equal(wallet.copayers[0].id); done(); }); }); it('should notify sign and acceptance', function(done) { server.getPendingTxs({}, function(err, txs) { blockchainExplorer.broadcast = sinon.stub().callsArgWith(1, 'broadcast error'); var tx = txs[0]; var signatures = helpers.clientSign(tx, TestData.copayers[0].xPrivKey_44H_0H_0H); server.signTx({ txProposalId: tx.id, signatures: signatures, }, function(err) { server.getNotifications({ minTs: Date.now(), }, function(err, notifications) { should.not.exist(err); notifications.length.should.equal(2); var types = _.pluck(notifications, 'type'); types.should.deep.equal(['TxProposalAcceptedBy', 'TxProposalFinallyAccepted']); 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({ minTs: Date.now(), }, function(err, notifications) { should.not.exist(err); notifications.length.should.equal(2); var types = _.pluck(notifications, 'type'); types.should.deep.equal(['TxProposalRejectedBy', 'TxProposalFinallyRejected']); 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_44H_0H_0H); server.signTx({ txProposalId: tx.id, signatures: signatures, }, function(err) { should.not.exist(err); helpers.stubBroadcast(); server.broadcastTx({ txProposalId: tx.id }, function(err, txp) { should.not.exist(err); server.getNotifications({ minTs: Date.now(), }, function(err, notifications) { should.not.exist(err); notifications.length.should.equal(3); var types = _.pluck(notifications, 'type'); types.should.deep.equal(['TxProposalAcceptedBy', 'TxProposalFinallyAccepted', 'NewOutgoingTx']); done(); }); }); }); }); }); it('should notify sign, acceptance, and broadcast, and emit (with 3rd party broadcast', function(done) { server.getPendingTxs({}, function(err, txs) { var tx = txs[2]; var signatures = helpers.clientSign(tx, TestData.copayers[0].xPrivKey_44H_0H_0H); server.signTx({ txProposalId: tx.id, signatures: signatures, }, function(err) { should.not.exist(err); blockchainExplorer.broadcast = sinon.stub().callsArgWith(1, 'err'); blockchainExplorer.getTransaction = sinon.stub().callsArgWith(1, null, { txid: 11 }); server.broadcastTx({ txProposalId: tx.id }, function(err, txp) { should.not.exist(err); server.getNotifications({ minTs: Date.now(), }, function(err, notifications) { should.not.exist(err); notifications.length.should.equal(3); var types = _.pluck(notifications, 'type'); types.should.deep.equal(['TxProposalAcceptedBy', 'TxProposalFinallyAccepted', 'NewOutgoingTxByThirdParty']); done(); }); }); }); }); }); }); describe('#removeWallet', function() { var server, wallet, clock; beforeEach(function(done) { helpers.createAndJoinWallet(1, 1, function(s, w) { server = s; wallet = w; helpers.stubUtxos(server, wallet, _.range(2), function() { var txOpts = { toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', amount: helpers.toSatoshi(0.1), }; async.eachSeries(_.range(2), function(i, next) { server.createTxLegacy(txOpts, function(err, tx) { next(); }); }, done); }); }); }); it('should delete a wallet', function(done) { server.removeWallet({}, function(err) { should.not.exist(err); server.getWallet({}, function(err, w) { should.exist(err); err.code.should.equal('WALLET_NOT_FOUND'); should.not.exist(w); async.parallel([ function(next) { server.storage.fetchAddresses(wallet.id, function(err, items) { items.length.should.equal(0); next(); }); }, function(next) { server.storage.fetchTxs(wallet.id, {}, function(err, items) { items.length.should.equal(0); next(); }); }, function(next) { server.storage.fetchNotifications(wallet.id, null, 0, function(err, items) { items.length.should.equal(0); next(); }); }, ], function(err) { should.not.exist(err); done(); }); }); }); }); // creates 2 wallet, and deletes only 1. it('should delete a wallet, and only that wallet', function(done) { var server2, wallet2; async.series([ function(next) { helpers.createAndJoinWallet(1, 1, { offset: 1 }, function(s, w) { server2 = s; wallet2 = w; helpers.stubUtxos(server2, wallet2, _.range(1, 3), function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 0.1, TestData.copayers[1].privKey_1H_0, { message: 'some message' }); async.eachSeries(_.range(2), function(i, next) { server2.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); next(err); }); }, next); }); }); }, function(next) { server.removeWallet({}, next); }, function(next) { server.getWallet({}, function(err, wallet) { should.exist(err); err.code.should.equal('WALLET_NOT_FOUND'); next(); }); }, function(next) { server2.getWallet({}, function(err, wallet) { should.not.exist(err); should.exist(wallet); wallet.id.should.equal(wallet2.id); next(); }); }, function(next) { server2.getMainAddresses({}, function(err, addresses) { should.not.exist(err); should.exist(addresses); addresses.length.should.above(0); next(); }); }, function(next) { server2.getTxs({}, function(err, txs) { should.not.exist(err); should.exist(txs); txs.length.should.equal(2); next(); }); }, function(next) { server2.getNotifications({}, function(err, notifications) { should.not.exist(err); should.exist(notifications); notifications.length.should.above(0); next(); }); }, ], function(err) { should.not.exist(err); done(); }); }); }); describe('#removePendingTx', function() { var server, wallet, txp; beforeEach(function(done) { helpers.createAndJoinWallet(2, 3, function(s, w) { server = s; wallet = w; helpers.stubUtxos(server, wallet, [100, 200], function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 80, TestData.copayers[0].privKey_1H_0, { message: 'some message' }); server.createTxLegacy(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 a signed TX by himself', function(done) { var signatures = helpers.clientSign(txp, TestData.copayers[0].xPrivKey_44H_0H_0H); server.signTx({ txProposalId: txp.id, signatures: signatures, }, function(err) { should.not.exist(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 fail to remove non-pending TX', function(done) { async.waterfall([ function(next) { var signatures = helpers.clientSign(txp, TestData.copayers[0].xPrivKey_44H_0H_0H); server.signTx({ txProposalId: txp.id, signatures: signatures, }, function(err) { should.not.exist(err); next(); }); }, function(next) { helpers.getAuthServer(wallet.copayers[1].id, function(server) { server.rejectTx({ txProposalId: txp.id, }, function(err) { should.not.exist(err); next(); }); }); }, function(next) { helpers.getAuthServer(wallet.copayers[2].id, function(server) { server.rejectTx({ txProposalId: txp.id, }, function(err) { should.not.exist(err); next(); }); }); }, function(next) { server.getPendingTxs({}, function(err, txs) { should.not.exist(err); txs.should.be.empty; next(); }); }, function(next) { server.removePendingTx({ txProposalId: txp.id }, function(err) { should.exist(err); err.code.should.equal('TX_NOT_PENDING'); 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) { should.exist(err); err.code.should.contain('TX_CANNOT_REMOVE'); server2.getPendingTxs({}, function(err, txs) { txs.length.should.equal(1); done(); }); }); }); }); it('should not allow creator copayer to remove a TX signed by other copayer, in less than 24hrs', function(done) { helpers.getAuthServer(wallet.copayers[1].id, function(server2) { var signatures = helpers.clientSign(txp, TestData.copayers[1].xPrivKey_44H_0H_0H); server2.signTx({ txProposalId: txp.id, signatures: signatures, }, function(err) { should.not.exist(err); server.removePendingTx({ txProposalId: txp.id }, function(err) { err.code.should.equal('TX_CANNOT_REMOVE'); err.message.should.contain('Cannot remove'); done(); }); }); }); }); it('should allow creator copayer to remove a TX rejected by other copayer, in less than 24hrs', function(done) { helpers.getAuthServer(wallet.copayers[1].id, function(server2) { var signatures = helpers.clientSign(txp, TestData.copayers[1].xPrivKey_44H_0H_0H); server2.rejectTx({ txProposalId: txp.id, signatures: signatures, }, function(err) { should.not.exist(err); server.removePendingTx({ txProposalId: txp.id }, function(err) { should.not.exist(err); done(); }); }); }); }); it('should allow creator copayer to remove a TX signed by other copayer, after 24hrs', function(done) { helpers.getAuthServer(wallet.copayers[1].id, function(server2) { var signatures = helpers.clientSign(txp, TestData.copayers[1].xPrivKey_44H_0H_0H); server2.signTx({ txProposalId: txp.id, signatures: signatures, }, function(err) { should.not.exist(err); server.getPendingTxs({}, function(err, txs) { should.not.exist(err); txs[0].deleteLockTime.should.be.above(Defaults.DELETE_LOCKTIME - 10); var clock = sinon.useFakeTimers(Date.now() + 1 + 24 * 3600 * 1000, 'Date'); server.removePendingTx({ txProposalId: txp.id }, function(err) { should.not.exist(err); clock.restore(); done(); }); }); }); }); }); it('should allow other copayer to remove a TX signed, after 24hrs', function(done) { helpers.getAuthServer(wallet.copayers[1].id, function(server2) { var signatures = helpers.clientSign(txp, TestData.copayers[1].xPrivKey_44H_0H_0H); server2.signTx({ txProposalId: txp.id, signatures: signatures, }, function(err) { should.not.exist(err); var clock = sinon.useFakeTimers(Date.now() + 2000 + Defaults.DELETE_LOCKTIME * 1000, 'Date'); server2.removePendingTx({ txProposalId: txp.id }, function(err) { should.not.exist(err); clock.restore(); done(); }); }); }); }); }); describe('#getTxHistory', function() { var server, wallet, mainAddresses, changeAddresses; beforeEach(function(done) { helpers.createAndJoinWallet(1, 1, function(s, w) { server = s; wallet = w; helpers.createAddresses(server, wallet, 1, 1, function(main, change) { mainAddresses = main; changeAddresses = change; done(); }); }); }); it('should get tx history from insight', function(done) { helpers.stubHistory(TestData.history); server.getTxHistory({}, function(err, txs) { should.not.exist(err); should.exist(txs); txs.length.should.equal(TestData.history.length); var i = 0; _.each(txs, function(tx) { var h = TestData.history[i++]; tx.time.should.equal(h.confirmations ? h.blocktime : h.firstSeenTs); }); done(); }); }); it('should get tx history for incoming txs', function(done) { server._normalizeTxHistory = sinon.stub().returnsArg(0); var txs = [{ txid: '1', confirmations: 1, fees: 100, time: 20, inputs: [{ address: 'external', amount: 500, }], outputs: [{ address: mainAddresses[0].address, amount: 200, }], }]; helpers.stubHistory(txs); server.getTxHistory({}, function(err, txs) { should.not.exist(err); should.exist(txs); txs.length.should.equal(1); var tx = txs[0]; tx.action.should.equal('received'); tx.amount.should.equal(200); tx.fees.should.equal(100); tx.time.should.equal(20); done(); }); }); it('should get tx history for outgoing txs', function(done) { server._normalizeTxHistory = sinon.stub().returnsArg(0); var txs = [{ txid: '1', confirmations: 1, fees: 100, time: 1, inputs: [{ address: mainAddresses[0].address, amount: 500, }], outputs: [{ address: 'external', amount: 400, }], }]; helpers.stubHistory(txs); server.getTxHistory({}, function(err, txs) { should.not.exist(err); should.exist(txs); txs.length.should.equal(1); var tx = txs[0]; tx.action.should.equal('sent'); tx.amount.should.equal(400); tx.fees.should.equal(100); tx.time.should.equal(1); done(); }); }); it('should get tx history for outgoing txs + change', function(done) { server._normalizeTxHistory = sinon.stub().returnsArg(0); var txs = [{ txid: '1', confirmations: 1, fees: 100, time: 1, inputs: [{ address: mainAddresses[0].address, amount: 500, }], outputs: [{ address: 'external', amount: 300, }, { address: changeAddresses[0].address, amount: 100, }], }]; helpers.stubHistory(txs); server.getTxHistory({}, function(err, txs) { should.not.exist(err); should.exist(txs); txs.length.should.equal(1); var tx = txs[0]; tx.action.should.equal('sent'); tx.amount.should.equal(300); tx.fees.should.equal(100); tx.outputs[0].address.should.equal('external'); tx.outputs[0].amount.should.equal(300); done(); }); }); it('should get tx history with accepted proposal', function(done) { server._normalizeTxHistory = sinon.stub().returnsArg(0); var external = '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7'; helpers.stubUtxos(server, wallet, [100, 200], function(utxos) { var outputs = [{ toAddress: external, amount: 50, message: undefined // no message }, { toAddress: external, amount: 30, message: 'message #2' }]; var txOpts = helpers.createProposalOpts(Model.TxProposalLegacy.Types.MULTIPLEOUTPUTS, outputs, TestData.copayers[0].privKey_1H_0, { message: 'some message' }); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); should.exist(tx); var signatures = helpers.clientSign(tx, TestData.copayers[0].xPrivKey_44H_0H_0H); server.signTx({ txProposalId: tx.id, signatures: signatures, }, function(err, tx) { should.not.exist(err); helpers.stubBroadcast(); server.broadcastTx({ txProposalId: tx.id }, function(err, txp) { should.not.exist(err); var txs = [{ txid: txp.txid, confirmations: 1, fees: 5460, time: 1, inputs: [{ address: tx.inputs[0].address, amount: utxos[0].satoshis, }], outputs: [{ address: changeAddresses[0].address, amount: helpers.toSatoshi(20) - 5460, }, { address: external, amount: helpers.toSatoshi(50) }, { address: external, amount: helpers.toSatoshi(30) }] }]; helpers.stubHistory(txs); server.getTxHistory({}, function(err, txs) { should.not.exist(err); should.exist(txs); txs.length.should.equal(1); var tx = txs[0]; tx.action.should.equal('sent'); tx.amount.should.equal(helpers.toSatoshi(80)); tx.message.should.equal('some message'); tx.addressTo.should.equal(external); tx.actions.length.should.equal(1); tx.actions[0].type.should.equal('accept'); tx.actions[0].copayerName.should.equal('copayer 1'); tx.proposalType.should.equal(Model.TxProposalLegacy.Types.MULTIPLEOUTPUTS); tx.outputs[0].address.should.equal(external); tx.outputs[0].amount.should.equal(helpers.toSatoshi(50)); should.not.exist(tx.outputs[0].message); should.not.exist(tx.outputs[0]['isMine']); should.not.exist(tx.outputs[0]['isChange']); tx.outputs[1].address.should.equal(external); tx.outputs[1].amount.should.equal(helpers.toSatoshi(30)); should.exist(tx.outputs[1].message); tx.outputs[1].message.should.equal('message #2'); done(); }); }); }); }); }); }); it('should get various paginated tx history', function(done) { var testCases = [{ opts: {}, expected: [50, 40, 30, 20, 10], }, { opts: { skip: 1, limit: 3, }, expected: [40, 30, 20], }, { opts: { skip: 1, limit: 2, }, expected: [40, 30], }, { opts: { skip: 2, }, expected: [30, 20, 10], }, { opts: { limit: 4, }, expected: [50, 40, 30, 20], }, { opts: { skip: 0, limit: 3, }, expected: [50, 40, 30], }, { opts: { skip: 0, limit: 0, }, expected: [], }, { opts: { skip: 4, limit: 10, }, expected: [10], }, { opts: { skip: 20, limit: 1, }, expected: [], }]; server._normalizeTxHistory = sinon.stub().returnsArg(0); var timestamps = [50, 40, 30, 20, 10]; var txs = _.map(timestamps, function(ts, idx) { return { txid: (idx + 1).toString(), confirmations: ts / 10, fees: 100, time: ts, inputs: [{ address: 'external', amount: 500, }], outputs: [{ address: mainAddresses[0].address, amount: 200, }], }; }); helpers.stubHistory(txs); async.each(testCases, function(testCase, next) { server.getTxHistory(testCase.opts, function(err, txs) { should.not.exist(err); should.exist(txs); _.pluck(txs, 'time').should.deep.equal(testCase.expected); next(); }); }, done); }); it('should fail gracefully if unable to reach the blockchain', function(done) { blockchainExplorer.getTransactions = sinon.stub().callsArgWith(3, 'dummy error'); server.getTxHistory({}, function(err, txs) { should.exist(err); err.toString().should.equal('dummy error'); done(); }); }); it('should handle invalid tx in history ', function(done) { var h = _.clone(TestData.history); h.push({ txid: 'xx' }) helpers.stubHistory(h); var l = TestData.history.length; server.getTxHistory({}, function(err, txs) { should.not.exist(err); should.exist(txs); txs.length.should.equal(l + 1); txs[l].action.should.equal('invalid'); done(); }); }); it('should handle exceeded limit', function(done) { server.getTxHistory({ limit: 1000 }, function(err, txs) { err.code.should.equal('HISTORY_LIMIT_EXCEEDED'); done(); }); }); }); describe('#scan', function() { var server, wallet; describe('1-of-1 wallet (BIP44 & P2PKH)', function() { beforeEach(function(done) { this.timeout(5000); Defaults.SCAN_ADDRESS_GAP = 2; helpers.createAndJoinWallet(1, 1, function(s, w) { server = s; wallet = w; done(); }); }); afterEach(function() {}); it('should scan main addresses', function(done) { helpers.stubAddressActivity( ['1L3z9LPd861FWQhf3vDn89Fnc9dkdBo2CG', // m/0/0 '1GdXraZ1gtoVAvBh49D4hK9xLm6SKgesoE', // m/0/2 '1FUzgKcyPJsYwDLUEVJYeE2N3KVaoxTjGS', // m/1/0 ]); var expectedPaths = [ 'm/0/0', 'm/0/1', 'm/0/2', 'm/1/0', ]; server.scan({}, function(err) { should.not.exist(err); server.getWallet({}, function(err, wallet) { should.not.exist(err); wallet.scanStatus.should.equal('success'); server.storage.fetchAddresses(wallet.id, function(err, addresses) { should.exist(addresses); addresses.length.should.equal(expectedPaths.length); var paths = _.pluck(addresses, 'path'); _.difference(paths, expectedPaths).length.should.equal(0); server.createAddress({}, function(err, address) { should.not.exist(err); address.path.should.equal('m/0/3'); done(); }); }); }); }); }); it('should not go beyond max gap', function(done) { helpers.stubAddressActivity( ['1L3z9LPd861FWQhf3vDn89Fnc9dkdBo2CG', // m/0/0 '1GdXraZ1gtoVAvBh49D4hK9xLm6SKgesoE', // m/0/2 '1DY9exavapgnCUWDnSTJe1BPzXcpgwAQC4', // m/0/5 '1LD7Cr68LvBPTUeXrr6YXfGrogR7TVj3WQ', // m/1/3 ]); var expectedPaths = [ 'm/0/0', 'm/0/1', 'm/0/2', ]; server.scan({}, function(err) { should.not.exist(err); server.getWallet({}, function(err, wallet) { should.not.exist(err); wallet.scanStatus.should.equal('success'); server.storage.fetchAddresses(wallet.id, function(err, addresses) { should.exist(addresses); addresses.length.should.equal(expectedPaths.length); var paths = _.pluck(addresses, 'path'); _.difference(paths, expectedPaths).length.should.equal(0); server.createAddress({}, function(err, address) { should.not.exist(err); address.path.should.equal('m/0/3'); // A rescan should see the m/0/5 address initially beyond the gap server.scan({}, function(err) { server.createAddress({}, function(err, address) { should.not.exist(err); address.path.should.equal('m/0/6'); done(); }); }); }); }); }); }); }); it('should not affect indexes on new wallet', function(done) { helpers.stubAddressActivity([]); server.scan({}, function(err) { should.not.exist(err); server.getWallet({}, function(err, wallet) { should.not.exist(err); wallet.scanStatus.should.equal('success'); server.storage.fetchAddresses(wallet.id, function(err, addresses) { should.not.exist(err); addresses.length.should.equal(0); server.createAddress({}, function(err, address) { should.not.exist(err); address.path.should.equal('m/0/0'); done(); }); }); }); }); }); it('should not rewind already generated addresses on error', function(done) { server.createAddress({}, function(err, address) { should.not.exist(err); address.path.should.equal('m/0/0'); blockchainExplorer.getAddressActivity = sinon.stub().callsArgWith(1, 'dummy error'); server.scan({}, function(err) { should.exist(err); err.toString().should.equal('dummy error'); server.getWallet({}, function(err, wallet) { should.not.exist(err); wallet.scanStatus.should.equal('error'); wallet.addressManager.receiveAddressIndex.should.equal(1); wallet.addressManager.changeAddressIndex.should.equal(0); server.createAddress({}, function(err, address) { should.not.exist(err); address.path.should.equal('m/0/1'); done(); }); }); }); }); }); it('should restore wallet balance', function(done) { async.waterfall([ function(next) { helpers.stubUtxos(server, wallet, [1, 2, 3], function(utxos) { should.exist(utxos); helpers.stubAddressActivity(_.pluck(utxos, 'address')); server.getBalance({}, function(err, balance) { balance.totalAmount.should.equal(helpers.toSatoshi(6)); next(null, server, wallet); }); }); }, function(server, wallet, next) { server.removeWallet({}, function(err) { next(err); }); }, function(next) { // NOTE: this works because it creates the exact same wallet! helpers.createAndJoinWallet(1, 1, function(server, wallet) { server.getBalance({}, function(err, balance) { balance.totalAmount.should.equal(0); next(null, server, wallet); }); }); }, function(server, wallet, next) { server.scan({}, function(err) { should.not.exist(err); server.getBalance(wallet.id, function(err, balance) { balance.totalAmount.should.equal(helpers.toSatoshi(6)); next(); }) }); }, ], function(err) { should.not.exist(err); done(); }); }); it('should abort scan if there is an error checking address activity', function(done) { blockchainExplorer.getAddressActivity = sinon.stub().callsArgWith(1, 'dummy error'); server.scan({}, function(err) { should.exist(err); err.toString().should.equal('dummy error'); server.getWallet({}, function(err, wallet) { should.not.exist(err); wallet.scanStatus.should.equal('error'); wallet.addressManager.receiveAddressIndex.should.equal(0); wallet.addressManager.changeAddressIndex.should.equal(0); server.storage.fetchAddresses(wallet.id, function(err, addresses) { should.not.exist(err); addresses.should.be.empty; done(); }); }); }); }); }); describe('shared wallet (BIP45)', function() { beforeEach(function(done) { this.timeout(5000); Defaults.SCAN_ADDRESS_GAP = 2; helpers.createAndJoinWallet(1, 2, { supportBIP44AndP2PKH: false }, function(s, w) { server = s; wallet = w; done(); }); }); afterEach(function() {}); it('should scan main addresses', function(done) { helpers.stubAddressActivity( ['39AA1Y2VvPJhV3RFbc7cKbUax1WgkPwweR', // m/2147483647/0/0 '3QX2MNSijnhCALBmUVnDo5UGPj3SEGASWx', // m/2147483647/0/2 '3MzGaz4KKX66w8ShKaR536ZqzVvREBqqYu', // m/2147483647/1/0 ]); var expectedPaths = [ 'm/2147483647/0/0', 'm/2147483647/0/1', 'm/2147483647/0/2', 'm/2147483647/1/0', ]; server.scan({}, function(err) { should.not.exist(err); server.getWallet({}, function(err, wallet) { should.not.exist(err); wallet.scanStatus.should.equal('success'); server.storage.fetchAddresses(wallet.id, function(err, addresses) { should.exist(addresses); addresses.length.should.equal(expectedPaths.length); var paths = _.pluck(addresses, 'path'); _.difference(paths, expectedPaths).length.should.equal(0); server.createAddress({}, function(err, address) { should.not.exist(err); address.path.should.equal('m/2147483647/0/3'); done(); }); }); }); }); }); it('should scan main addresses & copayer addresses', function(done) { helpers.stubAddressActivity( ['39AA1Y2VvPJhV3RFbc7cKbUax1WgkPwweR', // m/2147483647/0/0 '3MzGaz4KKX66w8ShKaR536ZqzVvREBqqYu', // m/2147483647/1/0 '3BYoynejwBH9q4Jhr9m9P5YTnLTu57US6g', // m/0/0/1 '37Pb8c32hzm16tCZaVHj4Dtjva45L2a3A3', // m/1/1/0 '32TB2n283YsXdseMqUm9zHSRcfS5JxTWxx', // m/1/0/0 ]); var expectedPaths = [ 'm/2147483647/0/0', 'm/2147483647/1/0', 'm/0/0/0', 'm/0/0/1', 'm/1/0/0', 'm/1/1/0', ]; server.scan({ includeCopayerBranches: true }, function(err) { should.not.exist(err); server.storage.fetchAddresses(wallet.id, function(err, addresses) { should.exist(addresses); addresses.length.should.equal(expectedPaths.length); var paths = _.pluck(addresses, 'path'); _.difference(paths, expectedPaths).length.should.equal(0); done(); }) }); }); }); }); describe('#startScan', function() { var server, wallet; beforeEach(function(done) { this.timeout(5000); Defaults.SCAN_ADDRESS_GAP = 2; helpers.createAndJoinWallet(1, 1, { supportBIP44AndP2PKH: false }, function(s, w) { server = s; wallet = w; done(); }); }); afterEach(function() { server.messageBroker.removeAllListeners(); }); it('should start an asynchronous scan', function(done) { helpers.stubAddressActivity( ['3GvvHimEMk2GBZnPxTF89GHZL6QhZjUZVs', // m/2147483647/0/0 '37pd1jjTUiGBh8JL2hKLDgsyrhBoiz5vsi', // m/2147483647/0/2 '3C3tBn8Sr1wHTp2brMgYsj9ncB7R7paYuB', // m/2147483647/1/0 ]); var expectedPaths = [ 'm/2147483647/0/0', 'm/2147483647/0/1', 'm/2147483647/0/2', 'm/2147483647/1/0', ]; server.messageBroker.onMessage(function(n) { if (n.type == 'ScanFinished') { server.getWallet({}, function(err, wallet) { should.exist(wallet.scanStatus); wallet.scanStatus.should.equal('success'); should.not.exist(n.creatorId); server.storage.fetchAddresses(wallet.id, function(err, addresses) { should.exist(addresses); addresses.length.should.equal(expectedPaths.length); var paths = _.pluck(addresses, 'path'); _.difference(paths, expectedPaths).length.should.equal(0); server.createAddress({}, function(err, address) { should.not.exist(err); address.path.should.equal('m/2147483647/0/3'); done(); }); }) }); } }); server.startScan({}, function(err) { should.not.exist(err); }); }); it('should set scan status error when unable to reach blockchain', function(done) { blockchainExplorer.getAddressActivity = sinon.stub().yields('dummy error'); server.messageBroker.onMessage(function(n) { if (n.type == 'ScanFinished') { should.exist(n.data.error); server.getWallet({}, function(err, wallet) { should.exist(wallet.scanStatus); wallet.scanStatus.should.equal('error'); done(); }); } }); server.startScan({}, function(err) { should.not.exist(err); }); }); it('should start multiple asynchronous scans for different wallets', function(done) { helpers.stubAddressActivity(['3K2VWMXheGZ4qG35DyGjA2dLeKfaSr534A']); Defaults.SCAN_ADDRESS_GAP = 1; var scans = 0; server.messageBroker.onMessage(function(n) { if (n.type == 'ScanFinished') { scans++; if (scans == 2) done(); } }); // Create a second wallet var server2 = new WalletService(); var opts = { name: 'second wallet', m: 1, n: 1, pubKey: TestData.keyPair.pub, }; server2.createWallet(opts, function(err, walletId) { should.not.exist(err); var copayerOpts = helpers.getSignedCopayerOpts({ walletId: walletId, name: 'copayer 1', xPubKey: TestData.copayers[3].xPubKey_45H, requestPubKey: TestData.copayers[3].pubKey_1H_0, }); server.joinWallet(copayerOpts, function(err, result) { should.not.exist(err); helpers.getAuthServer(result.copayerId, function(server2) { server.startScan({}, function(err) { should.not.exist(err); scans.should.equal(0); }); server2.startScan({}, function(err) { should.not.exist(err); scans.should.equal(0); }); scans.should.equal(0); }); }); }); }); }); describe('Legacy', function() { describe('Fees', function() { var server, wallet; beforeEach(function(done) { helpers.createAndJoinWallet(2, 3, function(s, w) { server = s; wallet = w; done(); }); }); it('should create a tx from legacy (bwc-0.0.*) client', function(done) { helpers.stubUtxos(server, wallet, [100, 200], function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 80, TestData.copayers[0].privKey_1H_0, { message: 'some message' }); var verifyStub = sinon.stub(WalletService.prototype, '_verifySignature'); verifyStub.returns(true); WalletService.getInstanceWithAuth({ copayerId: wallet.copayers[0].id, message: 'dummy', signature: 'dummy', clientVersion: 'bwc-0.0.40', }, function(err, server) { should.not.exist(err); should.exist(server); verifyStub.restore(); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); should.exist(tx); tx.amount.should.equal(helpers.toSatoshi(80)); tx.fee.should.equal(Defaults.DEFAULT_FEE_PER_KB); done(); }); }); }); }); it('should not return error when fetching new txps from legacy (bwc-0.0.*) client', function(done) { helpers.stubUtxos(server, wallet, [100, 200], function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 80, TestData.copayers[0].privKey_1H_0, { message: 'some message' }); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); should.exist(tx); var verifyStub = sinon.stub(WalletService.prototype, '_verifySignature'); verifyStub.returns(true); WalletService.getInstanceWithAuth({ copayerId: wallet.copayers[0].id, message: 'dummy', signature: 'dummy', clientVersion: 'bwc-0.0.40', }, function(err, server) { verifyStub.restore(); should.not.exist(err); should.exist(server); server.getPendingTxs({}, function(err, txps) { should.not.exist(err); should.exist(txps); done(); }); }); }); }); }); it('should fail to sign tx from legacy (bwc-0.0.*) client', function(done) { helpers.stubUtxos(server, wallet, [100, 200], function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 80, TestData.copayers[0].privKey_1H_0, { message: 'some message' }); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); should.exist(tx); _.startsWith(tx.version, '1.').should.be.false; var verifyStub = sinon.stub(WalletService.prototype, '_verifySignature'); verifyStub.returns(true); WalletService.getInstanceWithAuth({ copayerId: wallet.copayers[0].id, message: 'dummy', signature: 'dummy', clientVersion: 'bwc-0.0.40', }, function(err, server) { var signatures = helpers.clientSign(tx, TestData.copayers[0].xPrivKey_44H_0H_0H); server.signTx({ txProposalId: tx.id, signatures: signatures, }, function(err) { verifyStub.restore(); should.exist(err); err.code.should.equal('UPGRADE_NEEDED'); err.message.should.contain('sign this spend proposal'); done(); }); }); }); }); }); it('should create a tx from legacy (bwc-0.0.*) client and sign it from newer client', function(done) { helpers.stubUtxos(server, wallet, [100, 200], function() { var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 80, TestData.copayers[0].privKey_1H_0, { message: 'some message' }); var verifyStub = sinon.stub(WalletService.prototype, '_verifySignature'); verifyStub.returns(true); WalletService.getInstanceWithAuth({ copayerId: wallet.copayers[0].id, message: 'dummy', signature: 'dummy', clientVersion: 'bwc-0.0.40', }, function(err, server) { should.not.exist(err); should.exist(server); verifyStub.restore(); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); should.exist(tx); tx.amount.should.equal(helpers.toSatoshi(80)); tx.fee.should.equal(Defaults.DEFAULT_FEE_PER_KB); helpers.getAuthServer(wallet.copayers[0].id, function(server) { var signatures = helpers.clientSign(tx, TestData.copayers[0].xPrivKey_44H_0H_0H); server.signTx({ txProposalId: tx.id, signatures: signatures, }, function(err) { should.not.exist(err); done(); }); }); }); }); }); }); it('should fail with insufficient fee when invoked from legacy (bwc-0.0.*) client', function(done) { helpers.stubUtxos(server, wallet, 1, function() { var verifyStub = sinon.stub(WalletService.prototype, '_verifySignature'); verifyStub.returns(true); WalletService.getInstanceWithAuth({ copayerId: wallet.copayers[0].id, message: 'dummy', signature: 'dummy', clientVersion: 'bwc-0.0.40', }, function(err, server) { should.not.exist(err); should.exist(server); verifyStub.restore(); var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 0.99995, TestData.copayers[0].privKey_1H_0); server.createTxLegacy(txOpts, function(err, tx) { should.exist(err); err.code.should.equal('INSUFFICIENT_FUNDS_FOR_FEE'); var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 0.99995, TestData.copayers[0].privKey_1H_0, { feePerKb: 5000 }); server.createTxLegacy(txOpts, function(err, tx) { should.not.exist(err); tx.fee.should.equal(5000); // Sign it to make sure Bitcore doesn't complain about the fees var signatures = helpers.clientSign(tx, TestData.copayers[0].xPrivKey_44H_0H_0H); server.signTx({ txProposalId: tx.id, signatures: signatures, }, function(err) { should.not.exist(err); done(); }); }); }); }); }); }); }); }); });