import logger from '../../logger'; import { MempoolTransactionExtended } from '../../mempool.interfaces'; import { GraphTx, getSameBlockRelatives, initializeRelatives, makeBlockTemplate, mempoolComparator, removeAncestors, setAncestorScores } from '../mini-miner'; const BLOCK_WEIGHT_UNITS = 4_000_000; const MAX_RELATIVE_GRAPH_SIZE = 200; const BID_BOOST_WINDOW = 40_000; const BID_BOOST_MIN_OFFSET = 10_000; const BID_BOOST_MAX_OFFSET = 400_000; export type Acceleration = { txid: string; max_bid: number; }; interface TxSummary { txid: string; // txid of the current transaction effectiveVsize: number; // Total vsize of the dependency tree effectiveFee: number; // Total fee of the dependency tree in sats ancestorCount: number; // Number of ancestors } export interface AccelerationInfo { txSummary: TxSummary; targetFeeRate: number; // target fee rate (recommended next block fee, or median fee for mined block) nextBlockFee: number; // fee in sats required to be in the next block (using recommended next block fee, or median fee for mined block) cost: number; // additional cost to accelerate ((cost + txSummary.effectiveFee) / txSummary.effectiveVsize) >= targetFeeRate } class AccelerationCosts { /** * Takes a list of accelerations and verbose block data * Returns the "fair" boost rate to charge accelerations * * @param accelerationsx * @param verboseBlock */ public calculateBoostRate(accelerations: Acceleration[], blockTxs: MempoolTransactionExtended[]): number { // Run GBT ourselves to calculate accurate effective fee rates // the list of transactions comes from a mined block, so we already know everything fits within consensus limits const template = makeBlockTemplate(blockTxs, accelerations, 1, Infinity, Infinity); // initialize working maps for fast tx lookups const accMap = {}; const txMap = {}; for (const acceleration of accelerations) { accMap[acceleration.txid] = acceleration; } for (const tx of template) { txMap[tx.txid] = tx; } // Identify and exclude accelerated and otherwise prioritized transactions const excludeMap = {}; let totalWeight = 0; let minAcceleratedPackage = Infinity; let lastEffectiveRate = 0; // Iterate over the mined template from bottom to top. // Transactions should appear in ascending order of mining priority. for (const blockTx of [...blockTxs].reverse()) { const txid = blockTx.txid; const tx = txMap[txid]; totalWeight += tx.weight; const isAccelerated = accMap[txid] != null; // If a cluster has a in-band effective fee rate than the previous cluster, // it must have been prioritized out-of-band (in order to have a higher mining priority) // so exclude from the analysis. const isPrioritized = tx.effectiveFeePerVsize < lastEffectiveRate; if (isPrioritized || isAccelerated) { let packageWeight = 0; // exclude this whole CPFP cluster for (const clusterTxid of tx.cluster) { packageWeight += txMap[clusterTxid].weight; if (!excludeMap[clusterTxid]) { excludeMap[clusterTxid] = true; } } // keep track of the smallest accelerated CPFP cluster for later if (isAccelerated) { minAcceleratedPackage = Math.min(minAcceleratedPackage, packageWeight); } } if (!isPrioritized) { if (!isAccelerated) { lastEffectiveRate = tx.effectiveFeePerVsize; } } } // The Bid Boost Rate is calculated by disregarding the bottom X weight units of the block, // where X is the larger of BID_BOOST_MIN_OFFSET or the smallest accelerated package weight (the "offset"), // then taking the average fee rate of the following BID_BOOST_WINDOW weight units // (ignoring accelerated transactions and their ancestors). // // Transactions within the offset might pay less than the fair rate due to bin-packing effects // But the average rate paid by the next chunk of non-accelerated transactions provides a good // upper bound on the "next best rate" of alternatives to including the accelerated transactions // (since, if there were any better options, they would have been included instead) const spareWeight = BLOCK_WEIGHT_UNITS - totalWeight; const windowOffset = Math.min(Math.max(minAcceleratedPackage, BID_BOOST_MIN_OFFSET, spareWeight), BID_BOOST_MAX_OFFSET); const leftBound = windowOffset; const rightBound = windowOffset + BID_BOOST_WINDOW; let totalFeeInWindow = 0; let totalWeightInWindow = Math.max(0, spareWeight - leftBound); let txIndex = blockTxs.length - 1; for (let offset = spareWeight; offset < BLOCK_WEIGHT_UNITS && txIndex >= 0; txIndex--) { const txid = blockTxs[txIndex].txid; const tx = txMap[txid]; if (excludeMap[txid]) { // skip prioritized transactions and their ancestors continue; } const left = offset; const right = offset + tx.weight; offset += tx.weight; if (right < leftBound) { // not within window yet continue; } if (left > rightBound) { // past window break; } // count fees for weight units within the window const overlapLeft = Math.max(leftBound, left); const overlapRight = Math.min(rightBound, right); const overlapUnits = overlapRight - overlapLeft; totalFeeInWindow += (tx.effectiveFeePerVsize * (overlapUnits / 4)); totalWeightInWindow += overlapUnits; } if (totalWeightInWindow < BID_BOOST_WINDOW) { // not enough un-prioritized transactions to calculate a fair rate // just charge everyone their max bids return Infinity; } // Divide the total fee by the size of the BID_BOOST_WINDOW in vbytes const averageRate = totalFeeInWindow / (BID_BOOST_WINDOW / 4); return averageRate; } /** * Takes an accelerated mined txid and a target rate * Returns the total vsize, fees and acceleration cost (in sats) of the tx and all same-block ancestors * * @param txid * @param medianFeeRate */ public getAccelerationInfo(tx: MempoolTransactionExtended, targetFeeRate: number, transactions: MempoolTransactionExtended[]): AccelerationInfo { // Get same-block transaction ancestors const allRelatives = getSameBlockRelatives(tx, transactions); const relativesMap = initializeRelatives(allRelatives); const rootTx = relativesMap.get(tx.txid) as GraphTx; // Calculate cost to boost return this.calculateAccelerationAncestors(rootTx, relativesMap, targetFeeRate); } /** * Given a root transaction, a list of in-mempool ancestors, and a target fee rate, * Calculate the minimum set of transactions to fee-bump, their total vsize + fees * * @param tx * @param ancestors */ private calculateAccelerationAncestors(tx: GraphTx, relatives: Map, targetFeeRate: number): AccelerationInfo { // add root tx to the ancestor map relatives.set(tx.txid, tx); // Check for high-sigop transactions (not supported) relatives.forEach(entry => { if (entry.vsize > Math.ceil(entry.weight / 4)) { throw new Error(`high_sigop_tx`); } }); // Initialize individual & ancestor fee rates relatives.forEach(entry => setAncestorScores(entry)); // Sort by descending ancestor score let sortedRelatives = Array.from(relatives.values()).sort(mempoolComparator); let includedInCluster: Map | null = null; // While highest score >= targetFeeRate let maxIterations = MAX_RELATIVE_GRAPH_SIZE; while (sortedRelatives.length && sortedRelatives[0].score && sortedRelatives[0].score >= targetFeeRate && maxIterations > 0) { maxIterations--; // Grab the highest scoring entry const best = sortedRelatives.shift(); if (best) { const cluster = new Map(best.ancestors?.entries() || []); if (best.ancestors.has(tx.txid)) { includedInCluster = cluster; } cluster.set(best.txid, best); // Remove this cluster (it already pays over the target rate, so doesn't need to be boosted) // and update scores, ancestor totals and dependencies for the survivors removeAncestors(cluster, relatives); // re-sort sortedRelatives = Array.from(relatives.values()).sort(mempoolComparator); } } // sanity check for infinite loops / too many ancestors (should never happen) if (maxIterations <= 0) { logger.warn(`acceleration dependency calculation failed: calculateAccelerationAncestors loop exceeded ${MAX_RELATIVE_GRAPH_SIZE} iterations, unable to proceed`); throw new Error('invalid_tx_dependencies'); } let totalFee = tx.fees.ancestor; // transaction is already CPFP-d above the target rate by some descendant if (includedInCluster) { let clusterSize = 0; let clusterFee = 0; includedInCluster.forEach(entry => { clusterSize += entry.vsize; clusterFee += entry.fees.base; }); const clusterRate = clusterFee / clusterSize; totalFee = Math.ceil(tx.ancestorsize * clusterRate); } // Whatever remains in the accelerated tx's dependencies needs to be boosted to the targetFeeRate // Cost = (totalVsize * targetFeeRate) - totalFee return { txSummary: { txid: tx.txid, effectiveVsize: tx.ancestorsize, effectiveFee: totalFee, ancestorCount: tx.ancestorcount, }, cost: Math.max(0, Math.ceil(tx.ancestorsize * targetFeeRate) - totalFee), targetFeeRate, nextBlockFee: Math.ceil(tx.ancestorsize * targetFeeRate), }; } } export default new AccelerationCosts;