mtx: refactor coin selection.

This commit is contained in:
Christopher Jeffrey 2016-09-05 23:35:11 -07:00
parent 1edb5aa4cf
commit 1b87514542
No known key found for this signature in database
GPG Key ID: 8962AB9DE6666BBD
3 changed files with 358 additions and 207 deletions

View File

@ -947,7 +947,7 @@ MTX.prototype.isInputScripted = function isInputScripted(index) {
/**
* Estimate maximum possible size.
* @param {(Wallet|Object)?} options - Wallet or options object.
* @param {Object?} options - Wallet or options object.
* @param {Number} options.m - Multisig `m` value.
* @param {Number} options.n - Multisig `n` value.
* @returns {Number}
@ -1069,7 +1069,7 @@ MTX.prototype.maxSize = function maxSize(options) {
// simply add more of the fee to the change
// output.
// m value
m = options.m || 3;
m = options.m || 2;
// n value
n = options.n || 3;
// OP_0
@ -1144,7 +1144,8 @@ MTX.prototype._guessRedeem = function guessRedeem(options, hash) {
* See {@link TX#getMinFee} vs. {@link TX#getRoundFee}.
* @param {Boolean} options.free - Do not apply a fee if the
* transaction priority is high enough to be considered free.
* @param {Amount?} options.fee - Use a hard fee rather than calculating one.
* @param {Amount?} options.hardFee - Use a hard fee rather
* than calculating one.
* @param {Rate?} options.rate - Rate used for fee calculation.
* @param {Number|Boolean} options.subtractFee - Whether to subtract the
* fee from * existing outputs rather than adding more inputs.
@ -1154,155 +1155,8 @@ MTX.prototype._guessRedeem = function guessRedeem(options, hash) {
*/
MTX.prototype.selectCoins = function selectCoins(coins, options) {
var chosen = [];
var index = 0;
var tx = this.clone();
var outputValue = tx.getOutputValue();
var tryFree, size, change, fee;
if (!options)
options = {};
tryFree = options.free;
// Null the inputs if there are any.
tx.inputs.length = 0;
if (!options.selection || options.selection === 'age') {
// Oldest unspents first
coins = coins.slice().sort(function(a, b) {
a = a.height === -1 ? 0x7fffffff : a.height;
b = b.height === -1 ? 0x7fffffff : b.height;
return a - b;
});
} else if (options.selection === 'random' || options.selection === 'all') {
// Random unspents
coins = coins.slice().sort(function() {
return Math.random() > 0.5 ? 1 : -1;
});
}
function total() {
if (options.subtractFee || options.subtractFee === 0)
return outputValue;
return outputValue + fee;
}
function isFull() {
return tx.getInputValue() >= total();
}
function addCoins() {
var coin;
while (index < coins.length) {
coin = coins[index++];
if (options.confirmed && coin.height === -1)
continue;
if (options.height >= 0 && coin.coinbase) {
if (options.height + 1 < coin.height + constants.tx.COINBASE_MATURITY)
continue;
}
// Add new inputs until TX will have enough
// funds to cover both minimum post cost
// and fee.
tx.addInput(coin);
chosen.push(coin);
if (options.selection === 'all')
continue;
// Stop once we're full.
if (isFull())
break;
}
}
if (options.fee != null) {
fee = options.fee;
if (fee > constants.tx.MAX_FEE)
fee = constants.tx.MAX_FEE;
// Transfer `total` funds maximum.
addCoins();
} else {
fee = constants.tx.MIN_FEE;
// Transfer `total` funds maximum.
addCoins();
// Add dummy output (for `change`) to
// calculate maximum TX size.
tx.addOutput({
// In case we don't have a change address,
// use a fake p2pkh output to gauge size.
script: options.changeAddress
? Script.fromAddress(options.changeAddress)
: Script.fromPubkeyhash(constants.ZERO_HASH160),
value: 0
});
// Change fee value if it is more than 1024
// bytes (10000 satoshi for every 1024 bytes).
do {
// Calculate max possible size after signing.
size = tx.maxSize(options);
if (tryFree && options.height >= 0) {
// Note that this will only work
// if the mempool's rolling reject
// fee is zero (i.e. the mempool is
// not full).
if (tx.isFree(options.height + 1, size)) {
fee = 0;
break;
}
tryFree = false;
}
if (options.round)
fee = tx.getRoundFee(size, options.rate);
else
fee = tx.getMinFee(size, options.rate);
if (fee > constants.tx.MAX_FEE)
fee = constants.tx.MAX_FEE;
if (options.maxFee && fee > options.maxFee) {
throw new FundingError(
'Fee is too high.',
tx.getInputValue(),
total());
}
// Failed to get enough funds, add more coins.
if (!isFull())
addCoins();
} while (!isFull() && index < coins.length);
}
if (!isFull()) {
// Still failing to get enough funds.
throw new FundingError(
'Not enough funds.',
tx.getInputValue(),
total());
}
// How much money is left after filling outputs.
change = tx.getInputValue() - total();
// Return necessary inputs and change.
return {
coins: chosen,
change: change,
fee: fee,
total: total()
};
var selector = new CoinSelector(this, options);
return selector.select(coins);
};
/**
@ -1370,27 +1224,24 @@ MTX.prototype.subtractFee = function subtractFee(fee, index) {
* Select coins and fill the inputs.
* @param {Coin[]} coins
* @param {Object} options - See {@link MTX#selectCoins} options.
* @returns {Object} See {@link MTX#selectCoins} return value.
* @returns {CoinSelector}
*/
MTX.prototype.fund = function fund(coins, options) {
var result, i, change, changeAddress;
var i, result, change, changeAddress;
assert(this.inputs.length === 0, 'TX is already filled.');
if (!options)
options = {};
// Select necessary coins.
result = this.selectCoins(coins, options);
// We need a change address.
changeAddress = options.changeAddress;
changeAddress = result.changeAddress;
// If change address is not available,
// send back to one of the coins' addresses.
for (i = 0; i < result.coins.length && !changeAddress; i++)
changeAddress = result.coins[i].getAddress();
for (i = 0; i < result.chosen.length && !changeAddress; i++)
changeAddress = result.chosen[i].getAddress();
// Will only happen in rare cases where
// we're redeeming all non-standard coins.
@ -1398,12 +1249,12 @@ MTX.prototype.fund = function fund(coins, options) {
throw new Error('No change address available.');
// Add coins to transaction.
for (i = 0; i < result.coins.length; i++)
this.addInput(result.coins[i]);
for (i = 0; i < result.chosen.length; i++)
this.addInput(result.chosen[i]);
// Attempt to subtract fee.
if (options.subtractFee || options.subtractFee === 0)
this.subtractFee(result.fee, options.subtractFee);
if (result.shouldSubtract)
this.subtractFee(result.fee, result.subtractFee);
// Add a change output.
this.addOutput({
@ -1439,21 +1290,8 @@ MTX.prototype.sortMembers = function sortMembers() {
assert(changeOutput);
}
this.inputs = this.inputs.slice().sort(function(a, b) {
var h1 = new Buffer(a.prevout.hash, 'hex');
var h2 = new Buffer(b.prevout.hash, 'hex');
var res = utils.cmp(h1, h2);
if (res !== 0)
return res;
return a.prevout.index - b.prevout.index;
});
this.outputs = this.outputs.slice().sort(function(a, b) {
var res = a.value - b.value;
if (res !== 0)
return res;
return utils.cmp(a.script.toRaw(), b.script.toRaw());
});
this.inputs.sort(sortInputs);
this.outputs.sort(sortOutputs);
if (this.changeIndex !== -1) {
this.changeIndex = this.outputs.indexOf(changeOutput);
@ -1591,6 +1429,306 @@ MTX.isMTX = function isMTX(obj) {
&& typeof obj.scriptInput === 'function';
};
/**
* Coin Selector
* @constructor
* @param {TX} tx
* @param {Object?} options
*/
function CoinSelector(tx, options) {
if (!(this instanceof CoinSelector))
return new CoinSelector(tx, options);
if (!options)
options = {};
this.tx = tx.clone();
this.coins = [];
this.outputValue = -1;
this.index = -1;
this.chosen = [];
this.change = -1;
this.fee = -1;
this.type = options.selection || 'age';
this.shouldSubtract = !!options.subtractFee || options.subtractFee === 0;
this.free = options.free || false;
this.subtractFee = options.subtractFee || null;
this.height = options.height || -1;
this.confirmations = options.confirmations || -1;
this.hardFee = options.hardFee || null;
this.changeAddress = options.changeAddress || null;
this.round = options.round || false;
this.rate = options.rate || null;
this.maxFee = options.maxFee || null;
// Needed for size estimation.
this.m = options.m || null;
this.n = options.n || null;
this.witness = options.witness || false;
this.script = options.script || null;
}
/**
* Initialize the selector with coins to select from.
* @param {Coin[]} coins
*/
CoinSelector.prototype.init = function init(coins) {
this.coins = coins.slice();
this.outputValue = this.tx.getOutputValue();
this.index = 0;
this.chosen = [];
this.change = 0;
this.fee = 0;
this.tx.inputs.length = 0;
switch (this.type) {
case 'all':
case 'random':
this.coins.sort(sortRandom);
break;
case 'age':
this.coins.sort(sortAge);
break;
default:
throw new FundingError('Bad selection type: ' + this.type);
}
};
/**
* Calculate total value required.
* @returns {Amount}
*/
CoinSelector.prototype.total = function total() {
if (this.shouldSubtract)
return this.outputValue;
return this.outputValue + this.fee;
};
/**
* Test whether the selector has
* completely funded the transaction.
* @returns {Boolean}
*/
CoinSelector.prototype.isFull = function isFull() {
return this.tx.getInputValue() >= this.total();
};
/**
* Test whether a coin is spendable
* with regards to the options.
* @param {Coin}
* @returns {Boolean}
*/
CoinSelector.prototype.isSpendable = function isSpendable(coin) {
var height = this.height;
var maturity = constants.tx.COINBASE_MATURITY;
var conf;
if (!(height >= 0))
return true;
if (this.confirmations > 0) {
if (coin.height === -1)
return this.confirmations <= 0;
conf = height - coin.height;
if (conf < 0)
return false;
conf += 1;
if (conf < this.confirmations)
return false;
}
if (coin.coinbase) {
if (height + 1 < coin.height + maturity)
return false;
}
return true;
};
/**
* Get the current fee based on a size.
* @param {Number} size
* @returns {Amount}
*/
CoinSelector.prototype.getFee = function getFee(size) {
var fee;
if (this.round)
fee = bcoin.tx.getRoundFee(size, this.rate);
else
fee = bcoin.tx.getMinFee(size, this.rate);
if (fee > constants.tx.MAX_FEE)
fee = constants.tx.MAX_FEE;
return fee;
};
/**
* Fund the transaction with more
* coins if the `total` was updated.
*/
CoinSelector.prototype.fund = function fund() {
var coin;
while (this.index < this.coins.length) {
coin = this.coins[this.index++];
if (!this.isSpendable(coin))
continue;
// Add new inputs until TX will have enough
// funds to cover both minimum post cost
// and fee.
this.tx.addInput(coin);
this.chosen.push(coin);
if (this.type === 'all')
continue;
// Stop once we're full.
if (this.isFull())
break;
}
};
/**
* Initiate selection from `coins`.
* @param {Coin[]} coins
* @returns {CoinSelector}
*/
CoinSelector.prototype.select = function select(coins) {
this.init(coins);
if (this.hardFee != null)
this.selectHard(this.hardFee);
else
this.selectEstimate(constants.tx.MIN_FEE);
if (!this.isFull()) {
// Still failing to get enough funds.
throw new FundingError(
'Not enough funds.',
this.tx.getInputValue(),
this.total());
}
// How much money is left after filling outputs.
this.change = this.tx.getInputValue() - this.total();
return this;
};
/**
* Initialize selection based on size estimate.
* @param {Amount} fee
*/
CoinSelector.prototype.selectEstimate = function selectEstimate(fee) {
var size;
// Initial fee.
this.fee = fee;
// Transfer `total` funds maximum.
this.fund();
// Add dummy output (for `change`) to
// calculate maximum TX size.
this.tx.addOutput({
// In case we don't have a change address,
// use a fake p2pkh output to gauge size.
script: this.changeAddress
? Script.fromAddress(this.changeAddress)
: Script.fromPubkeyhash(constants.ZERO_HASH160),
value: 0
});
if (this.free && this.height >= 0) {
size = this.tx.maxSize(this);
// Note that this will only work
// if the mempool's rolling reject
// fee is zero (i.e. the mempool is
// not full).
if (this.tx.isFree(this.height + 1, size)) {
this.fee = 0;
return;
}
}
// Keep recalculating fee and funding
// until we reach some sort of equilibrium.
do {
size = this.tx.maxSize(this);
this.fee = this.getFee(size);
if (this.maxFee && this.fee > this.maxFee) {
throw new FundingError(
'Fee is too high.',
this.tx.getInputValue(),
this.total());
}
// Failed to get enough funds, add more coins.
if (!this.isFull())
this.fund();
} while (!this.isFull() && this.index < this.coins.length);
};
/**
* Initiate selection based on a hard fee.
* @param {Amount} fee
*/
CoinSelector.prototype.selectHard = function selectHard(fee) {
// Initial fee.
this.fee = fee;
if (this.fee > constants.tx.MAX_FEE)
this.fee = constants.tx.MAX_FEE;
// Transfer `total` funds maximum.
this.fund();
};
/*
* Helpers
*/
function sortAge(a, b) {
a = a.height === -1 ? 0x7fffffff : a.height;
b = b.height === -1 ? 0x7fffffff : b.height;
return a - b;
}
function sortRandom(a, b) {
return Math.random() > 0.5 ? 1 : -1;
}
function sortInputs(a, b) {
return utils.cmp(a.prevout.toRaw(), b.prevout.toRaw());
}
function sortOutputs(a, b) {
return utils.cmp(a.toRaw(), b.toRaw());
}
/*
* Expose
*/

View File

@ -1580,6 +1580,23 @@ TX.prototype.getMinFee = function getMinFee(size, rate) {
return TX.getMinFee(size, rate);
};
/**
* Calculate the minimum fee in order for the transaction
* to be relayable, but _round to the nearest kilobyte
* when taking into account size.
* @param {Number?} size - If not present, max size
* estimation will be calculated and used.
* @param {Rate?} rate - Rate of satoshi per kB.
* @returns {Amount} fee
*/
TX.prototype.getRoundFee = function getRoundFee(size, rate) {
if (size == null)
size = this.maxSize();
return TX.getRoundFee(size, rate);
};
/**
* Calculate the transaction's rate based on size
* and fees. Size will be calculated if not present.
@ -1594,33 +1611,6 @@ TX.prototype.getRate = function getRate(size) {
return TX.getRate(size, this.getFee());
};
/**
* Calculate the minimum fee in order for the transaction
* to be relayable, but _round to the nearest kilobyte
* when taking into account size.
* @param {Number?} size - If not present, max size
* estimation will be calculated and used.
* @param {Rate?} rate - Rate of satoshi per kB.
* @returns {Amount} fee
*/
TX.prototype.getRoundFee = function getRoundFee(size, rate) {
var fee;
if (size == null)
size = this.maxSize();
if (rate == null)
rate = constants.tx.MIN_RELAY;
fee = rate * Math.ceil(size / 1000);
if (fee === 0 && rate > 0)
fee = rate;
return fee;
};
/**
* Calculate current number of transaction confirmations.
* @param {Number?} height - Current chain height. If not
@ -1780,6 +1770,29 @@ TX.getMinFee = function getMinFee(size, rate) {
return fee;
};
/**
* Calculate the minimum fee in order for the transaction
* to be relayable, but _round to the nearest kilobyte
* when taking into account size.
* @param {Number?} size
* @param {Rate?} rate - Rate of satoshi per kB.
* @returns {Amount} fee
*/
TX.getRoundFee = function getRoundFee(size, rate) {
var fee;
if (rate == null)
rate = constants.tx.MIN_RELAY;
fee = rate * Math.ceil(size / 1000);
if (fee === 0 && rate > 0)
fee = rate;
return fee;
};
/**
* Calculate a fee rate based on size and fees.
* @param {Number} size

View File

@ -489,7 +489,7 @@ Account.prototype.derivePath = function derivePath(path, master) {
// Custom redeem script.
if (path.script)
script = new bcoin.script(path.script);
script = bcoin.script.fromRaw(path.script);
ring = this.deriveAddress(path.change, path.index, master, script);