diff --git a/Makefile b/Makefile index 59cbdfc0..7ab15b36 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ all: @./node_modules/.bin/browserify lib/bcoin.js -o browser/bcoin.js ugly: - @uglifyjs --comments '/\*[^\0]+?Copyright[^\0]+?\*/' -o browser/bcoin.min.js browser/bcoin.js + @./node_modules/.bin/uglifyjs --comments '/\*[^\0]+?Copyright[^\0]+?\*/' -o browser/bcoin.min.js browser/bcoin.js clean: @rm browser/bcoin.js diff --git a/lib/bcoin/fees.js b/lib/bcoin/fees.js new file mode 100644 index 00000000..86e7f506 --- /dev/null +++ b/lib/bcoin/fees.js @@ -0,0 +1,624 @@ +/*! + * fees.js - fee estimation for bcoin + * Copyright (c) 2014-2016, Christopher Jeffrey (MIT License). + * https://github.com/bcoin-org/bcoin + * Ported from: + * https://github.com/bitcoin/bitcoin/blob/master/src/policy/fees.cpp + */ + +var bcoin = require('./env'); +var constants = bcoin.protocol.constants; + +/* + * Constants + */ + +var MAX_BLOCK_CONFIRMS = 25; +var DEFAULT_DECAY = 0.998; +var MIN_SUCCESS_PCT = 0.95; +var UNLIKELY_PCT = 0.5; +var SUFFICIENT_FEETXS = 1; +var SUFFICIENT_PRITXS = 0.2; +var MIN_FEERATE = 10; +var MAX_FEERATE = 1e7; +var INF_FEERATE = constants.MAX_MONEY; +var MIN_PRIORITY = 10; +var MAX_PRIORITY = 1e16; +var INF_PRIORITY = 1e9 * constants.MAX_MONEY; +var FEE_SPACING = 1.1; +var PRI_SPACING = 2; +var FREE_THRESHOLD = constants.FREE_THRESHOLD; + +/** + * Confirmation stats. + * @exposes ConfirmStats + * @constructor + * @param {Number} buckets + * @param {Number} maxConfirms + * @param {Number} decay + * @param {String} type + */ + +function ConfirmStats(buckets, maxConfirms, decay, type) { + var i; + + if (!(this instanceof ConfirmStats)) + return new ConfirmStats(buckets, maxConfirms, decay, type); + + this.buckets = new Array(buckets.length); + this.bucketMap = {}; + + for (i = 0; i < buckets.length; i++) { + this.buckets[i] = buckets[i]; + this.bucketMap[buckets[i]] = i; + } + + this.maxConfirms = maxConfirms; + this.decay = decay; + this.type = type; + + this.confAvg = new Array(maxConfirms); + this.curBlockConf = new Array(maxConfirms); + this.unconfTX = new Array(maxConfirms); + + for (i = 0; i < maxConfirms; i++) { + this.confAvg[i] = new Array(buckets.length); + this.curBlockConf[i] = new Array(buckets.length); + this.unconfTX[i] = new Array(buckets.length); + } + + this.oldUnconfTX = new Array(buckets.length); + this.curBlockTX = new Array(buckets.length); + this.txAvg = new Array(buckets.length); + this.curBlockVal = new Array(buckets.length); + this.avg = new Array(buckets.length); +} + +/** + * Get max confirmations. + * @returns {Number} + */ + +ConfirmStats.prototype.getMaxConfirms = function getMaxConfirms() { + return this.confAvg.length; +}; + +/** + * Clear data for the current block. + * @param {Number} height + */ + +ConfirmStats.prototype.clearCurrent = function clearCurrent(height) { + var i, j; + + for (i = 0; i < this.buckets.length; i++) { + this.oldUnconfTX[i] = this.unconfTX[height % this.unconfTX.length][i]; + this.unconfTX[height % this.unconfTX.length][i] = 0; + for (j = 0; j < this.curBlockConf.length; j++) + this.curBlockConf[j][i] = 0; + this.curBlockTX[i] = 0; + this.curBlockVal[i] = 0; + } +}; + +/** + * Record a rate or priority based on number of blocks to confirm. + * @param {Number} blocks - Blocks to confirm. + * @param {Rate|Number} val - Rate or priority. + */ + +ConfirmStats.prototype.record = function record(blocks, val) { + var i, bucketIndex; + + if (blocks < 1) + return; + + bucketIndex = this.bucketMap[val]; + for (i = blocks; i <= this.curBlockConf.length; i++) + this.curBlockConf[i - 1][bucketIndex]++; + + this.curBlockTX[bucketIndex]++; + this.curBlockVal[bucketIndex] += val; +}; + +/** + * Update moving averages. + */ + +ConfirmStats.prototype.updateAverages = function updateAverages() { + var i, j; + + for (i = 0; i < this.buckets.length; i++) { + for (j = 0; j < this.confAvg.length; j++) { + this.confAvg[j][i] = + this.confAvg[j][i] * this.decay + this.curBlockConf[j][i]; + } + this.avg[i] = this.avg[i] * this.decay + this.curBlockVal[i]; + this.txAvg[i] = this.txAvg[i] * this.decay + this.curBlockTX[i]; + } +}; + +/** + * Estimate the median value for rate or priority. + * @param {Number} target - Confirmation target. + * @param {Number} needed - Sufficient tx value. + * @param {Number} breakpoint - Success break point. + * @param {Boolean} greater - Whether to look for lowest value. + * @param {Number} height - Block height. + * @returns {Rate|Number} Returns -1 on error. + */ + +ConfirmStats.prototype.estimateMedian = function estimateMedian(target, needed, breakpoint, greater, height) { + var conf = 0; + var total = 0; + var extra = 0; + var max = this.buckets.length - 1; + var start = greater ? max : 0; + var step = greater ? -1 : 1; + var near = start; + var far = start; + var bestNear = start; + var bestFar = start; + var found = false; + var bins = this.unconfTX.length; + var i, j, perc, median, sum, minBucket, maxBucket; + + for (i = start; i >= 0 && i <= max; i += step) { + far = i; + conf += this.confAvg[target - 1][i]; + total += this.txAvg[i]; + + for (j = target; j < this.getMaxConfirms(); j++) + extra += this.unconfTX[(height - j) % bins][i]; + + extra += this.oldUnconfTX[i]; + + if (total >= needed / (1 - this.decay)) { + perc = conf / (total + extra); + + if (greater && perc < breakpoint) + break; + + if (!greater && perc > breakpoint) + break; + + found = true; + conf = 0; + total = 0; + extra = 0; + bestNear = near; + bestFar = far; + near = i + step; + } + } + + median = -1; + sum = 0; + + minBucket = bestNear < bestFar ? bestNear : bestFar; + maxBucket = bestNear > bestFar ? bestNear : bestFar; + + for (j = minBucket; j <= maxBucket; j++) + sum += this.txAvg[j]; + + if (found && sum !== 0) { + sum = sum / 2; + for (j = minBucket; j <= maxBucket; j++) { + if (this.txAvg[j] < sum) { + sum -= this.txAvg[j]; + } else { + median = this.avg[j] / this.txAvg[j]; + break; + } + } + } + + bcoin.debug('estimatefee: %d:' + + ' For conf success %s %d need %s %s: %d from buckets %d - %d.' + + ' Cur Bucket stats %d% %d/%d (%d mempool).', + target, + greater ? '>' : '<', + breakpoint, + this.type, + greater ? '>' : '<', + median, + this.buckets[minBucket], + this.buckets[maxBucket], + 100 * conf / (total + extra), + conf, + total, + extra); + + return median; +}; + +/** + * Add a transaction's rate/priority to be tracked. + * @param {Number} height - Block height. + * @param {Number} val + * @returns {Number} Bucket index. + */ + +ConfirmStats.prototype.addTX = function addTX(height, val) { + var bucketIndex = this.bucketMap[val]; + var blockIndex = height % this.unconfTX.length; + this.unconfTX[blockIndex][bucketIndex]++; + bcoin.debug('estimatefee: Adding TX to %s.', this.type); + return bucketIndex; +}; + +/** + * Remove a transaction from tracking. + * @param {Number} entryHeight + * @param {Number} bestHeight + * @param {Number} bucketIndex + */ + +ConfirmStats.prototype.removeTX = function removeTX(entryHeight, bestHeight, bucketIndex) { + var blocksAgo = bestHeight - entryHeight; + var blockIndex; + + if (bestHeight === 0) + blocksAgo = 0; + + if (blocksAgo < 0) { + bcoin.debug('estimatefee: Blocks ago is negative for mempool tx.'); + return; + } + + if (blocksAgo >= this.unconfTX.length) { + if (this.oldUnconfTX[bucketIndex] > 0) { + this.oldUnconfTX[bucketIndex]--; + } else { + bcoin.debug('estimatefee:' + + ' Mempool tx removed from >25 blocks,bucketIndex=%d already.', + bucketIndex); + } + } else { + blockIndex = entryHeight % this.unconfTX.length; + if (this.unconfTX[blockIndex][bucketIndex] > 0) { + this.unconfTX[blockIndex][bucketIndex]--; + } else { + bcoin.debug('estimatefee:' + + ' Mempool tx removed from blockIndex=%d,bucketIndex=%d already.', + blockIndex, bucketIndex); + } + } +}; + +/** + * Estimator for fees and priority. + * @exposes PolicyEstimator + * @constructor + * @param {Rate} minRelay + * @param {Network|NetworkType} network + */ + +function PolicyEstimator(minRelay, network) { + var feelist, prilist, boundary; + + if (!(this instanceof PolicyEstimator)) + return new PolicyEstimator(minRelay); + + this.network = bcoin.network.get(network); + + this.minTrackedFee = minRelay < MIN_FEERATE + ? MIN_FEERATE + : minRelay; + + feelist = []; + + for (boundary = this.minTrackedFee; + boundary <= MAX_FEERATE; + boundary *= FEE_SPACING) { + feelist.push(boundary); + } + + feelist.push(INF_FEERATE); + + this.feeStats = new ConfirmStats( + feelist, MAX_BLOCK_CONFIRMS, + DEFAULT_DECAY, 'FeeRate'); + + this.minTrackedPri = FREE_THRESHOLD < MIN_PRIORITY + ? MIN_PRIORITY + : FREE_THRESHOLD; + + prilist = []; + + for (boundary = this.minTrackedPri; + boundary <= MAX_PRIORITY; + boundary *= PRI_SPACING) { + prilist.push(boundary); + } + + prilist.push(INF_PRIORITY); + + this.priStats = new ConfirmStats( + prilist, MAX_BLOCK_CONFIRMS, + DEFAULT_DECAY, 'Priority'); + + this.feeUnlikely = 0; + this.feeLikely = INF_FEERATE; + this.priUnlikely = 0; + this.priLikely = INF_PRIORITY; + this.map = {}; + this.bestHeight = 0; +} + +/** + * Stop tracking a tx. Remove from map. + * @param {Hash} hash + */ + +PolicyEstimator.prototype.removeTX = function removeTX(hash) { + var item = this.map[hash]; + + if (!item) { + bcoin.debug('estimatefee: mempool tx %s not found.', hash); + return; + } + + this.feeStats.removeTX(item.blockHeight, this.bestHeight, item.bucketIndex); + + delete this.map[hash]; +}; + +/** + * Test whether a fee should be used for calculation. + * @param {Amount} fee + * @param {Number} priority + * @returns {Boolean} + */ + +PolicyEstimator.prototype.isFeePoint = function isFeePoint(fee, priority) { + if ((priority < this.minTrackedPri && fee >= this.minTrackedFee) + || (priority < this.priUnlikely && fee > this.feeLikely)) { + return true; + } + return false; +}; + +/** + * Test whether a priority should be used for calculation. + * @param {Amount} fee + * @param {Number} priority + * @returns {Boolean} + */ + +PolicyEstimator.prototype.isPriPoint = function isPriPoint(fee, priority) { + if ((fee < this.minTrackedFee && priority >= this.minTrackedPri) + || (fee < this.feeUnlikely && priority > this.priLikely)) { + return true; + } + return false; +}; + +/** + * Process a mempool entry. + * @param {MempoolEntry} entry + * @param {Boolean} current - Whether the chain is synced. + */ + +PolicyEstimator.prototype.processTX = function processTX(entry, current) { + var height = entry.height; + var hash = entry.tx.hash('hex'); + var fee, rate, priority; + + if (this.map[hash]) { + bcoin.debug('estimatefee: Mempool tx %s already tracked.', hash); + return; + } + + // Ignore reorgs. + if (height < this.bestHeight) + return; + + // Wait for chain to sync. + if (!current) + return; + + // Requires other mempool txs in order to be confirmed. Ignore. + if (entry.dependencies) + return; + + fee = entry.tx.getFee(); + rate = entry.tx.getRate(); + priority = entry.getPriority(height); + + bcoin.debug('estimatefee: Processing mempool tx %s.', hash); + + if (fee === 0 || this.isPriPoint(rate, priority)) { + this.map[hash] = { + blockHeight: height, + bucketIndex: this.priStats.addTX(height, priority) + }; + } else if (this.isFeePoint(rate, priority)) { + this.map[hash] = { + blockHeight: height, + bucketIndex: this.feeStats.addTX(height, rate) + }; + } else { + bcoin.debug('estimatefee: Not adding ts %s.', hash); + } +}; + +/** + * Process an entry being removed from the mempool. + * @param {Number} height - Block height. + * @param {MempoolEntry} entry + */ + +PolicyEstimator.prototype.processBlockTX = function processBlockTX(height, entry) { + var blocks, fee, rate, priority; + + // Requires other mempool txs in order to be confirmed. Ignore. + if (entry.dependencies) + return; + + blocks = height - entry.height; + if (blocks <= 0) { + bcoin.debug('estimatefee: TX had negative blocks to confirm.'); + return; + } + + fee = entry.tx.getFee(); + rate = entry.tx.getRate(); + priority = entry.getPriority(height); + + if (fee === 0 || this.isPriPoint(rate, priority)) + this.priStats.record(blocks, priority); + else if (this.isFeePoint(rate, priority)) + this.feeStats.record(blocks, rate); +}; + +/** + * Process a block of transaction entries being removed from the mempool. + * @param {Number} height - Block height. + * @param {MempoolEntry[]} entries + * @param {Boolean} current - Whether the chain is synced. + */ + +PolicyEstimator.prototype.processBlock = function processBlock(height, entries, current) { + var i, median; + + // Ignore reorgs. + if (height <= this.bestHeight) + return; + + this.bestHeight = height; + + // Wait for chain to sync. + if (!current) + return; + + bcoin.debug('estimatefee: Recalculating dynamic cutoffs.'); + + this.priLikely = this.priStats.estimateMedian( + 2, SUFFICIENT_PRITXS, MIN_SUCCESS_PCT, + true, height); + + if (this.priLikely === -1) + this.priLikely = INF_PRIORITY; + + median = this.feeStats.estimateMedian( + 2, SUFFICIENT_FEETXS, MIN_SUCCESS_PCT, + true, height); + + this.feeLikely = median === -1 ? INF_FEERATE : median; + + this.priUnlikely = this.priStats.estimateMedian( + 10, SUFFICIENT_PRITXS, UNLIKELY_PCT, + false, height); + + if (this.priUnlikely === -1) + this.priUnlikely = 0; + + median = this.feeStats.estimateMedian( + 10, SUFFICIENT_FEETXS, UNLIKELY_PCT, + false, height); + + this.feeUnlikely = median === -1 ? 0 : median; + + this.feeStats.clearCurrent(height); + this.priStats.clearCurrent(height); + + for (i = 0; i < entries.length; i++) + this.processBlockTX(height, entries[i]); + + this.feeStats.updateAverages(); + this.priStats.updateAverages(); + + bcoin.debug('estimatefee: Done updating estimates' + + ' for %d confirmed entries. New mempool map size %d.', + entries.length, Object.keys(this.map).length); +}; + +/** + * Estimate a fee rate. + * @param {Number} target - Confirmation target. + * @param {Boolean?} smart - Smart estimation. + * @returns {Rate} + */ + +PolicyEstimator.prototype.estimateFee = function estimateFee(target, smart) { + var rate, minPoolFee; + + if (target <= 0 || target > this.feeStats.getMaxConfirms()) + return 0; + + if (!smart) { + rate = this.feeStats.estimateMedian( + target, SUFFICIENT_FEETXS, MIN_SUCCESS_PCT, + true, this.bestHeight); + + if (rate < 0) + return 0; + + return rate; + } + + rate = -1; + while (rate < 0 && target <= this.feeStats.getMaxConfirms()) { + rate = this.feeStats.estimateMedian( + target++, SUFFICIENT_FEETXS, MIN_SUCCESS_PCT, + true, this.bestHeight); + } + + target -= 1; + + minPoolFee = this.network.minRelay; + if (minPoolFee > 0 && minPoolFee > rate) + return minPoolFee; + + if (rate < 0) + return 0; + + return rate; +}; + +/** + * Estimate a priority. + * @param {Number} target - Confirmation target. + * @param {Boolean?} smart - Smart estimation. + * @returns {Number} + */ + +PolicyEstimator.prototype.estimatePriority = function estimatePriority(target, smart) { + var minPoolFee, priority; + + if (target <= 0 || target > this.priStats.getMaxConfirms()) + return -1; + + if (!smart) { + priority = this.priStats.estimateMedian( + target, SUFFICIENT_PRITXS, MIN_SUCCESS_PCT, + true, this.bestHeight); + return priority; + } + + minPoolFee = this.network.minRelay; + if (minPoolFee > 0) + return INF_PRIORITY; + + priority = -1; + while (priority < 0 && target <= this.priStats.getMaxConfirms()) { + priority = this.priStats.estimateMedian( + target++, SUFFICIENT_PRITXS, MIN_SUCCESS_PCT, + true, this.bestHeight); + } + + target -= 1; + + return priority; +}; + +/* + * Expose + */ + +exports = PolicyEstimator; +exports.PolicyEstimator = PolicyEstimator; +exports.ConfirmStats = ConfirmStats; + +module.exports = exports;