diff --git a/lib/blockchainexplorers/insight.js b/lib/blockchainexplorers/insight.js index cec4e70..950930f 100644 --- a/lib/blockchainexplorers/insight.js +++ b/lib/blockchainexplorers/insight.js @@ -114,7 +114,9 @@ Insight.prototype.getTransactions = function(addresses, from, to, cb) { }; Insight.prototype.getAddressActivity = function(address, cb) { - var url = this.url + this.apiPrefix + '/addr/' + address; + var self = this; + + var url = self.url + self.apiPrefix + '/addr/' + address; var args = { method: 'GET', url: url, diff --git a/lib/errors/errordefinitions.js b/lib/errors/errordefinitions.js index 93fc6e3..b701509 100644 --- a/lib/errors/errordefinitions.js +++ b/lib/errors/errordefinitions.js @@ -17,6 +17,7 @@ var errors = { INVALID_ADDRESS: 'Invalid address', KEY_IN_COPAYER: 'Key already registered', LOCKED_FUNDS: 'Funds are locked by pending transaction proposals', + MAIN_ADDRESS_GAP_REACHED: 'Maximum number of consecutive addresses without activity reached', NOT_AUTHORIZED: 'Not authorized', TOO_MANY_KEYS: 'Too many keys registered', TX_ALREADY_BROADCASTED: 'The transaction proposal is already broadcasted', diff --git a/lib/expressapp.js b/lib/expressapp.js index aafceda..a306437 100644 --- a/lib/expressapp.js +++ b/lib/expressapp.js @@ -259,8 +259,19 @@ ExpressApp.prototype.start = function(opts, cb) { }); }); - + // DEPRECATED router.post('/v1/addresses/', function(req, res) { + getServerWithAuth(req, res, function(server) { + server.createAddress({ + ignoreMaxGap: true + }, function(err, address) { + if (err) return returnError(err, res, req); + res.json(address); + }); + }); + }); + + router.post('/v2/addresses/', function(req, res) { getServerWithAuth(req, res, function(server) { server.createAddress(req.body, function(err, address) { if (err) return returnError(err, res, req); diff --git a/lib/model/address.js b/lib/model/address.js index a286187..c6af424 100644 --- a/lib/model/address.js +++ b/lib/model/address.js @@ -19,6 +19,7 @@ Address.create = function(opts) { x.publicKeys = opts.publicKeys; x.network = Bitcore.Address(x.address).toObject().network; x.type = opts.type || WalletUtils.SCRIPT_TYPES.P2SH; + x.hasActivity = undefined; return x; }; @@ -34,6 +35,7 @@ Address.fromObj = function(obj) { x.path = obj.path; x.publicKeys = obj.publicKeys; x.type = obj.type || WalletUtils.SCRIPT_TYPES.P2SH; + x.hasActivity = obj.hasActivity; return x; }; diff --git a/lib/server.js b/lib/server.js index 769dabd..6807e46 100644 --- a/lib/server.js +++ b/lib/server.js @@ -34,7 +34,6 @@ var blockchainExplorerOpts; var messageBroker; var serviceVersion; -var MAX_KEYS = 100; /** * Creates an instance of the Bitcore Wallet Service. @@ -53,6 +52,8 @@ function WalletService() { }; +WalletService.MAX_KEYS = 100; + // Time after which a Tx proposal can be erased by any copayer. in seconds WalletService.DELETE_LOCKTIME = 24 * 3600; @@ -62,9 +63,11 @@ WalletService.BACKOFF_OFFSET = 3; // Time a copayer need to wait to create a new TX after her tx previous proposal we rejected. (incremental). in Minutes. WalletService.BACKOFF_TIME = 2; +WalletService.MAX_MAIN_ADDRESS_GAP = 20; + // Fund scanning parameters WalletService.SCAN_CONFIG = { - maxGap: 20, + maxGap: WalletService.MAX_MAIN_ADDRESS_GAP, }; WalletService.FEE_LEVELS = [{ @@ -518,7 +521,7 @@ WalletService.prototype.addAccess = function(opts, cb) { return cb(Errors.NOT_AUTHORIZED); } - if (copayer.requestPubKeys.length > MAX_KEYS) + if (copayer.requestPubKeys.length > WalletService.MAX_KEYS) return cb(Errors.TOO_MANY_KEYS); self._addKeyToCopayer(wallet, copayer, opts, cb); @@ -689,29 +692,74 @@ WalletService.prototype.getPreferences = function(opts, cb) { }); }; +WalletService.prototype._canCreateAddress = function(ignoreMaxGap, cb) { + var self = this; + + if (ignoreMaxGap) return cb(null, true); + + self.storage.fetchAddresses(self.walletId, function(err, addresses) { + if (err) return cb(err); + var latestAddresses = _.takeRight(_.reject(addresses, { + isChange: true + }), WalletService.MAX_MAIN_ADDRESS_GAP); + if (latestAddresses.length < WalletService.MAX_MAIN_ADDRESS_GAP || _.any(latestAddresses, { + hasActivity: true + })) return cb(null, true); + + var bc = self._getBlockchainExplorer(latestAddresses[0].network); + var activityFound = false; + var i = latestAddresses.length; + async.whilst(function() { + return i > 0 && !activityFound; + }, function(next) { + bc.getAddressActivity(latestAddresses[--i].address, function(err, res) { + if (err) return next(err); + activityFound = !!res; + return next(); + }); + }, function(err) { + if (err) return cb(err); + if (!activityFound) return cb(null, false); + + var address = latestAddresses[i]; + address.hasActivity = true; + self.storage.storeAddress(address, function(err) { + return cb(err, true); + }); + }); + }); +}; /** * Creates a new address. * @param {Object} opts + * @param {Boolean} [opts.ignoreMaxGap=false] - Ignore constraint of maximum number of consecutive addresses without activity * @returns {Address} address */ WalletService.prototype.createAddress = function(opts, cb) { var self = this; + opts = opts || {}; + self._runLocked(cb, function(cb) { self.getWallet({}, function(err, wallet) { if (err) return cb(err); if (!wallet.isComplete()) return cb(Errors.WALLET_NOT_COMPLETE); - var address = wallet.createAddress(false); - - self.storage.storeAddressAndWallet(wallet, address, function(err) { + self._canCreateAddress(opts.ignoreMaxGap, function(err, canCreate) { if (err) return cb(err); + if (!canCreate) return cb(Errors.MAIN_ADDRESS_GAP_REACHED); - self._notify('NewAddress', { - address: address.address, - }, function() { - return cb(null, address); + 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); + }); }); }); }); diff --git a/lib/storage.js b/lib/storage.js index 983737f..ebbdf14 100644 --- a/lib/storage.js +++ b/lib/storage.js @@ -383,6 +383,17 @@ Storage.prototype.fetchAddresses = function(walletId, cb) { }); }; +Storage.prototype.storeAddress = function(address, cb) { + var self = this; + + self.db.collection(collections.ADDRESSES).update({ + address: address.address + }, address, { + w: 1, + upsert: false, + }, cb); +}; + Storage.prototype.storeAddressAndWallet = function(wallet, addresses, cb) { var self = this; diff --git a/test/integration/server.js b/test/integration/server.js index 869dd56..f7e31e0 100644 --- a/test/integration/server.js +++ b/test/integration/server.js @@ -1715,6 +1715,65 @@ describe('Wallet service', function() { done(); }); }); + + it('should fail to create more consecutive addresses with no activity than allowed', function(done) { + var MAX_MAIN_ADDRESS_GAP_old = WalletService.MAX_MAIN_ADDRESS_GAP; + WalletService.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'); + + WalletService.MAX_MAIN_ADDRESS_GAP = MAX_MAIN_ADDRESS_GAP_old; + done(); + }); + }); + }); + }); + }); + + it('should cache address activity', function(done) { + var MAX_MAIN_ADDRESS_GAP_old = WalletService.MAX_MAIN_ADDRESS_GAP; + WalletService.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); + WalletService.MAX_MAIN_ADDRESS_GAP = MAX_MAIN_ADDRESS_GAP_old; + done(); + }); + }); + }); + }); }); });