UNPKG

bitcore-wallet-service

Version:
672 lines 30.4 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.PushNotificationsService = void 0; const async = __importStar(require("async")); const fs = __importStar(require("fs")); const lodash_1 = __importDefault(require("lodash")); require("source-map-support/register"); const config_1 = __importDefault(require("../config")); const common_1 = require("./common"); const logger_1 = __importDefault(require("./logger")); const messagebroker_1 = require("./messagebroker"); const storage_1 = require("./storage"); const Mustache = require('mustache'); const defaultRequest = require('request'); const path = require('path'); const Utils = common_1.Common.Utils; const Defaults = common_1.Common.Defaults; const Constants = common_1.Common.Constants; const sjcl = require('sjcl'); const PUSHNOTIFICATIONS_TYPES = { NewCopayer: { filename: 'new_copayer' }, WalletComplete: { filename: 'wallet_complete' }, NewTxProposal: { filename: 'new_tx_proposal' }, NewOutgoingTx: { filename: ['new_outgoing_tx', 'new_zero_outgoing_tx'] }, NewIncomingTx: { filename: ['new_incoming_tx'] }, TxProposalFinallyRejected: { filename: 'txp_finally_rejected' }, TxConfirmation: { filename: ['tx_confirmation_sender', 'tx_confirmation_receiver'] }, NewAddress: { dataOnly: true }, ScanFinished: { dataOnly: true, broadcastToActiveUsers: true }, NewBlock: { dataOnly: true, broadcastToActiveUsers: true }, TxProposalAcceptedBy: { dataOnly: true }, TxProposalFinallyAccepted: { dataOnly: true }, TxProposalRejectedBy: { dataOnly: true }, TxProposalRemoved: { dataOnly: true } }; class PushNotificationsService { start(opts, cb) { opts = opts || {}; this.request = opts.request || defaultRequest; const _readDirectories = (basePath, cb) => { fs.readdir(basePath, (err, files) => { if (err) return cb(err); async.filter(files, (file, next) => { fs.stat(path.join(basePath, file), (err, stats) => { return next(!err && stats.isDirectory()); }); }, dirs => { return cb(null, dirs); }); }); }; this.templatePath = path.normalize((opts.pushNotificationsOpts.templatePath || __dirname + '../../templates') + '/'); this.defaultLanguage = opts.pushNotificationsOpts.defaultLanguage || 'en'; this.defaultUnit = opts.pushNotificationsOpts.defaultUnit || 'btc'; this.subjectPrefix = opts.pushNotificationsOpts.subjectPrefix || ''; this.pushServerUrl = opts.pushNotificationsOpts.pushServerUrl; this.authorizationKey = opts.pushNotificationsOpts.authorizationKey; this.pushServerUrlBraze = opts.pushNotificationsOpts.pushServerUrlBraze; this.authorizationKeyBraze = opts.pushNotificationsOpts.authorizationKeyBraze; if (!this.authorizationKey && !this.authorizationKeyBraze) return cb(new Error('Missing authorizationKey attribute in configuration.')); async.parallel([ done => { _readDirectories(this.templatePath, (err, res) => { this.availableLanguages = res; done(err); }); }, done => { if (opts.storage) { this.storage = opts.storage; done(); } else { this.storage = new storage_1.Storage(); this.storage.connect(opts.storageOpts, done); } }, done => { this.messageBroker = opts.messageBroker || new messagebroker_1.MessageBroker(opts.messageBrokerOpts); this.messageBroker.onMessage(lodash_1.default.bind(this._sendPushNotifications, this)); done(); } ], err => { if (err) { logger_1.default.error('ERROR:' + err); } return cb(err); }); } _sendPushNotifications(notification, cb) { cb = cb || function () { }; const notifType = lodash_1.default.cloneDeep(PUSHNOTIFICATIONS_TYPES[notification?.type]); if (!notifType) return cb(); if (notification.type === 'NewIncomingTx') { notifType.filename = notifType.filename[0]; if (notification.data.network && notification.data.network !== 'mainnet') { notification.data.networkStr = ' on ' + notification.data.network; } else { notification.data.networkStr = ''; } } else if (notification.type === 'NewOutgoingTx') { notifType.filename = notification.data.amount !== 0 ? notifType.filename[0] : notifType.filename[1]; } else if (notification.type === 'TxConfirmation') { if (notification.data && !notification.data.amount) { notifType.filename = 'tx_confirmation'; } else { notifType.filename = notification.isCreator ? notifType.filename[0] : notifType.filename[1]; } } logger_1.default.debug('Notification received: ' + notification.type); logger_1.default.debug(JSON.stringify(notification)); this._checkShouldSendNotif(notification, (err, should) => { if (err) return cb(err); logger_1.default.debug('Should send notification: ' + should); if (!should) return cb(); this._getRecipientsList(notification, notifType, (err, recipientsList) => { if (err) return cb(err); async.waterfall([ next => { this._readAndApplyTemplates(notification, notifType, recipientsList, next); }, (contents, next) => { this._getSubscriptions(notification, notifType, recipientsList, contents, next); }, (subs, next) => { const notifications = lodash_1.default.map(subs, sub => { if (notification.type === 'NewTxProposal' && sub.copayerId === notification.creatorId) return; const tokenAddress = notification.data && notification.data.tokenAddress ? notification.data.tokenAddress : null; const multisigContractAddress = notification.data && notification.data.multisigContractAddress ? notification.data.multisigContractAddress : null; let notificationData; const walletId = sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(notification.walletId || sub.walletId)); const copayerId = sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(sub.copayerId)); const notification_type = notification.type; const chain = notification?.data?.chain || notification?.data?.coin; const coin = chain; const network = notification?.data?.network ? Utils.getNetworkName(chain, notification.data.network) : null; if (sub.token) { notificationData = { to: sub.token, priority: 'high', restricted_package_name: sub.packageName, data: { walletId, tokenAddress, multisigContractAddress, copayerId, notification_type, coin, chain, network } }; if (!notifType.dataOnly) { notificationData.data.title = sub?.plain?.subject; notificationData.data.body = sub?.plain?.body; notificationData.notification = { title: sub?.plain?.subject, body: sub?.plain?.body, sound: 'default', click_action: 'FCM_PLUGIN_ACTIVITY', icon: 'fcm_push_icon' }; } } if (sub.externalUserId) { const title = sub?.plain?.subject; const body = sub?.plain?.body; const extras = { walletId, copayerId, notification_type, coin, chain, network, tokenAddress, multisigContractAddress, title, body }; const custom_uri = `bitpay://wallet?walletId=${walletId}&tokenAddress=${tokenAddress}&multisigContractAddress=${multisigContractAddress}&copayerId=${copayerId}&coin=${coin}&chain=${chain}&network=${network}&notification_type=${notification_type}&title=${title}&body=${body}`; notificationData = { external_user_ids: [sub.externalUserId], messages: { apple_push: { alert: { title, body }, extra: extras, custom_uri }, android_push: { alert: body, title, extra: extras, custom_uri } } }; if (notifType.dataOnly) { notificationData.messages.apple_push['content-available'] = true; notificationData.messages.android_push['send_to_sync'] = true; } } return notificationData; }); if (notifications && notifications[0] && notifications[0].notification && subs.length > Defaults.PUSH_NOTIFICATION_LIMIT) { logger_1.default.warn(`The recipient list for this push notification is greater than the established limit (${Defaults.PUSH_NOTIFICATION_LIMIT})`); } return next(err, notifications); }, (notifications, next) => { async.each(notifications, (notification, next) => { if (notification && notification.external_user_ids) { this._makeBrazeRequest(notification, (err, response) => { if (err) logger_1.default.error('An error occurred making a braze push notification request:' + err); if (response) { } next(); }); } else if (notification && notification.to) { this._makeRequest(notification, (err, response) => { if (err) logger_1.default.error('An error occurred making a firebase push notification request:' + err); if (response) { } next(); }); } }, err => { return next(err); }); } ], err => { if (err) { logger_1.default.error('An error ocurred generating notification:' + err); } return cb(err); }); }); }); } _checkShouldSendNotif(notification, cb) { if (notification.type != 'NewTxProposal') return cb(null, true); this.storage.fetchWallet(notification.walletId, (err, wallet) => { return cb(err, wallet && wallet.m > 1); }); } _getRecipientsList(notification, notificationType, cb) { if (notificationType.broadcastToActiveUsers) return cb(null, []); this.storage.fetchWallet(notification.walletId, (err, wallet) => { if (err) return cb(err); if (!wallet) return cb(null, []); let unit; if (wallet.coin != Defaults.COIN) { switch (wallet.coin) { case 'pax': unit = 'usdp'; break; default: unit = wallet.coin; } } this.storage.fetchPreferences(notification.walletId, null, (err, preferences) => { if (err) logger_1.default.error('%o', err); if (lodash_1.default.isEmpty(preferences)) preferences = []; const recipientPreferences = lodash_1.default.compact(lodash_1.default.map(preferences, p => { if (!lodash_1.default.includes(this.availableLanguages, p.language)) { if (p.language) logger_1.default.warn('Language for notifications "' + p.language + '" not available.'); p.language = this.defaultLanguage; } return { copayerId: p.copayerId, language: p.language || this.defaultLanguage, unit: unit || p.unit || this.defaultUnit }; })); const copayers = lodash_1.default.keyBy(recipientPreferences, 'copayerId'); const recipientsList = lodash_1.default.compact(lodash_1.default.map(wallet.copayers, copayer => { const p = copayers[copayer.id] || { language: this.defaultLanguage, unit: this.defaultUnit }; return { walletId: notification.walletId, copayerId: copayer.id, language: p.language || this.defaultLanguage, unit: unit || p.unit || this.defaultUnit }; })); return cb(null, recipientsList); }); }); } _readAndApplyTemplates(notification, notifType, recipientsList, cb) { if (!notifType.filename) return cb(null, []); async.map(recipientsList, (recipient, next) => { async.waterfall([ next => { this._getDataForTemplate(notification, recipient, next); }, (data, next) => { async.map(['plain', 'html'], (type, next) => { this._loadTemplate(notifType, recipient, '.' + type, (err, template) => { if (err && type == 'html') return next(); if (err) return next(err); this._applyTemplate(template, data, (err, res) => { return next(err, [type, res]); }); }); }, (err, res) => { return next(err, lodash_1.default.fromPairs(res.filter(Boolean))); }); }, (result, next) => { next(null, result); } ], (err, res) => { next(err, [recipient.language, res]); }); }, (err, res) => { return cb(err, lodash_1.default.fromPairs(res.filter(Boolean))); }); } async _getDataForTemplate(notification, recipient, cb) { const UNIT_LABELS = { btc: 'BTC', bit: 'bits', bch: 'BCH', eth: 'ETH', matic: 'MATIC', xrp: 'XRP', doge: 'DOGE', ltc: 'LTC', usdc: 'USDC', pyusd: 'PYUSD', usdp: 'USDP', gusd: 'GUSD', busd: 'BUSD', dai: 'DAI', wbtc: 'WBTC', shib: 'SHIB', ape: 'APE', euroc: 'EUROC', usdt: 'USDT', weth: 'WETH', 'usdc.e': 'USDC.e', sol: 'SOL' }; const data = lodash_1.default.cloneDeep(notification.data); data.subjectPrefix = lodash_1.default.trim(this.subjectPrefix + ' '); if (data.amount) { try { let unit = recipient.unit.toLowerCase(); let label = UNIT_LABELS[unit]; let opts = {}; if (data.tokenAddress) { const tokenAddress = data.tokenAddress.toLowerCase(); if (Constants.ETH_TOKEN_OPTS[tokenAddress]) { unit = Constants.ETH_TOKEN_OPTS[tokenAddress].symbol.toLowerCase(); label = UNIT_LABELS[unit]; } else if (Constants.MATIC_TOKEN_OPTS[tokenAddress]) { unit = Constants.MATIC_TOKEN_OPTS[tokenAddress].symbol.toLowerCase(); label = UNIT_LABELS[unit]; } else if (Constants.ARB_TOKEN_OPTS[tokenAddress]) { unit = Constants.ARB_TOKEN_OPTS[tokenAddress].symbol.toLowerCase(); label = UNIT_LABELS[unit]; } else if (Constants.OP_TOKEN_OPTS[tokenAddress]) { unit = Constants.OP_TOKEN_OPTS[tokenAddress].symbol.toLowerCase(); label = UNIT_LABELS[unit]; } else if (Constants.BASE_TOKEN_OPTS[tokenAddress]) { unit = Constants.BASE_TOKEN_OPTS[tokenAddress].symbol.toLowerCase(); label = UNIT_LABELS[unit]; } else if (Constants.SOL_TOKEN_OPTS[tokenAddress]) { unit = Constants.SOL_TOKEN_OPTS[tokenAddress].symbol.toLowerCase(); label = UNIT_LABELS[unit]; } else { let customTokensData; try { customTokensData = await this.getTokenData(data.address.coin); } catch (error) { return cb(new Error('Could not get custom tokens data')); } if (customTokensData && customTokensData[tokenAddress]) { unit = customTokensData[tokenAddress].symbol.toLowerCase(); label = unit.toUpperCase(); opts.toSatoshis = 10 ** customTokensData[tokenAddress].decimals; opts.decimals = { maxDecimals: 6, minDecimals: 2 }; } else { return cb(new Error(`Push notifications for unsupported tokens are not allowed: ${tokenAddress}`)); } } } data.amount = Utils.formatAmount(+data.amount, unit, opts) + ' ' + label; } catch (ex) { return cb(new Error('Could not format amount' + ex)); } } this.storage.fetchWallet(notification.walletId, (err, wallet) => { if (err || !wallet) return cb(err); data.walletId = wallet.id; data.walletName = wallet.name; data.walletM = wallet.m; data.walletN = wallet.n; const copayer = wallet.copayers.find(c => c.id === notification.creatorId); if (copayer) { data.copayerId = copayer.id; data.copayerName = copayer.name; } if (notification.type == 'TxProposalFinallyRejected' && data.rejectedBy) { const rejectors = lodash_1.default.map(data.rejectedBy, copayerId => { return wallet.copayers.find(c => c.id === copayerId).name; }); data.rejectorsNames = rejectors.join(', '); } return cb(null, data); }); } _applyTemplate(template, data, cb) { if (!data) return cb(new Error('Could not apply template to empty data')); let error; const result = lodash_1.default.mapValues(template, t => { try { return Mustache.render(t, data); } catch (e) { logger_1.default.error('Could not apply data to template: %o', e); error = e; } }); if (error) return cb(error); return cb(null, result); } _loadTemplate(notifType, recipient, extension, cb) { this._readTemplateFile(recipient.language, notifType.filename + extension, (err, template) => { if (err) return cb(err); return cb(null, this._compileTemplate(template, extension)); }); } _readTemplateFile(language, filename, cb) { const fullFilename = path.join(this.templatePath, language, filename); fs.readFile(fullFilename, 'utf8', (err, template) => { if (err) { return cb(new Error('Could not read template file ' + fullFilename + err)); } return cb(null, template); }); } _compileTemplate(template, extension) { const lines = template.split('\n'); if (extension == '.html') { lines.unshift(''); } return { subject: lines[0], body: lodash_1.default.tail(lines).join('\n') }; } _getSubscriptions(notification, notifType, recipientsList, contents, cb) { if (notifType.broadcastToActiveUsers) { this.storage.fetchLatestPushNotificationSubs((err, subs) => { if (err) return cb(err); const allSubsWithToken = lodash_1.default.uniqBy(lodash_1.default.reject(subs, sub => !sub.walletId || sub.externalUserId), 'token'); const allSubsWithExternalId = lodash_1.default.uniqBy(lodash_1.default.reject(subs, sub => !sub.walletId || sub.token), 'externalUserId'); const allSubs = allSubsWithExternalId.length > 0 ? allSubsWithExternalId : allSubsWithToken; const chainOrCoin = notification.data.chain || notification.data.coin; const networkInfo = notification.data.network; const hasChainNetworkInfo = chainOrCoin && networkInfo; const chainNetworkMessage = hasChainNetworkInfo ? ` [${chainOrCoin}/${networkInfo}]` : ''; logger_1.default.info(`Sending ${notification.type}${chainNetworkMessage} notifications to: ${allSubs.length} devices`); return cb(null, allSubs); }); } else { async.map(recipientsList, (recipient, next) => { const content = contents ? contents[recipient.language] : null; this.storage.fetchPushNotificationSubs(recipient.copayerId, (err, subs) => { if (err) return next(err); const subscriptions = subs && subs.length ? subs.map(obj => ({ ...obj, plain: content?.plain })) : subs; const allSubsWithToken = lodash_1.default.uniqBy(lodash_1.default.reject(subscriptions, sub => !sub.walletId || sub.externalUserId), 'token'); const allSubsWithExternalId = lodash_1.default.uniqBy(lodash_1.default.reject(subscriptions, sub => !sub.walletId || sub.token), 'externalUserId'); const allSubs = allSubsWithExternalId.length > 0 ? allSubsWithExternalId : allSubsWithToken; return next(err, allSubs); }); }, (err, allSubs) => { if (err) return cb(err); return cb(null, lodash_1.default.flatten(allSubs)); }); } } _makeRequest(opts, cb) { this.request({ url: this.pushServerUrl + '/send', method: 'POST', json: true, headers: { 'Content-Type': 'application/json', Authorization: 'key=' + this.authorizationKey }, body: opts }, cb); } _makeBrazeRequest(opts, cb) { this.request({ url: this.pushServerUrlBraze + '/messages/send', method: 'POST', json: true, headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + this.authorizationKeyBraze }, body: opts }, cb); } oneInchGetCredentials() { if (!config_1.default.oneInch) throw new Error('1Inch missing credentials'); const credentials = { API: config_1.default.oneInch.api, API_KEY: config_1.default.oneInch.apiKey, referrerAddress: config_1.default.oneInch.referrerAddress, referrerFee: config_1.default.oneInch.referrerFee }; return credentials; } getTokenData(chain) { return new Promise((resolve, reject) => { try { const credentials = this.oneInchGetCredentials(); const chainIdMap = { eth: 1, matic: 137 }; this.request({ url: `${credentials.API}/v5.2/${chainIdMap[chain]}/tokens`, method: 'GET', json: true, headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: 'Bearer ' + credentials.API_KEY, } }, (err, data) => { if (err) return reject(err); if (data?.statusCode === 429) { return reject(); } return resolve(data?.body?.tokens); }); } catch (err) { return reject(err); } }); } } exports.PushNotificationsService = PushNotificationsService; //# sourceMappingURL=pushnotificationsservice.js.map