2023-05-10 12:59:05 -06:00
|
|
|
import { Ancestor, CpfpInfo, CpfpSummary, EffectiveFeeStats, MempoolBlockWithTransactions, TransactionExtended, TransactionStripped, WorkingEffectiveFeeStats } from '../mempool.interfaces';
|
2021-08-05 02:03:52 +03:00
|
|
|
import config from '../config';
|
2022-08-13 10:24:11 +02:00
|
|
|
import { NodeSocket } from '../repositories/NodesSocketsRepository';
|
|
|
|
import { isIP } from 'net';
|
2020-05-24 16:29:30 +07:00
|
|
|
export class Common {
|
2021-12-28 17:59:11 +04:00
|
|
|
static nativeAssetId = config.MEMPOOL.NETWORK === 'liquidtestnet' ?
|
|
|
|
'144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49'
|
|
|
|
: '6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d';
|
2021-12-31 02:28:40 +04:00
|
|
|
static _isLiquid = config.MEMPOOL.NETWORK === 'liquid' || config.MEMPOOL.NETWORK === 'liquidtestnet';
|
|
|
|
|
|
|
|
static isLiquid(): boolean {
|
|
|
|
return this._isLiquid;
|
|
|
|
}
|
2021-09-18 13:37:25 +04:00
|
|
|
|
2020-05-24 16:29:30 +07:00
|
|
|
static median(numbers: number[]) {
|
|
|
|
let medianNr = 0;
|
|
|
|
const numsLen = numbers.length;
|
|
|
|
if (numsLen % 2 === 0) {
|
|
|
|
medianNr = (numbers[numsLen / 2 - 1] + numbers[numsLen / 2]) / 2;
|
|
|
|
} else {
|
|
|
|
medianNr = numbers[(numsLen - 1) / 2];
|
|
|
|
}
|
|
|
|
return medianNr;
|
|
|
|
}
|
|
|
|
|
2021-03-18 05:52:46 +00:00
|
|
|
static percentile(numbers: number[], percentile: number) {
|
2021-04-10 21:26:05 +04:00
|
|
|
if (percentile === 50) {
|
|
|
|
return this.median(numbers);
|
|
|
|
}
|
2021-03-18 05:52:46 +00:00
|
|
|
const index = Math.ceil(numbers.length * (100 - percentile) * 1e-2);
|
2021-04-10 21:26:05 +04:00
|
|
|
if (index < 0 || index > numbers.length - 1) {
|
|
|
|
return 0;
|
|
|
|
}
|
2021-03-18 05:52:46 +00:00
|
|
|
return numbers[index];
|
|
|
|
}
|
|
|
|
|
2020-07-24 00:20:59 +07:00
|
|
|
static getFeesInRange(transactions: TransactionExtended[], rangeLength: number) {
|
2023-01-19 11:09:03 -06:00
|
|
|
const filtered: TransactionExtended[] = [];
|
|
|
|
let lastValidRate = Infinity;
|
|
|
|
// filter out anomalous fee rates to ensure monotonic range
|
|
|
|
for (const tx of transactions) {
|
|
|
|
if (tx.effectiveFeePerVsize <= lastValidRate) {
|
|
|
|
filtered.push(tx);
|
|
|
|
lastValidRate = tx.effectiveFeePerVsize;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
const arr = [filtered[filtered.length - 1].effectiveFeePerVsize];
|
2020-05-24 16:29:30 +07:00
|
|
|
const chunk = 1 / (rangeLength - 1);
|
|
|
|
let itemsToAdd = rangeLength - 2;
|
|
|
|
|
|
|
|
while (itemsToAdd > 0) {
|
2023-01-19 11:09:03 -06:00
|
|
|
arr.push(filtered[Math.floor(filtered.length * chunk * itemsToAdd)].effectiveFeePerVsize);
|
2020-05-24 16:29:30 +07:00
|
|
|
itemsToAdd--;
|
|
|
|
}
|
|
|
|
|
2023-01-19 11:09:03 -06:00
|
|
|
arr.push(filtered[0].effectiveFeePerVsize);
|
2020-05-24 16:29:30 +07:00
|
|
|
return arr;
|
|
|
|
}
|
2020-06-08 18:55:53 +07:00
|
|
|
|
2022-12-17 09:39:06 -06:00
|
|
|
static findRbfTransactions(added: TransactionExtended[], deleted: TransactionExtended[]): { [txid: string]: TransactionExtended[] } {
|
|
|
|
const matches: { [txid: string]: TransactionExtended[] } = {};
|
|
|
|
added
|
|
|
|
.forEach((addedTx) => {
|
|
|
|
const foundMatches = deleted.filter((deletedTx) => {
|
2020-06-08 18:55:53 +07:00
|
|
|
// The new tx must, absolutely speaking, pay at least as much fee as the replaced tx.
|
|
|
|
return addedTx.fee > deletedTx.fee
|
|
|
|
// The new transaction must pay more fee per kB than the replaced tx.
|
|
|
|
&& addedTx.feePerVsize > deletedTx.feePerVsize
|
|
|
|
// Spends one or more of the same inputs
|
|
|
|
&& deletedTx.vin.some((deletedVin) =>
|
2022-12-14 08:56:46 -06:00
|
|
|
addedTx.vin.some((vin) => vin.txid === deletedVin.txid && vin.vout === deletedVin.vout));
|
2020-06-08 18:55:53 +07:00
|
|
|
});
|
2022-12-17 09:39:06 -06:00
|
|
|
if (foundMatches?.length) {
|
|
|
|
matches[addedTx.txid] = foundMatches;
|
2020-06-08 18:55:53 +07:00
|
|
|
}
|
|
|
|
});
|
|
|
|
return matches;
|
|
|
|
}
|
2020-09-26 02:11:30 +07:00
|
|
|
|
2023-05-18 09:51:41 -04:00
|
|
|
static findMinedRbfTransactions(minedTransactions: TransactionExtended[], spendMap: Map<string, TransactionExtended>): { [txid: string]: { replaced: TransactionExtended[], replacedBy: TransactionExtended }} {
|
|
|
|
const matches: { [txid: string]: { replaced: TransactionExtended[], replacedBy: TransactionExtended }} = {};
|
|
|
|
for (const tx of minedTransactions) {
|
|
|
|
const replaced: Set<TransactionExtended> = new Set();
|
|
|
|
for (let i = 0; i < tx.vin.length; i++) {
|
|
|
|
const vin = tx.vin[i];
|
|
|
|
const match = spendMap.get(`${vin.txid}:${vin.vout}`);
|
|
|
|
if (match && match.txid !== tx.txid) {
|
|
|
|
replaced.add(match);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (replaced.size) {
|
|
|
|
matches[tx.txid] = { replaced: Array.from(replaced), replacedBy: tx };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return matches;
|
|
|
|
}
|
|
|
|
|
2020-09-26 02:11:30 +07:00
|
|
|
static stripTransaction(tx: TransactionExtended): TransactionStripped {
|
|
|
|
return {
|
|
|
|
txid: tx.txid,
|
|
|
|
fee: tx.fee,
|
2021-01-25 01:09:42 +07:00
|
|
|
vsize: tx.weight / 4,
|
2020-12-21 23:08:34 +07:00
|
|
|
value: tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0),
|
2023-03-14 15:39:55 +09:00
|
|
|
rate: tx.effectiveFeePerVsize,
|
2020-09-26 02:11:30 +07:00
|
|
|
};
|
|
|
|
}
|
2020-12-28 20:17:32 +07:00
|
|
|
|
2022-04-22 04:03:08 -04:00
|
|
|
static sleep$(ms: number): Promise<void> {
|
2020-12-28 20:17:32 +07:00
|
|
|
return new Promise((resolve) => {
|
|
|
|
setTimeout(() => {
|
|
|
|
resolve();
|
|
|
|
}, ms);
|
|
|
|
});
|
|
|
|
}
|
2021-04-10 21:26:05 +04:00
|
|
|
|
2021-03-19 13:47:37 +07:00
|
|
|
static shuffleArray(array: any[]) {
|
|
|
|
for (let i = array.length - 1; i > 0; i--) {
|
|
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
|
|
[array[i], array[j]] = [array[j], array[i]];
|
|
|
|
}
|
|
|
|
}
|
2021-03-18 23:47:40 +07:00
|
|
|
|
|
|
|
static setRelativesAndGetCpfpInfo(tx: TransactionExtended, memPool: { [txid: string]: TransactionExtended }): CpfpInfo {
|
|
|
|
const parents = this.findAllParents(tx, memPool);
|
2021-04-10 21:26:05 +04:00
|
|
|
const lowerFeeParents = parents.filter((parent) => parent.feePerVsize < tx.effectiveFeePerVsize);
|
2021-03-18 23:47:40 +07:00
|
|
|
|
2021-04-05 23:45:47 +04:00
|
|
|
let totalWeight = tx.weight + lowerFeeParents.reduce((prev, val) => prev + val.weight, 0);
|
|
|
|
let totalFees = tx.fee + lowerFeeParents.reduce((prev, val) => prev + val.fee, 0);
|
2021-03-18 23:47:40 +07:00
|
|
|
|
|
|
|
tx.ancestors = parents
|
|
|
|
.map((t) => {
|
|
|
|
return {
|
|
|
|
txid: t.txid,
|
|
|
|
weight: t.weight,
|
|
|
|
fee: t.fee,
|
|
|
|
};
|
|
|
|
});
|
|
|
|
|
|
|
|
// Add high (high fee) decendant weight and fees
|
|
|
|
if (tx.bestDescendant) {
|
|
|
|
totalWeight += tx.bestDescendant.weight;
|
|
|
|
totalFees += tx.bestDescendant.fee;
|
|
|
|
}
|
|
|
|
|
2022-06-25 19:06:26 +02:00
|
|
|
tx.effectiveFeePerVsize = Math.max(0, totalFees / (totalWeight / 4));
|
2021-03-18 23:47:40 +07:00
|
|
|
tx.cpfpChecked = true;
|
|
|
|
|
|
|
|
return {
|
|
|
|
ancestors: tx.ancestors,
|
|
|
|
bestDescendant: tx.bestDescendant || null,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static findAllParents(tx: TransactionExtended, memPool: { [txid: string]: TransactionExtended }): TransactionExtended[] {
|
|
|
|
let parents: TransactionExtended[] = [];
|
|
|
|
tx.vin.forEach((parent) => {
|
2021-04-02 00:30:51 +04:00
|
|
|
if (parents.find((p) => p.txid === parent.txid)) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2021-03-18 23:47:40 +07:00
|
|
|
const parentTx = memPool[parent.txid];
|
|
|
|
if (parentTx) {
|
|
|
|
if (tx.bestDescendant && tx.bestDescendant.fee / (tx.bestDescendant.weight / 4) > parentTx.feePerVsize) {
|
|
|
|
if (parentTx.bestDescendant && parentTx.bestDescendant.fee < tx.fee + tx.bestDescendant.fee) {
|
|
|
|
parentTx.bestDescendant = {
|
|
|
|
weight: tx.weight + tx.bestDescendant.weight,
|
|
|
|
fee: tx.fee + tx.bestDescendant.fee,
|
|
|
|
txid: tx.txid,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
} else if (tx.feePerVsize > parentTx.feePerVsize) {
|
|
|
|
parentTx.bestDescendant = {
|
|
|
|
weight: tx.weight,
|
|
|
|
fee: tx.fee,
|
|
|
|
txid: tx.txid
|
|
|
|
};
|
|
|
|
}
|
|
|
|
parents.push(parentTx);
|
|
|
|
parents = parents.concat(this.findAllParents(parentTx, memPool));
|
|
|
|
}
|
|
|
|
});
|
|
|
|
return parents;
|
|
|
|
}
|
2022-02-09 19:41:05 +09:00
|
|
|
|
2023-03-14 14:52:34 +09:00
|
|
|
// calculates the ratio of matched transactions to projected transactions by weight
|
|
|
|
static getSimilarity(projectedBlock: MempoolBlockWithTransactions, transactions: TransactionExtended[]): number {
|
|
|
|
let matchedWeight = 0;
|
|
|
|
let projectedWeight = 0;
|
|
|
|
const inBlock = {};
|
|
|
|
|
|
|
|
for (const tx of transactions) {
|
|
|
|
inBlock[tx.txid] = tx;
|
|
|
|
}
|
|
|
|
|
|
|
|
// look for transactions that were expected in the template, but missing from the mined block
|
|
|
|
for (const tx of projectedBlock.transactions) {
|
|
|
|
if (inBlock[tx.txid]) {
|
|
|
|
matchedWeight += tx.vsize * 4;
|
|
|
|
}
|
|
|
|
projectedWeight += tx.vsize * 4;
|
|
|
|
}
|
|
|
|
|
|
|
|
projectedWeight += transactions[0].weight;
|
|
|
|
matchedWeight += transactions[0].weight;
|
|
|
|
|
|
|
|
return projectedWeight ? matchedWeight / projectedWeight : 1;
|
|
|
|
}
|
|
|
|
|
2022-02-09 19:41:05 +09:00
|
|
|
static getSqlInterval(interval: string | null): string | null {
|
|
|
|
switch (interval) {
|
|
|
|
case '24h': return '1 DAY';
|
|
|
|
case '3d': return '3 DAY';
|
|
|
|
case '1w': return '1 WEEK';
|
|
|
|
case '1m': return '1 MONTH';
|
|
|
|
case '3m': return '3 MONTH';
|
|
|
|
case '6m': return '6 MONTH';
|
|
|
|
case '1y': return '1 YEAR';
|
|
|
|
case '2y': return '2 YEAR';
|
|
|
|
case '3y': return '3 YEAR';
|
2023-03-04 18:48:16 +09:00
|
|
|
case '4y': return '4 YEAR';
|
2022-02-09 19:41:05 +09:00
|
|
|
default: return null;
|
|
|
|
}
|
|
|
|
}
|
2022-04-25 15:50:26 +09:00
|
|
|
|
2022-02-15 16:02:30 +09:00
|
|
|
static indexingEnabled(): boolean {
|
|
|
|
return (
|
2022-07-11 08:41:28 +02:00
|
|
|
['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) &&
|
2022-02-15 16:02:30 +09:00
|
|
|
config.DATABASE.ENABLED === true &&
|
2022-04-20 13:12:32 +09:00
|
|
|
config.MEMPOOL.INDEXING_BLOCKS_AMOUNT !== 0
|
2022-02-15 16:02:30 +09:00
|
|
|
);
|
|
|
|
}
|
2022-06-18 16:48:02 +02:00
|
|
|
|
|
|
|
static blocksSummariesIndexingEnabled(): boolean {
|
|
|
|
return (
|
|
|
|
Common.indexingEnabled() &&
|
|
|
|
config.MEMPOOL.BLOCKS_SUMMARIES_INDEXING === true
|
|
|
|
);
|
|
|
|
}
|
2022-08-08 09:00:11 +02:00
|
|
|
|
2022-11-27 13:46:23 +09:00
|
|
|
static cpfpIndexingEnabled(): boolean {
|
|
|
|
return (
|
|
|
|
Common.indexingEnabled() &&
|
2022-12-28 05:28:37 -06:00
|
|
|
config.MEMPOOL.CPFP_INDEXING === true
|
2022-11-27 13:46:23 +09:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2022-08-08 09:00:11 +02:00
|
|
|
static setDateMidnight(date: Date): void {
|
|
|
|
date.setUTCHours(0);
|
|
|
|
date.setUTCMinutes(0);
|
|
|
|
date.setUTCSeconds(0);
|
|
|
|
date.setUTCMilliseconds(0);
|
|
|
|
}
|
|
|
|
|
2022-08-10 16:58:29 +02:00
|
|
|
static channelShortIdToIntegerId(channelId: string): string {
|
|
|
|
if (channelId.indexOf('x') === -1) { // Already an integer id
|
|
|
|
return channelId;
|
|
|
|
}
|
|
|
|
if (channelId.indexOf('/') !== -1) { // Topology import
|
|
|
|
channelId = channelId.slice(0, -2);
|
2022-08-08 09:00:11 +02:00
|
|
|
}
|
2022-08-10 16:58:29 +02:00
|
|
|
const s = channelId.split('x').map(part => BigInt(part));
|
|
|
|
return ((s[0] << 40n) | (s[1] << 16n) | s[2]).toString();
|
2022-08-08 09:00:11 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
/** Decodes a channel id returned by lnd as uint64 to a short channel id */
|
|
|
|
static channelIntegerIdToShortId(id: string): string {
|
2022-08-18 10:59:03 +02:00
|
|
|
if (id.indexOf('/') !== -1) {
|
|
|
|
id = id.slice(0, -2);
|
|
|
|
}
|
|
|
|
|
2022-08-10 16:58:29 +02:00
|
|
|
if (id.indexOf('x') !== -1) { // Already a short id
|
|
|
|
return id;
|
|
|
|
}
|
|
|
|
|
2022-08-08 09:00:11 +02:00
|
|
|
const n = BigInt(id);
|
|
|
|
return [
|
|
|
|
n >> 40n, // nth block
|
|
|
|
(n >> 16n) & 0xffffffn, // nth tx of the block
|
|
|
|
n & 0xffffn // nth output of the tx
|
|
|
|
].join('x');
|
|
|
|
}
|
|
|
|
|
2023-02-13 14:23:32 +09:00
|
|
|
static utcDateToMysql(date?: number | null): string | null {
|
|
|
|
if (date === null) {
|
|
|
|
return null;
|
|
|
|
}
|
2022-08-08 09:00:11 +02:00
|
|
|
const d = new Date((date || 0) * 1000);
|
|
|
|
return d.toISOString().split('T')[0] + ' ' + d.toTimeString().split(' ')[0];
|
|
|
|
}
|
2022-08-13 10:24:11 +02:00
|
|
|
|
2022-08-29 08:43:26 +02:00
|
|
|
static findSocketNetwork(addr: string): {network: string | null, url: string} {
|
2022-08-13 10:24:11 +02:00
|
|
|
let network: string | null = null;
|
2023-02-13 18:01:15 +09:00
|
|
|
let url: string = addr;
|
|
|
|
|
|
|
|
if (config.LIGHTNING.BACKEND === 'cln') {
|
|
|
|
url = addr.split('://')[1];
|
|
|
|
}
|
2022-08-13 10:24:11 +02:00
|
|
|
|
2022-08-29 08:43:26 +02:00
|
|
|
if (!url) {
|
|
|
|
return {
|
|
|
|
network: null,
|
|
|
|
url: addr,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
if (addr.indexOf('onion') !== -1) {
|
|
|
|
if (url.split('.')[0].length >= 56) {
|
|
|
|
network = 'torv3';
|
2022-08-13 10:24:11 +02:00
|
|
|
} else {
|
2022-08-29 08:43:26 +02:00
|
|
|
network = 'torv2';
|
|
|
|
}
|
|
|
|
} else if (addr.indexOf('i2p') !== -1) {
|
|
|
|
network = 'i2p';
|
2023-02-13 18:01:15 +09:00
|
|
|
} else if (addr.indexOf('ipv4') !== -1 || (config.LIGHTNING.BACKEND === 'lnd' && isIP(url.split(':')[0]) === 4)) {
|
2022-08-29 08:43:26 +02:00
|
|
|
const ipv = isIP(url.split(':')[0]);
|
|
|
|
if (ipv === 4) {
|
|
|
|
network = 'ipv4';
|
|
|
|
} else {
|
|
|
|
return {
|
|
|
|
network: null,
|
|
|
|
url: addr,
|
|
|
|
};
|
2022-08-13 10:24:11 +02:00
|
|
|
}
|
2023-02-13 18:01:15 +09:00
|
|
|
} else if (addr.indexOf('ipv6') !== -1 || (config.LIGHTNING.BACKEND === 'lnd' && url.indexOf(']:'))) {
|
2022-08-29 08:43:26 +02:00
|
|
|
url = url.split('[')[1].split(']')[0];
|
|
|
|
const ipv = isIP(url);
|
|
|
|
if (ipv === 6) {
|
|
|
|
const parts = addr.split(':');
|
|
|
|
network = 'ipv6';
|
|
|
|
url = `[${url}]:${parts[parts.length - 1]}`;
|
|
|
|
} else {
|
|
|
|
return {
|
|
|
|
network: null,
|
|
|
|
url: addr,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
return {
|
|
|
|
network: null,
|
|
|
|
url: addr,
|
|
|
|
};
|
2022-08-13 10:24:11 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
network: network,
|
2022-08-29 08:43:26 +02:00
|
|
|
url: url,
|
2022-08-13 10:24:11 +02:00
|
|
|
};
|
|
|
|
}
|
2022-08-29 08:43:26 +02:00
|
|
|
|
|
|
|
static formatSocket(publicKey: string, socket: {network: string, addr: string}): NodeSocket {
|
|
|
|
if (config.LIGHTNING.BACKEND === 'cln') {
|
|
|
|
return {
|
|
|
|
publicKey: publicKey,
|
|
|
|
network: socket.network,
|
|
|
|
addr: socket.addr,
|
|
|
|
};
|
|
|
|
} else /* if (config.LIGHTNING.BACKEND === 'lnd') */ {
|
|
|
|
const formatted = this.findSocketNetwork(socket.addr);
|
|
|
|
return {
|
|
|
|
publicKey: publicKey,
|
|
|
|
network: formatted.network,
|
|
|
|
addr: formatted.url,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|
2023-03-12 14:36:36 +09:00
|
|
|
|
|
|
|
static calculateCpfp(height: number, transactions: TransactionExtended[]): CpfpSummary {
|
|
|
|
const clusters: { root: string, height: number, txs: Ancestor[], effectiveFeePerVsize: number }[] = [];
|
|
|
|
let cluster: TransactionExtended[] = [];
|
|
|
|
let ancestors: { [txid: string]: boolean } = {};
|
|
|
|
const txMap = {};
|
|
|
|
for (let i = transactions.length - 1; i >= 0; i--) {
|
|
|
|
const tx = transactions[i];
|
|
|
|
txMap[tx.txid] = tx;
|
|
|
|
if (!ancestors[tx.txid]) {
|
|
|
|
let totalFee = 0;
|
|
|
|
let totalVSize = 0;
|
|
|
|
cluster.forEach(tx => {
|
|
|
|
totalFee += tx?.fee || 0;
|
|
|
|
totalVSize += (tx.weight / 4);
|
|
|
|
});
|
|
|
|
const effectiveFeePerVsize = totalFee / totalVSize;
|
|
|
|
if (cluster.length > 1) {
|
|
|
|
clusters.push({
|
|
|
|
root: cluster[0].txid,
|
|
|
|
height,
|
|
|
|
txs: cluster.map(tx => { return { txid: tx.txid, weight: tx.weight, fee: tx.fee || 0 }; }),
|
|
|
|
effectiveFeePerVsize,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
cluster.forEach(tx => {
|
|
|
|
txMap[tx.txid].effectiveFeePerVsize = effectiveFeePerVsize;
|
|
|
|
});
|
|
|
|
cluster = [];
|
|
|
|
ancestors = {};
|
|
|
|
}
|
|
|
|
cluster.push(tx);
|
|
|
|
tx.vin.forEach(vin => {
|
|
|
|
ancestors[vin.txid] = true;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
return {
|
|
|
|
transactions,
|
|
|
|
clusters,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
static calcEffectiveFeeStatistics(transactions: { weight: number, fee: number, effectiveFeePerVsize?: number, txid: string }[]): EffectiveFeeStats {
|
|
|
|
const sortedTxs = transactions.map(tx => { return { txid: tx.txid, weight: tx.weight, rate: tx.effectiveFeePerVsize || ((tx.fee || 0) / (tx.weight / 4)) }; }).sort((a, b) => a.rate - b.rate);
|
|
|
|
|
|
|
|
let weightCount = 0;
|
|
|
|
let medianFee = 0;
|
|
|
|
let medianWeight = 0;
|
|
|
|
|
|
|
|
// calculate the "medianFee" as the average fee rate of the middle 10000 weight units of transactions
|
|
|
|
const leftBound = 1995000;
|
|
|
|
const rightBound = 2005000;
|
|
|
|
for (let i = 0; i < sortedTxs.length && weightCount < rightBound; i++) {
|
|
|
|
const left = weightCount;
|
|
|
|
const right = weightCount + sortedTxs[i].weight;
|
|
|
|
if (right > leftBound) {
|
|
|
|
const weight = Math.min(right, rightBound) - Math.max(left, leftBound);
|
|
|
|
medianFee += (sortedTxs[i].rate * (weight / 4) );
|
|
|
|
medianWeight += weight;
|
|
|
|
}
|
|
|
|
weightCount += sortedTxs[i].weight;
|
|
|
|
}
|
|
|
|
const medianFeeRate = medianWeight ? (medianFee / (medianWeight / 4)) : 0;
|
|
|
|
|
|
|
|
// minimum effective fee heuristic:
|
|
|
|
// lowest of
|
|
|
|
// a) the 1st percentile of effective fee rates
|
|
|
|
// b) the minimum effective fee rate in the last 2% of transactions (in block order)
|
|
|
|
const minFee = Math.min(
|
|
|
|
Common.getNthPercentile(1, sortedTxs).rate,
|
|
|
|
transactions.slice(-transactions.length / 50).reduce((min, tx) => { return Math.min(min, tx.effectiveFeePerVsize || ((tx.fee || 0) / (tx.weight / 4))); }, Infinity)
|
|
|
|
);
|
|
|
|
|
|
|
|
// maximum effective fee heuristic:
|
|
|
|
// highest of
|
|
|
|
// a) the 99th percentile of effective fee rates
|
|
|
|
// b) the maximum effective fee rate in the first 2% of transactions (in block order)
|
|
|
|
const maxFee = Math.max(
|
|
|
|
Common.getNthPercentile(99, sortedTxs).rate,
|
|
|
|
transactions.slice(0, transactions.length / 50).reduce((max, tx) => { return Math.max(max, tx.effectiveFeePerVsize || ((tx.fee || 0) / (tx.weight / 4))); }, 0)
|
|
|
|
);
|
|
|
|
|
|
|
|
return {
|
|
|
|
medianFee: medianFeeRate,
|
|
|
|
feeRange: [
|
|
|
|
minFee,
|
|
|
|
[10,25,50,75,90].map(n => Common.getNthPercentile(n, sortedTxs).rate),
|
|
|
|
maxFee,
|
|
|
|
].flat(),
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
static getNthPercentile(n: number, sortedDistribution: any[]): any {
|
|
|
|
return sortedDistribution[Math.floor((sortedDistribution.length - 1) * (n / 100))];
|
|
|
|
}
|
2020-05-24 16:29:30 +07:00
|
|
|
}
|
2023-05-10 12:59:05 -06:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Class to calculate average fee rates of a list of transactions
|
|
|
|
* at certain weight percentiles, in a single pass
|
|
|
|
*
|
|
|
|
* init with:
|
|
|
|
* maxWeight - the total weight to measure percentiles relative to (e.g. 4MW for a single block)
|
|
|
|
* percentileBandWidth - how many weight units to average over for each percentile (as a % of maxWeight)
|
|
|
|
* percentiles - an array of weight percentiles to compute, in %
|
|
|
|
*
|
|
|
|
* then call .processNext(tx) for each transaction, in descending order
|
|
|
|
*
|
|
|
|
* retrieve the final results with .getFeeStats()
|
|
|
|
*/
|
|
|
|
export class OnlineFeeStatsCalculator {
|
|
|
|
private maxWeight: number;
|
|
|
|
private percentiles = [10,25,50,75,90];
|
|
|
|
|
|
|
|
private bandWidthPercent = 2;
|
|
|
|
private bandWidth: number = 0;
|
|
|
|
private bandIndex = 0;
|
|
|
|
private leftBound = 0;
|
|
|
|
private rightBound = 0;
|
|
|
|
private inBand = false;
|
|
|
|
private totalBandFee = 0;
|
|
|
|
private totalBandWeight = 0;
|
|
|
|
private minBandRate = Infinity;
|
|
|
|
private maxBandRate = 0;
|
|
|
|
|
|
|
|
private feeRange: { avg: number, min: number, max: number }[] = [];
|
|
|
|
private totalWeight: number = 0;
|
|
|
|
|
|
|
|
constructor (maxWeight: number, percentileBandWidth?: number, percentiles?: number[]) {
|
|
|
|
this.maxWeight = maxWeight;
|
|
|
|
if (percentiles && percentiles.length) {
|
|
|
|
this.percentiles = percentiles;
|
|
|
|
}
|
|
|
|
if (percentileBandWidth != null) {
|
|
|
|
this.bandWidthPercent = percentileBandWidth;
|
|
|
|
}
|
|
|
|
this.bandWidth = this.maxWeight * (this.bandWidthPercent / 100);
|
|
|
|
// add min/max percentiles aligned to the ends of the range
|
|
|
|
this.percentiles.unshift(this.bandWidthPercent / 2);
|
|
|
|
this.percentiles.push(100 - (this.bandWidthPercent / 2));
|
|
|
|
this.setNextBounds();
|
|
|
|
}
|
|
|
|
|
|
|
|
processNext(tx: { weight: number, fee: number, effectiveFeePerVsize?: number, feePerVsize?: number, rate?: number, txid: string }): void {
|
|
|
|
let left = this.totalWeight;
|
|
|
|
const right = this.totalWeight + tx.weight;
|
|
|
|
if (!this.inBand && right <= this.leftBound) {
|
|
|
|
this.totalWeight += tx.weight;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
while (left < right) {
|
|
|
|
if (right > this.leftBound) {
|
|
|
|
this.inBand = true;
|
|
|
|
const txRate = (tx.rate || tx.effectiveFeePerVsize || tx.feePerVsize || 0);
|
|
|
|
const weight = Math.min(right, this.rightBound) - Math.max(left, this.leftBound);
|
|
|
|
this.totalBandFee += (txRate * weight);
|
|
|
|
this.totalBandWeight += weight;
|
|
|
|
this.maxBandRate = Math.max(this.maxBandRate, txRate);
|
|
|
|
this.minBandRate = Math.min(this.minBandRate, txRate);
|
|
|
|
}
|
|
|
|
left = Math.min(right, this.rightBound);
|
|
|
|
|
|
|
|
if (left >= this.rightBound) {
|
|
|
|
this.inBand = false;
|
|
|
|
const avgBandFeeRate = this.totalBandWeight ? (this.totalBandFee / this.totalBandWeight) : 0;
|
|
|
|
this.feeRange.unshift({ avg: avgBandFeeRate, min: this.minBandRate, max: this.maxBandRate });
|
|
|
|
this.bandIndex++;
|
|
|
|
this.setNextBounds();
|
|
|
|
this.totalBandFee = 0;
|
|
|
|
this.totalBandWeight = 0;
|
|
|
|
this.minBandRate = Infinity;
|
|
|
|
this.maxBandRate = 0;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
this.totalWeight += tx.weight;
|
|
|
|
}
|
|
|
|
|
|
|
|
private setNextBounds(): void {
|
|
|
|
const nextPercentile = this.percentiles[this.bandIndex];
|
|
|
|
if (nextPercentile != null) {
|
|
|
|
this.leftBound = ((nextPercentile / 100) * this.maxWeight) - (this.bandWidth / 2);
|
|
|
|
this.rightBound = this.leftBound + this.bandWidth;
|
|
|
|
} else {
|
|
|
|
this.leftBound = Infinity;
|
|
|
|
this.rightBound = Infinity;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
getRawFeeStats(): WorkingEffectiveFeeStats {
|
|
|
|
if (this.totalBandWeight > 0) {
|
|
|
|
const avgBandFeeRate = this.totalBandWeight ? (this.totalBandFee / this.totalBandWeight) : 0;
|
|
|
|
this.feeRange.unshift({ avg: avgBandFeeRate, min: this.minBandRate, max: this.maxBandRate });
|
|
|
|
}
|
|
|
|
while (this.feeRange.length < this.percentiles.length) {
|
|
|
|
this.feeRange.unshift({ avg: 0, min: 0, max: 0 });
|
|
|
|
}
|
|
|
|
return {
|
|
|
|
minFee: this.feeRange[0].min,
|
|
|
|
medianFee: this.feeRange[Math.floor(this.feeRange.length / 2)].avg,
|
|
|
|
maxFee: this.feeRange[this.feeRange.length - 1].max,
|
|
|
|
feeRange: this.feeRange.map(f => f.avg),
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
getFeeStats(): EffectiveFeeStats {
|
|
|
|
const stats = this.getRawFeeStats();
|
|
|
|
stats.feeRange[0] = stats.minFee;
|
|
|
|
stats.feeRange[stats.feeRange.length - 1] = stats.maxFee;
|
|
|
|
return stats;
|
|
|
|
}
|
|
|
|
}
|