UNPKG

@owstack/wallet-service

Version:

A service for multisignature HD wallets

1,761 lines (1,533 loc) 111 kB
'use strict'; var owsCommon = require('@owstack/ows-common'); var keyLib = require('@owstack/key-lib'); var async = require('async'); var baseConfig = require('../../config'); var ClientError = require('./errors/clienterror'); var Common = require('./common'); var Constants = owsCommon.Constants; var EmailValidator = require('email-validator'); var errors = owsCommon.errors; var Errors = require('./errors/errordefinitions'); var FiatRateService = require('./fiatrateservice'); var HDPublicKey = keyLib.HDPublicKey; var Lock = require('./lock'); var log = require('npmlog'); var MessageBroker = require('./messagebroker'); var Model = require('./model'); var pkg = require('../../package'); var PublicKey = keyLib.PublicKey; var request = require('request'); var Stringify = require('json-stable-stringify'); var lodash = owsCommon.deps.lodash; var $ = require('preconditions').singleton(); log.debug = log.verbose; log.disableColor(); var serviceVersion; /** * Static services shared among all instances of this WalletService. */ var lock; var fiatRateService; var messageBroker; /** * Creates an instance of the Wallet Service. * @constructor */ class WalletService { constructor(context, opts, config, cb) { // Context defines the coin network and is set by the implementing service in // order to instance this base service; e.g., btc-service. context.inject(this); // Set some frequently used contant values based on context. this.LIVENET = this.ctx.Networks.livenet; this.TESTNET = this.ctx.Networks.testnet; this.atomicsName = this.ctx.Unit().atomicsName(); this.utils = new this.ctx.Utils(); this.initialize(opts, config, cb); } }; WalletService.prototype.setLog = function() { if (this.config.log) { log.level = (this.config.log.disable == true ? 'silent' : this.config.log.level || 'info'); } else { log.level = 'info'; } }; WalletService.prototype.checkRequired = function(obj, args, cb) { var missing = this.ctx.Utils.getMissingFields(obj, args); if (lodash.isEmpty(missing)) { return true; } if (lodash.isFunction(cb)) { cb(new ClientError('Required argument ' + lodash.head(missing) + ' missing.')); } return false; }; /** * Gets the current version of this wallet service. */ WalletService.getServiceVersion = function() { if (!serviceVersion) { serviceVersion = 'ws-' + pkg.version; } return serviceVersion; }; /** * Gets information about this wallet service instance. */ WalletService.prototype.getServiceInfo = function() { return { version: WalletService.getServiceVersion(), currency: this.ctx.Networks.livenet.currency, // Currency same for livenet and testnet livenet: { description: this.ctx.Networks.livenet.description }, testnet: { description: this.ctx.Networks.testnet.description } }; }; /** * Initializes this instance. * @param {Object} opts - Options, most used for testing. * @param {Storage} [opts.storage] - A Storage instance. * @param {BlockchainExplorer} [opts.blockchainExplorer] - A BlockchainExporer instance. * @param {BlockchainMonitor} [opts.blockchainMonitor] - A BlockchainMonitor instance. * @param {MessageBroker} [opts.messageBroker] - A MessageBroker instance. * @param {Object} [opts.request] - A (http) request object. * @param {String} [opts.clientVersion] - A string that identifies the client issuing the request. * @param {Object} config - a server configuration * @param {Callback} cb */ WalletService.prototype.initialize = function(opts, config, cb) { var self = this; $.shouldBeFunction(cb); opts = opts || {}; self.config = config || baseConfig; self.notifyTicker = 0; self._setClientVersion(opts.clientVersion); self.setLog(); if (opts.request) { request = opts.request; } function initStorage(cb) { if (opts.storage) { self.storage = opts.storage; return cb(); } if (!self.storage) { var newStorage = new self.ctx.Storage(self.config.storageOpts, { creator: 'WalletService (' + self.LIVENET.currency + ')' }); newStorage.connect(function(err) { if (err) { return cb(err); } self.storage = newStorage; self.config.storage = self.storage; return cb(); }); } else { return cb(); } }; function initBlockchainExplorer(cb) { // If a blockchain explorer was provided then set it. self.blockchainExplorer = opts.blockchainExplorer; return cb(); }; function initMessageBroker(cb) { if (!messageBroker) { messageBroker = opts.messageBroker || new MessageBroker(self.config.messageBrokerOpts); messageBroker.onMessage(lodash.bind(self.handleIncomingNotification, self)); } return cb(); }; function initLock(cb) { if (!lock) { lock = config.lock || new Lock(config.lockOpts); } return cb(); }; function initFiatRateService(cb) { if (self.config.fiatRateService) { fiatRateService = self.config.fiatRateService; return cb(); } else { var newFiatRateService = new FiatRateService(self.config); newFiatRateService.init({ storage: self.storage } , function(err) { if (err) { return cb(err); } fiatRateService = newFiatRateService; return cb(); }); } }; async.series([ function(next) { initStorage(next); }, function(next) { initBlockchainExplorer(next); }, function(next) { initMessageBroker(next); }, function(next) { initLock(next); }, function(next) { initFiatRateService(next); } ], function(err) { if (err) { log.error('Could not initialize', err); throw err; } return cb(self); }); }; WalletService.prototype.handleIncomingNotification = function(notification, cb) { var self = this; cb = cb || function() {}; if (!notification || notification.type != 'NewBlock' || !MessageBroker.isNotificationForMe(notification, [self.LIVENET.name, self.TESTNET.name])) { return cb(); } WalletService._clearBlockchainHeightCache(notification.networkName); return cb(); }; WalletService.prototype.shutDown = function(cb) { var self = this; if (messageBroker) { messageBroker.removeAllListeners(); messageBroker = undefined; } if (self.storage) { self.storage.disconnect(function(err) { if (err) { return cb(err); } self.storage = undefined; return cb(); }); } else { return cb(); } }; /** * Gets an instance of the server without authentication. * @param {Object} opts - Options for the server * @param {Object} opts.blockchainExplorer - A blockchain explorer instance to attach * @param {Object} opts.storage - A storage instance to attach * @param {Object} config - Service configuration, see ../config.js */ WalletService.getInstance = function(opts, config) { throw 'Must override'; }; /** * Initialize an instance of the server after authenticating the copayer. * @param {Object} opts - Options for the server * @param {Object} opts.blockchainExplorer - A blockchain explorer instance to attach * @param {Object} opts.storage - A storage instance to attach * @param {string} opts.clientVersion - A string that identifies the client issuing the request * @param {Object} config - Service configuration, see ../config.js * @param {Object} auth * @param {string} auth.copayerId - The copayer id making the request. * @param {string} auth.message - (Optional) The contents of the request to be signed. Only needed if no session token is provided. * @param {string} auth.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} auth.session - (Optional) A valid session token previously obtained using the #login method * @param {string} [auth.walletId] - The wallet id to use as current wallet for this request (only when copayer is support staff). */ WalletService.getInstanceWithAuth = function(opts, config, auth, cb) { throw 'Must override'; }; WalletService.prototype.initInstanceWithAuth = function(auth, cb) { var self = this; if (auth.session) { if (!self.checkRequired(auth, ['copayerId', 'session'], cb)) { return; } } else { if (!self.checkRequired(auth, ['copayerId', 'message', 'signature'], cb)) { return; } } function withSignature(cb) { self.storage.fetchCopayerLookup(auth.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 = !!self._getSigningKey(auth.message, auth.signature, copayer.requestPubKeys); if (!isValid) { return cb(new ClientError(Errors.codes.NOT_AUTHORIZED, 'Invalid signature')); } self.walletId = copayer.walletId; } else { self.walletId = auth.walletId || copayer.walletId; self.copayerIsSupportStaff = true; } self.copayerId = auth.copayerId; return cb(null, self); }); }; function withSession(cb) { self.storage.getSession(auth.copayerId, function(err, s) { if (err) { return cb(err); } var isValid = s && s.id == auth.session && s.isValid(); if (!isValid) { return cb(new ClientError(Errors.codes.NOT_AUTHORIZED, 'Session expired')); } self.storage.fetchCopayerLookup(auth.copayerId, function(err, copayer) { if (err) { return cb(err); } if (!copayer) { return cb(new ClientError(Errors.codes.NOT_AUTHORIZED, 'Copayer not found')); } self.copayerId = auth.copayerId; self.walletId = copayer.walletId; return cb(null, self); }); }); }; var authFn = auth.session ? withSession : withSignature; return authFn(cb); }; WalletService.prototype._runLocked = function(cb, task) { $.checkState(this.walletId); lock.runLocked(this.walletId, cb, task); }; 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 = new self.ctx.Session({ 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); }; /** * Gets the storage for this instance of the server. */ WalletService.prototype.getStorage = function() { return self.storage; }; /** * Gets the message broker for this instance of the server. */ WalletService.prototype.getMessageBroker = function() { return messageBroker; }; /** * 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.networkName[=self.LIVENET.name] - The 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; var pubKey; if (!self.checkRequired(opts, ['name', 'm', 'n', 'pubKey'], cb)) { return; } if (lodash.isEmpty(opts.name)) { return cb(new ClientError('Invalid wallet name')); } if (!self.ctx.Wallet.verifyCopayerLimits(opts.m, opts.n)) { return cb(new ClientError('Invalid combination of required copayers / total copayers')); } opts.networkName = opts.networkName || self.LIVENET.name; if (!lodash.includes([self.LIVENET.name, self.TESTNET.name], opts.networkName)) { return cb(new ClientError('Invalid network')); } opts.supportBIP44AndP2PKH = lodash.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 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 = self.ctx.Wallet.create({ id: opts.id, name: opts.name, m: opts.m, n: opts.n, networkName: opts.networkName, pubKey: pubKey.toString(), singleAddress: !!opts.singleAddress, derivationStrategy: derivationStrategy, addressType: addressType, }); self.storage.storeWallet(wallet, function(err) { log.debug('Wallet created', wallet.id, opts.networkName); 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.fetchAddress(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? async.detectSeries(lodash.values([self.LIVENET, self.TESTNET]), function(networkName, nextNetwork) { var bc = self._getBlockchainExplorer(networkName); if (!bc) { return nextNetwork(false); } bc.getTransaction(opts.identifier, function(err, tx) { if (err || !tx) { return nextNetwork(false); } var outputs = lodash.head(self._normalizeTxHistory(tx)).outputs; var toAddresses = lodash.map(outputs, 'address'); async.detect(toAddresses, function(addressStr, nextAddress) { self.storage.fetchAddress(addressStr, function(err, address) { if (err || !address) { return nextAddress(false); } walletId = address.walletId; nextAddress(true); }); }, function() { nextNetwork(!!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', 'addressManager', 'customData']; wallet.copayers = lodash.map(wallet.copayers, function(copayer) { if (copayer.id == self.copayerId) { return copayer; } return lodash.omit(copayer, 'customData'); }); if (!opts.includeExtendedInfo) { wallet = lodash.omit(wallet, walletExtendedKeys); wallet.copayers = lodash.map(wallet.copayers, function(copayer) { return lodash.omit(copayer, copayerExtendedKeys); }); } status.wallet = wallet; next(); }); }, function(next) { console.log('WalletService.prototype.getStatus getBalance'); self.getBalance(opts, function(err, balance) { if (err) { return next(err); } status.balance = balance; next(); }); }, function(next) { console.log('WalletService.prototype.getStatus getPendingTxs'); self.getPendingTxs({}, function(err, pendingTxps) { if (err) { return next(err); } status.pendingTxps = pendingTxps; next(); }); }, function(next) { console.log('WalletService.prototype.getStatus getPreferences'); 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 this.ctx.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 HDPublicKey(xPubKey)).deriveChild(Constants.PATHS.REQUEST_KEY_AUTH).publicKey; return this.ctx.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 lodash.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 (lodash.isFunction(opts)) { cb = opts; opts = {}; } opts = opts || {}; log.debug('Notification', type, data); cb = cb || function() {}; var walletId = self.walletId || data.walletId; var copayerId = self.copayerId || data.copayerId; $.checkState(walletId); self.storage.fetchWallet(walletId, function(err, wallet) { if (err) { return cb(err); } var notification = Model.Notification.create({ type: type, data: data, ticker: self.notifyTicker++, creatorId: opts.isGlobal ? null : copayerId, walletId: walletId, networkName: opts.isGlobal && !wallet ? walletId : wallet.networkName, }); self.storage.storeNotification(walletId, notification, function(err) { messageBroker.send(notification); return cb(); }); }); }; WalletService.prototype._notifyTxProposalAction = function(type, txp, extraArgs, cb) { var self = this; if (lodash.isFunction(extraArgs)) { cb = extraArgs; extraArgs = {}; } var data = lodash.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 = self.ctx.Copayer.create({ 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 (!self.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 = lodash.find(wallet.copayers, { id: opts.copayerId }).xPubKey; if (!self._verifyRequestPubKey(opts.requestPubKey, opts.signature, xPubKey)) { return cb(Errors.NOT_AUTHORIZED); } if (copayer.requestPubKeys.length > self.ctx.Defaults.MAX_KEYS) { return cb(Errors.TOO_MANY_KEYS); } self._addKeyToCopayer(wallet, copayer, opts, cb); }); }); }; WalletService.prototype.getClientVersion = function(version) { return this.clientVersion; }; WalletService.prototype._setClientVersion = function(version) { delete this.parsedClientVersion; this.clientVersion = version; }; WalletService.prototype._parseClientVersion = function() { if (lodash.isUndefined(this.parsedClientVersion)) { this.parsedClientVersion = this.ctx.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.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 (!self.checkRequired(opts, ['walletId', 'name', 'xPubKey', 'requestPubKey', 'copayerSignature'], cb)) { return; } if (lodash.isEmpty(opts.name)) { return cb(new ClientError('Invalid copayer name')); } try { HDPublicKey(opts.xPubKey); } catch (ex) { return cb(new ClientError('Invalid extended public key')); } opts.supportBIP44AndP2PKH = lodash.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.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 (lodash.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 - Currency 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 lodash.isString(value) && value.length == 2; }, }, { name: 'unit', isValid: function(value) { return lodash.isString(value) && lodash.includes(self.ctx.Unit().getCodes(), value); }, }]; opts = lodash.pick(opts, lodash.map(preferences, 'name')); try { lodash.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(lodash.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 = lodash.takeRight(lodash.reject(addresses, { isChange: true }), self.ctx.Defaults.MAX_MAIN_ADDRESS_GAP); if (latestAddresses.length < self.ctx.Defaults.MAX_MAIN_ADDRESS_GAP || lodash.some(latestAddresses, { hasActivity: true })) return cb(null, true); var bc = self._getBlockchainExplorer(latestAddresses[0].networkName); 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 = 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 (!lodash.isEmpty(addresses)) { return cb(null, lodash.head(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); }); }); }); }; /** * 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 = lodash.reject(addresses, { isChange: true }); if (opts.reverse) { onlyMain.reverse(); } if (opts.limit > 0) { onlyMain = lodash.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 (!self.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(networkName) { var self = this; if (self.blockchainExplorer) { return self.blockchainExplorer; } // Use network alias to lookup configuration. var network = self.ctx.Networks.get(networkName); var config = {}; var provider; if (self.config[network.currency].blockchainExplorerOpts) { // TODO: provider should be configurable provider = self.config[network.currency].blockchainExplorerOpts.defaultProvider; if (self.config[network.currency].blockchainExplorerOpts[provider][network.alias]) { config = self.config[network.currency].blockchainExplorerOpts[provider][network.alias]; } } var opts = {}; opts.provider = provider; opts.networkAlias = network.alias; opts.userAgent = WalletService.getServiceVersion(); var bc; try { bc = new self.ctx.BlockchainExplorer(opts, self.config); } catch (ex) { log.warn('Could not instantiate blockchain explorer', ex); } return bc; }; WalletService.prototype._getUtxos = function(addresses, cb) { var self = this; if (addresses.length == 0) { return cb(null, []); } var networkName = self.ctx.Address(addresses[0]).toObject().network; var bc = self._getBlockchainExplorer(networkName); if (!bc) { return cb(new Error('Could not get blockchain explorer instance')); } bc.getUtxos(addresses, function(err, utxos) { if (err) { return cb(err); } var utxos = lodash.map(utxos, function(utxo) { var u = lodash.pick(utxo, ['txid', 'vout', 'address', 'scriptPubKey', 'amount', self.atomicsName, 'confirmations']); u.confirmations = u.confirmations || 0; u.locked = false; u[self.atomicsName] = lodash.isNumber(u[self.atomicsName]) ? +u[self.atomicsName] : self.ctx.Utils.strip(u.amount * 1e8); delete u.amount; return u; }); return cb(null, utxos); }); }; WalletService.prototype._getUtxosForCurrentWallet = function(addresses, cb) { var self = this; function utxoKey(utxo) { return utxo.txid + '|' + utxo.vout }; var allAddresses, allUtxos, utxoIndex; async.series([ function(next) { if (lodash.isArray(addresses)) { allAddresses = addresses; return next(); } self.storage.fetchAddresses(self.walletId, function(err, addresses) { allAddresses = addresses; return next(); }); }, function(next) { if (allAddresses.length == 0) { return cb(null, []); } var addressStrs = lodash.map(allAddresses, 'address'); self._getUtxos(addressStrs, function(err, utxos) { if (err) { return next(err); } if (utxos.length == 0) { return cb(null, []); } allUtxos = utxos; utxoIndex = lodash.keyBy(allUtxos, utxoKey); return next(); }); }, function(next) { self.getPendingTxs({}, function(err, txps) { if (err) { return next(err); } var lockedInputs = lodash.map(lodash.flatten(lodash.map(txps, 'inputs')), utxoKey); lodash.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 = lodash.map(lodash.flatten(lodash.map(txs, 'inputs')), utxoKey); lodash.each(spentInputs, function(input) { if (utxoIndex[input]) { utxoIndex[input].spent = true; } }); allUtxos = lodash.reject(allUtxos, { spent: true }); return next(); }); }, function(next) { // Needed for the clients to sign UTXOs var addressToPath = lodash.keyBy(allAddresses, 'address'); lodash.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 {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 (lodash.isUndefined(opts.addresses)) { self._getUtxosForCurrentWallet(null, cb); } else { self._getUtxos(opts.addresses, cb); } }; WalletService.prototype._totalizeUtxos = function(utxos) { var self = this; var balance = { totalAmount: lodash.sumBy(utxos, self.atomicsName), lockedAmount: lodash.sumBy(lodash.filter(utxos, 'locked'), self.atomicsName), totalConfirmedAmount: lodash.sumBy(lodash.filter(utxos, 'confirmations'), self.atomicsName), lockedConfirmedAmount: lodash.sumBy(lodash.filter(lodash.filter(utxos, 'locked'), 'confirmations'), self.atomicsName), }; balance.availableAmount = balance.totalAmount - balance.lockedAmount; balance.availableConfirmedAmount = balance.totalConfirmedAmount - balance.lockedConfirmedAmount; return balance; }; WalletService.prototype._getBalanceFromAddresses = function(addresses, cb) { var self = this; self._getUtxosForCurrentWallet(addresses, function(err, utxos) { if (err) { return cb(err); } var balance = self._totalizeUtxos(utxos); // Compute balance by address var byAddress = {}; lodash.each(lodash.keyBy(lodash.sortBy(utxos, 'address'), 'address'), function(value, key) { byAddress[key] = { address: key, path: value.path, amount: 0, }; }); lodash.each(utxos, function(utxo) { byAddress[utxo.address].amount += utxo[self.atomicsName]; }); balance.byAddress = lodash.values(byAddress); return cb(null, balance); }); }; WalletService.prototype._getBalanceOneStep = function(opts, cb) { var self = this; self.storage.fetchAddresses(self.walletId, function(err, addresses) { if (err) { return cb(err); } self._getBalanceFromAddresses(addresses, function(err, balance) { if (err) { return cb(err); } // Update cache async.series([ function(next) { self.storage.cleanActiveAddresses(self.walletId, next); }, function(next) { var active = lodash.map(balance.byAddress, 'address') self.storage.storeActiveAddresses(self.walletId, active, next); }, ], function(err) { if (err) { log.warn('Could not update wallet cache', err); } return cb(null, balance); }); }); }); }; WalletService.prototype._getActiveAddresses = function(cb) { var self = this; self.storage.fetchActiveAddresses(self.walletId, function(err, active) { if (err) { log.warn('Could not fetch active addresses from cache', err); return cb(); } if (!lodash.isArray(active)) { return cb(); } self.storage.fetchAddresses(self.walletId, function(err, allAddresses) { if (err) { return cb(err); } var now = Math.floor(Date.now() / 1000); var recent = lodash.map(lodash.filter(allAddresses, function(address) { return address.createdOn > (now - 24 * 3600); }), 'address'); var result = lodash.union(active, recent); var index = lodash.keyBy(allAddresses, 'address'); result = lodash.compact(lodash.map(result, function(r) { return index[r]; })); return cb(null, result); }); }); }; /** * Get wallet balance. * @param {Object} opts * @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) { var self = this; opts = opts || {}; if (!opts.twoStep) { return self._getBalanceOneStep(opts, cb); } self.storage.countAddresses(self.walletId, function(err, nbAddresses) { if (err) { return cb(err); } if (nbAddresses < self.ctx.Defaults.TWO_STEP_BALANCE_THRESHOLD) { return self._getBalanceOneStep(opts, cb); } self._getActiveAddresses(function(err, activeAddresses) { if (err) { return cb(err); } if (!lodash.isArray(activeAddresses)) { return self._getBalanceOneStep(opts, cb); } else { log.debug('Requesting partial balance for ' + activeAddresses.length + ' out of ' + nbAddresses + ' addresses'); self._getBalanceFromAddresses(activeAddresses, function(err, partialBalance) { if (err) { return cb(err); } cb(null, partialBalance); setTimeout(function() { self._getBalanceOneStep(opts, function(err, fullBalance) { if (err) { return; } if (!lodash.isEqual(partialBalance, fullBalance)) { log.info('Balance in active addresses differs from final balance'); self._notify('BalanceUpdated', fullBalance, { isGlobal: true }); } }); }, 1); return; }); } }); }); }; /** * 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 atomic units). * @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 || {}; var feeArgs = !!opts.feeLevel + lodash.isNumber(opts.feePerKb); if (feeArgs > 1) { return cb(new ClientError('Only one of feeLevel/feePerKb can be specified')); } if (feeArgs == 0) { log.debug('No fee provided, using "normal" fee level'); opts.feeLevel = 'normal'; } if (opts.feeLevel) { if (!lodash.some(self.ctx.Defaults.FEE_LEVELS, { name: opts.feeLevel })) return cb(new ClientError('Invalid fee level. Valid values are ' + lodash.map(self.ctx.Defaults.FEE_LEVELS, 'name').join(', '))); } if (lodash.isNumber(opts.feePerKb)) { if (opts.feePerKb < self.ctx.Defaults.MIN_FEE_PER_KB || opts.feePerKb > self.ctx.Defaults.MAX_FEE_PER_KB) { return cb(new ClientError('Invalid fee per KB')); } } self.getWallet({}, function(err, wallet) { if (err) { return cb(err); } self._getUtxosForCurrentWallet(null, 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 = lodash.reject(utxos, 'locked'); if (!!opts.excludeUnconfirmedUtxos) { inputs = lodash.filter(inputs, 'confirmations'); } inputs = lodash.sortBy(inputs, function(input) { return -input[self.atomicsName]; }); if (lodash.isEmpty(inputs)) { return cb(null, info); } self._getFeePerKb(wallet, opts, function(err, feePerKb) { if (err) { return cb(err); } info.feePerKb = feePerKb; var txp = new self.ctx.TxProposal({ walletId: self.walletId, networkName: wallet.networkName, 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 = lodash.partition(inputs, function(input) { return input[self.atomicsName] > feePerInput; }); info.utxosBelowFee = partitionedByAmount[1].length; info.amountBelowFee = lodash.sumBy(partitionedByAmount[1], self.atomicsName); inputs = partitionedByAmount[0]; lodash.each(inputs, function(input, i) { var sizeInKb = (baseTxpSize + (i + 1) * sizePerInput) / 1000.; if (sizeInKb > self.ctx.Defaults.MAX_TX_SIZE_IN_KB) { info.utxosAboveMaxSize = inputs.length - i; info.amountAboveMaxSize = lodash.sumBy(lodash.slice(inputs, i), self.atomicsName); return false; } txp.inputs.push(input); }); if (lodash.isEmpty(txp.inputs)) { return cb(null, info); } var fee = txp.getEstimatedFee(); var amount = lodash.sumBy(txp.inputs, self.atomicsName) - fee; if (amount < self.ctx.Defaults.MIN_OUTPUT_AMOUNT) { return cb(null, info); } info.size = txp.getEstimatedSize(); info.fee = fee; info.amount = amount; if (opts.returnInputs) { info.inputs = lodash.shuffle(txp.inputs); } return cb(null, info); }); }); }); }; WalletService.prototype._sampleFeeLevels = function(networkName, points, cb) { var self = this; var bc = self._getBlockchainExplorer(networkName); if (!bc) { return cb(new Error('Could not get blockchain explorer instance')); } bc.estimateFee(points, function(err, result) { if (err) { log.error('Error estimating fee', err); return cb(err); } var failed = []; var levels = lodash.fromPairs(lodash.map(points, function(p) { var feePerKb = lodash.isObject(result) ? +result[p] : -1; if (feePerKb < 0) { failed.push(p); } return [p, self.ctx.Utils.strip(feePerKb * 1e8)]; })); if (failed.length) { var logge