matrixbitcore-wallet-service
Version:
A service for Mutisig HD Matrixbit Wallets
1,651 lines (1,377 loc) • 113 kB
JavaScript
'use strict';
var _ = require('lodash');
var $ = require('preconditions').singleton();
var async = require('async');
var log = require('npmlog');
var config = require('../config');
log.debug = log.verbose;
log.disableColor();
var EmailValidator = require('email-validator');
var Stringify = require('json-stable-stringify');
var Bitcore = require('matrixbitcore-lib');
var Bitcore_ = {
btc: Bitcore,
bch: require('bitcore-lib-cash')
};
var Common = require('./common');
var Utils = Common.Utils;
var Constants = Common.Constants;
var Defaults = Common.Defaults;
var ClientError = require('./errors/clienterror');
var Errors = require('./errors/errordefinitions');
var Lock = require('./lock');
var Storage = require('./storage');
var MessageBroker = require('./messagebroker');
var BlockchainExplorer = require('./blockchainexplorer');
var FiatRateService = require('./fiatrateservice');
var request = require('request');
var Model = require('./model');
var Wallet = Model.Wallet;
var initialized = false;
var lock;
var storage;
var blockchainExplorer;
var blockchainExplorerOpts;
var messageBroker;
var fiatRateService;
var serviceVersion;
/**
* Creates an instance of the Bitcore Wallet Service.
* @constructor
*/
function WalletService() {
if (!initialized)
throw new Error('Server not initialized');
this.lock = lock;
this.storage = storage;
this.blockchainExplorer = blockchainExplorer;
this.blockchainExplorerOpts = blockchainExplorerOpts;
this.messageBroker = messageBroker;
this.fiatRateService = fiatRateService;
this.notifyTicker = 0;
};
function checkRequired(obj, args, cb) {
var missing = Utils.getMissingFields(obj, args);
if (_.isEmpty(missing)) return true;
if (_.isFunction(cb))
cb(new ClientError('Required argument ' + _.first(missing) + ' missing.'));
return false;
};
/**
* Gets the current version of BWS
*/
WalletService.getServiceVersion = function() {
if (!serviceVersion)
serviceVersion = 'bws-' + require('../package').version;
return serviceVersion;
};
/**
* Initializes global settings for all instances.
* @param {Object} opts
* @param {Storage} [opts.storage] - The storage provider.
* @param {Storage} [opts.blockchainExplorer] - The blockchainExporer provider.
* @param {Callback} cb
*/
WalletService.initialize = function(opts, cb) {
$.shouldBeFunction(cb);
opts = opts || {};
lock = opts.lock || new Lock(opts.lockOpts);
blockchainExplorer = opts.blockchainExplorer;
blockchainExplorerOpts = opts.blockchainExplorerOpts;
if (opts.request)
request = opts.request;
function initStorage(cb) {
if (opts.storage) {
storage = opts.storage;
return cb();
} else {
var newStorage = new Storage();
newStorage.connect(opts.storageOpts, function(err) {
if (err) return cb(err);
storage = newStorage;
return cb();
});
}
};
function initMessageBroker(cb) {
messageBroker = opts.messageBroker || new MessageBroker(opts.messageBrokerOpts);
if (messageBroker) {
messageBroker.onMessage(WalletService.handleIncomingNotification);
}
return cb();
};
function initFiatRateService(cb) {
if (opts.fiatRateService) {
fiatRateService = opts.fiatRateService;
return cb();
} else {
var newFiatRateService = new FiatRateService();
var opts2 = opts.fiatRateServiceOpts || {};
opts2.storage = storage;
newFiatRateService.init(opts2, function(err) {
if (err) return cb(err);
fiatRateService = newFiatRateService;
return cb();
});
}
};
async.series([
function(next) {
initStorage(next);
},
function(next) {
initMessageBroker(next);
},
function(next) {
initFiatRateService(next);
},
], function(err) {
if (err) {
log.error('Could not initialize', err);
throw err;
}
initialized = true;
return cb();
});
};
WalletService.handleIncomingNotification = function(notification, cb) {
cb = cb || function() {};
if (!notification || notification.type != 'NewBlock') return cb();
WalletService._clearBlockchainHeightCache(notification.data.coin, notification.data.network);
return cb();
};
WalletService.shutDown = function(cb) {
if (!initialized) return cb();
storage.disconnect(function(err) {
if (err) return cb(err);
initialized = false;
return cb();
});
};
/**
* Gets an instance of the server without authentication.
* @param {Object} opts
* @param {string} opts.clientVersion - A string that identifies the client issuing the request
*/
WalletService.getInstance = function(opts) {
opts = opts || {};
var version = Utils.parseVersion(opts.clientVersion);
if (version && version.agent == 'bwc') {
if (version.major == 0 || (version.major == 1 && version.minor < 2)) {
throw new ClientError(Errors.codes.UPGRADE_NEEDED, 'BWC clients < 1.2 are no longer supported.');
}
}
var server = new WalletService();
server._setClientVersion(opts.clientVersion);
return server;
};
/**
* Gets an instance of the server after authenticating the copayer.
* @param {Object} opts
* @param {string} opts.copayerId - The copayer id making the request.
* @param {string} opts.message - (Optional) The contents of the request to be signed. Only needed if no session token is provided.
* @param {string} opts.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} opts.session - (Optional) A valid session token previously obtained using the #login method
* @param {string} opts.clientVersion - A string that identifies the client issuing the request
* @param {string} [opts.walletId] - The wallet id to use as current wallet for this request (only when copayer is support staff).
*/
WalletService.getInstanceWithAuth = function(opts, cb) {
function withSignature(cb) {
if (!checkRequired(opts, ['copayerId', 'message', 'signature'], cb)) return;
var server;
try {
server = WalletService.getInstance(opts);
} catch (ex) {
return cb(ex);
}
server.storage.fetchCopayerLookup(opts.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) {
var isValid = !!server._getSigningKey(opts.message, opts.signature, copayer.requestPubKeys);
if (!isValid)
return cb(new ClientError(Errors.codes.NOT_AUTHORIZED, 'Invalid signature'));
server.walletId = copayer.walletId;
} else {
server.walletId = opts.walletId || copayer.walletId;
server.copayerIsSupportStaff = true;
}
server.copayerId = opts.copayerId;
return cb(null, server);
});
};
function withSession(cb) {
if (!checkRequired(opts, ['copayerId', 'session'], cb)) return;
var server;
try {
server = WalletService.getInstance(opts);
} catch (ex) {
return cb(ex);
}
server.storage.getSession(opts.copayerId, function(err, s) {
if (err) return cb(err);
var isValid = s && s.id == opts.session && s.isValid();
if (!isValid) return cb(new ClientError(Errors.codes.NOT_AUTHORIZED, 'Session expired'));
server.storage.fetchCopayerLookup(opts.copayerId, function(err, copayer) {
if (err) return cb(err);
if (!copayer) return cb(new ClientError(Errors.codes.NOT_AUTHORIZED, 'Copayer not found'));
server.copayerId = opts.copayerId;
server.walletId = copayer.walletId;
return cb(null, server);
});
});
};
var authFn = opts.session ? withSession : withSignature;
return authFn(cb);
};
WalletService.prototype._runLocked = function(cb, task, waitTime) {
$.checkState(this.walletId);
this.lock.runLocked(this.walletId, cb, task, waitTime);
};
WalletService.prototype.logi = function() {
if (!this) return log.info.apply(this, arguments);
if (!this.walletId) return log.info.apply(this, arguments);
var args = [].slice.call(arguments);
args.unshift('<' + this.walletId + '>');
log.info.apply(this, args);
};
WalletService.prototype.logw = function() {
if (!this) return log.info.apply(this, arguments);
if (!this.walletId) return log.info.apply(this, arguments);
var args = [].slice.call(arguments);
args.unshift('<' + this.walletId + '>');
log.warn.apply(this, args);
};
WalletService.prototype.login = function(opts, cb) {
var self = this;
var 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 = Model.Session.create({
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) {
var self = this;
self.storage.removeSession(self.copayerId, cb);
};
/**
* 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.coin[='btc'] - The coin for this wallet (btc, bch).
* @param {string} opts.network[='livenet'] - The Bitcoin network for this wallet.
* @param {string} opts.supportBIP44AndP2PKH[=true] - Client supports BIP44 & P2PKH for new wallets.
*/
WalletService.prototype.createWallet = function(opts, cb) {
var self = this,
pubKey;
if (!checkRequired(opts, ['name', 'm', 'n', 'pubKey'], cb)) return;
if (_.isEmpty(opts.name)) return cb(new ClientError('Invalid wallet name'));
if (!Wallet.verifyCopayerLimits(opts.m, opts.n))
return cb(new ClientError('Invalid combination of required copayers / total copayers'));
opts.coin = opts.coin || Defaults.COIN;
if (!Utils.checkValueInCollection(opts.coin, Constants.COINS))
return cb(new ClientError('Invalid coin'));
opts.network = opts.network || 'livenet';
if (!Utils.checkValueInCollection(opts.network, Constants.NETWORKS))
return cb(new ClientError('Invalid network'));
opts.supportBIP44AndP2PKH = _.isBoolean(opts.supportBIP44AndP2PKH) ? opts.supportBIP44AndP2PKH : true;
var derivationStrategy = opts.supportBIP44AndP2PKH ? Constants.DERIVATION_STRATEGIES.BIP44 : Constants.DERIVATION_STRATEGIES.BIP45;
var addressType = (opts.n == 1 && opts.supportBIP44AndP2PKH) ? Constants.SCRIPT_TYPES.P2PKH : Constants.SCRIPT_TYPES.P2SH;
try {
pubKey = new Bitcore.PublicKey.fromString(opts.pubKey);
} catch (ex) {
return cb(new ClientError('Invalid public key'));
};
var 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) {
var wallet = Wallet.create({
id: opts.id,
name: opts.name,
m: opts.m,
n: opts.n,
coin: opts.coin,
network: opts.network,
pubKey: pubKey.toString(),
singleAddress: !!opts.singleAddress,
derivationStrategy: derivationStrategy,
addressType: addressType,
});
self.storage.storeWallet(wallet, function(err) {
self.logi('Wallet created', wallet.id, opts.network);
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) {
var 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) {
var self = this;
if (!opts.identifier) return cb();
var 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.fetchAddressByCoin(Defaults.COIN, 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);
}
var re = /^[\da-f]+$/gi;
if (!re.test(opts.identifier)) return cb();
// Is identifier a txid form an incomming tx?
var coinNetworkPairs = [];
_.each(_.values(Constants.COINS), function(coin) {
_.each(_.values(Constants.NETWORKS), function(network) {
coinNetworkPairs.push({
coin: coin,
network: network
});
});
});
async.detectSeries(coinNetworkPairs, function(coinNetwork, nextCoinNetwork) {
var bc = self._getBlockchainExplorer(coinNetwork.coin, coinNetwork.network);
if (!bc) return nextCoinNetwork(false);
bc.getTransaction(opts.identifier, function(err, tx) {
if (err || !tx) return nextCoinNetwork(false);
var outputs = _.first(self._normalizeTxHistory(tx)).outputs;
var toAddresses = _.map(outputs, 'address');
async.detect(toAddresses, function(addressStr, nextAddress) {
self.storage.fetchAddressByCoin(coinNetwork.coin, addressStr, function(err, address) {
if (err || !address) return nextAddress(false);
walletId = address.walletId;
nextAddress(true);
});
}, function() {
nextCoinNetwork(!!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) {
var self = this;
opts = opts || {};
var status = {};
async.parallel([
function(next) {
self.getWallet({}, function(err, wallet) {
if (err) return next(err);
var walletExtendedKeys = ['publicKeyRing', 'pubKey', 'addressManager'];
var copayerExtendedKeys = ['xPubKey', 'requestPubKey', 'signature', 'addressManager', 'customData'];
wallet.copayers = _.map(wallet.copayers, function(copayer) {
if (copayer.id == self.copayerId) return copayer;
return _.omit(copayer, 'customData');
});
if (!opts.includeExtendedInfo) {
wallet = _.omit(wallet, walletExtendedKeys);
wallet.copayers = _.map(wallet.copayers, function(copayer) {
return _.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 Utils.verifyMessage(text, signature, pubkey);
};
/*
* Verifies a request public key
* @param requestPubKey
* @param signature
* @param xPubKey
*/
WalletService.prototype._verifyRequestPubKey = function(requestPubKey, signature, xPubKey) {
var pub = (new Bitcore.HDPublicKey(xPubKey)).deriveChild(Constants.PATHS.REQUEST_KEY_AUTH).publicKey;
return 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) {
var self = this;
return _.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) {
var self = this;
if (_.isFunction(opts)) {
cb = opts;
opts = {};
}
opts = opts || {};
//self.logi('Notification', type);
cb = cb || function() {};
var walletId = self.walletId || data.walletId;
var copayerId = self.copayerId || data.copayerId;
$.checkState(walletId);
var notification = Model.Notification.create({
type: type,
data: data,
ticker: this.notifyTicker++,
creatorId: opts.isGlobal ? null : copayerId,
walletId: walletId,
});
this.storage.storeNotification(walletId, notification, function() {
self.messageBroker.send(notification);
return cb();
});
};
WalletService.prototype._notifyTxProposalAction = function(type, txp, extraArgs, cb) {
var self = this;
if (_.isFunction(extraArgs)) {
cb = extraArgs;
extraArgs = {};
}
var data = _.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) {
var self = this;
var copayer = Model.Copayer.create({
coin: wallet.coin,
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) {
var 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) {
var self = this;
if (!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);
var xPubKey = _.find(wallet.copayers, {
id: opts.copayerId
}).xPubKey;
if (!self._verifyRequestPubKey(opts.requestPubKey, opts.signature, xPubKey)) {
return cb(Errors.NOT_AUTHORIZED);
}
if (copayer.requestPubKeys.length > Defaults.MAX_KEYS)
return cb(Errors.TOO_MANY_KEYS);
self._addKeyToCopayer(wallet, copayer, opts, cb);
});
});
};
WalletService.prototype._setClientVersion = function(version) {
delete this.parsedClientVersion;
this.clientVersion = version;
};
WalletService.prototype._parseClientVersion = function() {
if (_.isUndefined(this.parsedClientVersion)) {
this.parsedClientVersion = Utils.parseVersion(this.clientVersion);
}
return this.parsedClientVersion;
};
WalletService.prototype._clientSupportsPayProRefund = function() {
var 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.coin[='btc'] - The expected coin for this wallet (btc, bch).
* @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) {
var self = this;
if (!checkRequired(opts, ['walletId', 'name', 'xPubKey', 'requestPubKey', 'copayerSignature'], cb)) return;
if (_.isEmpty(opts.name))
return cb(new ClientError('Invalid copayer name'));
opts.coin = opts.coin || Defaults.COIN;
if (!Utils.checkValueInCollection(opts.coin, Constants.COINS))
return cb(new ClientError('Invalid coin'));
var xPubKey;
try {
xPubKey = Bitcore.HDPublicKey(opts.xPubKey);
} catch (ex) {
return cb(new ClientError('Invalid extended public key'));
}
if (_.isUndefined(xPubKey.network)) {
return cb(new ClientError('Invalid extended public key'));
}
opts.supportBIP44AndP2PKH = _.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.coin != wallet.coin) {
return cb(new ClientError('The wallet you are trying to join was created for a different coin'));
}
if (wallet.network != xPubKey.network.name) {
return cb(new ClientError('The wallet you are trying to join was created for a different network'));
}
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.'));
}
}
var hash = WalletService._getCopayerHash(opts.name, opts.xPubKey, opts.requestPubKey);
if (!self._verifySignature(hash, opts.copayerSignature, wallet.pubKey)) {
return cb(new ClientError());
}
if (_.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 - Bitcoin unit used to format amounts in notifications.
*/
WalletService.prototype.savePreferences = function(opts, cb) {
var self = this;
opts = opts || {};
var preferences = [{
name: 'email',
isValid: function(value) {
return EmailValidator.validate(value);
},
}, {
name: 'language',
isValid: function(value) {
return _.isString(value) && value.length == 2;
},
}, {
name: 'unit',
isValid: function(value) {
return _.isString(value) && _.contains(['btc', 'bit'], value.toLowerCase());
},
}];
opts = _.pick(opts, _.map(preferences, 'name'));
try {
_.each(preferences, function(preference) {
var value = opts[preference.name];
if (!value) return;
if (!preference.isValid(value)) {
throw 'Invalid ' + preference.name;
return false;
}
});
} 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);
var newPref = Model.Preferences.create({
walletId: self.walletId,
copayerId: self.copayerId,
});
var preferences = Model.Preferences.fromObj(_.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) {
var 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) {
var self = this;
if (ignoreMaxGap) return cb(null, true);
self.storage.fetchAddresses(self.walletId, function(err, addresses) {
if (err) return cb(err);
var latestAddresses = _.takeRight(_.reject(addresses, {
isChange: true
}), Defaults.MAX_MAIN_ADDRESS_GAP);
if (latestAddresses.length < Defaults.MAX_MAIN_ADDRESS_GAP || _.any(latestAddresses, {
hasActivity: true
})) return cb(null, true);
var bc = self._getBlockchainExplorer(latestAddresses[0].coin, latestAddresses[0].network);
if (!bc) return cb(new Error('Could not get blockchain explorer instance'));
var activityFound = false;
var 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);
var 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) {
var self = this;
opts = opts || {};
function createNewAddress(wallet, cb) {
var address;
try{
address = wallet.createAddress(false);
} catch(e){
log.warn("Error creating address for " + self.walletId, e);
return cb("Bad xPub");
};
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 (!_.isEmpty(addresses)) return cb(null, _.first(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);
var createFn = wallet.singleAddress ? getFirstAddress : createNewAddress;
return createFn(wallet, cb);
});
}, 10 * 1000);
});
};
/**
* 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) {
var self = this;
opts = opts || {};
self.storage.fetchAddresses(self.walletId, function(err, addresses) {
if (err) return cb(err);
var onlyMain = _.reject(addresses, {
isChange: true
});
if (opts.reverse) onlyMain.reverse();
if (opts.limit > 0) onlyMain = _.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) {
var self = this;
if (!checkRequired(opts, ['message', 'signature'], cb)) return;
self.getWallet({}, function(err, wallet) {
if (err) return cb(err);
var copayer = wallet.getCopayer(self.copayerId);
var isValid = !!self._getSigningKey(opts.message, opts.signature, copayer.requestPubKeys);
return cb(null, isValid);
});
};
WalletService.prototype._getBlockchainExplorer = function(coin, network) {
var opts = {};
if (this.blockchainExplorer) return this.blockchainExplorer;
if (this.blockchainExplorerOpts) {
if (this.blockchainExplorerOpts[coin] && this.blockchainExplorerOpts[coin][network]) {
opts = this.blockchainExplorerOpts[coin][network];
} else if (this.blockchainExplorerOpts[network]) {
opts = this.blockchainExplorerOpts[network];
}
}
// TODO: provider should be configurable
opts.provider = 'insight';
opts.coin = coin;
opts.network = network;
opts.userAgent = WalletService.getServiceVersion();
var bc;
try {
bc = new BlockchainExplorer(opts);
} catch (ex) {
this.logw('Could not instantiate blockchain explorer', ex);
}
return bc;
};
WalletService.prototype._getUtxos = function(coin, addresses, cb) {
var self = this;
if (addresses.length == 0) return cb(null, []);
var networkName = Bitcore_[coin].Address(addresses[0]).toObject().network;
var bc = self._getBlockchainExplorer(coin, networkName);
if (!bc) return cb(new Error('Could not get blockchain explorer instance'));
self.logi('Querying utxos: %s addrs', addresses.length);
bc.getUtxos(addresses, function(err, utxos) {
if (err) return cb(err);
var utxos = _.map(utxos, function(utxo) {
var u = _.pick(utxo, ['txid', 'vout', 'address', 'scriptPubKey', 'amount', 'satoshis', 'confirmations']);
u.confirmations = u.confirmations || 0;
u.locked = false;
u.satoshis = _.isNumber(u.satoshis) ? +u.satoshis : Utils.strip(u.amount * 1e8);
delete u.amount;
return u;
});
return cb(null, utxos);
});
};
WalletService.prototype._getUtxosForCurrentWallet = function(opts, cb) {
var self = this;
var opts = opts || {};
function utxoKey(utxo) {
return utxo.txid + '|' + utxo.vout
};
var coin, allAddresses, allUtxos, utxoIndex, addressStrs;
async.series([
function(next) {
self.getWallet({}, function(err, wallet) {
if (err) return next(err);
coin = wallet.coin;
return next();
});
},
function(next) {
if (_.isArray(opts.addresses)) {
allAddresses = opts.addresses;
return next();
}
self.storage.fetchAddresses(self.walletId, function(err, addresses) {
allAddresses = addresses;
if (allAddresses.length == 0) return cb(null, []);
return next();
});
},
function(next) {
addressStrs = _.map(allAddresses, 'address');
if (!opts.coin) return next();
coin = opts.coin;
addressStrs = _.map(addressStrs, function(a) {
return Utils.translateAddress(a, coin);
});
next();
},
function(next) {
self._getUtxos(coin, addressStrs, function(err, utxos) {
if (err) return next(err);
if (utxos.length == 0) return cb(null, []);
allUtxos = utxos;
utxoIndex = _.indexBy(allUtxos, utxoKey);
return next();
});
},
function(next) {
self.getPendingTxs({}, function(err, txps) {
if (err) return next(err);
var lockedInputs = _.map(_.flatten(_.map(txps, 'inputs')), utxoKey);
_.each(lockedInputs, function(input) {
if (utxoIndex[input]) {
utxoIndex[input].locked = true;
}
});
return next();
});
},
function(next) {
var 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);
var spentInputs = _.map(_.flatten(_.map(txs, 'inputs')), utxoKey);
_.each(spentInputs, function(input) {
if (utxoIndex[input]) {
utxoIndex[input].spent = true;
}
});
allUtxos = _.reject(allUtxos, {
spent: true
});
return next();
});
},
function(next) {
if (opts.coin) return next();
// Needed for the clients to sign UTXOs
var addressToPath = _.indexBy(allAddresses, 'address');
_.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 {String} [opts.coin='btc'] (optional)
* @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) {
var self = this;
opts = opts || {};
if (opts.coin) {
if (!Utils.checkValueInCollection(opts.coin, Constants.COINS))
return cb(new ClientError('Invalid coin'));
}
if (_.isUndefined(opts.addresses)) {
self._getUtxosForCurrentWallet({
coin: opts.coin
}, cb);
} else {
self._getUtxos(Utils.getAddressCoin(opts.addresses[0]), opts.addresses, cb);
}
};
WalletService.prototype._totalizeUtxos = function(utxos) {
var balance = {
totalAmount: _.sum(utxos, 'satoshis'),
lockedAmount: _.sum(_.filter(utxos, 'locked'), 'satoshis'),
totalConfirmedAmount: _.sum(_.filter(utxos, 'confirmations'), 'satoshis'),
lockedConfirmedAmount: _.sum(_.filter(_.filter(utxos, 'locked'), 'confirmations'), 'satoshis'),
};
balance.availableAmount = balance.totalAmount - balance.lockedAmount;
balance.availableConfirmedAmount = balance.totalConfirmedAmount - balance.lockedConfirmedAmount;
return balance;
};
WalletService.prototype._getBalanceFromAddresses = function(opts, cb, i) {
var self = this;
var opts = opts || {};
opts.addresses = opts.addresses || [];
function checkBalanceCache(cb) {
if (opts.addresses.length < Defaults.BALANCE_CACHE_ADDRESS_THRESOLD || !opts.fastCache)
return cb();
self.storage.checkAndUseBalanceCache(self.walletId, opts.addresses, opts.fastCache, cb);
};
function storeBalanceCache(balance, cb) {
if (opts.addresses.length < Defaults.BALANCE_CACHE_ADDRESS_THRESOLD)
return cb(null, balance);
self.storage.storeBalanceCache(self.walletId, opts.addresses, balance, function(err) {
if (err)
self.logw('Could not save cache:',err);
return cb(null, balance);
});
};
// This lock is to prevent server starvation on big wallets
self._runLocked(cb, function(cb) {
checkBalanceCache(function(err, cache) {
if (err) return cb(err);
if (cache) {
self.logi('Using UTXO Cache');
return cb(null, cache, true);
}
self._getUtxosForCurrentWallet({
coin: opts.coin,
addresses: opts.addresses
}, function(err, utxos) {
if (err) return cb(err);
var balance = self._totalizeUtxos(utxos);
// Compute balance by address
var byAddress = {};
_.each(_.indexBy(_.sortBy(utxos, 'address'), 'address'), function(value, key) {
byAddress[key] = {
address: key,
path: value.path,
amount: 0,
};
});
_.each(utxos, function(utxo) {
byAddress[utxo.address].amount += utxo.satoshis;
});
balance.byAddress = _.values(byAddress);
storeBalanceCache(balance, cb);
});
}, 10 * 1000);
});
};
WalletService.prototype._getBalanceOneStep = function(opts, cb) {
var self = this;
self.storage.fetchAddresses(self.walletId, function(err, addresses) {
if (err) return cb(err);
if (addresses.length == opts.alreadyQueriedLength) {
self.logi('Query Skipped, all active addresses');
return cb(null, null, true);
}
self._getBalanceFromAddresses({
coin: opts.coin,
addresses: addresses,
fastCache: opts.fastCache,
}, function(err, balance, cacheUsed) {
if (err) return cb(err);
// Update cache
var withBalance = _.map(balance.byAddress, 'address')
self.storage.storeAddressesWithBalance(self.walletId, withBalance, function(err) {
if (err) {
self.logw('Could not update wallet cache', err);
}
return cb(null, balance, cacheUsed);
});
});
});
};
WalletService.prototype._getActiveAddresses = function(cb) {
var self = this;
self.storage.fetchAddressesWithBalance(self.walletId, function(err, addressesWB) {
if (err) {
self.logw('Could not fetch active addresses from cache', err);
return cb();
}
if (!_.isArray(addressesWB))
addressesWB = [];
var now = Math.floor(Date.now() / 1000);
var fromTs = now - Defaults.TWO_STEP_CREATION_HOURS * 3600;
self.storage.fetchNewAddresses(self.walletId, fromTs, function(err, recent) {
if (err) return cb(err);
var result = _.uniq(_.union(addressesWB, recent), 'address');
return cb(null, result);
});
});
};
WalletService.prototype._checkAndUpdateAddressCount = function(twoStepCache, cb) {
var self = this;
if (twoStepCache.addressCount > Defaults.TWO_STEP_BALANCE_THRESHOLD) {
self.logi('Not counting addresses');
return cb(null, true);
}
self.storage.countAddresses(self.walletId, function(err, addressCount) {
if (err) return cb(err);
if (addressCount < Defaults.TWO_STEP_BALANCE_THRESHOLD)
return cb(null, false);
twoStepCache.addressCount = addressCount;
// updates cache
self.storage.storeTwoStepCache(self.walletId, twoStepCache, function(err) {
if (err) return cb(err);
return cb(null, true);
});
});
};
/**
* Get wallet balance.
* @param {Object} opts
* @param {string} [opts.coin] - Override wallet coin (default wallet's coin).
* @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, i) {
var self = this;
opts = opts || {};
if (opts.coin) {
if (!Utils.checkValueInCollection(opts.coin, Constants.COINS))
return cb(new ClientError('Invalid coin'));
}
if (!opts.twoStep) {
opts.fastCache = Defaults.BALANCE_CACHE_DIRECT_DURATION;
return self._getBalanceOneStep(opts, cb);
}
self.storage.getTwoStepCache(self.walletId, function(err, twoStepCache) {
if (err) return cb(err);
twoStepCache = twoStepCache || {};
self._checkAndUpdateAddressCount(twoStepCache, function(err, needsTwoStep ) {
if (err) return cb(err);
if (!needsTwoStep) {
return self._getBalanceOneStep(opts, cb);
}
self._getActiveAddresses(function(err, activeAddresses) {
if (err) return cb(err);
if (!_.isArray(activeAddresses)) {
return self._getBalanceOneStep(opts, cb);
} else {
self.logi('Requesting partial balance for ' + activeAddresses.length + ' addresses');
self._getBalanceFromAddresses({
coin: opts.coin,
addresses: activeAddresses
}, function(err, partialBalance, cacheUsed) {
if (err) return cb(err);
cb(null, partialBalance, cacheUsed);
var now = Math.floor(Date.now() / 1000);
if (twoStepCache.lastEmpty > now - Defaults.TWO_STEP_INACTIVE_CLEAN_DURATION_MIN * 60 ) {
self.logi('Not running the FULL balance query due to TWO_STEP_INACTIVE_CLEAN_DURATION_MIN ');
return;
}
setTimeout(function() {
self.logi('Running full balance query');
opts.alreadyQueriedLength = activeAddresses.length;
opts.fastCache = Defaults.BALANCE_CACHE_DURATION;
self._getBalanceOneStep(opts, function(err, fullBalance, skipped) {
if (err) return;
if (!skipped && !_.isEqual(partialBalance, fullBalance)) {
self.logi('Balance in active addresses differs from final balance');
self._notify('BalanceUpdated', fullBalance, {
isGlobal: true
});
} else if (skipped) {
return;
} else {
// updates cache
twoStepCache.lastEmpty = now;
// updates cache
return self.storage.storeTwoStepCache(self.walletId, twoStepCache, function(err) {
return;
});
}
});
}, 1);
return;
}, i);
}
});
});
});
};
/**
* Return info needed to send all funds in the wallet
* @param {Object} opts
* @param {number} opts.feeLevel[='normal'] - Optional. Specify the fee level for this TX ('priority', 'normal', 'economy', 'superEconomy') as defined in Defaults.FEE_LEVELS.
* @param {number} opts.feePerKb - Optional. Specify the fee per KB for this TX (in satoshi).
* @param {string} opts.excludeUnconfirmedUtxos[=false] - Optional. Do not use UTXOs of unconfirmed transactions as inputs
* @param {string} opts.returnInputs[=false] - Optional. Return the list of UTXOs that would be included in the tx.
* @returns {Object} sendMaxInfo
*/
WalletService.prototype.getSendMaxInfo = function(opts, cb) {
var self = this;
opts = opts || {};
self.getWallet({}, function(err, wallet) {
if (err) return cb(err);
var feeArgs = !!opts.feeLevel + _.isNumber(opts.feePerKb);
if (feeArgs > 1)
return cb(new ClientError('Only one of feeLevel/feePerKb can be specified'));
if (feeArgs == 0) {
opts.feeLevel = 'normal';
}
var feeLevels = Defaults.FEE_LEVELS[wallet.coin];
if (opts.feeLevel) {
if (!_.any(feeLevels, {
name: opts.feeLevel
}))
return cb(new ClientError('Invalid fee level. Valid values are ' + _.map(feeLevels, 'name').join(', ')));
}
if (_.isNumber(opts.feePerKb)) {
if (opts.feePerKb < Defaults.MIN_FEE_PER_KB || opts.feePerKb > Defaults.MAX_FEE_PER_KB)
return cb(new ClientError('Invalid fee per KB'));
}
self._getUtxosForCurrentWallet({}, function(err, utxos) {
if (err) return cb(err);
var info = {
size: 0,
amount: 0,
fee: 0,
feePerKb: 0,
inputs: [],
utxosBelowFee: 0,
amountBelowFee: 0,
utxosAboveMaxSize: 0,
amountAboveMaxSize: 0,
};
var inputs = _.reject(utxos, 'locked');
if (!!opts.excludeUnconfirmedUtxos) {
inputs = _.filter(inputs, 'confirmations');
}
inputs = _.sortBy(inputs, function(input) {
return -input.satoshis;
});
if (_.isEmpty(inputs)) return cb(null, info);
self._getFeePerKb(wallet, opts, function(err, feePerKb) {
if (err) return cb(err);
info.feePerKb = feePerKb;
var txp = Model.TxProposal.create({
walletId: self.walletId,
coin: wallet.coin,
network: wallet.network,
walletM: wallet.m,
walletN: wallet.n,
feePerKb: feePerKb,
});
var baseTxpSize = txp.getEstimatedSize();
var baseTxpFee = baseTxpSize * txp.feePerKb / 1000.;
var sizePerInput = txp.getEstimatedSizeForSingleInput();
var feePerInput = sizePerInput * txp.feePerKb / 1000.;
var partitionedByAmount = _.partition(inputs, function(input) {
return input.satoshis > feePerInput;
});
info.utxosBelowFee = partitionedByAmount[1].length;
info.amountBelowFee = _.sum(partitionedByAmount[1], 'satoshis');
inputs = partitionedByAmount[0];
_.each(inputs, function(input, i) {
var sizeInKb = (baseTxpSize + (i + 1) * sizePerInput) / 1000.;
if (sizeInKb > Defaults.MAX_TX_SIZE_IN_KB) {
info.utxosAboveMaxSize = inputs.length - i;
info.amountAboveMaxSize = _.sum(_.slice(inputs, i), 'satoshis');
return false;
}
txp.inputs.push(input);
});
if (_.isEmpty(txp.inputs)) return cb(null, info);
var fee = txp.getEstimatedFee();
var amount = _.sum(txp.inputs, 'satoshis') - fee;
if (amount < Defaults.MIN_OUTPUT_AMOUNT) return cb(null, info);
info.size = txp.getEstimatedSize();
info.fee = fee;
info.amount = amount;
if (opts.returnInputs) {
info.inputs = _.shuffle(txp.inputs);
}
return cb(null, info);
});
});
});
};
WalletService.prototype._sampleFeeLevels = function(coin, network, points, cb) {
var self = this;
var bc = self._getBlockchainExplorer(coin, network);
if (!bc) return cb(new Err