bitcore-wallet-service
Version:
A service for Mutisig HD Bitcoin Wallets
760 lines (697 loc) • 25.8 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 config from '../config';
import { Common } from './common';
import logger from './logger';
import { MessageBroker } from './messagebroker';
import { INotification, IPreferences, Preferences } from './model';
import { Storage } from './storage';
const Mustache = require('mustache');
const defaultRequest = require('request');
const path = require('path');
const Utils = Common.Utils;
const Defaults = Common.Defaults;
const Constants = 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
}
};
export interface IPushNotificationService {
templatePath: string;
defaultLanguage: string;
defaultUnit: string;
subjectPrefix: string;
pushServerUrl: string;
pushServerUrlBraze: string;
availableLanguages: string;
authorizationKey: string;
authorizationKeyBraze: string;
messageBroker: any;
}
export class PushNotificationsService {
request: request.RequestAPI<any, any, any>;
templatePath: string;
defaultLanguage: string;
defaultUnit: string;
subjectPrefix: string;
pushServerUrl: string;
pushServerUrlBraze: string;
availableLanguages: string;
authorizationKey: string;
authorizationKeyBraze: 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;
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();
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 = 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') {
// Handle zero amount ETH transactions to contract addresses
notifType.filename = notification.data.amount !== 0 ? 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;
let notificationData: any;
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;
// chain and network are needed for NewBlock notifications
const chain = notification?.data?.chain || notification?.data?.coin;
const coin = chain; // backwards compatibility
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, // not really coin value it's chain
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, // not really coin value it's chain
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}¬ification_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.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: any, next) => {
if (notification && notification.external_user_ids) {
this._makeBrazeRequest(notification, (err, response) => {
if (err) logger.error('An error occurred making a braze push notification request:' + err);
if (response) {
// logger.debug('Request status: ' + response.statusCode);
// logger.debug('Request message: ' + response.statusMessage);
// logger.debug('Request body: ' + response.request.body);
}
next();
});
// if external_user_ids is not set use old version firebase token
} else if (notification && notification.to) {
this._makeRequest(notification, (err, response) => {
if (err) logger.error('An error occurred making a firebase push notification request:' + 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, (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'; // backwards compatibility
break;
default:
unit = wallet.coin;
}
}
this.storage.fetchPreferences<Preferences[]>(notification.walletId, null, (err, preferences) => {
if (err) logger.error('%o', 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
};
})
);
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[]));
}
);
}
async _getDataForTemplate(notification: INotification, 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 = _.cloneDeep(notification.data);
data.subjectPrefix = _.trim(this.subjectPrefix + ' ');
if (data.amount) {
try {
let unit = recipient.unit.toLowerCase();
let label = UNIT_LABELS[unit];
let opts = {} as any;
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]) {
// check for eth tokens
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);
/*
*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: %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: _.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 = _.uniqBy(
_.reject(subs, sub => !sub.walletId || sub.externalUserId),
'token'
);
const allSubsWithExternalId = _.uniqBy(
_.reject(subs, sub => !sub.walletId || sub.token),
'externalUserId'
);
// if copayerid is associated to externalUserId use Braze subscriptions
// avoid multiple notifications
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.info(
`Sending ${notification.type}${chainNetworkMessage} 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;
const allSubsWithToken = _.uniqBy(
_.reject(subscriptions, sub => !sub.walletId || sub.externalUserId),
'token'
);
const allSubsWithExternalId = _.uniqBy(
_.reject(subscriptions, sub => !sub.walletId || sub.token),
'externalUserId'
);
// if copayerid is associated to externalUserId use Braze subscriptions
// avoid multiple notifications
const allSubs = allSubsWithExternalId.length > 0 ? allSubsWithExternalId : allSubsWithToken;
return next(err, allSubs);
});
},
(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
);
}
_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
);
}
private oneInchGetCredentials() {
if (!config.oneInch) throw new Error('1Inch missing credentials');
const credentials = {
API: config.oneInch.api,
API_KEY: config.oneInch.apiKey,
referrerAddress: config.oneInch.referrerAddress,
referrerFee: config.oneInch.referrerFee
};
return credentials;
}
getTokenData(chain: string) {
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) {
// oneinch rate limit
return reject();
}
return resolve(data?.body?.tokens);
}
);
} catch (err) {
return reject(err);
}
});
}
}