mtx: refactor coin selection.
This commit is contained in:
parent
1edb5aa4cf
commit
1b87514542
@ -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
|
||||
*/
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user