diff --git a/lib/model/wallet.js b/lib/model/wallet.js index 99d5667..e3baf21 100644 --- a/lib/model/wallet.js +++ b/lib/model/wallet.js @@ -27,6 +27,7 @@ Wallet.create = function(opts) { x.name = opts.name; x.m = opts.m; x.n = opts.n; + x.singleAddress = !!opts.singleAddress; x.status = 'pending'; x.publicKeyRing = []; x.addressIndex = 0; @@ -56,6 +57,7 @@ Wallet.fromObj = function(obj) { x.name = obj.name; x.m = obj.m; x.n = obj.n; + x.singleAddress = !!obj.singleAddress; x.status = obj.status; x.publicKeyRing = obj.publicKeyRing; x.copayers = _.map(obj.copayers, function(copayer) { diff --git a/lib/server.js b/lib/server.js index 8c3ccd2..c3bade5 100644 --- a/lib/server.js +++ b/lib/server.js @@ -62,7 +62,8 @@ function WalletService() { function checkRequired(obj, args, cb) { var missing = Utils.getMissingFields(obj, args); if (_.isEmpty(missing)) return true; - cb(new ClientError('Required argument ' + _.first(missing) + ' missing.')); + if (_.isFunction(cb)) + cb(new ClientError('Required argument ' + _.first(missing) + ' missing.')); return false; }; @@ -215,8 +216,9 @@ WalletService.prototype._runLocked = function(cb, task) { * @param {number} opts.m - Required copayers. * @param {number} opts.n - Total copayers. * @param {string} opts.pubKey - Public key to verify copayers joining have access to the wallet secret. - * @param {string} [opts.network = 'livenet'] - The Bitcoin network for this wallet. - * @param {string} [opts.supportBIP44AndP2PKH = true] - Client supports BIP44 & P2PKH for new wallets. + * @param {string} opts.singleAddress[=false] - The wallet will only ever have one address. + * @param {string} opts.network[='livenet'] - The Bitcoin network for this wallet. + * @param {string} opts.supportBIP44AndP2PKH[=true] - Client supports BIP44 & P2PKH for new wallets. */ WalletService.prototype.createWallet = function(opts, cb) { var self = this, @@ -263,6 +265,7 @@ WalletService.prototype.createWallet = function(opts, cb) { n: opts.n, network: opts.network, pubKey: pubKey.toString(), + singleAddress: !!opts.singleAddress, derivationStrategy: derivationStrategy, addressType: addressType, }); @@ -784,27 +787,40 @@ WalletService.prototype.createAddress = function(opts, cb) { opts = opts || {}; + function createNewAddress(wallet, cb) { + self._canCreateAddress(opts.ignoreMaxGap, function(err, canCreate) { + if (err) return cb(err); + if (!canCreate) return cb(Errors.MAIN_ADDRESS_GAP_REACHED); + + var address = wallet.createAddress(false); + + self.storage.storeAddressAndWallet(wallet, address, function(err) { + if (err) return cb(err); + + self._notify('NewAddress', { + address: address.address, + }, function() { + return cb(null, address); + }); + }); + }); + }; + + function getFirstAddress(wallet, cb) { + self.storage.fetchAddresses(self.walletId, function(err, addresses) { + if (err) return cb(err); + if (!_.isEmpty(addresses)) return cb(null, _.first(addresses)) + return createNewAddress(wallet, cb); + }); + }; + self._runLocked(cb, function(cb) { self.getWallet({}, function(err, wallet) { if (err) return cb(err); if (!wallet.isComplete()) return cb(Errors.WALLET_NOT_COMPLETE); - self._canCreateAddress(opts.ignoreMaxGap, function(err, canCreate) { - if (err) return cb(err); - if (!canCreate) return cb(Errors.MAIN_ADDRESS_GAP_REACHED); - - var address = wallet.createAddress(false); - - self.storage.storeAddressAndWallet(wallet, address, function(err) { - if (err) return cb(err); - - self._notify('NewAddress', { - address: address.address, - }, function() { - return cb(null, address); - }); - }); - }); + var createFn = wallet.singleAddress ? getFirstAddress : createNewAddress; + return createFn(wallet, cb); }); }); }; @@ -1669,7 +1685,9 @@ WalletService.prototype._validateOutputs = function(opts, wallet, cb) { var output = opts.outputs[i]; output.valid = false; - if (!checkRequired(output, ['toAddress', 'amount'], cb)) return; + if (!checkRequired(output, ['toAddress', 'amount'])) { + return new ClientError('Argument missing in output #' + (i + 1) + '.'); + } var toAddress = {}; try { @@ -1744,6 +1762,8 @@ WalletService.prototype.createTxLegacy = function(opts, cb) { if (err) return cb(err); if (!wallet.isComplete()) return cb(Errors.WALLET_NOT_COMPLETE); + if (wallet.singleAddress) return cb(new ClientError('Not compatible with single-address wallets')); + if (opts.payProUrl) { if (wallet.addressType == Constants.SCRIPT_TYPES.P2PKH && !self._clientSupportsPayProRefund()) { return cb(new ClientError(Errors.codes.UPGRADE_NEEDED, 'To sign this spend proposal you need to upgrade your client app.')); @@ -1858,6 +1878,10 @@ WalletService.prototype._validateAndSanitizeTxOpts = function(wallet, opts, cb) } next(); }, + function(next) { + if (wallet.singleAddress && opts.changeAddress) return next(new ClientError('Cannot specify change address on single-address wallet')); + next(); + }, function(next) { if (!opts.sendMax) return next(); if (!_.isArray(opts.outputs) || opts.outputs.length > 1) { @@ -1899,7 +1923,7 @@ WalletService.prototype._validateAndSanitizeTxOpts = function(wallet, opts, cb) * @param {string} opts.outputs[].message - A message to attach to this output. * @param {string} opts.message - A message to attach to this transaction. * @param {number} opts.feePerKb - Use an alternative fee per KB for this TX. - * @param {string} opts.changeAddress - Optional. Use this address as the change address for the tx. The address should belong to the wallet. + * @param {string} opts.changeAddress - Optional. Use this address as the change address for the tx. The address should belong to the wallet. In the case of singleAddress wallets, the first main address will be used. * @param {Boolean} opts.sendMax - Optional. Send maximum amount of funds that make sense under the specified fee/feePerKb conditions. (defaults to false). * @param {string} opts.payProUrl - Optional. Paypro URL for peers to verify TX * @param {Boolean} opts.excludeUnconfirmedUtxos[=false] - Optional. Do not use UTXOs of unconfirmed transactions as inputs @@ -1913,6 +1937,25 @@ WalletService.prototype._validateAndSanitizeTxOpts = function(wallet, opts, cb) WalletService.prototype.createTx = function(opts, cb) { var self = this; + function getChangeAddress(wallet, cb) { + if (wallet.singleAddress) { + self.storage.fetchAddresses(self.walletId, function(err, addresses) { + if (err) return cb(err); + if (_.isEmpty(addresses)) return cb(new ClientError('The wallet has no addresses')); + return cb(null, _.first(addresses)); + }); + } else { + if (opts.changeAddress) { + self.storage.fetchAddress(opts.changeAddress, function(err, address) { + if (err) return cb(Errors.INVALID_CHANGE_ADDRESS); + return cb(null, address); + }); + } else { + return cb(null, wallet.createAddress(true)); + } + } + }; + self._runLocked(cb, function(cb) { var wallet, txp, changeAddress; async.series([ @@ -1937,16 +1980,11 @@ WalletService.prototype.createTx = function(opts, cb) { }, function(next) { if (opts.sendMax) return next(); - if (opts.changeAddress) { - self.storage.fetchAddress(opts.changeAddress, function(err, address) { - if (err) return next(Errors.INVALID_CHANGE_ADDRESS); - changeAddress = address; - return next(); - }); - } else { - changeAddress = wallet.createAddress(true); - return next(); - } + getChangeAddress(wallet, function(err, address) { + if (err) return next(err); + changeAddress = address; + next(); + }); }, function(next) { var txOpts = { diff --git a/package.json b/package.json index 6d21233..60b0e0b 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": "1.8.2", + "version": "1.9.0", "keywords": [ "bitcoin", "copay", diff --git a/test/integration/helpers.js b/test/integration/helpers.js index e53d38b..f0d0cac 100644 --- a/test/integration/helpers.js +++ b/test/integration/helpers.js @@ -164,6 +164,7 @@ helpers.createAndJoinWallet = function(m, n, opts, cb) { m: m, n: n, pubKey: TestData.keyPair.pub, + singleAddress: !!opts.singleAddress, }; if (_.isBoolean(opts.supportBIP44AndP2PKH)) walletOpts.supportBIP44AndP2PKH = opts.supportBIP44AndP2PKH; diff --git a/test/integration/server.js b/test/integration/server.js index 57b5f9e..a9de253 100644 --- a/test/integration/server.js +++ b/test/integration/server.js @@ -2793,7 +2793,7 @@ describe('Wallet service', function() { }); server.createTxLegacy(txOpts, function(err, tx) { should.exist(err); - err.message.should.contain('outputs argument missing'); + err.message.should.contain('Argument missing in output #1.'); done(); }); }); @@ -3707,7 +3707,7 @@ describe('Wallet service', function() { should.not.exist(err); should.exist(txp); txp.inputs.length.should.equal(3); - txOpts.feePerKb = 120e2; + txOpts.feePerKb = 160e2; server.createTx(txOpts, function(err, txp) { should.exist(err); should.not.exist(txp); @@ -4209,6 +4209,95 @@ describe('Wallet service', function() { }); }); + describe('Single-address wallet', function() { + var server, wallet, firstAddress; + beforeEach(function(done) { + helpers.createAndJoinWallet(1, 2, { + singleAddress: true, + }, function(s, w) { + server = s; + wallet = w; + server.createAddress({}, function(err, a) { + should.not.exist(err); + should.exist(a.address); + firstAddress = a; + done(); + }); + }); + }); + it('should include singleAddress property', function(done) { + server.getWallet({}, function(err, wallet) { + should.not.exist(err); + wallet.singleAddress.should.be.true; + done(); + }); + }); + it('should always return same address', function(done) { + firstAddress.path.should.equal('m/0/0'); + server.createAddress({}, function(err, x) { + should.not.exist(err); + should.exist(x); + x.path.should.equal('m/0/0'); + x.address.should.equal(firstAddress.address); + server.getMainAddresses({}, function(err, addr) { + should.not.exist(err); + addr.length.should.equal(1); + done(); + }); + }); + }); + it('should reuse address as change address on tx proposal creation', function(done) { + helpers.stubUtxos(server, wallet, 2, function() { + var toAddress = '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7'; + var opts = { + outputs: [{ + amount: 1e8, + toAddress: toAddress, + }], + feePerKb: 100e2, + }; + server.createTx(opts, function(err, txp) { + should.not.exist(err); + should.exist(txp); + should.exist(txp.changeAddress); + txp.changeAddress.address.should.equal(firstAddress.address); + txp.changeAddress.path.should.equal(firstAddress.path); + done(); + }); + }); + }); + it('should not allow legacy txs', function(done) { + helpers.stubUtxos(server, wallet, 2, function() { + var toAddress = '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7'; + var txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 1, TestData.copayers[0].privKey_1H_0); + server.createTxLegacy(txOpts, function(err, tx) { + should.exist(err); + err.message.should.contain('single-address'); + done(); + }); + }); + }); + it('should not be able to specify custom changeAddress', function(done) { + helpers.stubUtxos(server, wallet, 2, function() { + var toAddress = '18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7'; + var opts = { + outputs: [{ + amount: 1e8, + toAddress: toAddress, + }], + feePerKb: 100e2, + changeAddress: firstAddress.address, + }; + server.createTx(opts, function(err, txp) { + should.exist(err); + err.message.should.contain('single-address'); + done(); + }); + }); + }); + }); + + describe('#getSendMaxInfo', function() { var server, wallet; beforeEach(function(done) {