UNPKG

@owstack/wallet-service

Version:

A service for multisignature HD wallets

1,144 lines (1,017 loc) 30.2 kB
const owsCommon = require('@owstack/ows-common'); const async = require('async'); const log = require('npmlog'); const Model = require('./model'); const mongodb = require('mongodb'); const lodash = owsCommon.deps.lodash; const $ = require('preconditions').singleton(); log.debug = log.verbose; log.disableColor(); const collections = { WALLETS: 'wallets', TXS: 'txs', ADDRESSES: 'addresses', NOTIFICATIONS: 'notifications', COPAYERS_LOOKUP: 'copayers_lookup', PREFERENCES: 'preferences', EMAIL_QUEUE: 'email_queue', CACHE: 'cache', FIAT_RATES: 'fiat_rates', TX_NOTES: 'tx_notes', SESSIONS: 'sessions', PUSH_NOTIFICATION_SUBS: 'push_notification_subs', TX_CONFIRMATION_SUBS: 'tx_confirmation_subs', }; class Storage { constructor(context, config, opts) { // 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); opts = opts || {}; opts.creator = opts.creator || 'unknown'; this.config = config || {}; this.opts = opts; // If a database object is provided then use it. if (this.opts.db) { Storage.db = this.opts.db; } } } Storage.db; Storage.prototype._createIndexes = function () { Storage.db.collection(collections.WALLETS).createIndex({ id: 1 }); Storage.db.collection(collections.COPAYERS_LOOKUP).createIndex({ copayerId: 1 }); Storage.db.collection(collections.TXS).createIndex({ walletId: 1, id: 1, }); Storage.db.collection(collections.TXS).createIndex({ walletId: 1, isPending: 1, txid: 1, }); Storage.db.collection(collections.TXS).createIndex({ walletId: 1, createdOn: -1, }); Storage.db.collection(collections.NOTIFICATIONS).createIndex({ walletId: 1, id: 1, }); Storage.db.collection(collections.ADDRESSES).createIndex({ walletId: 1, createdOn: 1, }); Storage.db.collection(collections.ADDRESSES).createIndex({ address: 1, }); Storage.db.collection(collections.EMAIL_QUEUE).createIndex({ notificationId: 1, }); Storage.db.collection(collections.CACHE).createIndex({ walletId: 1, type: 1, key: 1, }); Storage.db.collection(collections.TX_NOTES).createIndex({ walletId: 1, txid: 1, }); Storage.db.collection(collections.PUSH_NOTIFICATION_SUBS).createIndex({ copayerId: 1, }); Storage.db.collection(collections.TX_CONFIRMATION_SUBS).createIndex({ copayerId: 1, txid: 1, }); Storage.db.collection(collections.SESSIONS).createIndex({ copayerId: 1 }); }; Storage.prototype.connect = function (cb) { const self = this; if (Storage.db) { return cb(); } const config = self.config.mongoDb || {}; mongodb.MongoClient.connect(config.uri, function (err, db) { if (err) { log.error(`Unable to connect to the mongoDB for ${ self.opts.creator }. Check the credentials.`); return cb(err); } Storage.db = db; self._createIndexes(); console.log(`Connection established to mongoDB for ${ self.opts.creator}`); return cb(); }); }; Storage.prototype.disconnect = function (cb) { Storage.db.close(true, function (err) { if (err) { return cb(err); } Storage.db = null; return cb(); }); }; Storage.prototype.getDB = function () { return Storage.db; }; Storage.prototype.fetchWallet = function (id, cb) { const self = this; Storage.db.collection(collections.WALLETS).findOne({ id: id }, function (err, result) { if (err) { return cb(err); } if (!result) { return cb(); } return cb(null, self.ctx.Wallet.fromObj(result)); }); }; Storage.prototype.storeWallet = function (wallet, cb) { Storage.db.collection(collections.WALLETS).update({ id: wallet.id }, wallet.toObject(), { w: 1, upsert: true, }, cb); }; Storage.prototype.storeWalletAndUpdateCopayersLookup = function (wallet, cb) { const self = this; const copayerLookups = lodash.map(wallet.copayers, function (copayer) { $.checkState(copayer.requestPubKeys); return { copayerId: copayer.id, walletId: wallet.id, requestPubKeys: copayer.requestPubKeys, }; }); Storage.db.collection(collections.COPAYERS_LOOKUP).remove({ walletId: wallet.id }, { w: 1 }, function (err) { if (err) { return cb(err); } Storage.db.collection(collections.COPAYERS_LOOKUP).insert(copayerLookups, { w: 1 }, function (err) { if (err) { return cb(err); } return self.storeWallet(wallet, cb); }); }); }; Storage.prototype.fetchCopayerLookup = function (copayerId, cb) { Storage.db.collection(collections.COPAYERS_LOOKUP).findOne({ copayerId: copayerId }, function (err, result) { if (err) { return cb(err); } if (!result) { return cb(); } if (!result.requestPubKeys) { result.requestPubKeys = [{ key: result.requestPubKey, signature: result.signature, }]; } return cb(null, result); }); }; // TODO: should be done client-side Storage.prototype._completeTxData = function (walletId, txs, cb) { const self = this; self.fetchWallet(walletId, function (err, wallet) { if (err) { return cb(err); } lodash.each([].concat(txs), function (tx) { tx.derivationStrategy = wallet.derivationStrategy || 'BIP45'; tx.creatorName = wallet.getCopayer(tx.creatorId).name; lodash.each(tx.actions, function (action) { action.copayerName = wallet.getCopayer(action.copayerId).name; }); if (tx.status == 'accepted') { tx.raw = tx.getRawTx(); } }); return cb(null, txs); }); }; // TODO: remove walletId from signature Storage.prototype.fetchTx = function (walletId, txProposalId, cb) { const self = this; Storage.db.collection(collections.TXS).findOne({ id: txProposalId, walletId: walletId }, function (err, result) { if (err) { return cb(err); } if (!result) { return cb(); } return self._completeTxData(walletId, self.ctx.TxProposal.fromObj(result), cb); }); }; Storage.prototype.fetchTxByHash = function (hash, cb) { const self = this; Storage.db.collection(collections.TXS).findOne({ txid: hash, }, function (err, result) { if (err) { return cb(err); } if (!result) { return cb(); } return self._completeTxData(result.walletId, self.ctx.TxProposal.fromObj(result), cb); }); }; Storage.prototype.fetchLastTxs = function (walletId, creatorId, limit, cb) { const self = this; Storage.db.collection(collections.TXS).find({ walletId: walletId, creatorId: creatorId, }, { limit: limit || 5 }).sort({ createdOn: -1 }).toArray(function (err, result) { if (err) { return cb(err); } if (!result) { return cb(); } const txs = lodash.map(result, function (tx) { return self.ctx.TxProposal.fromObj(tx); }); return cb(null, txs); }); }; Storage.prototype.fetchPendingTxs = function (walletId, cb) { const self = this; Storage.db.collection(collections.TXS).find({ walletId: walletId, isPending: true, }).sort({ createdOn: -1 }).toArray(function (err, result) { if (err) { return cb(err); } if (!result) { return cb(); } const txs = lodash.map(result, function (tx) { return self.ctx.TxProposal.fromObj(tx); }); return self._completeTxData(walletId, txs, cb); }); }; /** * fetchTxs. Times are in UNIX EPOCH (seconds) * * @param walletId * @param opts.minTs * @param opts.maxTs * @param opts.limit */ Storage.prototype.fetchTxs = function (walletId, opts, cb) { const self = this; opts = opts || {}; const tsFilter = {}; if (!opts.minTs) { opts.minTs = 0; } if (!opts.maxTs) { opts.maxTs = Date.now(); } if (lodash.isNumber(opts.minTs)) { tsFilter.$gte = opts.minTs; } if (lodash.isNumber(opts.maxTs)) { tsFilter.$lte = opts.maxTs; } const filter = { walletId: walletId }; if (!lodash.isEmpty(tsFilter)) { filter.createdOn = tsFilter; } const mods = {}; if (lodash.isNumber(opts.limit)) { mods.limit = opts.limit; } Storage.db.collection(collections.TXS).find(filter, mods).sort({ createdOn: -1 }).toArray(function (err, result) { if (err) { return cb(err); } if (!result) { return cb(); } const txs = lodash.map(result, function (tx) { return self.ctx.TxProposal.fromObj(tx); }); return self._completeTxData(walletId, txs, cb); }); }; /** * fetchBroadcastedTxs. Times are in UNIX EPOCH (seconds) * * @param walletId * @param opts.minTs * @param opts.maxTs * @param opts.limit */ Storage.prototype.fetchBroadcastedTxs = function (walletId, opts, cb) { const self = this; opts = opts || {}; const tsFilter = {}; if (lodash.isNumber(opts.minTs)) { tsFilter.$gte = opts.minTs; } if (lodash.isNumber(opts.maxTs)) { tsFilter.$lte = opts.maxTs; } const filter = { walletId: walletId, status: 'broadcasted', }; if (!lodash.isEmpty(tsFilter)) { filter.broadcastedOn = tsFilter; } const mods = {}; if (lodash.isNumber(opts.limit)) { mods.limit = opts.limit; } Storage.db.collection(collections.TXS).find(filter, mods).sort({ createdOn: -1 }).toArray(function (err, result) { if (err) { return cb(err); } if (!result) { return cb(); } const txs = lodash.map(result, function (tx) { return self.ctx.TxProposal.fromObj(tx); }); return self._completeTxData(walletId, txs, cb); }); }; /** * Retrieves notifications after a specific id or from a given ts (whichever is more recent). * * @param {String} notificationId * @param {Number} minTs * @returns {Notification[]} Notifications */ Storage.prototype.fetchNotifications = function (walletId, notificationId, minTs, cb) { const self = this; function makeId(timestamp) { return lodash.padStart(timestamp, 14, '0') + lodash.repeat('0', 4); } let minId = makeId(minTs); if (notificationId) { minId = notificationId > minId ? notificationId : minId; } Storage.db.collection(collections.NOTIFICATIONS) .find({ walletId: walletId, id: { $gt: minId, }, }) .sort({ id: 1 }) .toArray(function (err, result) { if (err) { return cb(err); } if (!result) { return cb(); } const notifications = lodash.map(result, function (notification) { return Model.Notification.fromObj(notification); }); return cb(null, notifications); }); }; // TODO: remove walletId from signature Storage.prototype.storeNotification = function (walletId, notification, cb) { Storage.db.collection(collections.NOTIFICATIONS).insert(notification, { w: 1 }, cb); }; // TODO: remove walletId from signature Storage.prototype.storeTx = function (walletId, txp, cb) { Storage.db.collection(collections.TXS).update({ id: txp.id, walletId: walletId }, txp.toObject(), { w: 1, upsert: true, }, cb); }; Storage.prototype.removeTx = function (walletId, txProposalId, cb) { Storage.db.collection(collections.TXS).findAndRemove({ id: txProposalId, walletId: walletId }, { w: 1 }, cb); }; Storage.prototype.removeWallet = function (walletId, cb) { async.parallel([ function (next) { Storage.db.collection(collections.WALLETS).findAndRemove({ id: walletId }, next); }, function (next) { const otherCollections = lodash.without(lodash.values(collections), collections.WALLETS); async.each(otherCollections, function (col, next) { Storage.db.collection(col).remove({ walletId: walletId }, next); }, next); }, ], cb); }; Storage.prototype.fetchAddresses = function (walletId, cb) { const self = this; Storage.db.collection(collections.ADDRESSES).find({ walletId: walletId, }).sort({ createdOn: 1 }).toArray(function (err, result) { if (err) { return cb(err); } if (!result) { return cb(); } const addresses = lodash.map(result, function (address) { return self.ctx.Address.fromObj(address); }); return cb(null, addresses); }); }; Storage.prototype.countAddresses = function (walletId, cb) { Storage.db.collection(collections.ADDRESSES).find({ walletId: walletId, }).count(cb); }; Storage.prototype.storeAddress = function (address, cb) { Storage.db.collection(collections.ADDRESSES).update({ address: address.address }, (address.toObject ? address.toObject() : address), { w: 1, upsert: false, }, cb); }; Storage.prototype.storeAddressAndWallet = function (wallet, addresses, cb) { const self = this; function saveAddresses(addresses, cb) { if (lodash.isEmpty(addresses)) { return cb(); } const addrs = lodash.map(addresses, function (address) { return (address.toObject ? address.toObject() : address); }); Storage.db.collection(collections.ADDRESSES).insert(addrs, { w: 1 }, cb); } addresses = [].concat(addresses); if (addresses.length == 0) { return cb(); } async.filter(addresses, function (address, next) { Storage.db.collection(collections.ADDRESSES).findOne({ address: address.address, }, { walletId: true, }, function (err, result) { if (err || !result) { return next(true); } if (result.walletId != wallet.id) { log.warn(`Address ${ address.address } exists in more than one wallet.`); return next(true); } // Ignore if address was already in wallet return next(false); }); }, function (newAddresses) { if (newAddresses.length < addresses.length) { log.warn(`Attempted to store already existing addresses on wallet ${ wallet.id}`); } saveAddresses(newAddresses, function (err) { if (err) { return cb(err); } self.storeWallet(wallet, cb); }); }); }; Storage.prototype.fetchAddress = function (address, cb) { const self = this; Storage.db.collection(collections.ADDRESSES).findOne({ address: address, }, function (err, result) { if (err) { return cb(err); } if (!result) { return cb(); } return cb(null, self.ctx.Address.fromObj(result)); }); }; Storage.prototype.fetchPreferences = function (walletId, copayerId, cb) { Storage.db.collection(collections.PREFERENCES).find({ walletId: walletId, }).toArray(function (err, result) { if (err) { return cb(err); } if (copayerId) { result = lodash.find(result, { copayerId: copayerId }); } if (!result) { return cb(); } let preferences = lodash.map([].concat(result), function (r) { return Model.Preferences.fromObj(r); }); if (copayerId) { preferences = preferences[0]; } return cb(null, preferences); }); }; Storage.prototype.storePreferences = function (preferences, cb) { Storage.db.collection(collections.PREFERENCES).update({ walletId: preferences.walletId, copayerId: preferences.copayerId, }, preferences, { w: 1, upsert: true, }, cb); }; Storage.prototype.storeEmail = function (email, cb) { Storage.db.collection(collections.EMAIL_QUEUE).update({ id: email.id, }, email, { w: 1, upsert: true, }, cb); }; Storage.prototype.fetchUnsentEmails = function (cb) { Storage.db.collection(collections.EMAIL_QUEUE).find({ status: 'pending', }).toArray(function (err, result) { if (err) { return cb(err); } if (!result || lodash.isEmpty(result)) { return cb(null, []); } return cb(null, Model.Email.fromObj(result)); }); }; Storage.prototype.fetchEmailByNotification = function (notificationId, cb) { Storage.db.collection(collections.EMAIL_QUEUE).findOne({ notificationId: notificationId, }, function (err, result) { if (err) { return cb(err); } if (!result) { return cb(); } return cb(null, Model.Email.fromObj(result)); }); }; Storage.prototype.cleanActiveAddresses = function (walletId, cb) { async.series([ function (next) { Storage.db.collection(collections.CACHE).remove({ walletId: walletId, type: 'activeAddresses', }, { w: 1 }, next); }, function (next) { Storage.db.collection(collections.CACHE).insert({ walletId: walletId, type: 'activeAddresses', key: null }, { w: 1 }, next); }, ], cb); }; Storage.prototype.storeActiveAddresses = function (walletId, addresses, cb) { async.each(addresses, function (address, next) { const record = { walletId: walletId, type: 'activeAddresses', key: address, }; Storage.db.collection(collections.CACHE).update({ walletId: record.walletId, type: record.type, key: record.key, }, record, { w: 1, upsert: true, }, next); }, cb); }; // -------- --------------------------- Total // > Time > // ^to <= ^from // ^fwdIndex => ^end Storage.prototype.getTxHistoryCache = function (walletId, from, to, cb) { $.checkArgument(from >= 0); $.checkArgument(from <= to); Storage.db.collection(collections.CACHE).findOne({ walletId: walletId, type: 'historyCacheStatus', key: null }, function (err, result) { if (err) { return cb(err); } if (!result) { return cb(); } if (!result.isUpdated) { return cb(); } // Reverse indexes let fwdIndex = result.totalItems - to; if (fwdIndex < 0) { fwdIndex = 0; } const end = result.totalItems - from; // nothing to return if (end <= 0) { return cb(null, []); } // Cache is OK. Storage.db.collection(collections.CACHE).find({ walletId: walletId, type: 'historyCache', key: { $gte: fwdIndex, $lt: end }, }).sort({ key: -1, }).toArray(function (err, result) { if (err) { return cb(err); } if (!result) { return cb(); } if (result.length < end - fwdIndex) { // some items are not yet defined. return cb(); } const txs = lodash.map(result, 'tx'); return cb(null, txs); }); }); }; Storage.prototype.softResetAllTxHistoryCache = function (cb) { Storage.db.collection(collections.CACHE).update({ type: 'historyCacheStatus', }, { isUpdated: false, }, { multi: true, }, cb); }; Storage.prototype.softResetTxHistoryCache = function (walletId, cb) { Storage.db.collection(collections.CACHE).update({ walletId: walletId, type: 'historyCacheStatus', key: null }, { isUpdated: false, }, { w: 1, upsert: true, }, cb); }; Storage.prototype.clearTxHistoryCache = function (walletId, cb) { Storage.db.collection(collections.CACHE).remove({ walletId: walletId, type: 'historyCache', }, { multi: 1 }, function (err) { if (err) { return cb(err); } Storage.db.collection(collections.CACHE).remove({ walletId: walletId, type: 'historyCacheStatus', key: null }, { w: 1 }, cb); }); }; // items should be in CHRONOLOGICAL order Storage.prototype.storeTxHistoryCache = function (walletId, totalItems, firstPosition, items, cb) { $.shouldBeNumber(firstPosition); $.checkArgument(firstPosition >= 0); $.shouldBeNumber(totalItems); $.checkArgument(totalItems >= 0); lodash.each(items, function (item, i) { item.position = firstPosition + i; }); const cacheIsComplete = (firstPosition == 0); // TODO: check txid uniqness? async.each(items, function (item, next) { const pos = item.position; delete item.position; Storage.db.collection(collections.CACHE).update({ walletId: walletId, type: 'historyCache', key: pos, }, { walletId: walletId, type: 'historyCache', key: pos, tx: item, }, { w: 1, upsert: true, }, next); }, function (err) { if (err) { return cb(err); } Storage.db.collection(collections.CACHE).update({ walletId: walletId, type: 'historyCacheStatus', key: null }, { walletId: walletId, type: 'historyCacheStatus', key: null, totalItems: totalItems, updatedOn: Date.now(), isComplete: cacheIsComplete, isUpdated: true, }, { w: 1, upsert: true, }, cb); }); }; Storage.prototype.fetchActiveAddresses = function (walletId, cb) { Storage.db.collection(collections.CACHE).find({ walletId: walletId, type: 'activeAddresses', }).toArray(function (err, result) { if (err) { return cb(err); } if (lodash.isEmpty(result)) { return cb(); } return cb(null, lodash.compact(lodash.map(result, 'key'))); }); }; Storage.prototype.storeFiatRate = function (providerName, currencyCode, rates, cb) { const now = Date.now(); async.each(rates, function (rate, next) { Storage.db.collection(collections.FIAT_RATES).insert({ provider: providerName, ts: now, currency: currencyCode, // blockchain currency code: rate.code, // Fiat currency code value: rate.value, }, { w: 1 }, next); }, cb); }; Storage.prototype.fetchFiatRate = function (providerName, currencyCode, code, ts, cb) { Storage.db.collection(collections.FIAT_RATES).find({ provider: providerName, currency: currencyCode, code: code, ts: { $lte: ts }, }).sort({ ts: -1 }).limit(1).toArray(function (err, result) { if (err || lodash.isEmpty(result)) { return cb(err); } return cb(null, result[0]); }); }; Storage.prototype.fetchTxNote = function (walletId, txid, cb) { const self = this; Storage.db.collection(collections.TX_NOTES).findOne({ walletId: walletId, txid: txid, }, function (err, result) { if (err) { return cb(err); } if (!result) { return cb(); } return self._completeTxNotesData(walletId, Model.TxNote.fromObj(result), cb); }); }; // TODO: should be done client-side Storage.prototype._completeTxNotesData = function (walletId, notes, cb) { const self = this; self.fetchWallet(walletId, function (err, wallet) { if (err) { return cb(err); } lodash.each([].concat(notes), function (note) { note.editedByName = wallet.getCopayer(note.editedBy).name; }); return cb(null, notes); }); }; /** * fetchTxNotes. Times are in UNIX EPOCH (seconds) * * @param walletId * @param opts.minTs */ Storage.prototype.fetchTxNotes = function (walletId, opts, cb) { const self = this; const filter = { walletId: walletId, }; if (lodash.isNumber(opts.minTs)) { filter.editedOn = { $gte: opts.minTs }; } Storage.db.collection(collections.TX_NOTES).find(filter).toArray(function (err, result) { if (err) { return cb(err); } const notes = lodash.compact(lodash.map(result, function (note) { return Model.TxNote.fromObj(note); })); return self._completeTxNotesData(walletId, notes, cb); }); }; Storage.prototype.storeTxNote = function (txNote, cb) { Storage.db.collection(collections.TX_NOTES).update({ txid: txNote.txid, walletId: txNote.walletId }, txNote.toObject(), { w: 1, upsert: true, }, cb); }; Storage.prototype.getSession = function (copayerId, cb) { const self = this; Storage.db.collection(collections.SESSIONS).findOne({ copayerId: copayerId, }, function (err, result) { if (err || !result) { return cb(err); } return cb(null, self.ctx.Session.fromObj(result)); }); }; Storage.prototype.storeSession = function (session, cb) { Storage.db.collection(collections.SESSIONS).update({ copayerId: session.copayerId, }, session.toObject(), { w: 1, upsert: true, }, cb); }; Storage.prototype.fetchPushNotificationSubs = function (copayerId, cb) { Storage.db.collection(collections.PUSH_NOTIFICATION_SUBS).find({ copayerId: copayerId, }).toArray(function (err, result) { if (err) { return cb(err); } if (!result) { return cb(); } const tokens = lodash.map([].concat(result), function (r) { return Model.PushNotificationSub.fromObj(r); }); return cb(null, tokens); }); }; Storage.prototype.storePushNotificationSub = function (pushNotificationSub, cb) { Storage.db.collection(collections.PUSH_NOTIFICATION_SUBS).update({ copayerId: pushNotificationSub.copayerId, token: pushNotificationSub.token, }, pushNotificationSub, { w: 1, upsert: true, }, cb); }; Storage.prototype.removePushNotificationSub = function (copayerId, token, cb) { Storage.db.collection(collections.PUSH_NOTIFICATION_SUBS).remove({ copayerId: copayerId, token: token, }, { w: 1 }, cb); }; Storage.prototype.fetchActiveTxConfirmationSubs = function (copayerId, cb) { const filter = { isActive: true }; if (copayerId) { filter.copayerId = copayerId; } Storage.db.collection(collections.TX_CONFIRMATION_SUBS).find(filter) .toArray(function (err, result) { if (err) { return cb(err); } if (!result) { return cb(); } const subs = lodash.map([].concat(result), function (r) { return Model.TxConfirmationSub.fromObj(r); }); return cb(null, subs); }); }; Storage.prototype.storeTxConfirmationSub = function (txConfirmationSub, cb) { Storage.db.collection(collections.TX_CONFIRMATION_SUBS).update({ copayerId: txConfirmationSub.copayerId, txid: txConfirmationSub.txid, }, txConfirmationSub, { w: 1, upsert: true, }, cb); }; Storage.prototype.removeTxConfirmationSub = function (copayerId, txid, cb) { Storage.db.collection(collections.TX_CONFIRMATION_SUBS).remove({ copayerId: copayerId, txid: txid, }, { w: 1 }, cb); }; Storage.prototype._dump = function (cb, fn) { fn = fn || console.log; cb = cb || function () {}; Storage.db.collections(function (err, collections) { if (err) { return cb(err); } async.eachSeries(collections, function (col, next) { col.find().toArray(function (err, items) { fn('--------', col.s.name); fn(items); fn('------------------------------------------------------------------\n\n'); next(err); }); }, cb); }); }; Storage.collections = collections; module.exports = Storage;