mirror of
https://github.com/mempool/mempool.git
synced 2025-03-29 19:23:45 +01:00
1166 lines
42 KiB
TypeScript
1166 lines
42 KiB
TypeScript
import * as bitcoinjs from 'bitcoinjs-lib';
|
|
import { Request } from 'express';
|
|
import { EffectiveFeeStats, MempoolBlockWithTransactions, TransactionExtended, MempoolTransactionExtended, TransactionStripped, WorkingEffectiveFeeStats, TransactionClassified, TransactionFlags } from '../mempool.interfaces';
|
|
import config from '../config';
|
|
import { NodeSocket } from '../repositories/NodesSocketsRepository';
|
|
import { isIP } from 'net';
|
|
import transactionUtils from './transaction-utils';
|
|
import { isPoint } from '../utils/secp256k1';
|
|
import logger from '../logger';
|
|
import { getVarIntLength, opcodes, parseMultisigScript } from '../utils/bitcoin-script';
|
|
|
|
// Bitcoin Core default policy settings
|
|
const MAX_STANDARD_TX_WEIGHT = 400_000;
|
|
const MAX_BLOCK_SIGOPS_COST = 80_000;
|
|
const MAX_STANDARD_TX_SIGOPS_COST = (MAX_BLOCK_SIGOPS_COST / 5);
|
|
const MIN_STANDARD_TX_NONWITNESS_SIZE = 65;
|
|
const MAX_P2SH_SIGOPS = 15;
|
|
const MAX_STANDARD_P2WSH_STACK_ITEMS = 100;
|
|
const MAX_STANDARD_P2WSH_STACK_ITEM_SIZE = 80;
|
|
const MAX_STANDARD_TAPSCRIPT_STACK_ITEM_SIZE = 80;
|
|
const MAX_STANDARD_P2WSH_SCRIPT_SIZE = 3600;
|
|
const MAX_STANDARD_SCRIPTSIG_SIZE = 1650;
|
|
const DUST_RELAY_TX_FEE = 3;
|
|
const MAX_OP_RETURN_RELAY = 83;
|
|
const DEFAULT_PERMIT_BAREMULTISIG = true;
|
|
|
|
export class Common {
|
|
static nativeAssetId = config.MEMPOOL.NETWORK === 'liquidtestnet' ?
|
|
'144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49'
|
|
: '6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d';
|
|
static _isLiquid = config.MEMPOOL.NETWORK === 'liquid' || config.MEMPOOL.NETWORK === 'liquidtestnet';
|
|
|
|
static isLiquid(): boolean {
|
|
return this._isLiquid;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
static percentile(numbers: number[], percentile: number) {
|
|
if (percentile === 50) {
|
|
return this.median(numbers);
|
|
}
|
|
const index = Math.ceil(numbers.length * (100 - percentile) * 1e-2);
|
|
if (index < 0 || index > numbers.length - 1) {
|
|
return 0;
|
|
}
|
|
return numbers[index];
|
|
}
|
|
|
|
static getFeesInRange(transactions: TransactionExtended[], rangeLength: number) {
|
|
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];
|
|
const chunk = 1 / (rangeLength - 1);
|
|
let itemsToAdd = rangeLength - 2;
|
|
|
|
while (itemsToAdd > 0) {
|
|
arr.push(filtered[Math.floor(filtered.length * chunk * itemsToAdd)].effectiveFeePerVsize);
|
|
itemsToAdd--;
|
|
}
|
|
|
|
arr.push(filtered[0].effectiveFeePerVsize);
|
|
return arr;
|
|
}
|
|
|
|
static findRbfTransactions(added: MempoolTransactionExtended[], deleted: MempoolTransactionExtended[], forceScalable = false): { [txid: string]: { replaced: MempoolTransactionExtended[], replacedBy: TransactionExtended }} {
|
|
const matches: { [txid: string]: { replaced: MempoolTransactionExtended[], replacedBy: TransactionExtended }} = {};
|
|
|
|
// For small N, a naive nested loop is extremely fast, but it doesn't scale
|
|
if (added.length < 1000 && deleted.length < 50 && !forceScalable) {
|
|
added.forEach((addedTx) => {
|
|
const foundMatches = deleted.filter((deletedTx) => {
|
|
// 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.adjustedFeePerVsize > deletedTx.adjustedFeePerVsize
|
|
// Spends one or more of the same inputs
|
|
&& deletedTx.vin.some((deletedVin) =>
|
|
addedTx.vin.some((vin) => vin.txid === deletedVin.txid && vin.vout === deletedVin.vout));
|
|
});
|
|
if (foundMatches?.length) {
|
|
matches[addedTx.txid] = { replaced: [...new Set(foundMatches)], replacedBy: addedTx };
|
|
}
|
|
});
|
|
} else {
|
|
// for large N, build a lookup table of prevouts we can check in ~constant time
|
|
const deletedSpendMap: { [txid: string]: { [vout: number]: MempoolTransactionExtended } } = {};
|
|
for (const tx of deleted) {
|
|
for (const vin of tx.vin) {
|
|
if (!deletedSpendMap[vin.txid]) {
|
|
deletedSpendMap[vin.txid] = {};
|
|
}
|
|
deletedSpendMap[vin.txid][vin.vout] = tx;
|
|
}
|
|
}
|
|
|
|
for (const addedTx of added) {
|
|
const foundMatches = new Set<MempoolTransactionExtended>();
|
|
for (const vin of addedTx.vin) {
|
|
const deletedTx = deletedSpendMap[vin.txid]?.[vin.vout];
|
|
if (deletedTx && deletedTx.txid !== addedTx.txid
|
|
// The new tx must, absolutely speaking, pay at least as much fee as the replaced tx.
|
|
&& addedTx.fee > deletedTx.fee
|
|
// The new transaction must pay more fee per kB than the replaced tx.
|
|
&& addedTx.adjustedFeePerVsize > deletedTx.adjustedFeePerVsize
|
|
) {
|
|
foundMatches.add(deletedTx);
|
|
}
|
|
if (foundMatches.size) {
|
|
matches[addedTx.txid] = { replaced: [...foundMatches], replacedBy: addedTx };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return matches;
|
|
}
|
|
|
|
static findMinedRbfTransactions(minedTransactions: TransactionExtended[], spendMap: Map<string, MempoolTransactionExtended>): { [txid: string]: { replaced: MempoolTransactionExtended[], replacedBy: TransactionExtended }} {
|
|
const matches: { [txid: string]: { replaced: MempoolTransactionExtended[], replacedBy: TransactionExtended }} = {};
|
|
for (const tx of minedTransactions) {
|
|
const replaced: Set<MempoolTransactionExtended> = new Set();
|
|
for (let i = 0; i < tx.vin.length; i++) {
|
|
const vin = tx.vin[i];
|
|
const key = `${vin.txid}:${vin.vout}`;
|
|
const match = spendMap.get(key);
|
|
if (match && match.txid !== tx.txid) {
|
|
replaced.add(match);
|
|
// remove this tx from the spendMap
|
|
// prevents the same tx being replaced more than once
|
|
for (const replacedVin of match.vin) {
|
|
const replacedKey = `${replacedVin.txid}:${replacedVin.vout}`;
|
|
spendMap.delete(replacedKey);
|
|
}
|
|
}
|
|
spendMap.delete(key);
|
|
}
|
|
if (replaced.size) {
|
|
matches[tx.txid] = { replaced: Array.from(replaced), replacedBy: tx };
|
|
}
|
|
}
|
|
return matches;
|
|
}
|
|
|
|
static setSchnorrSighashFlags(flags: bigint, witness: string[]): bigint {
|
|
// no witness items
|
|
if (!witness?.length) {
|
|
return flags;
|
|
}
|
|
const hasAnnex = witness.length > 1 && witness[witness.length - 1].startsWith('50');
|
|
if (witness?.length === (hasAnnex ? 2 : 1)) {
|
|
// keypath spend, signature is the only witness item
|
|
if (witness[0].length === 130) {
|
|
flags |= this.setSighashFlags(flags, witness[0]);
|
|
} else {
|
|
flags |= TransactionFlags.sighash_default;
|
|
}
|
|
} else {
|
|
// scriptpath spend, all items except for the script, control block and annex could be signatures
|
|
for (let i = 0; i < witness.length - (hasAnnex ? 3 : 2); i++) {
|
|
// handle probable signatures
|
|
if (witness[i].length === 130) {
|
|
flags |= this.setSighashFlags(flags, witness[i]);
|
|
} else if (witness[i].length === 128) {
|
|
flags |= TransactionFlags.sighash_default;
|
|
}
|
|
}
|
|
}
|
|
return flags;
|
|
}
|
|
|
|
static isDERSig(w: string): boolean {
|
|
// heuristic to detect probable DER signatures
|
|
return (w.length >= 18
|
|
&& w.startsWith('30') // minimum DER signature length is 8 bytes + sighash flag (see https://mempool.space/testnet/tx/c6c232a36395fa338da458b86ff1327395a9afc28c5d2daa4273e410089fd433)
|
|
&& ['01', '02', '03', '81', '82', '83'].includes(w.slice(-2)) // signature must end with a valid sighash flag
|
|
&& (w.length === (2 * parseInt(w.slice(2, 4), 16)) + 6) // second byte encodes the combined length of the R and S components
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Validates most standardness rules
|
|
*
|
|
* returns true early if any standardness rule is violated, otherwise false
|
|
* (except for non-mandatory-script-verify-flag and p2sh script evaluation rules which are *not* enforced)
|
|
*
|
|
* As standardness rules change, we'll need to apply the rules in force *at the time* to older blocks.
|
|
* For now, just pull out individual rules into versioned functions where necessary.
|
|
*/
|
|
static isNonStandard(tx: TransactionExtended, height?: number): boolean {
|
|
// version
|
|
if (this.isNonStandardVersion(tx, height)) {
|
|
return true;
|
|
}
|
|
|
|
// tx-size
|
|
if (tx.weight > MAX_STANDARD_TX_WEIGHT) {
|
|
return true;
|
|
}
|
|
|
|
// tx-size-small
|
|
if (this.getNonWitnessSize(tx) < MIN_STANDARD_TX_NONWITNESS_SIZE) {
|
|
return true;
|
|
}
|
|
|
|
// bad-txns-too-many-sigops
|
|
if (tx.sigops && tx.sigops > MAX_STANDARD_TX_SIGOPS_COST) {
|
|
return true;
|
|
}
|
|
|
|
// input validation
|
|
for (const vin of tx.vin) {
|
|
if (vin.is_coinbase) {
|
|
// standardness rules don't apply to coinbase transactions
|
|
return false;
|
|
}
|
|
// scriptsig-size
|
|
if ((vin.scriptsig.length / 2) > MAX_STANDARD_SCRIPTSIG_SIZE) {
|
|
return true;
|
|
}
|
|
// scriptsig-not-pushonly
|
|
if (vin.scriptsig_asm) {
|
|
for (const op of vin.scriptsig_asm.split(' ')) {
|
|
if (opcodes[op] && opcodes[op] > opcodes['OP_16']) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
// bad-txns-nonstandard-inputs
|
|
if (vin.prevout?.scriptpubkey_type === 'p2sh') {
|
|
// TODO: evaluate script (https://github.com/bitcoin/bitcoin/blob/1ac627c485a43e50a9a49baddce186ee3ad4daad/src/policy/policy.cpp#L177)
|
|
// countScriptSigops returns the witness-scaled sigops, so divide by 4 before comparison with MAX_P2SH_SIGOPS
|
|
const sigops = (transactionUtils.countScriptSigops(vin.inner_redeemscript_asm) / 4);
|
|
if (sigops > MAX_P2SH_SIGOPS) {
|
|
return true;
|
|
}
|
|
} else if (['unknown', 'provably_unspendable', 'empty'].includes(vin.prevout?.scriptpubkey_type || '')) {
|
|
return true;
|
|
} else if (this.isNonStandardAnchor(tx, height)) {
|
|
return true;
|
|
}
|
|
// TODO: bad-witness-nonstandard
|
|
}
|
|
|
|
// output validation
|
|
let opreturnCount = 0;
|
|
for (const vout of tx.vout) {
|
|
// scriptpubkey
|
|
if (['nonstandard', 'provably_unspendable', 'empty'].includes(vout.scriptpubkey_type)) {
|
|
// (non-standard output type)
|
|
return true;
|
|
} else if (vout.scriptpubkey_type === 'unknown') {
|
|
// undefined segwit version/length combinations are actually standard in outputs
|
|
// https://github.com/bitcoin/bitcoin/blob/2c79abc7ad4850e9e3ba32a04c530155cda7f980/src/script/interpreter.cpp#L1950-L1951
|
|
if (vout.scriptpubkey.startsWith('00') || !this.isWitnessProgram(vout.scriptpubkey)) {
|
|
return true;
|
|
}
|
|
} else if (vout.scriptpubkey_type === 'multisig') {
|
|
if (!DEFAULT_PERMIT_BAREMULTISIG) {
|
|
// bare-multisig
|
|
return true;
|
|
}
|
|
const mOfN = parseMultisigScript(vout.scriptpubkey_asm);
|
|
if (!mOfN || mOfN.n < 1 || mOfN.n > 3 || mOfN.m < 1 || mOfN.m > mOfN.n) {
|
|
// (non-standard bare multisig threshold)
|
|
return true;
|
|
}
|
|
} else if (vout.scriptpubkey_type === 'op_return') {
|
|
opreturnCount++;
|
|
if ((vout.scriptpubkey.length / 2) > MAX_OP_RETURN_RELAY) {
|
|
// over default datacarrier limit
|
|
return true;
|
|
}
|
|
}
|
|
// dust
|
|
// (we could probably hardcode this for the different output types...)
|
|
if (vout.scriptpubkey_type !== 'op_return') {
|
|
let dustSize = (vout.scriptpubkey.length / 2);
|
|
// add varint length overhead
|
|
dustSize += getVarIntLength(dustSize);
|
|
// add value size
|
|
dustSize += 8;
|
|
if (Common.isWitnessProgram(vout.scriptpubkey)) {
|
|
dustSize += 67;
|
|
} else {
|
|
dustSize += 148;
|
|
}
|
|
if (vout.value < (dustSize * DUST_RELAY_TX_FEE)) {
|
|
// under minimum output size
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// multi-op-return
|
|
if (opreturnCount > 1) {
|
|
return true;
|
|
}
|
|
|
|
// TODO: non-mandatory-script-verify-flag
|
|
|
|
return false;
|
|
}
|
|
|
|
// A witness program is any valid scriptpubkey that consists of a 1-byte push opcode
|
|
// followed by a data push between 2 and 40 bytes.
|
|
// https://github.com/bitcoin/bitcoin/blob/2c79abc7ad4850e9e3ba32a04c530155cda7f980/src/script/script.cpp#L224-L240
|
|
static isWitnessProgram(scriptpubkey: string): false | { version: number, program: string } {
|
|
if (scriptpubkey.length < 8 || scriptpubkey.length > 84) {
|
|
return false;
|
|
}
|
|
const version = parseInt(scriptpubkey.slice(0,2), 16);
|
|
if (version !== 0 && version < 0x51 || version > 0x60) {
|
|
return false;
|
|
}
|
|
const push = parseInt(scriptpubkey.slice(2,4), 16);
|
|
if (push + 2 === (scriptpubkey.length / 2)) {
|
|
return {
|
|
version: version ? version - 0x50 : 0,
|
|
program: scriptpubkey.slice(4),
|
|
};
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Individual versioned standardness rules
|
|
|
|
static V3_STANDARDNESS_ACTIVATION_HEIGHT = {
|
|
'testnet4': 42_000,
|
|
'testnet': 2_900_000,
|
|
'signet': 211_000,
|
|
'': 863_500,
|
|
};
|
|
static isNonStandardVersion(tx: TransactionExtended, height?: number): boolean {
|
|
let TX_MAX_STANDARD_VERSION = 3;
|
|
if (
|
|
height != null
|
|
&& this.V3_STANDARDNESS_ACTIVATION_HEIGHT[config.MEMPOOL.NETWORK]
|
|
&& height <= this.V3_STANDARDNESS_ACTIVATION_HEIGHT[config.MEMPOOL.NETWORK]
|
|
) {
|
|
// V3 transactions were non-standard to spend before v28.x (scheduled for 2024/09/30 https://github.com/bitcoin/bitcoin/issues/29891)
|
|
TX_MAX_STANDARD_VERSION = 2;
|
|
}
|
|
|
|
if (tx.version > TX_MAX_STANDARD_VERSION) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static ANCHOR_STANDARDNESS_ACTIVATION_HEIGHT = {
|
|
'testnet4': 42_000,
|
|
'testnet': 2_900_000,
|
|
'signet': 211_000,
|
|
'': 863_500,
|
|
};
|
|
static isNonStandardAnchor(tx: TransactionExtended, height?: number): boolean {
|
|
if (
|
|
height != null
|
|
&& this.ANCHOR_STANDARDNESS_ACTIVATION_HEIGHT[config.MEMPOOL.NETWORK]
|
|
&& height <= this.ANCHOR_STANDARDNESS_ACTIVATION_HEIGHT[config.MEMPOOL.NETWORK]
|
|
) {
|
|
// anchor outputs were non-standard to spend before v28.x (scheduled for 2024/09/30 https://github.com/bitcoin/bitcoin/issues/29891)
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static getNonWitnessSize(tx: TransactionExtended): number {
|
|
let weight = tx.weight;
|
|
let hasWitness = false;
|
|
for (const vin of tx.vin) {
|
|
if (vin.witness?.length) {
|
|
hasWitness = true;
|
|
// witness count
|
|
weight -= getVarIntLength(vin.witness.length);
|
|
for (const witness of vin.witness) {
|
|
// witness item size + content
|
|
weight -= getVarIntLength(witness.length / 2) + (witness.length / 2);
|
|
}
|
|
}
|
|
}
|
|
if (hasWitness) {
|
|
// marker & segwit flag
|
|
weight -= 2;
|
|
}
|
|
return Math.ceil(weight / 4);
|
|
}
|
|
|
|
static setSegwitSighashFlags(flags: bigint, witness: string[]): bigint {
|
|
for (const w of witness) {
|
|
if (this.isDERSig(w)) {
|
|
flags |= this.setSighashFlags(flags, w);
|
|
}
|
|
}
|
|
return flags;
|
|
}
|
|
|
|
static setLegacySighashFlags(flags: bigint, scriptsig_asm: string): bigint {
|
|
for (const item of scriptsig_asm.split(' ')) {
|
|
// skip op_codes
|
|
if (item.startsWith('OP_')) {
|
|
continue;
|
|
}
|
|
// check pushed data
|
|
if (this.isDERSig(item)) {
|
|
flags |= this.setSighashFlags(flags, item);
|
|
}
|
|
}
|
|
return flags;
|
|
}
|
|
|
|
static setSighashFlags(flags: bigint, signature: string): bigint {
|
|
switch(signature.slice(-2)) {
|
|
case '01': return flags | TransactionFlags.sighash_all;
|
|
case '02': return flags | TransactionFlags.sighash_none;
|
|
case '03': return flags | TransactionFlags.sighash_single;
|
|
case '81': return flags | TransactionFlags.sighash_all | TransactionFlags.sighash_acp;
|
|
case '82': return flags | TransactionFlags.sighash_none | TransactionFlags.sighash_acp;
|
|
case '83': return flags | TransactionFlags.sighash_single | TransactionFlags.sighash_acp;
|
|
default: return flags | TransactionFlags.sighash_default; // taproot only
|
|
}
|
|
}
|
|
|
|
static isBurnKey(pubkey: string): boolean {
|
|
return [
|
|
'022222222222222222222222222222222222222222222222222222222222222222',
|
|
'033333333333333333333333333333333333333333333333333333333333333333',
|
|
'020202020202020202020202020202020202020202020202020202020202020202',
|
|
'030303030303030303030303030303030303030303030303030303030303030303',
|
|
].includes(pubkey);
|
|
}
|
|
|
|
static isInscription(vin, flags): bigint {
|
|
// in taproot, if the last witness item begins with 0x50, it's an annex
|
|
const hasAnnex = vin.witness?.[vin.witness.length - 1].startsWith('50');
|
|
// script spends have more than one witness item, not counting the annex (if present)
|
|
if (vin.witness.length > (hasAnnex ? 2 : 1)) {
|
|
// the script itself is the second-to-last witness item, not counting the annex
|
|
const asm = vin.inner_witnessscript_asm || transactionUtils.convertScriptSigAsm(vin.witness[vin.witness.length - (hasAnnex ? 3 : 2)]);
|
|
// inscriptions smuggle data within an 'OP_0 OP_IF ... OP_ENDIF' envelope
|
|
if (asm?.includes('OP_0 OP_IF')) {
|
|
flags |= TransactionFlags.inscription;
|
|
}
|
|
}
|
|
return flags;
|
|
}
|
|
|
|
static getTransactionFlags(tx: TransactionExtended, height?: number): number {
|
|
let flags = tx.flags ? BigInt(tx.flags) : 0n;
|
|
|
|
// Update variable flags (CPFP, RBF)
|
|
flags &= ~TransactionFlags.cpfp_child;
|
|
if (tx.ancestors?.length) {
|
|
flags |= TransactionFlags.cpfp_child;
|
|
}
|
|
flags &= ~TransactionFlags.cpfp_parent;
|
|
if (tx.descendants?.length) {
|
|
flags |= TransactionFlags.cpfp_parent;
|
|
}
|
|
flags &= ~TransactionFlags.replacement;
|
|
if (tx.replacement) {
|
|
flags |= TransactionFlags.replacement;
|
|
}
|
|
|
|
// Already processed static flags, no need to do it again
|
|
if (tx.flags) {
|
|
return Number(flags);
|
|
}
|
|
|
|
// Process static flags
|
|
if (tx.version === 1) {
|
|
flags |= TransactionFlags.v1;
|
|
} else if (tx.version === 2) {
|
|
flags |= TransactionFlags.v2;
|
|
} else if (tx.version === 3) {
|
|
flags |= TransactionFlags.v3;
|
|
}
|
|
const reusedInputAddresses: { [address: string ]: number } = {};
|
|
const reusedOutputAddresses: { [address: string ]: number } = {};
|
|
const inValues = {};
|
|
const outValues = {};
|
|
let rbf = false;
|
|
for (const vin of tx.vin) {
|
|
if (vin.sequence < 0xfffffffe) {
|
|
rbf = true;
|
|
}
|
|
if (vin.prevout?.scriptpubkey_type) {
|
|
switch (vin.prevout?.scriptpubkey_type) {
|
|
case 'p2pk': flags |= TransactionFlags.p2pk; break;
|
|
case 'multisig': flags |= TransactionFlags.p2ms; break;
|
|
case 'p2pkh': flags |= TransactionFlags.p2pkh; break;
|
|
case 'p2sh': flags |= TransactionFlags.p2sh; break;
|
|
case 'v0_p2wpkh': flags |= TransactionFlags.p2wpkh; break;
|
|
case 'v0_p2wsh': flags |= TransactionFlags.p2wsh; break;
|
|
case 'v1_p2tr': {
|
|
flags |= TransactionFlags.p2tr;
|
|
if (vin.witness?.length) {
|
|
flags = Common.isInscription(vin, flags);
|
|
}
|
|
} break;
|
|
}
|
|
} else {
|
|
// no prevouts, optimistically check witness-bearing inputs
|
|
if (vin.witness?.length >= 2) {
|
|
try {
|
|
flags = Common.isInscription(vin, flags);
|
|
} catch {
|
|
// witness script parsing will fail if this isn't really a taproot output
|
|
}
|
|
}
|
|
}
|
|
|
|
// sighash flags
|
|
if (vin.prevout?.scriptpubkey_type === 'v1_p2tr') {
|
|
flags |= this.setSchnorrSighashFlags(flags, vin.witness);
|
|
} else if (vin.witness) {
|
|
flags |= this.setSegwitSighashFlags(flags, vin.witness);
|
|
} else if (vin.scriptsig?.length) {
|
|
flags |= this.setLegacySighashFlags(flags, vin.scriptsig_asm || transactionUtils.convertScriptSigAsm(vin.scriptsig));
|
|
}
|
|
|
|
if (vin.prevout?.scriptpubkey_address) {
|
|
reusedInputAddresses[vin.prevout?.scriptpubkey_address] = (reusedInputAddresses[vin.prevout?.scriptpubkey_address] || 0) + 1;
|
|
}
|
|
inValues[vin.prevout?.value || Math.random()] = (inValues[vin.prevout?.value || Math.random()] || 0) + 1;
|
|
}
|
|
if (rbf) {
|
|
flags |= TransactionFlags.rbf;
|
|
} else {
|
|
flags |= TransactionFlags.no_rbf;
|
|
}
|
|
let hasFakePubkey = false;
|
|
let P2WSHCount = 0;
|
|
let olgaSize = 0;
|
|
for (const vout of tx.vout) {
|
|
switch (vout.scriptpubkey_type) {
|
|
case 'p2pk': {
|
|
flags |= TransactionFlags.p2pk;
|
|
// detect fake pubkey (i.e. not a valid DER point on the secp256k1 curve)
|
|
hasFakePubkey = hasFakePubkey || !isPoint(vout.scriptpubkey?.slice(2, -2));
|
|
} break;
|
|
case 'multisig': {
|
|
flags |= TransactionFlags.p2ms;
|
|
// detect fake pubkeys (i.e. not valid DER points on the secp256k1 curve)
|
|
const asm = vout.scriptpubkey_asm || transactionUtils.convertScriptSigAsm(vout.scriptpubkey);
|
|
for (const key of (asm?.split(' ') || [])) {
|
|
if (!hasFakePubkey && !key.startsWith('OP_')) {
|
|
hasFakePubkey = hasFakePubkey || this.isBurnKey(key) || !isPoint(key);
|
|
}
|
|
}
|
|
} break;
|
|
case 'p2pkh': flags |= TransactionFlags.p2pkh; break;
|
|
case 'p2sh': flags |= TransactionFlags.p2sh; break;
|
|
case 'v0_p2wpkh': flags |= TransactionFlags.p2wpkh; break;
|
|
case 'v0_p2wsh': flags |= TransactionFlags.p2wsh; break;
|
|
case 'v1_p2tr': flags |= TransactionFlags.p2tr; break;
|
|
case 'op_return': flags |= TransactionFlags.op_return; break;
|
|
}
|
|
if (vout.scriptpubkey_address) {
|
|
reusedOutputAddresses[vout.scriptpubkey_address] = (reusedOutputAddresses[vout.scriptpubkey_address] || 0) + 1;
|
|
}
|
|
if (vout.scriptpubkey_type === 'v0_p2wsh') {
|
|
if (!P2WSHCount) {
|
|
olgaSize = parseInt(vout.scriptpubkey.slice(4, 8), 16);
|
|
}
|
|
P2WSHCount++;
|
|
if (P2WSHCount === Math.ceil((olgaSize + 2) / 32)) {
|
|
const nullBytes = (P2WSHCount * 32) - olgaSize - 2;
|
|
if (vout.scriptpubkey.endsWith(''.padEnd(nullBytes * 2, '0'))) {
|
|
flags |= TransactionFlags.fake_scripthash;
|
|
}
|
|
}
|
|
} else {
|
|
P2WSHCount = 0;
|
|
}
|
|
outValues[vout.value || Math.random()] = (outValues[vout.value || Math.random()] || 0) + 1;
|
|
}
|
|
if (hasFakePubkey) {
|
|
flags |= TransactionFlags.fake_pubkey;
|
|
}
|
|
|
|
// fast but bad heuristic to detect possible coinjoins
|
|
// (at least 5 inputs and 5 outputs, less than half of which are unique amounts, with no address reuse)
|
|
const addressReuse = Object.keys(reusedOutputAddresses).reduce((acc, key) => Math.max(acc, (reusedInputAddresses[key] || 0) + (reusedOutputAddresses[key] || 0)), 0) > 1;
|
|
if (!addressReuse && tx.vin.length >= 5 && tx.vout.length >= 5 && (Object.keys(inValues).length + Object.keys(outValues).length) <= (tx.vin.length + tx.vout.length) / 2 ) {
|
|
flags |= TransactionFlags.coinjoin;
|
|
}
|
|
// more than 5:1 input:output ratio
|
|
if (tx.vin.length / tx.vout.length >= 5) {
|
|
flags |= TransactionFlags.consolidation;
|
|
}
|
|
// less than 1:5 input:output ratio
|
|
if (tx.vin.length / tx.vout.length <= 0.2) {
|
|
flags |= TransactionFlags.batch_payout;
|
|
}
|
|
|
|
if (this.isNonStandard(tx, height)) {
|
|
flags |= TransactionFlags.nonstandard;
|
|
}
|
|
|
|
return Number(flags);
|
|
}
|
|
|
|
static classifyTransaction(tx: TransactionExtended, height?: number): TransactionClassified {
|
|
let flags = 0;
|
|
try {
|
|
flags = Common.getTransactionFlags(tx, height);
|
|
} catch (e) {
|
|
logger.warn('Failed to add classification flags to transaction: ' + (e instanceof Error ? e.message : e));
|
|
}
|
|
tx.flags = flags;
|
|
return {
|
|
...Common.stripTransaction(tx),
|
|
flags,
|
|
};
|
|
}
|
|
|
|
static classifyTransactions(txs: TransactionExtended[], height?: number): TransactionClassified[] {
|
|
return txs.map(tx => Common.classifyTransaction(tx, height));
|
|
}
|
|
|
|
static stripTransaction(tx: TransactionExtended): TransactionStripped {
|
|
return {
|
|
txid: tx.txid,
|
|
fee: tx.fee || 0,
|
|
vsize: tx.weight / 4,
|
|
value: tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0),
|
|
acc: tx.acceleration || undefined,
|
|
rate: tx.effectiveFeePerVsize,
|
|
time: tx.firstSeen || undefined,
|
|
};
|
|
}
|
|
|
|
static stripTransactions(txs: TransactionExtended[]): TransactionStripped[] {
|
|
return txs.map(Common.stripTransaction);
|
|
}
|
|
|
|
static sleep$(ms: number): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
setTimeout(() => {
|
|
resolve();
|
|
}, ms);
|
|
});
|
|
}
|
|
|
|
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]];
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
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';
|
|
case '4y': return '4 YEAR';
|
|
default: return null;
|
|
}
|
|
}
|
|
|
|
static indexingEnabled(): boolean {
|
|
return (
|
|
['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) &&
|
|
config.DATABASE.ENABLED === true &&
|
|
config.MEMPOOL.INDEXING_BLOCKS_AMOUNT !== 0
|
|
);
|
|
}
|
|
|
|
static blocksSummariesIndexingEnabled(): boolean {
|
|
return (
|
|
Common.indexingEnabled() &&
|
|
config.MEMPOOL.BLOCKS_SUMMARIES_INDEXING === true
|
|
);
|
|
}
|
|
|
|
static gogglesIndexingEnabled(): boolean {
|
|
return (
|
|
Common.blocksSummariesIndexingEnabled() &&
|
|
config.MEMPOOL.GOGGLES_INDEXING === true
|
|
);
|
|
}
|
|
|
|
static cpfpIndexingEnabled(): boolean {
|
|
return (
|
|
Common.indexingEnabled() &&
|
|
config.MEMPOOL.CPFP_INDEXING === true
|
|
);
|
|
}
|
|
|
|
static setDateMidnight(date: Date): void {
|
|
date.setUTCHours(0);
|
|
date.setUTCMinutes(0);
|
|
date.setUTCSeconds(0);
|
|
date.setUTCMilliseconds(0);
|
|
}
|
|
|
|
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);
|
|
}
|
|
const s = channelId.split('x').map(part => BigInt(part));
|
|
return ((s[0] << 40n) | (s[1] << 16n) | s[2]).toString();
|
|
}
|
|
|
|
/** Decodes a channel id returned by lnd as uint64 to a short channel id */
|
|
static channelIntegerIdToShortId(id: string): string {
|
|
if (id.indexOf('/') !== -1) {
|
|
id = id.slice(0, -2);
|
|
}
|
|
|
|
if (id.indexOf('x') !== -1) { // Already a short id
|
|
return id;
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
static utcDateToMysql(date?: number | null): string | null {
|
|
if (date === null) {
|
|
return null;
|
|
}
|
|
const d = new Date((date || 0) * 1000);
|
|
return d.toISOString().split('T')[0] + ' ' + d.toTimeString().split(' ')[0];
|
|
}
|
|
|
|
static findSocketNetwork(addr: string): {network: string | null, url: string} {
|
|
let network: string | null = null;
|
|
let url: string = addr;
|
|
|
|
if (config.LIGHTNING.BACKEND === 'cln') {
|
|
url = addr.split('://')[1];
|
|
}
|
|
|
|
if (!url) {
|
|
return {
|
|
network: null,
|
|
url: addr,
|
|
};
|
|
}
|
|
|
|
if (addr.indexOf('onion') !== -1) {
|
|
if (url.split('.')[0].length >= 56) {
|
|
network = 'torv3';
|
|
} else {
|
|
network = 'torv2';
|
|
}
|
|
} else if (addr.indexOf('i2p') !== -1) {
|
|
network = 'i2p';
|
|
} else if (addr.indexOf('ipv4') !== -1 || (config.LIGHTNING.BACKEND === 'lnd' && isIP(url.split(':')[0]) === 4)) {
|
|
const ipv = isIP(url.split(':')[0]);
|
|
if (ipv === 4) {
|
|
network = 'ipv4';
|
|
} else {
|
|
return {
|
|
network: null,
|
|
url: addr,
|
|
};
|
|
}
|
|
} else if (addr.indexOf('ipv6') !== -1 || (config.LIGHTNING.BACKEND === 'lnd' && url.indexOf(']:'))) {
|
|
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,
|
|
};
|
|
}
|
|
|
|
return {
|
|
network: network,
|
|
url: url,
|
|
};
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
}
|
|
|
|
static calcEffectiveFeeStatistics(transactions: { weight: number, fee: number, effectiveFeePerVsize?: number, txid: string, acceleration?: boolean }[]): 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 0.25% weight units of transactions
|
|
const halfWidth = config.MEMPOOL.BLOCK_WEIGHT_UNITS / 800;
|
|
const leftBound = Math.floor((config.MEMPOOL.BLOCK_WEIGHT_UNITS / 2) - halfWidth);
|
|
const rightBound = Math.ceil((config.MEMPOOL.BLOCK_WEIGHT_UNITS / 2) + halfWidth);
|
|
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))];
|
|
}
|
|
|
|
static getTransactionFromRequest(req: Request, form: boolean): string {
|
|
let rawTx: any = typeof req.body === 'object' && form
|
|
? Object.values(req.body)[0] as any
|
|
: req.body;
|
|
if (typeof rawTx !== 'string') {
|
|
throw Object.assign(new Error('Non-string request body'), { code: -1 });
|
|
}
|
|
|
|
// Support both upper and lower case hex
|
|
// Support both txHash= Form and direct API POST
|
|
const reg = form ? /^txHash=((?:[a-fA-F0-9]{2})+)$/ : /^((?:[a-fA-F0-9]{2})+)$/;
|
|
const matches = reg.exec(rawTx);
|
|
if (!matches || !matches[1]) {
|
|
throw Object.assign(new Error('Non-hex request body'), { code: -2 });
|
|
}
|
|
|
|
// Guaranteed to be a hex string of multiple of 2
|
|
// Guaranteed to be lower case
|
|
// Guaranteed to pass validation (see function below)
|
|
return this.validateTransactionHex(matches[1].toLowerCase());
|
|
}
|
|
|
|
static getTransactionsFromRequest(req: Request, limit: number = 25): string[] {
|
|
if (!Array.isArray(req.body) || req.body.some(hex => typeof hex !== 'string')) {
|
|
throw Object.assign(new Error('Invalid request body (should be an array of hexadecimal strings)'), { code: -1 });
|
|
}
|
|
|
|
if (limit && req.body.length > limit) {
|
|
throw Object.assign(new Error('Exceeded maximum of 25 transactions'), { code: -1 });
|
|
}
|
|
|
|
const txs = req.body;
|
|
|
|
return txs.map(rawTx => {
|
|
// Support both upper and lower case hex
|
|
// Support both txHash= Form and direct API POST
|
|
const reg = /^((?:[a-fA-F0-9]{2})+)$/;
|
|
const matches = reg.exec(rawTx);
|
|
if (!matches || !matches[1]) {
|
|
throw Object.assign(new Error('Invalid hex string'), { code: -2 });
|
|
}
|
|
|
|
// Guaranteed to be a hex string of multiple of 2
|
|
// Guaranteed to be lower case
|
|
// Guaranteed to pass validation (see function below)
|
|
return this.validateTransactionHex(matches[1].toLowerCase());
|
|
});
|
|
}
|
|
|
|
private static validateTransactionHex(txhex: string): string {
|
|
// Do not mutate txhex
|
|
|
|
// We assume txhex to be valid hex (output of getTransactionFromRequest above)
|
|
|
|
// Check 1: Valid transaction parse
|
|
let tx: bitcoinjs.Transaction;
|
|
try {
|
|
tx = bitcoinjs.Transaction.fromHex(txhex);
|
|
} catch(e) {
|
|
throw Object.assign(new Error('Invalid transaction (could not parse)'), { code: -4 });
|
|
}
|
|
|
|
// Check 2: Simple size check
|
|
if (tx.weight() > config.MEMPOOL.MAX_PUSH_TX_SIZE_WEIGHT) {
|
|
throw Object.assign(new Error(`Transaction too large (max ${config.MEMPOOL.MAX_PUSH_TX_SIZE_WEIGHT} weight units)`), { code: -3 });
|
|
}
|
|
|
|
// Check 3: Check unreachable script in taproot (if not allowed)
|
|
if (!config.MEMPOOL.ALLOW_UNREACHABLE) {
|
|
tx.ins.forEach(input => {
|
|
const witness = input.witness;
|
|
// See BIP 341: Script validation rules
|
|
const hasAnnex = witness.length >= 2 &&
|
|
witness[witness.length - 1][0] === 0x50;
|
|
const scriptSpendMinLength = hasAnnex ? 3 : 2;
|
|
const maybeScriptSpend = witness.length >= scriptSpendMinLength;
|
|
|
|
if (maybeScriptSpend) {
|
|
const controlBlock = witness[witness.length - scriptSpendMinLength + 1];
|
|
if (controlBlock.length === 0 || !this.isValidLeafVersion(controlBlock[0])) {
|
|
// Skip this input, it's not taproot
|
|
return;
|
|
}
|
|
// Definitely taproot. Get script
|
|
const script = witness[witness.length - scriptSpendMinLength];
|
|
const decompiled = bitcoinjs.script.decompile(script);
|
|
if (!decompiled || decompiled.length < 2) {
|
|
// Skip this input
|
|
return;
|
|
}
|
|
// Iterate up to second last (will look ahead 1 item)
|
|
for (let i = 0; i < decompiled.length - 1; i++) {
|
|
const first = decompiled[i];
|
|
const second = decompiled[i + 1];
|
|
if (
|
|
first === bitcoinjs.opcodes.OP_FALSE &&
|
|
second === bitcoinjs.opcodes.OP_IF
|
|
) {
|
|
throw Object.assign(new Error('Unreachable taproot scripts not allowed'), { code: -5 });
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// Pass through the input string untouched
|
|
return txhex;
|
|
}
|
|
|
|
private static isValidLeafVersion(leafVersion: number): boolean {
|
|
// See Note 7 in BIP341
|
|
// https://github.com/bitcoin/bips/blob/66a1a8151021913047934ebab3f8883f2f8ca75b/bip-0341.mediawiki#cite_note-7
|
|
// "What constraints are there on the leaf version?"
|
|
|
|
// Must be an integer between 0 and 255
|
|
// Since we're parsing a byte
|
|
if (Math.floor(leafVersion) !== leafVersion || leafVersion < 0 || leafVersion > 255) {
|
|
return false;
|
|
}
|
|
// "the leaf version cannot be odd"
|
|
if ((leafVersion & 0x01) === 1) {
|
|
return false;
|
|
}
|
|
// "The values that comply to this rule are
|
|
// the 32 even values between 0xc0 and 0xfe
|
|
if (leafVersion >= 0xc0 && leafVersion <= 0xfe) {
|
|
return true;
|
|
}
|
|
// and also 0x66, 0x7e, 0x80, 0x84, 0x96, 0x98, 0xba, 0xbc, 0xbe."
|
|
if ([0x66, 0x7e, 0x80, 0x84, 0x96, 0x98, 0xba, 0xbc, 0xbe].includes(leafVersion)) {
|
|
return true;
|
|
}
|
|
// Otherwise, invalid
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|