bitcore-wallet-service/lib/server.js

3575 lines
108 KiB
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('bitcore-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);
if (wallet.scanStatus == 'error')
return cb(Errors.WALLET_NEED_SCAN);
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);
if (wallet.scanStatus == 'error')
return cb(Errors.WALLET_NEED_SCAN);
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;
if (coin != 'bch') return next();
if (Utils.getAddressCoin(addressStrs[0]) == 'bch')
return next();
// because some old BCH walelts could have legacy addresses?
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) {
if (!addressToPath[utxo.address]) {
log.warn('Ignored UTXO!: ' + utxo.address);
return;
}
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 Error('Could not get blockchain explorer instance'));
bc.estimateFee(points, function(err, result) {
if (err) {
self.logw('Error estimating fee', err);
return cb(err);
}
var failed = [];
var levels = _.zipObject(_.map(points, function(p) {
var feePerKb = _.isObject(result) ? +result[p] : -1;
if (feePerKb < 0)
failed.push(p);
return [p, Utils.strip(feePerKb * 1e8)];
}));
if (failed.length) {
var logger = network == 'livenet' ? self.logw : self.logi;
logger('Could not compute fee estimation in ' + network + ': ' + failed.join(', ') + ' blocks.');
}
return cb(null, levels);
});
};
/**
* Returns fee levels for the current state of the network.
* @param {Object} opts
* @param {string} [opts.coin = 'btc'] - The coin to estimate fee levels from.
* @param {string} [opts.network = 'livenet'] - The Bitcoin network to estimate fee levels from.
* @returns {Object} feeLevels - A list of fee levels & associated amount per kB in satoshi.
*/
WalletService.prototype.getFeeLevels = function(opts, cb) {
var self = this;
opts = opts || {};
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'));
function checkAndUseFeeLevelsCache (next) {
self.storage.checkAndUseFeeLevelsCache(opts, next);
};
function storeFeeLevelsCache(values,next) {
self.storage.storeFeeLevelsCache(opts, values, next);
};
checkAndUseFeeLevelsCache(function(err, values) {
if (err) return cb(err);
if (values) return cb(null, values, true);
var feeLevels = Defaults.FEE_LEVELS[opts.coin];
function samplePoints() {
var definedPoints = _.uniq(_.map(feeLevels, 'nbBlocks'));
return _.uniq(_.flatten(_.map(definedPoints, function(p) {
return _.range(p, p + Defaults.FEE_LEVELS_FALLBACK + 1);
})));
};
function getFeeLevel(feeSamples, level, n, fallback) {
var result;
if (feeSamples[n] >= 0) {
result = {
nbBlocks: n,
feePerKb: feeSamples[n],
};
} else {
if (fallback > 0) {
result = getFeeLevel(feeSamples, level, n + 1, fallback - 1);
} else {
result = {
feePerKb: level.defaultValue,
nbBlocks: null,
};
}
}
return result;
};
self._sampleFeeLevels(opts.coin, opts.network, samplePoints(), function(err, feeSamples) {
var values = _.map(feeLevels, function(level) {
var result = {
level: level.name,
};
if (err) {
result.feePerKb = level.defaultValue;
result.nbBlocks = null;
} else {
var feeLevel = getFeeLevel(feeSamples, level, level.nbBlocks, Defaults.FEE_LEVELS_FALLBACK);
result.feePerKb = +(feeLevel.feePerKb * (level.multiplier || 1)).toFixed(0);
result.nbBlocks = feeLevel.nbBlocks;
}
return result;
});
// Ensure monotonically decreasing values
for (var i = 1; i < values.length; i++) {
values[i].feePerKb = Math.min(values[i].feePerKb, values[i - 1].feePerKb);
}
storeFeeLevelsCache(values, function(err) {
if (err) {
log.warn('Could not store fee level cache');
}
return cb(null, values);
});
});
});
};
WalletService.prototype._estimateFee = function(txp) {
txp.estimateFee();
};
WalletService.prototype._checkTx = function(txp) {
var self = this;
var bitcoreError;
var serializationOpts = {
disableIsFullySigned: true,
disableSmallFees: true,
disableLargeFees: true,
};
if (txp.getEstimatedSize() / 1000 > Defaults.MAX_TX_SIZE_IN_KB)
return Errors.TX_MAX_SIZE_EXCEEDED;
try {
var bitcoreTx = txp.getBitcoreTx();
bitcoreError = bitcoreTx.getSerializationError(serializationOpts);
if (!bitcoreError) {
txp.fee = bitcoreTx.getFee();
}
} catch (ex) {
self.logw('Error building Bitcore transaction', ex);
return ex;
}
if (bitcoreError instanceof Bitcore_[txp.coin].errors.Transaction.FeeError)
return Errors.INSUFFICIENT_FUNDS_FOR_FEE;
if (bitcoreError instanceof Bitcore_[txp.coin].errors.Transaction.DustOutputs)
return Errors.DUST_AMOUNT;
return bitcoreError;
};
WalletService.prototype._selectTxInputs = function(txp, utxosToExclude, cb) {
var self = this;
//todo: check inputs are ours and have enough value
if (txp.inputs && !_.isEmpty(txp.inputs)) {
if (!_.isNumber(txp.fee))
self._estimateFee(txp);
return cb(self._checkTx(txp));
}
var txpAmount = txp.getTotalAmount();
var baseTxpSize = txp.getEstimatedSize();
var baseTxpFee = baseTxpSize * txp.feePerKb / 1000.;
var sizePerInput = txp.getEstimatedSizeForSingleInput();
var feePerInput = sizePerInput * txp.feePerKb / 1000.;
function sanitizeUtxos(utxos) {
var excludeIndex = _.reduce(utxosToExclude, function(res, val) {
res[val] = val;
return res;
}, {});
return _.filter(utxos, function(utxo) {
if (utxo.locked) return false;
if (utxo.satoshis <= feePerInput) return false;
if (txp.excludeUnconfirmedUtxos && !utxo.confirmations) return false;
if (excludeIndex[utxo.txid + ":" + utxo.vout]) return false;
return true;
});
};
function partitionUtxos(utxos) {
return _.groupBy(utxos, function(utxo) {
if (utxo.confirmations == 0) return '0'
if (utxo.confirmations < 6) return '<6';
return '6+';
});
};
function select(utxos, cb) {
var totalValueInUtxos = _.sum(utxos, 'satoshis');
var netValueInUtxos = totalValueInUtxos - baseTxpFee - (utxos.length * feePerInput);
if (totalValueInUtxos < txpAmount) {
self.logi('Total value in all utxos (' + Utils.formatAmountInBtc(totalValueInUtxos) + ') is insufficient to cover for txp amount (' + Utils.formatAmountInBtc(txpAmount) + ')');
return cb(Errors.INSUFFICIENT_FUNDS);
}
if (netValueInUtxos < txpAmount) {
self.logi('Value after fees in all utxos (' + Utils.formatAmountInBtc(netValueInUtxos) + ') is insufficient to cover for txp amount (' + Utils.formatAmountInBtc(txpAmount) + ')');
return cb(Errors.INSUFFICIENT_FUNDS_FOR_FEE);
}
var bigInputThreshold = txpAmount * Defaults.UTXO_SELECTION_MAX_SINGLE_UTXO_FACTOR + (baseTxpFee + feePerInput);
self.logi('Big input threshold ' + Utils.formatAmountInBtc(bigInputThreshold));
var partitions = _.partition(utxos, function(utxo) {
return utxo.satoshis > bigInputThreshold;
});
var bigInputs = _.sortBy(partitions[0], 'satoshis');
var smallInputs = _.sortBy(partitions[1], function(utxo) {
return -utxo.satoshis;
});
//log.debug('Considering ' + bigInputs.length + ' big inputs (' + Utils.formatUtxos(bigInputs) + ')');
//log.debug('Considering ' + smallInputs.length + ' small inputs (' + Utils.formatUtxos(smallInputs) + ')');
var total = 0;
var netTotal = -baseTxpFee;
var selected = [];
var fee;
var error;
_.each(smallInputs, function(input, i) {
//log.debug('Input #' + i + ': ' + Utils.formatUtxos(input));
var netInputAmount = input.satoshis - feePerInput;
//log.debug('The input contributes ' + Utils.formatAmountInBtc(netInputAmount));
selected.push(input);
total += input.satoshis;
netTotal += netInputAmount;
var txpSize = baseTxpSize + selected.length * sizePerInput;
fee = Math.round(baseTxpFee + selected.length * feePerInput);
//log.debug('Tx size: ' + Utils.formatSize(txpSize) + ', Tx fee: ' + Utils.formatAmountInBtc(fee));
var feeVsAmountRatio = fee / txpAmount;
var amountVsUtxoRatio = netInputAmount / txpAmount;
//log.debug('Fee/Tx amount: ' + Utils.formatRatio(feeVsAmountRatio) + ' (max: ' + Utils.formatRatio(Defaults.UTXO_SELECTION_MAX_FEE_VS_TX_AMOUNT_FACTOR) + ')');
//log.debug('Tx amount/Input amount:' + Utils.formatRatio(amountVsUtxoRatio) + ' (min: ' + Utils.formatRatio(Defaults.UTXO_SELECTION_MIN_TX_AMOUNT_VS_UTXO_FACTOR) + ')');
if (txpSize / 1000. > Defaults.MAX_TX_SIZE_IN_KB) {
//log.debug('Breaking because tx size (' + Utils.formatSize(txpSize) + ') is too big (max: ' + Utils.formatSize(Defaults.MAX_TX_SIZE_IN_KB * 1000.) + ')');
error = Errors.TX_MAX_SIZE_EXCEEDED;
return false;
}
if (!_.isEmpty(bigInputs)) {
if (amountVsUtxoRatio < Defaults.UTXO_SELECTION_MIN_TX_AMOUNT_VS_UTXO_FACTOR) {
//log.debug('Breaking because utxo is too small compared to tx amount');
return false;
}
if (feeVsAmountRatio > Defaults.UTXO_SELECTION_MAX_FEE_VS_TX_AMOUNT_FACTOR) {
var feeVsSingleInputFeeRatio = fee / (baseTxpFee + feePerInput);
//log.debug('Fee/Single-input fee: ' + Utils.formatRatio(feeVsSingleInputFeeRatio) + ' (max: ' + Utils.formatRatio(Defaults.UTXO_SELECTION_MAX_FEE_VS_SINGLE_UTXO_FEE_FACTOR) + ')' + ' loses wrt single-input tx: ' + Utils.formatAmountInBtc((selected.length - 1) * feePerInput));
if (feeVsSingleInputFeeRatio > Defaults.UTXO_SELECTION_MAX_FEE_VS_SINGLE_UTXO_FEE_FACTOR) {
//log.debug('Breaking because fee is too significant compared to tx amount and it is too expensive compared to using single input');
return false;
}
}
}
//log.debug('Cumuled total so far: ' + Utils.formatAmountInBtc(total) + ', Net total so far: ' + Utils.formatAmountInBtc(netTotal));
if (netTotal >= txpAmount) {
var changeAmount = Math.round(total - txpAmount - fee);
//log.debug('Tx change: ', Utils.formatAmountInBtc(changeAmount));
var dustThreshold = Math.max(Defaults.MIN_OUTPUT_AMOUNT, Bitcore_[txp.coin].Transaction.DUST_AMOUNT);
if (changeAmount > 0 && changeAmount <= dustThreshold) {
//log.debug('Change below dust threshold (' + Utils.formatAmountInBtc(dustThreshold) + '). Incrementing fee to remove change.');
// Remove dust change by incrementing fee
fee += changeAmount;
}
return false;
}
});
if (netTotal < txpAmount) {
//log.debug('Could not reach Txp total (' + Utils.formatAmountInBtc(txpAmount) + '), still missing: ' + Utils.formatAmountInBtc(txpAmount - netTotal));
selected = [];
if (!_.isEmpty(bigInputs)) {
var input = _.first(bigInputs);
//log.debug('Using big input: ', Utils.formatUtxos(input));
total = input.satoshis;
fee = Math.round(baseTxpFee + feePerInput);
netTotal = total - fee;
selected = [input];
}
}
if (_.isEmpty(selected)) {
//log.debug('Could not find enough funds within this utxo subset');
return cb(error || Errors.INSUFFICIENT_FUNDS_FOR_FEE);
}
return cb(null, selected, fee);
};
//log.debug('Selecting inputs for a ' + Utils.formatAmountInBtc(txp.getTotalAmount()) + ' txp');
self._getUtxosForCurrentWallet({}, function(err, utxos) {
if (err) return cb(err);
var totalAmount;
var availableAmount;
var balance = self._totalizeUtxos(utxos);
if (txp.excludeUnconfirmedUtxos) {
totalAmount = balance.totalConfirmedAmount;
availableAmount = balance.availableConfirmedAmount;
} else {
totalAmount = balance.totalAmount;
availableAmount = balance.availableAmount;
}
if (totalAmount < txp.getTotalAmount()) return cb(Errors.INSUFFICIENT_FUNDS);
if (availableAmount < txp.getTotalAmount()) return cb(Errors.LOCKED_FUNDS);
utxos = sanitizeUtxos(utxos);
//log.debug('Considering ' + utxos.length + ' utxos (' + Utils.formatUtxos(utxos) + ')');
var groups = [6, 1];
if (!txp.excludeUnconfirmedUtxos) groups.push(0);
var inputs = [];
var fee;
var selectionError;
var i = 0;
var lastGroupLength;
async.whilst(function() {
return i < groups.length && _.isEmpty(inputs);
}, function(next) {
var group = groups[i++];
var candidateUtxos = _.filter(utxos, function(utxo) {
return utxo.confirmations >= group;
});
//log.debug('Group >= ' + group);
// If this group does not have any new elements, skip it
if (lastGroupLength === candidateUtxos.length) {
//log.debug('This group is identical to the one already explored');
return next();
}
//log.debug('Candidate utxos: ' + Utils.formatUtxos(candidateUtxos));
lastGroupLength = candidateUtxos.length;
select(candidateUtxos, function(err, selectedInputs, selectedFee) {
if (err) {
//log.debug('No inputs selected on this group: ', err);
selectionError = err;
return next();
}
selectionError = null;
inputs = selectedInputs;
fee = selectedFee;
//log.debug('Selected inputs from this group: ' + Utils.formatUtxos(inputs));
//log.debug('Fee for this selection: ' + Utils.formatAmountInBtc(fee));
return next();
});
}, function(err) {
if (err) return cb(err);
if (selectionError || _.isEmpty(inputs)) return cb(selectionError || new Error('Could not select tx inputs'));
txp.setInputs(_.shuffle(inputs));
txp.fee = fee;
var err = self._checkTx(txp);
if (!err) {
var change = _.sum(txp.inputs, 'satoshis') - _.sum(txp.outputs, 'amount') - txp.fee;
self.logi('Successfully built transaction. Total fees: ' + Utils.formatAmountInBtc(txp.fee) + ', total change: ' + Utils.formatAmountInBtc(change));
} else {
self.logw('Error building transaction', err);
}
return cb(err);
});
});
};
WalletService.prototype._canCreateTx = function(cb) {
var self = this;
self.storage.fetchLastTxs(self.walletId, self.copayerId, 5 + Defaults.BACKOFF_OFFSET, function(err, txs) {
if (err) return cb(err);
if (!txs.length)
return cb(null, true);
var lastRejections = _.takeWhile(txs, {
status: 'rejected'
});
var exceededRejections = lastRejections.length - Defaults.BACKOFF_OFFSET;
if (exceededRejections <= 0)
return cb(null, true);
var lastTxTs = txs[0].createdOn;
var now = Math.floor(Date.now() / 1000);
var timeSinceLastRejection = now - lastTxTs;
var backoffTime = Defaults.BACKOFF_TIME;
if (timeSinceLastRejection <= backoffTime)
self.logi('Not allowing to create TX: timeSinceLastRejection/backoffTime', timeSinceLastRejection, backoffTime);
return cb(null, timeSinceLastRejection > backoffTime);
});
};
WalletService.prototype._validateOutputs = function(opts, wallet, cb) {
var A = Bitcore_[wallet.coin].Address;
var dustThreshold = Math.max(Defaults.MIN_OUTPUT_AMOUNT, Bitcore_[wallet.coin].Transaction.DUST_AMOUNT);
if (_.isEmpty(opts.outputs)) return new ClientError('No outputs were specified');
for (var i = 0; i < opts.outputs.length; i++) {
var output = opts.outputs[i];
output.valid = false;
if (!checkRequired(output, ['toAddress', 'amount'])) {
return new ClientError('Argument missing in output #' + (i + 1) + '.');
}
var toAddress = {};
try {
toAddress = new A(output.toAddress);
} catch (ex) {
return Errors.INVALID_ADDRESS;
}
if (toAddress.network != wallet.network) {
return Errors.INCORRECT_ADDRESS_NETWORK;
}
if (!_.isNumber(output.amount) || _.isNaN(output.amount) || output.amount <= 0) {
return new ClientError('Invalid amount');
}
if (output.amount < dustThreshold) {
return Errors.DUST_AMOUNT;
}
output.valid = true;
}
return null;
};
WalletService.prototype._validateAndSanitizeTxOpts = function(wallet, opts, cb) {
var self = this;
async.series([
function(next) {
var feeArgs = !!opts.feeLevel + _.isNumber(opts.feePerKb) + _.isNumber(opts.fee);
if (feeArgs > 1)
return next(new ClientError('Only one of feeLevel/feePerKb/fee 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 next(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 next(new ClientError('Invalid fee per KB'));
}
if (_.isNumber(opts.fee) && _.isEmpty(opts.inputs))
return next(new ClientError('fee can only be set when inputs are specified'));
next();
},
function(next) {
if (wallet.singleAddress && opts.changeAddress) return next(new ClientError('Cannot specify change address on single-address wallet'));
next();
},
function(next) {
if (!opts.sendMax) return next();
if (!_.isArray(opts.outputs) || opts.outputs.length > 1) {
return next(new ClientError('Only one output allowed when sendMax is specified'));
}
if (_.isNumber(opts.outputs[0].amount))
return next(new ClientError('Amount is not allowed when sendMax is specified'));
if (_.isNumber(opts.fee))
return next(new ClientError('Fee is not allowed when sendMax is specified (use feeLevel/feePerKb instead)'));
self.getSendMaxInfo({
feePerKb: opts.feePerKb,
excludeUnconfirmedUtxos: !!opts.excludeUnconfirmedUtxos,
returnInputs: true,
}, function(err, info) {
if (err) return next(err);
opts.outputs[0].amount = info.amount;
opts.inputs = info.inputs;
return next();
});
},
function(next) {
if (opts.validateOutputs === false) return next();
var validationError = self._validateOutputs(opts, wallet, next);
if (validationError) {
return next(validationError);
}
next();
},
], cb);
};
WalletService.prototype._getFeePerKb = function(wallet, opts, cb) {
var self = this;
if (_.isNumber(opts.feePerKb)) return cb(null, opts.feePerKb);
self.getFeeLevels({
coin: wallet.coin,
network: wallet.network
}, function(err, levels) {
if (err) return cb(err);
var level = _.find(levels, {
level: opts.feeLevel
});
if (!level) {
var msg = 'Could not compute fee for "' + opts.feeLevel + '" level';
self.logw(msg);
return cb(new ClientError(msg));
}
return cb(null, level.feePerKb);
});
};
/**
* Creates a new transaction proposal.
* @param {Object} opts
* @param {string} opts.txProposalId - Optional. If provided it will be used as this TX proposal ID. Should be unique in the scope of the wallet.
* @param {Array} opts.outputs - List of outputs.
* @param {string} opts.outputs[].toAddress - Destination address.
* @param {number} opts.outputs[].amount - Amount to transfer in satoshi.
* @param {string} opts.outputs[].message - A message to attach to this output.
* @param {string} opts.message - A message to attach to this transaction.
* @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.changeAddress - Optional. Use this address as the change address for the tx. The address should belong to the wallet. In the case of singleAddress wallets, the first main address will be used.
* @param {Boolean} opts.sendMax - Optional. Send maximum amount of funds that make sense under the specified fee/feePerKb conditions. (defaults to false).
* @param {string} opts.payProUrl - Optional. Paypro URL for peers to verify TX
* @param {Boolean} opts.excludeUnconfirmedUtxos[=false] - Optional. Do not use UTXOs of unconfirmed transactions as inputs
* @param {Boolean} opts.validateOutputs[=true] - Optional. Perform validation on outputs.
* @param {Boolean} opts.dryRun[=false] - Optional. Simulate the action but do not change server state.
* @param {Array} opts.inputs - Optional. Inputs for this TX
* @param {number} opts.fee - Optional. Use an fixed fee for this TX (only when opts.inputs is specified)
* @param {Boolean} opts.noShuffleOutputs - Optional. If set, TX outputs won't be shuffled. Defaults to false
* @returns {TxProposal} Transaction proposal.
*/
WalletService.prototype.createTx = function(opts, cb) {
var self = this;
opts = opts || {};
function getChangeAddress(wallet, cb) {
if (wallet.singleAddress) {
self.storage.fetchAddresses(self.walletId, function(err, addresses) {
if (err) return cb(err);
if (_.isEmpty(addresses)) return cb(new ClientError('The wallet has no addresses'));
return cb(null, _.first(addresses));
});
} else {
if (opts.changeAddress) {
self.storage.fetchAddressByWalletId(wallet.id, opts.changeAddress, function(err, address) {
if (err) return cb(Errors.INVALID_CHANGE_ADDRESS);
return cb(null, address);
});
} else {
return cb(null, wallet.createAddress(true));
}
}
};
function checkTxpAlreadyExists(txProposalId, cb) {
if (!txProposalId) return cb();
self.storage.fetchTx(self.walletId, txProposalId, cb);
};
self._runLocked(cb, function(cb) {
var txp, changeAddress, feePerKb;
self.getWallet({}, function(err, wallet) {
if (err) return cb(err);
if (!wallet.isComplete()) return cb(Errors.WALLET_NOT_COMPLETE);
if (wallet.scanStatus == 'error')
return cb(Errors.WALLET_NEED_SCAN);
checkTxpAlreadyExists(opts.txProposalId, function(err, txp) {
if (err) return cb(err);
if (txp) return cb(null, txp);
async.series([
function(next) {
self._validateAndSanitizeTxOpts(wallet, opts, next);
},
function(next) {
self._canCreateTx(function(err, canCreate) {
if (err) return next(err);
if (!canCreate) return next(Errors.TX_CANNOT_CREATE);
next();
});
},
function(next) {
if (opts.sendMax) return next();
getChangeAddress(wallet, function(err, address) {
if (err) return next(err);
changeAddress = address;
next();
});
},
function(next) {
if (_.isNumber(opts.fee) && !_.isEmpty(opts.inputs)) return next();
self._getFeePerKb(wallet, opts, function(err, fee) {
feePerKb = fee;
next();
});
},
function(next) {
var txOpts = {
id: opts.txProposalId,
walletId: self.walletId,
creatorId: self.copayerId,
coin: wallet.coin,
network: wallet.network,
outputs: opts.outputs,
message: opts.message,
changeAddress: changeAddress,
feeLevel: opts.feeLevel,
feePerKb: feePerKb,
payProUrl: opts.payProUrl,
walletM: wallet.m,
walletN: wallet.n,
excludeUnconfirmedUtxos: !!opts.excludeUnconfirmedUtxos,
validateOutputs: !opts.validateOutputs,
addressType: wallet.addressType,
customData: opts.customData,
inputs: opts.inputs,
fee: opts.inputs && !_.isNumber(opts.feePerKb) ? opts.fee : null,
noShuffleOutputs: opts.noShuffleOutputs
};
txp = Model.TxProposal.create(txOpts);
next();
},
function(next) {
self._selectTxInputs(txp, opts.utxosToExclude, next);
},
function(next) {
if (!changeAddress || wallet.singleAddress || opts.dryRun) return next();
self.storage.storeAddressAndWallet(wallet, txp.changeAddress, next);
},
function(next) {
if (opts.dryRun) return next();
self.storage.storeTx(wallet.id, txp, next);
},
], function(err) {
if (err) return cb(err);
return cb(null, txp);
});
});
});
}, 10 * 1000);
};
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());
};
/**
* Publish an already created tx proposal so inputs are locked and other copayers in the wallet can see it.
* @param {Object} opts
* @param {string} opts.txProposalId - The tx id.
* @param {string} opts.proposalSignature - S(raw tx). Used by other copayers to verify the proposal.
*/
WalletService.prototype.publishTx = function(opts, cb) {
var self = this;
function utxoKey(utxo) {
return utxo.txid + '|' + utxo.vout
};
if (!checkRequired(opts, ['txProposalId', 'proposalSignature'], cb)) return;
self._runLocked(cb, function(cb) {
self.getWallet({}, function(err, wallet) {
if (err) return cb(err);
self.storage.fetchTx(self.walletId, opts.txProposalId, function(err, txp) {
if (err) return cb(err);
if (!txp) return cb(Errors.TX_NOT_FOUND);
if (!txp.isTemporary()) return cb(null, txp);
var copayer = wallet.getCopayer(self.copayerId);
var raw;
try {
raw = txp.getRawTx();
} catch (ex) {
return cb(ex);
}
var signingKey = self._getSigningKey(raw, opts.proposalSignature, copayer.requestPubKeys);
if (!signingKey) {
return cb(new ClientError('Invalid proposal signature'));
}
// Save signature info for other copayers to check
txp.proposalSignature = opts.proposalSignature;
if (signingKey.selfSigned) {
txp.proposalSignaturePubKey = signingKey.key;
txp.proposalSignaturePubKeySig = signingKey.signature;
}
// Verify UTXOs are still available
self._getUtxosForCurrentWallet({
addresses: txp.inputs,
}, function(err, utxos) {
if (err) return cb(err);
var txpInputs = _.map(txp.inputs, utxoKey);
var utxosIndex = _.indexBy(utxos, utxoKey);
var unavailable = _.any(txpInputs, function(i) {
var utxo = utxosIndex[i];
return !utxo || utxo.locked;
});
if (unavailable) return cb(Errors.UNAVAILABLE_UTXOS);
txp.status = 'pending';
self.storage.storeTx(self.walletId, txp, function(err) {
if (err) return cb(err);
self._notifyTxProposalAction('NewTxProposal', txp, function() {
return cb(null, txp);
});
});
});
});
});
});
};
/**
* Retrieves a tx from storage.
* @param {Object} opts
* @param {string} opts.txProposalId - The tx id.
* @returns {Object} txProposal
*/
WalletService.prototype.getTx = function(opts, cb) {
var self = this;
self.storage.fetchTx(self.walletId, opts.txProposalId, function(err, txp) {
if (err) return cb(err);
if (!txp) return cb(Errors.TX_NOT_FOUND);
if (!txp.txid) return cb(null, txp);
self.storage.fetchTxNote(self.walletId, txp.txid, function(err, note) {
if (err) {
self.logw('Error fetching tx note for ' + txp.txid);
}
txp.note = note;
return cb(null, txp);
});
});
};
/**
* Edit note associated to a txid.
* @param {Object} opts
* @param {string} opts.txid - The txid of the tx on the blockchain.
* @param {string} opts.body - The contents of the note.
*/
WalletService.prototype.editTxNote = function(opts, cb) {
var self = this;
if (!checkRequired(opts, 'txid', cb)) return;
self._runLocked(cb, function(cb) {
self.storage.fetchTxNote(self.walletId, opts.txid, function(err, note) {
if (err) return cb(err);
if (!note) {
note = Model.TxNote.create({
walletId: self.walletId,
txid: opts.txid,
copayerId: self.copayerId,
body: opts.body,
});
} else {
note.edit(opts.body, self.copayerId);
}
self.storage.storeTxNote(note, function(err) {
if (err) return cb(err);
self.storage.fetchTxNote(self.walletId, opts.txid, cb);
});
});
});
};
/**
* Get tx notes.
* @param {Object} opts
* @param {string} opts.txid - The txid associated with the note.
*/
WalletService.prototype.getTxNote = function(opts, cb) {
var self = this;
if (!checkRequired(opts, 'txid', cb)) return;
self.storage.fetchTxNote(self.walletId, opts.txid, cb);
};
/**
* Get tx notes.
* @param {Object} opts
* @param {string} opts.minTs[=0] - The start date used to filter notes.
*/
WalletService.prototype.getTxNotes = function(opts, cb) {
var self = this;
opts = opts || {};
self.storage.fetchTxNotes(self.walletId, opts, cb);
};
/**
* removeWallet
*
* @param opts
* @param cb
* @return {undefined}
*/
WalletService.prototype.removeWallet = function(opts, cb) {
var self = this;
self._runLocked(cb, function(cb) {
self.storage.removeWallet(self.walletId, cb);
});
};
WalletService.prototype.getRemainingDeleteLockTime = function(txp) {
var now = Math.floor(Date.now() / 1000);
var lockTimeRemaining = txp.createdOn + Defaults.DELETE_LOCKTIME - now;
if (lockTimeRemaining < 0)
return 0;
// not the creator? need to wait
if (txp.creatorId !== this.copayerId)
return lockTimeRemaining;
// has other approvers? need to wait
var approvers = txp.getApprovers();
if (approvers.length > 1 || (approvers.length == 1 && approvers[0] !== this.copayerId))
return lockTimeRemaining;
return 0;
};
/**
* removePendingTx
*
* @param opts
* @param {string} opts.txProposalId - The tx id.
* @return {undefined}
*/
WalletService.prototype.removePendingTx = function(opts, cb) {
var self = this;
if (!checkRequired(opts, ['txProposalId'], cb)) return;
self._runLocked(cb, function(cb) {
self.getTx({
txProposalId: opts.txProposalId,
}, function(err, txp) {
if (err) return cb(err);
if (!txp.isPending()) return cb(Errors.TX_NOT_PENDING);
var deleteLockTime = self.getRemainingDeleteLockTime(txp);
if (deleteLockTime > 0) return cb(Errors.TX_CANNOT_REMOVE);
self.storage.removeTx(self.walletId, txp.id, function() {
self._notifyTxProposalAction('TxProposalRemoved', txp, cb);
});
});
});
};
WalletService.prototype._broadcastRawTx = function(coin, network, raw, cb) {
var bc = this._getBlockchainExplorer(coin, network);
if (!bc) return cb(new Error('Could not get blockchain explorer instance'));
bc.broadcast(raw, function(err, txid) {
if (err) return cb(err);
return cb(null, txid);
});
};
/**
* Broadcast a raw transaction.
* @param {Object} opts
* @param {string} [opts.coin = 'btc'] - The coin for this transaction.
* @param {string} [opts.network = 'livenet'] - The Bitcoin network for this transaction.
* @param {string} opts.rawTx - Raw tx data.
*/
WalletService.prototype.broadcastRawTx = function(opts, cb) {
var self = this;
if (!checkRequired(opts, ['network', 'rawTx'], cb)) return;
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'));
self._broadcastRawTx(opts.coin, opts.network, opts.rawTx, cb);
};
WalletService.prototype._checkTxInBlockchain = function(txp, cb) {
if (!txp.txid) return cb();
var bc = this._getBlockchainExplorer(txp.coin, txp.network);
if (!bc) return cb(new Error('Could not get blockchain explorer instance'));
bc.getTransaction(txp.txid, function(err, tx) {
if (err) return cb(err);
return cb(null, !!tx);
})
};
/**
* Sign a transaction proposal.
* @param {Object} opts
* @param {string} opts.txProposalId - The identifier of the transaction.
* @param {string} opts.signatures - The signatures of the inputs of this tx for this copayer (in apperance order)
*/
WalletService.prototype.signTx = function(opts, cb) {
var self = this;
if (!checkRequired(opts, ['txProposalId', 'signatures'], cb)) return;
self.getWallet({}, function(err, wallet) {
if (err) return cb(err);
self.getTx({
txProposalId: opts.txProposalId
}, function(err, txp) {
if (err) return cb(err);
var action = _.find(txp.actions, {
copayerId: self.copayerId
});
if (action) return cb(Errors.COPAYER_VOTED);
if (!txp.isPending()) return cb(Errors.TX_NOT_PENDING);
var copayer = wallet.getCopayer(self.copayerId);
try {
if (!txp.sign(self.copayerId, opts.signatures, copayer.xPubKey)) {
self.logw('Error signing transaction (BAD_SIGNATURES)');
self.logw('Client version:', self.clientVersion);
self.logw('Arguments:', JSON.stringify(opts));
self.logw('Transaction proposal:', JSON.stringify(txp));
var raw = txp.getBitcoreTx().uncheckedSerialize();
self.logw('Raw tx:', raw);
return cb(Errors.BAD_SIGNATURES);
}
} catch (ex) {
self.logw('Error signing transaction proposal', ex);
return cb(ex);
}
self.storage.storeTx(self.walletId, txp, function(err) {
if (err) return cb(err);
async.series([
function(next) {
self._notifyTxProposalAction('TxProposalAcceptedBy', txp, {
copayerId: self.copayerId,
}, next);
},
function(next) {
if (txp.isAccepted()) {
self._notifyTxProposalAction('TxProposalFinallyAccepted', txp, next);
} else {
next();
}
},
], function() {
return cb(null, txp);
});
});
});
});
};
WalletService.prototype._processBroadcast = function(txp, opts, cb) {
var self = this;
$.checkState(txp.txid);
opts = opts || {};
txp.setBroadcasted();
self.storage.storeTx(self.walletId, txp, function(err) {
if (err) return cb(err);
var extraArgs = {
txid: txp.txid,
};
if (opts.byThirdParty) {
self._notifyTxProposalAction('NewOutgoingTxByThirdParty', txp, extraArgs);
} else {
self._notifyTxProposalAction('NewOutgoingTx', txp, extraArgs);
}
self.storage.softResetTxHistoryCache(self.walletId, function() {
return cb(err, txp);
});
});
};
/**
* Broadcast a transaction proposal.
* @param {Object} opts
* @param {string} opts.txProposalId - The identifier of the transaction.
*/
WalletService.prototype.broadcastTx = function(opts, cb) {
var self = this;
if (!checkRequired(opts, ['txProposalId'], cb)) return;
self.getWallet({}, function(err, wallet) {
if (err) return cb(err);
self.getTx({
txProposalId: opts.txProposalId
}, function(err, txp) {
if (err) return cb(err);
if (txp.status == 'broadcasted') return cb(Errors.TX_ALREADY_BROADCASTED);
if (txp.status != 'accepted') return cb(Errors.TX_NOT_ACCEPTED);
var raw;
try {
raw = txp.getRawTx();
} catch (ex) {
return cb(ex);
}
self._broadcastRawTx(wallet.coin, wallet.network, raw, function(err, txid) {
if (err) {
var broadcastErr = err;
// Check if tx already in blockchain
self._checkTxInBlockchain(txp, function(err, isInBlockchain) {
if (err) return cb(err);
if (!isInBlockchain) return cb(broadcastErr);
self._processBroadcast(txp, {
byThirdParty: true
}, cb);
});
} else {
self._processBroadcast(txp, {
byThirdParty: false
}, function(err) {
if (err) return cb(err);
return cb(null, txp);
});
}
});
});
});
};
/**
* Reject a transaction proposal.
* @param {Object} opts
* @param {string} opts.txProposalId - The identifier of the transaction.
* @param {string} [opts.reason] - A message to other copayers explaining the rejection.
*/
WalletService.prototype.rejectTx = function(opts, cb) {
var self = this;
if (!checkRequired(opts, ['txProposalId'], cb)) return;
self.getTx({
txProposalId: opts.txProposalId
}, function(err, txp) {
if (err) return cb(err);
var action = _.find(txp.actions, {
copayerId: self.copayerId
});
if (action) return cb(Errors.COPAYER_VOTED);
if (txp.status != 'pending') return cb(Errors.TX_NOT_PENDING);
txp.reject(self.copayerId, opts.reason);
self.storage.storeTx(self.walletId, txp, function(err) {
if (err) return cb(err);
async.series([
function(next) {
self._notifyTxProposalAction('TxProposalRejectedBy', txp, {
copayerId: self.copayerId,
}, next);
},
function(next) {
if (txp.status == 'rejected') {
var rejectedBy = _.map(_.filter(txp.actions, {
type: 'reject'
}), 'copayerId');
self._notifyTxProposalAction('TxProposalFinallyRejected', txp, {
rejectedBy: rejectedBy,
}, next);
} else {
next();
}
},
], function() {
return cb(null, txp);
});
});
});
};
/**
* Retrieves pending transaction proposals.
* @param {Object} opts
* @returns {TxProposal[]} Transaction proposal.
*/
WalletService.prototype.getPendingTxs = function(opts, cb) {
var self = this;
self.storage.fetchPendingTxs(self.walletId, function(err, txps) {
if (err) return cb(err);
_.each(txps, function(txp) {
txp.deleteLockTime = self.getRemainingDeleteLockTime(txp);
});
async.each(txps, function(txp, next) {
if (txp.status != 'accepted') return next();
self._checkTxInBlockchain(txp, function(err, isInBlockchain) {
if (err || !isInBlockchain) return next(err);
self._processBroadcast(txp, {
byThirdParty: true
}, next);
});
}, function(err) {
return cb(err, _.reject(txps, function(txp) {
return txp.status == 'broadcasted';
}));
});
});
};
/**
* Retrieves all transaction proposals in the range (maxTs-minTs)
* Times are in UNIX EPOCH
*
* @param {Object} opts.minTs (defaults to 0)
* @param {Object} opts.maxTs (defaults to now)
* @param {Object} opts.limit
* @returns {TxProposal[]} Transaction proposals, newer first
*/
WalletService.prototype.getTxs = function(opts, cb) {
var self = this;
self.storage.fetchTxs(self.walletId, opts, function(err, txps) {
if (err) return cb(err);
return cb(null, txps);
});
};
/**
* Retrieves notifications after a specific id or from a given ts (whichever is more recent).
*
* @param {Object} opts
* @param {Object} opts.notificationId (optional)
* @param {Object} opts.minTs (optional) - default 0.
* @returns {Notification[]} Notifications
*/
WalletService.prototype.getNotifications = function(opts, cb) {
var self = this;
opts = opts || {};
self.getWallet({}, function(err, wallet) {
if (err) return cb(err);
async.map([wallet.network, self.walletId], function(walletId, next) {
self.storage.fetchNotifications(walletId, opts.notificationId, opts.minTs || 0, next);
}, function(err, res) {
if (err) return cb(err);
var notifications = _.sortBy(_.map(_.flatten(res), function(n) {
n.walletId = self.walletId;
return n;
}), 'id');
return cb(null, notifications);
});
});
};
WalletService.prototype._normalizeTxHistory = function(txs) {
var now = Math.floor(Date.now() / 1000);
return _.map([].concat(txs), function(tx) {
var inputs = _.map(tx.vin, function(item) {
return {
address: item.addr,
amount: item.valueSat,
}
});
var outputs = _.map(tx.vout, function(item) {
var itemAddr;
// If classic multisig, ignore
if (item.scriptPubKey && _.isArray(item.scriptPubKey.addresses) && item.scriptPubKey.addresses.length == 1) {
itemAddr = item.scriptPubKey.addresses[0];
}
return {
address: itemAddr,
amount: parseInt((item.value * 1e8).toFixed(0)),
}
});
var t = tx.blocktime; // blocktime
if (!t || _.isNaN(t)) t = tx.firstSeenTs;
if (!t || _.isNaN(t)) t = now;
return {
txid: tx.txid,
confirmations: tx.confirmations,
blockheight: tx.blockheight,
fees: parseInt((tx.fees * 1e8).toFixed(0)),
size: tx.size,
time: t,
inputs: inputs,
outputs: outputs,
};
});
};
WalletService._cachedBlockheight;
WalletService._initBlockchainHeightCache = function() {
if (WalletService._cachedBlockheight) return;
WalletService._cachedBlockheight = {
btc: {
livenet: {},
testnet: {}
},
bch: {
livenet: {},
testnet: {}
},
};
};
WalletService._clearBlockchainHeightCache = function(coin, network) {
WalletService._initBlockchainHeightCache();
if (!Utils.checkValueInCollection(network, Constants.NETWORKS)) {
log.error('Incorrect network in new block: ' + coin + '/' + network);
return;
}
WalletService._cachedBlockheight[coin][network].current = null;
};
WalletService.prototype._getBlockchainHeight = function(coin, network, cb) {
var self = this;
var now = Date.now();
WalletService._initBlockchainHeightCache();
var cache = WalletService._cachedBlockheight[coin][network];
function fetchFromBlockchain(cb) {
var bc = self._getBlockchainExplorer(coin, network);
if (!bc) return cb(new Error('Could not get blockchain explorer instance'));
bc.getBlockchainHeight(function(err, height) {
if (!err && height > 0) {
cache.current = height;
cache.last = height;
cache.updatedOn = now;
}
return cb(null, cache.last);
});
};
if (!cache.current || (now - cache.updatedOn) > Defaults.BLOCKHEIGHT_CACHE_TIME * 1000) {
return fetchFromBlockchain(cb);
}
return cb(null, cache.current);
};
/**
* Retrieves all transactions (incoming & outgoing)
* Times are in UNIX EPOCH
*
* @param {Object} opts
* @param {Number} opts.skip (defaults to 0)
* @param {Number} opts.limit
* @param {Number} opts.includeExtendedInfo[=false] - Include all inputs/outputs for every tx.
* @returns {TxProposal[]} Transaction proposals, first newer
*/
WalletService.prototype.getTxHistory = function(opts, cb) {
var self = this;
opts = opts || {};
opts.limit = (_.isUndefined(opts.limit) ? Defaults.HISTORY_LIMIT : opts.limit);
if (opts.limit > Defaults.HISTORY_LIMIT)
return cb(Errors.HISTORY_LIMIT_EXCEEDED);
function decorate(wallet, txs, addresses, proposals, notes) {
var indexedAddresses = _.indexBy(addresses, 'address');
var indexedProposals = _.indexBy(proposals, 'txid');
var indexedNotes = _.indexBy(notes, 'txid');
function sum(items, isMine, isChange) {
var filter = {};
if (_.isBoolean(isMine)) filter.isMine = isMine;
if (_.isBoolean(isChange)) filter.isChange = isChange;
return _.sum(_.filter(items, filter), 'amount');
};
function classify(items) {
return _.map(items, function(item) {
var address = indexedAddresses[item.address];
return {
address: item.address,
amount: item.amount,
isMine: !!address,
isChange: address ? (address.isChange || wallet.singleAddress) : false,
}
});
};
return _.map(txs, function(tx) {
var amountIn, amountOut, amountOutChange;
var amount, action, addressTo;
var inputs, outputs, foreignCrafted;
if (tx.outputs.length || tx.inputs.length) {
inputs = classify(tx.inputs);
outputs = classify(tx.outputs);
amountIn = sum(inputs, true);
amountOut = sum(outputs, true, false);
amountOutChange = sum(outputs, true, true);
if (amountIn == (amountOut + amountOutChange + (amountIn > 0 ? tx.fees : 0))) {
amount = amountOut;
action = 'moved';
} else {
// BWS standard sent
//(amountIn > 0 && amountOutChange >0 && outputs.length <= 2)
amount = amountIn - amountOut - amountOutChange - ((amountIn > 0 && amountOutChange >0 ) ? tx.fees : 0);
action = amount > 0 ? 'sent' : 'received';
}
amount = Math.abs(amount);
if (action == 'sent' || action == 'moved') {
var firstExternalOutput = _.find(outputs, {
isMine: false
});
addressTo = firstExternalOutput ? firstExternalOutput.address : null;
};
if (action == 'sent' && inputs.length != (_.filter(inputs,'isMine')).length ) {
foreignCrafted = true;
}
} else {
action = 'invalid';
amount = 0;
}
function formatOutput(o) {
return {
amount: o.amount,
address: o.address
}
};
var newTx = {
txid: tx.txid,
action: action,
amount: amount,
fees: tx.fees,
time: tx.time,
addressTo: addressTo,
confirmations: tx.confirmations,
foreignCrafted: foreignCrafted,
};
if (_.isNumber(tx.size) && tx.size > 0) {
newTx.feePerKb = +(tx.fees * 1000 / tx.size).toFixed();
}
if (opts.includeExtendedInfo) {
newTx.inputs = _.map(inputs, function(input) {
return _.pick(input, 'address', 'amount', 'isMine');
});
newTx.outputs = _.map(outputs, function(output) {
return _.pick(output, 'address', 'amount', 'isMine');
});
} else {
outputs = _.filter(outputs, {
isChange: false
});
if (action == 'received') {
outputs = _.filter(outputs, {
isMine: true
});
}
newTx.outputs = _.map(outputs, formatOutput);
}
var proposal = indexedProposals[tx.txid];
if (proposal) {
newTx.createdOn = proposal.createdOn;
newTx.proposalId = proposal.id;
newTx.proposalType = proposal.type;
newTx.creatorName = proposal.creatorName;
newTx.message = proposal.message;
newTx.actions = _.map(proposal.actions, function(action) {
return _.pick(action, ['createdOn', 'type', 'copayerId', 'copayerName', 'comment']);
});
_.each(newTx.outputs, function(output) {
var query = {
toAddress: output.address,
amount: output.amount
};
var txpOut = _.find(proposal.outputs, query);
output.message = txpOut ? txpOut.message : null;
});
newTx.customData = proposal.customData;
// newTx.sentTs = proposal.sentTs;
// newTx.merchant = proposal.merchant;
//newTx.paymentAckMemo = proposal.paymentAckMemo;
}
var note = indexedNotes[tx.txid];
if (note) {
newTx.note = _.pick(note, ['body', 'editedBy', 'editedByName', 'editedOn']);
}
return newTx;
});
};
function getNormalizedTxs(wallet, addresses, from, to, cb) {
var txs, fromCache, totalItems;
var useCache = addresses.length >= Defaults.HISTORY_CACHE_ADDRESS_THRESOLD;
async.series([
function(next) {
if (!useCache) return next();
self.storage.getTxHistoryCache(self.walletId, from, to, function(err, res) {
if (err) return next(err);
if (!res || !res[0]) return next();
txs = res;
fromCache = true;
return next()
});
},
function(next) {
if (txs) return next();
var addressStrs = _.map(addresses, 'address');
var bc = self._getBlockchainExplorer(wallet.coin, wallet.network);
if (!bc) return next(new Error('Could not get blockchain explorer instance'));
self.logi('Querying tx for: %s addrs', addresses.length);
bc.getTransactions(addressStrs, from, to, function(err, rawTxs, total) {
if (err) return next(err);
console.log('[server.js.3197: RAWtxs:]', JSON.stringify(rawTxs, null, 4)); //TODO
txs = self._normalizeTxHistory(rawTxs);
console.log('[server.js.3197:txs:]', JSON.stringify(txs, null, 4)); //TODO
totalItems = total;
return next();
});
},
function(next) {
if (!useCache || fromCache) return next();
var txsToCache = _.filter(txs, function(i) {
return i.confirmations >= Defaults.CONFIRMATIONS_TO_START_CACHING;
}).reverse();
if (!txsToCache.length) return next();
var fwdIndex = totalItems - to;
if (fwdIndex < 0) fwdIndex = 0;
self.storage.storeTxHistoryCache(self.walletId, totalItems, fwdIndex, txsToCache, next);
},
function(next) {
if (!useCache || !fromCache) return next();
if (!txs) return next();
// Fix tx confirmations for cached txs
self._getBlockchainHeight(wallet.coin, wallet.network, function(err, height) {
if (err || !height) return next(err);
_.each(txs, function(tx) {
if (tx.blockheight >= 0) {
tx.confirmations = height - tx.blockheight + 1;
}
});
next();
});
},
], function(err) {
if (err) return cb(err);
return cb(null, {
items: txs,
fromCache: fromCache
});
});
};
function tagLowFees(wallet, txs, cb) {
var unconfirmed = _.filter(txs, {
confirmations: 0
});
if (_.isEmpty(unconfirmed)) return cb();
self.getFeeLevels({
coin: wallet.coin,
network: wallet.network
}, function(err, levels) {
if (err) {
self.logw('Could not fetch fee levels', err);
} else {
var level = _.find(levels, {
level: 'superEconomy'
});
if (!level || !level.nbBlocks) {
self.logi('Cannot compute super economy fee level from blockchain');
} else {
var minFeePerKb = level.feePerKb;
_.each(unconfirmed, function(tx) {
tx.lowFees = tx.feePerKb < minFeePerKb;
});
}
}
return cb();
});
};
self.getWallet({}, function(err, wallet) {
if (err) return cb(err);
// Get addresses for this wallet
self.storage.fetchAddresses(self.walletId, function(err, addresses) {
if (err) return cb(err);
if (addresses.length == 0) return cb(null, []);
var from = opts.skip || 0;
var to = from + opts.limit;
async.waterfall([
function(next) {
getNormalizedTxs(wallet, addresses, from, to, next);
},
function(txs, next) {
// Fetch all proposals in [t - 7 days, t + 1 day]
var minTs = _.min(txs.items, 'time').time - 7 * 24 * 3600;
var maxTs = _.max(txs.items, 'time').time + 1 * 24 * 3600;
async.parallel([
function(done) {
self.storage.fetchTxs(self.walletId, {
minTs: minTs,
maxTs: maxTs
}, done);
},
function(done) {
self.storage.fetchTxNotes(self.walletId, {
minTs: minTs
}, done);
},
], function(err, res) {
return next(err, {
txs: txs,
txps: res[0],
notes: res[1]
});
});
},
], function(err, res) {
if (err) return cb(err);
var finalTxs = decorate(wallet, res.txs.items, addresses, res.txps, res.notes);
tagLowFees(wallet, finalTxs, function(err) {
if (err)
self.logw('Failed to tag unconfirmed with low fee');
if (res.txs.fromCache)
self.logi("History from cache for:", self.walletId, from, to);
return cb(null, finalTxs, !!res.txs.fromCache);
});
});
});
});
};
/**
* Scan the blockchain looking for addresses having some activity
*
* @param {Object} opts
* @param {Boolean} opts.includeCopayerBranches (defaults to false)
*/
WalletService.prototype.scan = function(opts, cb) {
var self = this;
opts = opts || {};
function checkActivity(wallet, address, cb) {
var bc = self._getBlockchainExplorer(wallet.coin, wallet.network);
if (!bc) return cb(new Error('Could not get blockchain explorer instance'));
bc.getAddressActivity(address, cb);
};
function scanBranch(wallet, derivator, cb) {
var inactiveCounter = 0;
var allAddresses = [];
var gap = Defaults.SCAN_ADDRESS_GAP;
async.whilst(function() {
self.logi('Scanning addr branch: %s index: %d gap %d ', derivator.id, derivator.index(), inactiveCounter);
return inactiveCounter < gap;
}, function(next) {
var address = derivator.derive();
checkActivity(wallet, address.address, function(err, activity) {
if (err) return next(err);
allAddresses.push(address);
inactiveCounter = activity ? 0 : inactiveCounter + 1;
if (!activity)
return next();
// Store address manager cache. This is usefull for interrupted scans
// in which the addresses are not inserted.
self.storage.storeAddressIndexCache(wallet.id, derivator.id, derivator.index(),next);
});
}, function(err) {
derivator.rewind(gap);
return cb(err, _.dropRight(allAddresses, gap));
});
}
self._runLocked(cb, function(cb) {
self.getWallet({}, function(err, wallet) {
if (err) return cb(err);
if (!wallet.isComplete()) return cb(Errors.WALLET_NOT_COMPLETE);
wallet.scanStatus = 'running';
self.storage.clearTxHistoryCache(self.walletId, function() {
self.storage.storeWallet(wallet, function(err) {
if (err) return cb(err);
var derivators = [];
_.each([false, true], function(isChange) {
derivators.push({
id: wallet.addressManager.getBaseAddressPath(isChange),
derive: _.bind(wallet.createAddress, wallet, isChange),
index: _.bind(wallet.addressManager.getCurrentIndex, wallet.addressManager, isChange),
rewind: _.bind(wallet.addressManager.rewindIndex, wallet.addressManager, isChange),
});
if (opts.includeCopayerBranches) {
_.each(wallet.copayers, function(copayer) {
if (copayer.addressManager) {
derivators.push({
id: copayer.addressManager.getBaseAddressPath(isChange),
derive: _.bind(copayer.createAddress, copayer, wallet, isChange),
index: _.bind(copayer.addressManager.getCurrentIndex, copayer.addressManager, isChange),
rewind: _.bind(copayer.addressManager.rewindIndex, copayer.addressManager, isChange),
});
}
});
}
});
async.eachSeries(derivators, function(derivator, next) {
self.storage.fetchAddressIndexCache(wallet.id, derivator.id, function(err, index){
var addresses = [];
// prederive known addresses
var diff = index - derivator.index();
if (diff>0) {
self.logi('Prederiving '+ diff +' addresses for ' + derivator.id);
for(var i =0; i<diff; i++) {
var address = derivator.derive();
addresses.push(address);
}
}
scanBranch(wallet, derivator, function(err, scannedAddresses) {
if (err) return next(err);
addresses = addresses.concat(scannedAddresses);
self.storage.storeAddressAndWallet(wallet, addresses, next);
});
});
}, function(error) {
self.storage.fetchWallet(wallet.id, function(err, wallet) {
if (err) return cb(err);
wallet.scanStatus = error ? 'error' : 'success';
self.storage.storeWallet(wallet, function() {
self.storage.storeTwoStepCache(self.walletId, {}, function(err) {
return cb(error);
});
});
})
});
});
});
});
});
};
/**
* Start a scan process.
*
* @param {Object} opts
* @param {Boolean} opts.includeCopayerBranches (defaults to false)
*/
WalletService.prototype.startScan = function(opts, cb) {
var self = this;
function scanFinished(err) {
var data = {
result: err ? 'error' : 'success',
};
if (err) data.error = err;
self._notify('ScanFinished', data, {
isGlobal: true
});
};
self.getWallet({}, function(err, wallet) {
if (err) return cb(err);
if (!wallet.isComplete()) return cb(Errors.WALLET_NOT_COMPLETE);
setTimeout(function() {
self.scan(opts, scanFinished);
}, 100);
return cb(null, {
started: true
});
});
};
/**
* Returns exchange rate for the specified currency & timestamp.
* @param {Object} opts
* @param {string} opts.code - Currency ISO code.
* @param {Date} [opts.ts] - A timestamp to base the rate on (default Date.now()).
* @param {String} [opts.provider] - A provider of exchange rates (default 'BitPay').
* @returns {Object} rates - The exchange rate.
*/
WalletService.prototype.getFiatRate = function(opts, cb) {
var self = this;
if (!checkRequired(opts, ['code'], cb)) return;
self.fiatRateService.getRate(opts, function(err, rate) {
if (err) return cb(err);
return cb(null, rate);
});
};
/**
* Subscribe this copayer to the Push Notifications service using the specified token.
* @param {Object} opts
* @param {string} opts.token - The token representing the app/device.
* @param {string} [opts.packageName] - The restricted_package_name option associated with this token.
* @param {string} [opts.platform] - The platform associated with this token.
*/
WalletService.prototype.pushNotificationsSubscribe = function(opts, cb) {
if (!checkRequired(opts, ['token'], cb)) return;
var self = this;
var sub = Model.PushNotificationSub.create({
copayerId: self.copayerId,
token: opts.token,
packageName: opts.packageName,
platform: opts.platform,
});
self.storage.storePushNotificationSub(sub, cb);
};
/**
* Unsubscribe this copayer to the Push Notifications service using the specified token.
* @param {Object} opts
* @param {string} opts.token - The token representing the app/device.
*/
WalletService.prototype.pushNotificationsUnsubscribe = function(opts, cb) {
if (!checkRequired(opts, ['token'], cb)) return;
var self = this;
self.storage.removePushNotificationSub(self.copayerId, opts.token, cb);
};
/**
* Subscribe this copayer to the specified tx to get a notification when the tx confirms.
* @param {Object} opts
* @param {string} opts.txid - The txid of the tx to be notified of.
*/
WalletService.prototype.txConfirmationSubscribe = function(opts, cb) {
if (!checkRequired(opts, ['txid'], cb)) return;
var self = this;
var sub = Model.TxConfirmationSub.create({
copayerId: self.copayerId,
walletId: self.walletId,
txid: opts.txid,
});
self.storage.storeTxConfirmationSub(sub, cb);
};
/**
* Unsubscribe this copayer to the Push Notifications service using the specified token.
* @param {Object} opts
* @param {string} opts.txid - The txid of the tx to be notified of.
*/
WalletService.prototype.txConfirmationUnsubscribe = function(opts, cb) {
if (!checkRequired(opts, ['txid'], cb)) return;
var self = this;
self.storage.removeTxConfirmationSub(self.copayerId, opts.txid, cb);
};
module.exports = WalletService;
module.exports.ClientError = ClientError;