@owstack/wallet-service
Version:
A service for multisignature HD wallets
1,574 lines (1,374 loc) • 128 kB
JavaScript
const owsCommon = require('@owstack/ows-common');
const keyLib = require('@owstack/key-lib');
const async = require('async');
const baseConfig = require('config');
const ClientError = require('./errors/clienterror');
const Constants = owsCommon.Constants;
const EmailValidator = require('email-validator');
const errors = owsCommon.errors;
const Errors = require('./errors/errordefinitions');
const FiatRateService = require('./fiatrateservice');
const HDPublicKey = keyLib.HDPublicKey;
const Lock = require('./lock');
const log = require('npmlog');
const MessageBroker = require('./messagebroker');
const Model = require('./model');
const pkg = require('../../package');
const PublicKey = keyLib.PublicKey;
const lodash = owsCommon.deps.lodash;
const $ = require('preconditions').singleton();
log.debug = log.verbose;
log.disableColor();
let serviceVersion;
/**
* Static services shared among all instances of this WalletService.
*/
let lock;
let fiatRateService;
let 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.atomicsAccessor = this.ctx.Unit().atomicsAccessor();
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) {
const 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) {
const self = this;
$.shouldBeFunction(cb);
opts = opts || {};
self.config = config || baseConfig;
self.notifyTicker = 0;
self._setClientVersion(opts.clientVersion);
self.setLog();
function initStorage(cb) {
if (opts.storage) {
self.storage = opts.storage;
return cb();
}
if (!self.storage) {
const 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 {
const 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) {
const 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) {
const 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) {
const 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) {
const 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);
}
const 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);
});
});
}
const 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) {
const self = this;
let 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) {
this.storage.removeSession(this.copayerId, cb);
};
/**
* Gets the storage for this instance of the server.
*/
WalletService.prototype.getStorage = function () {
return this.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) {
const self = this;
let 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;
const derivationStrategy = opts.supportBIP44AndP2PKH ? Constants.DERIVATION_STRATEGIES.BIP44 : Constants.DERIVATION_STRATEGIES.BIP45;
const 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'));
}
let 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) {
const 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) {
const 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) {
const self = this;
if (!opts.identifier) {
return cb();
}
let 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);
}
const 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) {
const bc = self._getBlockchainExplorer(networkName);
if (!bc) {
return nextNetwork(false);
}
bc.getTransaction(opts.identifier, function (err, tx) {
if (err || !tx) {
return nextNetwork(false);
}
const outputs = lodash.head(self._normalizeTxHistory(tx)).outputs;
const 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) {
const self = this;
opts = opts || {};
const status = {};
async.parallel([
function (next) {
self.getWallet({}, function (err, wallet) {
if (err) {
return next(err);
}
const walletExtendedKeys = ['publicKeyRing', 'pubKey', 'addressManager'];
const 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) {
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 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) {
const 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) {
const 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) {
const self = this;
if (lodash.isFunction(opts)) {
cb = opts;
opts = {};
}
opts = opts || {};
log.debug('Notification', type, data);
cb = cb || function () {};
const walletId = self.walletId || data.walletId;
const copayerId = self.copayerId || data.copayerId;
$.checkState(walletId);
self.storage.fetchWallet(walletId, function (err, wallet) {
if (err) {
return cb(err);
}
const 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) {
if (err) {
log.error(err);
}
messageBroker.send(notification);
return cb();
});
});
};
WalletService.prototype._notifyTxProposalAction = function (type, txp, extraArgs, cb) {
const self = this;
if (lodash.isFunction(extraArgs)) {
cb = extraArgs;
extraArgs = {};
}
const 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) {
const self = this;
const 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) {
const 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) {
const 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);
}
const 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 () {
const 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) {
const 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.'));
}
}
const 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) {
const self = this;
opts = opts || {};
const 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) {
const value = opts[preference.name];
if (!value) {
return;
}
if (!preference.isValid(value)) {
throw `Invalid ${ preference.name}`;
}
});
} 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);
}
const newPref = Model.Preferences.create({
walletId: self.walletId,
copayerId: self.copayerId,
});
const 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) {
const 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) {
const self = this;
if (ignoreMaxGap) {
return cb(null, true);
}
self.storage.fetchAddresses(self.walletId, function (err, addresses) {
if (err) {
return cb(err);
}
const 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);
}
const bc = self._getBlockchainExplorer(latestAddresses[0].networkName);
if (!bc) {
return cb(new Error('Could not get blockchain explorer instance'));
}
let activityFound = false;
let 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);
}
const 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) {
const self = this;
opts = opts || {};
function createNewAddress(wallet, cb) {
const 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);
}
const 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) {
const self = this;
opts = opts || {};
self.storage.fetchAddresses(self.walletId, function (err, addresses) {
if (err) {
return cb(err);
}
let 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) {
const self = this;
if (!self.checkRequired(opts, ['message', 'signature'], cb)) {
return;
}
self.getWallet({}, function (err, wallet) {
if (err) {
return cb(err);
}
const copayer = wallet.getCopayer(self.copayerId);
const isValid = !!self._getSigningKey(opts.message, opts.signature, copayer.requestPubKeys);
return cb(null, isValid);
});
};
WalletService.prototype._getBlockchainExplorer = function (networkName) {
const self = this;
if (self.blockchainExplorer) {
return self.blockchainExplorer;
}
// Use network alias to lookup configuration.
const network = self.ctx.Networks.get(networkName);
let config = {};
let 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];
}
}
const opts = {};
opts.provider = provider;
opts.networkAlias = network.alias;
opts.userAgent = WalletService.getServiceVersion();
let 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) {
const self = this;
if (addresses.length == 0) {
return cb(null, []);
}
const networkName = self.ctx.Address(addresses[0]).toObject().network;
const 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);
}
utxos = lodash.map(utxos, function (utxo) {
const u = lodash.pick(utxo, ['txid', 'vout', 'address', 'scriptPubKey', 'amount', self.atomicsAccessor, 'confirmations']);
u.confirmations = u.confirmations || 0;
u.locked = false;
u[self.atomicsAccessor] = lodash.isNumber(u[self.atomicsAccessor]) ? +u[self.atomicsAccessor] : self.ctx.Utils.strip(u.amount * 1e8);
delete u.amount;
return u;
});
return cb(null, utxos);
});
};
WalletService.prototype._getUtxosForCurrentWallet = function (addresses, cb) {
const self = this;
function utxoKey(utxo) {
return `${utxo.txid }|${ utxo.vout}`;
}
let allAddresses; let allUtxos; let 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, []);
}
const 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);
}
const 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) {
const 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);
}
const 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
const 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) {
const self = this;
opts = opts || {};
if (lodash.isUndefined(opts.addresses)) {
self._getUtxosForCurrentWallet(null, cb);
} else {
self._getUtxos(opts.addresses, cb);
}
};
WalletService.prototype._totalizeUtxos = function (utxos) {
const self = this;
const balance = {
totalAmount: lodash.sumBy(utxos, self.atomicsAccessor),
lockedAmount: lodash.sumBy(lodash.filter(utxos, 'locked'), self.atomicsAccessor),
totalConfirmedAmount: lodash.sumBy(lodash.filter(utxos, 'confirmations'), self.atomicsAccessor),
lockedConfirmedAmount: lodash.sumBy(lodash.filter(lodash.filter(utxos, 'locked'), 'confirmations'), self.atomicsAccessor),
};
balance.availableAmount = balance.totalAmount - balance.lockedAmount;
balance.availableConfirmedAmount = balance.totalConfirmedAmount - balance.lockedConfirmedAmount;
return balance;
};
WalletService.prototype._getBalanceFromAddresses = function (addresses, cb) {
const self = this;
self._getUtxosForCurrentWallet(addresses, function (err, utxos) {
if (err) {
return cb(err);
}
const balance = self._totalizeUtxos(utxos);
// Compute balance by address
const 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.atomicsAccessor];
});
balance.byAddress = lodash.values(byAddress);
return cb(null, balance);
});
};
WalletService.prototype._getBalanceOneStep = function (opts, cb) {
const 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) {
const 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) {
const 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);
}
const now = Math.floor(Date.now() / 1000);
const recent = lodash.map(lodash.filter(allAddresses, function (address) {
return address.createdOn > (now - 24 * 3600);
}), 'address');
let result = lodash.union(active, recent);
const 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) {
const 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) {