Merge pull request #477 from isocolsky/utxo-selection
Improve UTXO selection
This commit is contained in:
commit
a3da2deb65
|
@ -43,6 +43,7 @@ var config = {
|
|||
testnet: {
|
||||
provider: 'insight',
|
||||
url: 'https://test-insight.bitpay.com:443',
|
||||
// url: 'http://localhost:3001',
|
||||
// Multiple servers (in priority order)
|
||||
// url: ['http://a.b.c', 'https://test-insight.bitpay.com:443'],
|
||||
},
|
||||
|
|
|
@ -49,4 +49,18 @@ Defaults.FIAT_RATE_MAX_LOOK_BACK_TIME = 120; // In minutes
|
|||
|
||||
Defaults.HISTORY_LIMIT = 100;
|
||||
|
||||
// The maximum amount of an UTXO to be considered too big to be used in the tx before exploring smaller
|
||||
// alternatives (proportinal to tx amount).
|
||||
Defaults.UTXO_SELECTION_MAX_SINGLE_UTXO_FACTOR = 2;
|
||||
|
||||
// The minimum amount an UTXO need to contribute proportional to tx amount.
|
||||
Defaults.UTXO_SELECTION_MIN_TX_AMOUNT_VS_UTXO_FACTOR = 0.1;
|
||||
|
||||
// The maximum threshold to consider fees non-significant in relation to tx amount.
|
||||
Defaults.UTXO_SELECTION_MAX_FEE_VS_TX_AMOUNT_FACTOR = 0.05;
|
||||
|
||||
// The maximum amount to pay for using small inputs instead of one big input
|
||||
// when fees are significant (proportional to how much we would pay for using that big input only).
|
||||
Defaults.UTXO_SELECTION_MAX_FEE_VS_SINGLE_UTXO_FEE_FACTOR = 5;
|
||||
|
||||
module.exports = Defaults;
|
||||
|
|
|
@ -23,7 +23,7 @@ Utils.checkRequired = function(obj, args) {
|
|||
* @return {number}
|
||||
*/
|
||||
Utils.strip = function(number) {
|
||||
return (parseFloat(number.toPrecision(12)));
|
||||
return parseFloat(number.toPrecision(12));
|
||||
}
|
||||
|
||||
/* TODO: It would be nice to be compatible with bitcoind signmessage. How
|
||||
|
@ -66,6 +66,11 @@ Utils.formatAmount = function(satoshis, unit, opts) {
|
|||
maxDecimals: 0,
|
||||
minDecimals: 0,
|
||||
},
|
||||
sat: {
|
||||
toSatoshis: 1,
|
||||
maxDecimals: 0,
|
||||
minDecimals: 0,
|
||||
}
|
||||
};
|
||||
|
||||
$.shouldBeNumber(satoshis);
|
||||
|
@ -88,10 +93,33 @@ Utils.formatAmount = function(satoshis, unit, opts) {
|
|||
|
||||
opts = opts || {};
|
||||
|
||||
var u = UNITS[unit];
|
||||
var u = _.assign(UNITS[unit], opts);
|
||||
var amount = (satoshis / u.toSatoshis).toFixed(u.maxDecimals);
|
||||
return addSeparators(amount, opts.thousandsSeparator || ',', opts.decimalSeparator || '.', u.minDecimals);
|
||||
};
|
||||
|
||||
Utils.formatAmountInBtc = function(amount) {
|
||||
return Utils.formatAmount(amount, 'btc', {
|
||||
minDecimals: 8,
|
||||
maxDecimals: 8,
|
||||
}) + 'btc';
|
||||
};
|
||||
|
||||
Utils.formatUtxos = function(utxos) {
|
||||
if (_.isEmpty(utxos)) return 'none';
|
||||
return _.map([].concat(utxos), function(i) {
|
||||
var amount = Utils.formatAmountInBtc(i.satoshis);
|
||||
var confirmations = i.confirmations ? i.confirmations + 'c' : 'u';
|
||||
return amount + '/' + confirmations;
|
||||
}).join(', ');
|
||||
};
|
||||
|
||||
Utils.formatRatio = function(ratio) {
|
||||
return (ratio * 100.).toFixed(4) + '%';
|
||||
};
|
||||
|
||||
Utils.formatSize = function(size) {
|
||||
return (size / 1000.).toFixed(4) + 'kB';
|
||||
};
|
||||
|
||||
module.exports = Utils;
|
||||
|
|
|
@ -33,7 +33,6 @@ TxProposal.create = function(opts) {
|
|||
x.message = opts.message;
|
||||
x.payProUrl = opts.payProUrl;
|
||||
x.changeAddress = opts.changeAddress;
|
||||
x.setInputs(opts.inputs);
|
||||
x.outputs = _.map(opts.outputs, function(output) {
|
||||
return _.pick(output, ['amount', 'toAddress', 'message']);
|
||||
});
|
||||
|
@ -44,7 +43,6 @@ TxProposal.create = function(opts) {
|
|||
x.requiredRejections = Math.min(x.walletM, x.walletN - x.walletM + 1),
|
||||
x.status = 'temporary';
|
||||
x.actions = [];
|
||||
x.fee = null;
|
||||
x.feePerKb = opts.feePerKb;
|
||||
x.excludeUnconfirmedUtxos = opts.excludeUnconfirmedUtxos;
|
||||
|
||||
|
@ -59,6 +57,9 @@ TxProposal.create = function(opts) {
|
|||
} catch (ex) {}
|
||||
$.checkState(_.contains(_.values(Constants.NETWORKS), x.network));
|
||||
|
||||
x.setInputs(opts.inputs);
|
||||
x.fee = opts.fee;
|
||||
|
||||
return x;
|
||||
};
|
||||
|
||||
|
@ -137,6 +138,7 @@ TxProposal.prototype._buildTx = function() {
|
|||
switch (self.addressType) {
|
||||
case Constants.SCRIPT_TYPES.P2SH:
|
||||
_.each(self.inputs, function(i) {
|
||||
$.checkState(i.publicKeys, 'Inputs should include public keys');
|
||||
t.from(i, i.publicKeys, self.requiredSignatures);
|
||||
});
|
||||
break;
|
||||
|
@ -158,7 +160,13 @@ TxProposal.prototype._buildTx = function() {
|
|||
});
|
||||
|
||||
t.fee(self.fee);
|
||||
t.change(self.changeAddress.address);
|
||||
|
||||
var totalInputs = _.sum(self.inputs, 'satoshis');
|
||||
var totalOutputs = _.sum(self.outputs, 'satoshis');
|
||||
|
||||
if (totalInputs - totalOutputs - self.fee > 0) {
|
||||
t.change(self.changeAddress.address);
|
||||
}
|
||||
|
||||
// Shuffle outputs for improved privacy
|
||||
if (t.outputs.length > 1) {
|
||||
|
@ -173,8 +181,8 @@ TxProposal.prototype._buildTx = function() {
|
|||
});
|
||||
}
|
||||
|
||||
// Validate inputs vs outputs independently of Bitcore
|
||||
var totalInputs = _.sum(self.inputs, 'satoshis');
|
||||
// Validate actual inputs vs outputs independently of Bitcore
|
||||
var totalInputs = _.sum(t.inputs, 'satoshis');
|
||||
var totalOutputs = _.sum(t.outputs, 'satoshis');
|
||||
|
||||
$.checkState(totalInputs - totalOutputs <= Defaults.MAX_TX_FEE);
|
||||
|
@ -219,13 +227,22 @@ TxProposal.prototype.getRawTx = function() {
|
|||
return t.uncheckedSerialize();
|
||||
};
|
||||
|
||||
TxProposal.prototype.getEstimatedSizeForSingleInput = function() {
|
||||
switch (this.addressType) {
|
||||
case Constants.SCRIPT_TYPES.P2PKH:
|
||||
return 147;
|
||||
default:
|
||||
case Constants.SCRIPT_TYPES.P2SH:
|
||||
return this.requiredSignatures * 72 + this.walletN * 36 + 44;
|
||||
}
|
||||
};
|
||||
|
||||
TxProposal.prototype.getEstimatedSize = function() {
|
||||
// Note: found empirically based on all multisig P2SH inputs and within m & n allowed limits.
|
||||
var safetyMargin = 0.05;
|
||||
var walletM = this.requiredSignatures;
|
||||
var safetyMargin = 0.02;
|
||||
|
||||
var overhead = 4 + 4 + 9 + 9;
|
||||
var inputSize = walletM * 72 + this.walletN * 36 + 44;
|
||||
var inputSize = this.getEstimatedSizeForSingleInput();
|
||||
var outputSize = 34;
|
||||
var nbInputs = this.inputs.length;
|
||||
var nbOutputs = (_.isArray(this.outputs) ? Math.max(1, this.outputs.length) : 1) + 1;
|
||||
|
|
|
@ -273,16 +273,19 @@ TxProposal.prototype.getRawTx = function() {
|
|||
return t.uncheckedSerialize();
|
||||
};
|
||||
|
||||
TxProposal.prototype.getEstimatedSizeForSingleInput = function() {
|
||||
return this.requiredSignatures * 72 + this.walletN * 36 + 44;
|
||||
};
|
||||
|
||||
TxProposal.prototype.getEstimatedSize = function() {
|
||||
// Note: found empirically based on all multisig P2SH inputs and within m & n allowed limits.
|
||||
var safetyMargin = 0.05;
|
||||
var walletM = this.requiredSignatures;
|
||||
|
||||
var overhead = 4 + 4 + 9 + 9;
|
||||
var inputSize = walletM * 72 + this.walletN * 36 + 44;
|
||||
var inputSize = this.getEstimatedSizeForSingleInput();
|
||||
var outputSize = 34;
|
||||
var nbInputs = this.inputs.length;
|
||||
var nbOutputs = (_.isArray(this.outputs) ? this.outputs.length : 1) + 1;
|
||||
var nbOutputs = (_.isArray(this.outputs) ? Math.max(1, this.outputs.length) : 1) + 1;
|
||||
|
||||
var size = overhead + inputSize * nbInputs + outputSize * nbOutputs;
|
||||
|
||||
|
|
285
lib/server.js
285
lib/server.js
|
@ -1230,7 +1230,11 @@ WalletService.prototype.getFeeLevels = function(opts, cb) {
|
|||
});
|
||||
};
|
||||
|
||||
WalletService.prototype._checkTxAndEstimateFee = function(txp) {
|
||||
WalletService.prototype._estimateFee = function(txp) {
|
||||
txp.estimateFee();
|
||||
};
|
||||
|
||||
WalletService.prototype._checkTx = function(txp) {
|
||||
var bitcoreError;
|
||||
|
||||
var serializationOpts = {
|
||||
|
@ -1241,7 +1245,8 @@ WalletService.prototype._checkTxAndEstimateFee = function(txp) {
|
|||
serializationOpts.disableLargeFees = true;
|
||||
}
|
||||
|
||||
txp.estimateFee();
|
||||
if (txp.getEstimatedSize() / 1000 > Defaults.MAX_TX_SIZE_IN_KB)
|
||||
return Errors.TX_MAX_SIZE_EXCEEDED;
|
||||
|
||||
try {
|
||||
var bitcoreTx = txp.getBitcoreTx();
|
||||
|
@ -1264,40 +1269,164 @@ WalletService.prototype._checkTxAndEstimateFee = function(txp) {
|
|||
|
||||
WalletService.prototype._selectTxInputs = function(txp, utxosToExclude, cb) {
|
||||
var self = this;
|
||||
//todo: check inputs are ours and has enough value
|
||||
if (txp.inputs && txp.inputs.length > 0) {
|
||||
return cb(self._checkTxAndEstimateFee(txp));
|
||||
|
||||
//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));
|
||||
}
|
||||
|
||||
function sortUtxos(utxos) {
|
||||
var list = _.map(utxos, function(utxo) {
|
||||
var order;
|
||||
if (utxo.confirmations == 0) {
|
||||
order = 0;
|
||||
} else if (utxo.confirmations < 6) {
|
||||
order = -1;
|
||||
} else {
|
||||
order = -2;
|
||||
}
|
||||
return {
|
||||
order: order,
|
||||
utxo: utxo
|
||||
};
|
||||
});
|
||||
return _.pluck(_.sortBy(list, 'order'), 'utxo');
|
||||
};
|
||||
|
||||
self._getUtxosForCurrentWallet(null, function(err, utxos) {
|
||||
if (err) return cb(err);
|
||||
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;
|
||||
}, {});
|
||||
|
||||
utxos = _.reject(utxos, function(utxo) {
|
||||
return excludeIndex[utxo.txid + ":" + utxo.vout];
|
||||
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) {
|
||||
log.debug('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) {
|
||||
log.debug('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);
|
||||
log.debug('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));
|
||||
|
||||
if (changeAmount > 0 && changeAmount <= Bitcore.Transaction.DUST_AMOUNT) {
|
||||
log.debug('Change below dust amount (' + Utils.formatAmountInBtc(Bitcore.Transaction.DUST_AMOUNT) + ')');
|
||||
// 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(null, function(err, utxos) {
|
||||
if (err) return cb(err);
|
||||
|
||||
var totalAmount;
|
||||
var availableAmount;
|
||||
|
@ -1314,36 +1443,72 @@ WalletService.prototype._selectTxInputs = function(txp, utxosToExclude, cb) {
|
|||
if (totalAmount < txp.getTotalAmount()) return cb(Errors.INSUFFICIENT_FUNDS);
|
||||
if (availableAmount < txp.getTotalAmount()) return cb(Errors.LOCKED_FUNDS);
|
||||
|
||||
// Prepare UTXOs list
|
||||
utxos = _.reject(utxos, 'locked');
|
||||
if (txp.excludeUnconfirmedUtxos) {
|
||||
utxos = _.filter(utxos, 'confirmations');
|
||||
}
|
||||
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 total = 0;
|
||||
var selected = [];
|
||||
var inputs = sortUtxos(utxos);
|
||||
var lastGroupLength;
|
||||
async.whilst(function() {
|
||||
return i < groups.length && _.isEmpty(inputs);
|
||||
}, function(next) {
|
||||
var group = groups[i++];
|
||||
|
||||
var bitcoreTx, bitcoreError;
|
||||
var candidateUtxos = _.filter(utxos, function(utxo) {
|
||||
return utxo.confirmations >= group;
|
||||
});
|
||||
|
||||
function select() {
|
||||
if (i >= inputs.length) return cb(bitcoreError || new Error('Could not select tx inputs'));
|
||||
log.debug('Group >= ' + group);
|
||||
|
||||
var input = inputs[i++];
|
||||
selected.push(input);
|
||||
total += input.satoshis;
|
||||
if (total >= txp.getTotalAmount()) {
|
||||
txp.setInputs(selected);
|
||||
bitcoreError = self._checkTxAndEstimateFee(txp);
|
||||
if (!bitcoreError) return cb();
|
||||
if (txp.getEstimatedSize() / 1000 > Defaults.MAX_TX_SIZE_IN_KB)
|
||||
return cb(Errors.TX_MAX_SIZE_EXCEEDED);
|
||||
// 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();
|
||||
}
|
||||
setTimeout(select, 0);
|
||||
};
|
||||
|
||||
select();
|
||||
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) {
|
||||
log.debug('Successfully built transaction. Total fees: ' + Utils.formatAmountInBtc(txp.fee) + ', total change: ' + Utils.formatAmountInBtc(_.sum(txp.inputs, 'satoshis') - txp.fee));
|
||||
} else {
|
||||
log.warn('Error building transaction', err);
|
||||
}
|
||||
|
||||
return cb(err);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -1559,21 +1724,28 @@ WalletService.prototype.createTxLegacy = function(opts, cb) {
|
|||
* @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 {Array} opts.inputs - Optional. Inputs for this TX
|
||||
* @param {string} opts.feePerKb - The fee per kB to use for this TX.
|
||||
* @param {number} opts.feePerKb - The fee per kB to use for this TX.
|
||||
* @param {string} opts.payProUrl - Optional. Paypro URL for peers to verify TX
|
||||
* @param {string} opts.excludeUnconfirmedUtxos[=false] - Optional. Do not use UTXOs of unconfirmed transactions as inputs
|
||||
* @param {string} opts.validateOutputs[=true] - Optional. Perform validation on outputs.
|
||||
* @param {Array} opts.inputs - Optional. Inputs for this TX
|
||||
* @param {number} opts.fee - Optional. The fee to use for this TX (used only when opts.inputs is specified).
|
||||
* @returns {TxProposal} Transaction proposal.
|
||||
*/
|
||||
WalletService.prototype.createTx = function(opts, cb) {
|
||||
var self = this;
|
||||
|
||||
if (!Utils.checkRequired(opts, ['outputs', 'feePerKb']))
|
||||
if (!Utils.checkRequired(opts, ['outputs']))
|
||||
return cb(new ClientError('Required argument missing'));
|
||||
|
||||
if (opts.feePerKb < Defaults.MIN_FEE_PER_KB || opts.feePerKb > Defaults.MAX_FEE_PER_KB)
|
||||
return cb(new ClientError('Invalid fee per KB'));
|
||||
// feePerKb is required unless inputs & fee are specified
|
||||
if (!_.isNumber(opts.feePerKb) && !(opts.inputs && _.isNumber(opts.fee)))
|
||||
return cb(new ClientError('Required argument missing'));
|
||||
|
||||
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._runLocked(cb, function(cb) {
|
||||
self.getWallet({}, function(err, wallet) {
|
||||
|
@ -1594,7 +1766,6 @@ WalletService.prototype.createTx = function(opts, cb) {
|
|||
var txOpts = {
|
||||
walletId: self.walletId,
|
||||
creatorId: self.copayerId,
|
||||
inputs: opts.inputs,
|
||||
outputs: opts.outputs,
|
||||
message: opts.message,
|
||||
changeAddress: wallet.createAddress(true),
|
||||
|
@ -1606,6 +1777,8 @@ WalletService.prototype.createTx = function(opts, cb) {
|
|||
validateOutputs: !opts.validateOutputs,
|
||||
addressType: wallet.addressType,
|
||||
customData: opts.customData,
|
||||
inputs: opts.inputs,
|
||||
fee: opts.inputs && !_.isNumber(opts.feePerKb) ? opts.fee : null,
|
||||
};
|
||||
|
||||
var txp = Model.TxProposal.create(txOpts);
|
||||
|
|
|
@ -212,6 +212,40 @@ helpers.toSatoshi = function(btc) {
|
|||
}
|
||||
};
|
||||
|
||||
helpers._parseAmount = function(str) {
|
||||
var result = {
|
||||
amount: +0,
|
||||
confirmations: _.random(6, 100),
|
||||
};
|
||||
|
||||
if (_.isNumber(str)) str = str.toString();
|
||||
|
||||
var re = /^((?:\d+c)|u)?\s*([\d\.]+)\s*(btc|bit|sat)?$/;
|
||||
var match = str.match(re);
|
||||
|
||||
if (!match) throw new Error('Could not parse amount ' + str);
|
||||
|
||||
if (match[1]) {
|
||||
if (match[1] == 'u') result.confirmations = 0;
|
||||
if (_.endsWith(match[1], 'c')) result.confirmations = +match[1].slice(0, -1);
|
||||
}
|
||||
|
||||
switch (match[3]) {
|
||||
default:
|
||||
case 'btc':
|
||||
result.amount = Utils.strip(+match[2] * 1e8);
|
||||
break;
|
||||
case 'bit':
|
||||
result.amount = Utils.strip(+match[2] * 1e2);
|
||||
break
|
||||
case 'sat':
|
||||
result.amount = Utils.strip(+match[2]);
|
||||
break;
|
||||
};
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
helpers.stubUtxos = function(server, wallet, amounts, opts, cb) {
|
||||
if (_.isFunction(opts)) {
|
||||
cb = opts;
|
||||
|
@ -233,14 +267,9 @@ helpers.stubUtxos = function(server, wallet, amounts, opts, cb) {
|
|||
addresses.should.not.be.empty;
|
||||
|
||||
var utxos = _.compact(_.map([].concat(amounts), function(amount, i) {
|
||||
var confirmations;
|
||||
if (_.isString(amount) && _.startsWith(amount, 'u')) {
|
||||
amount = parseFloat(amount.substring(1));
|
||||
confirmations = 0;
|
||||
} else {
|
||||
confirmations = Math.floor(Math.random() * 100 + 1);
|
||||
}
|
||||
if (amount <= 0) return null;
|
||||
var parsed = helpers._parseAmount(amount);
|
||||
|
||||
if (parsed.amount <= 0) return null;
|
||||
|
||||
var address = addresses[i % addresses.length];
|
||||
|
||||
|
@ -257,11 +286,12 @@ helpers.stubUtxos = function(server, wallet, amounts, opts, cb) {
|
|||
|
||||
return {
|
||||
txid: helpers.randomTXID(),
|
||||
vout: Math.floor(Math.random() * 10 + 1),
|
||||
satoshis: helpers.toSatoshi(amount),
|
||||
vout: _.random(0, 10),
|
||||
satoshis: parsed.amount,
|
||||
scriptPubKey: scriptPubKey.toBuffer().toString('hex'),
|
||||
address: address.address,
|
||||
confirmations: confirmations
|
||||
confirmations: parsed.confirmations,
|
||||
publicKeys: address.publicKeys,
|
||||
};
|
||||
}));
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -56,7 +56,7 @@ describe('TxProposal', function() {
|
|||
describe('#getEstimatedSize', function() {
|
||||
it('should return estimated size in bytes', function() {
|
||||
var x = TxProposal.fromObj(aTXP());
|
||||
x.getEstimatedSize().should.equal(407);
|
||||
x.getEstimatedSize().should.equal(396);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
Loading…
Reference in New Issue