diff --git a/lib/primitives/mtx.js b/lib/primitives/mtx.js index d6d19c5b..7ac33f9b 100644 --- a/lib/primitives/mtx.js +++ b/lib/primitives/mtx.js @@ -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 */ diff --git a/lib/primitives/tx.js b/lib/primitives/tx.js index 2dd34634..d91ccc2d 100644 --- a/lib/primitives/tx.js +++ b/lib/primitives/tx.js @@ -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 diff --git a/lib/wallet/account.js b/lib/wallet/account.js index c8245197..3e3705a1 100644 --- a/lib/wallet/account.js +++ b/lib/wallet/account.js @@ -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);