Fix db version conflicts
This commit is contained in:
commit
847b90f167
1
.github/workflows/on-tag.yml
vendored
1
.github/workflows/on-tag.yml
vendored
@ -100,5 +100,6 @@ jobs:
|
||||
--cache-to "type=local,dest=/tmp/.buildx-cache" \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--tag ${{ secrets.DOCKER_HUB_USER }}/${{ matrix.service }}:$TAG \
|
||||
--tag ${{ secrets.DOCKER_HUB_USER }}/${{ matrix.service }}:latest \
|
||||
--output "type=registry" ./${{ matrix.service }}/ \
|
||||
--build-arg commitHash=$SHORT_SHA
|
||||
|
738
backend/src/api/acceleration.ts
Normal file
738
backend/src/api/acceleration.ts
Normal file
@ -0,0 +1,738 @@
|
||||
import logger from '../logger';
|
||||
import { MempoolTransactionExtended } from '../mempool.interfaces';
|
||||
import { IEsploraApi } from './bitcoin/esplora-api.interface';
|
||||
|
||||
const BLOCK_WEIGHT_UNITS = 4_000_000;
|
||||
const BLOCK_SIGOPS = 80_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;
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
interface GraphTx {
|
||||
txid: string;
|
||||
vsize: number;
|
||||
weight: number;
|
||||
fees: {
|
||||
base: number;
|
||||
};
|
||||
depends: string[];
|
||||
spentby: string[];
|
||||
}
|
||||
|
||||
interface MempoolTx extends GraphTx {
|
||||
ancestorcount: number;
|
||||
ancestorsize: number;
|
||||
fees: {
|
||||
base: number;
|
||||
ancestor: number;
|
||||
};
|
||||
|
||||
ancestors: Map<string, MempoolTx>,
|
||||
ancestorRate: number;
|
||||
individualRate: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
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: IEsploraApi.Transaction[]): 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) {
|
||||
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 = this.getSameBlockRelatives(tx, transactions);
|
||||
const relativesMap = this.initializeRelatives(allRelatives);
|
||||
const rootTx = relativesMap.get(tx.txid) as MempoolTx;
|
||||
|
||||
// Calculate cost to boost
|
||||
return this.calculateAccelerationAncestors(rootTx, relativesMap, targetFeeRate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a raw transaction, and builds a graph of same-block relatives,
|
||||
* and returns as a MempoolTx
|
||||
*
|
||||
* @param tx
|
||||
*/
|
||||
private getSameBlockRelatives(tx: MempoolTransactionExtended, transactions: MempoolTransactionExtended[]): Map<string, GraphTx> {
|
||||
const blockTxs = new Map<string, MempoolTransactionExtended>(); // map of txs in this block
|
||||
const spendMap = new Map<string, string>(); // map of outpoints to spending txids
|
||||
for (const tx of transactions) {
|
||||
blockTxs.set(tx.txid, tx);
|
||||
for (const vin of tx.vin) {
|
||||
spendMap.set(`${vin.txid}:${vin.vout}`, tx.txid);
|
||||
}
|
||||
}
|
||||
|
||||
const relatives: Map<string, GraphTx> = new Map();
|
||||
const stack: string[] = [tx.txid];
|
||||
|
||||
// build set of same-block ancestors
|
||||
while (stack.length > 0) {
|
||||
const nextTxid = stack.pop();
|
||||
const nextTx = nextTxid ? blockTxs.get(nextTxid) : null;
|
||||
if (!nextTx || relatives.has(nextTx.txid)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mempoolTx = this.convertToGraphTx(nextTx);
|
||||
|
||||
mempoolTx.fees.base = nextTx.fee || 0;
|
||||
mempoolTx.depends = nextTx.vin.map(vin => vin.txid).filter(inTxid => inTxid && blockTxs.has(inTxid)) as string[];
|
||||
mempoolTx.spentby = nextTx.vout.map((vout, index) => spendMap.get(`${nextTx.txid}:${index}`)).filter(outTxid => outTxid && blockTxs.has(outTxid)) as string[];
|
||||
|
||||
for (const txid of [...mempoolTx.depends, ...mempoolTx.spentby]) {
|
||||
if (txid) {
|
||||
stack.push(txid);
|
||||
}
|
||||
}
|
||||
|
||||
relatives.set(mempoolTx.txid, mempoolTx);
|
||||
}
|
||||
|
||||
return relatives;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a raw transaction and converts it to MempoolTx format
|
||||
* fee and ancestor data is initialized with dummy/null values
|
||||
*
|
||||
* @param tx
|
||||
*/
|
||||
private convertToGraphTx(tx: MempoolTransactionExtended): GraphTx {
|
||||
return {
|
||||
txid: tx.txid,
|
||||
vsize: tx.vsize,
|
||||
weight: tx.weight,
|
||||
fees: {
|
||||
base: 0, // dummy
|
||||
},
|
||||
depends: [], // dummy
|
||||
spentby: [], //dummy
|
||||
};
|
||||
}
|
||||
|
||||
private convertGraphToMempoolTx(tx: GraphTx): MempoolTx {
|
||||
return {
|
||||
...tx,
|
||||
fees: {
|
||||
base: tx.fees.base,
|
||||
ancestor: tx.fees.base,
|
||||
},
|
||||
ancestorcount: 1,
|
||||
ancestorsize: tx.vsize,
|
||||
ancestors: new Map<string, MempoolTx>(),
|
||||
ancestorRate: 0,
|
||||
individualRate: 0,
|
||||
score: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: MempoolTx, relatives: Map<string, MempoolTx>, 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 => this.setAncestorScores(entry));
|
||||
|
||||
// Sort by descending ancestor score
|
||||
let sortedRelatives = Array.from(relatives.values()).sort(this.mempoolComparator);
|
||||
|
||||
let includedInCluster: Map<string, MempoolTx> | 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<string, MempoolTx>(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
|
||||
this.removeAncestors(cluster, relatives);
|
||||
|
||||
// re-sort
|
||||
sortedRelatives = Array.from(relatives.values()).sort(this.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 = Math.round(tx.fees.ancestor * 100_000_000);
|
||||
|
||||
// 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 * 100_000_000);
|
||||
});
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively traverses an in-mempool dependency graph, and sets a Map of in-mempool ancestors
|
||||
* for each transaction.
|
||||
*
|
||||
* @param tx
|
||||
* @param all
|
||||
*/
|
||||
private setAncestors(tx: MempoolTx, all: Map<string, MempoolTx>, visited: Map<string, Map<string, MempoolTx>>, depth: number = 0): Map<string, MempoolTx> {
|
||||
// sanity check for infinite recursion / too many ancestors (should never happen)
|
||||
if (depth >= 100) {
|
||||
logger.warn('acceleration dependency calculation failed: setAncestors reached depth of 100, unable to proceed', `Accelerator`);
|
||||
throw new Error('invalid_tx_dependencies');
|
||||
}
|
||||
|
||||
// initialize the ancestor map for this tx
|
||||
tx.ancestors = new Map<string, MempoolTx>();
|
||||
tx.depends.forEach(parentId => {
|
||||
const parent = all.get(parentId);
|
||||
if (parent) {
|
||||
// add the parent
|
||||
tx.ancestors?.set(parentId, parent);
|
||||
// check for a cached copy of this parent's ancestors
|
||||
let ancestors = visited.get(parent.txid);
|
||||
if (!ancestors) {
|
||||
// recursively fetch the parent's ancestors
|
||||
ancestors = this.setAncestors(parent, all, visited, depth + 1);
|
||||
}
|
||||
// and add to this tx's map
|
||||
ancestors.forEach((ancestor, ancestorId) => {
|
||||
tx.ancestors?.set(ancestorId, ancestor);
|
||||
});
|
||||
}
|
||||
});
|
||||
visited.set(tx.txid, tx.ancestors);
|
||||
|
||||
return tx.ancestors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Efficiently sets a Map of in-mempool ancestors for each member of an expanded relative graph
|
||||
* by running setAncestors on each leaf, and caching intermediate results.
|
||||
* then initializes ancestor data for each transaction
|
||||
*
|
||||
* @param all
|
||||
*/
|
||||
private initializeRelatives(all: Map<string, GraphTx>): Map<string, MempoolTx> {
|
||||
const mempoolTxs = new Map<string, MempoolTx>();
|
||||
all.forEach(entry => {
|
||||
mempoolTxs.set(entry.txid, this.convertGraphToMempoolTx(entry));
|
||||
});
|
||||
const visited: Map<string, Map<string, MempoolTx>> = new Map();
|
||||
const leaves: MempoolTx[] = Array.from(mempoolTxs.values()).filter(entry => entry.spentby.length === 0);
|
||||
for (const leaf of leaves) {
|
||||
this.setAncestors(leaf, mempoolTxs, visited);
|
||||
}
|
||||
mempoolTxs.forEach(entry => {
|
||||
entry.ancestors?.forEach(ancestor => {
|
||||
entry.ancestorcount++;
|
||||
entry.ancestorsize += ancestor.vsize;
|
||||
entry.fees.ancestor += ancestor.fees.base;
|
||||
});
|
||||
this.setAncestorScores(entry);
|
||||
});
|
||||
return mempoolTxs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a cluster of transactions from an in-mempool dependency graph
|
||||
* and update the survivors' scores and ancestors
|
||||
*
|
||||
* @param cluster
|
||||
* @param ancestors
|
||||
*/
|
||||
private removeAncestors(cluster: Map<string, MempoolTx>, all: Map<string, MempoolTx>): void {
|
||||
// remove
|
||||
cluster.forEach(tx => {
|
||||
all.delete(tx.txid);
|
||||
});
|
||||
|
||||
// update survivors
|
||||
all.forEach(tx => {
|
||||
cluster.forEach(remove => {
|
||||
if (tx.ancestors?.has(remove.txid)) {
|
||||
// remove as dependency
|
||||
tx.ancestors.delete(remove.txid);
|
||||
tx.depends = tx.depends.filter(parent => parent !== remove.txid);
|
||||
// update ancestor sizes and fees
|
||||
tx.ancestorsize -= remove.vsize;
|
||||
tx.fees.ancestor -= remove.fees.base;
|
||||
}
|
||||
});
|
||||
// recalculate fee rates
|
||||
this.setAncestorScores(tx);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a mempool transaction, and set the fee rates and ancestor score
|
||||
*
|
||||
* @param tx
|
||||
*/
|
||||
private setAncestorScores(tx: MempoolTx): void {
|
||||
tx.individualRate = (tx.fees.base * 100_000_000) / tx.vsize;
|
||||
tx.ancestorRate = (tx.fees.ancestor * 100_000_000) / tx.ancestorsize;
|
||||
tx.score = Math.min(tx.individualRate, tx.ancestorRate);
|
||||
}
|
||||
|
||||
// Sort by descending score
|
||||
private mempoolComparator(a, b): number {
|
||||
return b.score - a.score;
|
||||
}
|
||||
}
|
||||
|
||||
export default new AccelerationCosts;
|
||||
|
||||
interface TemplateTransaction {
|
||||
txid: string;
|
||||
order: number;
|
||||
weight: number;
|
||||
adjustedVsize: number; // sigop-adjusted vsize, rounded up to the nearest integer
|
||||
sigops: number;
|
||||
fee: number;
|
||||
feeDelta: number;
|
||||
ancestors: string[];
|
||||
cluster: string[];
|
||||
effectiveFeePerVsize: number;
|
||||
}
|
||||
|
||||
interface MinerTransaction extends TemplateTransaction {
|
||||
inputs: string[];
|
||||
feePerVsize: number;
|
||||
relativesSet: boolean;
|
||||
ancestorMap: Map<string, MinerTransaction>;
|
||||
children: Set<MinerTransaction>;
|
||||
ancestorFee: number;
|
||||
ancestorVsize: number;
|
||||
ancestorSigops: number;
|
||||
score: number;
|
||||
used: boolean;
|
||||
modified: boolean;
|
||||
dependencyRate: number;
|
||||
}
|
||||
|
||||
/*
|
||||
* Build a block using an approximation of the transaction selection algorithm from Bitcoin Core
|
||||
* (see BlockAssembler in https://github.com/bitcoin/bitcoin/blob/master/src/node/miner.cpp)
|
||||
*/
|
||||
function makeBlockTemplate(candidates: IEsploraApi.Transaction[], accelerations: Acceleration[], maxBlocks: number = 8, weightLimit: number = BLOCK_WEIGHT_UNITS, sigopLimit: number = BLOCK_SIGOPS): TemplateTransaction[] {
|
||||
const auditPool: Map<string, MinerTransaction> = new Map();
|
||||
const mempoolArray: MinerTransaction[] = [];
|
||||
|
||||
candidates.forEach(tx => {
|
||||
// initializing everything up front helps V8 optimize property access later
|
||||
const adjustedVsize = Math.ceil(Math.max(tx.weight / 4, 5 * (tx.sigops || 0)));
|
||||
const feePerVsize = (tx.fee / adjustedVsize);
|
||||
auditPool.set(tx.txid, {
|
||||
txid: tx.txid,
|
||||
order: txidToOrdering(tx.txid),
|
||||
fee: tx.fee,
|
||||
feeDelta: 0,
|
||||
weight: tx.weight,
|
||||
adjustedVsize,
|
||||
feePerVsize: feePerVsize,
|
||||
effectiveFeePerVsize: feePerVsize,
|
||||
dependencyRate: feePerVsize,
|
||||
sigops: tx.sigops || 0,
|
||||
inputs: (tx.vin?.map(vin => vin.txid) || []) as string[],
|
||||
relativesSet: false,
|
||||
ancestors: [],
|
||||
cluster: [],
|
||||
ancestorMap: new Map<string, MinerTransaction>(),
|
||||
children: new Set<MinerTransaction>(),
|
||||
ancestorFee: 0,
|
||||
ancestorVsize: 0,
|
||||
ancestorSigops: 0,
|
||||
score: 0,
|
||||
used: false,
|
||||
modified: false,
|
||||
});
|
||||
mempoolArray.push(auditPool.get(tx.txid) as MinerTransaction);
|
||||
});
|
||||
|
||||
// set accelerated effective fee
|
||||
for (const acceleration of accelerations) {
|
||||
const tx = auditPool.get(acceleration.txid);
|
||||
if (tx) {
|
||||
tx.feeDelta = acceleration.max_bid;
|
||||
tx.feePerVsize = ((tx.fee + tx.feeDelta) / tx.adjustedVsize);
|
||||
tx.effectiveFeePerVsize = tx.feePerVsize;
|
||||
tx.dependencyRate = tx.feePerVsize;
|
||||
}
|
||||
}
|
||||
|
||||
// Build relatives graph & calculate ancestor scores
|
||||
for (const tx of mempoolArray) {
|
||||
if (!tx.relativesSet) {
|
||||
setRelatives(tx, auditPool);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by descending ancestor score
|
||||
mempoolArray.sort(priorityComparator);
|
||||
|
||||
// Build blocks by greedily choosing the highest feerate package
|
||||
// (i.e. the package rooted in the transaction with the best ancestor score)
|
||||
const blocks: number[][] = [];
|
||||
let blockWeight = 0;
|
||||
let blockSigops = 0;
|
||||
const transactions: MinerTransaction[] = [];
|
||||
let modified: MinerTransaction[] = [];
|
||||
const overflow: MinerTransaction[] = [];
|
||||
let failures = 0;
|
||||
while (mempoolArray.length || modified.length) {
|
||||
// skip invalid transactions
|
||||
while (mempoolArray[0].used || mempoolArray[0].modified) {
|
||||
mempoolArray.shift();
|
||||
}
|
||||
|
||||
// Select best next package
|
||||
let nextTx;
|
||||
const nextPoolTx = mempoolArray[0];
|
||||
const nextModifiedTx = modified[0];
|
||||
if (nextPoolTx && (!nextModifiedTx || (nextPoolTx.score || 0) > (nextModifiedTx.score || 0))) {
|
||||
nextTx = nextPoolTx;
|
||||
mempoolArray.shift();
|
||||
} else {
|
||||
modified.shift();
|
||||
if (nextModifiedTx) {
|
||||
nextTx = nextModifiedTx;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextTx && !nextTx?.used) {
|
||||
// Check if the package fits into this block
|
||||
if (blocks.length >= (maxBlocks - 1) || ((blockWeight + (4 * nextTx.ancestorVsize) < weightLimit) && (blockSigops + nextTx.ancestorSigops <= sigopLimit))) {
|
||||
const ancestors: MinerTransaction[] = Array.from(nextTx.ancestorMap.values());
|
||||
// sort ancestors by dependency graph (equivalent to sorting by ascending ancestor count)
|
||||
const sortedTxSet = [...ancestors.sort((a, b) => { return (a.ancestorMap.size || 0) - (b.ancestorMap.size || 0); }), nextTx];
|
||||
const clusterTxids = sortedTxSet.map(tx => tx.txid);
|
||||
const effectiveFeeRate = Math.min(nextTx.dependencyRate || Infinity, nextTx.ancestorFee / nextTx.ancestorVsize);
|
||||
const used: MinerTransaction[] = [];
|
||||
while (sortedTxSet.length) {
|
||||
const ancestor = sortedTxSet.pop();
|
||||
if (!ancestor) {
|
||||
continue;
|
||||
}
|
||||
ancestor.used = true;
|
||||
ancestor.usedBy = nextTx.txid;
|
||||
// update this tx with effective fee rate & relatives data
|
||||
if (ancestor.effectiveFeePerVsize !== effectiveFeeRate) {
|
||||
ancestor.effectiveFeePerVsize = effectiveFeeRate;
|
||||
}
|
||||
ancestor.cluster = clusterTxids;
|
||||
transactions.push(ancestor);
|
||||
blockWeight += ancestor.weight;
|
||||
blockSigops += ancestor.sigops;
|
||||
used.push(ancestor);
|
||||
}
|
||||
|
||||
// remove these as valid package ancestors for any descendants remaining in the mempool
|
||||
if (used.length) {
|
||||
used.forEach(tx => {
|
||||
modified = updateDescendants(tx, auditPool, modified, effectiveFeeRate);
|
||||
});
|
||||
}
|
||||
|
||||
failures = 0;
|
||||
} else {
|
||||
// hold this package in an overflow list while we check for smaller options
|
||||
overflow.push(nextTx);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
// this block is full
|
||||
const exceededPackageTries = failures > 1000 && blockWeight > (weightLimit - 4000);
|
||||
const queueEmpty = !mempoolArray.length && !modified.length;
|
||||
|
||||
if (exceededPackageTries || queueEmpty) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const tx of transactions) {
|
||||
tx.ancestors = Object.values(tx.ancestorMap);
|
||||
}
|
||||
|
||||
return transactions;
|
||||
}
|
||||
|
||||
// traverse in-mempool ancestors
|
||||
// recursion unavoidable, but should be limited to depth < 25 by mempool policy
|
||||
function setRelatives(
|
||||
tx: MinerTransaction,
|
||||
mempool: Map<string, MinerTransaction>,
|
||||
): void {
|
||||
for (const parent of tx.inputs) {
|
||||
const parentTx = mempool.get(parent);
|
||||
if (parentTx && !tx.ancestorMap?.has(parent)) {
|
||||
tx.ancestorMap.set(parent, parentTx);
|
||||
parentTx.children.add(tx);
|
||||
// visit each node only once
|
||||
if (!parentTx.relativesSet) {
|
||||
setRelatives(parentTx, mempool);
|
||||
}
|
||||
parentTx.ancestorMap.forEach((ancestor) => {
|
||||
tx.ancestorMap.set(ancestor.txid, ancestor);
|
||||
});
|
||||
}
|
||||
};
|
||||
tx.ancestorFee = (tx.fee + tx.feeDelta);
|
||||
tx.ancestorVsize = tx.adjustedVsize || 0;
|
||||
tx.ancestorSigops = tx.sigops || 0;
|
||||
tx.ancestorMap.forEach((ancestor) => {
|
||||
tx.ancestorFee += (ancestor.fee + ancestor.feeDelta);
|
||||
tx.ancestorVsize += ancestor.adjustedVsize;
|
||||
tx.ancestorSigops += ancestor.sigops;
|
||||
});
|
||||
tx.score = tx.ancestorFee / tx.ancestorVsize;
|
||||
tx.relativesSet = true;
|
||||
}
|
||||
|
||||
// iterate over remaining descendants, removing the root as a valid ancestor & updating the ancestor score
|
||||
// avoids recursion to limit call stack depth
|
||||
function updateDescendants(
|
||||
rootTx: MinerTransaction,
|
||||
mempool: Map<string, MinerTransaction>,
|
||||
modified: MinerTransaction[],
|
||||
clusterRate: number,
|
||||
): MinerTransaction[] {
|
||||
const descendantSet: Set<MinerTransaction> = new Set();
|
||||
// stack of nodes left to visit
|
||||
const descendants: MinerTransaction[] = [];
|
||||
let descendantTx: MinerTransaction | undefined;
|
||||
rootTx.children.forEach(childTx => {
|
||||
if (!descendantSet.has(childTx)) {
|
||||
descendants.push(childTx);
|
||||
descendantSet.add(childTx);
|
||||
}
|
||||
});
|
||||
while (descendants.length) {
|
||||
descendantTx = descendants.pop();
|
||||
if (descendantTx && descendantTx.ancestorMap && descendantTx.ancestorMap.has(rootTx.txid)) {
|
||||
// remove tx as ancestor
|
||||
descendantTx.ancestorMap.delete(rootTx.txid);
|
||||
descendantTx.ancestorFee -= (rootTx.fee + rootTx.feeDelta);
|
||||
descendantTx.ancestorVsize -= rootTx.adjustedVsize;
|
||||
descendantTx.ancestorSigops -= rootTx.sigops;
|
||||
descendantTx.score = descendantTx.ancestorFee / descendantTx.ancestorVsize;
|
||||
descendantTx.dependencyRate = descendantTx.dependencyRate ? Math.min(descendantTx.dependencyRate, clusterRate) : clusterRate;
|
||||
|
||||
if (!descendantTx.modified) {
|
||||
descendantTx.modified = true;
|
||||
modified.push(descendantTx);
|
||||
}
|
||||
|
||||
// add this node's children to the stack
|
||||
descendantTx.children.forEach(childTx => {
|
||||
// visit each node only once
|
||||
if (!descendantSet.has(childTx)) {
|
||||
descendants.push(childTx);
|
||||
descendantSet.add(childTx);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// return new, resorted modified list
|
||||
return modified.sort(priorityComparator);
|
||||
}
|
||||
|
||||
// Used to sort an array of MinerTransactions by descending ancestor score
|
||||
function priorityComparator(a: MinerTransaction, b: MinerTransaction): number {
|
||||
if (b.score === a.score) {
|
||||
// tie-break by txid for stability
|
||||
return a.order - b.order;
|
||||
} else {
|
||||
return b.score - a.score;
|
||||
}
|
||||
}
|
||||
|
||||
// returns the most significant 4 bytes of the txid as an integer
|
||||
function txidToOrdering(txid: string): number {
|
||||
return parseInt(
|
||||
txid.substring(62, 64) +
|
||||
txid.substring(60, 62) +
|
||||
txid.substring(58, 60) +
|
||||
txid.substring(56, 58),
|
||||
16
|
||||
);
|
||||
}
|
@ -7,6 +7,24 @@ 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 TX_MAX_STANDARD_VERSION = 2;
|
||||
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'
|
||||
@ -177,6 +195,141 @@ export class Common {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
static isNonStandard(tx: TransactionExtended): boolean {
|
||||
// version
|
||||
if (tx.version > TX_MAX_STANDARD_VERSION) {
|
||||
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;
|
||||
}
|
||||
// TODO: bad-witness-nonstandard
|
||||
}
|
||||
|
||||
// output validation
|
||||
let opreturnCount = 0;
|
||||
for (const vout of tx.vout) {
|
||||
// scriptpubkey
|
||||
if (['unknown', 'provably_unspendable', 'empty'].includes(vout.scriptpubkey_type)) {
|
||||
// (non-standard output type)
|
||||
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 (['v0_p2wpkh', 'v0_p2wsh', 'v1_p2tr'].includes(vout.scriptpubkey_type)) {
|
||||
dustSize += 67;
|
||||
} else {
|
||||
dustSize += 148;
|
||||
}
|
||||
if (vout.value < (dustSize * DUST_RELAY_TX_FEE)) {
|
||||
// under minimum output size
|
||||
console.log(`NON-STANDARD | dust | ${vout.value} | ${dustSize} ${dustSize * DUST_RELAY_TX_FEE} `, tx.txid);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// multi-op-return
|
||||
if (opreturnCount > 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// TODO: non-mandatory-script-verify-flag
|
||||
|
||||
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)) {
|
||||
@ -351,6 +504,10 @@ export class Common {
|
||||
flags |= TransactionFlags.batch_payout;
|
||||
}
|
||||
|
||||
if (this.isNonStandard(tx)) {
|
||||
flags |= TransactionFlags.nonstandard;
|
||||
}
|
||||
|
||||
return Number(flags);
|
||||
}
|
||||
|
||||
|
@ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository';
|
||||
import { RowDataPacket } from 'mysql2';
|
||||
|
||||
class DatabaseMigration {
|
||||
private static currentVersion = 69;
|
||||
private static currentVersion = 71;
|
||||
private queryTimeout = 3600_000;
|
||||
private statisticsAddedIndexed = false;
|
||||
private uniqueLogs: string[] = [];
|
||||
@ -581,7 +581,17 @@ class DatabaseMigration {
|
||||
await this.updateToSchemaVersion(68);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 69 && config.MEMPOOL.NETWORK === "liquid") {
|
||||
if (databaseSchemaVersion < 69 && config.MEMPOOL.NETWORK === 'mainnet') {
|
||||
await this.$executeQuery(this.getCreateAccelerationsTableQuery(), await this.$checkIfTableExists('accelerations'));
|
||||
await this.updateToSchemaVersion(69);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 70 && config.MEMPOOL.NETWORK === 'mainnet') {
|
||||
await this.$executeQuery('ALTER TABLE accelerations MODIFY COLUMN added DATETIME;');
|
||||
await this.updateToSchemaVersion(70);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 71 && config.MEMPOOL.NETWORK === 'liquid') {
|
||||
await this.$executeQuery('TRUNCATE TABLE elements_pegs');
|
||||
await this.$executeQuery('TRUNCATE TABLE federation_txos');
|
||||
await this.$executeQuery('SET FOREIGN_KEY_CHECKS = 0');
|
||||
@ -594,7 +604,7 @@ class DatabaseMigration {
|
||||
await this.$executeQuery('ALTER TABLE `federation_txos` ADD timelock INT NOT NULL DEFAULT 0');
|
||||
await this.$executeQuery('ALTER TABLE `federation_txos` ADD expiredAt INT NOT NULL DEFAULT 0');
|
||||
await this.$executeQuery('ALTER TABLE `federation_txos` ADD emergencyKey TINYINT NOT NULL DEFAULT 0');
|
||||
await this.updateToSchemaVersion(69);
|
||||
await this.updateToSchemaVersion(71);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1139,6 +1149,23 @@ class DatabaseMigration {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
}
|
||||
|
||||
private getCreateAccelerationsTableQuery(): string {
|
||||
return `CREATE TABLE IF NOT EXISTS accelerations (
|
||||
txid varchar(65) NOT NULL,
|
||||
added datetime NOT NULL,
|
||||
height int(10) NOT NULL,
|
||||
pool smallint unsigned NULL,
|
||||
effective_vsize int(10) NOT NULL,
|
||||
effective_fee bigint(20) unsigned NOT NULL,
|
||||
boost_rate float unsigned,
|
||||
boost_cost bigint(20) unsigned NOT NULL,
|
||||
PRIMARY KEY (txid),
|
||||
INDEX (added),
|
||||
INDEX (height),
|
||||
INDEX (pool)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
}
|
||||
|
||||
public async $blocksReindexingTruncate(): Promise<void> {
|
||||
logger.warn(`Truncating pools, blocks, hashrates and difficulty_adjustments tables for re-indexing (using '--reindex-blocks'). You can cancel this command within 5 seconds`);
|
||||
await Common.sleep$(5000);
|
||||
|
@ -8,6 +8,7 @@ import HashratesRepository from '../../repositories/HashratesRepository';
|
||||
import bitcoinClient from '../bitcoin/bitcoin-client';
|
||||
import mining from "./mining";
|
||||
import PricesRepository from '../../repositories/PricesRepository';
|
||||
import AccelerationRepository from '../../repositories/AccelerationRepository';
|
||||
|
||||
class MiningRoutes {
|
||||
public initRoutes(app: Application) {
|
||||
@ -34,6 +35,10 @@ class MiningRoutes {
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/audit/:hash', this.$getBlockAudit)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/timestamp/:timestamp', this.$getHeightFromTimestamp)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'historical-price', this.$getHistoricalPrice)
|
||||
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'accelerations/pool/:slug', this.$getAccelerationsByPool)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'accelerations/block/:height', this.$getAccelerationsByHeight)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'accelerations/recent/:interval', this.$getRecentAccelerations)
|
||||
;
|
||||
}
|
||||
|
||||
@ -352,6 +357,52 @@ class MiningRoutes {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getAccelerationsByPool(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
if (!config.MEMPOOL_SERVICES.ACCELERATIONS || ['testnet', 'signet', 'liquidtestnet', 'liquid'].includes(config.MEMPOOL.NETWORK)) {
|
||||
res.status(400).send('Acceleration data is not available.');
|
||||
return;
|
||||
}
|
||||
res.status(200).send(await AccelerationRepository.$getAccelerationInfo(req.params.slug));
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getAccelerationsByHeight(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24).toUTCString());
|
||||
if (!config.MEMPOOL_SERVICES.ACCELERATIONS || ['testnet', 'signet', 'liquidtestnet', 'liquid'].includes(config.MEMPOOL.NETWORK)) {
|
||||
res.status(400).send('Acceleration data is not available.');
|
||||
return;
|
||||
}
|
||||
const height = req.params.height === undefined ? undefined : parseInt(req.params.height, 10);
|
||||
res.status(200).send(await AccelerationRepository.$getAccelerationInfo(null, height));
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getRecentAccelerations(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
if (!config.MEMPOOL_SERVICES.ACCELERATIONS || ['testnet', 'signet', 'liquidtestnet', 'liquid'].includes(config.MEMPOOL.NETWORK)) {
|
||||
res.status(400).send('Acceleration data is not available.');
|
||||
return;
|
||||
}
|
||||
res.status(200).send(await AccelerationRepository.$getAccelerationInfo(null, null, req.params.interval));
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new MiningRoutes();
|
||||
|
@ -145,6 +145,10 @@ class TransactionUtils {
|
||||
}
|
||||
|
||||
public countScriptSigops(script: string, isRawScript: boolean = false, witness: boolean = false): number {
|
||||
if (!script?.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let sigops = 0;
|
||||
// count OP_CHECKSIG and OP_CHECKSIGVERIFY
|
||||
sigops += (script.match(/OP_CHECKSIG/g)?.length || 0);
|
||||
|
@ -24,6 +24,8 @@ import { ApiPrice } from '../repositories/PricesRepository';
|
||||
import accelerationApi from './services/acceleration';
|
||||
import mempool from './mempool';
|
||||
import statistics from './statistics/statistics';
|
||||
import accelerationCosts from './acceleration';
|
||||
import accelerationRepository from '../repositories/AccelerationRepository';
|
||||
|
||||
interface AddressTransactions {
|
||||
mempool: MempoolTransactionExtended[],
|
||||
@ -728,6 +730,28 @@ class WebsocketHandler {
|
||||
|
||||
const _memPool = memPool.getMempool();
|
||||
|
||||
const isAccelerated = config.MEMPOOL_SERVICES.ACCELERATIONS && accelerationApi.isAcceleratedBlock(block, Object.values(mempool.getAccelerations()));
|
||||
|
||||
|
||||
if (isAccelerated) {
|
||||
const blockTxs: { [txid: string]: MempoolTransactionExtended } = {};
|
||||
for (const tx of transactions) {
|
||||
blockTxs[tx.txid] = tx;
|
||||
}
|
||||
const accelerations = Object.values(mempool.getAccelerations());
|
||||
const boostRate = accelerationCosts.calculateBoostRate(
|
||||
accelerations.map(acc => ({ txid: acc.txid, max_bid: acc.feeDelta })),
|
||||
transactions
|
||||
);
|
||||
for (const acc of accelerations) {
|
||||
if (blockTxs[acc.txid]) {
|
||||
const tx = blockTxs[acc.txid];
|
||||
const accelerationInfo = accelerationCosts.getAccelerationInfo(tx, boostRate, transactions);
|
||||
accelerationRepository.$saveAcceleration(accelerationInfo, block, block.extras.pool.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rbfTransactions = Common.findMinedRbfTransactions(transactions, memPool.getSpendMap());
|
||||
memPool.handleMinedRbfTransactions(rbfTransactions);
|
||||
memPool.removeFromSpendMap(transactions);
|
||||
@ -735,7 +759,6 @@ class WebsocketHandler {
|
||||
if (config.MEMPOOL.AUDIT && memPool.isInSync()) {
|
||||
let projectedBlocks;
|
||||
let auditMempool = _memPool;
|
||||
const isAccelerated = config.MEMPOOL_SERVICES.ACCELERATIONS && accelerationApi.isAcceleratedBlock(block, Object.values(mempool.getAccelerations()));
|
||||
// template calculation functions have mempool side effects, so calculate audits using
|
||||
// a cloned copy of the mempool if we're running a different algorithm for mempool updates
|
||||
const separateAudit = config.MEMPOOL.ADVANCED_GBT_AUDIT !== config.MEMPOOL.ADVANCED_GBT_MEMPOOL;
|
||||
|
@ -209,6 +209,7 @@ export const TransactionFlags = {
|
||||
v1: 0b00000100n,
|
||||
v2: 0b00001000n,
|
||||
v3: 0b00010000n,
|
||||
nonstandard: 0b00100000n,
|
||||
// address types
|
||||
p2pk: 0b00000001_00000000n,
|
||||
p2ms: 0b00000010_00000000n,
|
||||
|
109
backend/src/repositories/AccelerationRepository.ts
Normal file
109
backend/src/repositories/AccelerationRepository.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { AccelerationInfo } from '../api/acceleration';
|
||||
import { ResultSetHeader, RowDataPacket } from 'mysql2';
|
||||
import DB from '../database';
|
||||
import logger from '../logger';
|
||||
import { IEsploraApi } from '../api/bitcoin/esplora-api.interface';
|
||||
import { Common } from '../api/common';
|
||||
import config from '../config';
|
||||
|
||||
export interface PublicAcceleration {
|
||||
txid: string,
|
||||
height: number,
|
||||
pool: {
|
||||
id: number,
|
||||
slug: string,
|
||||
name: string,
|
||||
},
|
||||
effective_vsize: number,
|
||||
effective_fee: number,
|
||||
boost_rate: number,
|
||||
boost_cost: number,
|
||||
}
|
||||
|
||||
class AccelerationRepository {
|
||||
public async $saveAcceleration(acceleration: AccelerationInfo, block: IEsploraApi.Block, pool_id: number): Promise<void> {
|
||||
try {
|
||||
await DB.query(`
|
||||
INSERT INTO accelerations(txid, added, height, pool, effective_vsize, effective_fee, boost_rate, boost_cost)
|
||||
VALUE (?, FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
added = FROM_UNIXTIME(?),
|
||||
height = ?,
|
||||
pool = ?,
|
||||
effective_vsize = ?,
|
||||
effective_fee = ?,
|
||||
boost_rate = ?,
|
||||
boost_cost = ?
|
||||
`, [
|
||||
acceleration.txSummary.txid,
|
||||
block.timestamp,
|
||||
block.height,
|
||||
pool_id,
|
||||
acceleration.txSummary.effectiveVsize,
|
||||
acceleration.txSummary.effectiveFee,
|
||||
acceleration.targetFeeRate, acceleration.cost,
|
||||
block.timestamp,
|
||||
block.height,
|
||||
pool_id,
|
||||
acceleration.txSummary.effectiveVsize,
|
||||
acceleration.txSummary.effectiveFee,
|
||||
acceleration.targetFeeRate, acceleration.cost,
|
||||
]);
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot save acceleration (${acceleration.txSummary.txid}) into db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
// We don't throw, not a critical issue if we miss some accelerations
|
||||
}
|
||||
}
|
||||
|
||||
public async $getAccelerationInfo(poolSlug: string | null = null, height: number | null = null, interval: string | null = null): Promise<PublicAcceleration[]> {
|
||||
interval = Common.getSqlInterval(interval);
|
||||
|
||||
if (!config.MEMPOOL_SERVICES.ACCELERATIONS || (interval == null && poolSlug == null && height == null)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let query = `
|
||||
SELECT * FROM accelerations
|
||||
JOIN pools on pools.unique_id = accelerations.pool
|
||||
`;
|
||||
let params: any[] = [];
|
||||
|
||||
if (interval) {
|
||||
query += ` WHERE accelerations.added BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW() `;
|
||||
} else if (height != null) {
|
||||
query += ` WHERE accelerations.height = ? `;
|
||||
params.push(height);
|
||||
} else if (poolSlug != null) {
|
||||
query += ` WHERE pools.slug = ? `;
|
||||
params.push(poolSlug);
|
||||
}
|
||||
|
||||
query += ` ORDER BY accelerations.added DESC `;
|
||||
|
||||
try {
|
||||
const [rows] = await DB.query(query, params) as RowDataPacket[][];
|
||||
if (rows?.length) {
|
||||
return rows.map(row => ({
|
||||
txid: row.txid,
|
||||
height: row.height,
|
||||
pool: {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
},
|
||||
effective_vsize: row.effective_vsize,
|
||||
effective_fee: row.effective_fee,
|
||||
boost_rate: row.boost_rate,
|
||||
boost_cost: row.boost_cost,
|
||||
}));
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err(`Cannot query acceleration info. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new AccelerationRepository();
|
203
backend/src/utils/bitcoin-script.ts
Normal file
203
backend/src/utils/bitcoin-script.ts
Normal file
@ -0,0 +1,203 @@
|
||||
const opcodes = {
|
||||
OP_FALSE: 0,
|
||||
OP_0: 0,
|
||||
OP_PUSHDATA1: 76,
|
||||
OP_PUSHDATA2: 77,
|
||||
OP_PUSHDATA4: 78,
|
||||
OP_1NEGATE: 79,
|
||||
OP_PUSHNUM_NEG1: 79,
|
||||
OP_RESERVED: 80,
|
||||
OP_TRUE: 81,
|
||||
OP_1: 81,
|
||||
OP_2: 82,
|
||||
OP_3: 83,
|
||||
OP_4: 84,
|
||||
OP_5: 85,
|
||||
OP_6: 86,
|
||||
OP_7: 87,
|
||||
OP_8: 88,
|
||||
OP_9: 89,
|
||||
OP_10: 90,
|
||||
OP_11: 91,
|
||||
OP_12: 92,
|
||||
OP_13: 93,
|
||||
OP_14: 94,
|
||||
OP_15: 95,
|
||||
OP_16: 96,
|
||||
OP_PUSHNUM_1: 81,
|
||||
OP_PUSHNUM_2: 82,
|
||||
OP_PUSHNUM_3: 83,
|
||||
OP_PUSHNUM_4: 84,
|
||||
OP_PUSHNUM_5: 85,
|
||||
OP_PUSHNUM_6: 86,
|
||||
OP_PUSHNUM_7: 87,
|
||||
OP_PUSHNUM_8: 88,
|
||||
OP_PUSHNUM_9: 89,
|
||||
OP_PUSHNUM_10: 90,
|
||||
OP_PUSHNUM_11: 91,
|
||||
OP_PUSHNUM_12: 92,
|
||||
OP_PUSHNUM_13: 93,
|
||||
OP_PUSHNUM_14: 94,
|
||||
OP_PUSHNUM_15: 95,
|
||||
OP_PUSHNUM_16: 96,
|
||||
OP_NOP: 97,
|
||||
OP_VER: 98,
|
||||
OP_IF: 99,
|
||||
OP_NOTIF: 100,
|
||||
OP_VERIF: 101,
|
||||
OP_VERNOTIF: 102,
|
||||
OP_ELSE: 103,
|
||||
OP_ENDIF: 104,
|
||||
OP_VERIFY: 105,
|
||||
OP_RETURN: 106,
|
||||
OP_TOALTSTACK: 107,
|
||||
OP_FROMALTSTACK: 108,
|
||||
OP_2DROP: 109,
|
||||
OP_2DUP: 110,
|
||||
OP_3DUP: 111,
|
||||
OP_2OVER: 112,
|
||||
OP_2ROT: 113,
|
||||
OP_2SWAP: 114,
|
||||
OP_IFDUP: 115,
|
||||
OP_DEPTH: 116,
|
||||
OP_DROP: 117,
|
||||
OP_DUP: 118,
|
||||
OP_NIP: 119,
|
||||
OP_OVER: 120,
|
||||
OP_PICK: 121,
|
||||
OP_ROLL: 122,
|
||||
OP_ROT: 123,
|
||||
OP_SWAP: 124,
|
||||
OP_TUCK: 125,
|
||||
OP_CAT: 126,
|
||||
OP_SUBSTR: 127,
|
||||
OP_LEFT: 128,
|
||||
OP_RIGHT: 129,
|
||||
OP_SIZE: 130,
|
||||
OP_INVERT: 131,
|
||||
OP_AND: 132,
|
||||
OP_OR: 133,
|
||||
OP_XOR: 134,
|
||||
OP_EQUAL: 135,
|
||||
OP_EQUALVERIFY: 136,
|
||||
OP_RESERVED1: 137,
|
||||
OP_RESERVED2: 138,
|
||||
OP_1ADD: 139,
|
||||
OP_1SUB: 140,
|
||||
OP_2MUL: 141,
|
||||
OP_2DIV: 142,
|
||||
OP_NEGATE: 143,
|
||||
OP_ABS: 144,
|
||||
OP_NOT: 145,
|
||||
OP_0NOTEQUAL: 146,
|
||||
OP_ADD: 147,
|
||||
OP_SUB: 148,
|
||||
OP_MUL: 149,
|
||||
OP_DIV: 150,
|
||||
OP_MOD: 151,
|
||||
OP_LSHIFT: 152,
|
||||
OP_RSHIFT: 153,
|
||||
OP_BOOLAND: 154,
|
||||
OP_BOOLOR: 155,
|
||||
OP_NUMEQUAL: 156,
|
||||
OP_NUMEQUALVERIFY: 157,
|
||||
OP_NUMNOTEQUAL: 158,
|
||||
OP_LESSTHAN: 159,
|
||||
OP_GREATERTHAN: 160,
|
||||
OP_LESSTHANOREQUAL: 161,
|
||||
OP_GREATERTHANOREQUAL: 162,
|
||||
OP_MIN: 163,
|
||||
OP_MAX: 164,
|
||||
OP_WITHIN: 165,
|
||||
OP_RIPEMD160: 166,
|
||||
OP_SHA1: 167,
|
||||
OP_SHA256: 168,
|
||||
OP_HASH160: 169,
|
||||
OP_HASH256: 170,
|
||||
OP_CODESEPARATOR: 171,
|
||||
OP_CHECKSIG: 172,
|
||||
OP_CHECKSIGVERIFY: 173,
|
||||
OP_CHECKMULTISIG: 174,
|
||||
OP_CHECKMULTISIGVERIFY: 175,
|
||||
OP_NOP1: 176,
|
||||
OP_NOP2: 177,
|
||||
OP_CHECKLOCKTIMEVERIFY: 177,
|
||||
OP_CLTV: 177,
|
||||
OP_NOP3: 178,
|
||||
OP_CHECKSEQUENCEVERIFY: 178,
|
||||
OP_CSV: 178,
|
||||
OP_NOP4: 179,
|
||||
OP_NOP5: 180,
|
||||
OP_NOP6: 181,
|
||||
OP_NOP7: 182,
|
||||
OP_NOP8: 183,
|
||||
OP_NOP9: 184,
|
||||
OP_NOP10: 185,
|
||||
OP_CHECKSIGADD: 186,
|
||||
OP_PUBKEYHASH: 253,
|
||||
OP_PUBKEY: 254,
|
||||
OP_INVALIDOPCODE: 255,
|
||||
};
|
||||
// add unused opcodes
|
||||
for (let i = 187; i <= 255; i++) {
|
||||
opcodes[`OP_RETURN_${i}`] = i;
|
||||
}
|
||||
|
||||
export { opcodes };
|
||||
|
||||
/** extracts m and n from a multisig script (asm), returns nothing if it is not a multisig script */
|
||||
export function parseMultisigScript(script: string): void | { m: number, n: number } {
|
||||
if (!script) {
|
||||
return;
|
||||
}
|
||||
const ops = script.split(' ');
|
||||
if (ops.length < 3 || ops.pop() !== 'OP_CHECKMULTISIG') {
|
||||
return;
|
||||
}
|
||||
const opN = ops.pop();
|
||||
if (!opN) {
|
||||
return;
|
||||
}
|
||||
if (!opN.startsWith('OP_PUSHNUM_')) {
|
||||
return;
|
||||
}
|
||||
const n = parseInt(opN.match(/[0-9]+/)?.[0] || '', 10);
|
||||
if (ops.length < n * 2 + 1) {
|
||||
return;
|
||||
}
|
||||
// pop n public keys
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (!/^0((2|3)\w{64}|4\w{128})$/.test(ops.pop() || '')) {
|
||||
return;
|
||||
}
|
||||
if (!/^OP_PUSHBYTES_(33|65)$/.test(ops.pop() || '')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const opM = ops.pop();
|
||||
if (!opM) {
|
||||
return;
|
||||
}
|
||||
if (!opM.startsWith('OP_PUSHNUM_')) {
|
||||
return;
|
||||
}
|
||||
const m = parseInt(opM.match(/[0-9]+/)?.[0] || '', 10);
|
||||
|
||||
if (ops.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return { m, n };
|
||||
}
|
||||
|
||||
export function getVarIntLength(n: number): number {
|
||||
if (n < 0xfd) {
|
||||
return 1;
|
||||
} else if (n <= 0xffff) {
|
||||
return 3;
|
||||
} else if (n <= 0xffffffff) {
|
||||
return 5;
|
||||
} else {
|
||||
return 9;
|
||||
}
|
||||
}
|
@ -23,6 +23,9 @@
|
||||
<label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '1m'">
|
||||
<input type="radio" [value]="'1m'" fragment="1m" [routerLink]="['/graphs/acceleration/fees' | relativeUrl]" formControlName="dateSpan"> 1M
|
||||
</label>
|
||||
<label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '3m'">
|
||||
<input type="radio" [value]="'3m'" fragment="3m" [routerLink]="['/graphs/acceleration/fees' | relativeUrl]" formControlName="dateSpan"> 3M
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -25,7 +25,8 @@
|
||||
flex-direction: column;
|
||||
padding: 0px 15px;
|
||||
width: 100%;
|
||||
height: calc(100vh - 250px);
|
||||
height: calc(100vh - 225px);
|
||||
min-height: 400px;
|
||||
@media (min-width: 992px) {
|
||||
height: calc(100vh - 150px);
|
||||
}
|
||||
@ -35,6 +36,7 @@
|
||||
display: flex;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-bottom: 20px;
|
||||
padding-right: 10px;
|
||||
@media (max-width: 992px) {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnDestroy, OnInit } from '@angular/core';
|
||||
import { EChartsOption, graphic } from 'echarts';
|
||||
import { EChartsOption } from 'echarts';
|
||||
import { Observable, Subscription, combineLatest, fromEvent } from 'rxjs';
|
||||
import { map, max, startWith, switchMap, tap } from 'rxjs/operators';
|
||||
import { startWith, switchMap, tap } from 'rxjs/operators';
|
||||
import { SeoService } from '../../../services/seo.service';
|
||||
import { formatNumber } from '@angular/common';
|
||||
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
|
||||
@ -11,7 +11,6 @@ import { MiningService } from '../../../services/mining.service';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Acceleration } from '../../../interfaces/node-api.interface';
|
||||
import { ServicesApiServices } from '../../../services/services-api.service';
|
||||
import { ApiService } from '../../../services/api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-acceleration-fees-graph',
|
||||
@ -29,7 +28,7 @@ import { ApiService } from '../../../services/api.service';
|
||||
})
|
||||
export class AccelerationFeesGraphComponent implements OnInit, OnDestroy {
|
||||
@Input() widget: boolean = false;
|
||||
@Input() height: number | string = '200';
|
||||
@Input() height: number = 300;
|
||||
@Input() right: number | string = 45;
|
||||
@Input() left: number | string = 75;
|
||||
@Input() accelerations$: Observable<Acceleration[]>;
|
||||
@ -55,7 +54,6 @@ export class AccelerationFeesGraphComponent implements OnInit, OnDestroy {
|
||||
constructor(
|
||||
@Inject(LOCALE_ID) public locale: string,
|
||||
private seoService: SeoService,
|
||||
private apiService: ApiService,
|
||||
private servicesApiService: ServicesApiServices,
|
||||
private formBuilder: UntypedFormBuilder,
|
||||
private storageService: StorageService,
|
||||
@ -69,104 +67,56 @@ export class AccelerationFeesGraphComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.isLoading = true;
|
||||
if (this.widget) {
|
||||
this.miningWindowPreference = '1m';
|
||||
this.timespan = this.miningWindowPreference;
|
||||
|
||||
this.statsObservable$ = combineLatest([
|
||||
(this.accelerations$ || this.servicesApiService.getAccelerationHistory$({ timeframe: this.miningWindowPreference })),
|
||||
this.apiService.getHistoricalBlockFees$(this.miningWindowPreference),
|
||||
fromEvent(window, 'resize').pipe(startWith(null)),
|
||||
]).pipe(
|
||||
tap(([accelerations, blockFeesResponse]) => {
|
||||
this.prepareChartOptions(accelerations, blockFeesResponse.body);
|
||||
}),
|
||||
map(([accelerations, blockFeesResponse]) => {
|
||||
return {
|
||||
avgFeesPaid: accelerations.filter(acc => acc.status === 'completed').reduce((total, acc) => total + (acc.feePaid - acc.baseFee - acc.vsizeFee), 0) / accelerations.length
|
||||
};
|
||||
}),
|
||||
);
|
||||
this.miningWindowPreference = '3m';
|
||||
} else {
|
||||
this.seoService.setTitle($localize`:@@bcf34abc2d9ed8f45a2f65dd464c46694e9a181e:Acceleration Fees`);
|
||||
this.miningWindowPreference = this.miningService.getDefaultTimespan('1w');
|
||||
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
|
||||
this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference);
|
||||
this.route.fragment.subscribe((fragment) => {
|
||||
if (['24h', '3d', '1w', '1m'].indexOf(fragment) > -1) {
|
||||
this.radioGroupForm.controls.dateSpan.setValue(fragment, { emitEvent: false });
|
||||
}
|
||||
});
|
||||
this.statsObservable$ = combineLatest([
|
||||
this.radioGroupForm.get('dateSpan').valueChanges.pipe(
|
||||
startWith(this.radioGroupForm.controls.dateSpan.value),
|
||||
switchMap((timespan) => {
|
||||
this.isLoading = true;
|
||||
this.storageService.setValue('miningWindowPreference', timespan);
|
||||
this.timespan = timespan;
|
||||
return this.servicesApiService.getAccelerationHistory$({});
|
||||
})
|
||||
),
|
||||
this.radioGroupForm.get('dateSpan').valueChanges.pipe(
|
||||
startWith(this.radioGroupForm.controls.dateSpan.value),
|
||||
switchMap((timespan) => {
|
||||
return this.apiService.getHistoricalBlockFees$(timespan);
|
||||
})
|
||||
)
|
||||
]).pipe(
|
||||
tap(([accelerations, blockFeesResponse]) => {
|
||||
this.prepareChartOptions(accelerations, blockFeesResponse.body);
|
||||
})
|
||||
);
|
||||
this.miningWindowPreference = this.miningService.getDefaultTimespan('3m');
|
||||
}
|
||||
this.statsSubscription = this.statsObservable$.subscribe(() => {
|
||||
this.isLoading = false;
|
||||
this.cd.markForCheck();
|
||||
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
|
||||
this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference);
|
||||
|
||||
this.route.fragment.subscribe((fragment) => {
|
||||
if (['24h', '3d', '1w', '1m', '3m'].indexOf(fragment) > -1) {
|
||||
this.radioGroupForm.controls.dateSpan.setValue(fragment, { emitEvent: false });
|
||||
}
|
||||
});
|
||||
this.statsObservable$ = combineLatest([
|
||||
this.radioGroupForm.get('dateSpan').valueChanges.pipe(
|
||||
startWith(this.radioGroupForm.controls.dateSpan.value),
|
||||
switchMap((timespan) => {
|
||||
if (!this.widget) {
|
||||
this.storageService.setValue('miningWindowPreference', timespan);
|
||||
}
|
||||
this.isLoading = true;
|
||||
this.timespan = timespan;
|
||||
return this.servicesApiService.getAggregatedAccelerationHistory$({timeframe: this.timespan});
|
||||
})
|
||||
),
|
||||
fromEvent(window, 'resize').pipe(startWith(null)),
|
||||
]).pipe(
|
||||
tap(([history]) => {
|
||||
this.isLoading = false;
|
||||
this.prepareChartOptions(history);
|
||||
this.cd.markForCheck();
|
||||
})
|
||||
);
|
||||
|
||||
this.statsObservable$.subscribe();
|
||||
}
|
||||
|
||||
prepareChartOptions(accelerations, blockFees) {
|
||||
prepareChartOptions(data) {
|
||||
let title: object;
|
||||
|
||||
const blockAccelerations = {};
|
||||
|
||||
for (const acceleration of accelerations) {
|
||||
if (acceleration.status === 'completed') {
|
||||
if (!blockAccelerations[acceleration.blockHeight]) {
|
||||
blockAccelerations[acceleration.blockHeight] = [];
|
||||
}
|
||||
blockAccelerations[acceleration.blockHeight].push(acceleration);
|
||||
}
|
||||
}
|
||||
|
||||
let last = null;
|
||||
let minValue = Infinity;
|
||||
let maxValue = 0;
|
||||
const data = [];
|
||||
for (const val of blockFees) {
|
||||
if (last == null) {
|
||||
last = val.avgHeight;
|
||||
}
|
||||
let totalFeeDelta = 0;
|
||||
let totalFeePaid = 0;
|
||||
let totalCount = 0;
|
||||
let blockCount = 0;
|
||||
while (last <= val.avgHeight) {
|
||||
blockCount++;
|
||||
totalFeeDelta += (blockAccelerations[last] || []).reduce((total, acc) => total + acc.feeDelta, 0);
|
||||
totalFeePaid += (blockAccelerations[last] || []).reduce((total, acc) => total + (acc.feePaid - acc.baseFee - acc.vsizeFee), 0);
|
||||
totalCount += (blockAccelerations[last] || []).length;
|
||||
last++;
|
||||
}
|
||||
minValue = Math.min(minValue, val.avgFees);
|
||||
maxValue = Math.max(maxValue, val.avgFees);
|
||||
data.push({
|
||||
...val,
|
||||
feeDelta: totalFeeDelta,
|
||||
avgFeePaid: (totalFeePaid / blockCount),
|
||||
accelerations: totalCount / blockCount,
|
||||
});
|
||||
if (data.length === 0) {
|
||||
title = {
|
||||
textStyle: {
|
||||
color: 'grey',
|
||||
fontSize: 15
|
||||
},
|
||||
text: $localize`No accelerated transaction for this timeframe`,
|
||||
left: 'center',
|
||||
top: 'center'
|
||||
};
|
||||
}
|
||||
|
||||
this.chartOptions = {
|
||||
@ -177,11 +127,11 @@ export class AccelerationFeesGraphComponent implements OnInit, OnDestroy {
|
||||
],
|
||||
animation: false,
|
||||
grid: {
|
||||
height: this.height,
|
||||
height: (this.widget && this.height) ? this.height - 30 : undefined,
|
||||
top: this.widget ? 20 : 40,
|
||||
bottom: this.widget ? 30 : 80,
|
||||
right: this.right,
|
||||
left: this.left,
|
||||
bottom: this.widget ? 30 : 80,
|
||||
top: this.widget ? 20 : (this.isMobile() ? 10 : 50),
|
||||
},
|
||||
tooltip: {
|
||||
show: !this.isMobile(),
|
||||
@ -197,29 +147,23 @@ export class AccelerationFeesGraphComponent implements OnInit, OnDestroy {
|
||||
align: 'left',
|
||||
},
|
||||
borderColor: '#000',
|
||||
formatter: function (data) {
|
||||
if (data.length <= 0) {
|
||||
return '';
|
||||
}
|
||||
let tooltip = `<b style="color: white; margin-left: 2px">
|
||||
${formatterXAxis(this.locale, this.timespan, parseInt(data[0].axisValue, 10))}</b><br>`;
|
||||
formatter: (ticks) => {
|
||||
let tooltip = `<b style="color: white; margin-left: 2px">${formatterXAxis(this.locale, this.timespan, parseInt(ticks[0].axisValue, 10))}</b><br>`;
|
||||
|
||||
for (const tick of data.reverse()) {
|
||||
if (tick.data[1] >= 1_000_000) {
|
||||
tooltip += `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1] / 100_000_000, this.locale, '1.0-3')} BTC<br>`;
|
||||
} else {
|
||||
tooltip += `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1], this.locale, '1.0-0')} sats<br>`;
|
||||
}
|
||||
if (ticks[0].data[1] > 10_000_000) {
|
||||
tooltip += `${ticks[0].marker} ${ticks[0].seriesName}: ${formatNumber(ticks[0].data[1] / 100_000_000, this.locale, '1.0-0')} BTC<br>`;
|
||||
} else {
|
||||
tooltip += `${ticks[0].marker} ${ticks[0].seriesName}: ${formatNumber(ticks[0].data[1], this.locale, '1.0-0')} sats<br>`;
|
||||
}
|
||||
|
||||
if (['24h', '3d'].includes(this.timespan)) {
|
||||
tooltip += `<small>` + $localize`At block: ${data[0].data[2]}` + `</small>`;
|
||||
tooltip += `<small>` + $localize`At block: ${ticks[0].data[2]}` + `</small>`;
|
||||
} else {
|
||||
tooltip += `<small>` + $localize`Around block: ${data[0].data[2]}` + `</small>`;
|
||||
tooltip += `<small>` + $localize`Around block: ${ticks[0].data[2]}` + `</small>`;
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
}.bind(this)
|
||||
}
|
||||
},
|
||||
xAxis: data.length === 0 ? undefined :
|
||||
{
|
||||
@ -228,7 +172,7 @@ export class AccelerationFeesGraphComponent implements OnInit, OnDestroy {
|
||||
nameTextStyle: {
|
||||
padding: [10, 0, 0, 0],
|
||||
},
|
||||
type: 'category',
|
||||
type: 'time',
|
||||
boundaryGap: false,
|
||||
axisLine: { onZero: true },
|
||||
axisLabel: {
|
||||
@ -243,15 +187,7 @@ export class AccelerationFeesGraphComponent implements OnInit, OnDestroy {
|
||||
legend: {
|
||||
data: [
|
||||
{
|
||||
name: 'In-band fees per block',
|
||||
inactiveColor: 'rgb(110, 112, 121)',
|
||||
textStyle: {
|
||||
color: 'white',
|
||||
},
|
||||
icon: 'roundRect',
|
||||
},
|
||||
{
|
||||
name: 'Total bid boost per block',
|
||||
name: 'Total bid boost',
|
||||
inactiveColor: 'rgb(110, 112, 121)',
|
||||
textStyle: {
|
||||
color: 'white',
|
||||
@ -260,8 +196,7 @@ export class AccelerationFeesGraphComponent implements OnInit, OnDestroy {
|
||||
},
|
||||
],
|
||||
selected: {
|
||||
'In-band fees per block': false,
|
||||
'Total bid boost per block': true,
|
||||
'Total bid boost': true,
|
||||
},
|
||||
show: !this.widget,
|
||||
},
|
||||
@ -304,21 +239,13 @@ export class AccelerationFeesGraphComponent implements OnInit, OnDestroy {
|
||||
{
|
||||
legendHoverLink: false,
|
||||
zlevel: 1,
|
||||
name: 'Total bid boost per block',
|
||||
data: data.map(block => [block.timestamp * 1000, block.avgFeePaid, block.avgHeight]),
|
||||
name: 'Total bid boost',
|
||||
data: data.map(h => {
|
||||
return [h.timestamp * 1000, h.sumBidBoost, h.avgHeight]
|
||||
}),
|
||||
stack: 'Total',
|
||||
type: 'bar',
|
||||
barWidth: '100%',
|
||||
large: true,
|
||||
},
|
||||
{
|
||||
legendHoverLink: false,
|
||||
zlevel: 0,
|
||||
name: 'In-band fees per block',
|
||||
data: data.map(block => [block.timestamp * 1000, block.avgFees, block.avgHeight]),
|
||||
stack: 'Total',
|
||||
type: 'bar',
|
||||
barWidth: '100%',
|
||||
barWidth: '90%',
|
||||
large: true,
|
||||
},
|
||||
],
|
||||
@ -347,17 +274,6 @@ export class AccelerationFeesGraphComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
},
|
||||
}],
|
||||
visualMap: {
|
||||
type: 'continuous',
|
||||
min: minValue,
|
||||
max: maxValue,
|
||||
dimension: 1,
|
||||
seriesIndex: 1,
|
||||
show: false,
|
||||
inRange: {
|
||||
color: ['#F4511E7f', '#FB8C007f', '#FFB3007f', '#FDD8357f', '#7CB3427f'].reverse() // Gradient color range
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -3,16 +3,16 @@
|
||||
<div class="item">
|
||||
<h5 class="card-title" i18n="accelerator.requests">Requests</h5>
|
||||
<div class="card-text">
|
||||
<div>{{ stats.count }}</div>
|
||||
<div>{{ stats.totalRequested }}</div>
|
||||
<div class="symbol" i18n="accelerator.total-accelerated">accelerated</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<h5 class="card-title" i18n="accelerator.total-boost">Total Bid Boost</h5>
|
||||
<div class="card-text">
|
||||
<div>{{ stats.totalFeesPaid / 100_000_000 | amountShortener: 4 }} <span class="symbol" i18n="shared.btc|BTC">BTC</span></div>
|
||||
<div>{{ stats.totalBidBoost / 100_000_000 | amountShortener: 4 }} <span class="symbol" i18n="shared.btc|BTC">BTC</span></div>
|
||||
<span class="fiat">
|
||||
<app-fiat [value]="stats.totalFeesPaid"></app-fiat>
|
||||
<app-fiat [value]="stats.totalBidBoost"></app-fiat>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,9 +1,12 @@
|
||||
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { ApiService } from '../../../services/api.service';
|
||||
import { StateService } from '../../../services/state.service';
|
||||
import { Acceleration } from '../../../interfaces/node-api.interface';
|
||||
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ServicesApiServices } from '../../../services/services-api.service';
|
||||
|
||||
export type AccelerationStats = {
|
||||
totalRequested: number;
|
||||
totalBidBoost: number;
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-acceleration-stats',
|
||||
@ -12,35 +15,13 @@ import { Acceleration } from '../../../interfaces/node-api.interface';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AccelerationStatsComponent implements OnInit {
|
||||
@Input() timespan: '24h' | '1w' | '1m' = '24h';
|
||||
@Input() accelerations$: Observable<Acceleration[]>;
|
||||
public accelerationStats$: Observable<any>;
|
||||
accelerationStats$: Observable<AccelerationStats>;
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private stateService: StateService,
|
||||
private servicesApiService: ServicesApiServices
|
||||
) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.accelerationStats$ = this.accelerations$.pipe(
|
||||
switchMap(accelerations => {
|
||||
let totalFeesPaid = 0;
|
||||
let totalSucceeded = 0;
|
||||
let totalCanceled = 0;
|
||||
for (const acc of accelerations) {
|
||||
if (acc.status === 'completed') {
|
||||
totalSucceeded++;
|
||||
totalFeesPaid += (acc.feePaid - acc.baseFee - acc.vsizeFee) || 0;
|
||||
} else if (acc.status === 'failed') {
|
||||
totalCanceled++;
|
||||
}
|
||||
}
|
||||
return of({
|
||||
count: totalSucceeded,
|
||||
totalFeesPaid,
|
||||
successRate: (totalSucceeded + totalCanceled > 0) ? ((totalSucceeded / (totalSucceeded + totalCanceled)) * 100) : 0.0,
|
||||
});
|
||||
})
|
||||
);
|
||||
this.accelerationStats$ = this.servicesApiService.getAccelerationStats$();
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
<div class="container-xl widget-container" [class.widget]="widget" [class.full-height]="!widget">
|
||||
<div class="container-lg widget-container" [class.widget]="widget" [class.full-height]="!widget">
|
||||
<h1 *ngIf="!widget" class="float-left" i18n="master-page.blocks">Accelerations</h1>
|
||||
<div *ngIf="!widget && isLoading" class="spinner-border ml-3" role="status"></div>
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
<th class="fee text-right" i18n="transaction.bid-boost|Bid Boost">Bid Boost</th>
|
||||
<th class="block text-right" i18n="accelerator.block">Block</th>
|
||||
<th class="status text-right" i18n="transaction.status|Transaction Status">Status</th>
|
||||
<th class="date text-right" i18n="" *ngIf="!this.widget">Requested</th>
|
||||
</ng-container>
|
||||
</thead>
|
||||
<tbody *ngIf="accelerations; else skeleton" [style]="isLoading ? 'opacity: 0.75' : ''">
|
||||
@ -49,9 +50,13 @@
|
||||
</td>
|
||||
<td class="status text-right">
|
||||
<span *ngIf="acceleration.status === 'accelerating'" class="badge badge-warning" i18n="accelerator.pending">Pending</span>
|
||||
<span *ngIf="acceleration.status === 'mined' || acceleration.status === 'completed'" class="badge badge-success" i18n="transaction.rbf.mined">Mined</span>
|
||||
<span *ngIf="acceleration.status === 'mined'" class="badge badge-info" i18n="transaction.rbf.mined">Mined</span>
|
||||
<span *ngIf="acceleration.status === 'completed'" class="badge badge-success" i18n="">Completed</span>
|
||||
<span *ngIf="acceleration.status === 'failed'" class="badge badge-danger" i18n="accelerator.canceled">Canceled</span>
|
||||
</td>
|
||||
<td class="date text-right" *ngIf="!this.widget">
|
||||
<app-time kind="since" [time]="acceleration.added" [fastRender]="true"></app-time>
|
||||
</td>
|
||||
</ng-container>
|
||||
</tr>
|
||||
</tbody>
|
||||
@ -75,6 +80,11 @@
|
||||
</ng-template>
|
||||
</table>
|
||||
|
||||
<ngb-pagination *ngIf="!widget" class="pagination-container float-right mt-2" [class]="isLoading ? 'disabled' : ''"
|
||||
[collectionSize]="this.accelerationCount" [rotate]="true" [maxSize]="maxSize" [pageSize]="15" [(page)]="page"
|
||||
(pageChange)="pageChange(page)" [boundaryLinks]="true" [ellipses]="false">
|
||||
</ngb-pagination>
|
||||
|
||||
<ng-template [ngIf]="!widget">
|
||||
<div class="clearfix"></div>
|
||||
<br>
|
||||
|
@ -63,16 +63,28 @@ tr, td, th {
|
||||
}
|
||||
|
||||
.txid {
|
||||
@media (max-width: 500px) {
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
.fee {
|
||||
width: 15%;
|
||||
}
|
||||
|
||||
.block {
|
||||
width: 15%;
|
||||
@media (max-width: 700px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fee, .block, .status {
|
||||
width: 15%;
|
||||
.status {
|
||||
width: 13%;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
width: 20%;
|
||||
.date {
|
||||
width: 20%;
|
||||
@media (max-width: 600px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@ -83,23 +95,12 @@ tr, td, th {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 30%;
|
||||
@media (max-width: 1060px) and (min-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fee-rate {
|
||||
width: 20%;
|
||||
@media (max-width: 1060px) and (min-width: 768px) {
|
||||
text-align: start !important;
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
text-align: start !important;
|
||||
}
|
||||
@media (max-width: 840px) and (min-width: 768px) {
|
||||
text-align: end !important;
|
||||
@media (max-width: 975px) and (min-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 410px) {
|
||||
@ -108,32 +109,31 @@ tr, td, th {
|
||||
}
|
||||
|
||||
.bid {
|
||||
text-align: end !important;
|
||||
width: 30%;
|
||||
min-width: 150px;
|
||||
@media (max-width: 840px) and (min-width: 768px) {
|
||||
text-align: start !important;
|
||||
}
|
||||
@media (max-width: 410px) {
|
||||
text-align: start !important;
|
||||
}
|
||||
}
|
||||
|
||||
.time {
|
||||
width: 25%;
|
||||
@media (max-width: 600px) {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 1200px) and (min-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fee {
|
||||
width: 30%;
|
||||
@media (max-width: 1060px) and (min-width: 768px) {
|
||||
text-align: start !important;
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
text-align: start !important;
|
||||
}
|
||||
text-align: end !important;
|
||||
}
|
||||
|
||||
.block {
|
||||
width: 20%;
|
||||
@media (max-width: 1200px) and (min-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Component, OnInit, ChangeDetectionStrategy, Input, ChangeDetectorRef } from '@angular/core';
|
||||
import { Observable, catchError, of, switchMap, tap } from 'rxjs';
|
||||
import { combineLatest, BehaviorSubject, Observable, catchError, of, switchMap, tap } from 'rxjs';
|
||||
import { Acceleration, BlockExtended } from '../../../interfaces/node-api.interface';
|
||||
import { StateService } from '../../../services/state.service';
|
||||
import { WebsocketService } from '../../../services/websocket.service';
|
||||
@ -21,9 +21,10 @@ export class AccelerationsListComponent implements OnInit {
|
||||
isLoading = true;
|
||||
paginationMaxSize: number;
|
||||
page = 1;
|
||||
lastPage = 1;
|
||||
accelerationCount: number;
|
||||
maxSize = window.innerWidth <= 767.98 ? 3 : 5;
|
||||
skeletonLines: number[] = [];
|
||||
pageSubject: BehaviorSubject<number> = new BehaviorSubject(this.page);
|
||||
|
||||
constructor(
|
||||
private servicesApiService: ServicesApiServices,
|
||||
@ -40,34 +41,47 @@ export class AccelerationsListComponent implements OnInit {
|
||||
|
||||
this.skeletonLines = this.widget === true ? [...Array(6).keys()] : [...Array(15).keys()];
|
||||
this.paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5;
|
||||
|
||||
const accelerationObservable$ = this.accelerations$ || (this.pending ? this.servicesApiService.getAccelerations$() : this.servicesApiService.getAccelerationHistory$({ timeframe: '1m' }));
|
||||
this.accelerationList$ = accelerationObservable$.pipe(
|
||||
switchMap(accelerations => {
|
||||
if (this.pending) {
|
||||
for (const acceleration of accelerations) {
|
||||
acceleration.status = acceleration.status || 'accelerating';
|
||||
}
|
||||
}
|
||||
for (const acc of accelerations) {
|
||||
acc.boost = acc.feePaid - acc.baseFee - acc.vsizeFee;
|
||||
}
|
||||
if (this.widget) {
|
||||
return of(accelerations.slice(-6).reverse());
|
||||
} else {
|
||||
return of(accelerations.reverse());
|
||||
}
|
||||
}),
|
||||
catchError((err) => {
|
||||
this.isLoading = false;
|
||||
return of([]);
|
||||
}),
|
||||
tap(() => {
|
||||
this.isLoading = false;
|
||||
|
||||
this.accelerationList$ = this.pageSubject.pipe(
|
||||
switchMap((page) => {
|
||||
const accelerationObservable$ = this.accelerations$ || (this.pending ? this.servicesApiService.getAccelerations$() : this.servicesApiService.getAccelerationHistoryObserveResponse$({ timeframe: '1y', page: page }));
|
||||
return accelerationObservable$.pipe(
|
||||
switchMap(response => {
|
||||
let accelerations = response;
|
||||
if (response.body) {
|
||||
accelerations = response.body;
|
||||
this.accelerationCount = parseInt(response.headers.get('x-total-count'), 10);
|
||||
}
|
||||
if (this.pending) {
|
||||
for (const acceleration of accelerations) {
|
||||
acceleration.status = acceleration.status || 'accelerating';
|
||||
}
|
||||
}
|
||||
for (const acc of accelerations) {
|
||||
acc.boost = acc.feePaid - acc.baseFee - acc.vsizeFee;
|
||||
}
|
||||
if (this.widget) {
|
||||
return of(accelerations.slice(0, 6));
|
||||
} else {
|
||||
return of(accelerations);
|
||||
}
|
||||
}),
|
||||
catchError((err) => {
|
||||
this.isLoading = false;
|
||||
return of([]);
|
||||
}),
|
||||
tap(() => {
|
||||
this.isLoading = false;
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
pageChange(page: number): void {
|
||||
this.pageSubject.next(page);
|
||||
}
|
||||
|
||||
trackByBlock(index: number, block: BlockExtended): number {
|
||||
return block.height;
|
||||
}
|
||||
|
@ -22,12 +22,12 @@
|
||||
<div class="col">
|
||||
<div class="main-title">
|
||||
<span [attr.data-cy]="'acceleration-stats'" i18n="accelerator.acceleration-stats">Acceleration stats</span>
|
||||
<span style="font-size: xx-small" i18n="mining.144-blocks">(1 month)</span>
|
||||
<span style="font-size: xx-small" i18n="mining.3-months">(3 months)</span>
|
||||
</div>
|
||||
<div class="card-wrapper">
|
||||
<div class="card">
|
||||
<div class="card-body more-padding">
|
||||
<app-acceleration-stats timespan="1m" [accelerations$]="minedAccelerations$"></app-acceleration-stats>
|
||||
<app-acceleration-stats></app-acceleration-stats>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -59,7 +59,6 @@
|
||||
[height]="graphHeight"
|
||||
[attr.data-cy]="'acceleration-fees'"
|
||||
[widget]=true
|
||||
[accelerations$]="accelerations$"
|
||||
></app-acceleration-fees-graph>
|
||||
</div>
|
||||
<div class="mt-1"><a [attr.data-cy]="'acceleration-fees-view-more'" [routerLink]="['/graphs/acceleration/fees' | relativeUrl]" i18n="dashboard.view-more">View more »</a></div>
|
||||
@ -84,7 +83,7 @@
|
||||
<div class="title-link">
|
||||
<h5 class="card-title d-inline" i18n="accelerator.pending-accelerations">Active Accelerations</h5>
|
||||
</div>
|
||||
<app-accelerations-list [attr.data-cy]="'pending-accelerations'" [widget]=true [pending]="true" [accelerations$]="pendingAccelerations$"></app-accelerations-list>
|
||||
<app-accelerations-list [attr.data-cy]="'pending-accelerations'" [widget]=true [pending]=true [accelerations$]="pendingAccelerations$"></app-accelerations-list>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -60,7 +60,7 @@ export class AcceleratorDashboardComponent implements OnInit {
|
||||
this.accelerations$ = this.stateService.chainTip$.pipe(
|
||||
distinctUntilChanged(),
|
||||
switchMap(() => {
|
||||
return this.serviceApiServices.getAccelerationHistory$({ timeframe: '1m' }).pipe(
|
||||
return this.serviceApiServices.getAccelerationHistory$({ timeframe: '3m', page: 1, pageLength: 100}).pipe(
|
||||
catchError(() => {
|
||||
return of([]);
|
||||
}),
|
||||
@ -71,7 +71,7 @@ export class AcceleratorDashboardComponent implements OnInit {
|
||||
|
||||
this.minedAccelerations$ = this.accelerations$.pipe(
|
||||
map(accelerations => {
|
||||
return accelerations.filter(acc => ['mined', 'completed', 'failed'].includes(acc.status));
|
||||
return accelerations.filter(acc => ['mined', 'completed'].includes(acc.status));
|
||||
})
|
||||
);
|
||||
|
||||
@ -128,11 +128,11 @@ export class AcceleratorDashboardComponent implements OnInit {
|
||||
@HostListener('window:resize', ['$event'])
|
||||
onResize(): void {
|
||||
if (window.innerWidth >= 992) {
|
||||
this.graphHeight = 330;
|
||||
this.graphHeight = 380;
|
||||
} else if (window.innerWidth >= 768) {
|
||||
this.graphHeight = 245;
|
||||
this.graphHeight = 300;
|
||||
} else {
|
||||
this.graphHeight = 210;
|
||||
this.graphHeight = 270;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -59,7 +59,7 @@ export default class TxView implements TransactionStripped {
|
||||
this.acc = tx.acc;
|
||||
this.rate = tx.rate;
|
||||
this.status = tx.status;
|
||||
this.bigintFlags = tx.flags ? (BigInt(tx.flags) ^ (this.acc ? TransactionFlags.acceleration : 0n)): 0n;
|
||||
this.bigintFlags = tx.flags ? (BigInt(tx.flags) | (this.acc ? TransactionFlags.acceleration : 0n)): 0n;
|
||||
this.initialised = false;
|
||||
this.vertexArray = scene.vertexArray;
|
||||
|
||||
|
@ -28,7 +28,7 @@
|
||||
<app-fee-rate [fee]="feeRate"></app-fee-rate>
|
||||
</td>
|
||||
</tr>
|
||||
<tr *ngIf="effectiveRate && effectiveRate !== feeRate">
|
||||
<tr *ngIf="hasEffectiveRate && effectiveRate != null">
|
||||
<td *ngIf="!this.acceleration" class="td-width" i18n="transaction.effective-fee-rate|Effective transaction fee rate">Effective fee rate</td>
|
||||
<td *ngIf="this.acceleration" class="td-width" i18n="transaction.effective-fee-rate|Effective transaction fee rate">Accelerated fee rate</td>
|
||||
<td>
|
||||
|
@ -2,6 +2,7 @@ import { Component, ElementRef, ViewChild, Input, OnChanges, ChangeDetectionStra
|
||||
import { Position } from '../../components/block-overview-graph/sprite-types.js';
|
||||
import { Price } from '../../services/price.service';
|
||||
import { TransactionStripped } from '../../interfaces/node-api.interface.js';
|
||||
import { TransactionFlags } from '../../shared/filters.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'app-block-overview-tooltip',
|
||||
@ -22,6 +23,7 @@ export class BlockOverviewTooltipComponent implements OnChanges {
|
||||
feeRate = 0;
|
||||
effectiveRate;
|
||||
acceleration;
|
||||
hasEffectiveRate: boolean = false;
|
||||
|
||||
tooltipPosition: Position = { x: 0, y: 0 };
|
||||
|
||||
@ -55,6 +57,8 @@ export class BlockOverviewTooltipComponent implements OnChanges {
|
||||
this.feeRate = this.fee / this.vsize;
|
||||
this.effectiveRate = tx.rate;
|
||||
this.acceleration = tx.acc;
|
||||
this.hasEffectiveRate = Math.abs((this.fee / this.vsize) - this.effectiveRate) > 0.05
|
||||
|| (tx.bigintFlags && (tx.bigintFlags & (TransactionFlags.cpfp_child | TransactionFlags.cpfp_parent)) > 0n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -8,6 +8,7 @@
|
||||
[showFilters]="showFilters"
|
||||
[filterFlags]="filterFlags"
|
||||
[filterMode]="filterMode"
|
||||
[excludeFilters]="['nonstandard']"
|
||||
[overrideColors]="overrideColors"
|
||||
(txClickEvent)="onTxClick($event)"
|
||||
></app-block-overview-graph>
|
||||
|
@ -396,8 +396,11 @@ export interface Acceleration {
|
||||
}
|
||||
|
||||
export interface AccelerationHistoryParams {
|
||||
timeframe?: string,
|
||||
status?: string,
|
||||
pool?: string,
|
||||
blockHash?: string,
|
||||
status?: string;
|
||||
timeframe?: string;
|
||||
poolUniqueId?: number;
|
||||
blockHash?: string;
|
||||
blockHeight?: number;
|
||||
page?: number;
|
||||
pageLength?: number;
|
||||
}
|
@ -7,6 +7,7 @@ import { MenuGroup } from '../interfaces/services.interface';
|
||||
import { Observable, of, ReplaySubject, tap, catchError, share, filter, switchMap } from 'rxjs';
|
||||
import { IBackendInfo } from '../interfaces/websocket.interface';
|
||||
import { Acceleration, AccelerationHistoryParams } from '../interfaces/node-api.interface';
|
||||
import { AccelerationStats } from '../components/acceleration/acceleration-stats/acceleration-stats.component';
|
||||
|
||||
export type ProductType = 'enterprise' | 'community' | 'mining_pool' | 'custom';
|
||||
export interface IUser {
|
||||
@ -144,7 +145,19 @@ export class ServicesApiServices {
|
||||
return this.httpClient.get<Acceleration[]>(`${SERVICES_API_PREFIX}/accelerator/accelerations`);
|
||||
}
|
||||
|
||||
getAggregatedAccelerationHistory$(params: AccelerationHistoryParams): Observable<Acceleration[]> {
|
||||
return this.httpClient.get<Acceleration[]>(`${SERVICES_API_PREFIX}/accelerator/accelerations/history/aggregated`, { params: { ...params } });
|
||||
}
|
||||
|
||||
getAccelerationHistory$(params: AccelerationHistoryParams): Observable<Acceleration[]> {
|
||||
return this.httpClient.get<Acceleration[]>(`${SERVICES_API_PREFIX}/accelerator/accelerations/history`, { params: { ...params } });
|
||||
}
|
||||
|
||||
getAccelerationHistoryObserveResponse$(params: AccelerationHistoryParams): Observable<any> {
|
||||
return this.httpClient.get<any>(`${SERVICES_API_PREFIX}/accelerator/accelerations/history`, { params: { ...params }, observe: 'response'});
|
||||
}
|
||||
|
||||
getAccelerationStats$(): Observable<AccelerationStats> {
|
||||
return this.httpClient.get<AccelerationStats>(`${SERVICES_API_PREFIX}/accelerator/accelerations/stats`);
|
||||
}
|
||||
}
|
||||
|
@ -22,6 +22,7 @@ export const TransactionFlags = {
|
||||
v1: 0b00000100n,
|
||||
v2: 0b00001000n,
|
||||
v3: 0b00010000n,
|
||||
nonstandard: 0b00100000n,
|
||||
// address types
|
||||
p2pk: 0b00000001_00000000n,
|
||||
p2ms: 0b00000010_00000000n,
|
||||
@ -66,6 +67,7 @@ export const TransactionFilters: { [key: string]: Filter } = {
|
||||
v1: { key: 'v1', label: 'Version 1', flag: TransactionFlags.v1, toggle: 'version' },
|
||||
v2: { key: 'v2', label: 'Version 2', flag: TransactionFlags.v2, toggle: 'version' },
|
||||
v3: { key: 'v3', label: 'Version 3', flag: TransactionFlags.v3, toggle: 'version' },
|
||||
nonstandard: { key: 'nonstandard', label: 'Non-Standard', flag: TransactionFlags.nonstandard, important: true },
|
||||
/* address types */
|
||||
p2pk: { key: 'p2pk', label: 'P2PK', flag: TransactionFlags.p2pk, important: true },
|
||||
p2ms: { key: 'p2ms', label: 'Bare multisig', flag: TransactionFlags.p2ms, important: true },
|
||||
@ -96,7 +98,7 @@ export const TransactionFilters: { [key: string]: Filter } = {
|
||||
};
|
||||
|
||||
export const FilterGroups: { label: string, filters: Filter[]}[] = [
|
||||
{ label: 'Features', filters: ['rbf', 'no_rbf', 'v1', 'v2', 'v3'] },
|
||||
{ label: 'Features', filters: ['rbf', 'no_rbf', 'v1', 'v2', 'v3', 'nonstandard'] },
|
||||
{ label: 'Address Types', filters: ['p2pk', 'p2ms', 'p2pkh', 'p2sh', 'p2wpkh', 'p2wsh', 'p2tr'] },
|
||||
{ label: 'Behavior', filters: ['cpfp_parent', 'cpfp_child', 'replacement', 'acceleration'] },
|
||||
{ label: 'Data', filters: ['op_return', 'fake_pubkey', 'inscription'] },
|
||||
|
Loading…
x
Reference in New Issue
Block a user