/*! * 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 */ 'use strict'; var utils = require('../utils/utils'); var assert = require('assert'); var constants = require('../protocol/constants'); var BufferReader = require('../utils/reader'); var BufferWriter = require('../utils/writer'); var Logger = require('../node/logger'); var Network = require('../protocol/network'); var global = utils.global; var Float64Array = global.Float64Array || Array; var Int32Array = global.Int32Array || Array; /* * 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.tx.FREE_THRESHOLD; /** * Confirmation stats. * @exports ConfirmStats * @constructor * @param {Number} buckets * @param {Number} maxConfirms * @param {Number} decay * @param {String} type */ function ConfirmStats(buckets, maxConfirms, decay, type, logger) { var i; if (!(this instanceof ConfirmStats)) return new ConfirmStats(buckets, maxConfirms, decay, type, logger); this.logger = logger || Logger.global; this.maxConfirms = maxConfirms; this.decay = decay; this.type = type; this.buckets = new Float64Array(buckets.length); this.bucketMap = new DoubleMap(); for (i = 0; i < buckets.length; i++) { this.buckets[i] = buckets[i]; this.bucketMap.insert(buckets[i], i); } 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 Float64Array(buckets.length); this.curBlockConf[i] = new Int32Array(buckets.length); this.unconfTX[i] = new Int32Array(buckets.length); } this.oldUnconfTX = new Int32Array(buckets.length); this.curBlockTX = new Int32Array(buckets.length); this.txAvg = new Float64Array(buckets.length); this.curBlockVal = new Float64Array(buckets.length); this.avg = new Float64Array(buckets.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.search(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.maxConfirms; j++) extra += this.unconfTX[Math.max(height - j, 0) % 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 (i = minBucket; i <= maxBucket; i++) sum += this.txAvg[i]; 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; } } } // this.logger.debug('estimatefee: ' // + ' For confirmation success in %d blocks' // + ' %s %d need %s %s: %d from buckets %d - %d.' // + ' Current bucket stats %d% %d/%d (%d mempool).', // target, // greater ? '>' : '<', // breakpoint, // this.type, // greater ? '>' : '<', // median, // this.buckets[minBucket], // this.buckets[maxBucket], // 100 * conf / Math.max(1, 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.search(val); var blockIndex = height % this.unconfTX.length; this.unconfTX[blockIndex][bucketIndex]++; this.logger.spam('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) { this.logger.debug('estimatefee: Blocks ago is negative for mempool tx.'); return; } if (blocksAgo >= this.unconfTX.length) { if (this.oldUnconfTX[bucketIndex] > 0) { this.oldUnconfTX[bucketIndex]--; } else { this.logger.debug('estimatefee:' + ' Mempool tx removed >25 blocks (bucket=%d).', bucketIndex); } } else { blockIndex = entryHeight % this.unconfTX.length; if (this.unconfTX[blockIndex][bucketIndex] > 0) { this.unconfTX[blockIndex][bucketIndex]--; } else { this.logger.debug('estimatefee:' + ' Mempool tx removed (block=%d, bucket=%d).', blockIndex, bucketIndex); } } }; /** * Serialize confirm stats. * @returns {Buffer} */ ConfirmStats.prototype.toRaw = function toRaw(writer) { var p = new BufferWriter(writer); var i; function writeArray(buckets) { var i; p.writeVarint(buckets.length); for (i = 0; i < buckets.length; i++) p.writeDouble(buckets[i]); } p.writeDouble(this.decay); writeArray(this.buckets); writeArray(this.avg); writeArray(this.txAvg); p.writeVarint(this.maxConfirms); for (i = 0; i < this.maxConfirms; i++) writeArray(this.confAvg[i]); if (!writer) p = p.render(); return p; }; /** * Instantiate confirm stats from serialized data. * @param {Buffer} data * @param {String} type * @returns {ConfirmStats} */ ConfirmStats.fromRaw = function fromRaw(data, type, logger) { var p = new BufferReader(data); var i, decay, buckets, avg, txAvg, maxConfirms, confAvg, stats; function readArray() { var buckets = new Float64Array(p.readVarint()); var i; for (i = 0; i < buckets.length; i++) buckets[i] = p.readDouble(); return buckets; } decay = p.readDouble(); buckets = readArray(); avg = readArray(); txAvg = readArray(); maxConfirms = p.readVarint(); confAvg = new Array(maxConfirms); for (i = 0; i < maxConfirms; i++) confAvg[i] = readArray(); if (decay <= 0 || decay >= 1) throw new Error('Decay must be between 0 and 1 (non-inclusive).'); if (buckets.length <= 1 || buckets.length > 1000) throw new Error('Must have between 2 and 1000 fee/pri buckets.'); if (avg.length !== buckets.length) throw new Error('Mismatch in fee/pri average bucket count.'); if (txAvg.length !== buckets.length) throw new Error('Mismatch in tx count bucket count.'); if (maxConfirms <= 0 || maxConfirms > 6 * 24 * 7) throw new Error('Must maintain estimates for between 1 and 1008 confirms.'); for (i = 0; i < maxConfirms; i++) { if (confAvg[i].length !== buckets.length) throw new Error('Mismatch in fee/pri conf average bucket count.'); } stats = new ConfirmStats(buckets, maxConfirms, decay, type, logger); stats.avg = avg; stats.txAvg = txAvg; stats.confAvg = confAvg; return stats; }; /** * Estimator for fees and priority. * @exports PolicyEstimator * @constructor * @param {Rate} minRelay * @param {Network|NetworkType} network */ function PolicyEstimator(minRelay, network, logger) { var fee, priority, boundary; if (!(this instanceof PolicyEstimator)) return new PolicyEstimator(minRelay, network, logger); fee = []; priority = []; this.network = Network.get(network); this.logger = logger || Logger.global; this.minTrackedFee = minRelay < MIN_FEERATE ? MIN_FEERATE : minRelay; this.minTrackedPri = FREE_THRESHOLD < MIN_PRIORITY ? MIN_PRIORITY : FREE_THRESHOLD; for (boundary = this.minTrackedFee; boundary <= MAX_FEERATE; boundary *= FEE_SPACING) { fee.push(boundary); } fee.push(INF_FEERATE); for (boundary = this.minTrackedPri; boundary <= MAX_PRIORITY; boundary *= PRI_SPACING) { priority.push(boundary); } priority.push(INF_PRIORITY); this.feeStats = new ConfirmStats( fee, MAX_BLOCK_CONFIRMS, DEFAULT_DECAY, 'FeeRate', this.logger); this.priStats = new ConfirmStats( priority, MAX_BLOCK_CONFIRMS, DEFAULT_DECAY, 'Priority', this.logger); this.feeUnlikely = 0; this.feeLikely = INF_FEERATE; this.priUnlikely = 0; this.priLikely = INF_PRIORITY; this.map = {}; this.mapSize = 0; 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) { this.logger.spam( 'estimatefee: Mempool tx %s not found.', utils.revHex(hash)); return; } this.feeStats.removeTX(item.blockHeight, this.bestHeight, item.bucketIndex); delete this.map[hash]; this.mapSize--; }; /** * 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, item; if (this.map[hash]) { this.logger.debug( 'estimatefee: Mempool tx %s already tracked.', entry.tx.rhash); 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.getFee(); rate = entry.getRate(); priority = entry.getPriority(height); this.logger.spam('estimatefee: Processing mempool tx %s.', entry.tx.rhash); if (fee === 0 || this.isPriPoint(rate, priority)) { item = new StatEntry(); item.blockHeight = height; item.bucketIndex = this.priStats.addTX(height, priority); } else if (this.isFeePoint(rate, priority)) { item = new StatEntry(); item.blockHeight = height; item.bucketIndex = this.feeStats.addTX(height, rate); } if (!item) { this.logger.spam('estimatefee: Not adding tx %s.', entry.tx.rhash); return; } this.map[hash] = item; this.mapSize++; }; /** * 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) { this.logger.debug( 'estimatefee: Block tx %s had negative blocks to confirm (%d, %d).', entry.tx.rhash, height, entry.height); return; } fee = entry.getFee(); rate = entry.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; // Ignore reorgs. if (height <= this.bestHeight) return; this.bestHeight = height; if (entries.length === 0) return; // Wait for chain to sync. if (!current) return; this.logger.debug('estimatefee: Recalculating dynamic cutoffs.'); this.feeLikely = this.feeStats.estimateMedian( 2, SUFFICIENT_FEETXS, MIN_SUCCESS_PCT, true, height); if (this.feeLikely === -1) this.feeLikely = INF_FEERATE; this.feeUnlikely = this.feeStats.estimateMedian( 10, SUFFICIENT_FEETXS, UNLIKELY_PCT, false, height); if (this.feeUnlikely === -1) this.feeUnlikely = 0; this.priLikely = this.priStats.estimateMedian( 2, SUFFICIENT_PRITXS, MIN_SUCCESS_PCT, true, height); if (this.priLikely === -1) this.priLikely = INF_PRIORITY; this.priUnlikely = this.priStats.estimateMedian( 10, SUFFICIENT_PRITXS, UNLIKELY_PCT, false, height); if (this.priUnlikely === -1) this.priUnlikely = 0; 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(); this.logger.debug('estimatefee: Done updating estimates' + ' for %d confirmed entries. New mempool map size %d.', entries.length, this.mapSize); this.logger.debug('estimatefee: Rate: %d.', this.estimateFee()); }; /** * Estimate a fee rate. * @param {Number} target - Confirmation target. * @param {Boolean} [smart=true] - Smart estimation. * @returns {Rate} */ PolicyEstimator.prototype.estimateFee = function estimateFee(target, smart) { var rate, minPoolFee; if (!target) target = 1; if (smart == null) smart = true; if (!(target > 0) || target > this.feeStats.maxConfirms) return 0; if (!smart) { rate = this.feeStats.estimateMedian( target, SUFFICIENT_FEETXS, MIN_SUCCESS_PCT, true, this.bestHeight); if (rate < 0) return 0; return Math.floor(rate); } rate = -1; while (rate < 0 && target <= this.feeStats.maxConfirms) { 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 Math.floor(rate); }; /** * Estimate a priority. * @param {Number} target - Confirmation target. * @param {Boolean} [smart=true] - Smart estimation. * @returns {Number} */ PolicyEstimator.prototype.estimatePriority = function estimatePriority(target, smart) { var minPoolFee, priority; if (!target) target = 1; if (smart == null) smart = true; if (!(target > 0) || target > this.priStats.maxConfirms) return 0; if (!smart) { priority = this.priStats.estimateMedian( target, SUFFICIENT_PRITXS, MIN_SUCCESS_PCT, true, this.bestHeight); return Math.floor(priority); } minPoolFee = this.network.minRelay; if (minPoolFee > 0) return INF_PRIORITY; priority = -1; while (priority < 0 && target <= this.priStats.maxConfirms) { priority = this.priStats.estimateMedian( target++, SUFFICIENT_PRITXS, MIN_SUCCESS_PCT, true, this.bestHeight); } target -= 1; if (priority < 0) return 0; return Math.floor(priority); }; /** * Serialize the estimator. * @returns {Buffer} */ PolicyEstimator.prototype.toRaw = function toRaw(writer) { var p = new BufferWriter(writer); p.writeU32(this.network.magic); p.writeU32(this.bestHeight); p.writeVarBytes(this.feeStats.toRaw()); p.writeVarBytes(this.priStats.toRaw()); if (!writer) p = p.render(); return p; }; /** * Instantiate a policy estimator from serialized data. * @param {Buffer} data * @param {Rate} minRelay * @param {Network|NetworkType} network * @returns {PolicyEstimator} */ PolicyEstimator.fromRaw = function fromRaw(minRelay, data, logger) { var p = new BufferReader(data); var network = Network.fromMagic(p.readU32()); var bestHeight = p.readU32(); var estimator = new PolicyEstimator(minRelay, network, logger); var feeStats = ConfirmStats.fromRaw(p.readVarBytes(), 'FeeRate', logger); var priStats = ConfirmStats.fromRaw(p.readVarBytes(), 'Priority', logger); estimator.bestHeight = bestHeight; estimator.feeStats = feeStats; estimator.priStats = priStats; return estimator; }; /** * StatEntry * @private */ function StatEntry() { this.blockHeight = -1; this.bucketIndex = -1; } /** * DoubleMap * @private */ function DoubleMap() { if (!(this instanceof DoubleMap)) return new DoubleMap(); this.buckets = []; } DoubleMap.prototype.insert = function insert(key, value) { var i = utils.binarySearch(this.buckets, key, compare, true); this.buckets.splice(i, 0, [key, value]); }; DoubleMap.prototype.search = function search(key) { var i = utils.binarySearch(this.buckets, key, compare, true); assert(this.buckets.length !== 0, 'Cannot search.'); return this.buckets[i][1]; }; /* * Helpers */ function compare(a, b) { return a[0] - b; } /* * Expose */ exports = PolicyEstimator; exports.PolicyEstimator = PolicyEstimator; exports.ConfirmStats = ConfirmStats; module.exports = exports;