@abcpros/bitcore-wallet-service
Version:
A service for Mutisig HD Bitcoin Wallets
592 lines (537 loc) • 19.1 kB
text/typescript
import * as async from 'async';
import * as fs from 'fs';
import _ from 'lodash';
import 'source-map-support/register';
import request from 'request';
import { ChainService } from './chain';
import logger from './logger';
import { MessageBroker } from './messagebroker';
import { INotification, IPreferences } from './model';
import { Storage } from './storage';
const Mustache = require('mustache');
const defaultRequest = require('request');
const path = require('path');
const Utils = require('./common/utils');
const Defaults = require('./common/defaults');
const Constants = require('./common/constants');
const sjcl = require('sjcl');
const config = require('../config');
const PUSHNOTIFICATIONS_TYPES = {
NewCopayer: {
filename: 'new_copayer'
},
WalletComplete: {
filename: 'wallet_complete'
},
NewTxProposal: {
filename: 'new_tx_proposal'
},
NewOutgoingTx: {
filename: 'new_outgoing_tx'
},
NewIncomingTx: {
filename: ['new_incoming_tx_testnet', 'new_incoming_tx']
},
TxProposalFinallyRejected: {
filename: 'txp_finally_rejected'
},
TxConfirmation: {
filename: ['tx_confirmation_sender', 'tx_confirmation_receiver']
},
NewAddress: {
dataOnly: true
},
NewBlock: {
dataOnly: true,
broadcastToActiveUsers: true
},
TxProposalAcceptedBy: {
dataOnly: true
},
TxProposalFinallyAccepted: {
dataOnly: true
},
TxProposalRejectedBy: {
dataOnly: true
},
TxProposalRemoved: {
dataOnly: true
}
};
export interface IPushNotificationService {
templatePath: string;
defaultLanguage: string;
defaultUnit: string;
subjectPrefix: string;
pushServerUrl: string;
availableLanguages: string;
authorizationKey: string;
messageBroker: any;
}
export class PushNotificationsService {
request: request.RequestAPI<any, any, any>;
templatePath: string;
defaultLanguage: string;
defaultUnit: string;
subjectPrefix: string;
pushServerUrl: string;
availableLanguages: string;
authorizationKey: string;
storage: Storage;
messageBroker: any;
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: (err: boolean) => void) => {
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;
if (!this.authorizationKey) return cb(new Error('Missing authorizationKey attribute in configuration.'));
async.parallel(
[
done => {
_readDirectories(this.templatePath, (err, res) => {
if (err) {
this.templatePath = path.normalize(__dirname + '../../../templates/');
_readDirectories(this.templatePath, (err, res) => {
this.availableLanguages = res;
done(err);
});
} else {
this.availableLanguages = res;
done(err);
}
});
},
done => {
if (opts.storage) {
this.storage = opts.storage;
done();
} else {
this.storage = new Storage();
this.storage.connect(opts.storageOpts, done);
}
},
done => {
this.messageBroker = opts.messageBroker || new MessageBroker(opts.messageBrokerOpts);
this.messageBroker.onMessage(_.bind(this._sendPushNotifications, this));
done();
}
],
err => {
if (err) {
logger.error('ERROR:' + err);
}
return cb(err);
}
);
}
_sendPushNotifications(notification, cb) {
cb = cb || function() {};
const notifType = _.cloneDeep(PUSHNOTIFICATIONS_TYPES[notification.type]);
if (!notifType) return cb();
if (notification.type === 'NewIncomingTx') {
notifType.filename = notification.data.network === 'testnet' ? notifType.filename[0] : notifType.filename[1];
} else if (notification.type === 'TxConfirmation') {
if (notification.data && !notification.data.amount) {
// backward compatibility
notifType.filename = 'tx_confirmation';
} else {
notifType.filename = notification.isCreator ? notifType.filename[0] : notifType.filename[1];
}
}
logger.debug('Notification received: ' + notification.type);
logger.debug(JSON.stringify(notification));
this._checkShouldSendNotif(notification, (err, should) => {
if (err) return cb(err);
logger.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 = _.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;
const notificationData: any = {
to: sub.token,
priority: 'high',
restricted_package_name: sub.packageName,
data: {
walletId: sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(notification.walletId || sub.walletId)),
tokenAddress,
multisigContractAddress,
copayerId: sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash(sub.copayerId)),
notification_type: notification.type,
// coin and network are needed for NewBlock notifications
coin: notification?.data?.coin,
network: notification?.data?.network,
tokenId: notification?.data?.tokenId
}
};
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'
};
}
return notificationData;
});
if (
notifications &&
notifications[0] &&
notifications[0].notification &&
subs.length > Defaults.PUSH_NOTIFICATION_LIMIT
) {
logger.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) => {
this._makeRequest(notification, (err, response) => {
if (err) logger.error('ERROR:' + err);
if (response) {
// logger.debug('Request status: ' + response.statusCode);
// logger.debug('Request message: ' + response.statusMessage);
// logger.debug('Request body: ' + response.request.body);
}
next();
});
},
err => {
return next(err);
}
);
}
],
err => {
if (err) {
logger.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, async (err, wallet) => {
if (err) return cb(err);
if (!wallet) return cb(null, []);
let unit;
let tokenId;
let tokenName;
let tokenDecimals;
if (wallet.coin != Defaults.COIN) {
unit = wallet.coin;
}
if (wallet.isSlpToken) {
const chronikClient = ChainService.getChronikClient(wallet.coin);
const txDetail = await chronikClient.tx(notification.data.txid);
tokenId = txDetail?.slpTxData?.slpMeta?.tokenId || null;
if (tokenId) {
this.storage.fetchTokenInfoById(tokenId, async (err, tokenInfo) => {
if (err) logger.error(err);
if (_.isEmpty(tokenInfo)) {
const tokenInfoChronik = await chronikClient.tx(tokenId);
let tokenInfo = tokenInfoChronik?.slpTxData?.genesisInfo;
tokenId = tokenId;
unit = tokenInfo?.tokenTicker;
tokenName = tokenInfo?.tokenName;
tokenDecimals = tokenInfo?.decimals;
} else {
tokenId = tokenId;
unit = tokenInfo?.symbol;
tokenName = tokenInfo?.name;
tokenDecimals = tokenInfo?.decimals;
}
});
notification.data.amount = Number(txDetail.outputs[1].slpToken.amount) || null;
notification.data.tokenId = tokenId || null;
}
}
this.storage.fetchPreferences(notification.walletId, null, (err, preferences) => {
if (err) logger.error(err);
if (_.isEmpty(preferences)) preferences = [];
const recipientPreferences = _.compact(
_.map(preferences, p => {
if (!_.includes(this.availableLanguages, p.language)) {
if (p.language) logger.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 = _.keyBy(recipientPreferences, 'copayerId');
const recipientsList = _.compact(
_.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,
tokenId: tokenId || null,
tokenName: tokenName || null,
tokenDecimals: tokenDecimals || null
};
})
);
return cb(null, recipientsList);
});
});
}
_readAndApplyTemplates(notification, notifType, recipientsList, cb) {
if (!notifType.filename) return cb(null, []);
async.map(
recipientsList,
(recipient: { language: string }, 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, _.fromPairs(res.filter(Boolean) as any[]));
}
);
},
(result, next) => {
next(null, result);
}
],
(err, res) => {
next(err, [recipient.language, res]);
}
);
},
(err, res) => {
return cb(err, _.fromPairs(res.filter(Boolean) as any[]));
}
);
}
_getDataForTemplate(notification: INotification, recipient, cb) {
const UNIT_LABELS = {
btc: 'BTC',
bit: 'bits',
bch: 'BCH',
xec: 'XEC',
eth: 'ETH',
xrp: 'XRP',
doge: 'DOGE',
ltc: 'LTC',
usdc: 'USDC',
pax: 'PAX',
gusd: 'GUSD',
busd: 'BUSD',
wbtc: 'WBTC',
dai: 'DAI',
xpi: 'XPI'
};
const data = _.cloneDeep(notification.data);
data.subjectPrefix = _.trim(this.subjectPrefix + ' ');
if (data.amount) {
try {
let unit = recipient.unit.toLowerCase();
let label = recipient.tokenName || UNIT_LABELS[unit];
if (data.tokenAddress) {
const tokenAddress = data.tokenAddress.toLowerCase();
if (Constants.TOKEN_OPTS[tokenAddress]) {
unit = Constants.TOKEN_OPTS[tokenAddress].symbol.toLowerCase();
label = UNIT_LABELS[unit];
} else {
label = 'tokens';
throw new Error('Notifications for unsupported token are not allowed');
}
}
if (recipient.tokenId && recipient.tokenDecimals) {
const caculateAmountToken = (amount, decimals) => {
return amount / Math.pow(10, decimals);
};
data.amount = caculateAmountToken(data.amount, recipient.tokenDecimals) + ' ' + label;
} else {
data.amount = Utils.formatAmount(+data.amount, unit) + ' ' + 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);
/*
*var copayer = _.find(wallet.copayers, {
* id: notification.creatorId
*});
*/
if (copayer) {
data.copayerId = copayer.id;
data.copayerName = copayer.name;
}
if (notification.type == 'TxProposalFinallyRejected' && data.rejectedBy) {
const rejectors = _.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 = _.mapValues(template, t => {
try {
return Mustache.render(t, data);
} catch (e) {
logger.error('Could not apply data to template:' + 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: _.tail(lines).join('\n')
};
}
_getSubscriptions(notification, notifType, recipientsList, contents, cb) {
if (notifType.broadcastToActiveUsers) {
this.storage.fetchLatestPushNotificationSubs((err, subs) => {
if (err) return cb(err);
const allSubs = _.uniqBy(
_.reject(subs, sub => !sub.walletId),
'token'
);
logger.info(
`Sending ${notification.type} [${notification.data.coin}/${notification.data.network}] notifications to: ${allSubs.length} devices`
);
return cb(null, allSubs);
});
} else {
async.map(
recipientsList,
(recipient: IPreferences, 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;
return next(err, subscriptions);
});
},
(err, allSubs) => {
if (err) return cb(err);
return cb(null, _.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
);
}
}