UNPKG

@owstack/wallet-service

Version:

A service for multisignature HD wallets

1,574 lines (1,374 loc) 128 kB
const owsCommon = require('@owstack/ows-common'); const keyLib = require('@owstack/key-lib'); const async = require('async'); const baseConfig = require('config'); const ClientError = require('./errors/clienterror'); const Constants = owsCommon.Constants; const EmailValidator = require('email-validator'); const errors = owsCommon.errors; const Errors = require('./errors/errordefinitions'); const FiatRateService = require('./fiatrateservice'); const HDPublicKey = keyLib.HDPublicKey; const Lock = require('./lock'); const log = require('npmlog'); const MessageBroker = require('./messagebroker'); const Model = require('./model'); const pkg = require('../../package'); const PublicKey = keyLib.PublicKey; const lodash = owsCommon.deps.lodash; const $ = require('preconditions').singleton(); log.debug = log.verbose; log.disableColor(); let serviceVersion; /** * Static services shared among all instances of this WalletService. */ let lock; let fiatRateService; let 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.atomicsAccessor = this.ctx.Unit().atomicsAccessor(); 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) { const 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) { const self = this; $.shouldBeFunction(cb); opts = opts || {}; self.config = config || baseConfig; self.notifyTicker = 0; self._setClientVersion(opts.clientVersion); self.setLog(); function initStorage(cb) { if (opts.storage) { self.storage = opts.storage; return cb(); } if (!self.storage) { const 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 { const 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) { const 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) { const 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) { const 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) { const 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); } const 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); }); }); } const 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) { const self = this; let 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) { this.storage.removeSession(this.copayerId, cb); }; /** * Gets the storage for this instance of the server. */ WalletService.prototype.getStorage = function () { return this.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) { const self = this; let 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; const derivationStrategy = opts.supportBIP44AndP2PKH ? Constants.DERIVATION_STRATEGIES.BIP44 : Constants.DERIVATION_STRATEGIES.BIP45; const 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')); } let 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) { const 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) { const 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) { const self = this; if (!opts.identifier) { return cb(); } let 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); } const 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) { const bc = self._getBlockchainExplorer(networkName); if (!bc) { return nextNetwork(false); } bc.getTransaction(opts.identifier, function (err, tx) { if (err || !tx) { return nextNetwork(false); } const outputs = lodash.head(self._normalizeTxHistory(tx)).outputs; const 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) { const self = this; opts = opts || {}; const status = {}; async.parallel([ function (next) { self.getWallet({}, function (err, wallet) { if (err) { return next(err); } const walletExtendedKeys = ['publicKeyRing', 'pubKey', 'addressManager']; const 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) { 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 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) { const 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) { const 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) { const self = this; if (lodash.isFunction(opts)) { cb = opts; opts = {}; } opts = opts || {}; log.debug('Notification', type, data); cb = cb || function () {}; const walletId = self.walletId || data.walletId; const copayerId = self.copayerId || data.copayerId; $.checkState(walletId); self.storage.fetchWallet(walletId, function (err, wallet) { if (err) { return cb(err); } const 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) { if (err) { log.error(err); } messageBroker.send(notification); return cb(); }); }); }; WalletService.prototype._notifyTxProposalAction = function (type, txp, extraArgs, cb) { const self = this; if (lodash.isFunction(extraArgs)) { cb = extraArgs; extraArgs = {}; } const 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) { const self = this; const 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) { const 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) { const 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); } const 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 () { const 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) { const 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.')); } } const 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) { const self = this; opts = opts || {}; const 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) { const value = opts[preference.name]; if (!value) { return; } if (!preference.isValid(value)) { throw `Invalid ${ preference.name}`; } }); } 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); } const newPref = Model.Preferences.create({ walletId: self.walletId, copayerId: self.copayerId, }); const 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) { const 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) { const self = this; if (ignoreMaxGap) { return cb(null, true); } self.storage.fetchAddresses(self.walletId, function (err, addresses) { if (err) { return cb(err); } const 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); } const bc = self._getBlockchainExplorer(latestAddresses[0].networkName); if (!bc) { return cb(new Error('Could not get blockchain explorer instance')); } let activityFound = false; let 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); } const 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) { const self = this; opts = opts || {}; function createNewAddress(wallet, cb) { const 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); } const 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) { const self = this; opts = opts || {}; self.storage.fetchAddresses(self.walletId, function (err, addresses) { if (err) { return cb(err); } let 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) { const self = this; if (!self.checkRequired(opts, ['message', 'signature'], cb)) { return; } self.getWallet({}, function (err, wallet) { if (err) { return cb(err); } const copayer = wallet.getCopayer(self.copayerId); const isValid = !!self._getSigningKey(opts.message, opts.signature, copayer.requestPubKeys); return cb(null, isValid); }); }; WalletService.prototype._getBlockchainExplorer = function (networkName) { const self = this; if (self.blockchainExplorer) { return self.blockchainExplorer; } // Use network alias to lookup configuration. const network = self.ctx.Networks.get(networkName); let config = {}; let 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]; } } const opts = {}; opts.provider = provider; opts.networkAlias = network.alias; opts.userAgent = WalletService.getServiceVersion(); let 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) { const self = this; if (addresses.length == 0) { return cb(null, []); } const networkName = self.ctx.Address(addresses[0]).toObject().network; const 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); } utxos = lodash.map(utxos, function (utxo) { const u = lodash.pick(utxo, ['txid', 'vout', 'address', 'scriptPubKey', 'amount', self.atomicsAccessor, 'confirmations']); u.confirmations = u.confirmations || 0; u.locked = false; u[self.atomicsAccessor] = lodash.isNumber(u[self.atomicsAccessor]) ? +u[self.atomicsAccessor] : self.ctx.Utils.strip(u.amount * 1e8); delete u.amount; return u; }); return cb(null, utxos); }); }; WalletService.prototype._getUtxosForCurrentWallet = function (addresses, cb) { const self = this; function utxoKey(utxo) { return `${utxo.txid }|${ utxo.vout}`; } let allAddresses; let allUtxos; let 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, []); } const 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); } const 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) { const 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); } const 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 const 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) { const self = this; opts = opts || {}; if (lodash.isUndefined(opts.addresses)) { self._getUtxosForCurrentWallet(null, cb); } else { self._getUtxos(opts.addresses, cb); } }; WalletService.prototype._totalizeUtxos = function (utxos) { const self = this; const balance = { totalAmount: lodash.sumBy(utxos, self.atomicsAccessor), lockedAmount: lodash.sumBy(lodash.filter(utxos, 'locked'), self.atomicsAccessor), totalConfirmedAmount: lodash.sumBy(lodash.filter(utxos, 'confirmations'), self.atomicsAccessor), lockedConfirmedAmount: lodash.sumBy(lodash.filter(lodash.filter(utxos, 'locked'), 'confirmations'), self.atomicsAccessor), }; balance.availableAmount = balance.totalAmount - balance.lockedAmount; balance.availableConfirmedAmount = balance.totalConfirmedAmount - balance.lockedConfirmedAmount; return balance; }; WalletService.prototype._getBalanceFromAddresses = function (addresses, cb) { const self = this; self._getUtxosForCurrentWallet(addresses, function (err, utxos) { if (err) { return cb(err); } const balance = self._totalizeUtxos(utxos); // Compute balance by address const 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.atomicsAccessor]; }); balance.byAddress = lodash.values(byAddress); return cb(null, balance); }); }; WalletService.prototype._getBalanceOneStep = function (opts, cb) { const 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) { const 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) { const 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); } const now = Math.floor(Date.now() / 1000); const recent = lodash.map(lodash.filter(allAddresses, function (address) { return address.createdOn > (now - 24 * 3600); }), 'address'); let result = lodash.union(active, recent); const 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) { const 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) {