@owstack/wallet-service
Version:
A service for multisignature HD wallets
1,761 lines (1,533 loc) • 111 kB
JavaScript
'use strict';
var owsCommon = require('@owstack/ows-common');
var keyLib = require('@owstack/key-lib');
var async = require('async');
var baseConfig = require('../../config');
var ClientError = require('./errors/clienterror');
var Common = require('./common');
var Constants = owsCommon.Constants;
var EmailValidator = require('email-validator');
var errors = owsCommon.errors;
var Errors = require('./errors/errordefinitions');
var FiatRateService = require('./fiatrateservice');
var HDPublicKey = keyLib.HDPublicKey;
var Lock = require('./lock');
var log = require('npmlog');
var MessageBroker = require('./messagebroker');
var Model = require('./model');
var pkg = require('../../package');
var PublicKey = keyLib.PublicKey;
var request = require('request');
var Stringify = require('json-stable-stringify');
var lodash = owsCommon.deps.lodash;
var $ = require('preconditions').singleton();
log.debug = log.verbose;
log.disableColor();
var serviceVersion;
/**
* Static services shared among all instances of this WalletService.
*/
var lock;
var fiatRateService;
var messageBroker;
/**
* Creates an instance of the Wallet Service.
* @constructor
*/
class WalletService {
constructor(context, opts, config, cb) {
// Context defines the coin network and is set by the implementing service in
// order to instance this base service; e.g., btc-service.
context.inject(this);
// Set some frequently used contant values based on context.
this.LIVENET = this.ctx.Networks.livenet;
this.TESTNET = this.ctx.Networks.testnet;
this.atomicsName = this.ctx.Unit().atomicsName();
this.utils = new this.ctx.Utils();
this.initialize(opts, config, cb);
}
};
WalletService.prototype.setLog = function() {
if (this.config.log) {
log.level = (this.config.log.disable == true ? 'silent' : this.config.log.level || 'info');
} else {
log.level = 'info';
}
};
WalletService.prototype.checkRequired = function(obj, args, cb) {
var missing = this.ctx.Utils.getMissingFields(obj, args);
if (lodash.isEmpty(missing)) {
return true;
}
if (lodash.isFunction(cb)) {
cb(new ClientError('Required argument ' + lodash.head(missing) + ' missing.'));
}
return false;
};
/**
* Gets the current version of this wallet service.
*/
WalletService.getServiceVersion = function() {
if (!serviceVersion) {
serviceVersion = 'ws-' + pkg.version;
}
return serviceVersion;
};
/**
* Gets information about this wallet service instance.
*/
WalletService.prototype.getServiceInfo = function() {
return {
version: WalletService.getServiceVersion(),
currency: this.ctx.Networks.livenet.currency, // Currency same for livenet and testnet
livenet: {
description: this.ctx.Networks.livenet.description
},
testnet: {
description: this.ctx.Networks.testnet.description
}
};
};
/**
* Initializes this instance.
* @param {Object} opts - Options, most used for testing.
* @param {Storage} [opts.storage] - A Storage instance.
* @param {BlockchainExplorer} [opts.blockchainExplorer] - A BlockchainExporer instance.
* @param {BlockchainMonitor} [opts.blockchainMonitor] - A BlockchainMonitor instance.
* @param {MessageBroker} [opts.messageBroker] - A MessageBroker instance.
* @param {Object} [opts.request] - A (http) request object.
* @param {String} [opts.clientVersion] - A string that identifies the client issuing the request.
* @param {Object} config - a server configuration
* @param {Callback} cb
*/
WalletService.prototype.initialize = function(opts, config, cb) {
var self = this;
$.shouldBeFunction(cb);
opts = opts || {};
self.config = config || baseConfig;
self.notifyTicker = 0;
self._setClientVersion(opts.clientVersion);
self.setLog();
if (opts.request) {
request = opts.request;
}
function initStorage(cb) {
if (opts.storage) {
self.storage = opts.storage;
return cb();
}
if (!self.storage) {
var newStorage = new self.ctx.Storage(self.config.storageOpts, {
creator: 'WalletService (' + self.LIVENET.currency + ')'
});
newStorage.connect(function(err) {
if (err) {
return cb(err);
}
self.storage = newStorage;
self.config.storage = self.storage;
return cb();
});
} else {
return cb();
}
};
function initBlockchainExplorer(cb) {
// If a blockchain explorer was provided then set it.
self.blockchainExplorer = opts.blockchainExplorer;
return cb();
};
function initMessageBroker(cb) {
if (!messageBroker) {
messageBroker = opts.messageBroker || new MessageBroker(self.config.messageBrokerOpts);
messageBroker.onMessage(lodash.bind(self.handleIncomingNotification, self));
}
return cb();
};
function initLock(cb) {
if (!lock) {
lock = config.lock || new Lock(config.lockOpts);
}
return cb();
};
function initFiatRateService(cb) {
if (self.config.fiatRateService) {
fiatRateService = self.config.fiatRateService;
return cb();
} else {
var newFiatRateService = new FiatRateService(self.config);
newFiatRateService.init({
storage: self.storage
} , function(err) {
if (err) {
return cb(err);
}
fiatRateService = newFiatRateService;
return cb();
});
}
};
async.series([
function(next) {
initStorage(next);
},
function(next) {
initBlockchainExplorer(next);
},
function(next) {
initMessageBroker(next);
},
function(next) {
initLock(next);
},
function(next) {
initFiatRateService(next);
}
], function(err) {
if (err) {
log.error('Could not initialize', err);
throw err;
}
return cb(self);
});
};
WalletService.prototype.handleIncomingNotification = function(notification, cb) {
var self = this;
cb = cb || function() {};
if (!notification || notification.type != 'NewBlock' ||
!MessageBroker.isNotificationForMe(notification, [self.LIVENET.name, self.TESTNET.name])) {
return cb();
}
WalletService._clearBlockchainHeightCache(notification.networkName);
return cb();
};
WalletService.prototype.shutDown = function(cb) {
var self = this;
if (messageBroker) {
messageBroker.removeAllListeners();
messageBroker = undefined;
}
if (self.storage) {
self.storage.disconnect(function(err) {
if (err) {
return cb(err);
}
self.storage = undefined;
return cb();
});
} else {
return cb();
}
};
/**
* Gets an instance of the server without authentication.
* @param {Object} opts - Options for the server
* @param {Object} opts.blockchainExplorer - A blockchain explorer instance to attach
* @param {Object} opts.storage - A storage instance to attach
* @param {Object} config - Service configuration, see ../config.js
*/
WalletService.getInstance = function(opts, config) {
throw 'Must override';
};
/**
* Initialize an instance of the server after authenticating the copayer.
* @param {Object} opts - Options for the server
* @param {Object} opts.blockchainExplorer - A blockchain explorer instance to attach
* @param {Object} opts.storage - A storage instance to attach
* @param {string} opts.clientVersion - A string that identifies the client issuing the request
* @param {Object} config - Service configuration, see ../config.js
* @param {Object} auth
* @param {string} auth.copayerId - The copayer id making the request.
* @param {string} auth.message - (Optional) The contents of the request to be signed. Only needed if no session token is provided.
* @param {string} auth.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} auth.session - (Optional) A valid session token previously obtained using the #login method
* @param {string} [auth.walletId] - The wallet id to use as current wallet for this request (only when copayer is support staff).
*/
WalletService.getInstanceWithAuth = function(opts, config, auth, cb) {
throw 'Must override';
};
WalletService.prototype.initInstanceWithAuth = function(auth, cb) {
var self = this;
if (auth.session) {
if (!self.checkRequired(auth, ['copayerId', 'session'], cb)) {
return;
}
} else {
if (!self.checkRequired(auth, ['copayerId', 'message', 'signature'], cb)) {
return;
}
}
function withSignature(cb) {
self.storage.fetchCopayerLookup(auth.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 = !!self._getSigningKey(auth.message, auth.signature, copayer.requestPubKeys);
if (!isValid) {
return cb(new ClientError(Errors.codes.NOT_AUTHORIZED, 'Invalid signature'));
}
self.walletId = copayer.walletId;
} else {
self.walletId = auth.walletId || copayer.walletId;
self.copayerIsSupportStaff = true;
}
self.copayerId = auth.copayerId;
return cb(null, self);
});
};
function withSession(cb) {
self.storage.getSession(auth.copayerId, function(err, s) {
if (err) {
return cb(err);
}
var isValid = s && s.id == auth.session && s.isValid();
if (!isValid) {
return cb(new ClientError(Errors.codes.NOT_AUTHORIZED, 'Session expired'));
}
self.storage.fetchCopayerLookup(auth.copayerId, function(err, copayer) {
if (err) {
return cb(err);
}
if (!copayer) {
return cb(new ClientError(Errors.codes.NOT_AUTHORIZED, 'Copayer not found'));
}
self.copayerId = auth.copayerId;
self.walletId = copayer.walletId;
return cb(null, self);
});
});
};
var authFn = auth.session ? withSession : withSignature;
return authFn(cb);
};
WalletService.prototype._runLocked = function(cb, task) {
$.checkState(this.walletId);
lock.runLocked(this.walletId, cb, task);
};
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 = new self.ctx.Session({
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);
};
/**
* Gets the storage for this instance of the server.
*/
WalletService.prototype.getStorage = function() {
return self.storage;
};
/**
* Gets the message broker for this instance of the server.
*/
WalletService.prototype.getMessageBroker = function() {
return messageBroker;
};
/**
* 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.networkName[=self.LIVENET.name] - The 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;
var pubKey;
if (!self.checkRequired(opts, ['name', 'm', 'n', 'pubKey'], cb)) {
return;
}
if (lodash.isEmpty(opts.name)) {
return cb(new ClientError('Invalid wallet name'));
}
if (!self.ctx.Wallet.verifyCopayerLimits(opts.m, opts.n)) {
return cb(new ClientError('Invalid combination of required copayers / total copayers'));
}
opts.networkName = opts.networkName || self.LIVENET.name;
if (!lodash.includes([self.LIVENET.name, self.TESTNET.name], opts.networkName)) {
return cb(new ClientError('Invalid network'));
}
opts.supportBIP44AndP2PKH = lodash.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 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 = self.ctx.Wallet.create({
id: opts.id,
name: opts.name,
m: opts.m,
n: opts.n,
networkName: opts.networkName,
pubKey: pubKey.toString(),
singleAddress: !!opts.singleAddress,
derivationStrategy: derivationStrategy,
addressType: addressType,
});
self.storage.storeWallet(wallet, function(err) {
log.debug('Wallet created', wallet.id, opts.networkName);
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.fetchAddress(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?
async.detectSeries(lodash.values([self.LIVENET, self.TESTNET]), function(networkName, nextNetwork) {
var bc = self._getBlockchainExplorer(networkName);
if (!bc) {
return nextNetwork(false);
}
bc.getTransaction(opts.identifier, function(err, tx) {
if (err || !tx) {
return nextNetwork(false);
}
var outputs = lodash.head(self._normalizeTxHistory(tx)).outputs;
var toAddresses = lodash.map(outputs, 'address');
async.detect(toAddresses, function(addressStr, nextAddress) {
self.storage.fetchAddress(addressStr, function(err, address) {
if (err || !address) {
return nextAddress(false);
}
walletId = address.walletId;
nextAddress(true);
});
}, function() {
nextNetwork(!!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', 'addressManager', 'customData'];
wallet.copayers = lodash.map(wallet.copayers, function(copayer) {
if (copayer.id == self.copayerId) {
return copayer;
}
return lodash.omit(copayer, 'customData');
});
if (!opts.includeExtendedInfo) {
wallet = lodash.omit(wallet, walletExtendedKeys);
wallet.copayers = lodash.map(wallet.copayers, function(copayer) {
return lodash.omit(copayer, copayerExtendedKeys);
});
}
status.wallet = wallet;
next();
});
},
function(next) {
console.log('WalletService.prototype.getStatus getBalance');
self.getBalance(opts, function(err, balance) {
if (err) {
return next(err);
}
status.balance = balance;
next();
});
},
function(next) {
console.log('WalletService.prototype.getStatus getPendingTxs');
self.getPendingTxs({}, function(err, pendingTxps) {
if (err) {
return next(err);
}
status.pendingTxps = pendingTxps;
next();
});
},
function(next) {
console.log('WalletService.prototype.getStatus getPreferences');
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 this.ctx.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 HDPublicKey(xPubKey)).deriveChild(Constants.PATHS.REQUEST_KEY_AUTH).publicKey;
return this.ctx.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 lodash.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 (lodash.isFunction(opts)) {
cb = opts;
opts = {};
}
opts = opts || {};
log.debug('Notification', type, data);
cb = cb || function() {};
var walletId = self.walletId || data.walletId;
var copayerId = self.copayerId || data.copayerId;
$.checkState(walletId);
self.storage.fetchWallet(walletId, function(err, wallet) {
if (err) {
return cb(err);
}
var notification = Model.Notification.create({
type: type,
data: data,
ticker: self.notifyTicker++,
creatorId: opts.isGlobal ? null : copayerId,
walletId: walletId,
networkName: opts.isGlobal && !wallet ? walletId : wallet.networkName,
});
self.storage.storeNotification(walletId, notification, function(err) {
messageBroker.send(notification);
return cb();
});
});
};
WalletService.prototype._notifyTxProposalAction = function(type, txp, extraArgs, cb) {
var self = this;
if (lodash.isFunction(extraArgs)) {
cb = extraArgs;
extraArgs = {};
}
var data = lodash.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 = self.ctx.Copayer.create({
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 (!self.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 = lodash.find(wallet.copayers, {
id: opts.copayerId
}).xPubKey;
if (!self._verifyRequestPubKey(opts.requestPubKey, opts.signature, xPubKey)) {
return cb(Errors.NOT_AUTHORIZED);
}
if (copayer.requestPubKeys.length > self.ctx.Defaults.MAX_KEYS) {
return cb(Errors.TOO_MANY_KEYS);
}
self._addKeyToCopayer(wallet, copayer, opts, cb);
});
});
};
WalletService.prototype.getClientVersion = function(version) {
return this.clientVersion;
};
WalletService.prototype._setClientVersion = function(version) {
delete this.parsedClientVersion;
this.clientVersion = version;
};
WalletService.prototype._parseClientVersion = function() {
if (lodash.isUndefined(this.parsedClientVersion)) {
this.parsedClientVersion = this.ctx.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.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 (!self.checkRequired(opts, ['walletId', 'name', 'xPubKey', 'requestPubKey', 'copayerSignature'], cb)) {
return;
}
if (lodash.isEmpty(opts.name)) {
return cb(new ClientError('Invalid copayer name'));
}
try {
HDPublicKey(opts.xPubKey);
} catch (ex) {
return cb(new ClientError('Invalid extended public key'));
}
opts.supportBIP44AndP2PKH = lodash.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.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 (lodash.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 - Currency 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 lodash.isString(value) && value.length == 2;
},
}, {
name: 'unit',
isValid: function(value) {
return lodash.isString(value) && lodash.includes(self.ctx.Unit().getCodes(), value);
},
}];
opts = lodash.pick(opts, lodash.map(preferences, 'name'));
try {
lodash.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(lodash.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 = lodash.takeRight(lodash.reject(addresses, {
isChange: true
}), self.ctx.Defaults.MAX_MAIN_ADDRESS_GAP);
if (latestAddresses.length < self.ctx.Defaults.MAX_MAIN_ADDRESS_GAP || lodash.some(latestAddresses, {
hasActivity: true
})) return cb(null, true);
var bc = self._getBlockchainExplorer(latestAddresses[0].networkName);
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 = wallet.createAddress(false);
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 (!lodash.isEmpty(addresses)) {
return cb(null, lodash.head(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);
});
});
});
};
/**
* 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 = lodash.reject(addresses, {
isChange: true
});
if (opts.reverse) {
onlyMain.reverse();
}
if (opts.limit > 0) {
onlyMain = lodash.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 (!self.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(networkName) {
var self = this;
if (self.blockchainExplorer) {
return self.blockchainExplorer;
}
// Use network alias to lookup configuration.
var network = self.ctx.Networks.get(networkName);
var config = {};
var provider;
if (self.config[network.currency].blockchainExplorerOpts) {
// TODO: provider should be configurable
provider = self.config[network.currency].blockchainExplorerOpts.defaultProvider;
if (self.config[network.currency].blockchainExplorerOpts[provider][network.alias]) {
config = self.config[network.currency].blockchainExplorerOpts[provider][network.alias];
}
}
var opts = {};
opts.provider = provider;
opts.networkAlias = network.alias;
opts.userAgent = WalletService.getServiceVersion();
var bc;
try {
bc = new self.ctx.BlockchainExplorer(opts, self.config);
} catch (ex) {
log.warn('Could not instantiate blockchain explorer', ex);
}
return bc;
};
WalletService.prototype._getUtxos = function(addresses, cb) {
var self = this;
if (addresses.length == 0) {
return cb(null, []);
}
var networkName = self.ctx.Address(addresses[0]).toObject().network;
var bc = self._getBlockchainExplorer(networkName);
if (!bc) {
return cb(new Error('Could not get blockchain explorer instance'));
}
bc.getUtxos(addresses, function(err, utxos) {
if (err) {
return cb(err);
}
var utxos = lodash.map(utxos, function(utxo) {
var u = lodash.pick(utxo, ['txid', 'vout', 'address', 'scriptPubKey', 'amount', self.atomicsName, 'confirmations']);
u.confirmations = u.confirmations || 0;
u.locked = false;
u[self.atomicsName] = lodash.isNumber(u[self.atomicsName]) ? +u[self.atomicsName] : self.ctx.Utils.strip(u.amount * 1e8);
delete u.amount;
return u;
});
return cb(null, utxos);
});
};
WalletService.prototype._getUtxosForCurrentWallet = function(addresses, cb) {
var self = this;
function utxoKey(utxo) {
return utxo.txid + '|' + utxo.vout
};
var allAddresses, allUtxos, utxoIndex;
async.series([
function(next) {
if (lodash.isArray(addresses)) {
allAddresses = addresses;
return next();
}
self.storage.fetchAddresses(self.walletId, function(err, addresses) {
allAddresses = addresses;
return next();
});
},
function(next) {
if (allAddresses.length == 0) {
return cb(null, []);
}
var addressStrs = lodash.map(allAddresses, 'address');
self._getUtxos(addressStrs, function(err, utxos) {
if (err) {
return next(err);
}
if (utxos.length == 0) {
return cb(null, []);
}
allUtxos = utxos;
utxoIndex = lodash.keyBy(allUtxos, utxoKey);
return next();
});
},
function(next) {
self.getPendingTxs({}, function(err, txps) {
if (err) {
return next(err);
}
var lockedInputs = lodash.map(lodash.flatten(lodash.map(txps, 'inputs')), utxoKey);
lodash.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 = lodash.map(lodash.flatten(lodash.map(txs, 'inputs')), utxoKey);
lodash.each(spentInputs, function(input) {
if (utxoIndex[input]) {
utxoIndex[input].spent = true;
}
});
allUtxos = lodash.reject(allUtxos, {
spent: true
});
return next();
});
},
function(next) {
// Needed for the clients to sign UTXOs
var addressToPath = lodash.keyBy(allAddresses, 'address');
lodash.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 {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 (lodash.isUndefined(opts.addresses)) {
self._getUtxosForCurrentWallet(null, cb);
} else {
self._getUtxos(opts.addresses, cb);
}
};
WalletService.prototype._totalizeUtxos = function(utxos) {
var self = this;
var balance = {
totalAmount: lodash.sumBy(utxos, self.atomicsName),
lockedAmount: lodash.sumBy(lodash.filter(utxos, 'locked'), self.atomicsName),
totalConfirmedAmount: lodash.sumBy(lodash.filter(utxos, 'confirmations'), self.atomicsName),
lockedConfirmedAmount: lodash.sumBy(lodash.filter(lodash.filter(utxos, 'locked'), 'confirmations'), self.atomicsName),
};
balance.availableAmount = balance.totalAmount - balance.lockedAmount;
balance.availableConfirmedAmount = balance.totalConfirmedAmount - balance.lockedConfirmedAmount;
return balance;
};
WalletService.prototype._getBalanceFromAddresses = function(addresses, cb) {
var self = this;
self._getUtxosForCurrentWallet(addresses, function(err, utxos) {
if (err) {
return cb(err);
}
var balance = self._totalizeUtxos(utxos);
// Compute balance by address
var byAddress = {};
lodash.each(lodash.keyBy(lodash.sortBy(utxos, 'address'), 'address'), function(value, key) {
byAddress[key] = {
address: key,
path: value.path,
amount: 0,
};
});
lodash.each(utxos, function(utxo) {
byAddress[utxo.address].amount += utxo[self.atomicsName];
});
balance.byAddress = lodash.values(byAddress);
return cb(null, balance);
});
};
WalletService.prototype._getBalanceOneStep = function(opts, cb) {
var self = this;
self.storage.fetchAddresses(self.walletId, function(err, addresses) {
if (err) {
return cb(err);
}
self._getBalanceFromAddresses(addresses, function(err, balance) {
if (err) {
return cb(err);
}
// Update cache
async.series([
function(next) {
self.storage.cleanActiveAddresses(self.walletId, next);
},
function(next) {
var active = lodash.map(balance.byAddress, 'address')
self.storage.storeActiveAddresses(self.walletId, active, next);
},
], function(err) {
if (err) {
log.warn('Could not update wallet cache', err);
}
return cb(null, balance);
});
});
});
};
WalletService.prototype._getActiveAddresses = function(cb) {
var self = this;
self.storage.fetchActiveAddresses(self.walletId, function(err, active) {
if (err) {
log.warn('Could not fetch active addresses from cache', err);
return cb();
}
if (!lodash.isArray(active)) {
return cb();
}
self.storage.fetchAddresses(self.walletId, function(err, allAddresses) {
if (err) {
return cb(err);
}
var now = Math.floor(Date.now() / 1000);
var recent = lodash.map(lodash.filter(allAddresses, function(address) {
return address.createdOn > (now - 24 * 3600);
}), 'address');
var result = lodash.union(active, recent);
var index = lodash.keyBy(allAddresses, 'address');
result = lodash.compact(lodash.map(result, function(r) {
return index[r];
}));
return cb(null, result);
});
});
};
/**
* Get wallet balance.
* @param {Object} opts
* @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) {
var self = this;
opts = opts || {};
if (!opts.twoStep) {
return self._getBalanceOneStep(opts, cb);
}
self.storage.countAddresses(self.walletId, function(err, nbAddresses) {
if (err) {
return cb(err);
}
if (nbAddresses < self.ctx.Defaults.TWO_STEP_BALANCE_THRESHOLD) {
return self._getBalanceOneStep(opts, cb);
}
self._getActiveAddresses(function(err, activeAddresses) {
if (err) {
return cb(err);
}
if (!lodash.isArray(activeAddresses)) {
return self._getBalanceOneStep(opts, cb);
} else {
log.debug('Requesting partial balance for ' + activeAddresses.length + ' out of ' + nbAddresses + ' addresses');
self._getBalanceFromAddresses(activeAddresses, function(err, partialBalance) {
if (err) {
return cb(err);
}
cb(null, partialBalance);
setTimeout(function() {
self._getBalanceOneStep(opts, function(err, fullBalance) {
if (err) {
return;
}
if (!lodash.isEqual(partialBalance, fullBalance)) {
log.info('Balance in active addresses differs from final balance');
self._notify('BalanceUpdated', fullBalance, {
isGlobal: true
});
}
});
}, 1);
return;
});
}
});
});
};
/**
* 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 atomic units).
* @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 || {};
var feeArgs = !!opts.feeLevel + lodash.isNumber(opts.feePerKb);
if (feeArgs > 1) {
return cb(new ClientError('Only one of feeLevel/feePerKb can be specified'));
}
if (feeArgs == 0) {
log.debug('No fee provided, using "normal" fee level');
opts.feeLevel = 'normal';
}
if (opts.feeLevel) {
if (!lodash.some(self.ctx.Defaults.FEE_LEVELS, {
name: opts.feeLevel
}))
return cb(new ClientError('Invalid fee level. Valid values are ' + lodash.map(self.ctx.Defaults.FEE_LEVELS, 'name').join(', ')));
}
if (lodash.isNumber(opts.feePerKb)) {
if (opts.feePerKb < self.ctx.Defaults.MIN_FEE_PER_KB || opts.feePerKb > self.ctx.Defaults.MAX_FEE_PER_KB) {
return cb(new ClientError('Invalid fee per KB'));
}
}
self.getWallet({}, function(err, wallet) {
if (err) {
return cb(err);
}
self._getUtxosForCurrentWallet(null, 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 = lodash.reject(utxos, 'locked');
if (!!opts.excludeUnconfirmedUtxos) {
inputs = lodash.filter(inputs, 'confirmations');
}
inputs = lodash.sortBy(inputs, function(input) {
return -input[self.atomicsName];
});
if (lodash.isEmpty(inputs)) {
return cb(null, info);
}
self._getFeePerKb(wallet, opts, function(err, feePerKb) {
if (err) {
return cb(err);
}
info.feePerKb = feePerKb;
var txp = new self.ctx.TxProposal({
walletId: self.walletId,
networkName: wallet.networkName,
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 = lodash.partition(inputs, function(input) {
return input[self.atomicsName] > feePerInput;
});
info.utxosBelowFee = partitionedByAmount[1].length;
info.amountBelowFee = lodash.sumBy(partitionedByAmount[1], self.atomicsName);
inputs = partitionedByAmount[0];
lodash.each(inputs, function(input, i) {
var sizeInKb = (baseTxpSize + (i + 1) * sizePerInput) / 1000.;
if (sizeInKb > self.ctx.Defaults.MAX_TX_SIZE_IN_KB) {
info.utxosAboveMaxSize = inputs.length - i;
info.amountAboveMaxSize = lodash.sumBy(lodash.slice(inputs, i), self.atomicsName);
return false;
}
txp.inputs.push(input);
});
if (lodash.isEmpty(txp.inputs)) {
return cb(null, info);
}
var fee = txp.getEstimatedFee();
var amount = lodash.sumBy(txp.inputs, self.atomicsName) - fee;
if (amount < self.ctx.Defaults.MIN_OUTPUT_AMOUNT) {
return cb(null, info);
}
info.size = txp.getEstimatedSize();
info.fee = fee;
info.amount = amount;
if (opts.returnInputs) {
info.inputs = lodash.shuffle(txp.inputs);
}
return cb(null, info);
});
});
});
};
WalletService.prototype._sampleFeeLevels = function(networkName, points, cb) {
var self = this;
var bc = self._getBlockchainExplorer(networkName);
if (!bc) {
return cb(new Error('Could not get blockchain explorer instance'));
}
bc.estimateFee(points, function(err, result) {
if (err) {
log.error('Error estimating fee', err);
return cb(err);
}
var failed = [];
var levels = lodash.fromPairs(lodash.map(points, function(p) {
var feePerKb = lodash.isObject(result) ? +result[p] : -1;
if (feePerKb < 0) {
failed.push(p);
}
return [p, self.ctx.Utils.strip(feePerKb * 1e8)];
}));
if (failed.length) {
var logge