From 570c8c193f3c8893ad7590623b3cac89f6fc2388 Mon Sep 17 00:00:00 2001 From: matiu Date: Tue, 12 Sep 2017 23:42:14 -0300 Subject: [PATCH 1/5] add coin to getUtxo --- lib/server.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/server.js b/lib/server.js index c7b511f..d675439 100644 --- a/lib/server.js +++ b/lib/server.js @@ -1077,7 +1077,8 @@ WalletService.prototype._getUtxos = function(coin, addresses, cb) { var self = this; if (addresses.length == 0) return cb(null, []); - var networkName = Bitcore.Address(addresses[0]).toObject().network; + + var networkName = Bitcore_[coin].Address(addresses[0]).toObject().network; var bc = self._getBlockchainExplorer(coin, networkName); if (!bc) return cb(new Error('Could not get blockchain explorer instance')); From d8e6964587751f86f6ed98081c5ae53335371cf4 Mon Sep 17 00:00:00 2001 From: matiu Date: Wed, 13 Sep 2017 15:35:48 -0300 Subject: [PATCH 2/5] support cross-coin balance querying, using different address versions --- lib/common/utils.js | 33 +++++++++++++++++ lib/server.js | 32 +++++++++------- package.json | 24 +++++++----- test/integration/server.js | 75 +++++++++++++++++++++++++++++++++++++- test/utils.js | 38 +++++++++++++++++++ 5 files changed, 177 insertions(+), 25 deletions(-) diff --git a/lib/common/utils.js b/lib/common/utils.js index 2d4d807..cd4413f 100644 --- a/lib/common/utils.js +++ b/lib/common/utils.js @@ -7,6 +7,13 @@ var encoding = bitcore.encoding; var secp256k1 = require('secp256k1'); var Utils = {}; +var Bitcore = require('bitcore-lib'); +var Bitcore_ = { + btc: Bitcore, + bch: require('bitcore-lib-cash') +}; + + Utils.getMissingFields = function(obj, args) { args = [].concat(args); @@ -186,4 +193,30 @@ Utils.checkValueInCollection = function(value, collection) { return _.contains(_.values(collection), value); }; + +Utils.getAddressCoin = function(address) { + try { + new Bitcore_['btc'].Address(address); + return 'btc'; + } catch (e) { + try { + new Bitcore_['bch'].Address(address); + return 'bch'; + } catch (e) { + return; + } + } +}; + +Utils.translateAddress = function(address, coin) { + var origCoin = Utils.getAddressCoin(address); + var origAddress = new Bitcore_[origCoin].Address(address); + var origObj = origAddress.toObject(); + + var result = Bitcore_[coin].Address.fromObject(origObj) + return result.toString(); +}; + + + module.exports = Utils; diff --git a/lib/server.js b/lib/server.js index d675439..d5a3578 100644 --- a/lib/server.js +++ b/lib/server.js @@ -1107,20 +1107,16 @@ WalletService.prototype._getUtxosForCurrentWallet = function(opts, cb) { return utxo.txid + '|' + utxo.vout }; - var coin, allAddresses, allUtxos, utxoIndex; + var coin, allAddresses, allUtxos, utxoIndex, addressStrs; async.series([ - function(next) { - if (opts.coin) { - coin = opts.coin; - next(); - } else { - self.getWallet({}, function(err, wallet) { - coin = wallet.coin; - return next(); - }); - } + self.getWallet({}, function(err, wallet) { + if (err) return next(err); + + coin = wallet.coin; + return next(); + }); }, function(next) { if (_.isArray(opts.addresses)) { @@ -1129,13 +1125,23 @@ WalletService.prototype._getUtxosForCurrentWallet = function(opts, cb) { } self.storage.fetchAddresses(self.walletId, function(err, addresses) { allAddresses = addresses; + if (allAddresses.length == 0) return cb(null, []); + return next(); }); }, function(next) { - if (allAddresses.length == 0) return cb(null, []); + addressStrs = _.pluck(allAddresses, 'address'); + if (!opts.coin) return next(); + + coin = opts.coin; + addressStrs = _.map(addressStrs, function(a) { + return Utils.translateAddress(a, coin); + }); + next(); + }, + function(next) { - var addressStrs = _.pluck(allAddresses, 'address'); self._getUtxos(coin, addressStrs, function(err, utxos) { if (err) return next(err); diff --git a/package.json b/package.json index c4cea08..c8d1d4b 100644 --- a/package.json +++ b/package.json @@ -71,14 +71,18 @@ "coveralls": "./node_modules/.bin/istanbul cover ./node_modules/mocha/bin/_mocha --report lcovonly -- -R spec && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js && rm -rf ./coverage" }, "bitcoreNode": "./bitcorenode", - "contributors": [{ - "name": "Braydon Fuller", - "email": "braydon@bitpay.com" - }, { - "name": "Ivan Socolsky", - "email": "ivan@bitpay.com" - }, { - "name": "Matias Alejo Garcia", - "email": "ematiu@gmail.com" - }] + "contributors": [ + { + "name": "Braydon Fuller", + "email": "braydon@bitpay.com" + }, + { + "name": "Ivan Socolsky", + "email": "ivan@bitpay.com" + }, + { + "name": "Matias Alejo Garcia", + "email": "ematiu@gmail.com" + } + ] } diff --git a/test/integration/server.js b/test/integration/server.js index 4058608..92ea2c4 100644 --- a/test/integration/server.js +++ b/test/integration/server.js @@ -1222,6 +1222,7 @@ describe('Wallet service', function() { address.network.should.equal('livenet'); address.address.should.equal('36q2G5FMGvJbPgAVEaiyAsFGmpkhPKwk2r'); address.isChange.should.be.false; + address.coin.should.equal('btc'); address.path.should.equal('m/0/0'); address.type.should.equal('P2SH'); server.getNotifications({}, function(err, notifications) { @@ -1271,6 +1272,76 @@ describe('Wallet service', function() { }); }); + + describe('shared wallets (BIP44/BCH)', function() { + beforeEach(function(done) { + helpers.createAndJoinWallet(2, 2, { + coin: 'bch' + }, 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('HBf8isgS8EXG1r3X6GP89FmooUmiJ42wHS'); + address.isChange.should.be.false; + address.path.should.equal('m/0/0'); + address.type.should.equal('P2SH'); + address.coin.should.equal('bch'); + 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.mapSeries(_.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) { @@ -7672,7 +7743,7 @@ describe('Wallet service', function() { address.walletId.should.equal(wallet.bch.id); address.coin.should.equal('bch'); address.network.should.equal('livenet'); - address.address.should.equal('1L3z9LPd861FWQhf3vDn89Fnc9dkdBo2CG'); + address.address.should.equal('CbWsiNjh18ynQYc5jfYhhespEGrAaW8YUq'); server.btc.getMainAddresses({}, function(err, addresses) { should.not.exist(err); addresses.length.should.equal(1); @@ -7684,7 +7755,7 @@ describe('Wallet service', function() { addresses.length.should.equal(1); addresses[0].coin.should.equal('bch'); addresses[0].walletId.should.equal(wallet.bch.id); - addresses[0].address.should.equal('1L3z9LPd861FWQhf3vDn89Fnc9dkdBo2CG'); + addresses[0].address.should.equal('CbWsiNjh18ynQYc5jfYhhespEGrAaW8YUq'); done(); }); }); diff --git a/test/utils.js b/test/utils.js index f945aa9..33e3aa0 100644 --- a/test/utils.js +++ b/test/utils.js @@ -131,4 +131,42 @@ describe('Utils', function() { }); }); }); + + describe('#getAddressCoin', function() { + it('should identify btc as coin for 1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA', function() { + Utils.getAddressCoin('1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA').should.equal('btc'); + }); + it('should identify bch as coin for CcJ4qUfyQ8x5NwhAeCQkrBSWVeXxXghcNz', function() { + Utils.getAddressCoin('CcJ4qUfyQ8x5NwhAeCQkrBSWVeXxXghcNz').should.equal('bch'); + }); + it('should return null for 1L', function() { + should.not.exist(Utils.getAddressCoin('1L')); + }); + }); + + + describe('#translateAddress', function() { + it('should translate address from btc to bch', function() { + var res = Utils.translateAddress('1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA', 'bch'); + res.should.equal('CcJ4qUfyQ8x5NwhAeCQkrBSWVeXxXghcNz'); + }); + it('should translate address from bch to btc', function() { + var res = Utils.translateAddress('HBf8isgS8EXG1r3X6GP89FmooUmiJ42wHS', 'btc'); + res.should.equal('36q2G5FMGvJbPgAVEaiyAsFGmpkhPKwk2r'); + }); + + it('should keep the address if there is nothing to do (bch)', function() { + var res = Utils.translateAddress('CcJ4qUfyQ8x5NwhAeCQkrBSWVeXxXghcNz', 'bch'); + res.should.equal('CcJ4qUfyQ8x5NwhAeCQkrBSWVeXxXghcNz'); + }); + it('should keep the address if there is nothing to do (btc)', function() { + var res = Utils.translateAddress('1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA', 'btc'); + should.exist(res); + res.should.equal('1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA'); + }); + + + + }); + }); From 518639255820d5f412a3058f184b593f47e04b68 Mon Sep 17 00:00:00 2001 From: matiu Date: Wed, 13 Sep 2017 16:46:23 -0300 Subject: [PATCH 3/5] add BCH createTX tests --- lib/server.js | 3 +- test/integration/helpers.js | 6 +- test/integration/server.js | 1754 ++++++++++++++++++----------------- 3 files changed, 895 insertions(+), 868 deletions(-) diff --git a/lib/server.js b/lib/server.js index d5a3578..af836a4 100644 --- a/lib/server.js +++ b/lib/server.js @@ -1929,6 +1929,7 @@ WalletService.prototype._canCreateTx = function(cb) { }; WalletService.prototype._validateOutputs = function(opts, wallet, cb) { + var A = Bitcore_[wallet.coin].Address; var dustThreshold = Math.max(Defaults.MIN_OUTPUT_AMOUNT, Bitcore_[wallet.coin].Transaction.DUST_AMOUNT); if (_.isEmpty(opts.outputs)) return new ClientError('No outputs were specified'); @@ -1943,7 +1944,7 @@ WalletService.prototype._validateOutputs = function(opts, wallet, cb) { var toAddress = {}; try { - toAddress = new Bitcore.Address(output.toAddress); + toAddress = new A(output.toAddress); } catch (ex) { return Errors.INVALID_ADDRESS; } diff --git a/test/integration/helpers.js b/test/integration/helpers.js index bb8008e..d7b57de 100644 --- a/test/integration/helpers.js +++ b/test/integration/helpers.js @@ -277,6 +277,8 @@ helpers.stubUtxos = function(server, wallet, amounts, opts, cb) { if (!helpers._utxos) helpers._utxos = {}; + var S = Bitcore_[wallet.coin].Script; + async.waterfall([ function(next) { @@ -298,10 +300,10 @@ helpers.stubUtxos = function(server, wallet, amounts, opts, cb) { var scriptPubKey; switch (wallet.addressType) { case Constants.SCRIPT_TYPES.P2SH: - scriptPubKey = Bitcore.Script.buildMultisigOut(address.publicKeys, wallet.m).toScriptHashOut(); + scriptPubKey = S.buildMultisigOut(address.publicKeys, wallet.m).toScriptHashOut(); break; case Constants.SCRIPT_TYPES.P2PKH: - scriptPubKey = Bitcore.Script.buildPublicKeyHashOut(address.address); + scriptPubKey = S.buildPublicKeyHashOut(address.address); break; } should.exist(scriptPubKey); diff --git a/test/integration/server.js b/test/integration/server.js index 92ea2c4..17278ae 100644 --- a/test/integration/server.js +++ b/test/integration/server.js @@ -11,6 +11,12 @@ log.debug = log.verbose; log.level = 'info'; var Bitcore = require('bitcore-lib'); +var Bitcore_ = { + btc: Bitcore, + bch: require('bitcore-lib-cash') +}; + + var Common = require('../../lib/common'); var Utils = Common.Utils; @@ -2518,275 +2524,260 @@ describe('Wallet service', function() { }); }); - describe('#createTx', function() { - describe('Tx proposal creation & publishing', function() { - var server, wallet; - beforeEach(function(done) { - helpers.createAndJoinWallet(1, 1, function(s, w) { - server = s; - wallet = w; - done(); - }); + var addrMap = { + btc: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', + bch: 'CPrtPWbp8cCftTQu5fzuLG5zPJNDHMMf8X', + } + + _.each(['bch', 'btc'], function(coin) { + + describe('#createTx ' + coin, function() { + var addressStr; + before(function() { + addressStr = addrMap[coin]; }); - it('should create a tx', function(done) { - helpers.stubUtxos(server, wallet, [1, 2], function() { - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 0.8 * 1e8, - }], - message: 'some message', - customData: 'some custom data', - feePerKb: 123e2, - }; - server.createTx(txOpts, function(err, tx) { - should.not.exist(err); - should.exist(tx); - tx.walletM.should.equal(1); - tx.walletN.should.equal(1); - tx.requiredRejections.should.equal(1); - tx.requiredSignatures.should.equal(1); - 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)); - tx.feePerKb.should.equal(123e2); - should.not.exist(tx.feeLevel); - server.getPendingTxs({}, function(err, txs) { - should.not.exist(err); - txs.should.be.empty; - done(); - }); - }); - }); - }); - describe('Validations', function() { - it('should fail to create a tx without outputs', function(done) { - helpers.stubUtxos(server, wallet, [1, 2], function() { - var txOpts = { - outputs: [], - feePerKb: 123e2, - }; - server.createTx(txOpts, function(err, tx) { - should.exist(err); - should.not.exist(tx); - err.message.should.equal('No outputs were specified'); - done(); - }); - }); - }); - it('should fail to create tx for invalid address', function(done) { - helpers.stubUtxos(server, wallet, 1, function() { - var txOpts = { - outputs: [{ - toAddress: 'invalid address', - amount: 0.5e8 - }], - feePerKb: 100e2, - }; - server.createTx(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, 1, function() { - var txOpts = { - outputs: [{ - toAddress: 'myE38JHdxmQcTJGP1ZiX4BiGhDxMJDvLJD', - amount: 0.5e8 - }], - feePerKb: 100e2, - }; - server.createTx(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 = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 0, - }], - feePerKb: 100e2, - }; - server.createTx(txOpts, function(err, tx) { - should.not.exist(tx); - should.exist(err); - err.message.should.equal('Invalid amount'); + + describe('Tx proposal creation & publishing ' + coin, function() { + var server, wallet; + beforeEach(function(done) { + helpers.createAndJoinWallet(1, 1, { + coin: coin, + }, function(s, w) { + server = s; + wallet = w; done(); }); }); - it('should fail to specify both feeLevel & feePerKb', function(done) { - helpers.stubUtxos(server, wallet, 2, function() { + + + it('should create a tx', function(done) { + helpers.stubUtxos(server, wallet, [1, 2], function() { var txOpts = { outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 1e8, + toAddress: addressStr, + amount: 0.8 * 1e8, }], - feeLevel: 'normal', + message: 'some message', + customData: 'some custom data', feePerKb: 123e2, }; - server.createTx(txOpts, function(err, txp) { - should.exist(err); - should.not.exist(txp); - err.toString().should.contain('Only one of feeLevel/feePerKb'); - 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) { + server.createTx(txOpts, function(err, tx) { should.not.exist(err); - var inputs = [utxos[0], utxos[2]]; - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 2.5e8, - }], - feePerKb: 100e2, - inputs: inputs, - }; - server.createTx(txOpts, function(err, tx) { + should.exist(tx); + tx.walletM.should.equal(1); + tx.walletN.should.equal(1); + tx.requiredRejections.should.equal(1); + tx.requiredSignatures.should.equal(1); + 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)); + tx.feePerKb.should.equal(123e2); + should.not.exist(tx.feeLevel); + server.getPendingTxs({}, function(err, txs) { 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); + txs.should.be.empty; done(); }); }); }); }); - it('should be able to specify change address', function(done) { - helpers.stubUtxos(server, wallet, [1, 2], function(utxos) { - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 0.8e8, - }], - feePerKb: 100e2, - changeAddress: utxos[0].address, - }; - server.createTx(txOpts, function(err, tx) { - should.not.exist(err); - should.exist(tx); - var t = tx.getBitcoreTx(); - t.getChangeOutput().script.toAddress().toString().should.equal(txOpts.changeAddress); - done(); - }); - }); - }); - it('should be able to specify inputs & absolute fee', function(done) { - helpers.stubUtxos(server, wallet, [1, 2], function(utxos) { - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 0.8e8, - }], - inputs: utxos, - fee: 1000e2, - }; - server.createTx(txOpts, function(err, tx) { - should.not.exist(err); - should.exist(tx); - tx.amount.should.equal(helpers.toSatoshi(0.8)); - should.not.exist(tx.feePerKb); - tx.fee.should.equal(1000e2); - var t = tx.getBitcoreTx(); - t.getFee().should.equal(1000e2); - t.getChangeOutput().satoshis.should.equal(3e8 - 0.8e8 - 1000e2); - done(); - }); - }); - }); - }); - describe('Foreign ID', function() { - it('should create a tx with foreign ID', function(done) { - helpers.stubUtxos(server, wallet, 2, function() { + describe('Validations', function() { + it('should fail to create a tx without outputs', function(done) { + helpers.stubUtxos(server, wallet, [1, 2], function() { + var txOpts = { + outputs: [], + feePerKb: 123e2, + }; + server.createTx(txOpts, function(err, tx) { + should.exist(err); + should.not.exist(tx); + err.message.should.equal('No outputs were specified'); + done(); + }); + }); + }); + it('should fail to create tx for invalid address', function(done) { + helpers.stubUtxos(server, wallet, 1, function() { + var txOpts = { + outputs: [{ + toAddress: 'invalid address', + amount: 0.5e8 + }], + feePerKb: 100e2, + }; + server.createTx(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, 1, function() { + var txOpts = { + outputs: [{ + toAddress: 'myE38JHdxmQcTJGP1ZiX4BiGhDxMJDvLJD', + amount: 0.5e8 + }], + feePerKb: 100e2, + }; + server.createTx(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 = { - txProposalId: '123', outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 1e8, + toAddress: addressStr, + amount: 0, }], feePerKb: 100e2, }; server.createTx(txOpts, function(err, tx) { - should.not.exist(err); - should.exist(tx); - tx.id.should.equal('123'); + should.not.exist(tx); + should.exist(err); + err.message.should.equal('Invalid amount'); done(); }); }); - }); - it('should return already created tx if same foreign ID is specified and tx still unpublished', function(done) { - helpers.stubUtxos(server, wallet, 2, function() { - var txOpts = { - txProposalId: '123', - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 1e8, - }], - feePerKb: 100e2, - }; - server.createTx(txOpts, function(err, tx) { - should.not.exist(err); - should.exist(tx); - tx.id.should.equal('123'); - server.createTx(txOpts, function(err, tx) { + it('should fail to specify both feeLevel & feePerKb', function(done) { + helpers.stubUtxos(server, wallet, 2, function() { + var txOpts = { + outputs: [{ + toAddress: addressStr, + amount: 1e8, + }], + feeLevel: 'normal', + feePerKb: 123e2, + }; + server.createTx(txOpts, function(err, txp) { + should.exist(err); + should.not.exist(txp); + err.toString().should.contain('Only one of feeLevel/feePerKb'); + 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); - should.exist(tx); - tx.id.should.equal('123'); - server.storage.fetchTxs(wallet.id, {}, function(err, txs) { + var inputs = [utxos[0], utxos[2]]; + var txOpts = { + outputs: [{ + toAddress: addressStr, + amount: 2.5e8, + }], + feePerKb: 100e2, + inputs: inputs, + }; + server.createTx(txOpts, function(err, tx) { should.not.exist(err); - should.exist(txs); - txs.length.should.equal(1); + 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 return already published tx if same foreign ID is specified and tx already published', function(done) { - helpers.stubUtxos(server, wallet, [2, 2, 2], function() { - var txOpts = { - txProposalId: '123', - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 1e8, - }], - feePerKb: 100e2, - }; - server.createTx(txOpts, function(err, tx) { - should.not.exist(err); - should.exist(tx); - tx.id.should.equal('123'); - var publishOpts = helpers.getProposalSignatureOpts(tx, TestData.copayers[0].privKey_1H_0); - server.publishTx(publishOpts, function(err, tx) { + it('should be able to specify change address', function(done) { + helpers.stubUtxos(server, wallet, [1, 2], function(utxos) { + var txOpts = { + outputs: [{ + toAddress: addressStr, + amount: 0.8e8, + }], + feePerKb: 100e2, + changeAddress: utxos[0].address, + }; + server.createTx(txOpts, function(err, tx) { should.not.exist(err); should.exist(tx); + var t = tx.getBitcoreTx(); + t.getChangeOutput().script.toAddress().toString().should.equal(txOpts.changeAddress); + done(); + }); + }); + }); + it('should be able to specify inputs & absolute fee', function(done) { + helpers.stubUtxos(server, wallet, [1, 2], function(utxos) { + var txOpts = { + outputs: [{ + toAddress: addressStr, + amount: 0.8e8, + }], + inputs: utxos, + fee: 1000e2, + }; + server.createTx(txOpts, function(err, tx) { + should.not.exist(err); + should.exist(tx); + tx.amount.should.equal(helpers.toSatoshi(0.8)); + should.not.exist(tx.feePerKb); + tx.fee.should.equal(1000e2); + var t = tx.getBitcoreTx(); + t.getFee().should.equal(1000e2); + t.getChangeOutput().satoshis.should.equal(3e8 - 0.8e8 - 1000e2); + done(); + }); + }); + }); + }); + + describe('Foreign ID', function() { + it('should create a tx with foreign ID', function(done) { + helpers.stubUtxos(server, wallet, 2, function() { + var txOpts = { + txProposalId: '123', + outputs: [{ + toAddress: addressStr, + amount: 1e8, + }], + feePerKb: 100e2, + }; + server.createTx(txOpts, function(err, tx) { + should.not.exist(err); + should.exist(tx); + tx.id.should.equal('123'); + done(); + }); + }); + }); + it('should return already created tx if same foreign ID is specified and tx still unpublished', function(done) { + helpers.stubUtxos(server, wallet, 2, function() { + var txOpts = { + txProposalId: '123', + outputs: [{ + toAddress: addressStr, + amount: 1e8, + }], + feePerKb: 100e2, + }; + server.createTx(txOpts, function(err, tx) { + should.not.exist(err); + should.exist(tx); + tx.id.should.equal('123'); server.createTx(txOpts, function(err, tx) { should.not.exist(err); should.exist(tx); tx.id.should.equal('123'); - tx.status.should.equal('pending'); server.storage.fetchTxs(wallet.id, {}, function(err, txs) { should.not.exist(err); + should.exist(txs); txs.length.should.equal(1); done(); }); @@ -2794,93 +2785,582 @@ describe('Wallet service', function() { }); }); }); + it('should return already published tx if same foreign ID is specified and tx already published', function(done) { + helpers.stubUtxos(server, wallet, [2, 2, 2], function() { + var txOpts = { + txProposalId: '123', + outputs: [{ + toAddress: addressStr, + amount: 1e8, + }], + feePerKb: 100e2, + }; + server.createTx(txOpts, function(err, tx) { + should.not.exist(err); + should.exist(tx); + tx.id.should.equal('123'); + var publishOpts = helpers.getProposalSignatureOpts(tx, TestData.copayers[0].privKey_1H_0); + server.publishTx(publishOpts, function(err, tx) { + should.not.exist(err); + should.exist(tx); + server.createTx(txOpts, function(err, tx) { + should.not.exist(err); + should.exist(tx); + tx.id.should.equal('123'); + tx.status.should.equal('pending'); + server.storage.fetchTxs(wallet.id, {}, function(err, txs) { + should.not.exist(err); + txs.length.should.equal(1); + done(); + }); + }); + }); + }); + }); + }); }); - }); - describe('Publishing', function() { - it('should be able to publish a temporary tx proposal', function(done) { - helpers.stubUtxos(server, wallet, [1, 2], function() { - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 0.8 * 1e8, - }], - feePerKb: 100e2, - 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) { + describe('Publishing', function() { + it('should be able to publish a temporary tx proposal', function(done) { + helpers.stubUtxos(server, wallet, [1, 2], function() { + var txOpts = { + outputs: [{ + toAddress: addressStr, + amount: 0.8 * 1e8, + }], + feePerKb: 100e2, + message: 'some message', + customData: 'some custom data', + }; + server.createTx(txOpts, function(err, txp) { should.not.exist(err); - server.getPendingTxs({}, function(err, txs) { - should.not.exist(err); - txs.length.should.equal(1); - should.exist(txs[0].proposalSignature); - done(); - }); - }); - }); - }); - }); - it('should not be able to publish a temporary tx proposal created in a dry run', function(done) { - helpers.stubUtxos(server, wallet, [1, 2], function() { - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 0.8 * 1e8, - }], - feePerKb: 100e2, - dryRun: true, - }; - 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.exist(err); - err.code.should.equal('TX_NOT_FOUND'); - server.getPendingTxs({}, function(err, txs) { - should.not.exist(err); - txs.length.should.equal(0); - done(); - }); - }); - }); - }); - }); - it('should delay NewTxProposal notification until published', function(done) { - helpers.stubUtxos(server, wallet, [1, 2], function() { - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 0.8 * 1e8, - }], - feePerKb: 100e2, - message: 'some message', - }; - server.createTx(txOpts, function(err, txp) { - should.not.exist(err); - should.exist(txp); - server.getNotifications({}, function(err, notifications) { - should.not.exist(err); - _.pluck(notifications, 'type').should.not.contain('NewTxProposal'); + should.exist(txp); var publishOpts = helpers.getProposalSignatureOpts(txp, TestData.copayers[0].privKey_1H_0); server.publishTx(publishOpts, function(err) { should.not.exist(err); - server.getNotifications({}, function(err, notifications) { + server.getPendingTxs({}, function(err, txs) { should.not.exist(err); + txs.length.should.equal(1); + should.exist(txs[0].proposalSignature); + done(); + }); + }); + }); + }); + }); + it('should not be able to publish a temporary tx proposal created in a dry run', function(done) { + helpers.stubUtxos(server, wallet, [1, 2], function() { + var txOpts = { + outputs: [{ + toAddress: addressStr, + amount: 0.8 * 1e8, + }], + feePerKb: 100e2, + dryRun: true, + }; + 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.exist(err); + err.code.should.equal('TX_NOT_FOUND'); + server.getPendingTxs({}, function(err, txs) { + should.not.exist(err); + txs.length.should.equal(0); + done(); + }); + }); + }); + }); + }); + it('should delay NewTxProposal notification until published', function(done) { + helpers.stubUtxos(server, wallet, [1, 2], function() { + var txOpts = { + outputs: [{ + toAddress: addressStr, + amount: 0.8 * 1e8, + }], + feePerKb: 100e2, + message: 'some message', + }; + server.createTx(txOpts, function(err, txp) { + should.not.exist(err); + should.exist(txp); + server.getNotifications({}, function(err, notifications) { + should.not.exist(err); + _.pluck(notifications, 'type').should.not.contain('NewTxProposal'); + var publishOpts = helpers.getProposalSignatureOpts(txp, TestData.copayers[0].privKey_1H_0); + server.publishTx(publishOpts, function(err) { + should.not.exist(err); + server.getNotifications({}, function(err, notifications) { + should.not.exist(err); - var n = _.find(notifications, { - 'type': 'NewTxProposal' + var n = _.find(notifications, { + 'type': 'NewTxProposal' + }); + should.exist(n); + should.exist(n.data.txProposalId); + should.exist(n.data.message); + should.exist(n.data.creatorId); + n.data.creatorId.should.equal(server.copayerId); + done(); }); - should.exist(n); - should.exist(n.data.txProposalId); - should.exist(n.data.message); - should.exist(n.data.creatorId); - n.data.creatorId.should.equal(server.copayerId); + }); + }); + }); + }); + }); + it('should fail to publish 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 publish tx proposal with wrong signature', function(done) { + helpers.stubUtxos(server, wallet, [1, 2], function() { + var txOpts = { + outputs: [{ + toAddress: addressStr, + amount: 0.8 * 1e8, + }], + feePerKb: 100e2, + 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 publish tx proposal not signed by the creator', function(done) { + helpers.stubUtxos(server, wallet, [1, 2], function() { + var txOpts = { + outputs: [{ + toAddress: addressStr, + amount: 0.8 * 1e8, + }], + feePerKb: 100e2, + 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(), TestData.copayers[1].privKey_1H_0), + } + + server.publishTx(publishOpts, function(err) { + should.exist(err); + err.message.should.contain('Invalid proposal signature'); + done(); + }); + }); + }); + }); + it('should fail to publish a temporary tx proposal if utxos are locked by other pending proposals', function(done) { + var txp1, txp2; + var txOpts = { + outputs: [{ + toAddress: addressStr, + amount: 0.8 * 1e8, + }], + message: 'some message', + feePerKb: 100e2, + }; + + 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(txp, 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(txp, 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 publish a temporary tx proposal if utxos are already spent', function(done) { + var txp1, txp2; + var txOpts = { + outputs: [{ + toAddress: addressStr, + amount: 0.8 * 1e8, + }], + message: 'some message', + feePerKb: 100e2, + }; + + 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(txp, next) { + // Sign & Broadcast txp1 + 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); + + helpers.stubBroadcast(); + server.broadcastTx({ + txProposalId: txp.id + }, function(err, txp) { + should.not.exist(err); + should.exist(txp.txid); + txp.status.should.equal('broadcasted'); + 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(err) { + should.not.exist(err); + done(); + }); + }); + }); + + describe('Fee levels', function() { + it('should create a tx specifying feeLevel', function(done) { + helpers.stubFeeLevels({ + 1: 400e2, + 2: 200e2, + 6: 180e2, + 24: 90e2, + }); + helpers.stubUtxos(server, wallet, 2, function() { + var txOpts = { + outputs: [{ + toAddress: addressStr, + amount: 1e8, + }], + feeLevel: 'economy', + }; + server.createTx(txOpts, function(err, txp) { + should.not.exist(err); + should.exist(txp); + txp.feePerKb.should.equal(180e2); + txp.feeLevel.should.equal('economy'); + done(); + }); + }); + }); + it('should fail if the specified fee level does not exist', function(done) { + helpers.stubUtxos(server, wallet, 2, function() { + var txOpts = { + outputs: [{ + toAddress: addressStr, + amount: 1e8, + }], + feeLevel: 'madeUpLevel', + }; + server.createTx(txOpts, function(err, txp) { + should.exist(err); + should.not.exist(txp); + err.toString().should.contain('Invalid fee level'); + done(); + }); + }); + }); + it('should assume "normal" fee level if no feeLevel and no feePerKb/fee is specified', function(done) { + helpers.stubFeeLevels({ + 1: 400e2, + 2: 200e2, + 6: 180e2, + 24: 90e2, + }); + helpers.stubUtxos(server, wallet, 2, function() { + var txOpts = { + outputs: [{ + toAddress: addressStr, + amount: 1e8, + }], + }; + server.createTx(txOpts, function(err, txp) { + should.not.exist(err); + should.exist(txp); + txp.feePerKb.should.equal(200e2); + txp.feeLevel.should.equal('normal'); + done(); + }); + }); + }); + }); + it('should generate new change address for each created tx', function(done) { + helpers.stubUtxos(server, wallet, [1, 2], function() { + var txOpts = { + outputs: [{ + toAddress: addressStr, + amount: 0.8e8, + }], + feePerKb: 100e2, + }; + server.createTx(txOpts, function(err, tx1) { + should.not.exist(err); + should.exist(tx1); + server.createTx(txOpts, function(err, tx2) { + should.not.exist(err); + should.exist(tx2); + tx1.changeAddress.address.should.not.equal(tx2.changeAddress.address); + done(); + }); + }); + }); + }); + it('should support creating a tx with no change address', function(done) { + helpers.stubUtxos(server, wallet, [1, 2], function() { + var max = 3e8 - 7000; // Fees for this tx at 100bits/kB = 7000 sat + var txOpts = { + outputs: [{ + toAddress: addressStr, + amount: max, + }], + feePerKb: 100e2, + }; + server.createTx(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); + done(); + }); + }); + }); + it('should fail gracefully if unable to reach the blockchain', function(done) { + blockchainExplorer.getUtxos = sinon.stub().callsArgWith(1, 'dummy error'); + server.createAddress({}, function(err, address) { + should.not.exist(err); + var txOpts = { + outputs: [{ + toAddress: addressStr, + amount: 1e8 + }], + feePerKb: 100e2, + }; + server.createTx(txOpts, function(err, tx) { + should.exist(err); + err.toString().should.equal('dummy error'); + done(); + }); + }); + }); + it('should fail gracefully when bitcore throws exception on raw tx creation', function(done) { + helpers.stubUtxos(server, wallet, 1, function() { + var bitcoreStub = sinon.stub(Bitcore_[coin], 'Transaction'); + bitcoreStub.throws({ + name: 'dummy', + message: 'dummy exception' + }); + var txOpts = { + outputs: [{ + toAddress: addressStr, + amount: 0.5e8, + }], + feePerKb: 100e2, + }; + server.createTx(txOpts, function(err, tx) { + should.exist(err); + err.message.should.equal('dummy exception'); + bitcoreStub.restore(); + done(); + }); + }); + }); + it('should fail to create a tx exceeding max size in kb', function(done) { + var _oldDefault = Defaults.MAX_TX_SIZE_IN_KB; + Defaults.MAX_TX_SIZE_IN_KB = 1; + helpers.stubUtxos(server, wallet, _.range(1, 10, 0), function() { + var txOpts = { + outputs: [{ + toAddress: addressStr, + amount: 8e8, + }], + feePerKb: 100e2, + }; + server.createTx(txOpts, function(err, tx) { + should.exist(err); + err.code.should.equal('TX_MAX_SIZE_EXCEEDED'); + Defaults.MAX_TX_SIZE_IN_KB = _oldDefault; + done(); + }); + }); + }); + it('should fail with different error for insufficient funds and locked funds', function(done) { + helpers.stubUtxos(server, wallet, [1, 1], function() { + var txOpts = { + outputs: [{ + toAddress: addressStr, + amount: 1.1e8, + }], + feePerKb: 100e2, + }; + helpers.createAndPublishTx(server, txOpts, TestData.copayers[0].privKey_1H_0, function(tx) { + server.getBalance({}, function(err, balance) { + should.not.exist(err); + balance.totalAmount.should.equal(2e8); + balance.lockedAmount.should.equal(2e8); + txOpts.outputs[0].amount = 0.8e8; + server.createTx(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 fail to create tx for dust amount in outputs', function(done) { + helpers.stubUtxos(server, wallet, 1, function() { + var txOpts = { + outputs: [{ + toAddress: addressStr, + amount: 20e2, + }], + feePerKb: 100e2, + }; + server.createTx(txOpts, function(err, tx) { + should.exist(err); + err.code.should.equal('DUST_AMOUNT'); + err.message.should.equal('Amount below dust threshold'); + done(); + }); + }); + }); + it('should create tx with 0 change output', function(done) { + helpers.stubUtxos(server, wallet, 1, function() { + var fee = 4100; // The exact fee of the resulting tx + var amount = 1e8 - fee; + + var txOpts = { + outputs: [{ + toAddress: addressStr, + amount: amount, + }], + feePerKb: 100e2, + }; + server.createTx(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 create tx when there is a pending tx and enough UTXOs', function(done) { + helpers.stubUtxos(server, wallet, [1.1, 1.2, 1.3], function() { + var txOpts = { + outputs: [{ + toAddress: addressStr, + amount: 1.5e8, + }], + feePerKb: 100e2, + }; + helpers.createAndPublishTx(server, txOpts, TestData.copayers[0].privKey_1H_0, function(tx) { + should.exist(tx); + txOpts.outputs[0].amount = 0.8e8; + helpers.createAndPublishTx(server, txOpts, TestData.copayers[0].privKey_1H_0, function(tx) { + 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(3.6e8); + balance.lockedAmount.should.equal(3.6e8); done(); }); }); @@ -2888,603 +3368,147 @@ describe('Wallet service', function() { }); }); }); - it('should fail to publish 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 publish tx proposal with wrong signature', function(done) { - helpers.stubUtxos(server, wallet, [1, 2], function() { + it('should fail to create tx when there is a pending tx and not enough UTXOs', function(done) { + helpers.stubUtxos(server, wallet, [1.1, 1.2, 1.3], function() { var txOpts = { outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 0.8 * 1e8, + toAddress: addressStr, + amount: 1.5e8, }], feePerKb: 100e2, - 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 publish tx proposal not signed by the creator', function(done) { - helpers.stubUtxos(server, wallet, [1, 2], function() { - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 0.8 * 1e8, - }], - feePerKb: 100e2, - 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(), TestData.copayers[1].privKey_1H_0), - } - - server.publishTx(publishOpts, function(err) { - should.exist(err); - err.message.should.contain('Invalid proposal signature'); - done(); - }); - }); - }); - }); - it('should fail to publish a temporary tx proposal if utxos are locked by other pending proposals', function(done) { - var txp1, txp2; - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 0.8 * 1e8, - }], - message: 'some message', - feePerKb: 100e2, - }; - - 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(txp, 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(txp, 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 publish a temporary tx proposal if utxos are already spent', function(done) { - var txp1, txp2; - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 0.8 * 1e8, - }], - message: 'some message', - feePerKb: 100e2, - }; - - 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(txp, next) { - // Sign & Broadcast txp1 - 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); - - helpers.stubBroadcast(); - server.broadcastTx({ - txProposalId: txp.id - }, function(err, txp) { - should.not.exist(err); - should.exist(txp.txid); - txp.status.should.equal('broadcasted'); - 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(err) { - should.not.exist(err); - done(); - }); - }); - }); - - describe('Fee levels', function() { - it('should create a tx specifying feeLevel', function(done) { - helpers.stubFeeLevels({ - 1: 400e2, - 2: 200e2, - 6: 180e2, - 24: 90e2, - }); - helpers.stubUtxos(server, wallet, 2, function() { - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 1e8, - }], - feeLevel: 'economy', - }; - server.createTx(txOpts, function(err, txp) { - should.not.exist(err); - should.exist(txp); - txp.feePerKb.should.equal(180e2); - txp.feeLevel.should.equal('economy'); - done(); - }); - }); - }); - it('should fail if the specified fee level does not exist', function(done) { - helpers.stubUtxos(server, wallet, 2, function() { - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 1e8, - }], - feeLevel: 'madeUpLevel', - }; - server.createTx(txOpts, function(err, txp) { - should.exist(err); - should.not.exist(txp); - err.toString().should.contain('Invalid fee level'); - done(); - }); - }); - }); - it('should assume "normal" fee level if no feeLevel and no feePerKb/fee is specified', function(done) { - helpers.stubFeeLevels({ - 1: 400e2, - 2: 200e2, - 6: 180e2, - 24: 90e2, - }); - helpers.stubUtxos(server, wallet, 2, function() { - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 1e8, - }], - }; - server.createTx(txOpts, function(err, txp) { - should.not.exist(err); - should.exist(txp); - txp.feePerKb.should.equal(200e2); - txp.feeLevel.should.equal('normal'); - done(); - }); - }); - }); - }); - it('should generate new change address for each created tx', function(done) { - helpers.stubUtxos(server, wallet, [1, 2], function() { - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 0.8e8, - }], - feePerKb: 100e2, - }; - server.createTx(txOpts, function(err, tx1) { - should.not.exist(err); - should.exist(tx1); - server.createTx(txOpts, function(err, tx2) { - should.not.exist(err); - should.exist(tx2); - tx1.changeAddress.address.should.not.equal(tx2.changeAddress.address); - done(); - }); - }); - }); - }); - it('should support creating a tx with no change address', function(done) { - helpers.stubUtxos(server, wallet, [1, 2], function() { - var max = 3e8 - 7000; // Fees for this tx at 100bits/kB = 7000 sat - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: max, - }], - feePerKb: 100e2, - }; - server.createTx(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); - done(); - }); - }); - }); - it('should fail gracefully if unable to reach the blockchain', function(done) { - blockchainExplorer.getUtxos = sinon.stub().callsArgWith(1, 'dummy error'); - server.createAddress({}, function(err, address) { - should.not.exist(err); - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 1e8 - }], - feePerKb: 100e2, - }; - server.createTx(txOpts, function(err, tx) { - should.exist(err); - err.toString().should.equal('dummy error'); - done(); - }); - }); - }); - it('should fail gracefully when bitcore throws exception on raw tx creation', function(done) { - helpers.stubUtxos(server, wallet, 1, function() { - var bitcoreStub = sinon.stub(Bitcore, 'Transaction'); - bitcoreStub.throws({ - name: 'dummy', - message: 'dummy exception' - }); - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 0.5e8, - }], - feePerKb: 100e2, - }; - server.createTx(txOpts, function(err, tx) { - should.exist(err); - err.message.should.equal('dummy exception'); - bitcoreStub.restore(); - done(); - }); - }); - }); - it('should fail to create a tx exceeding max size in kb', function(done) { - var _oldDefault = Defaults.MAX_TX_SIZE_IN_KB; - Defaults.MAX_TX_SIZE_IN_KB = 1; - helpers.stubUtxos(server, wallet, _.range(1, 10, 0), function() { - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 8e8, - }], - feePerKb: 100e2, - }; - server.createTx(txOpts, function(err, tx) { - should.exist(err); - err.code.should.equal('TX_MAX_SIZE_EXCEEDED'); - Defaults.MAX_TX_SIZE_IN_KB = _oldDefault; - done(); - }); - }); - }); - it('should fail with different error for insufficient funds and locked funds', function(done) { - helpers.stubUtxos(server, wallet, [1, 1], function() { - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 1.1e8, - }], - feePerKb: 100e2, - }; - helpers.createAndPublishTx(server, txOpts, TestData.copayers[0].privKey_1H_0, function(tx) { - server.getBalance({}, function(err, balance) { - should.not.exist(err); - balance.totalAmount.should.equal(2e8); - balance.lockedAmount.should.equal(2e8); - txOpts.outputs[0].amount = 0.8e8; - server.createTx(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 fail to create tx for dust amount in outputs', function(done) { - helpers.stubUtxos(server, wallet, 1, function() { - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 20e2, - }], - feePerKb: 100e2, - }; - server.createTx(txOpts, function(err, tx) { - should.exist(err); - err.code.should.equal('DUST_AMOUNT'); - err.message.should.equal('Amount below dust threshold'); - done(); - }); - }); - }); - it('should create tx with 0 change output', function(done) { - helpers.stubUtxos(server, wallet, 1, function() { - var fee = 4100; // The exact fee of the resulting tx - var amount = 1e8 - fee; - - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: amount, - }], - feePerKb: 100e2, - }; - server.createTx(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 create tx when there is a pending tx and enough UTXOs', function(done) { - helpers.stubUtxos(server, wallet, [1.1, 1.2, 1.3], function() { - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 1.5e8, - }], - feePerKb: 100e2, - }; - helpers.createAndPublishTx(server, txOpts, TestData.copayers[0].privKey_1H_0, function(tx) { - should.exist(tx); - txOpts.outputs[0].amount = 0.8e8; helpers.createAndPublishTx(server, txOpts, TestData.copayers[0].privKey_1H_0, function(tx) { should.exist(tx); - server.getPendingTxs({}, function(err, txs) { - should.not.exist(err); - txs.length.should.equal(2); - server.getBalance({}, function(err, balance) { + txOpts.outputs[0].amount = 1.8e8; + server.createTx(txOpts, function(err, tx) { + err.code.should.equal('LOCKED_FUNDS'); + should.not.exist(tx); + server.getPendingTxs({}, function(err, txs) { should.not.exist(err); - balance.totalAmount.should.equal(3.6e8); - balance.lockedAmount.should.equal(3.6e8); - done(); + txs.length.should.equal(1); + server.getBalance({}, function(err, balance) { + should.not.exist(err); + balance.totalAmount.should.equal(3.6e8); + 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 fail to create tx when there is a pending tx and not enough UTXOs', function(done) { - helpers.stubUtxos(server, wallet, [1.1, 1.2, 1.3], function() { - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 1.5e8, - }], - feePerKb: 100e2, + it('should accept a tx proposal signed with a custom key', function(done) { + var reqPrivKey = new Bitcore.PrivateKey(); + var reqPubKey = reqPrivKey.toPublicKey().toString(); + + var xPrivKey = TestData.copayers[0].xPrivKey_44H_0H_0H; + + var accessOpts = { + copayerId: TestData.copayers[0].id44btc, + requestPubKey: reqPubKey, + signature: helpers.signRequestPubKey(reqPubKey, xPrivKey), }; - helpers.createAndPublishTx(server, txOpts, TestData.copayers[0].privKey_1H_0, function(tx) { - should.exist(tx); - txOpts.outputs[0].amount = 1.8e8; - server.createTx(txOpts, function(err, tx) { - err.code.should.equal('LOCKED_FUNDS'); - should.not.exist(tx); - server.getPendingTxs({}, function(err, txs) { + + server.addAccess(accessOpts, function(err) { + should.not.exist(err); + + helpers.stubUtxos(server, wallet, [1, 2], function() { + var txOpts = { + outputs: [{ + toAddress: addressStr, + amount: 0.8 * 1e8, + }], + message: 'some message', + feePerKb: 100e2, + }; + server.createTx(txOpts, function(err, txp) { should.not.exist(err); - txs.length.should.equal(1); - server.getBalance({}, function(err, balance) { + should.exist(txp); + + var publishOpts = { + txProposalId: txp.id, + proposalSignature: helpers.signMessage(txp.getRawTx(), reqPrivKey), + } + + server.publishTx(publishOpts, function(err) { should.not.exist(err); - balance.totalAmount.should.equal(3.6e8); - 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(); + server.getTx({ + txProposalId: txp.id + }, function(err, x) { + should.not.exist(err); + x.proposalSignature.should.equal(publishOpts.proposalSignature); + x.proposalSignaturePubKey.should.equal(accessOpts.requestPubKey); + x.proposalSignaturePubKeySig.should.equal(accessOpts.signature); + done(); + }); }); }); }); }); }); - }); - it('should accept a tx proposal signed with a custom key', function(done) { - var reqPrivKey = new Bitcore.PrivateKey(); - var reqPubKey = reqPrivKey.toPublicKey().toString(); - - var xPrivKey = TestData.copayers[0].xPrivKey_44H_0H_0H; - - var accessOpts = { - copayerId: TestData.copayers[0].id44btc, - requestPubKey: reqPubKey, - signature: helpers.signRequestPubKey(reqPubKey, xPrivKey), - }; - - server.addAccess(accessOpts, function(err) { - should.not.exist(err); - + it('should be able to send max funds', function(done) { helpers.stubUtxos(server, wallet, [1, 2], function() { var txOpts = { outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: 0.8 * 1e8, + toAddress: addressStr, + amount: null, }], - message: 'some message', - feePerKb: 100e2, + feePerKb: 10000, + sendMax: true, + }; + server.createTx(txOpts, function(err, tx) { + should.not.exist(err); + should.exist(tx); + should.not.exist(tx.changeAddress); + tx.amount.should.equal(3e8 - tx.fee); + + var t = tx.getBitcoreTx(); + t.getFee().should.equal(tx.fee); + should.not.exist(t.getChangeOutput()); + t.toObject().inputs.length.should.equal(tx.inputs.length); + t.toObject().outputs[0].satoshis.should.equal(tx.amount); + done(); + }); + }); + }); + it('should shuffle outputs unless specified', function(done) { + helpers.stubUtxos(server, wallet, 1, function() { + var txOpts = { + outputs: _.times(30, function(i) { + return { + toAddress: addressStr, + amount: (i + 1) * 100e2, + }; + }), + feePerKb: 123e2, }; server.createTx(txOpts, function(err, txp) { should.not.exist(err); should.exist(txp); + var t = txp.getBitcoreTx(); + var changeOutput = t.getChangeOutput().satoshis; + var outputs = _.without(_.pluck(t.outputs, 'satoshis'), changeOutput); - var publishOpts = { - txProposalId: txp.id, - proposalSignature: helpers.signMessage(txp.getRawTx(), reqPrivKey), - } - - server.publishTx(publishOpts, function(err) { + outputs.should.not.deep.equal(_.pluck(txOpts.outputs, 'amount')); + txOpts.noShuffleOutputs = true; + server.createTx(txOpts, function(err, txp) { should.not.exist(err); - server.getTx({ - txProposalId: txp.id - }, function(err, x) { - should.not.exist(err); - x.proposalSignature.should.equal(publishOpts.proposalSignature); - x.proposalSignaturePubKey.should.equal(accessOpts.requestPubKey); - x.proposalSignaturePubKeySig.should.equal(accessOpts.signature); - done(); - }); + should.exist(txp); + + t = txp.getBitcoreTx(); + changeOutput = t.getChangeOutput().satoshis; + outputs = _.without(_.pluck(t.outputs, 'satoshis'), changeOutput); + + outputs.should.deep.equal(_.pluck(txOpts.outputs, 'amount')); + done(); }); }); }); }); }); - it('should be able to send max funds', function(done) { - helpers.stubUtxos(server, wallet, [1, 2], function() { - var txOpts = { - outputs: [{ - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: null, - }], - feePerKb: 10000, - sendMax: true, - }; - server.createTx(txOpts, function(err, tx) { - should.not.exist(err); - should.exist(tx); - should.not.exist(tx.changeAddress); - tx.amount.should.equal(3e8 - tx.fee); - - var t = tx.getBitcoreTx(); - t.getFee().should.equal(tx.fee); - should.not.exist(t.getChangeOutput()); - t.toObject().inputs.length.should.equal(tx.inputs.length); - t.toObject().outputs[0].satoshis.should.equal(tx.amount); - done(); - }); - }); - }); - it('should shuffle outputs unless specified', function(done) { - helpers.stubUtxos(server, wallet, 1, function() { - var txOpts = { - outputs: _.times(30, function(i) { - return { - toAddress: '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', - amount: (i + 1) * 100e2, - }; - }), - feePerKb: 123e2, - }; - server.createTx(txOpts, function(err, txp) { - should.not.exist(err); - should.exist(txp); - var t = txp.getBitcoreTx(); - var changeOutput = t.getChangeOutput().satoshis; - var outputs = _.without(_.pluck(t.outputs, 'satoshis'), changeOutput); - - outputs.should.not.deep.equal(_.pluck(txOpts.outputs, 'amount')); - txOpts.noShuffleOutputs = true; - server.createTx(txOpts, function(err, txp) { - should.not.exist(err); - should.exist(txp); - - t = txp.getBitcoreTx(); - changeOutput = t.getChangeOutput().satoshis; - outputs = _.without(_.pluck(t.outputs, 'satoshis'), changeOutput); - - outputs.should.deep.equal(_.pluck(txOpts.outputs, 'amount')); - done(); - }); - }); - }); - }); }); describe('Backoff time', function(done) { From a9e56dd3be22af5113d3f6bda184526c9d3b2ea7 Mon Sep 17 00:00:00 2001 From: matiu Date: Thu, 14 Sep 2017 00:49:44 -0300 Subject: [PATCH 4/5] fix getUtxos + tests --- lib/server.js | 9 +++++---- test/integration/server.js | 20 +++++++++++++++----- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/lib/server.js b/lib/server.js index af836a4..cbbd951 100644 --- a/lib/server.js +++ b/lib/server.js @@ -1213,16 +1213,17 @@ WalletService.prototype.getUtxos = function(opts, cb) { opts = opts || {}; - opts.coin = opts.coin || Defaults.COIN; - if (!Utils.checkValueInCollection(opts.coin, Constants.COINS)) - return cb(new ClientError('Invalid coin')); + if (opts.coin) { + if (!Utils.checkValueInCollection(opts.coin, Constants.COINS)) + return cb(new ClientError('Invalid coin')); + } if (_.isUndefined(opts.addresses)) { self._getUtxosForCurrentWallet({ coin: opts.coin }, cb); } else { - self._getUtxos(opts.coin, opts.addresses, cb); + self._getUtxos(Utils.getAddressCoin(opts.addresses[0]), opts.addresses, cb); } }; diff --git a/test/integration/server.js b/test/integration/server.js index 17278ae..918988a 100644 --- a/test/integration/server.js +++ b/test/integration/server.js @@ -2529,12 +2529,18 @@ describe('Wallet service', function() { bch: 'CPrtPWbp8cCftTQu5fzuLG5zPJNDHMMf8X', } + var idKeyMap = { + btc: 'id44btc', + bch: 'id44bch', + }; + _.each(['bch', 'btc'], function(coin) { describe('#createTx ' + coin, function() { - var addressStr; + var addressStr, idKey; before(function() { addressStr = addrMap[coin]; + idKey = idKeyMap[coin]; }); @@ -3112,6 +3118,9 @@ describe('Wallet service', function() { describe('Fee levels', function() { it('should create a tx specifying feeLevel', function(done) { + //ToDo + var level = wallet.coin == 'btc' ? 'economy' : 'normal'; + var expected = wallet.coin == 'btc' ? 180e2 : 200e2; helpers.stubFeeLevels({ 1: 400e2, 2: 200e2, @@ -3124,13 +3133,14 @@ describe('Wallet service', function() { toAddress: addressStr, amount: 1e8, }], - feeLevel: 'economy', + // ToDo + feeLevel: level, }; server.createTx(txOpts, function(err, txp) { should.not.exist(err); should.exist(txp); - txp.feePerKb.should.equal(180e2); - txp.feeLevel.should.equal('economy'); + txp.feePerKb.should.equal(expected); + txp.feeLevel.should.equal(level); done(); }); }); @@ -3407,7 +3417,7 @@ describe('Wallet service', function() { var xPrivKey = TestData.copayers[0].xPrivKey_44H_0H_0H; var accessOpts = { - copayerId: TestData.copayers[0].id44btc, + copayerId: TestData.copayers[0][idKey], requestPubKey: reqPubKey, signature: helpers.signRequestPubKey(reqPubKey, xPrivKey), }; From 357419b53c7ef83ea7b33958961ea8c4e8ad6a5f Mon Sep 17 00:00:00 2001 From: matiu Date: Thu, 14 Sep 2017 15:45:50 -0300 Subject: [PATCH 5/5] bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c8d1d4b..198b6b6 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "bitcore-wallet-service", "description": "A service for Mutisig HD Bitcoin Wallets", "author": "BitPay Inc", - "version": "2.0.0", + "version": "2.1.0", "licence": "MIT", "keywords": [ "bitcoin",