UNPKG

matrixbitcore-wallet-service

Version:
1,651 lines (1,377 loc) 113 kB
'use strict'; var _ = require('lodash'); var $ = require('preconditions').singleton(); var async = require('async'); var log = require('npmlog'); var config = require('../config'); log.debug = log.verbose; log.disableColor(); var EmailValidator = require('email-validator'); var Stringify = require('json-stable-stringify'); var Bitcore = require('matrixbitcore-lib'); var Bitcore_ = { btc: Bitcore, bch: require('bitcore-lib-cash') }; var Common = require('./common'); var Utils = Common.Utils; var Constants = Common.Constants; var Defaults = Common.Defaults; var ClientError = require('./errors/clienterror'); var Errors = require('./errors/errordefinitions'); var Lock = require('./lock'); var Storage = require('./storage'); var MessageBroker = require('./messagebroker'); var BlockchainExplorer = require('./blockchainexplorer'); var FiatRateService = require('./fiatrateservice'); var request = require('request'); var Model = require('./model'); var Wallet = Model.Wallet; var initialized = false; var lock; var storage; var blockchainExplorer; var blockchainExplorerOpts; var messageBroker; var fiatRateService; var serviceVersion; /** * Creates an instance of the Bitcore Wallet Service. * @constructor */ function WalletService() { if (!initialized) throw new Error('Server not initialized'); this.lock = lock; this.storage = storage; this.blockchainExplorer = blockchainExplorer; this.blockchainExplorerOpts = blockchainExplorerOpts; this.messageBroker = messageBroker; this.fiatRateService = fiatRateService; this.notifyTicker = 0; }; function checkRequired(obj, args, cb) { var missing = Utils.getMissingFields(obj, args); if (_.isEmpty(missing)) return true; if (_.isFunction(cb)) cb(new ClientError('Required argument ' + _.first(missing) + ' missing.')); return false; }; /** * Gets the current version of BWS */ WalletService.getServiceVersion = function() { if (!serviceVersion) serviceVersion = 'bws-' + require('../package').version; return serviceVersion; }; /** * Initializes global settings for all instances. * @param {Object} opts * @param {Storage} [opts.storage] - The storage provider. * @param {Storage} [opts.blockchainExplorer] - The blockchainExporer provider. * @param {Callback} cb */ WalletService.initialize = function(opts, cb) { $.shouldBeFunction(cb); opts = opts || {}; lock = opts.lock || new Lock(opts.lockOpts); blockchainExplorer = opts.blockchainExplorer; blockchainExplorerOpts = opts.blockchainExplorerOpts; if (opts.request) request = opts.request; function initStorage(cb) { if (opts.storage) { storage = opts.storage; return cb(); } else { var newStorage = new Storage(); newStorage.connect(opts.storageOpts, function(err) { if (err) return cb(err); storage = newStorage; return cb(); }); } }; function initMessageBroker(cb) { messageBroker = opts.messageBroker || new MessageBroker(opts.messageBrokerOpts); if (messageBroker) { messageBroker.onMessage(WalletService.handleIncomingNotification); } return cb(); }; function initFiatRateService(cb) { if (opts.fiatRateService) { fiatRateService = opts.fiatRateService; return cb(); } else { var newFiatRateService = new FiatRateService(); var opts2 = opts.fiatRateServiceOpts || {}; opts2.storage = storage; newFiatRateService.init(opts2, function(err) { if (err) return cb(err); fiatRateService = newFiatRateService; return cb(); }); } }; async.series([ function(next) { initStorage(next); }, function(next) { initMessageBroker(next); }, function(next) { initFiatRateService(next); }, ], function(err) { if (err) { log.error('Could not initialize', err); throw err; } initialized = true; return cb(); }); }; WalletService.handleIncomingNotification = function(notification, cb) { cb = cb || function() {}; if (!notification || notification.type != 'NewBlock') return cb(); WalletService._clearBlockchainHeightCache(notification.data.coin, notification.data.network); return cb(); }; WalletService.shutDown = function(cb) { if (!initialized) return cb(); storage.disconnect(function(err) { if (err) return cb(err); initialized = false; return cb(); }); }; /** * Gets an instance of the server without authentication. * @param {Object} opts * @param {string} opts.clientVersion - A string that identifies the client issuing the request */ WalletService.getInstance = function(opts) { opts = opts || {}; var version = Utils.parseVersion(opts.clientVersion); if (version && version.agent == 'bwc') { if (version.major == 0 || (version.major == 1 && version.minor < 2)) { throw new ClientError(Errors.codes.UPGRADE_NEEDED, 'BWC clients < 1.2 are no longer supported.'); } } var server = new WalletService(); server._setClientVersion(opts.clientVersion); return server; }; /** * Gets an instance of the server after authenticating the copayer. * @param {Object} opts * @param {string} opts.copayerId - The copayer id making the request. * @param {string} opts.message - (Optional) The contents of the request to be signed. Only needed if no session token is provided. * @param {string} opts.signature - (Optional) Signature of message to be verified using one of the copayer's requestPubKeys. Only needed if no session token is provided. * @param {string} opts.session - (Optional) A valid session token previously obtained using the #login method * @param {string} opts.clientVersion - A string that identifies the client issuing the request * @param {string} [opts.walletId] - The wallet id to use as current wallet for this request (only when copayer is support staff). */ WalletService.getInstanceWithAuth = function(opts, cb) { function withSignature(cb) { if (!checkRequired(opts, ['copayerId', 'message', 'signature'], cb)) return; var server; try { server = WalletService.getInstance(opts); } catch (ex) { return cb(ex); } server.storage.fetchCopayerLookup(opts.copayerId, function(err, copayer) { if (err) return cb(err); if (!copayer) return cb(new ClientError(Errors.codes.NOT_AUTHORIZED, 'Copayer not found')); if (!copayer.isSupportStaff) { var isValid = !!server._getSigningKey(opts.message, opts.signature, copayer.requestPubKeys); if (!isValid) return cb(new ClientError(Errors.codes.NOT_AUTHORIZED, 'Invalid signature')); server.walletId = copayer.walletId; } else { server.walletId = opts.walletId || copayer.walletId; server.copayerIsSupportStaff = true; } server.copayerId = opts.copayerId; return cb(null, server); }); }; function withSession(cb) { if (!checkRequired(opts, ['copayerId', 'session'], cb)) return; var server; try { server = WalletService.getInstance(opts); } catch (ex) { return cb(ex); } server.storage.getSession(opts.copayerId, function(err, s) { if (err) return cb(err); var isValid = s && s.id == opts.session && s.isValid(); if (!isValid) return cb(new ClientError(Errors.codes.NOT_AUTHORIZED, 'Session expired')); server.storage.fetchCopayerLookup(opts.copayerId, function(err, copayer) { if (err) return cb(err); if (!copayer) return cb(new ClientError(Errors.codes.NOT_AUTHORIZED, 'Copayer not found')); server.copayerId = opts.copayerId; server.walletId = copayer.walletId; return cb(null, server); }); }); }; var authFn = opts.session ? withSession : withSignature; return authFn(cb); }; WalletService.prototype._runLocked = function(cb, task, waitTime) { $.checkState(this.walletId); this.lock.runLocked(this.walletId, cb, task, waitTime); }; WalletService.prototype.logi = function() { if (!this) return log.info.apply(this, arguments); if (!this.walletId) return log.info.apply(this, arguments); var args = [].slice.call(arguments); args.unshift('<' + this.walletId + '>'); log.info.apply(this, args); }; WalletService.prototype.logw = function() { if (!this) return log.info.apply(this, arguments); if (!this.walletId) return log.info.apply(this, arguments); var args = [].slice.call(arguments); args.unshift('<' + this.walletId + '>'); log.warn.apply(this, args); }; WalletService.prototype.login = function(opts, cb) { var self = this; var session; async.series([ function(next) { self.storage.getSession(self.copayerId, function(err, s) { if (err) return next(err); session = s; next(); }); }, function(next) { if (!session || !session.isValid()) { session = Model.Session.create({ copayerId: self.copayerId, walletId: self.walletId, }); } else { session.touch(); } next(); }, function(next) { self.storage.storeSession(session, next); }, ], function(err) { if (err) return cb(err); if (!session) return cb(new Error('Could not get current session for this copayer')); return cb(null, session.id); }); }; WalletService.prototype.logout = function(opts, cb) { var self = this; self.storage.removeSession(self.copayerId, cb); }; /** * Creates a new wallet. * @param {Object} opts * @param {string} opts.id - The wallet id. * @param {string} opts.name - The wallet name. * @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.singleAddress[=false] - The wallet will only ever have one address. * @param {string} opts.coin[='btc'] - The coin for this wallet (btc, bch). * @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, pubKey; if (!checkRequired(opts, ['name', 'm', 'n', 'pubKey'], cb)) return; if (_.isEmpty(opts.name)) return cb(new ClientError('Invalid wallet name')); if (!Wallet.verifyCopayerLimits(opts.m, opts.n)) return cb(new ClientError('Invalid combination of required copayers / total copayers')); opts.coin = opts.coin || Defaults.COIN; if (!Utils.checkValueInCollection(opts.coin, Constants.COINS)) return cb(new ClientError('Invalid coin')); opts.network = opts.network || 'livenet'; if (!Utils.checkValueInCollection(opts.network, Constants.NETWORKS)) return cb(new ClientError('Invalid network')); opts.supportBIP44AndP2PKH = _.isBoolean(opts.supportBIP44AndP2PKH) ? opts.supportBIP44AndP2PKH : true; var derivationStrategy = opts.supportBIP44AndP2PKH ? Constants.DERIVATION_STRATEGIES.BIP44 : Constants.DERIVATION_STRATEGIES.BIP45; var addressType = (opts.n == 1 && opts.supportBIP44AndP2PKH) ? Constants.SCRIPT_TYPES.P2PKH : Constants.SCRIPT_TYPES.P2SH; try { pubKey = new Bitcore.PublicKey.fromString(opts.pubKey); } catch (ex) { return cb(new ClientError('Invalid public key')); }; var newWallet; async.series([ function(acb) { if (!opts.id) return acb(); self.storage.fetchWallet(opts.id, function(err, wallet) { if (wallet) return acb(Errors.WALLET_ALREADY_EXISTS); return acb(err); }); }, function(acb) { var wallet = Wallet.create({ id: opts.id, name: opts.name, m: opts.m, n: opts.n, coin: opts.coin, network: opts.network, pubKey: pubKey.toString(), singleAddress: !!opts.singleAddress, derivationStrategy: derivationStrategy, addressType: addressType, }); self.storage.storeWallet(wallet, function(err) { self.logi('Wallet created', wallet.id, opts.network); newWallet = wallet; return acb(err); }); } ], function(err) { return cb(err, newWallet ? newWallet.id : null); }); }; /** * Retrieves a wallet from storage. * @param {Object} opts * @returns {Object} wallet */ WalletService.prototype.getWallet = function(opts, cb) { var self = this; self.storage.fetchWallet(self.walletId, function(err, wallet) { if (err) return cb(err); if (!wallet) return cb(Errors.WALLET_NOT_FOUND); return cb(null, wallet); }); }; /** * Retrieves a wallet from storage. * @param {Object} opts * @param {string} opts.identifier - The identifier associated with the wallet (one of: walletId, address, txid). * @returns {Object} wallet */ WalletService.prototype.getWalletFromIdentifier = function(opts, cb) { var self = this; if (!opts.identifier) return cb(); var walletId; async.parallel([ function(done) { self.storage.fetchWallet(opts.identifier, function(err, wallet) { if (wallet) walletId = wallet.id; return done(err); }); }, function(done) { self.storage.fetchAddressByCoin(Defaults.COIN, opts.identifier, function(err, address) { if (address) walletId = address.walletId; return done(err); }); }, function(done) { self.storage.fetchTxByHash(opts.identifier, function(err, tx) { if (tx) walletId = tx.walletId; return done(err); }); }, ], function(err) { if (err) return cb(err); if (walletId) { return self.storage.fetchWallet(walletId, cb); } var re = /^[\da-f]+$/gi; if (!re.test(opts.identifier)) return cb(); // Is identifier a txid form an incomming tx? var coinNetworkPairs = []; _.each(_.values(Constants.COINS), function(coin) { _.each(_.values(Constants.NETWORKS), function(network) { coinNetworkPairs.push({ coin: coin, network: network }); }); }); async.detectSeries(coinNetworkPairs, function(coinNetwork, nextCoinNetwork) { var bc = self._getBlockchainExplorer(coinNetwork.coin, coinNetwork.network); if (!bc) return nextCoinNetwork(false); bc.getTransaction(opts.identifier, function(err, tx) { if (err || !tx) return nextCoinNetwork(false); var outputs = _.first(self._normalizeTxHistory(tx)).outputs; var toAddresses = _.map(outputs, 'address'); async.detect(toAddresses, function(addressStr, nextAddress) { self.storage.fetchAddressByCoin(coinNetwork.coin, addressStr, function(err, address) { if (err || !address) return nextAddress(false); walletId = address.walletId; nextAddress(true); }); }, function() { nextCoinNetwork(!!walletId); }); }); }, function() { if (!walletId) return cb(); return self.storage.fetchWallet(walletId, cb); }); }); }; /** * Retrieves wallet status. * @param {Object} opts * @param {Object} opts.twoStep[=false] - Optional: use 2-step balance computation for improved performance * @param {Object} opts.includeExtendedInfo - Include PKR info & address managers for wallet & copayers * @returns {Object} status */ WalletService.prototype.getStatus = function(opts, cb) { var self = this; opts = opts || {}; var status = {}; async.parallel([ function(next) { self.getWallet({}, function(err, wallet) { if (err) return next(err); var walletExtendedKeys = ['publicKeyRing', 'pubKey', 'addressManager']; var copayerExtendedKeys = ['xPubKey', 'requestPubKey', 'signature', 'addressManager', 'customData']; wallet.copayers = _.map(wallet.copayers, function(copayer) { if (copayer.id == self.copayerId) return copayer; return _.omit(copayer, 'customData'); }); if (!opts.includeExtendedInfo) { wallet = _.omit(wallet, walletExtendedKeys); wallet.copayers = _.map(wallet.copayers, function(copayer) { return _.omit(copayer, copayerExtendedKeys); }); } status.wallet = wallet; next(); }); }, function(next) { self.getBalance(opts, function(err, balance) { if (err) return next(err); status.balance = balance; next(); }); }, function(next) { self.getPendingTxs({}, function(err, pendingTxps) { if (err) return next(err); status.pendingTxps = pendingTxps; next(); }); }, function(next) { self.getPreferences({}, function(err, preferences) { if (err) return next(err); status.preferences = preferences; next(); }); }, ], function(err) { if (err) return cb(err); return cb(null, status); }); }; /* * Verifies a signature * @param text * @param signature * @param pubKeys */ WalletService.prototype._verifySignature = function(text, signature, pubkey) { return Utils.verifyMessage(text, signature, pubkey); }; /* * Verifies a request public key * @param requestPubKey * @param signature * @param xPubKey */ WalletService.prototype._verifyRequestPubKey = function(requestPubKey, signature, xPubKey) { var pub = (new Bitcore.HDPublicKey(xPubKey)).deriveChild(Constants.PATHS.REQUEST_KEY_AUTH).publicKey; return Utils.verifyMessage(requestPubKey, signature, pub.toString()); }; /* * Verifies signature againt a collection of pubkeys * @param text * @param signature * @param pubKeys */ WalletService.prototype._getSigningKey = function(text, signature, pubKeys) { var self = this; return _.find(pubKeys, function(item) { return self._verifySignature(text, signature, item.key); }); }; /** * _notify * * @param {String} type * @param {Object} data * @param {Object} opts * @param {Boolean} opts.isGlobal - If true, the notification is not issued on behalf of any particular copayer (defaults to false) */ WalletService.prototype._notify = function(type, data, opts, cb) { var self = this; if (_.isFunction(opts)) { cb = opts; opts = {}; } opts = opts || {}; //self.logi('Notification', type); cb = cb || function() {}; var walletId = self.walletId || data.walletId; var copayerId = self.copayerId || data.copayerId; $.checkState(walletId); var notification = Model.Notification.create({ type: type, data: data, ticker: this.notifyTicker++, creatorId: opts.isGlobal ? null : copayerId, walletId: walletId, }); this.storage.storeNotification(walletId, notification, function() { self.messageBroker.send(notification); return cb(); }); }; WalletService.prototype._notifyTxProposalAction = function(type, txp, extraArgs, cb) { var self = this; if (_.isFunction(extraArgs)) { cb = extraArgs; extraArgs = {}; } var data = _.assign({ txProposalId: txp.id, creatorId: txp.creatorId, amount: txp.getTotalAmount(), message: txp.message, }, extraArgs); self._notify(type, data, {}, cb); }; WalletService.prototype._addCopayerToWallet = function(wallet, opts, cb) { var self = this; var copayer = Model.Copayer.create({ coin: wallet.coin, name: opts.name, copayerIndex: wallet.copayers.length, xPubKey: opts.xPubKey, requestPubKey: opts.requestPubKey, signature: opts.copayerSignature, customData: opts.customData, derivationStrategy: wallet.derivationStrategy, }); self.storage.fetchCopayerLookup(copayer.id, function(err, res) { if (err) return cb(err); if (res) return cb(Errors.COPAYER_REGISTERED); if (opts.dryRun) return cb(null, { copayerId: null, wallet: wallet }); wallet.addCopayer(copayer); self.storage.storeWalletAndUpdateCopayersLookup(wallet, function(err) { if (err) return cb(err); async.series([ function(next) { self._notify('NewCopayer', { walletId: opts.walletId, copayerId: copayer.id, copayerName: copayer.name, }, next); }, function(next) { if (wallet.isComplete() && wallet.isShared()) { self._notify('WalletComplete', { walletId: opts.walletId, }, { isGlobal: true }, next); } else { next(); } }, ], function() { return cb(null, { copayerId: copayer.id, wallet: wallet }); }); }); }); }; WalletService.prototype._addKeyToCopayer = function(wallet, copayer, opts, cb) { var self = this; wallet.addCopayerRequestKey(copayer.copayerId, opts.requestPubKey, opts.signature, opts.restrictions, opts.name); self.storage.storeWalletAndUpdateCopayersLookup(wallet, function(err) { if (err) return cb(err); return cb(null, { copayerId: copayer.id, wallet: wallet }); }); }; /** * Adds access to a given copayer * * @param {Object} opts * @param {string} opts.copayerId - The copayer id * @param {string} opts.requestPubKey - Public Key used to check requests from this copayer. * @param {string} opts.copayerSignature - S(requestPubKey). Used by other copayers to verify the that the copayer is himself (signed with REQUEST_KEY_AUTH) * @param {string} opts.restrictions * - cannotProposeTXs * - cannotXXX TODO * @param {string} opts.name (name for the new access) */ WalletService.prototype.addAccess = function(opts, cb) { var self = this; if (!checkRequired(opts, ['copayerId', 'requestPubKey', 'signature'], cb)) return; self.storage.fetchCopayerLookup(opts.copayerId, function(err, copayer) { if (err) return cb(err); if (!copayer) return cb(Errors.NOT_AUTHORIZED); self.storage.fetchWallet(copayer.walletId, function(err, wallet) { if (err) return cb(err); if (!wallet) return cb(Errors.NOT_AUTHORIZED); var xPubKey = _.find(wallet.copayers, { id: opts.copayerId }).xPubKey; if (!self._verifyRequestPubKey(opts.requestPubKey, opts.signature, xPubKey)) { return cb(Errors.NOT_AUTHORIZED); } if (copayer.requestPubKeys.length > Defaults.MAX_KEYS) return cb(Errors.TOO_MANY_KEYS); self._addKeyToCopayer(wallet, copayer, opts, cb); }); }); }; WalletService.prototype._setClientVersion = function(version) { delete this.parsedClientVersion; this.clientVersion = version; }; WalletService.prototype._parseClientVersion = function() { if (_.isUndefined(this.parsedClientVersion)) { this.parsedClientVersion = Utils.parseVersion(this.clientVersion); } return this.parsedClientVersion; }; WalletService.prototype._clientSupportsPayProRefund = function() { var version = this._parseClientVersion(); if (!version) return false; if (version.agent != 'bwc') return true; if (version.major < 1 || (version.major == 1 && version.minor < 2)) return false; return true; }; WalletService._getCopayerHash = function(name, xPubKey, requestPubKey) { return [name, xPubKey, requestPubKey].join('|'); }; /** * Joins a wallet in creation. * @param {Object} opts * @param {string} opts.walletId - The wallet id. * @param {string} opts.coin[='btc'] - The expected coin for this wallet (btc, bch). * @param {string} opts.name - The copayer name. * @param {string} opts.xPubKey - Extended Public Key for this copayer. * @param {string} opts.requestPubKey - Public Key used to check requests from this copayer. * @param {string} opts.copayerSignature - S(name|xPubKey|requestPubKey). Used by other copayers to verify that the copayer joining knows the wallet secret. * @param {string} opts.customData - (optional) Custom data for this copayer. * @param {string} opts.dryRun[=false] - (optional) Simulate the action but do not change server state. * @param {string} [opts.supportBIP44AndP2PKH = true] - Client supports BIP44 & P2PKH for joining wallets. */ WalletService.prototype.joinWallet = function(opts, cb) { var self = this; if (!checkRequired(opts, ['walletId', 'name', 'xPubKey', 'requestPubKey', 'copayerSignature'], cb)) return; if (_.isEmpty(opts.name)) return cb(new ClientError('Invalid copayer name')); opts.coin = opts.coin || Defaults.COIN; if (!Utils.checkValueInCollection(opts.coin, Constants.COINS)) return cb(new ClientError('Invalid coin')); var xPubKey; try { xPubKey = Bitcore.HDPublicKey(opts.xPubKey); } catch (ex) { return cb(new ClientError('Invalid extended public key')); } if (_.isUndefined(xPubKey.network)) { return cb(new ClientError('Invalid extended public key')); } opts.supportBIP44AndP2PKH = _.isBoolean(opts.supportBIP44AndP2PKH) ? opts.supportBIP44AndP2PKH : true; self.walletId = opts.walletId; self._runLocked(cb, function(cb) { self.storage.fetchWallet(opts.walletId, function(err, wallet) { if (err) return cb(err); if (!wallet) return cb(Errors.WALLET_NOT_FOUND); if (opts.coin != wallet.coin) { return cb(new ClientError('The wallet you are trying to join was created for a different coin')); } if (wallet.network != xPubKey.network.name) { return cb(new ClientError('The wallet you are trying to join was created for a different network')); } if (opts.supportBIP44AndP2PKH) { // New client trying to join legacy wallet if (wallet.derivationStrategy == Constants.DERIVATION_STRATEGIES.BIP45) { return cb(new ClientError('The wallet you are trying to join was created with an older version of the client app.')); } } else { // Legacy client trying to join new wallet if (wallet.derivationStrategy == Constants.DERIVATION_STRATEGIES.BIP44) { return cb(new ClientError(Errors.codes.UPGRADE_NEEDED, 'To join this wallet you need to upgrade your client app.')); } } var hash = WalletService._getCopayerHash(opts.name, opts.xPubKey, opts.requestPubKey); if (!self._verifySignature(hash, opts.copayerSignature, wallet.pubKey)) { return cb(new ClientError()); } if (_.find(wallet.copayers, { xPubKey: opts.xPubKey })) return cb(Errors.COPAYER_IN_WALLET); if (wallet.copayers.length == wallet.n) return cb(Errors.WALLET_FULL); self._addCopayerToWallet(wallet, opts, cb); }); }); }; /** * Save copayer preferences for the current wallet/copayer pair. * @param {Object} opts * @param {string} opts.email - Email address for notifications. * @param {string} opts.language - Language used for notifications. * @param {string} opts.unit - Bitcoin unit used to format amounts in notifications. */ WalletService.prototype.savePreferences = function(opts, cb) { var self = this; opts = opts || {}; var preferences = [{ name: 'email', isValid: function(value) { return EmailValidator.validate(value); }, }, { name: 'language', isValid: function(value) { return _.isString(value) && value.length == 2; }, }, { name: 'unit', isValid: function(value) { return _.isString(value) && _.contains(['btc', 'bit'], value.toLowerCase()); }, }]; opts = _.pick(opts, _.map(preferences, 'name')); try { _.each(preferences, function(preference) { var value = opts[preference.name]; if (!value) return; if (!preference.isValid(value)) { throw 'Invalid ' + preference.name; return false; } }); } catch (ex) { return cb(new ClientError(ex)); } self._runLocked(cb, function(cb) { self.storage.fetchPreferences(self.walletId, self.copayerId, function(err, oldPref) { if (err) return cb(err); var newPref = Model.Preferences.create({ walletId: self.walletId, copayerId: self.copayerId, }); var preferences = Model.Preferences.fromObj(_.defaults(newPref, opts, oldPref)); self.storage.storePreferences(preferences, function(err) { return cb(err); }); }); }); }; /** * Retrieves a preferences for the current wallet/copayer pair. * @param {Object} opts * @returns {Object} preferences */ WalletService.prototype.getPreferences = function(opts, cb) { var self = this; self.storage.fetchPreferences(self.walletId, self.copayerId, function(err, preferences) { if (err) return cb(err); return cb(null, preferences || {}); }); }; 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 }), Defaults.MAX_MAIN_ADDRESS_GAP); if (latestAddresses.length < Defaults.MAX_MAIN_ADDRESS_GAP || _.any(latestAddresses, { hasActivity: true })) return cb(null, true); var bc = self._getBlockchainExplorer(latestAddresses[0].coin, latestAddresses[0].network); if (!bc) return cb(new Error('Could not get blockchain explorer instance')); 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 || {}; function createNewAddress(wallet, cb) { var address; try{ address = wallet.createAddress(false); } catch(e){ log.warn("Error creating address for " + self.walletId, e); return cb("Bad xPub"); }; 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._canCreateAddress(opts.ignoreMaxGap, function(err, canCreate) { if (err) return cb(err); if (!canCreate) return cb(Errors.MAIN_ADDRESS_GAP_REACHED); 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 createFn = wallet.singleAddress ? getFirstAddress : createNewAddress; return createFn(wallet, cb); }); }, 10 * 1000); }); }; /** * Get all addresses. * @param {Object} opts * @param {Numeric} opts.limit (optional) - Limit the resultset. Return all addresses by default. * @param {Boolean} [opts.reverse=false] (optional) - Reverse the order of returned addresses. * @returns {Address[]} */ WalletService.prototype.getMainAddresses = function(opts, cb) { var self = this; opts = opts || {}; self.storage.fetchAddresses(self.walletId, function(err, addresses) { if (err) return cb(err); var onlyMain = _.reject(addresses, { isChange: true }); if (opts.reverse) onlyMain.reverse(); if (opts.limit > 0) onlyMain = _.take(onlyMain, opts.limit); return cb(null, onlyMain); }); }; /** * Verifies that a given message was actually sent by an authorized copayer. * @param {Object} opts * @param {string} opts.message - The message to verify. * @param {string} opts.signature - The signature of message to verify. * @returns {truthy} The result of the verification. */ WalletService.prototype.verifyMessageSignature = function(opts, cb) { var self = this; if (!checkRequired(opts, ['message', 'signature'], cb)) return; self.getWallet({}, function(err, wallet) { if (err) return cb(err); var copayer = wallet.getCopayer(self.copayerId); var isValid = !!self._getSigningKey(opts.message, opts.signature, copayer.requestPubKeys); return cb(null, isValid); }); }; WalletService.prototype._getBlockchainExplorer = function(coin, network) { var opts = {}; if (this.blockchainExplorer) return this.blockchainExplorer; if (this.blockchainExplorerOpts) { if (this.blockchainExplorerOpts[coin] && this.blockchainExplorerOpts[coin][network]) { opts = this.blockchainExplorerOpts[coin][network]; } else if (this.blockchainExplorerOpts[network]) { opts = this.blockchainExplorerOpts[network]; } } // TODO: provider should be configurable opts.provider = 'insight'; opts.coin = coin; opts.network = network; opts.userAgent = WalletService.getServiceVersion(); var bc; try { bc = new BlockchainExplorer(opts); } catch (ex) { this.logw('Could not instantiate blockchain explorer', ex); } return bc; }; WalletService.prototype._getUtxos = function(coin, addresses, cb) { var self = this; if (addresses.length == 0) return cb(null, []); 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')); self.logi('Querying utxos: %s addrs', addresses.length); bc.getUtxos(addresses, function(err, utxos) { if (err) return cb(err); var utxos = _.map(utxos, function(utxo) { var u = _.pick(utxo, ['txid', 'vout', 'address', 'scriptPubKey', 'amount', 'satoshis', 'confirmations']); u.confirmations = u.confirmations || 0; u.locked = false; u.satoshis = _.isNumber(u.satoshis) ? +u.satoshis : Utils.strip(u.amount * 1e8); delete u.amount; return u; }); return cb(null, utxos); }); }; WalletService.prototype._getUtxosForCurrentWallet = function(opts, cb) { var self = this; var opts = opts || {}; function utxoKey(utxo) { return utxo.txid + '|' + utxo.vout }; var coin, allAddresses, allUtxos, utxoIndex, addressStrs; async.series([ function(next) { self.getWallet({}, function(err, wallet) { if (err) return next(err); coin = wallet.coin; return next(); }); }, function(next) { if (_.isArray(opts.addresses)) { allAddresses = opts.addresses; return next(); } self.storage.fetchAddresses(self.walletId, function(err, addresses) { allAddresses = addresses; if (allAddresses.length == 0) return cb(null, []); return next(); }); }, function(next) { addressStrs = _.map(allAddresses, 'address'); if (!opts.coin) return next(); coin = opts.coin; addressStrs = _.map(addressStrs, function(a) { return Utils.translateAddress(a, coin); }); next(); }, function(next) { self._getUtxos(coin, addressStrs, function(err, utxos) { if (err) return next(err); if (utxos.length == 0) return cb(null, []); allUtxos = utxos; utxoIndex = _.indexBy(allUtxos, utxoKey); return next(); }); }, function(next) { self.getPendingTxs({}, function(err, txps) { if (err) return next(err); var lockedInputs = _.map(_.flatten(_.map(txps, 'inputs')), utxoKey); _.each(lockedInputs, function(input) { if (utxoIndex[input]) { utxoIndex[input].locked = true; } }); return next(); }); }, function(next) { var now = Math.floor(Date.now() / 1000); // Fetch latest broadcasted txs and remove any spent inputs from the // list of UTXOs returned by the block explorer. This counteracts any out-of-sync // effects between broadcasting a tx and getting the list of UTXOs. // This is especially true in the case of having multiple instances of the block explorer. self.storage.fetchBroadcastedTxs(self.walletId, { minTs: now - 24 * 3600, limit: 100 }, function(err, txs) { if (err) return next(err); var spentInputs = _.map(_.flatten(_.map(txs, 'inputs')), utxoKey); _.each(spentInputs, function(input) { if (utxoIndex[input]) { utxoIndex[input].spent = true; } }); allUtxos = _.reject(allUtxos, { spent: true }); return next(); }); }, function(next) { if (opts.coin) return next(); // Needed for the clients to sign UTXOs var addressToPath = _.indexBy(allAddresses, 'address'); _.each(allUtxos, function(utxo) { utxo.path = addressToPath[utxo.address].path; utxo.publicKeys = addressToPath[utxo.address].publicKeys; }); return next(); }, ], function(err) { return cb(err, allUtxos); }); }; /** * Returns list of UTXOs * @param {Object} opts * @param {String} [opts.coin='btc'] (optional) * @param {Array} opts.addresses (optional) - List of addresses from where to fetch UTXOs. * @returns {Array} utxos - List of UTXOs. */ WalletService.prototype.getUtxos = function(opts, cb) { var self = this; opts = opts || {}; 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(Utils.getAddressCoin(opts.addresses[0]), opts.addresses, cb); } }; WalletService.prototype._totalizeUtxos = function(utxos) { var balance = { totalAmount: _.sum(utxos, 'satoshis'), lockedAmount: _.sum(_.filter(utxos, 'locked'), 'satoshis'), totalConfirmedAmount: _.sum(_.filter(utxos, 'confirmations'), 'satoshis'), lockedConfirmedAmount: _.sum(_.filter(_.filter(utxos, 'locked'), 'confirmations'), 'satoshis'), }; balance.availableAmount = balance.totalAmount - balance.lockedAmount; balance.availableConfirmedAmount = balance.totalConfirmedAmount - balance.lockedConfirmedAmount; return balance; }; WalletService.prototype._getBalanceFromAddresses = function(opts, cb, i) { var self = this; var opts = opts || {}; opts.addresses = opts.addresses || []; function checkBalanceCache(cb) { if (opts.addresses.length < Defaults.BALANCE_CACHE_ADDRESS_THRESOLD || !opts.fastCache) return cb(); self.storage.checkAndUseBalanceCache(self.walletId, opts.addresses, opts.fastCache, cb); }; function storeBalanceCache(balance, cb) { if (opts.addresses.length < Defaults.BALANCE_CACHE_ADDRESS_THRESOLD) return cb(null, balance); self.storage.storeBalanceCache(self.walletId, opts.addresses, balance, function(err) { if (err) self.logw('Could not save cache:',err); return cb(null, balance); }); }; // This lock is to prevent server starvation on big wallets self._runLocked(cb, function(cb) { checkBalanceCache(function(err, cache) { if (err) return cb(err); if (cache) { self.logi('Using UTXO Cache'); return cb(null, cache, true); } self._getUtxosForCurrentWallet({ coin: opts.coin, addresses: opts.addresses }, function(err, utxos) { if (err) return cb(err); var balance = self._totalizeUtxos(utxos); // Compute balance by address var byAddress = {}; _.each(_.indexBy(_.sortBy(utxos, 'address'), 'address'), function(value, key) { byAddress[key] = { address: key, path: value.path, amount: 0, }; }); _.each(utxos, function(utxo) { byAddress[utxo.address].amount += utxo.satoshis; }); balance.byAddress = _.values(byAddress); storeBalanceCache(balance, cb); }); }, 10 * 1000); }); }; WalletService.prototype._getBalanceOneStep = function(opts, cb) { var self = this; self.storage.fetchAddresses(self.walletId, function(err, addresses) { if (err) return cb(err); if (addresses.length == opts.alreadyQueriedLength) { self.logi('Query Skipped, all active addresses'); return cb(null, null, true); } self._getBalanceFromAddresses({ coin: opts.coin, addresses: addresses, fastCache: opts.fastCache, }, function(err, balance, cacheUsed) { if (err) return cb(err); // Update cache var withBalance = _.map(balance.byAddress, 'address') self.storage.storeAddressesWithBalance(self.walletId, withBalance, function(err) { if (err) { self.logw('Could not update wallet cache', err); } return cb(null, balance, cacheUsed); }); }); }); }; WalletService.prototype._getActiveAddresses = function(cb) { var self = this; self.storage.fetchAddressesWithBalance(self.walletId, function(err, addressesWB) { if (err) { self.logw('Could not fetch active addresses from cache', err); return cb(); } if (!_.isArray(addressesWB)) addressesWB = []; var now = Math.floor(Date.now() / 1000); var fromTs = now - Defaults.TWO_STEP_CREATION_HOURS * 3600; self.storage.fetchNewAddresses(self.walletId, fromTs, function(err, recent) { if (err) return cb(err); var result = _.uniq(_.union(addressesWB, recent), 'address'); return cb(null, result); }); }); }; WalletService.prototype._checkAndUpdateAddressCount = function(twoStepCache, cb) { var self = this; if (twoStepCache.addressCount > Defaults.TWO_STEP_BALANCE_THRESHOLD) { self.logi('Not counting addresses'); return cb(null, true); } self.storage.countAddresses(self.walletId, function(err, addressCount) { if (err) return cb(err); if (addressCount < Defaults.TWO_STEP_BALANCE_THRESHOLD) return cb(null, false); twoStepCache.addressCount = addressCount; // updates cache self.storage.storeTwoStepCache(self.walletId, twoStepCache, function(err) { if (err) return cb(err); return cb(null, true); }); }); }; /** * Get wallet balance. * @param {Object} opts * @param {string} [opts.coin] - Override wallet coin (default wallet's coin). * @param {Boolean} opts.twoStep[=false] - Optional - Use 2 step balance computation for improved performance * @returns {Object} balance - Total amount & locked amount. */ WalletService.prototype.getBalance = function(opts, cb, i) { var self = this; opts = opts || {}; if (opts.coin) { if (!Utils.checkValueInCollection(opts.coin, Constants.COINS)) return cb(new ClientError('Invalid coin')); } if (!opts.twoStep) { opts.fastCache = Defaults.BALANCE_CACHE_DIRECT_DURATION; return self._getBalanceOneStep(opts, cb); } self.storage.getTwoStepCache(self.walletId, function(err, twoStepCache) { if (err) return cb(err); twoStepCache = twoStepCache || {}; self._checkAndUpdateAddressCount(twoStepCache, function(err, needsTwoStep ) { if (err) return cb(err); if (!needsTwoStep) { return self._getBalanceOneStep(opts, cb); } self._getActiveAddresses(function(err, activeAddresses) { if (err) return cb(err); if (!_.isArray(activeAddresses)) { return self._getBalanceOneStep(opts, cb); } else { self.logi('Requesting partial balance for ' + activeAddresses.length + ' addresses'); self._getBalanceFromAddresses({ coin: opts.coin, addresses: activeAddresses }, function(err, partialBalance, cacheUsed) { if (err) return cb(err); cb(null, partialBalance, cacheUsed); var now = Math.floor(Date.now() / 1000); if (twoStepCache.lastEmpty > now - Defaults.TWO_STEP_INACTIVE_CLEAN_DURATION_MIN * 60 ) { self.logi('Not running the FULL balance query due to TWO_STEP_INACTIVE_CLEAN_DURATION_MIN '); return; } setTimeout(function() { self.logi('Running full balance query'); opts.alreadyQueriedLength = activeAddresses.length; opts.fastCache = Defaults.BALANCE_CACHE_DURATION; self._getBalanceOneStep(opts, function(err, fullBalance, skipped) { if (err) return; if (!skipped && !_.isEqual(partialBalance, fullBalance)) { self.logi('Balance in active addresses differs from final balance'); self._notify('BalanceUpdated', fullBalance, { isGlobal: true }); } else if (skipped) { return; } else { // updates cache twoStepCache.lastEmpty = now; // updates cache return self.storage.storeTwoStepCache(self.walletId, twoStepCache, function(err) { return; }); } }); }, 1); return; }, i); } }); }); }); }; /** * Return info needed to send all funds in the wallet * @param {Object} opts * @param {number} opts.feeLevel[='normal'] - Optional. Specify the fee level for this TX ('priority', 'normal', 'economy', 'superEconomy') as defined in Defaults.FEE_LEVELS. * @param {number} opts.feePerKb - Optional. Specify the fee per KB for this TX (in satoshi). * @param {string} opts.excludeUnconfirmedUtxos[=false] - Optional. Do not use UTXOs of unconfirmed transactions as inputs * @param {string} opts.returnInputs[=false] - Optional. Return the list of UTXOs that would be included in the tx. * @returns {Object} sendMaxInfo */ WalletService.prototype.getSendMaxInfo = function(opts, cb) { var self = this; opts = opts || {}; self.getWallet({}, function(err, wallet) { if (err) return cb(err); var feeArgs = !!opts.feeLevel + _.isNumber(opts.feePerKb); if (feeArgs > 1) return cb(new ClientError('Only one of feeLevel/feePerKb can be specified')); if (feeArgs == 0) { opts.feeLevel = 'normal'; } var feeLevels = Defaults.FEE_LEVELS[wallet.coin]; if (opts.feeLevel) { if (!_.any(feeLevels, { name: opts.feeLevel })) return cb(new ClientError('Invalid fee level. Valid values are ' + _.map(feeLevels, 'name').join(', '))); } if (_.isNumber(opts.feePerKb)) { if (opts.feePerKb < Defaults.MIN_FEE_PER_KB || opts.feePerKb > Defaults.MAX_FEE_PER_KB) return cb(new ClientError('Invalid fee per KB')); } self._getUtxosForCurrentWallet({}, function(err, utxos) { if (err) return cb(err); var info = { size: 0, amount: 0, fee: 0, feePerKb: 0, inputs: [], utxosBelowFee: 0, amountBelowFee: 0, utxosAboveMaxSize: 0, amountAboveMaxSize: 0, }; var inputs = _.reject(utxos, 'locked'); if (!!opts.excludeUnconfirmedUtxos) { inputs = _.filter(inputs, 'confirmations'); } inputs = _.sortBy(inputs, function(input) { return -input.satoshis; }); if (_.isEmpty(inputs)) return cb(null, info); self._getFeePerKb(wallet, opts, function(err, feePerKb) { if (err) return cb(err); info.feePerKb = feePerKb; var txp = Model.TxProposal.create({ walletId: self.walletId, coin: wallet.coin, network: wallet.network, walletM: wallet.m, walletN: wallet.n, feePerKb: feePerKb, }); var baseTxpSize = txp.getEstimatedSize(); var baseTxpFee = baseTxpSize * txp.feePerKb / 1000.; var sizePerInput = txp.getEstimatedSizeForSingleInput(); var feePerInput = sizePerInput * txp.feePerKb / 1000.; var partitionedByAmount = _.partition(inputs, function(input) { return input.satoshis > feePerInput; }); info.utxosBelowFee = partitionedByAmount[1].length; info.amountBelowFee = _.sum(partitionedByAmount[1], 'satoshis'); inputs = partitionedByAmount[0]; _.each(inputs, function(input, i) { var sizeInKb = (baseTxpSize + (i + 1) * sizePerInput) / 1000.; if (sizeInKb > Defaults.MAX_TX_SIZE_IN_KB) { info.utxosAboveMaxSize = inputs.length - i; info.amountAboveMaxSize = _.sum(_.slice(inputs, i), 'satoshis'); return false; } txp.inputs.push(input); }); if (_.isEmpty(txp.inputs)) return cb(null, info); var fee = txp.getEstimatedFee(); var amount = _.sum(txp.inputs, 'satoshis') - fee; if (amount < Defaults.MIN_OUTPUT_AMOUNT) return cb(null, info); info.size = txp.getEstimatedSize(); info.fee = fee; info.amount = amount; if (opts.returnInputs) { info.inputs = _.shuffle(txp.inputs); } return cb(null, info); }); }); }); }; WalletService.prototype._sampleFeeLevels = function(coin, network, points, cb) { var self = this; var bc = self._getBlockchainExplorer(coin, network); if (!bc) return cb(new Err