Merge branch 'master' into mononaut/fix-mempool-block-skeleton

This commit is contained in:
softsimon 2023-05-14 16:18:37 -05:00 committed by GitHub
commit 585309f9f0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
133 changed files with 4155 additions and 1028 deletions

View file

@ -61,7 +61,8 @@
"SOCKET": "/var/run/mysql/mysql.sock", "SOCKET": "/var/run/mysql/mysql.sock",
"DATABASE": "mempool", "DATABASE": "mempool",
"USERNAME": "mempool", "USERNAME": "mempool",
"PASSWORD": "mempool" "PASSWORD": "mempool",
"TIMEOUT": 180000
}, },
"SYSLOG": { "SYSLOG": {
"ENABLED": true, "ENABLED": true,

View file

@ -62,7 +62,8 @@
"PORT": 18, "PORT": 18,
"DATABASE": "__DATABASE_DATABASE__", "DATABASE": "__DATABASE_DATABASE__",
"USERNAME": "__DATABASE_USERNAME__", "USERNAME": "__DATABASE_USERNAME__",
"PASSWORD": "__DATABASE_PASSWORD__" "PASSWORD": "__DATABASE_PASSWORD__",
"TIMEOUT": "__DATABASE_TIMEOUT__"
}, },
"SYSLOG": { "SYSLOG": {
"ENABLED": false, "ENABLED": false,

View file

@ -14,11 +14,11 @@ describe('Mempool Difficulty Adjustment', () => {
750134, // Current block height 750134, // Current block height
0.6280047707459726, // Previous retarget % (Passed through) 0.6280047707459726, // Previous retarget % (Passed through)
'mainnet', // Network (if testnet, next value is non-zero) 'mainnet', // Network (if testnet, next value is non-zero)
0, // If not testnet, not used 0, // Latest block timestamp in seconds (only used if difficulty already locked in)
], ],
{ // Expected Result { // Expected Result
progressPercent: 9.027777777777777, progressPercent: 9.027777777777777,
difficultyChange: 12.562233927411782, difficultyChange: 13.180707740199772,
estimatedRetargetDate: 1661895424692, estimatedRetargetDate: 1661895424692,
remainingBlocks: 1834, remainingBlocks: 1834,
remainingTime: 977591692, remainingTime: 977591692,
@ -41,7 +41,7 @@ describe('Mempool Difficulty Adjustment', () => {
], ],
{ // Expected Result is same other than timeOffset { // Expected Result is same other than timeOffset
progressPercent: 9.027777777777777, progressPercent: 9.027777777777777,
difficultyChange: 12.562233927411782, difficultyChange: 13.180707740199772,
estimatedRetargetDate: 1661895424692, estimatedRetargetDate: 1661895424692,
remainingBlocks: 1834, remainingBlocks: 1834,
remainingTime: 977591692, remainingTime: 977591692,
@ -54,6 +54,29 @@ describe('Mempool Difficulty Adjustment', () => {
expectedBlocks: 161.68833333333333, expectedBlocks: 161.68833333333333,
}, },
], ],
[ // Vector 3 (mainnet lock-in (epoch ending 788255))
[ // Inputs
dt('2023-04-20T09:57:33.000Z'), // Last DA time (in seconds)
dt('2023-05-04T14:54:09.000Z'), // Current time (now) (in seconds)
788255, // Current block height
1.7220298879531821, // Previous retarget % (Passed through)
'mainnet', // Network (if testnet, next value is non-zero)
dt('2023-05-04T14:54:26.000Z'), // Latest block timestamp in seconds
],
{ // Expected Result
progressPercent: 99.95039682539682,
difficultyChange: -1.4512637555574193,
estimatedRetargetDate: 1683212658129,
remainingBlocks: 1,
remainingTime: 609129,
previousRetarget: 1.7220298879531821,
previousTime: 1681984653,
nextRetargetHeight: 788256,
timeAvg: 609129,
timeOffset: 0,
expectedBlocks: 2045.66,
},
],
] as [[number, number, number, number, string, number], DifficultyAdjustment][]; ] as [[number, number, number, number, string, number], DifficultyAdjustment][];
for (const vector of vectors) { for (const vector of vectors) {

View file

@ -72,7 +72,8 @@ describe('Mempool Backend Config', () => {
PORT: 3306, PORT: 3306,
DATABASE: 'mempool', DATABASE: 'mempool',
USERNAME: 'mempool', USERNAME: 'mempool',
PASSWORD: 'mempool' PASSWORD: 'mempool',
TIMEOUT: 180000,
}); });
expect(config.SYSLOG).toStrictEqual({ expect(config.SYSLOG).toStrictEqual({

View file

@ -93,17 +93,7 @@ class Audit {
} else { } else {
if (!isDisplaced[tx.txid]) { if (!isDisplaced[tx.txid]) {
added.push(tx.txid); added.push(tx.txid);
} else {
} }
let blockIndex = -1;
let index = -1;
projectedBlocks.forEach((block, bi) => {
const i = block.transactionIds.indexOf(tx.txid);
if (i >= 0) {
blockIndex = bi;
index = i;
}
});
overflowWeight += tx.weight; overflowWeight += tx.weight;
} }
totalWeight += tx.weight; totalWeight += tx.weight;

View file

@ -32,8 +32,10 @@ class BitcoinRoutes {
.get(config.MEMPOOL.API_URL_PREFIX + 'backend-info', this.getBackendInfo) .get(config.MEMPOOL.API_URL_PREFIX + 'backend-info', this.getBackendInfo)
.get(config.MEMPOOL.API_URL_PREFIX + 'init-data', this.getInitData) .get(config.MEMPOOL.API_URL_PREFIX + 'init-data', this.getInitData)
.get(config.MEMPOOL.API_URL_PREFIX + 'validate-address/:address', this.validateAddress) .get(config.MEMPOOL.API_URL_PREFIX + 'validate-address/:address', this.validateAddress)
.get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/replaces', this.getRbfHistory) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/rbf', this.getRbfHistory)
.get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/cached', this.getCachedTx) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/cached', this.getCachedTx)
.get(config.MEMPOOL.API_URL_PREFIX + 'replacements', this.getRbfReplacements)
.get(config.MEMPOOL.API_URL_PREFIX + 'fullrbf/replacements', this.getFullRbfReplacements)
.post(config.MEMPOOL.API_URL_PREFIX + 'tx/push', this.$postTransactionForm) .post(config.MEMPOOL.API_URL_PREFIX + 'tx/push', this.$postTransactionForm)
.get(config.MEMPOOL.API_URL_PREFIX + 'donations', async (req, res) => { .get(config.MEMPOOL.API_URL_PREFIX + 'donations', async (req, res) => {
try { try {
@ -94,6 +96,7 @@ class BitcoinRoutes {
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash', this.getBlock) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash', this.getBlock)
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/summary', this.getStrippedBlockTransactions) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/summary', this.getStrippedBlockTransactions)
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/audit-summary', this.getBlockAuditSummary) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/audit-summary', this.getBlockAuditSummary)
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks/tip/height', this.getBlockTipHeight)
.post(config.MEMPOOL.API_URL_PREFIX + 'psbt/addparents', this.postPsbtCompletion) .post(config.MEMPOOL.API_URL_PREFIX + 'psbt/addparents', this.postPsbtCompletion)
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from', this.getBlocksByBulk.bind(this)) .get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from', this.getBlocksByBulk.bind(this))
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from/:to', this.getBlocksByBulk.bind(this)) .get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from/:to', this.getBlocksByBulk.bind(this))
@ -110,7 +113,6 @@ class BitcoinRoutes {
.get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/status', this.getTransactionStatus) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/status', this.getTransactionStatus)
.get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/outspends', this.getTransactionOutspends) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/outspends', this.getTransactionOutspends)
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/header', this.getBlockHeader) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/header', this.getBlockHeader)
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks/tip/height', this.getBlockTipHeight)
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks/tip/hash', this.getBlockTipHash) .get(config.MEMPOOL.API_URL_PREFIX + 'blocks/tip/hash', this.getBlockTipHash)
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/raw', this.getRawBlock) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/raw', this.getRawBlock)
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/txids', this.getTxIdsForBlock) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/txids', this.getTxIdsForBlock)
@ -128,8 +130,9 @@ class BitcoinRoutes {
private getInitData(req: Request, res: Response) { private getInitData(req: Request, res: Response) {
try { try {
const result = websocketHandler.getInitData(); const result = websocketHandler.getSerializedInitData();
res.json(result); res.set('Content-Type', 'application/json');
res.send(result);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); res.status(500).send(e instanceof Error ? e.message : e);
} }
@ -589,10 +592,14 @@ class BitcoinRoutes {
} }
} }
private async getBlockTipHeight(req: Request, res: Response) { private getBlockTipHeight(req: Request, res: Response) {
try { try {
const result = await bitcoinApi.$getBlockHeightTip(); const result = blocks.getCurrentBlockHeight();
res.json(result); if (!result) {
return res.status(503).send(`Service Temporarily Unavailable`);
}
res.setHeader('content-type', 'text/plain');
res.send(result.toString());
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); res.status(500).send(e instanceof Error ? e.message : e);
} }
@ -638,8 +645,30 @@ class BitcoinRoutes {
private async getRbfHistory(req: Request, res: Response) { private async getRbfHistory(req: Request, res: Response) {
try { try {
const result = rbfCache.getReplaces(req.params.txId); const replacements = rbfCache.getRbfTree(req.params.txId) || null;
res.json(result || []); const replaces = rbfCache.getReplaces(req.params.txId) || null;
res.json({
replacements,
replaces
});
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async getRbfReplacements(req: Request, res: Response) {
try {
const result = rbfCache.getRbfTrees(false);
res.json(result);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async getFullRbfReplacements(req: Request, res: Response) {
try {
const result = rbfCache.getRbfTrees(true);
res.json(result);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); res.status(500).send(e instanceof Error ? e.message : e);
} }

View file

@ -36,6 +36,8 @@ class Blocks {
private newBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void)[] = []; private newBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void)[] = [];
private newAsyncBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => Promise<void>)[] = []; private newAsyncBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => Promise<void>)[] = [];
private mainLoopTimeout: number = 120000;
constructor() { } constructor() { }
public getBlocks(): BlockExtended[] { public getBlocks(): BlockExtended[] {
@ -527,9 +529,16 @@ class Blocks {
return await BlocksRepository.$validateChain(); return await BlocksRepository.$validateChain();
} }
public async $updateBlocks() { public async $updateBlocks(): Promise<number> {
// warn if this run stalls the main loop for more than 2 minutes
const timer = this.startTimer();
diskCache.lock();
let fastForwarded = false; let fastForwarded = false;
let handledBlocks = 0;
const blockHeightTip = await bitcoinApi.$getBlockHeightTip(); const blockHeightTip = await bitcoinApi.$getBlockHeightTip();
this.updateTimerProgress(timer, 'got block height tip');
if (this.blocks.length === 0) { if (this.blocks.length === 0) {
this.currentBlockHeight = Math.max(blockHeightTip - config.MEMPOOL.INITIAL_BLOCKS_AMOUNT, -1); this.currentBlockHeight = Math.max(blockHeightTip - config.MEMPOOL.INITIAL_BLOCKS_AMOUNT, -1);
@ -547,16 +556,21 @@ class Blocks {
if (!this.lastDifficultyAdjustmentTime) { if (!this.lastDifficultyAdjustmentTime) {
const blockchainInfo = await bitcoinClient.getBlockchainInfo(); const blockchainInfo = await bitcoinClient.getBlockchainInfo();
this.updateTimerProgress(timer, 'got blockchain info for initial difficulty adjustment');
if (blockchainInfo.blocks === blockchainInfo.headers) { if (blockchainInfo.blocks === blockchainInfo.headers) {
const heightDiff = blockHeightTip % 2016; const heightDiff = blockHeightTip % 2016;
const blockHash = await bitcoinApi.$getBlockHash(blockHeightTip - heightDiff); const blockHash = await bitcoinApi.$getBlockHash(blockHeightTip - heightDiff);
this.updateTimerProgress(timer, 'got block hash for initial difficulty adjustment');
const block: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(blockHash); const block: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(blockHash);
this.updateTimerProgress(timer, 'got block for initial difficulty adjustment');
this.lastDifficultyAdjustmentTime = block.timestamp; this.lastDifficultyAdjustmentTime = block.timestamp;
this.currentDifficulty = block.difficulty; this.currentDifficulty = block.difficulty;
if (blockHeightTip >= 2016) { if (blockHeightTip >= 2016) {
const previousPeriodBlockHash = await bitcoinApi.$getBlockHash(blockHeightTip - heightDiff - 2016); const previousPeriodBlockHash = await bitcoinApi.$getBlockHash(blockHeightTip - heightDiff - 2016);
this.updateTimerProgress(timer, 'got previous block hash for initial difficulty adjustment');
const previousPeriodBlock: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(previousPeriodBlockHash); const previousPeriodBlock: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(previousPeriodBlockHash);
this.updateTimerProgress(timer, 'got previous block for initial difficulty adjustment');
this.previousDifficultyRetarget = (block.difficulty - previousPeriodBlock.difficulty) / previousPeriodBlock.difficulty * 100; this.previousDifficultyRetarget = (block.difficulty - previousPeriodBlock.difficulty) / previousPeriodBlock.difficulty * 100;
logger.debug(`Initial difficulty adjustment data set.`); logger.debug(`Initial difficulty adjustment data set.`);
} }
@ -571,9 +585,11 @@ class Blocks {
} else { } else {
this.currentBlockHeight++; this.currentBlockHeight++;
logger.debug(`New block found (#${this.currentBlockHeight})!`); logger.debug(`New block found (#${this.currentBlockHeight})!`);
this.updateTimerProgress(timer, `getting orphaned blocks for ${this.currentBlockHeight}`);
await chainTips.updateOrphanedBlocks(); await chainTips.updateOrphanedBlocks();
} }
this.updateTimerProgress(timer, `getting block data for ${this.currentBlockHeight}`);
const blockHash = await bitcoinApi.$getBlockHash(this.currentBlockHeight); const blockHash = await bitcoinApi.$getBlockHash(this.currentBlockHeight);
const verboseBlock = await bitcoinClient.getBlock(blockHash, 2); const verboseBlock = await bitcoinClient.getBlock(blockHash, 2);
const block = BitcoinApi.convertBlock(verboseBlock); const block = BitcoinApi.convertBlock(verboseBlock);
@ -582,39 +598,51 @@ class Blocks {
const cpfpSummary: CpfpSummary = Common.calculateCpfp(block.height, transactions); const cpfpSummary: CpfpSummary = Common.calculateCpfp(block.height, transactions);
const blockExtended: BlockExtended = await this.$getBlockExtended(block, cpfpSummary.transactions); const blockExtended: BlockExtended = await this.$getBlockExtended(block, cpfpSummary.transactions);
const blockSummary: BlockSummary = this.summarizeBlock(verboseBlock); const blockSummary: BlockSummary = this.summarizeBlock(verboseBlock);
this.updateTimerProgress(timer, `got block data for ${this.currentBlockHeight}`);
// start async callbacks // start async callbacks
this.updateTimerProgress(timer, `starting async callbacks for ${this.currentBlockHeight}`);
const callbackPromises = this.newAsyncBlockCallbacks.map((cb) => cb(blockExtended, txIds, transactions)); const callbackPromises = this.newAsyncBlockCallbacks.map((cb) => cb(blockExtended, txIds, transactions));
if (Common.indexingEnabled()) { if (Common.indexingEnabled()) {
if (!fastForwarded) { if (!fastForwarded) {
const lastBlock = await blocksRepository.$getBlockByHeight(blockExtended.height - 1); const lastBlock = await blocksRepository.$getBlockByHeight(blockExtended.height - 1);
this.updateTimerProgress(timer, `got block by height for ${this.currentBlockHeight}`);
if (lastBlock !== null && blockExtended.previousblockhash !== lastBlock.id) { if (lastBlock !== null && blockExtended.previousblockhash !== lastBlock.id) {
logger.warn(`Chain divergence detected at block ${lastBlock.height}, re-indexing most recent data`, logger.tags.mining); logger.warn(`Chain divergence detected at block ${lastBlock.height}, re-indexing most recent data`, logger.tags.mining);
// We assume there won't be a reorg with more than 10 block depth // We assume there won't be a reorg with more than 10 block depth
this.updateTimerProgress(timer, `rolling back diverged chain from ${this.currentBlockHeight}`);
await BlocksRepository.$deleteBlocksFrom(lastBlock.height - 10); await BlocksRepository.$deleteBlocksFrom(lastBlock.height - 10);
await HashratesRepository.$deleteLastEntries(); await HashratesRepository.$deleteLastEntries();
await cpfpRepository.$deleteClustersFrom(lastBlock.height - 10); await cpfpRepository.$deleteClustersFrom(lastBlock.height - 10);
this.updateTimerProgress(timer, `rolled back chain divergence from ${this.currentBlockHeight}`);
for (let i = 10; i >= 0; --i) { for (let i = 10; i >= 0; --i) {
const newBlock = await this.$indexBlock(lastBlock.height - i); const newBlock = await this.$indexBlock(lastBlock.height - i);
this.updateTimerProgress(timer, `reindexed block`);
await this.$getStrippedBlockTransactions(newBlock.id, true, true); await this.$getStrippedBlockTransactions(newBlock.id, true, true);
this.updateTimerProgress(timer, `reindexed block summary`);
if (config.MEMPOOL.CPFP_INDEXING) { if (config.MEMPOOL.CPFP_INDEXING) {
await this.$indexCPFP(newBlock.id, lastBlock.height - i); await this.$indexCPFP(newBlock.id, lastBlock.height - i);
this.updateTimerProgress(timer, `reindexed block cpfp`);
} }
} }
await mining.$indexDifficultyAdjustments(); await mining.$indexDifficultyAdjustments();
await DifficultyAdjustmentsRepository.$deleteLastAdjustment(); await DifficultyAdjustmentsRepository.$deleteLastAdjustment();
this.updateTimerProgress(timer, `reindexed difficulty adjustments`);
logger.info(`Re-indexed 10 blocks and summaries. Also re-indexed the last difficulty adjustments. Will re-index latest hashrates in a few seconds.`, logger.tags.mining); logger.info(`Re-indexed 10 blocks and summaries. Also re-indexed the last difficulty adjustments. Will re-index latest hashrates in a few seconds.`, logger.tags.mining);
indexer.reindex(); indexer.reindex();
} }
await blocksRepository.$saveBlockInDatabase(blockExtended); await blocksRepository.$saveBlockInDatabase(blockExtended);
this.updateTimerProgress(timer, `saved ${this.currentBlockHeight} to database`);
const lastestPriceId = await PricesRepository.$getLatestPriceId(); const lastestPriceId = await PricesRepository.$getLatestPriceId();
this.updateTimerProgress(timer, `got latest price id ${this.currentBlockHeight}`);
if (priceUpdater.historyInserted === true && lastestPriceId !== null) { if (priceUpdater.historyInserted === true && lastestPriceId !== null) {
await blocksRepository.$saveBlockPrices([{ await blocksRepository.$saveBlockPrices([{
height: blockExtended.height, height: blockExtended.height,
priceId: lastestPriceId, priceId: lastestPriceId,
}]); }]);
this.updateTimerProgress(timer, `saved prices for ${this.currentBlockHeight}`);
} else { } else {
logger.debug(`Cannot save block price for ${blockExtended.height} because the price updater hasnt completed yet. Trying again in 10 seconds.`, logger.tags.mining); logger.debug(`Cannot save block price for ${blockExtended.height} because the price updater hasnt completed yet. Trying again in 10 seconds.`, logger.tags.mining);
setTimeout(() => { setTimeout(() => {
@ -625,9 +653,11 @@ class Blocks {
// Save blocks summary for visualization if it's enabled // Save blocks summary for visualization if it's enabled
if (Common.blocksSummariesIndexingEnabled() === true) { if (Common.blocksSummariesIndexingEnabled() === true) {
await this.$getStrippedBlockTransactions(blockExtended.id, true); await this.$getStrippedBlockTransactions(blockExtended.id, true);
this.updateTimerProgress(timer, `saved block summary for ${this.currentBlockHeight}`);
} }
if (config.MEMPOOL.CPFP_INDEXING) { if (config.MEMPOOL.CPFP_INDEXING) {
this.$saveCpfp(blockExtended.id, this.currentBlockHeight, cpfpSummary); this.$saveCpfp(blockExtended.id, this.currentBlockHeight, cpfpSummary);
this.updateTimerProgress(timer, `saved cpfp for ${this.currentBlockHeight}`);
} }
} }
} }
@ -640,6 +670,7 @@ class Blocks {
difficulty: block.difficulty, difficulty: block.difficulty,
adjustment: Math.round((block.difficulty / this.currentDifficulty) * 1000000) / 1000000, // Remove float point noise adjustment: Math.round((block.difficulty / this.currentDifficulty) * 1000000) / 1000000, // Remove float point noise
}); });
this.updateTimerProgress(timer, `saved difficulty adjustment for ${this.currentBlockHeight}`);
} }
this.previousDifficultyRetarget = (block.difficulty - this.currentDifficulty) / this.currentDifficulty * 100; this.previousDifficultyRetarget = (block.difficulty - this.currentDifficulty) / this.currentDifficulty * 100;
@ -664,7 +695,39 @@ class Blocks {
} }
// wait for pending async callbacks to finish // wait for pending async callbacks to finish
this.updateTimerProgress(timer, `waiting for async callbacks to complete for ${this.currentBlockHeight}`);
await Promise.all(callbackPromises); await Promise.all(callbackPromises);
this.updateTimerProgress(timer, `async callbacks completed for ${this.currentBlockHeight}`);
handledBlocks++;
}
diskCache.unlock();
this.clearTimer(timer);
return handledBlocks;
}
private startTimer() {
const state: any = {
start: Date.now(),
progress: 'begin $updateBlocks',
timer: null,
};
state.timer = setTimeout(() => {
logger.err(`$updateBlocks stalled at "${state.progress}"`);
}, this.mainLoopTimeout);
return state;
}
private updateTimerProgress(state, msg) {
state.progress = msg;
}
private clearTimer(state) {
if (state.timer) {
clearTimeout(state.timer);
} }
} }

View file

@ -1,4 +1,4 @@
import { Ancestor, CpfpInfo, CpfpSummary, EffectiveFeeStats, MempoolBlockWithTransactions, TransactionExtended, TransactionStripped } from '../mempool.interfaces'; import { Ancestor, CpfpInfo, CpfpSummary, EffectiveFeeStats, MempoolBlockWithTransactions, TransactionExtended, TransactionStripped, WorkingEffectiveFeeStats } from '../mempool.interfaces';
import config from '../config'; import config from '../config';
import { NodeSocket } from '../repositories/NodesSocketsRepository'; import { NodeSocket } from '../repositories/NodesSocketsRepository';
import { isIP } from 'net'; import { isIP } from 'net';
@ -57,11 +57,11 @@ export class Common {
return arr; return arr;
} }
static findRbfTransactions(added: TransactionExtended[], deleted: TransactionExtended[]): { [txid: string]: TransactionExtended } { static findRbfTransactions(added: TransactionExtended[], deleted: TransactionExtended[]): { [txid: string]: TransactionExtended[] } {
const matches: { [txid: string]: TransactionExtended } = {}; const matches: { [txid: string]: TransactionExtended[] } = {};
deleted added
.forEach((deletedTx) => { .forEach((addedTx) => {
const foundMatches = added.find((addedTx) => { const foundMatches = deleted.filter((deletedTx) => {
// The new tx must, absolutely speaking, pay at least as much fee as the replaced tx. // The new tx must, absolutely speaking, pay at least as much fee as the replaced tx.
return addedTx.fee > deletedTx.fee return addedTx.fee > deletedTx.fee
// The new transaction must pay more fee per kB than the replaced tx. // The new transaction must pay more fee per kB than the replaced tx.
@ -70,8 +70,8 @@ export class Common {
&& deletedTx.vin.some((deletedVin) => && deletedTx.vin.some((deletedVin) =>
addedTx.vin.some((vin) => vin.txid === deletedVin.txid && vin.vout === deletedVin.vout)); addedTx.vin.some((vin) => vin.txid === deletedVin.txid && vin.vout === deletedVin.vout));
}); });
if (foundMatches) { if (foundMatches?.length) {
matches[deletedTx.txid] = foundMatches; matches[addedTx.txid] = foundMatches;
} }
}); });
return matches; return matches;
@ -83,6 +83,7 @@ export class Common {
fee: tx.fee, fee: tx.fee,
vsize: tx.weight / 4, vsize: tx.weight / 4,
value: tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0), value: tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0),
rate: tx.effectiveFeePerVsize,
}; };
} }
@ -441,3 +442,119 @@ export class Common {
return sortedDistribution[Math.floor((sortedDistribution.length - 1) * (n / 100))]; return sortedDistribution[Math.floor((sortedDistribution.length - 1) * (n / 100))];
} }
} }
/**
* 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;
}
}

View file

@ -34,11 +34,12 @@ export function calcDifficultyAdjustment(
const remainingBlocks = EPOCH_BLOCK_LENGTH - blocksInEpoch; const remainingBlocks = EPOCH_BLOCK_LENGTH - blocksInEpoch;
const nextRetargetHeight = (blockHeight >= 0) ? blockHeight + remainingBlocks : 0; const nextRetargetHeight = (blockHeight >= 0) ? blockHeight + remainingBlocks : 0;
const expectedBlocks = diffSeconds / BLOCK_SECONDS_TARGET; const expectedBlocks = diffSeconds / BLOCK_SECONDS_TARGET;
const actualTimespan = (blocksInEpoch === 2015 ? latestBlockTimestamp : nowSeconds) - DATime;
let difficultyChange = 0; let difficultyChange = 0;
let timeAvgSecs = blocksInEpoch ? diffSeconds / blocksInEpoch : BLOCK_SECONDS_TARGET; let timeAvgSecs = blocksInEpoch ? diffSeconds / blocksInEpoch : BLOCK_SECONDS_TARGET;
difficultyChange = (BLOCK_SECONDS_TARGET / timeAvgSecs - 1) * 100; difficultyChange = (BLOCK_SECONDS_TARGET / (actualTimespan / (blocksInEpoch + 1)) - 1) * 100;
// Max increase is x4 (+300%) // Max increase is x4 (+300%)
if (difficultyChange > 300) { if (difficultyChange > 300) {
difficultyChange = 300; difficultyChange = 300;

View file

@ -7,17 +7,26 @@ import logger from '../logger';
import config from '../config'; import config from '../config';
import { TransactionExtended } from '../mempool.interfaces'; import { TransactionExtended } from '../mempool.interfaces';
import { Common } from './common'; import { Common } from './common';
import rbfCache from './rbf-cache';
class DiskCache { class DiskCache {
private cacheSchemaVersion = 3; private cacheSchemaVersion = 3;
private rbfCacheSchemaVersion = 1;
private static TMP_FILE_NAME = config.MEMPOOL.CACHE_DIR + '/tmp-cache.json'; private static TMP_FILE_NAME = config.MEMPOOL.CACHE_DIR + '/tmp-cache.json';
private static TMP_FILE_NAMES = config.MEMPOOL.CACHE_DIR + '/tmp-cache{number}.json'; private static TMP_FILE_NAMES = config.MEMPOOL.CACHE_DIR + '/tmp-cache{number}.json';
private static FILE_NAME = config.MEMPOOL.CACHE_DIR + '/cache.json'; private static FILE_NAME = config.MEMPOOL.CACHE_DIR + '/cache.json';
private static FILE_NAMES = config.MEMPOOL.CACHE_DIR + '/cache{number}.json'; private static FILE_NAMES = config.MEMPOOL.CACHE_DIR + '/cache{number}.json';
private static TMP_RBF_FILE_NAME = config.MEMPOOL.CACHE_DIR + '/tmp-rbfcache.json';
private static RBF_FILE_NAME = config.MEMPOOL.CACHE_DIR + '/rbfcache.json';
private static CHUNK_FILES = 25; private static CHUNK_FILES = 25;
private isWritingCache = false; private isWritingCache = false;
private semaphore: { resume: (() => void)[], locks: number } = {
resume: [],
locks: 0,
};
constructor() { constructor() {
if (!cluster.isPrimary) { if (!cluster.isPrimary) {
return; return;
@ -43,7 +52,7 @@ class DiskCache {
const mempool = memPool.getMempool(); const mempool = memPool.getMempool();
const mempoolArray: TransactionExtended[] = []; const mempoolArray: TransactionExtended[] = [];
for (const tx in mempool) { for (const tx in mempool) {
if (mempool[tx] && !mempool[tx].deleteAfter) { if (mempool[tx]) {
mempoolArray.push(mempool[tx]); mempoolArray.push(mempool[tx]);
} }
} }
@ -73,6 +82,7 @@ class DiskCache {
fs.renameSync(DiskCache.TMP_FILE_NAMES.replace('{number}', i.toString()), DiskCache.FILE_NAMES.replace('{number}', i.toString())); fs.renameSync(DiskCache.TMP_FILE_NAMES.replace('{number}', i.toString()), DiskCache.FILE_NAMES.replace('{number}', i.toString()));
} }
} else { } else {
await this.$yield();
await fsPromises.writeFile(DiskCache.TMP_FILE_NAME, JSON.stringify({ await fsPromises.writeFile(DiskCache.TMP_FILE_NAME, JSON.stringify({
network: config.MEMPOOL.NETWORK, network: config.MEMPOOL.NETWORK,
cacheSchemaVersion: this.cacheSchemaVersion, cacheSchemaVersion: this.cacheSchemaVersion,
@ -82,6 +92,7 @@ class DiskCache {
mempoolArray: mempoolArray.splice(0, chunkSize), mempoolArray: mempoolArray.splice(0, chunkSize),
}), { flag: 'w' }); }), { flag: 'w' });
for (let i = 1; i < DiskCache.CHUNK_FILES; i++) { for (let i = 1; i < DiskCache.CHUNK_FILES; i++) {
await this.$yield();
await fsPromises.writeFile(DiskCache.TMP_FILE_NAMES.replace('{number}', i.toString()), JSON.stringify({ await fsPromises.writeFile(DiskCache.TMP_FILE_NAMES.replace('{number}', i.toString()), JSON.stringify({
mempool: {}, mempool: {},
mempoolArray: mempoolArray.splice(0, chunkSize), mempoolArray: mempoolArray.splice(0, chunkSize),
@ -100,6 +111,32 @@ class DiskCache {
logger.warn('Error writing to cache file: ' + (e instanceof Error ? e.message : e)); logger.warn('Error writing to cache file: ' + (e instanceof Error ? e.message : e));
this.isWritingCache = false; this.isWritingCache = false;
} }
try {
logger.debug('Writing rbf data to disk cache (async)...');
this.isWritingCache = true;
const rbfData = rbfCache.dump();
if (sync) {
fs.writeFileSync(DiskCache.TMP_RBF_FILE_NAME, JSON.stringify({
network: config.MEMPOOL.NETWORK,
rbfCacheSchemaVersion: this.rbfCacheSchemaVersion,
rbf: rbfData,
}), { flag: 'w' });
fs.renameSync(DiskCache.TMP_RBF_FILE_NAME, DiskCache.RBF_FILE_NAME);
} else {
await fsPromises.writeFile(DiskCache.TMP_RBF_FILE_NAME, JSON.stringify({
network: config.MEMPOOL.NETWORK,
rbfCacheSchemaVersion: this.rbfCacheSchemaVersion,
rbf: rbfData,
}), { flag: 'w' });
await fsPromises.rename(DiskCache.TMP_RBF_FILE_NAME, DiskCache.RBF_FILE_NAME);
}
logger.debug('Rbf data saved to disk cache');
this.isWritingCache = false;
} catch (e) {
logger.warn('Error writing rbf data to cache file: ' + (e instanceof Error ? e.message : e));
this.isWritingCache = false;
}
} }
wipeCache(): void { wipeCache(): void {
@ -124,7 +161,19 @@ class DiskCache {
} }
} }
loadMempoolCache(): void { wipeRbfCache() {
logger.notice(`Wipping nodejs backend cache/rbfcache.json file`);
try {
fs.unlinkSync(DiskCache.RBF_FILE_NAME);
} catch (e: any) {
if (e?.code !== 'ENOENT') {
logger.err(`Cannot wipe cache file ${DiskCache.RBF_FILE_NAME}. Exception ${JSON.stringify(e)}`);
}
}
}
async $loadMempoolCache(): Promise<void> {
if (!fs.existsSync(DiskCache.FILE_NAME)) { if (!fs.existsSync(DiskCache.FILE_NAME)) {
return; return;
} }
@ -168,12 +217,61 @@ class DiskCache {
} }
} }
memPool.setMempool(data.mempool); await memPool.$setMempool(data.mempool);
blocks.setBlocks(data.blocks); blocks.setBlocks(data.blocks);
blocks.setBlockSummaries(data.blockSummaries || []); blocks.setBlockSummaries(data.blockSummaries || []);
} catch (e) { } catch (e) {
logger.warn('Failed to parse mempoool and blocks cache. Skipping. Reason: ' + (e instanceof Error ? e.message : e)); logger.warn('Failed to parse mempoool and blocks cache. Skipping. Reason: ' + (e instanceof Error ? e.message : e));
} }
try {
let rbfData: any = {};
const rbfCacheData = fs.readFileSync(DiskCache.RBF_FILE_NAME, 'utf8');
if (rbfCacheData) {
logger.info('Restoring rbf data from disk cache');
rbfData = JSON.parse(rbfCacheData);
if (rbfData.rbfCacheSchemaVersion === undefined || rbfData.rbfCacheSchemaVersion !== this.rbfCacheSchemaVersion) {
logger.notice('Rbf disk cache contains an outdated schema version. Clearing it and skipping the cache loading.');
return this.wipeRbfCache();
}
if (rbfData.network && rbfData.network !== config.MEMPOOL.NETWORK) {
logger.notice('Rbf disk cache contains data from a different network. Clearing it and skipping the cache loading.');
return this.wipeRbfCache();
}
}
if (rbfData?.rbf) {
rbfCache.load(rbfData.rbf);
}
} catch (e) {
logger.warn('Failed to parse rbf cache. Skipping. Reason: ' + (e instanceof Error ? e.message : e));
}
}
private $yield(): Promise<void> {
if (this.semaphore.locks) {
logger.debug('Pause writing mempool and blocks data to disk cache (async)');
return new Promise((resolve) => {
this.semaphore.resume.push(resolve);
});
} else {
return Promise.resolve();
}
}
public lock(): void {
this.semaphore.locks++;
}
public unlock(): void {
this.semaphore.locks = Math.max(0, this.semaphore.locks - 1);
if (!this.semaphore.locks && this.semaphore.resume.length) {
const nextResume = this.semaphore.resume.shift();
if (nextResume) {
logger.debug('Resume writing mempool and blocks data to disk cache (async)');
nextResume();
}
}
} }
} }

View file

@ -1,6 +1,6 @@
import logger from '../logger'; import logger from '../logger';
import { MempoolBlock, TransactionExtended, ThreadTransaction, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor } from '../mempool.interfaces'; import { MempoolBlock, TransactionExtended, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor, CompactThreadTransaction, EffectiveFeeStats } from '../mempool.interfaces';
import { Common } from './common'; import { Common, OnlineFeeStatsCalculator } from './common';
import config from '../config'; import config from '../config';
import { Worker } from 'worker_threads'; import { Worker } from 'worker_threads';
import path from 'path'; import path from 'path';
@ -10,6 +10,9 @@ class MempoolBlocks {
private mempoolBlockDeltas: MempoolBlockDelta[] = []; private mempoolBlockDeltas: MempoolBlockDelta[] = [];
private txSelectionWorker: Worker | null = null; private txSelectionWorker: Worker | null = null;
private nextUid: number = 1;
private uidMap: Map<number, string> = new Map(); // map short numerical uids to full txids
constructor() {} constructor() {}
public getMempoolBlocks(): MempoolBlock[] { public getMempoolBlocks(): MempoolBlock[] {
@ -54,7 +57,14 @@ class MempoolBlocks {
}); });
// First sort // First sort
memPoolArray.sort((a, b) => b.feePerVsize - a.feePerVsize); memPoolArray.sort((a, b) => {
if (a.feePerVsize === b.feePerVsize) {
// tie-break by lexicographic txid order for stability
return a.txid < b.txid ? -1 : 1;
} else {
return b.feePerVsize - a.feePerVsize;
}
});
// Loop through and traverse all ancestors and sum up all the sizes + fees // Loop through and traverse all ancestors and sum up all the sizes + fees
// Pass down size + fee to all unconfirmed children // Pass down size + fee to all unconfirmed children
@ -68,7 +78,14 @@ class MempoolBlocks {
}); });
// Final sort, by effective fee // Final sort, by effective fee
memPoolArray.sort((a, b) => b.effectiveFeePerVsize - a.effectiveFeePerVsize); memPoolArray.sort((a, b) => {
if (a.effectiveFeePerVsize === b.effectiveFeePerVsize) {
// tie-break by lexicographic txid order for stability
return a.txid < b.txid ? -1 : 1;
} else {
return b.effectiveFeePerVsize - a.effectiveFeePerVsize;
}
});
const end = new Date().getTime(); const end = new Date().getTime();
const time = end - start; const time = end - start;
@ -87,21 +104,61 @@ class MempoolBlocks {
private calculateMempoolBlocks(transactionsSorted: TransactionExtended[]): MempoolBlockWithTransactions[] { private calculateMempoolBlocks(transactionsSorted: TransactionExtended[]): MempoolBlockWithTransactions[] {
const mempoolBlocks: MempoolBlockWithTransactions[] = []; const mempoolBlocks: MempoolBlockWithTransactions[] = [];
let feeStatsCalculator: OnlineFeeStatsCalculator = new OnlineFeeStatsCalculator(config.MEMPOOL.BLOCK_WEIGHT_UNITS);
let onlineStats = false;
let blockSize = 0;
let blockWeight = 0; let blockWeight = 0;
let blockVsize = 0;
let blockFees = 0;
const sizeLimit = (config.MEMPOOL.BLOCK_WEIGHT_UNITS / 4) * 1.2;
let transactionIds: string[] = [];
let transactions: TransactionExtended[] = []; let transactions: TransactionExtended[] = [];
transactionsSorted.forEach((tx) => { transactionsSorted.forEach((tx, index) => {
if (blockWeight + tx.weight <= config.MEMPOOL.BLOCK_WEIGHT_UNITS if (blockWeight + tx.weight <= config.MEMPOOL.BLOCK_WEIGHT_UNITS
|| mempoolBlocks.length === config.MEMPOOL.MEMPOOL_BLOCKS_AMOUNT - 1) { || mempoolBlocks.length === config.MEMPOOL.MEMPOOL_BLOCKS_AMOUNT - 1) {
tx.position = {
block: mempoolBlocks.length,
vsize: blockVsize + (tx.vsize / 2),
};
blockWeight += tx.weight; blockWeight += tx.weight;
transactions.push(tx); blockVsize += tx.vsize;
blockSize += tx.size;
blockFees += tx.fee;
if (blockVsize <= sizeLimit) {
transactions.push(tx);
}
transactionIds.push(tx.txid);
if (onlineStats) {
feeStatsCalculator.processNext(tx);
}
} else { } else {
mempoolBlocks.push(this.dataToMempoolBlocks(transactions)); mempoolBlocks.push(this.dataToMempoolBlocks(transactionIds, transactions, blockSize, blockWeight, blockFees));
blockVsize = 0;
tx.position = {
block: mempoolBlocks.length,
vsize: blockVsize + (tx.vsize / 2),
};
if (mempoolBlocks.length === config.MEMPOOL.MEMPOOL_BLOCKS_AMOUNT - 1) {
const stackWeight = transactionsSorted.slice(index).reduce((total, tx) => total + (tx.weight || 0), 0);
if (stackWeight > config.MEMPOOL.BLOCK_WEIGHT_UNITS) {
onlineStats = true;
feeStatsCalculator = new OnlineFeeStatsCalculator(stackWeight, 0.5);
feeStatsCalculator.processNext(tx);
}
}
blockVsize += tx.vsize;
blockWeight = tx.weight; blockWeight = tx.weight;
blockSize = tx.size;
blockFees = tx.fee;
transactionIds = [tx.txid];
transactions = [tx]; transactions = [tx];
} }
}); });
if (transactions.length) { if (transactions.length) {
mempoolBlocks.push(this.dataToMempoolBlocks(transactions)); const feeStats = onlineStats ? feeStatsCalculator.getRawFeeStats() : undefined;
mempoolBlocks.push(this.dataToMempoolBlocks(transactionIds, transactions, blockSize, blockWeight, blockFees, feeStats));
} }
return mempoolBlocks; return mempoolBlocks;
@ -112,6 +169,7 @@ class MempoolBlocks {
for (let i = 0; i < Math.max(mempoolBlocks.length, prevBlocks.length); i++) { for (let i = 0; i < Math.max(mempoolBlocks.length, prevBlocks.length); i++) {
let added: TransactionStripped[] = []; let added: TransactionStripped[] = [];
let removed: string[] = []; let removed: string[] = [];
const changed: { txid: string, rate: number | undefined }[] = [];
if (mempoolBlocks[i] && !prevBlocks[i]) { if (mempoolBlocks[i] && !prevBlocks[i]) {
added = mempoolBlocks[i].transactions; added = mempoolBlocks[i].transactions;
} else if (!mempoolBlocks[i] && prevBlocks[i]) { } else if (!mempoolBlocks[i] && prevBlocks[i]) {
@ -120,7 +178,7 @@ class MempoolBlocks {
const prevIds = {}; const prevIds = {};
const newIds = {}; const newIds = {};
prevBlocks[i].transactions.forEach(tx => { prevBlocks[i].transactions.forEach(tx => {
prevIds[tx.txid] = true; prevIds[tx.txid] = tx;
}); });
mempoolBlocks[i].transactions.forEach(tx => { mempoolBlocks[i].transactions.forEach(tx => {
newIds[tx.txid] = true; newIds[tx.txid] = true;
@ -133,30 +191,43 @@ class MempoolBlocks {
mempoolBlocks[i].transactions.forEach(tx => { mempoolBlocks[i].transactions.forEach(tx => {
if (!prevIds[tx.txid]) { if (!prevIds[tx.txid]) {
added.push(tx); added.push(tx);
} else if (tx.rate !== prevIds[tx.txid].rate) {
changed.push({ txid: tx.txid, rate: tx.rate });
} }
}); });
} }
mempoolBlockDeltas.push({ mempoolBlockDeltas.push({
added, added,
removed removed,
changed,
}); });
} }
return mempoolBlockDeltas; return mempoolBlockDeltas;
} }
public async makeBlockTemplates(newMempool: { [txid: string]: TransactionExtended }, saveResults: boolean = false): Promise<MempoolBlockWithTransactions[]> { public async $makeBlockTemplates(newMempool: { [txid: string]: TransactionExtended }, saveResults: boolean = false): Promise<MempoolBlockWithTransactions[]> {
const start = Date.now();
// reset mempool short ids
this.resetUids();
for (const tx of Object.values(newMempool)) {
this.setUid(tx);
}
// prepare a stripped down version of the mempool with only the minimum necessary data // prepare a stripped down version of the mempool with only the minimum necessary data
// to reduce the overhead of passing this data to the worker thread // to reduce the overhead of passing this data to the worker thread
const strippedMempool: { [txid: string]: ThreadTransaction } = {}; const strippedMempool: Map<number, CompactThreadTransaction> = new Map();
Object.values(newMempool).filter(tx => !tx.deleteAfter).forEach(entry => { Object.values(newMempool).forEach(entry => {
strippedMempool[entry.txid] = { if (entry.uid != null) {
txid: entry.txid, strippedMempool.set(entry.uid, {
fee: entry.fee, uid: entry.uid,
weight: entry.weight, fee: entry.fee,
feePerVsize: entry.fee / (entry.weight / 4), weight: entry.weight,
effectiveFeePerVsize: entry.fee / (entry.weight / 4), feePerVsize: entry.fee / (entry.weight / 4),
vin: entry.vin.map(v => v.txid), effectiveFeePerVsize: entry.effectiveFeePerVsize || (entry.fee / (entry.weight / 4)),
}; inputs: entry.vin.map(v => this.getUid(newMempool[v.txid])).filter(uid => uid != null) as number[],
});
}
}); });
// (re)initialize tx selection worker thread // (re)initialize tx selection worker thread
@ -175,7 +246,7 @@ class MempoolBlocks {
// run the block construction algorithm in a separate thread, and wait for a result // run the block construction algorithm in a separate thread, and wait for a result
let threadErrorListener; let threadErrorListener;
try { try {
const workerResultPromise = new Promise<{ blocks: ThreadTransaction[][], clusters: { [root: string]: string[] } }>((resolve, reject) => { const workerResultPromise = new Promise<{ blocks: number[][], rates: Map<number, number>, clusters: Map<number, number[]> }>((resolve, reject) => {
threadErrorListener = reject; threadErrorListener = reject;
this.txSelectionWorker?.once('message', (result): void => { this.txSelectionWorker?.once('message', (result): void => {
resolve(result); resolve(result);
@ -183,122 +254,167 @@ class MempoolBlocks {
this.txSelectionWorker?.once('error', reject); this.txSelectionWorker?.once('error', reject);
}); });
this.txSelectionWorker.postMessage({ type: 'set', mempool: strippedMempool }); this.txSelectionWorker.postMessage({ type: 'set', mempool: strippedMempool });
let { blocks, clusters } = await workerResultPromise; const { blocks, rates, clusters } = this.convertResultTxids(await workerResultPromise);
// filter out stale transactions
const unfilteredCount = blocks.reduce((total, block) => { return total + block.length; }, 0);
blocks = blocks.map(block => block.filter(tx => (tx.txid && tx.txid in newMempool)));
const filteredCount = blocks.reduce((total, block) => { return total + block.length; }, 0);
if (filteredCount < unfilteredCount) {
logger.warn(`tx selection worker thread returned ${unfilteredCount - filteredCount} stale transactions from makeBlockTemplates`);
}
// clean up thread error listener // clean up thread error listener
this.txSelectionWorker?.removeListener('error', threadErrorListener); this.txSelectionWorker?.removeListener('error', threadErrorListener);
return this.processBlockTemplates(newMempool, blocks, clusters, saveResults); const processed = this.processBlockTemplates(newMempool, blocks, rates, clusters, saveResults);
logger.debug(`makeBlockTemplates completed in ${(Date.now() - start)/1000} seconds`);
return processed;
} catch (e) { } catch (e) {
logger.err('makeBlockTemplates failed. ' + (e instanceof Error ? e.message : e)); logger.err('makeBlockTemplates failed. ' + (e instanceof Error ? e.message : e));
} }
return this.mempoolBlocks; return this.mempoolBlocks;
} }
public async updateBlockTemplates(newMempool: { [txid: string]: TransactionExtended }, added: TransactionExtended[], removed: string[], saveResults: boolean = false): Promise<void> { public async $updateBlockTemplates(newMempool: { [txid: string]: TransactionExtended }, added: TransactionExtended[], removed: TransactionExtended[], saveResults: boolean = false): Promise<void> {
if (!this.txSelectionWorker) { if (!this.txSelectionWorker) {
// need to reset the worker // need to reset the worker
this.makeBlockTemplates(newMempool, saveResults); await this.$makeBlockTemplates(newMempool, saveResults);
return; return;
} }
const start = Date.now();
for (const tx of Object.values(added)) {
this.setUid(tx);
}
const removedUids = removed.map(tx => this.getUid(tx)).filter(uid => uid != null) as number[];
// prepare a stripped down version of the mempool with only the minimum necessary data // prepare a stripped down version of the mempool with only the minimum necessary data
// to reduce the overhead of passing this data to the worker thread // to reduce the overhead of passing this data to the worker thread
const addedStripped: ThreadTransaction[] = added.map(entry => { const addedStripped: CompactThreadTransaction[] = added.filter(entry => entry.uid != null).map(entry => {
return { return {
txid: entry.txid, uid: entry.uid || 0,
fee: entry.fee, fee: entry.fee,
weight: entry.weight, weight: entry.weight,
feePerVsize: entry.fee / (entry.weight / 4), feePerVsize: entry.fee / (entry.weight / 4),
effectiveFeePerVsize: entry.fee / (entry.weight / 4), effectiveFeePerVsize: entry.effectiveFeePerVsize || (entry.fee / (entry.weight / 4)),
vin: entry.vin.map(v => v.txid), inputs: entry.vin.map(v => this.getUid(newMempool[v.txid])).filter(uid => uid != null) as number[],
}; };
}); });
// run the block construction algorithm in a separate thread, and wait for a result // run the block construction algorithm in a separate thread, and wait for a result
let threadErrorListener; let threadErrorListener;
try { try {
const workerResultPromise = new Promise<{ blocks: ThreadTransaction[][], clusters: { [root: string]: string[] } }>((resolve, reject) => { const workerResultPromise = new Promise<{ blocks: number[][], rates: Map<number, number>, clusters: Map<number, number[]> }>((resolve, reject) => {
threadErrorListener = reject; threadErrorListener = reject;
this.txSelectionWorker?.once('message', (result): void => { this.txSelectionWorker?.once('message', (result): void => {
resolve(result); resolve(result);
}); });
this.txSelectionWorker?.once('error', reject); this.txSelectionWorker?.once('error', reject);
}); });
this.txSelectionWorker.postMessage({ type: 'update', added: addedStripped, removed }); this.txSelectionWorker.postMessage({ type: 'update', added: addedStripped, removed: removedUids });
let { blocks, clusters } = await workerResultPromise; const { blocks, rates, clusters } = this.convertResultTxids(await workerResultPromise);
// filter out stale transactions
const unfilteredCount = blocks.reduce((total, block) => { return total + block.length; }, 0); this.removeUids(removedUids);
blocks = blocks.map(block => block.filter(tx => (tx.txid && tx.txid in newMempool)));
const filteredCount = blocks.reduce((total, block) => { return total + block.length; }, 0);
if (filteredCount < unfilteredCount) {
logger.warn(`tx selection worker thread returned ${unfilteredCount - filteredCount} stale transactions from updateBlockTemplates`);
}
// clean up thread error listener // clean up thread error listener
this.txSelectionWorker?.removeListener('error', threadErrorListener); this.txSelectionWorker?.removeListener('error', threadErrorListener);
this.processBlockTemplates(newMempool, blocks, clusters, saveResults); this.processBlockTemplates(newMempool, blocks, rates, clusters, saveResults);
logger.debug(`updateBlockTemplates completed in ${(Date.now() - start) / 1000} seconds`);
} catch (e) { } catch (e) {
logger.err('updateBlockTemplates failed. ' + (e instanceof Error ? e.message : e)); logger.err('updateBlockTemplates failed. ' + (e instanceof Error ? e.message : e));
} }
} }
private processBlockTemplates(mempool, blocks, clusters, saveResults): MempoolBlockWithTransactions[] { private processBlockTemplates(mempool, blocks: string[][], rates: { [root: string]: number }, clusters: { [root: string]: string[] }, saveResults): MempoolBlockWithTransactions[] {
// update this thread's mempool with the results for (const txid of Object.keys(rates)) {
blocks.forEach(block => { if (txid in mempool) {
block.forEach(tx => { mempool[txid].effectiveFeePerVsize = rates[txid];
if (tx.txid && tx.txid in mempool) { }
if (tx.effectiveFeePerVsize != null) { }
mempool[tx.txid].effectiveFeePerVsize = tx.effectiveFeePerVsize;
}
if (tx.cpfpRoot && tx.cpfpRoot in clusters) {
const ancestors: Ancestor[] = [];
const descendants: Ancestor[] = [];
const cluster = clusters[tx.cpfpRoot];
let matched = false;
cluster.forEach(txid => {
if (!txid || !mempool[txid]) {
logger.warn('projected transaction ancestor missing from mempool cache');
return;
}
if (txid === tx.txid) {
matched = true;
} else {
const relative = {
txid: txid,
fee: mempool[txid].fee,
weight: mempool[txid].weight,
};
if (matched) {
descendants.push(relative);
} else {
ancestors.push(relative);
}
}
});
mempool[tx.txid].ancestors = ancestors;
mempool[tx.txid].descendants = descendants;
mempool[tx.txid].bestDescendant = null;
}
mempool[tx.txid].cpfpChecked = tx.cpfpChecked;
} else {
logger.warn('projected transaction missing from mempool cache');
}
});
});
// unpack the condensed blocks into proper mempool blocks let hasBlockStack = blocks.length >= 8;
const mempoolBlocks = blocks.map((transactions) => { let stackWeight;
return this.dataToMempoolBlocks(transactions.map(tx => { let feeStatsCalculator: OnlineFeeStatsCalculator | void;
return mempool[tx.txid] || null; if (hasBlockStack) {
}).filter(tx => !!tx)); stackWeight = blocks[blocks.length - 1].reduce((total, tx) => total + (mempool[tx]?.weight || 0), 0);
hasBlockStack = stackWeight > config.MEMPOOL.BLOCK_WEIGHT_UNITS;
feeStatsCalculator = new OnlineFeeStatsCalculator(stackWeight, 0.5);
}
const readyBlocks: { transactionIds, transactions, totalSize, totalWeight, totalFees, feeStats }[] = [];
const sizeLimit = (config.MEMPOOL.BLOCK_WEIGHT_UNITS / 4) * 1.2;
// update this thread's mempool with the results
for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
const block: string[] = blocks[blockIndex];
let txid: string;
let mempoolTx: TransactionExtended;
let totalSize = 0;
let totalVsize = 0;
let totalWeight = 0;
let totalFees = 0;
const transactions: TransactionExtended[] = [];
for (let txIndex = 0; txIndex < block.length; txIndex++) {
txid = block[txIndex];
if (txid) {
mempoolTx = mempool[txid];
// save position in projected blocks
mempoolTx.position = {
block: blockIndex,
vsize: totalVsize + (mempoolTx.vsize / 2),
};
mempoolTx.cpfpChecked = true;
// online calculation of stack-of-blocks fee stats
if (hasBlockStack && blockIndex === blocks.length - 1 && feeStatsCalculator) {
feeStatsCalculator.processNext(mempoolTx);
}
totalSize += mempoolTx.size;
totalVsize += mempoolTx.vsize;
totalWeight += mempoolTx.weight;
totalFees += mempoolTx.fee;
if (totalVsize <= sizeLimit) {
transactions.push(mempoolTx);
}
}
}
readyBlocks.push({
transactionIds: block,
transactions,
totalSize,
totalWeight,
totalFees,
feeStats: (hasBlockStack && blockIndex === blocks.length - 1 && feeStatsCalculator) ? feeStatsCalculator.getRawFeeStats() : undefined,
});
}
for (const cluster of Object.values(clusters)) {
for (const memberTxid of cluster) {
if (memberTxid in mempool) {
const mempoolTx = mempool[memberTxid];
const ancestors: Ancestor[] = [];
const descendants: Ancestor[] = [];
let matched = false;
cluster.forEach(txid => {
if (txid === memberTxid) {
matched = true;
} else {
const relative = {
txid: txid,
fee: mempool[txid].fee,
weight: mempool[txid].weight,
};
if (matched) {
descendants.push(relative);
} else {
ancestors.push(relative);
}
}
});
mempoolTx.ancestors = ancestors;
mempoolTx.descendants = descendants;
mempoolTx.bestDescendant = null;
}
}
}
const mempoolBlocks = readyBlocks.map((b, index) => {
return this.dataToMempoolBlocks(b.transactionIds, b.transactions, b.totalSize, b.totalWeight, b.totalFees, b.feeStats);
}); });
if (saveResults) { if (saveResults) {
@ -310,29 +426,71 @@ class MempoolBlocks {
return mempoolBlocks; return mempoolBlocks;
} }
private dataToMempoolBlocks(transactions: TransactionExtended[]): MempoolBlockWithTransactions { private dataToMempoolBlocks(transactionIds: string[], transactions: TransactionExtended[], totalSize: number, totalWeight: number, totalFees: number, feeStats?: EffectiveFeeStats ): MempoolBlockWithTransactions {
let totalSize = 0; if (!feeStats) {
let totalWeight = 0; feeStats = Common.calcEffectiveFeeStatistics(transactions);
const fitTransactions: TransactionExtended[] = []; }
transactions.forEach(tx => {
totalSize += tx.size;
totalWeight += tx.weight;
if ((totalWeight + tx.weight) <= config.MEMPOOL.BLOCK_WEIGHT_UNITS * 1.2) {
fitTransactions.push(tx);
}
});
const feeStats = Common.calcEffectiveFeeStatistics(transactions);
return { return {
blockSize: totalSize, blockSize: totalSize,
blockVSize: totalWeight / 4, blockVSize: (totalWeight / 4), // fractional vsize to avoid rounding errors
nTx: transactions.length, nTx: transactionIds.length,
totalFees: transactions.reduce((acc, cur) => acc + cur.fee, 0), totalFees: totalFees,
medianFee: feeStats.medianFee, // Common.percentile(transactions.map((tx) => tx.effectiveFeePerVsize), config.MEMPOOL.RECOMMENDED_FEE_PERCENTILE), medianFee: feeStats.medianFee, // Common.percentile(transactions.map((tx) => tx.effectiveFeePerVsize), config.MEMPOOL.RECOMMENDED_FEE_PERCENTILE),
feeRange: feeStats.feeRange, //Common.getFeesInRange(transactions, rangeLength), feeRange: feeStats.feeRange, //Common.getFeesInRange(transactions, rangeLength),
transactionIds: transactions.map((tx) => tx.txid), transactionIds: transactionIds,
transactions: fitTransactions.map((tx) => Common.stripTransaction(tx)), transactions: transactions.map((tx) => Common.stripTransaction(tx)),
}; };
} }
private resetUids(): void {
this.uidMap.clear();
this.nextUid = 1;
}
private setUid(tx: TransactionExtended): number {
const uid = this.nextUid;
this.nextUid++;
this.uidMap.set(uid, tx.txid);
tx.uid = uid;
return uid;
}
private getUid(tx: TransactionExtended): number | void {
if (tx?.uid != null && this.uidMap.has(tx.uid)) {
return tx.uid;
}
}
private removeUids(uids: number[]): void {
for (const uid of uids) {
this.uidMap.delete(uid);
}
}
private convertResultTxids({ blocks, rates, clusters }: { blocks: number[][], rates: Map<number, number>, clusters: Map<number, number[]>})
: { blocks: string[][], rates: { [root: string]: number }, clusters: { [root: string]: string[] }} {
const convertedBlocks: string[][] = blocks.map(block => block.map(uid => {
return this.uidMap.get(uid) || '';
}));
const convertedRates = {};
for (const rateUid of rates.keys()) {
const rateTxid = this.uidMap.get(rateUid);
if (rateTxid) {
convertedRates[rateTxid] = rates.get(rateUid);
}
}
const convertedClusters = {};
for (const rootUid of clusters.keys()) {
const rootTxid = this.uidMap.get(rootUid);
if (rootTxid) {
const members = clusters.get(rootUid)?.map(uid => {
return this.uidMap.get(uid);
});
convertedClusters[rootTxid] = members;
}
}
return { blocks: convertedBlocks, rates: convertedRates, clusters: convertedClusters } as { blocks: string[][], rates: { [root: string]: number }, clusters: { [root: string]: string[] }};
}
} }
export default new MempoolBlocks(); export default new MempoolBlocks();

View file

@ -11,8 +11,6 @@ import bitcoinSecondClient from './bitcoin/bitcoin-second-client';
import rbfCache from './rbf-cache'; import rbfCache from './rbf-cache';
class Mempool { class Mempool {
private static WEBSOCKET_REFRESH_RATE_MS = 10000;
private static LAZY_DELETE_AFTER_SECONDS = 30;
private inSync: boolean = false; private inSync: boolean = false;
private mempoolCacheDelta: number = -1; private mempoolCacheDelta: number = -1;
private mempoolCache: { [txId: string]: TransactionExtended } = {}; private mempoolCache: { [txId: string]: TransactionExtended } = {};
@ -20,7 +18,7 @@ class Mempool {
maxmempool: 300000000, mempoolminfee: 0.00001000, minrelaytxfee: 0.00001000 }; maxmempool: 300000000, mempoolminfee: 0.00001000, minrelaytxfee: 0.00001000 };
private mempoolChangedCallback: ((newMempool: {[txId: string]: TransactionExtended; }, newTransactions: TransactionExtended[], private mempoolChangedCallback: ((newMempool: {[txId: string]: TransactionExtended; }, newTransactions: TransactionExtended[],
deletedTransactions: TransactionExtended[]) => void) | undefined; deletedTransactions: TransactionExtended[]) => void) | undefined;
private asyncMempoolChangedCallback: ((newMempool: {[txId: string]: TransactionExtended; }, newTransactions: TransactionExtended[], private $asyncMempoolChangedCallback: ((newMempool: {[txId: string]: TransactionExtended; }, newTransactions: TransactionExtended[],
deletedTransactions: TransactionExtended[]) => Promise<void>) | undefined; deletedTransactions: TransactionExtended[]) => Promise<void>) | undefined;
private txPerSecondArray: number[] = []; private txPerSecondArray: number[] = [];
@ -35,6 +33,7 @@ class Mempool {
private SAMPLE_TIME = 10000; // In ms private SAMPLE_TIME = 10000; // In ms
private timer = new Date().getTime(); private timer = new Date().getTime();
private missingTxCount = 0; private missingTxCount = 0;
private mainLoopTimeout: number = 120000;
constructor() { constructor() {
setInterval(this.updateTxPerSecond.bind(this), 1000); setInterval(this.updateTxPerSecond.bind(this), 1000);
@ -71,20 +70,20 @@ class Mempool {
public setAsyncMempoolChangedCallback(fn: (newMempool: { [txId: string]: TransactionExtended; }, public setAsyncMempoolChangedCallback(fn: (newMempool: { [txId: string]: TransactionExtended; },
newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]) => Promise<void>) { newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]) => Promise<void>) {
this.asyncMempoolChangedCallback = fn; this.$asyncMempoolChangedCallback = fn;
} }
public getMempool(): { [txid: string]: TransactionExtended } { public getMempool(): { [txid: string]: TransactionExtended } {
return this.mempoolCache; return this.mempoolCache;
} }
public setMempool(mempoolData: { [txId: string]: TransactionExtended }) { public async $setMempool(mempoolData: { [txId: string]: TransactionExtended }) {
this.mempoolCache = mempoolData; this.mempoolCache = mempoolData;
if (this.mempoolChangedCallback) { if (this.mempoolChangedCallback) {
this.mempoolChangedCallback(this.mempoolCache, [], []); this.mempoolChangedCallback(this.mempoolCache, [], []);
} }
if (this.asyncMempoolChangedCallback) { if (this.$asyncMempoolChangedCallback) {
this.asyncMempoolChangedCallback(this.mempoolCache, [], []); await this.$asyncMempoolChangedCallback(this.mempoolCache, [], []);
} }
} }
@ -117,19 +116,23 @@ class Mempool {
return txTimes; return txTimes;
} }
public async $updateMempool(): Promise<void> { public async $updateMempool(transactions: string[]): Promise<void> {
logger.debug(`Updating mempool...`); logger.debug(`Updating mempool...`);
// warn if this run stalls the main loop for more than 2 minutes
const timer = this.startTimer();
const start = new Date().getTime(); const start = new Date().getTime();
let hasChange: boolean = false; let hasChange: boolean = false;
const currentMempoolSize = Object.keys(this.mempoolCache).length; const currentMempoolSize = Object.keys(this.mempoolCache).length;
const transactions = await bitcoinApi.$getRawMempool(); this.updateTimerProgress(timer, 'got raw mempool');
const diff = transactions.length - currentMempoolSize; const diff = transactions.length - currentMempoolSize;
const newTransactions: TransactionExtended[] = []; const newTransactions: TransactionExtended[] = [];
this.mempoolCacheDelta = Math.abs(diff); this.mempoolCacheDelta = Math.abs(diff);
if (!this.inSync) { if (!this.inSync) {
loadingIndicators.setProgress('mempool', Object.keys(this.mempoolCache).length / transactions.length * 100); loadingIndicators.setProgress('mempool', currentMempoolSize / transactions.length * 100);
} }
// https://github.com/mempool/mempool/issues/3283 // https://github.com/mempool/mempool/issues/3283
@ -142,10 +145,12 @@ class Mempool {
} }
}; };
let loggerTimer = new Date().getTime() / 1000;
for (const txid of transactions) { for (const txid of transactions) {
if (!this.mempoolCache[txid]) { if (!this.mempoolCache[txid]) {
try { try {
const transaction = await transactionUtils.$getTransactionExtended(txid); const transaction = await transactionUtils.$getTransactionExtended(txid);
this.updateTimerProgress(timer, 'fetched new transaction');
this.mempoolCache[txid] = transaction; this.mempoolCache[txid] = transaction;
if (this.inSync) { if (this.inSync) {
this.txPerSecondArray.push(new Date().getTime()); this.txPerSecondArray.push(new Date().getTime());
@ -163,9 +168,12 @@ class Mempool {
logger.debug(`Error finding transaction '${txid}' in the mempool: ` + (e instanceof Error ? e.message : e)); logger.debug(`Error finding transaction '${txid}' in the mempool: ` + (e instanceof Error ? e.message : e));
} }
} }
const elapsedSeconds = Math.round((new Date().getTime() / 1000) - loggerTimer);
if ((new Date().getTime()) - start > Mempool.WEBSOCKET_REFRESH_RATE_MS) { if (elapsedSeconds > 4) {
break; const progress = (currentMempoolSize + newTransactions.length) / transactions.length * 100;
logger.debug(`Mempool is synchronizing. Processed ${newTransactions.length}/${diff} txs (${Math.round(progress)}%)`);
loadingIndicators.setProgress('mempool', progress);
loggerTimer = new Date().getTime() / 1000;
} }
} }
@ -199,13 +207,15 @@ class Mempool {
const transactionsObject = {}; const transactionsObject = {};
transactions.forEach((txId) => transactionsObject[txId] = true); transactions.forEach((txId) => transactionsObject[txId] = true);
// Flag transactions for lazy deletion // Delete evicted transactions from mempool
for (const tx in this.mempoolCache) { for (const tx in this.mempoolCache) {
if (!transactionsObject[tx] && !this.mempoolCache[tx].deleteAfter) { if (!transactionsObject[tx]) {
deletedTransactions.push(this.mempoolCache[tx]); deletedTransactions.push(this.mempoolCache[tx]);
this.mempoolCache[tx].deleteAfter = new Date().getTime() + Mempool.LAZY_DELETE_AFTER_SECONDS * 1000;
} }
} }
for (const tx of deletedTransactions) {
delete this.mempoolCache[tx.txid];
}
} }
const newTransactionsStripped = newTransactions.map((tx) => Common.stripTransaction(tx)); const newTransactionsStripped = newTransactions.map((tx) => Common.stripTransaction(tx));
@ -222,22 +232,46 @@ class Mempool {
if (this.mempoolChangedCallback && (hasChange || deletedTransactions.length)) { if (this.mempoolChangedCallback && (hasChange || deletedTransactions.length)) {
this.mempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions); this.mempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions);
} }
if (this.asyncMempoolChangedCallback && (hasChange || deletedTransactions.length)) { if (this.$asyncMempoolChangedCallback && (hasChange || deletedTransactions.length)) {
await this.asyncMempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions); this.updateTimerProgress(timer, 'running async mempool callback');
await this.$asyncMempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions);
this.updateTimerProgress(timer, 'completed async mempool callback');
} }
const end = new Date().getTime(); const end = new Date().getTime();
const time = end - start; const time = end - start;
logger.debug(`Mempool updated in ${time / 1000} seconds. New size: ${Object.keys(this.mempoolCache).length} (${diff > 0 ? '+' + diff : diff})`); logger.debug(`Mempool updated in ${time / 1000} seconds. New size: ${Object.keys(this.mempoolCache).length} (${diff > 0 ? '+' + diff : diff})`);
this.clearTimer(timer);
} }
public handleRbfTransactions(rbfTransactions: { [txid: string]: TransactionExtended; }) { private startTimer() {
const state: any = {
start: Date.now(),
progress: 'begin $updateMempool',
timer: null,
};
state.timer = setTimeout(() => {
logger.err(`$updateMempool stalled at "${state.progress}"`);
}, this.mainLoopTimeout);
return state;
}
private updateTimerProgress(state, msg) {
state.progress = msg;
}
private clearTimer(state) {
if (state.timer) {
clearTimeout(state.timer);
}
}
public handleRbfTransactions(rbfTransactions: { [txid: string]: TransactionExtended[]; }): void {
for (const rbfTransaction in rbfTransactions) { for (const rbfTransaction in rbfTransactions) {
if (this.mempoolCache[rbfTransaction]) { if (this.mempoolCache[rbfTransaction] && rbfTransactions[rbfTransaction]?.length) {
// Store replaced transactions // Store replaced transactions
rbfCache.add(this.mempoolCache[rbfTransaction], rbfTransactions[rbfTransaction].txid); rbfCache.add(rbfTransactions[rbfTransaction], this.mempoolCache[rbfTransaction]);
// Erase the replaced transactions from the local mempool
delete this.mempoolCache[rbfTransaction];
} }
} }
} }
@ -255,17 +289,6 @@ class Mempool {
} }
} }
public deleteExpiredTransactions() {
const now = new Date().getTime();
for (const tx in this.mempoolCache) {
const lazyDeleteAt = this.mempoolCache[tx].deleteAfter;
if (lazyDeleteAt && lazyDeleteAt < now) {
delete this.mempoolCache[tx];
rbfCache.evict(tx);
}
}
}
private $getMempoolInfo() { private $getMempoolInfo() {
if (config.MEMPOOL.USE_SECOND_NODE_FOR_MINFEE) { if (config.MEMPOOL.USE_SECOND_NODE_FOR_MINFEE) {
return Promise.all([ return Promise.all([

View file

@ -1,65 +1,341 @@
import { TransactionExtended } from "../mempool.interfaces"; import logger from "../logger";
import { TransactionExtended, TransactionStripped } from "../mempool.interfaces";
import bitcoinApi from './bitcoin/bitcoin-api-factory';
import { Common } from "./common";
interface RbfTransaction extends TransactionStripped {
rbf?: boolean;
mined?: boolean;
}
interface RbfTree {
tx: RbfTransaction;
time: number;
interval?: number;
mined?: boolean;
fullRbf: boolean;
replaces: RbfTree[];
}
class RbfCache { class RbfCache {
private replacedBy: { [txid: string]: string; } = {}; private replacedBy: Map<string, string> = new Map();
private replaces: { [txid: string]: string[] } = {}; private replaces: Map<string, string[]> = new Map();
private txs: { [txid: string]: TransactionExtended } = {}; private rbfTrees: Map<string, RbfTree> = new Map(); // sequences of consecutive replacements
private expiring: { [txid: string]: Date } = {}; private dirtyTrees: Set<string> = new Set();
private treeMap: Map<string, string> = new Map(); // map of txids to sequence ids
private txs: Map<string, TransactionExtended> = new Map();
private expiring: Map<string, number> = new Map();
constructor() { constructor() {
setInterval(this.cleanup.bind(this), 1000 * 60 * 60); setInterval(this.cleanup.bind(this), 1000 * 60 * 10);
} }
public add(replacedTx: TransactionExtended, newTxId: string): void { public add(replaced: TransactionExtended[], newTxExtended: TransactionExtended): void {
this.replacedBy[replacedTx.txid] = newTxId; if (!newTxExtended || !replaced?.length) {
this.txs[replacedTx.txid] = replacedTx; return;
if (!this.replaces[newTxId]) {
this.replaces[newTxId] = [];
} }
this.replaces[newTxId].push(replacedTx.txid);
const newTx = Common.stripTransaction(newTxExtended) as RbfTransaction;
const newTime = newTxExtended.firstSeen || (Date.now() / 1000);
newTx.rbf = newTxExtended.vin.some((v) => v.sequence < 0xfffffffe);
this.txs.set(newTx.txid, newTxExtended);
// maintain rbf trees
let fullRbf = false;
const replacedTrees: RbfTree[] = [];
for (const replacedTxExtended of replaced) {
const replacedTx = Common.stripTransaction(replacedTxExtended) as RbfTransaction;
replacedTx.rbf = replacedTxExtended.vin.some((v) => v.sequence < 0xfffffffe);
this.replacedBy.set(replacedTx.txid, newTx.txid);
if (this.treeMap.has(replacedTx.txid)) {
const treeId = this.treeMap.get(replacedTx.txid);
if (treeId) {
const tree = this.rbfTrees.get(treeId);
this.rbfTrees.delete(treeId);
if (tree) {
tree.interval = newTime - tree?.time;
replacedTrees.push(tree);
fullRbf = fullRbf || tree.fullRbf;
}
}
} else {
const replacedTime = replacedTxExtended.firstSeen || (Date.now() / 1000);
replacedTrees.push({
tx: replacedTx,
time: replacedTime,
interval: newTime - replacedTime,
fullRbf: !replacedTx.rbf,
replaces: [],
});
fullRbf = fullRbf || !replacedTx.rbf;
this.txs.set(replacedTx.txid, replacedTxExtended);
}
}
const treeId = replacedTrees[0].tx.txid;
const newTree = {
tx: newTx,
time: newTime,
fullRbf,
replaces: replacedTrees
};
this.rbfTrees.set(treeId, newTree);
this.updateTreeMap(treeId, newTree);
this.replaces.set(newTx.txid, replacedTrees.map(tree => tree.tx.txid));
this.dirtyTrees.add(treeId);
} }
public getReplacedBy(txId: string): string | undefined { public getReplacedBy(txId: string): string | undefined {
return this.replacedBy[txId]; return this.replacedBy.get(txId);
} }
public getReplaces(txId: string): string[] | undefined { public getReplaces(txId: string): string[] | undefined {
return this.replaces[txId]; return this.replaces.get(txId);
} }
public getTx(txId: string): TransactionExtended | undefined { public getTx(txId: string): TransactionExtended | undefined {
return this.txs[txId]; return this.txs.get(txId);
}
public getRbfTree(txId: string): RbfTree | void {
return this.rbfTrees.get(this.treeMap.get(txId) || '');
}
// get a paginated list of RbfTrees
// ordered by most recent replacement time
public getRbfTrees(onlyFullRbf: boolean, after?: string): RbfTree[] {
const limit = 25;
const trees: RbfTree[] = [];
const used = new Set<string>();
const replacements: string[][] = Array.from(this.replacedBy).reverse();
const afterTree = after ? this.treeMap.get(after) : null;
let ready = !afterTree;
for (let i = 0; i < replacements.length && trees.length <= limit - 1; i++) {
const txid = replacements[i][1];
const treeId = this.treeMap.get(txid) || '';
if (treeId === afterTree) {
ready = true;
} else if (ready) {
if (!used.has(treeId)) {
const tree = this.rbfTrees.get(treeId);
used.add(treeId);
if (tree && (!onlyFullRbf || tree.fullRbf)) {
trees.push(tree);
}
}
}
}
return trees;
}
// get map of rbf trees that have been updated since the last call
public getRbfChanges(): { trees: {[id: string]: RbfTree }, map: { [txid: string]: string }} {
const changes: { trees: {[id: string]: RbfTree }, map: { [txid: string]: string }} = {
trees: {},
map: {},
};
this.dirtyTrees.forEach(id => {
const tree = this.rbfTrees.get(id);
if (tree) {
changes.trees[id] = tree;
this.getTransactionsInTree(tree).forEach(tx => {
changes.map[tx.txid] = id;
});
}
});
this.dirtyTrees = new Set();
return changes;
}
public mined(txid): void {
if (!this.txs.has(txid)) {
return;
}
const treeId = this.treeMap.get(txid);
if (treeId && this.rbfTrees.has(treeId)) {
const tree = this.rbfTrees.get(treeId);
if (tree) {
this.setTreeMined(tree, txid);
tree.mined = true;
this.dirtyTrees.add(treeId);
}
}
this.evict(txid);
} }
// flag a transaction as removed from the mempool // flag a transaction as removed from the mempool
public evict(txid): void { public evict(txid: string, fast: boolean = false): void {
this.expiring[txid] = new Date(Date.now() + 1000 * 86400); // 24 hours if (this.txs.has(txid) && (fast || !this.expiring.has(txid))) {
this.expiring.set(txid, fast ? Date.now() + (1000 * 60 * 10) : Date.now() + (1000 * 86400)); // 24 hours
}
} }
private cleanup(): void { private cleanup(): void {
const currentDate = new Date(); const now = Date.now();
for (const txid in this.expiring) { for (const txid of this.expiring.keys()) {
if (this.expiring[txid] < currentDate) { if ((this.expiring.get(txid) || 0) < now) {
delete this.expiring[txid]; this.expiring.delete(txid);
this.remove(txid); this.remove(txid);
} }
} }
logger.debug(`rbf cache contains ${this.txs.size} txs, ${this.expiring.size} due to expire`);
} }
// remove a transaction & all previous versions from the cache // remove a transaction & all previous versions from the cache
private remove(txid): void { private remove(txid): void {
// don't remove a transaction while a newer version remains in the mempool // don't remove a transaction if a newer version remains in the mempool
if (this.replaces[txid] && !this.replacedBy[txid]) { if (!this.replacedBy.has(txid)) {
const replaces = this.replaces[txid]; const replaces = this.replaces.get(txid);
delete this.replaces[txid]; this.replaces.delete(txid);
for (const tx of replaces) { this.treeMap.delete(txid);
this.txs.delete(txid);
this.expiring.delete(txid);
for (const tx of (replaces || [])) {
// recursively remove prior versions from the cache // recursively remove prior versions from the cache
delete this.replacedBy[tx]; this.replacedBy.delete(tx);
delete this.txs[tx]; // if this is the id of a tree, remove that too
if (this.treeMap.get(tx) === tx) {
this.rbfTrees.delete(tx);
}
this.remove(tx); this.remove(tx);
} }
} }
} }
private updateTreeMap(newId: string, tree: RbfTree): void {
this.treeMap.set(tree.tx.txid, newId);
tree.replaces.forEach(subtree => {
this.updateTreeMap(newId, subtree);
});
}
private getTransactionsInTree(tree: RbfTree, txs: RbfTransaction[] = []): RbfTransaction[] {
txs.push(tree.tx);
tree.replaces.forEach(subtree => {
this.getTransactionsInTree(subtree, txs);
});
return txs;
}
private setTreeMined(tree: RbfTree, txid: string): void {
if (tree.tx.txid === txid) {
tree.tx.mined = true;
} else {
tree.replaces.forEach(subtree => {
this.setTreeMined(subtree, txid);
});
}
}
public dump(): any {
const trees = Array.from(this.rbfTrees.values()).map((tree: RbfTree) => { return this.exportTree(tree); });
return {
txs: Array.from(this.txs.entries()),
trees,
expiring: Array.from(this.expiring.entries()),
};
}
public async load({ txs, trees, expiring }): Promise<void> {
txs.forEach(txEntry => {
this.txs.set(txEntry[0], txEntry[1]);
});
for (const deflatedTree of trees) {
await this.importTree(deflatedTree.root, deflatedTree.root, deflatedTree, this.txs);
}
expiring.forEach(expiringEntry => {
if (this.txs.has(expiringEntry[0])) {
this.expiring.set(expiringEntry[0], new Date(expiringEntry[1]).getTime());
}
});
this.cleanup();
}
exportTree(tree: RbfTree, deflated: any = null) {
if (!deflated) {
deflated = {
root: tree.tx.txid,
};
}
deflated[tree.tx.txid] = {
tx: tree.tx.txid,
txMined: tree.tx.mined,
time: tree.time,
interval: tree.interval,
mined: tree.mined,
fullRbf: tree.fullRbf,
replaces: tree.replaces.map(child => child.tx.txid),
};
tree.replaces.forEach(child => {
this.exportTree(child, deflated);
});
return deflated;
}
async importTree(root, txid, deflated, txs: Map<string, TransactionExtended>, mined: boolean = false): Promise<RbfTree | void> {
const treeInfo = deflated[txid];
const replaces: RbfTree[] = [];
// check if any transactions in this tree have already been confirmed
mined = mined || treeInfo.mined;
let exists = mined;
if (!mined) {
try {
const apiTx = await bitcoinApi.$getRawTransaction(txid);
if (apiTx) {
exists = true;
}
if (apiTx?.status?.confirmed) {
mined = true;
treeInfo.txMined = true;
this.evict(txid, true);
}
} catch (e) {
// most transactions do not exist
}
}
// if the root tx is not in the mempool or the blockchain
// evict this tree as soon as possible
if (root === txid && !exists) {
this.evict(txid, true);
}
// recursively reconstruct child trees
for (const childId of treeInfo.replaces) {
const replaced = await this.importTree(root, childId, deflated, txs, mined);
if (replaced) {
this.replacedBy.set(replaced.tx.txid, txid);
replaces.push(replaced);
if (replaced.mined) {
mined = true;
}
}
}
this.replaces.set(txid, replaces.map(t => t.tx.txid));
const tx = txs.get(txid);
if (!tx) {
return;
}
const strippedTx = Common.stripTransaction(tx) as RbfTransaction;
strippedTx.rbf = tx.vin.some((v) => v.sequence < 0xfffffffe);
strippedTx.mined = treeInfo.txMined;
const tree = {
tx: strippedTx,
time: treeInfo.time,
interval: treeInfo.interval,
mined: mined,
fullRbf: treeInfo.fullRbf,
replaces,
};
this.treeMap.set(txid, root);
if (root === txid) {
this.rbfTrees.set(root, tree);
this.dirtyTrees.add(root);
}
return tree;
}
} }
export default new RbfCache(); export default new RbfCache();

View file

@ -1,11 +1,10 @@
import config from '../config'; import config from '../config';
import logger from '../logger'; import logger from '../logger';
import { ThreadTransaction, MempoolBlockWithTransactions, AuditTransaction } from '../mempool.interfaces'; import { CompactThreadTransaction, AuditTransaction } from '../mempool.interfaces';
import { PairingHeap } from '../utils/pairing-heap'; import { PairingHeap } from '../utils/pairing-heap';
import { Common } from './common';
import { parentPort } from 'worker_threads'; import { parentPort } from 'worker_threads';
let mempool: { [txid: string]: ThreadTransaction } = {}; let mempool: Map<number, CompactThreadTransaction> = new Map();
if (parentPort) { if (parentPort) {
parentPort.on('message', (params) => { parentPort.on('message', (params) => {
@ -13,18 +12,18 @@ if (parentPort) {
mempool = params.mempool; mempool = params.mempool;
} else if (params.type === 'update') { } else if (params.type === 'update') {
params.added.forEach(tx => { params.added.forEach(tx => {
mempool[tx.txid] = tx; mempool.set(tx.uid, tx);
}); });
params.removed.forEach(txid => { params.removed.forEach(uid => {
delete mempool[txid]; mempool.delete(uid);
}); });
} }
const { blocks, clusters } = makeBlockTemplates(mempool); const { blocks, rates, clusters } = makeBlockTemplates(mempool);
// return the result to main thread. // return the result to main thread.
if (parentPort) { if (parentPort) {
parentPort.postMessage({ blocks, clusters }); parentPort.postMessage({ blocks, rates, clusters });
} }
}); });
} }
@ -33,26 +32,25 @@ if (parentPort) {
* Build projected mempool blocks using an approximation of the transaction selection algorithm from Bitcoin Core * Build projected mempool blocks 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) * (see BlockAssembler in https://github.com/bitcoin/bitcoin/blob/master/src/node/miner.cpp)
*/ */
function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction }) function makeBlockTemplates(mempool: Map<number, CompactThreadTransaction>)
: { blocks: ThreadTransaction[][], clusters: { [root: string]: string[] } } { : { blocks: number[][], rates: Map<number, number>, clusters: Map<number, number[]> } {
const start = Date.now(); const start = Date.now();
const auditPool: { [txid: string]: AuditTransaction } = {}; const auditPool: Map<number, AuditTransaction> = new Map();
const mempoolArray: AuditTransaction[] = []; const mempoolArray: AuditTransaction[] = [];
const restOfArray: ThreadTransaction[] = []; const cpfpClusters: Map<number, number[]> = new Map();
const cpfpClusters: { [root: string]: string[] } = {};
// grab the top feerate txs up to maxWeight mempool.forEach(tx => {
Object.values(mempool).sort((a, b) => b.feePerVsize - a.feePerVsize).forEach(tx => { tx.dirty = false;
// initializing everything up front helps V8 optimize property access later // initializing everything up front helps V8 optimize property access later
auditPool[tx.txid] = { auditPool.set(tx.uid, {
txid: tx.txid, uid: tx.uid,
fee: tx.fee, fee: tx.fee,
weight: tx.weight, weight: tx.weight,
feePerVsize: tx.feePerVsize, feePerVsize: tx.feePerVsize,
effectiveFeePerVsize: tx.feePerVsize, effectiveFeePerVsize: tx.feePerVsize,
vin: tx.vin, inputs: tx.inputs || [],
relativesSet: false, relativesSet: false,
ancestorMap: new Map<string, AuditTransaction>(), ancestorMap: new Map<number, AuditTransaction>(),
children: new Set<AuditTransaction>(), children: new Set<AuditTransaction>(),
ancestorFee: 0, ancestorFee: 0,
ancestorWeight: 0, ancestorWeight: 0,
@ -60,8 +58,8 @@ function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction })
used: false, used: false,
modified: false, modified: false,
modifiedNode: null, modifiedNode: null,
}; });
mempoolArray.push(auditPool[tx.txid]); mempoolArray.push(auditPool.get(tx.uid) as AuditTransaction);
}); });
// Build relatives graph & calculate ancestor scores // Build relatives graph & calculate ancestor scores
@ -72,15 +70,28 @@ function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction })
} }
// Sort by descending ancestor score // Sort by descending ancestor score
mempoolArray.sort((a, b) => (b.score || 0) - (a.score || 0)); mempoolArray.sort((a, b) => {
if (b.score === a.score) {
// tie-break by uid for stability
return a.uid < b.uid ? -1 : 1;
} else {
return (b.score || 0) - (a.score || 0);
}
});
// Build blocks by greedily choosing the highest feerate package // Build blocks by greedily choosing the highest feerate package
// (i.e. the package rooted in the transaction with the best ancestor score) // (i.e. the package rooted in the transaction with the best ancestor score)
const blocks: ThreadTransaction[][] = []; const blocks: number[][] = [];
let blockWeight = 4000; let blockWeight = 4000;
let blockSize = 0;
let transactions: AuditTransaction[] = []; let transactions: AuditTransaction[] = [];
const modified: PairingHeap<AuditTransaction> = new PairingHeap((a, b): boolean => (a.score || 0) > (b.score || 0)); const modified: PairingHeap<AuditTransaction> = new PairingHeap((a, b): boolean => {
if (a.score === b.score) {
// tie-break by uid for stability
return a.uid > b.uid;
} else {
return (a.score || 0) > (b.score || 0);
}
});
let overflow: AuditTransaction[] = []; let overflow: AuditTransaction[] = [];
let failures = 0; let failures = 0;
let top = 0; let top = 0;
@ -113,24 +124,30 @@ function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction })
const sortedTxSet = [...ancestors.sort((a, b) => { return (a.ancestorMap.size || 0) - (b.ancestorMap.size || 0); }), nextTx]; const sortedTxSet = [...ancestors.sort((a, b) => { return (a.ancestorMap.size || 0) - (b.ancestorMap.size || 0); }), nextTx];
let isCluster = false; let isCluster = false;
if (sortedTxSet.length > 1) { if (sortedTxSet.length > 1) {
cpfpClusters[nextTx.txid] = sortedTxSet.map(tx => tx.txid); cpfpClusters.set(nextTx.uid, sortedTxSet.map(tx => tx.uid));
isCluster = true; isCluster = true;
} }
const effectiveFeeRate = nextTx.ancestorFee / (nextTx.ancestorWeight / 4); const effectiveFeeRate = nextTx.ancestorFee / (nextTx.ancestorWeight / 4);
const used: AuditTransaction[] = []; const used: AuditTransaction[] = [];
while (sortedTxSet.length) { while (sortedTxSet.length) {
const ancestor = sortedTxSet.pop(); const ancestor = sortedTxSet.pop();
const mempoolTx = mempool[ancestor.txid]; const mempoolTx = mempool.get(ancestor.uid);
if (!mempoolTx) {
continue;
}
ancestor.used = true; ancestor.used = true;
ancestor.usedBy = nextTx.txid; ancestor.usedBy = nextTx.uid;
// update original copy of this tx with effective fee rate & relatives data // update original copy of this tx with effective fee rate & relatives data
mempoolTx.effectiveFeePerVsize = effectiveFeeRate; if (mempoolTx.effectiveFeePerVsize !== effectiveFeeRate) {
if (isCluster) { mempoolTx.effectiveFeePerVsize = effectiveFeeRate;
mempoolTx.cpfpRoot = nextTx.txid; mempoolTx.dirty = true;
}
if (mempoolTx.cpfpRoot !== nextTx.uid) {
mempoolTx.cpfpRoot = isCluster ? nextTx.uid : null;
mempoolTx.dirty;
} }
mempoolTx.cpfpChecked = true; mempoolTx.cpfpChecked = true;
transactions.push(ancestor); transactions.push(ancestor);
blockSize += ancestor.size;
blockWeight += ancestor.weight; blockWeight += ancestor.weight;
used.push(ancestor); used.push(ancestor);
} }
@ -156,11 +173,10 @@ function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction })
if ((exceededPackageTries || queueEmpty) && blocks.length < 7) { if ((exceededPackageTries || queueEmpty) && blocks.length < 7) {
// construct this block // construct this block
if (transactions.length) { if (transactions.length) {
blocks.push(transactions.map(t => mempool[t.txid])); blocks.push(transactions.map(t => t.uid));
} }
// reset for the next block // reset for the next block
transactions = []; transactions = [];
blockSize = 0;
blockWeight = 4000; blockWeight = 4000;
// 'overflow' packages didn't fit in this block, but are valid candidates for the next // 'overflow' packages didn't fit in this block, but are valid candidates for the next
@ -181,24 +197,32 @@ function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction })
} }
// add the final unbounded block if it contains any transactions // add the final unbounded block if it contains any transactions
if (transactions.length > 0) { if (transactions.length > 0) {
blocks.push(transactions.map(t => mempool[t.txid])); blocks.push(transactions.map(t => t.uid));
}
// get map of dirty transactions
const rates = new Map<number, number>();
for (const tx of mempool.values()) {
if (tx?.dirty) {
rates.set(tx.uid, tx.effectiveFeePerVsize || tx.feePerVsize);
}
} }
const end = Date.now(); const end = Date.now();
const time = end - start; const time = end - start;
logger.debug('Mempool templates calculated in ' + time / 1000 + ' seconds'); logger.debug('Mempool templates calculated in ' + time / 1000 + ' seconds');
return { blocks, clusters: cpfpClusters }; return { blocks, rates, clusters: cpfpClusters };
} }
// traverse in-mempool ancestors // traverse in-mempool ancestors
// recursion unavoidable, but should be limited to depth < 25 by mempool policy // recursion unavoidable, but should be limited to depth < 25 by mempool policy
function setRelatives( function setRelatives(
tx: AuditTransaction, tx: AuditTransaction,
mempool: { [txid: string]: AuditTransaction }, mempool: Map<number, AuditTransaction>,
): void { ): void {
for (const parent of tx.vin) { for (const parent of tx.inputs) {
const parentTx = mempool[parent]; const parentTx = mempool.get(parent);
if (parentTx && !tx.ancestorMap?.has(parent)) { if (parentTx && !tx.ancestorMap?.has(parent)) {
tx.ancestorMap.set(parent, parentTx); tx.ancestorMap.set(parent, parentTx);
parentTx.children.add(tx); parentTx.children.add(tx);
@ -207,7 +231,7 @@ function setRelatives(
setRelatives(parentTx, mempool); setRelatives(parentTx, mempool);
} }
parentTx.ancestorMap.forEach((ancestor) => { parentTx.ancestorMap.forEach((ancestor) => {
tx.ancestorMap.set(ancestor.txid, ancestor); tx.ancestorMap.set(ancestor.uid, ancestor);
}); });
} }
}; };
@ -225,7 +249,7 @@ function setRelatives(
// avoids recursion to limit call stack depth // avoids recursion to limit call stack depth
function updateDescendants( function updateDescendants(
rootTx: AuditTransaction, rootTx: AuditTransaction,
mempool: { [txid: string]: AuditTransaction }, mempool: Map<number, AuditTransaction>,
modified: PairingHeap<AuditTransaction>, modified: PairingHeap<AuditTransaction>,
): void { ): void {
const descendantSet: Set<AuditTransaction> = new Set(); const descendantSet: Set<AuditTransaction> = new Set();
@ -241,9 +265,9 @@ function updateDescendants(
}); });
while (descendants.length) { while (descendants.length) {
descendantTx = descendants.pop(); descendantTx = descendants.pop();
if (descendantTx && descendantTx.ancestorMap && descendantTx.ancestorMap.has(rootTx.txid)) { if (descendantTx && descendantTx.ancestorMap && descendantTx.ancestorMap.has(rootTx.uid)) {
// remove tx as ancestor // remove tx as ancestor
descendantTx.ancestorMap.delete(rootTx.txid); descendantTx.ancestorMap.delete(rootTx.uid);
descendantTx.ancestorFee -= rootTx.fee; descendantTx.ancestorFee -= rootTx.fee;
descendantTx.ancestorWeight -= rootTx.weight; descendantTx.ancestorWeight -= rootTx.weight;
tmpScore = descendantTx.score; tmpScore = descendantTx.score;

View file

@ -26,6 +26,13 @@ class WebsocketHandler {
private wss: WebSocket.Server | undefined; private wss: WebSocket.Server | undefined;
private extraInitProperties = {}; private extraInitProperties = {};
private numClients = 0;
private numConnected = 0;
private numDisconnected = 0;
private initData: { [key: string]: string } = {};
private serializedInitData: string = '{}';
constructor() { } constructor() { }
setWebsocketServer(wss: WebSocket.Server) { setWebsocketServer(wss: WebSocket.Server) {
@ -34,6 +41,41 @@ class WebsocketHandler {
setExtraInitProperties(property: string, value: any) { setExtraInitProperties(property: string, value: any) {
this.extraInitProperties[property] = value; this.extraInitProperties[property] = value;
this.setInitDataFields(this.extraInitProperties);
}
private setInitDataFields(data: { [property: string]: any }): void {
for (const property of Object.keys(data)) {
if (data[property] != null) {
this.initData[property] = JSON.stringify(data[property]);
} else {
delete this.initData[property];
}
}
this.serializedInitData = '{'
+ Object.keys(this.initData).map(key => `"${key}": ${this.initData[key]}`).join(', ')
+ '}';
}
private updateInitData(): void {
const _blocks = blocks.getBlocks().slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT);
const da = difficultyAdjustment.getDifficultyAdjustment();
this.setInitDataFields({
'mempoolInfo': memPool.getMempoolInfo(),
'vBytesPerSecond': memPool.getVBytesPerSecond(),
'blocks': _blocks,
'conversions': priceUpdater.getLatestPrices(),
'mempool-blocks': mempoolBlocks.getMempoolBlocks(),
'transactions': memPool.getLatestTransactions(),
'backendInfo': backendInfo.getBackendInfo(),
'loadingIndicators': loadingIndicators.getLoadingIndicators(),
'da': da?.previousTime ? da : undefined,
'fees': feeApi.getRecommendedFee(),
});
}
public getSerializedInitData(): string {
return this.serializedInitData;
} }
setupConnectionHandling() { setupConnectionHandling() {
@ -42,7 +84,11 @@ class WebsocketHandler {
} }
this.wss.on('connection', (client: WebSocket) => { this.wss.on('connection', (client: WebSocket) => {
this.numConnected++;
client.on('error', logger.info); client.on('error', logger.info);
client.on('close', () => {
this.numDisconnected++;
});
client.on('message', async (message: string) => { client.on('message', async (message: string) => {
try { try {
const parsedMessage: WebsocketResponse = JSON.parse(message); const parsedMessage: WebsocketResponse = JSON.parse(message);
@ -58,9 +104,10 @@ class WebsocketHandler {
if (parsedMessage && parsedMessage['track-tx']) { if (parsedMessage && parsedMessage['track-tx']) {
if (/^[a-fA-F0-9]{64}$/.test(parsedMessage['track-tx'])) { if (/^[a-fA-F0-9]{64}$/.test(parsedMessage['track-tx'])) {
client['track-tx'] = parsedMessage['track-tx']; client['track-tx'] = parsedMessage['track-tx'];
const trackTxid = client['track-tx'];
// Client is telling the transaction wasn't found // Client is telling the transaction wasn't found
if (parsedMessage['watch-mempool']) { if (parsedMessage['watch-mempool']) {
const rbfCacheTxid = rbfCache.getReplacedBy(client['track-tx']); const rbfCacheTxid = rbfCache.getReplacedBy(trackTxid);
if (rbfCacheTxid) { if (rbfCacheTxid) {
response['txReplaced'] = { response['txReplaced'] = {
txid: rbfCacheTxid, txid: rbfCacheTxid,
@ -68,7 +115,7 @@ class WebsocketHandler {
client['track-tx'] = null; client['track-tx'] = null;
} else { } else {
// It might have appeared before we had the time to start watching for it // It might have appeared before we had the time to start watching for it
const tx = memPool.getMempool()[client['track-tx']]; const tx = memPool.getMempool()[trackTxid];
if (tx) { if (tx) {
if (config.MEMPOOL.BACKEND === 'esplora') { if (config.MEMPOOL.BACKEND === 'esplora') {
response['tx'] = tx; response['tx'] = tx;
@ -92,6 +139,13 @@ class WebsocketHandler {
} }
} }
} }
const tx = memPool.getMempool()[trackTxid];
if (tx && tx.position) {
response['txPosition'] = {
txid: trackTxid,
position: tx.position,
};
}
} else { } else {
client['track-tx'] = null; client['track-tx'] = null;
} }
@ -132,12 +186,22 @@ class WebsocketHandler {
} }
} }
if (parsedMessage && parsedMessage['track-rbf'] !== undefined) {
if (['all', 'fullRbf'].includes(parsedMessage['track-rbf'])) {
client['track-rbf'] = parsedMessage['track-rbf'];
} else {
client['track-rbf'] = false;
}
}
if (parsedMessage.action === 'init') { if (parsedMessage.action === 'init') {
const _blocks = blocks.getBlocks().slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT); if (!this.initData['blocks']?.length || !this.initData['da']) {
if (!_blocks) { this.updateInitData();
}
if (!this.initData['blocks']?.length) {
return; return;
} }
client.send(JSON.stringify(this.getInitData(_blocks))); client.send(this.serializedInitData);
} }
if (parsedMessage.action === 'ping') { if (parsedMessage.action === 'ping') {
@ -186,11 +250,14 @@ class WebsocketHandler {
throw new Error('WebSocket.Server is not set'); throw new Error('WebSocket.Server is not set');
} }
this.setInitDataFields({ 'loadingIndicators': indicators });
const response = JSON.stringify({ loadingIndicators: indicators });
this.wss.clients.forEach((client) => { this.wss.clients.forEach((client) => {
if (client.readyState !== WebSocket.OPEN) { if (client.readyState !== WebSocket.OPEN) {
return; return;
} }
client.send(JSON.stringify({ loadingIndicators: indicators })); client.send(response);
}); });
} }
@ -199,39 +266,28 @@ class WebsocketHandler {
throw new Error('WebSocket.Server is not set'); throw new Error('WebSocket.Server is not set');
} }
this.setInitDataFields({ 'conversions': conversionRates });
const response = JSON.stringify({ conversions: conversionRates });
this.wss.clients.forEach((client) => { this.wss.clients.forEach((client) => {
if (client.readyState !== WebSocket.OPEN) { if (client.readyState !== WebSocket.OPEN) {
return; return;
} }
client.send(JSON.stringify({ conversions: conversionRates })); client.send(response);
}); });
} }
getInitData(_blocks?: BlockExtended[]) {
if (!_blocks) {
_blocks = blocks.getBlocks().slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT);
}
const da = difficultyAdjustment.getDifficultyAdjustment();
return {
'mempoolInfo': memPool.getMempoolInfo(),
'vBytesPerSecond': memPool.getVBytesPerSecond(),
'blocks': _blocks,
'conversions': priceUpdater.getLatestPrices(),
'mempool-blocks': mempoolBlocks.getMempoolBlocks(),
'transactions': memPool.getLatestTransactions(),
'backendInfo': backendInfo.getBackendInfo(),
'loadingIndicators': loadingIndicators.getLoadingIndicators(),
'da': da?.previousTime ? da : undefined,
'fees': feeApi.getRecommendedFee(),
...this.extraInitProperties
};
}
handleNewStatistic(stats: OptimizedStatistic) { handleNewStatistic(stats: OptimizedStatistic) {
if (!this.wss) { if (!this.wss) {
throw new Error('WebSocket.Server is not set'); throw new Error('WebSocket.Server is not set');
} }
this.printLogs();
const response = JSON.stringify({
'live-2h-chart': stats
});
this.wss.clients.forEach((client) => { this.wss.clients.forEach((client) => {
if (client.readyState !== WebSocket.OPEN) { if (client.readyState !== WebSocket.OPEN) {
return; return;
@ -241,20 +297,20 @@ class WebsocketHandler {
return; return;
} }
client.send(JSON.stringify({ client.send(response);
'live-2h-chart': stats
}));
}); });
} }
async handleMempoolChange(newMempool: { [txid: string]: TransactionExtended }, async $handleMempoolChange(newMempool: { [txid: string]: TransactionExtended },
newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]): Promise<void> { newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]): Promise<void> {
if (!this.wss) { if (!this.wss) {
throw new Error('WebSocket.Server is not set'); throw new Error('WebSocket.Server is not set');
} }
this.printLogs();
if (config.MEMPOOL.ADVANCED_GBT_MEMPOOL) { if (config.MEMPOOL.ADVANCED_GBT_MEMPOOL) {
await mempoolBlocks.updateBlockTemplates(newMempool, newTransactions, deletedTransactions.map(tx => tx.txid), true); await mempoolBlocks.$updateBlockTemplates(newMempool, newTransactions, deletedTransactions, true);
} else { } else {
mempoolBlocks.updateMempoolBlocks(newMempool, true); mempoolBlocks.updateMempoolBlocks(newMempool, true);
} }
@ -266,8 +322,55 @@ class WebsocketHandler {
const rbfTransactions = Common.findRbfTransactions(newTransactions, deletedTransactions); const rbfTransactions = Common.findRbfTransactions(newTransactions, deletedTransactions);
const da = difficultyAdjustment.getDifficultyAdjustment(); const da = difficultyAdjustment.getDifficultyAdjustment();
memPool.handleRbfTransactions(rbfTransactions); memPool.handleRbfTransactions(rbfTransactions);
const rbfChanges = rbfCache.getRbfChanges();
let rbfReplacements;
let fullRbfReplacements;
if (Object.keys(rbfChanges.trees).length) {
rbfReplacements = rbfCache.getRbfTrees(false);
fullRbfReplacements = rbfCache.getRbfTrees(true);
}
for (const deletedTx of deletedTransactions) {
rbfCache.evict(deletedTx.txid);
}
const recommendedFees = feeApi.getRecommendedFee(); const recommendedFees = feeApi.getRecommendedFee();
// update init data
this.updateInitData();
// cache serialized objects to avoid stringify-ing the same thing for every client
const responseCache = { ...this.initData };
function getCachedResponse(key: string, data): string {
if (!responseCache[key]) {
responseCache[key] = JSON.stringify(data);
}
return responseCache[key];
}
// pre-compute new tracked outspends
const outspendCache: { [txid: string]: { [vout: number]: { vin: number, txid: string } } } = {};
const trackedTxs = new Set<string>();
this.wss.clients.forEach((client) => {
if (client['track-tx']) {
trackedTxs.add(client['track-tx']);
}
});
if (trackedTxs.size > 0) {
for (const tx of newTransactions) {
for (let i = 0; i < tx.vin.length; i++) {
const vin = tx.vin[i];
if (trackedTxs.has(vin.txid)) {
if (!outspendCache[vin.txid]) {
outspendCache[vin.txid] = { [vin.vout]: { vin: i, txid: tx.txid }};
} else {
outspendCache[vin.txid][vin.vout] = { vin: i, txid: tx.txid };
}
}
}
}
}
const latestTransactions = newTransactions.slice(0, 6).map((tx) => Common.stripTransaction(tx));
this.wss.clients.forEach(async (client) => { this.wss.clients.forEach(async (client) => {
if (client.readyState !== WebSocket.OPEN) { if (client.readyState !== WebSocket.OPEN) {
return; return;
@ -276,17 +379,17 @@ class WebsocketHandler {
const response = {}; const response = {};
if (client['want-stats']) { if (client['want-stats']) {
response['mempoolInfo'] = mempoolInfo; response['mempoolInfo'] = getCachedResponse('mempoolInfo', mempoolInfo);
response['vBytesPerSecond'] = vBytesPerSecond; response['vBytesPerSecond'] = getCachedResponse('vBytesPerSecond', vBytesPerSecond);
response['transactions'] = newTransactions.slice(0, 6).map((tx) => Common.stripTransaction(tx)); response['transactions'] = getCachedResponse('transactions', latestTransactions);
if (da?.previousTime) { if (da?.previousTime) {
response['da'] = da; response['da'] = getCachedResponse('da', da);
} }
response['fees'] = recommendedFees; response['fees'] = getCachedResponse('fees', recommendedFees);
} }
if (client['want-mempool-blocks']) { if (client['want-mempool-blocks']) {
response['mempool-blocks'] = mBlocks; response['mempool-blocks'] = getCachedResponse('mempool-blocks', mBlocks);
} }
if (client['track-mempool-tx']) { if (client['track-mempool-tx']) {
@ -295,12 +398,12 @@ class WebsocketHandler {
if (config.MEMPOOL.BACKEND !== 'esplora') { if (config.MEMPOOL.BACKEND !== 'esplora') {
try { try {
const fullTx = await transactionUtils.$getTransactionExtended(tx.txid, true); const fullTx = await transactionUtils.$getTransactionExtended(tx.txid, true);
response['tx'] = fullTx; response['tx'] = JSON.stringify(fullTx);
} catch (e) { } catch (e) {
logger.debug('Error finding transaction in mempool: ' + (e instanceof Error ? e.message : e)); logger.debug('Error finding transaction in mempool: ' + (e instanceof Error ? e.message : e));
} }
} else { } else {
response['tx'] = tx; response['tx'] = JSON.stringify(tx);
} }
client['track-mempool-tx'] = null; client['track-mempool-tx'] = null;
} }
@ -340,7 +443,7 @@ class WebsocketHandler {
} }
if (foundTransactions.length) { if (foundTransactions.length) {
response['address-transactions'] = foundTransactions; response['address-transactions'] = JSON.stringify(foundTransactions);
} }
} }
@ -369,49 +472,60 @@ class WebsocketHandler {
}); });
if (foundTransactions.length) { if (foundTransactions.length) {
response['address-transactions'] = foundTransactions; response['address-transactions'] = JSON.stringify(foundTransactions);
} }
} }
if (client['track-tx']) { if (client['track-tx']) {
const outspends: object = {}; const trackTxid = client['track-tx'];
newTransactions.forEach((tx) => tx.vin.forEach((vin, i) => { const outspends = outspendCache[trackTxid];
if (vin.txid === client['track-tx']) {
outspends[vin.vout] = {
vin: i,
txid: tx.txid,
};
}
}));
if (Object.keys(outspends).length) { if (outspends && Object.keys(outspends).length) {
response['utxoSpent'] = outspends; response['utxoSpent'] = JSON.stringify(outspends);
} }
if (rbfTransactions[client['track-tx']]) { const rbfReplacedBy = rbfCache.getReplacedBy(client['track-tx']);
for (const rbfTransaction in rbfTransactions) { if (rbfReplacedBy) {
if (client['track-tx'] === rbfTransaction) { response['rbfTransaction'] = JSON.stringify({
response['rbfTransaction'] = { txid: rbfReplacedBy,
txid: rbfTransactions[rbfTransaction].txid, })
}; }
break;
} const rbfChange = rbfChanges.map[client['track-tx']];
} if (rbfChange) {
response['rbfInfo'] = JSON.stringify(rbfChanges.trees[rbfChange]);
}
const mempoolTx = newMempool[trackTxid];
if (mempoolTx && mempoolTx.position) {
response['txPosition'] = JSON.stringify({
txid: trackTxid,
position: mempoolTx.position,
});
} }
} }
if (client['track-mempool-block'] >= 0) { if (client['track-mempool-block'] >= 0) {
const index = client['track-mempool-block']; const index = client['track-mempool-block'];
if (mBlockDeltas[index]) { if (mBlockDeltas[index]) {
response['projected-block-transactions'] = { response['projected-block-transactions'] = getCachedResponse(`projected-block-transactions-${index}`, {
index: index, index: index,
delta: mBlockDeltas[index], delta: mBlockDeltas[index],
}; });
} }
} }
if (client['track-rbf'] === 'all' && rbfReplacements) {
response['rbfLatest'] = getCachedResponse('rbfLatest', rbfReplacements);
} else if (client['track-rbf'] === 'fullRbf' && fullRbfReplacements) {
response['rbfLatest'] = getCachedResponse('fullrbfLatest', fullRbfReplacements);
}
if (Object.keys(response).length) { if (Object.keys(response).length) {
client.send(JSON.stringify(response)); const serializedResponse = '{'
+ Object.keys(response).map(key => `"${key}": ${response[key]}`).join(', ')
+ '}';
client.send(serializedResponse);
} }
}); });
} }
@ -421,17 +535,25 @@ class WebsocketHandler {
throw new Error('WebSocket.Server is not set'); throw new Error('WebSocket.Server is not set');
} }
this.printLogs();
const _memPool = memPool.getMempool(); const _memPool = memPool.getMempool();
if (config.MEMPOOL.AUDIT) { if (config.MEMPOOL.AUDIT) {
let projectedBlocks; let projectedBlocks;
let auditMempool = _memPool;
// template calculation functions have mempool side effects, so calculate audits using // 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 // a cloned copy of the mempool if we're running a different algorithm for mempool updates
const auditMempool = (config.MEMPOOL.ADVANCED_GBT_AUDIT === config.MEMPOOL.ADVANCED_GBT_MEMPOOL) ? _memPool : deepClone(_memPool); const separateAudit = config.MEMPOOL.ADVANCED_GBT_AUDIT !== config.MEMPOOL.ADVANCED_GBT_MEMPOOL;
if (config.MEMPOOL.ADVANCED_GBT_AUDIT) { if (separateAudit) {
projectedBlocks = await mempoolBlocks.makeBlockTemplates(auditMempool, false); auditMempool = deepClone(_memPool);
if (config.MEMPOOL.ADVANCED_GBT_AUDIT) {
projectedBlocks = await mempoolBlocks.$makeBlockTemplates(auditMempool, false);
} else {
projectedBlocks = mempoolBlocks.updateMempoolBlocks(auditMempool, false);
}
} else { } else {
projectedBlocks = mempoolBlocks.updateMempoolBlocks(auditMempool, false); projectedBlocks = mempoolBlocks.getMempoolBlocksWithTransactions();
} }
if (Common.indexingEnabled() && memPool.isInSync()) { if (Common.indexingEnabled() && memPool.isInSync()) {
@ -477,16 +599,14 @@ class WebsocketHandler {
} }
} }
const removed: string[] = [];
// Update mempool to remove transactions included in the new block // Update mempool to remove transactions included in the new block
for (const txId of txIds) { for (const txId of txIds) {
delete _memPool[txId]; delete _memPool[txId];
removed.push(txId); rbfCache.mined(txId);
rbfCache.evict(txId);
} }
if (config.MEMPOOL.ADVANCED_GBT_MEMPOOL) { if (config.MEMPOOL.ADVANCED_GBT_MEMPOOL) {
await mempoolBlocks.updateBlockTemplates(_memPool, [], removed, true); await mempoolBlocks.$makeBlockTemplates(_memPool, true);
} else { } else {
mempoolBlocks.updateMempoolBlocks(_memPool, true); mempoolBlocks.updateMempoolBlocks(_memPool, true);
} }
@ -496,6 +616,19 @@ class WebsocketHandler {
const da = difficultyAdjustment.getDifficultyAdjustment(); const da = difficultyAdjustment.getDifficultyAdjustment();
const fees = feeApi.getRecommendedFee(); const fees = feeApi.getRecommendedFee();
// update init data
this.updateInitData();
const responseCache = { ...this.initData };
function getCachedResponse(key, data): string {
if (!responseCache[key]) {
responseCache[key] = JSON.stringify(data);
}
return responseCache[key];
}
const mempoolInfo = memPool.getMempoolInfo();
this.wss.clients.forEach((client) => { this.wss.clients.forEach((client) => {
if (client.readyState !== WebSocket.OPEN) { if (client.readyState !== WebSocket.OPEN) {
return; return;
@ -505,19 +638,29 @@ class WebsocketHandler {
return; return;
} }
const response = { const response = {};
'block': block, response['block'] = getCachedResponse('block', block);
'mempoolInfo': memPool.getMempoolInfo(), response['mempoolInfo'] = getCachedResponse('mempoolInfo', mempoolInfo);
'da': da?.previousTime ? da : undefined, response['da'] = getCachedResponse('da', da?.previousTime ? da : undefined);
'fees': fees, response['fees'] = getCachedResponse('fees', fees);
};
if (mBlocks && client['want-mempool-blocks']) { if (mBlocks && client['want-mempool-blocks']) {
response['mempool-blocks'] = mBlocks; response['mempool-blocks'] = getCachedResponse('mempool-blocks', mBlocks);
} }
if (client['track-tx'] && txIds.indexOf(client['track-tx']) > -1) { if (client['track-tx']) {
response['txConfirmed'] = true; const trackTxid = client['track-tx'];
if (txIds.indexOf(trackTxid) > -1) {
response['txConfirmed'] = 'true';
} else {
const mempoolTx = _memPool[trackTxid];
if (mempoolTx && mempoolTx.position) {
response['txPosition'] = JSON.stringify({
txid: trackTxid,
position: mempoolTx.position,
});
}
}
} }
if (client['track-address']) { if (client['track-address']) {
@ -543,7 +686,7 @@ class WebsocketHandler {
}; };
}); });
response['block-transactions'] = foundTransactions; response['block-transactions'] = JSON.stringify(foundTransactions);
} }
} }
@ -580,23 +723,37 @@ class WebsocketHandler {
}; };
}); });
response['block-transactions'] = foundTransactions; response['block-transactions'] = JSON.stringify(foundTransactions);
} }
} }
if (client['track-mempool-block'] >= 0) { if (client['track-mempool-block'] >= 0) {
const index = client['track-mempool-block']; const index = client['track-mempool-block'];
if (mBlockDeltas && mBlockDeltas[index]) { if (mBlockDeltas && mBlockDeltas[index]) {
response['projected-block-transactions'] = { response['projected-block-transactions'] = getCachedResponse(`projected-block-transactions-${index}`, {
index: index, index: index,
delta: mBlockDeltas[index], delta: mBlockDeltas[index],
}; });
} }
} }
client.send(JSON.stringify(response)); const serializedResponse = '{'
+ Object.keys(response).map(key => `"${key}": ${response[key]}`).join(', ')
+ '}';
client.send(serializedResponse);
}); });
} }
private printLogs(): void {
if (this.wss) {
const count = this.wss?.clients?.size || 0;
const diff = count - this.numClients;
this.numClients = count;
logger.debug(`${count} websocket clients | ${this.numConnected} connected | ${this.numDisconnected} disconnected | (${diff >= 0 ? '+' : ''}${diff})`);
this.numConnected = 0;
this.numDisconnected = 0;
}
}
} }
export default new WebsocketHandler(); export default new WebsocketHandler();

View file

@ -86,6 +86,7 @@ interface IConfig {
DATABASE: string; DATABASE: string;
USERNAME: string; USERNAME: string;
PASSWORD: string; PASSWORD: string;
TIMEOUT: number;
}; };
SYSLOG: { SYSLOG: {
ENABLED: boolean; ENABLED: boolean;
@ -194,7 +195,8 @@ const defaults: IConfig = {
'PORT': 3306, 'PORT': 3306,
'DATABASE': 'mempool', 'DATABASE': 'mempool',
'USERNAME': 'mempool', 'USERNAME': 'mempool',
'PASSWORD': 'mempool' 'PASSWORD': 'mempool',
'TIMEOUT': 180000,
}, },
'SYSLOG': { 'SYSLOG': {
'ENABLED': true, 'ENABLED': true,

View file

@ -33,8 +33,32 @@ import { FieldPacket, OkPacket, PoolOptions, ResultSetHeader, RowDataPacket } fr
OkPacket[] | ResultSetHeader>(query, params?): Promise<[T, FieldPacket[]]> OkPacket[] | ResultSetHeader>(query, params?): Promise<[T, FieldPacket[]]>
{ {
this.checkDBFlag(); this.checkDBFlag();
const pool = await this.getPool(); let hardTimeout;
return pool.query(query, params); if (query?.timeout != null) {
hardTimeout = Math.floor(query.timeout * 1.1);
} else {
hardTimeout = config.DATABASE.TIMEOUT;
}
if (hardTimeout > 0) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`DB query failed to return, reject or time out within ${hardTimeout / 1000}s - ${query?.sql?.slice(0, 160) || (typeof(query) === 'string' || query instanceof String ? query?.slice(0, 160) : 'unknown query')}`));
}, hardTimeout);
this.getPool().then(pool => {
return pool.query(query, params) as Promise<[T, FieldPacket[]]>;
}).then(result => {
resolve(result);
}).catch(error => {
reject(error);
}).finally(() => {
clearTimeout(timer);
});
});
} else {
const pool = await this.getPool();
return pool.query(query, params);
}
} }
public async checkDbConnection() { public async checkDbConnection() {

View file

@ -2,6 +2,7 @@ import express from 'express';
import { Application, Request, Response, NextFunction } from 'express'; import { Application, Request, Response, NextFunction } from 'express';
import * as http from 'http'; import * as http from 'http';
import * as WebSocket from 'ws'; import * as WebSocket from 'ws';
import bitcoinApi from './api/bitcoin/bitcoin-api-factory';
import cluster from 'cluster'; import cluster from 'cluster';
import DB from './database'; import DB from './database';
import config from './config'; import config from './config';
@ -121,7 +122,7 @@ class Server {
await poolsUpdater.updatePoolsJson(); // Needs to be done before loading the disk cache because we sometimes wipe it await poolsUpdater.updatePoolsJson(); // Needs to be done before loading the disk cache because we sometimes wipe it
await syncAssets.syncAssets$(); await syncAssets.syncAssets$();
if (config.MEMPOOL.ENABLED) { if (config.MEMPOOL.ENABLED) {
diskCache.loadMempoolCache(); await diskCache.$loadMempoolCache();
} }
if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED && cluster.isPrimary) { if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED && cluster.isPrimary) {
@ -179,12 +180,15 @@ class Server {
logger.debug(msg); logger.debug(msg);
} }
} }
memPool.deleteExpiredTransactions(); const newMempool = await bitcoinApi.$getRawMempool();
await blocks.$updateBlocks(); const numHandledBlocks = await blocks.$updateBlocks();
await memPool.$updateMempool(); if (numHandledBlocks === 0) {
await memPool.$updateMempool(newMempool);
}
indexer.$run(); indexer.$run();
setTimeout(this.runMainUpdateLoop.bind(this), config.MEMPOOL.POLL_RATE_MS); // rerun immediately if we skipped the mempool update, otherwise wait POLL_RATE_MS
setTimeout(this.runMainUpdateLoop.bind(this), numHandledBlocks > 0 ? 1 : config.MEMPOOL.POLL_RATE_MS);
this.backendRetryCount = 0; this.backendRetryCount = 0;
} catch (e: any) { } catch (e: any) {
this.backendRetryCount++; this.backendRetryCount++;
@ -205,6 +209,8 @@ class Server {
logger.debug(`AxiosError: ${e?.message}`); logger.debug(`AxiosError: ${e?.message}`);
} }
setTimeout(this.runMainUpdateLoop.bind(this), 1000 * this.currentBackendRetryInterval); setTimeout(this.runMainUpdateLoop.bind(this), 1000 * this.currentBackendRetryInterval);
} finally {
diskCache.unlock();
} }
} }
@ -237,7 +243,7 @@ class Server {
websocketHandler.setupConnectionHandling(); websocketHandler.setupConnectionHandling();
if (config.MEMPOOL.ENABLED) { if (config.MEMPOOL.ENABLED) {
statistics.setNewStatisticsEntryCallback(websocketHandler.handleNewStatistic.bind(websocketHandler)); statistics.setNewStatisticsEntryCallback(websocketHandler.handleNewStatistic.bind(websocketHandler));
memPool.setAsyncMempoolChangedCallback(websocketHandler.handleMempoolChange.bind(websocketHandler)); memPool.setAsyncMempoolChangedCallback(websocketHandler.$handleMempoolChange.bind(websocketHandler));
blocks.setNewAsyncBlockCallback(websocketHandler.handleNewBlock.bind(websocketHandler)); blocks.setNewAsyncBlockCallback(websocketHandler.handleNewBlock.bind(websocketHandler));
} }
priceUpdater.setRatesChangedCallback(websocketHandler.handleNewConversionRates.bind(websocketHandler)); priceUpdater.setRatesChangedCallback(websocketHandler.handleNewConversionRates.bind(websocketHandler));

View file

@ -58,6 +58,7 @@ export interface MempoolBlockWithTransactions extends MempoolBlock {
export interface MempoolBlockDelta { export interface MempoolBlockDelta {
added: TransactionStripped[]; added: TransactionStripped[];
removed: string[]; removed: string[];
changed: { txid: string, rate: number | undefined }[];
} }
interface VinStrippedToScriptsig { interface VinStrippedToScriptsig {
@ -79,18 +80,22 @@ export interface TransactionExtended extends IEsploraApi.Transaction {
descendants?: Ancestor[]; descendants?: Ancestor[];
bestDescendant?: BestDescendant | null; bestDescendant?: BestDescendant | null;
cpfpChecked?: boolean; cpfpChecked?: boolean;
deleteAfter?: number; position?: {
block: number,
vsize: number,
};
uid?: number;
} }
export interface AuditTransaction { export interface AuditTransaction {
txid: string; uid: number;
fee: number; fee: number;
weight: number; weight: number;
feePerVsize: number; feePerVsize: number;
effectiveFeePerVsize: number; effectiveFeePerVsize: number;
vin: string[]; inputs: number[];
relativesSet: boolean; relativesSet: boolean;
ancestorMap: Map<string, AuditTransaction>; ancestorMap: Map<number, AuditTransaction>;
children: Set<AuditTransaction>; children: Set<AuditTransaction>;
ancestorFee: number; ancestorFee: number;
ancestorWeight: number; ancestorWeight: number;
@ -100,13 +105,25 @@ export interface AuditTransaction {
modifiedNode: HeapNode<AuditTransaction>; modifiedNode: HeapNode<AuditTransaction>;
} }
export interface CompactThreadTransaction {
uid: number;
fee: number;
weight: number;
feePerVsize: number;
effectiveFeePerVsize?: number;
inputs: number[];
cpfpRoot?: string;
cpfpChecked?: boolean;
dirty?: boolean;
}
export interface ThreadTransaction { export interface ThreadTransaction {
txid: string; txid: string;
fee: number; fee: number;
weight: number; weight: number;
feePerVsize: number; feePerVsize: number;
effectiveFeePerVsize?: number; effectiveFeePerVsize?: number;
vin: string[]; inputs: number[];
cpfpRoot?: string; cpfpRoot?: string;
cpfpChecked?: boolean; cpfpChecked?: boolean;
} }
@ -145,6 +162,7 @@ export interface TransactionStripped {
fee: number; fee: number;
vsize: number; vsize: number;
value: number; value: number;
rate?: number; // effective fee rate
} }
export interface BlockExtension { export interface BlockExtension {
@ -219,6 +237,11 @@ export interface EffectiveFeeStats {
feeRange: number[]; // 2nd, 10th, 25th, 50th, 75th, 90th, 98th percentiles feeRange: number[]; // 2nd, 10th, 25th, 50th, 75th, 90th, 98th percentiles
} }
export interface WorkingEffectiveFeeStats extends EffectiveFeeStats {
minFee: number;
maxFee: number;
}
export interface CpfpSummary { export interface CpfpSummary {
transactions: TransactionExtended[]; transactions: TransactionExtended[];
clusters: { root: string, height: number, txs: Ancestor[], effectiveFeePerVsize: number }[]; clusters: { root: string, height: number, txs: Ancestor[], effectiveFeePerVsize: number }[];

View file

@ -152,7 +152,7 @@ class ForensicsService {
++progress; ++progress;
const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer); const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer);
if (elapsedSeconds > 10) { if (elapsedSeconds > 10) {
logger.info(`Updating channel closed channel forensics ${progress}/${channels.length}`); logger.debug(`Updating channel closed channel forensics ${progress}/${channels.length}`);
this.loggerTimer = new Date().getTime() / 1000; this.loggerTimer = new Date().getTime() / 1000;
} }
} }
@ -257,7 +257,7 @@ class ForensicsService {
++progress; ++progress;
const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer); const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer);
if (elapsedSeconds > 10) { if (elapsedSeconds > 10) {
logger.info(`Updating opened channel forensics ${progress}/${channels?.length}`); logger.debug(`Updating opened channel forensics ${progress}/${channels?.length}`);
this.loggerTimer = new Date().getTime() / 1000; this.loggerTimer = new Date().getTime() / 1000;
this.truncateTempCache(); this.truncateTempCache();
} }

View file

@ -300,7 +300,7 @@ class NetworkSyncService {
++progress; ++progress;
const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer); const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer);
if (elapsedSeconds > config.LIGHTNING.LOGGER_UPDATE_INTERVAL) { if (elapsedSeconds > config.LIGHTNING.LOGGER_UPDATE_INTERVAL) {
logger.info(`Checking if channel has been closed ${progress}/${channels.length}`, logger.tags.ln); logger.debug(`Checking if channel has been closed ${progress}/${channels.length}`, logger.tags.ln);
this.loggerTimer = new Date().getTime() / 1000; this.loggerTimer = new Date().getTime() / 1000;
} }
} }

View file

@ -0,0 +1,3 @@
I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of January 25, 2022.
Signed: vostrnad

View file

@ -269,6 +269,7 @@ Corresponding `docker-compose.yml` overrides:
DATABASE_DATABASE: "" DATABASE_DATABASE: ""
DATABASE_USERNAME: "" DATABASE_USERNAME: ""
DATABASE_PASSWORD: "" DATABASE_PASSWORD: ""
DATABASE_TIMEOUT: ""
... ...
``` ```

View file

@ -60,7 +60,8 @@
"PORT": __DATABASE_PORT__, "PORT": __DATABASE_PORT__,
"DATABASE": "__DATABASE_DATABASE__", "DATABASE": "__DATABASE_DATABASE__",
"USERNAME": "__DATABASE_USERNAME__", "USERNAME": "__DATABASE_USERNAME__",
"PASSWORD": "__DATABASE_PASSWORD__" "PASSWORD": "__DATABASE_PASSWORD__",
"TIMEOUT": "__DATABASE_TIMEOUT__"
}, },
"SYSLOG": { "SYSLOG": {
"ENABLED": __SYSLOG_ENABLED__, "ENABLED": __SYSLOG_ENABLED__,

View file

@ -64,6 +64,7 @@ __DATABASE_PORT__=${DATABASE_PORT:=3306}
__DATABASE_DATABASE__=${DATABASE_DATABASE:=mempool} __DATABASE_DATABASE__=${DATABASE_DATABASE:=mempool}
__DATABASE_USERNAME__=${DATABASE_USERNAME:=mempool} __DATABASE_USERNAME__=${DATABASE_USERNAME:=mempool}
__DATABASE_PASSWORD__=${DATABASE_PASSWORD:=mempool} __DATABASE_PASSWORD__=${DATABASE_PASSWORD:=mempool}
__DATABASE_TIMEOUT__=${DATABASE_TIMEOUT:=180000}
# SYSLOG # SYSLOG
__SYSLOG_ENABLED__=${SYSLOG_ENABLED:=false} __SYSLOG_ENABLED__=${SYSLOG_ENABLED:=false}

View file

@ -39,6 +39,7 @@ __AUDIT__=${AUDIT:=false}
__MAINNET_BLOCK_AUDIT_START_HEIGHT__=${MAINNET_BLOCK_AUDIT_START_HEIGHT:=0} __MAINNET_BLOCK_AUDIT_START_HEIGHT__=${MAINNET_BLOCK_AUDIT_START_HEIGHT:=0}
__TESTNET_BLOCK_AUDIT_START_HEIGHT__=${TESTNET_BLOCK_AUDIT_START_HEIGHT:=0} __TESTNET_BLOCK_AUDIT_START_HEIGHT__=${TESTNET_BLOCK_AUDIT_START_HEIGHT:=0}
__SIGNET_BLOCK_AUDIT_START_HEIGHT__=${SIGNET_BLOCK_AUDIT_START_HEIGHT:=0} __SIGNET_BLOCK_AUDIT_START_HEIGHT__=${SIGNET_BLOCK_AUDIT_START_HEIGHT:=0}
__FULL_RBF_ENABLED__=${FULL_RBF_ENABLED:=false}
__HISTORICAL_PRICE__=${HISTORICAL_PRICE:=true} __HISTORICAL_PRICE__=${HISTORICAL_PRICE:=true}
# Export as environment variables to be used by envsubst # Export as environment variables to be used by envsubst
@ -65,6 +66,7 @@ export __AUDIT__
export __MAINNET_BLOCK_AUDIT_START_HEIGHT__ export __MAINNET_BLOCK_AUDIT_START_HEIGHT__
export __TESTNET_BLOCK_AUDIT_START_HEIGHT__ export __TESTNET_BLOCK_AUDIT_START_HEIGHT__
export __SIGNET_BLOCK_AUDIT_START_HEIGHT__ export __SIGNET_BLOCK_AUDIT_START_HEIGHT__
export __FULL_RBF_ENABLED__
export __HISTORICAL_PRICE__ export __HISTORICAL_PRICE__
folder=$(find /var/www/mempool -name "config.js" | xargs dirname) folder=$(find /var/www/mempool -name "config.js" | xargs dirname)

View file

@ -504,9 +504,17 @@ describe('Mainnet', () => {
describe('RBF transactions', () => { describe('RBF transactions', () => {
it('shows RBF transactions properly (mobile)', () => { it('shows RBF transactions properly (mobile)', () => {
cy.intercept('/api/v1/tx/21518a98d1aa9df524865d2f88c578499f524eb1d0c4d3e70312ab863508692f/cached', {
fixture: 'mainnet_tx_cached.json'
}).as('cached_tx');
cy.intercept('/api/v1/tx/f81a08699b62b2070ad8fe0f2a076f8bea0386a2fdcd8124caee42cbc564a0d5/rbf', {
fixture: 'mainnet_rbf_new.json'
}).as('rbf');
cy.viewport('iphone-xr'); cy.viewport('iphone-xr');
cy.mockMempoolSocket(); cy.mockMempoolSocket();
cy.visit('/tx/f81a08699b62b2070ad8fe0f2a076f8bea0386a2fdcd8124caee42cbc564a0d5'); cy.visit('/tx/21518a98d1aa9df524865d2f88c578499f524eb1d0c4d3e70312ab863508692f');
cy.waitForSkeletonGone(); cy.waitForSkeletonGone();
@ -524,22 +532,30 @@ describe('Mainnet', () => {
} }
}); });
cy.get('.alert-mempool').should('be.visible'); cy.get('.alert').should('be.visible');
cy.get('.alert-mempool').invoke('css', 'width').then((alertWidth) => { cy.get('.alert').invoke('css', 'width').then((alertWidth) => {
cy.get('.container-xl > :nth-child(3)').invoke('css', 'width').should('equal', alertWidth); cy.get('.container-xl > :nth-child(3)').invoke('css', 'width').should('equal', alertWidth);
}); });
cy.get('.btn-success').then(getRectangle).then((rectA) => { cy.get('.btn-danger').then(getRectangle).then((rectA) => {
cy.get('.alert-mempool').then(getRectangle).then((rectB) => { cy.get('.alert').then(getRectangle).then((rectB) => {
expect(areOverlapping(rectA, rectB), 'Confirmations box and RBF alert are overlapping').to.be.false; expect(areOverlapping(rectA, rectB), 'Confirmations box and RBF alert are overlapping').to.be.false;
}); });
}); });
}); });
it('shows RBF transactions properly (desktop)', () => { it('shows RBF transactions properly (desktop)', () => {
cy.intercept('/api/v1/tx/21518a98d1aa9df524865d2f88c578499f524eb1d0c4d3e70312ab863508692f/cached', {
fixture: 'mainnet_tx_cached.json'
}).as('cached_tx');
cy.intercept('/api/v1/tx/f81a08699b62b2070ad8fe0f2a076f8bea0386a2fdcd8124caee42cbc564a0d5/rbf', {
fixture: 'mainnet_rbf_new.json'
}).as('rbf');
cy.viewport('macbook-16'); cy.viewport('macbook-16');
cy.mockMempoolSocket(); cy.mockMempoolSocket();
cy.visit('/tx/f81a08699b62b2070ad8fe0f2a076f8bea0386a2fdcd8124caee42cbc564a0d5'); cy.visit('/tx/21518a98d1aa9df524865d2f88c578499f524eb1d0c4d3e70312ab863508692f');
cy.waitForSkeletonGone(); cy.waitForSkeletonGone();
@ -557,17 +573,17 @@ describe('Mainnet', () => {
} }
}); });
cy.get('.alert-mempool').should('be.visible'); cy.get('.alert').should('be.visible');
const alertLocator = '.alert-mempool'; const alertLocator = '.alert';
const tableLocator = '.container-xl > :nth-child(3)'; const tableLocator = '.container-xl > :nth-child(3)';
cy.get(tableLocator).invoke('css', 'width').then((firstWidth) => { cy.get(tableLocator).invoke('css', 'width').then((firstWidth) => {
cy.get(alertLocator).invoke('css', 'width').should('equal', firstWidth); cy.get(alertLocator).invoke('css', 'width').should('equal', firstWidth);
}); });
cy.get('.btn-success').then(getRectangle).then((rectA) => { cy.get('.btn-danger').then(getRectangle).then((rectA) => {
cy.get('.alert-mempool').then(getRectangle).then((rectB) => { cy.get('.alert').then(getRectangle).then((rectB) => {
expect(areOverlapping(rectA, rectB), 'Confirmations box and RBF alert are overlapping').to.be.false; expect(areOverlapping(rectA, rectB), 'Confirmations box and RBF alert are overlapping').to.be.false;
}); });
}); });

View file

@ -1,52 +1,4 @@
{ {
"rbfTransaction": { "txReplaced": {
"txid": "8913ec7ba0ede285dbd120e46f6d61a28f2903c10814a6f6c4f97d0edf3e1f46", "txid": "8913ec7ba0ede285dbd120e46f6d61a28f2903c10814a6f6c4f97d0edf3e1f46"
"version": 2, }}
"locktime": 632699,
"vin": [
{
"txid": "02238126a63ea2669c5f378012180ef8b54402a949316f9b2f1352c51730a086",
"vout": 0,
"prevout": {
"scriptpubkey": "a914f8e495456956c833e5e8c69b9a9dc041aa14c72f87",
"scriptpubkey_asm": "OP_HASH160 OP_PUSHBYTES_20 f8e495456956c833e5e8c69b9a9dc041aa14c72f OP_EQUAL",
"scriptpubkey_type": "p2sh",
"scriptpubkey_address": "3QP3LMD8veT5GtWV83Nosif2Bhr73857VB",
"value": 25000000
},
"scriptsig": "22002043288fbbc0fc5efa86c229dbb7d88ab78d57957c65b5d5ceaece70838976ad1b",
"scriptsig_asm": "OP_PUSHBYTES_34 002043288fbbc0fc5efa86c229dbb7d88ab78d57957c65b5d5ceaece70838976ad1b",
"witness": [
"",
"3044022009e2d3a8e645f65bc89c8492cd9c08e6fb02609fd402214884a754a1970145340220575bb325429def59f3a3f1e22d9740a3feecbe97438ff3bb5796b2c46b3c477f01",
"3044022039c34372882da8fc1c1243bd72b5e7e5e6870301ef56bdebb87bc647fb50f9b5022071a704ee77d742f78b10e45be675d4c45a5f31e884139e75c975144fde70e41701",
"522102346eb7133f11e0dc279bc592d5ac948a91676372a6144c9ae2085625d7fbf70421021b9508a458f9d59be4eb8cc87ad582c3b494106fb1d4ec22801569be0700eb7b52ae"
],
"is_coinbase": false,
"sequence": 4294967293,
"inner_redeemscript_asm": "OP_0 OP_PUSHBYTES_32 43288fbbc0fc5efa86c229dbb7d88ab78d57957c65b5d5ceaece70838976ad1b",
"inner_witnessscript_asm": "OP_PUSHNUM_2 OP_PUSHBYTES_33 02346eb7133f11e0dc279bc592d5ac948a91676372a6144c9ae2085625d7fbf704 OP_PUSHBYTES_33 021b9508a458f9d59be4eb8cc87ad582c3b494106fb1d4ec22801569be0700eb7b OP_PUSHNUM_2 OP_CHECKMULTISIG"
}
],
"vout": [
{
"scriptpubkey": "a914fd4e5e59dd5cf2dc48eaedf1a2a1650ca1ce9d7f87",
"scriptpubkey_asm": "OP_HASH160 OP_PUSHBYTES_20 fd4e5e59dd5cf2dc48eaedf1a2a1650ca1ce9d7f OP_EQUAL",
"scriptpubkey_type": "p2sh",
"scriptpubkey_address": "3QnNmDhZS7toHA7bhhbTPBdtpLJoeecq5c",
"value": 13986350
},
{
"scriptpubkey": "76a914edc93d0446deec1c2d514f3a490f050096e74e0e88ac",
"scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 edc93d0446deec1c2d514f3a490f050096e74e0e OP_EQUALVERIFY OP_CHECKSIG",
"scriptpubkey_type": "p2pkh",
"scriptpubkey_address": "1NgJDkTUqJxxCAAZrrsC87kWag5kphrRtM",
"value": 11000000
}
],
"size": 372,
"weight": 828,
"fee": 1.5,
"status": { "confirmed": false }
}
}

View file

@ -0,0 +1,31 @@
{
"replacements": {
"tx": {
"txid": "f22735aaa8eb84bcae3e7705f78609c6f5f0cd7dfc34ae03094e61f2dab0cc64",
"fee": 13843,
"vsize": 109.25,
"value": 253003805,
"rate": 36.04666732302845,
"rbf": true
},
"time": 1683865345,
"fullRbf": false,
"replaces": [
{
"tx": {
"txid": "21518a98d1aa9df524865d2f88c578499f524eb1d0c4d3e70312ab863508692f",
"fee": 8794,
"vsize": 109.25,
"value": 253008854,
"rate": 35.05247612484001,
"rbf": true
},
"time": 1683864993,
"interval": 352,
"fullRbf": false,
"replaces": []
}
]
},
"replaces": null
}

View file

@ -0,0 +1,60 @@
{
"vsize": 109,
"feePerVsize": 80.49427917620137,
"effectiveFeePerVsize": 35.05247612484001,
"txid": "21518a98d1aa9df524865d2f88c578499f524eb1d0c4d3e70312ab863508692f",
"version": 2,
"locktime": 0,
"vin": [
{
"txid": "1e3bd5c634781a6ba8bb3d3385b14739bf38cad5332d5fbc5c0ab775e54b9aef",
"vout": 144,
"prevout": {
"scriptpubkey": "0014d98654186b90d95da7e31a30929f5b5b6a0af250",
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 d98654186b90d95da7e31a30929f5b5b6a0af250",
"scriptpubkey_type": "v0_p2wpkh",
"scriptpubkey_address": "bc1qmxr9gxrtjrv4mflrrgcf986mtd4q4ujss432tk",
"value": 253017648
},
"scriptsig": "",
"scriptsig_asm": "",
"witness": [
"30440220448e8f58fcdea87c1969d58438b49da5b43712380bc4c68b02d22cf6b164907302207b2ed660f1a5b3b74f712961ffb3f3a7d1ac6e48b269ea6ff15df985042211f301",
"02e39a1f3583e382cec1a1fab6a3f5950b6403c953fada58d809127a497f502ebe"
],
"is_coinbase": false,
"sequence": 4294967293
}
],
"vout": [
{
"scriptpubkey": "0014edb5167da7e97c73d7931eb2130ac3e34e6845a9",
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 edb5167da7e97c73d7931eb2130ac3e34e6845a9",
"scriptpubkey_type": "v0_p2wpkh",
"scriptpubkey_address": "bc1qak63vld8a97884unr6epxzkrud8xs3dfdqswy2",
"value": 253008854
}
],
"size": 191,
"weight": 437,
"fee": 8794,
"status": {
"confirmed": false
},
"firstSeen": 1683864993,
"uid": 298353,
"position": {
"block": 0,
"vsize": 886207.5
},
"cpfpChecked": true,
"ancestors": [
{
"txid": "1e3bd5c634781a6ba8bb3d3385b14739bf38cad5332d5fbc5c0ab775e54b9aef",
"fee": 169220,
"weight": 19877
}
],
"descendants": [],
"bestDescendant": null
}

View file

@ -22,5 +22,6 @@
"TESTNET_BLOCK_AUDIT_START_HEIGHT": 0, "TESTNET_BLOCK_AUDIT_START_HEIGHT": 0,
"SIGNET_BLOCK_AUDIT_START_HEIGHT": 0, "SIGNET_BLOCK_AUDIT_START_HEIGHT": 0,
"LIGHTNING": false, "LIGHTNING": false,
"FULL_RBF_ENABLED": false,
"HISTORICAL_PRICE": true "HISTORICAL_PRICE": true
} }

View file

@ -4,6 +4,8 @@ import { AppPreloadingStrategy } from './app.preloading-strategy'
import { StartComponent } from './components/start/start.component'; import { StartComponent } from './components/start/start.component';
import { TransactionComponent } from './components/transaction/transaction.component'; import { TransactionComponent } from './components/transaction/transaction.component';
import { BlockComponent } from './components/block/block.component'; import { BlockComponent } from './components/block/block.component';
import { ClockMinedComponent as ClockMinedComponent } from './components/clock/clock-mined.component';
import { ClockMempoolComponent as ClockMempoolComponent } from './components/clock/clock-mempool.component';
import { AddressComponent } from './components/address/address.component'; import { AddressComponent } from './components/address/address.component';
import { MasterPageComponent } from './components/master-page/master-page.component'; import { MasterPageComponent } from './components/master-page/master-page.component';
import { AboutComponent } from './components/about/about.component'; import { AboutComponent } from './components/about/about.component';
@ -14,6 +16,7 @@ import { TrademarkPolicyComponent } from './components/trademark-policy/trademar
import { BisqMasterPageComponent } from './components/bisq-master-page/bisq-master-page.component'; import { BisqMasterPageComponent } from './components/bisq-master-page/bisq-master-page.component';
import { PushTransactionComponent } from './components/push-transaction/push-transaction.component'; import { PushTransactionComponent } from './components/push-transaction/push-transaction.component';
import { BlocksList } from './components/blocks-list/blocks-list.component'; import { BlocksList } from './components/blocks-list/blocks-list.component';
import { RbfList } from './components/rbf-list/rbf-list.component';
import { LiquidMasterPageComponent } from './components/liquid-master-page/liquid-master-page.component'; import { LiquidMasterPageComponent } from './components/liquid-master-page/liquid-master-page.component';
import { AssetGroupComponent } from './components/assets/asset-group/asset-group.component'; import { AssetGroupComponent } from './components/assets/asset-group/asset-group.component';
import { AssetsFeaturedComponent } from './components/assets/assets-featured/assets-featured.component'; import { AssetsFeaturedComponent } from './components/assets/assets-featured/assets-featured.component';
@ -56,6 +59,10 @@ let routes: Routes = [
path: 'blocks', path: 'blocks',
component: BlocksList, component: BlocksList,
}, },
{
path: 'rbf',
component: RbfList,
},
{ {
path: 'terms-of-service', path: 'terms-of-service',
component: TermsOfServiceComponent component: TermsOfServiceComponent
@ -162,6 +169,10 @@ let routes: Routes = [
path: 'blocks', path: 'blocks',
component: BlocksList, component: BlocksList,
}, },
{
path: 'rbf',
component: RbfList,
},
{ {
path: 'terms-of-service', path: 'terms-of-service',
component: TermsOfServiceComponent component: TermsOfServiceComponent
@ -264,6 +275,10 @@ let routes: Routes = [
path: 'blocks', path: 'blocks',
component: BlocksList, component: BlocksList,
}, },
{
path: 'rbf',
component: RbfList,
},
{ {
path: 'terms-of-service', path: 'terms-of-service',
component: TermsOfServiceComponent component: TermsOfServiceComponent
@ -342,6 +357,14 @@ let routes: Routes = [
}, },
], ],
}, },
{
path: 'clock-mined',
component: ClockMinedComponent,
},
{
path: 'clock-mempool',
component: ClockMempoolComponent,
},
{ {
path: 'status', path: 'status',
data: { networks: ['bitcoin', 'liquid'] }, data: { networks: ['bitcoin', 'liquid'] },

View file

@ -29,6 +29,14 @@ export const mempoolFeeColors = [
'ba3243', 'ba3243',
'b92b48', 'b92b48',
'b9254b', 'b9254b',
'b8214d',
'b71d4f',
'b61951',
'b41453',
'b30e55',
'b10857',
'b00259',
'ae005b',
]; ];
export const chartColors = [ export const chartColors = [
@ -69,6 +77,7 @@ export const chartColors = [
"#3E2723", "#3E2723",
"#212121", "#212121",
"#263238", "#263238",
"#801313",
]; ];
export const poolsColor = { export const poolsColor = {

View file

@ -1,11 +1,6 @@
.pagination-container { .pagination-container {
float: none; float: none;
margin-bottom: 200px;
@media(min-width: 400px){ @media(min-width: 400px){
float: right; float: right;
} }
} }
.container-xl {
padding-bottom: 110px;
}

View file

@ -36,7 +36,7 @@
<h5 class="card-title">US Dollar - BTC/USD</h5> <h5 class="card-title">US Dollar - BTC/USD</h5>
<div class="chart-container"> <div class="chart-container">
<ng-container *ngIf="hlocData$ | async as hlocData; else loadingSpinner"> <ng-container *ngIf="hlocData$ | async as hlocData; else loadingSpinner">
<app-lightweight-charts [height]="300" [data]="hlocData.hloc" [volumeData]="hlocData.volume" [precision]="2"></app-lightweight-charts> <app-lightweight-charts [height]="300" [data]="hlocData.hloc" [volumeData]="hlocData.volume" [precision]="2"></app-lightweight-charts>
</ng-container> </ng-container>
</div> </div>
</div> </div>
@ -84,7 +84,7 @@
</ng-template> </ng-template>
</td> </td>
<td>{{ ticker.volume?.num_trades ? ticker.volume?.num_trades : 0 }}</td> <td>{{ ticker.volume?.num_trades ? ticker.volume?.num_trades : 0 }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@ -105,14 +105,6 @@
</ng-container> </ng-container>
</div> </div>
<app-language-selector></app-language-selector>
<div class="text-small text-center mt-3">
<a [routerLink]="['/terms-of-service']" i18n="shared.terms-of-service|Terms of Service">Terms of Service</a>
|
<a [routerLink]="['/privacy-policy']" i18n="shared.privacy-policy|Privacy Policy">Privacy Policy</a>
</div>
</div> </div>
<ng-template #loadingTmpl> <ng-template #loadingTmpl>
@ -129,4 +121,4 @@
<ng-template #loading> <ng-template #loading>
<div class="skeleton-loader shorter"></div> <div class="skeleton-loader shorter"></div>
</ng-template> </ng-template>

View file

@ -107,22 +107,7 @@
<span>Blockstream</span> <span>Blockstream</span>
</a> </a>
<a href="https://unchained.com/" target="_blank" title="Unchained"> <a href="https://unchained.com/" target="_blank" title="Unchained">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" x="0px" y="0px" viewBox="0 0 216 216" class="image" style="enable-background:new 0 0 216 216;"> <svg id="Layer_1" width="78" height="78" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 156.68 156.68"><defs><style>.cls-unchained-1{fill:#fff;}</style></defs><path class="cls-unchained-1" d="m78.34,0C35.07,0,0,35.07,0,78.34s35.07,78.34,78.34,78.34,78.34-35.07,78.34-78.34S121.6,0,78.34,0ZM20.23,109.5c-4.99-9.28-7.81-19.89-7.81-31.16C12.42,41.93,41.93,12.42,78.34,12.42c33.15,0,60.58,24.46,65.23,56.32h-37.48c-45.29,0-71.19,20.05-85.85,40.76Zm58.11,34.76c-12.42,0-24.04-3.44-33.96-9.41,3.94-8.85,9.11-18.7,15.84-28.9,20.99-31.8,52.2-31.19,76.49-31.19h7.45c.06,1.18.1,2.38.1,3.58,0,36.41-29.51,65.92-65.92,65.92Z"/><path class="cls-unchained-1" d="m91.98,42.4l-3.62-1.18c-3.94-1.29-7.03-4.38-8.32-8.32l-1.18-3.63c-.13-.39-.68-.39-.81,0l-1.18,3.63c-1.29,3.94-4.38,7.03-8.32,8.32l-3.62,1.18c-.39.13-.39.68,0,.81l3.62,1.18c3.94,1.29,7.03,4.38,8.32,8.32l1.18,3.63c.13.39.68.39.81,0l1.18-3.63c1.29-3.94,4.38-7.03,8.32-8.32l3.62-1.18c.39-.13.39-.68,0-.81Z"/></svg>
<style type="text/css">
.ucst0{fill:#002248;}
.ucst1{opacity:0.5;fill:#FFFFFF;}
.ucst2{fill:#FFFFFF;}
.ucst3{opacity:0.75;fill:#FFFFFF;}
</style>
<rect class="ucst0" width="216" height="216"/>
<g>
<g>
<path class="ucst1" d="M108,39.5V108l59.3,34.2V73.8L108,39.5z M126.9,95.4c0,2,1.1,3.8,2.8,4.8l27.9,16l0,10.8L125,108.2c-4.6-2.6-7.4-7.5-7.4-12.8l-0.1-22.7c0-1.9,0.5-3.7,1.4-5.3c0.9-1.5,2.2-2.9,3.8-3.8c3.3-1.9,7.2-1.9,10.5,0l24.5,14.2l-0.2,10.7l-29-16.8c-0.5-0.3-0.9-0.2-1.2,0c-0.3,0.2-0.6,0.5-0.6,1L126.9,95.4z"/>
<path class="ucst2" d="M108,39.5L48.7,73.8v68.5L108,108V39.5z M99.7,93.1c0,5.3-2.8,10.2-7.4,12.8l-19.6,11.4c-1.7,1-3.5,1.4-5.3,1.5c-1.8,0-3.6-0.5-5.2-1.4c-3.3-1.9-5.3-5.3-5.3-9.1V80l9.4-5.2l-0.1,33.5c0,0.6,0.3,0.9,0.6,1c0.3,0.2,0.7,0.3,1.2,0l19.6-11.4c1.7-1,2.8-2.8,2.8-4.8L90.3,61l9.4-5.4L99.7,93.1z"/>
<path class="ucst3" d="M108,108l-59.3,34.2l59.3,34.2l59.3-34.2L108,108z M133.8,152l-24.5,14.2l-9.2-5.5l29.1-16.7c0.5-0.3,0.6-0.7,0.6-1c0-0.3-0.1-0.7-0.6-1l-19.7-11.2c-1.7-1-3.8-1-5.5,0l-27.8,16.1l-9.4-5.4l32.6-18.7c4.6-2.6,10.2-2.6,14.8,0l19.7,11.2c1.7,0.9,3,2.3,3.9,3.9c0.9,1.5,1.4,3.3,1.4,5.2C139.1,146.7,137.1,150.1,133.8,152z"/>
</g>
</g>
</svg>
<span>Unchained</span> <span>Unchained</span>
</a> </a>
<a href="https://gemini.com/" target="_blank" title="Gemini"> <a href="https://gemini.com/" target="_blank" title="Gemini">
@ -408,33 +393,14 @@
<div class="footer-links"> <div class="footer-links">
<a href="/3rdpartylicenses.txt">Third-party Licenses</a> <a href="/3rdpartylicenses.txt">Third-party Licenses</a>
<a [routerLink]="['/terms-of-service']" i18n="shared.terms-of-service|Terms of Service">Terms of Service</a>
<div class="social-icons">
<a target="_blank" href="https://github.com/mempool/mempool">
<svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="github" class="svg-inline--fa fa-github fa-w-16 fa-2x" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path fill="currentColor" d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"></path></svg>
</a>
<a target="_blank" href="https://twitter.com/mempool">
<svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="twitter" class="svg-inline--fa fa-twitter fa-w-16 fa-2x" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z"></path></svg>
</a>
<a target="_blank" href="https://matrix.to/#/#mempool:bitcoin.kyoto">
<svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="matrix" class="svg-inline--fa fa-matrix fa-w-16 fa-2x" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1536 1792"><path fill="currentColor" d="M40.467 163.152v1465.696H145.92V1664H0V128h145.92v35.152zm450.757 464.64v74.14h2.069c19.79-28.356 43.717-50.215 71.483-65.575 27.765-15.656 59.963-23.336 96-23.336 34.56 0 66.165 6.795 94.818 20.086 28.652 13.293 50.216 37.22 65.28 70.893 16.246-23.926 38.4-45.194 66.166-63.507 27.766-18.314 60.848-27.472 98.954-27.472 28.948 0 55.828 3.545 80.64 10.635 24.812 7.088 45.785 18.314 63.508 33.968 17.722 15.656 31.31 35.742 41.354 60.85 9.747 25.107 14.768 55.236 14.768 90.683v366.573h-150.35V865.28c0-18.314-.59-35.741-2.068-51.987-1.476-16.247-5.316-30.426-11.52-42.24-6.499-12.112-15.656-21.563-28.062-28.653-12.405-7.088-29.242-10.634-50.214-10.634-21.268 0-38.4 4.135-51.397 12.112-12.997 8.27-23.336 18.608-30.72 31.901-7.386 12.997-12.407 27.765-14.77 44.602-2.363 16.542-3.84 33.379-3.84 50.216v305.133H692.971v-307.2c0-16.247-.294-32.197-1.18-48.149-.591-15.95-3.84-30.424-9.157-44.011-5.317-13.293-14.178-24.223-26.585-32.197-12.406-7.976-30.425-12.112-54.646-12.112-7.088 0-16.542 1.478-28.062 4.726-11.52 3.25-23.04 9.157-33.968 18.02-10.93 8.86-20.383 21.563-28.063 38.103-7.68 16.543-11.52 38.4-11.52 65.28v317.834H349.44V627.792zm1004.309 1001.056V163.152H1390.08V128H1536v1536h-145.92v-35.152z"/></svg>
</a>
<a target="_blank" href="https://youtube.com/@mempool">
<svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="youtube" class="svg-inline--fa fa-youtube fa-w-16 fa-2x" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M549.655 124.083c-6.281-23.65-24.787-42.276-48.284-48.597C458.781 64 288 64 288 64S117.22 64 74.629 75.486c-23.497 6.322-42.003 24.947-48.284 48.597-11.412 42.867-11.412 132.305-11.412 132.305s0 89.438 11.412 132.305c6.281 23.65 24.787 41.5 48.284 47.821C117.22 448 288 448 288 448s170.78 0 213.371-11.486c23.497-6.321 42.003-24.171 48.284-47.821 11.412-42.867 11.412-132.305 11.412-132.305s0-89.438-11.412-132.305zm-317.51 213.508V175.185l142.739 81.205-142.739 81.201z"/></svg>
</a>
<a target="_blank" href="https://bitcointv.com/c/mempool/videos" class="bitcointv">
<svg xmlns="http://www.w3.org/2000/svg" focusable="false" viewBox="0 0 440 440"><path d="M225.57,2.08l-.69-.45a4.22,4.22,0,0,0-5.72,1.23L182.33,46.09a4,4,0,0,0,.88,5.81l9.38,6.38L173.48,97.49a4.22,4.22,0,0,0,2.45,4.19s3.55.7,4.53-1l41.92-40.56a3.62,3.62,0,0,0-1.51-5.1l-10.55-6.12L227.44,6.79A4.26,4.26,0,0,0,225.57,2.08Z" fill="currentColor"></path><path d="M118.52,401.83c-62.51,0-113.37-51-113.37-113.67V214.68C5.15,152,56,101,118.52,101H342.08a24.82,24.82,0,0,1,24.76,24.83V377a24.81,24.81,0,0,1-24.76,24.82Z"></path><path d="M342.08,105.18a20.65,20.65,0,0,1,20.61,20.66V377a20.66,20.66,0,0,1-20.61,20.66H118.52C58.3,397.67,9.31,348.55,9.31,288.16V214.68c0-60.38,49-109.5,109.21-109.5H342.08m0-8.34H118.52C53.62,96.84,1,149.6,1,214.68v73.48C1,353.24,53.62,406,118.52,406H342.08A29,29,0,0,0,371,377V125.84a29,29,0,0,0-28.92-29Z" fill="currentColor"></path><path fill="currentColor" d="M344.69,346.23A25.84,25.84,0,1,0,335,369.87l-10.22-10.2a11.69,11.69,0,1,1,4.77-5.12l10.31,10.28A25.84,25.84,0,0,0,344.69,346.23Z"></path><path fill="currentColor" d="M315.82,257.61a25.67,25.67,0,0,0-12.53,5.22L315,274.49a9.58,9.58,0,0,1,2.11-.73A9.72,9.72,0,1,1,309.4,283a9.4,9.4,0,0,1,.75-3.41L298.4,267.84a25.77,25.77,0,1,0,17.42-10.23Z"></path><path fill="currentColor" d="M313,214a7.76,7.76,0,1,1,1.41,10.91,7.62,7.62,0,0,1-2.19-2.69l-18.67-.14a25.94,25.94,0,1,0,.05-7l18.64.14A7.4,7.4,0,0,1,313,214Z"></path><path fill="currentColor" d="M341.2,144.08h-6.32c-1.67,0-3.61,1.87-3.61,4.29s1.94,4.29,3.61,4.29h6.32c1.67,0,3.61-1.87,3.61-4.29S342.87,144.08,341.2,144.08Z"></path><path fill="currentColor" d="M301.75,144.08h-6.44c-1.67,0-3.61,1.87-3.61,4.29s1.94,4.29,3.61,4.29h6.44c1.67,0,3.61-1.87,3.61-4.29S303.42,144.08,301.75,144.08Z"></path><path fill="currentColor" d="M321.77,144.08h-7c-1.67,0-3.62,1.87-3.62,4.29s1.95,4.29,3.62,4.29h7c1.67,0,3.62-1.87,3.62-4.29S323.44,144.08,321.77,144.08Z"></path><ellipse fill="currentColor" cx="295.97" cy="127.61" rx="4.27" ry="4.29"></ellipse><path fill="currentColor" d="M340.54,131.9a4.29,4.29,0,1,0-4.27-4.29A4.28,4.28,0,0,0,340.54,131.9Z"></path><path fill="currentColor" d="M318.26,131.9a4.29,4.29,0,1,0-4.27-4.29A4.29,4.29,0,0,0,318.26,131.9Z"></path><ellipse fill="currentColor" cx="295.97" cy="169.13" rx="4.27" ry="4.29"></ellipse><path fill="currentColor" d="M340.54,164.84a4.3,4.3,0,1,0,4.27,4.29A4.29,4.29,0,0,0,340.54,164.84Z"></path><path fill="currentColor" d="M318.26,164.84a4.3,4.3,0,1,0,4.28,4.29A4.29,4.29,0,0,0,318.26,164.84Z"></path><path d="M108.62,256.87c8.36-1,7.68-7.76,3.14-17-3.64-7.4-9.74-16.39-15.75-25.36-14.23-21.23-27.69-42.23-5.35-41.07,19.55,1,42.9,18.63,68.22,36.74,31.1,22.24,65.16,45.21,98.81,39.11a151.19,151.19,0,0,1,20-2.37V221a92,92,0,0,0-91.91-92.16H124.33A92,92,0,0,0,32.42,221v17.59c17.71,3.81,31,9.94,43.8,14.15C86.6,256.16,96.69,258.31,108.62,256.87Z"></path><path d="M273.37,310.79c-35-15.26-76.67-32.1-104-23.59-3.15,1-5,2.3-6,3.85-3.35,5.31,4.67,13.57,14.89,22.17,7.17,6,15.36,12.21,21.44,17.64,11.47,10.26,15.35,17.84-9.89,16.62-29.75-1.44-49.18-13.75-71.18-24l-.29-.14a165.84,165.84,0,0,0-22.93-8.91c-15.74-4.67-34.22-6.79-58.51-3.28A91.93,91.93,0,0,0,124.33,375h61.45A92,92,0,0,0,273.37,310.79Z"></path><path fill="currentColor" d="M257.69,249.31C224,255.41,190,232.44,158.88,210.2c-25.32-18.11-48.67-35.72-68.22-36.74C68.32,172.3,81.78,193.3,96,214.53c6,9,12.11,18,15.75,25.36,4.54,9.22,5.22,16-3.14,17-11.93,1.44-22-.71-32.4-4.13-12.8-4.21-26.09-10.34-43.8-14.15v44.26c0,1.26.14,2.48.19,3.72a91.8,91.8,0,0,0,2.9,19.62c.43,1.67.84,3.34,1.37,5,24.29-3.51,42.77-1.39,58.51,3.28a165.84,165.84,0,0,1,22.93,8.91c.39-.12.76-.26,1.14-.39l-.85.53c22,10.25,41.43,22.56,71.18,24,25.24,1.22,21.36-6.36,9.89-16.62-6.08-5.43-14.27-11.61-21.44-17.64-10.22-8.6-18.24-16.86-14.89-22.17,1-1.55,2.87-2.87,6-3.85,27.33-8.51,69,8.33,104,23.59.32-1,.56-2.05.84-3.07a92.33,92.33,0,0,0,3.48-24.87V246.94A151.19,151.19,0,0,0,257.69,249.31Z"></path><path fill="currentColor" d="M192,137a78,78,0,0,1,77.78,78v73.91a78,78,0,0,1-77.78,78H118.51a78,78,0,0,1-77.78-78V215a78,78,0,0,1,77.78-78H192m0-8.33H118.51A86.21,86.21,0,0,0,32.42,215v73.91a86.21,86.21,0,0,0,86.09,86.33H192a86.21,86.21,0,0,0,86.09-86.33V215A86.21,86.21,0,0,0,192,128.64Z"></path></svg>
</a>
</div>
</div>
<div class="footer-version" *ngIf="officialMempoolSpace">
{{ (backendInfo$ | async)?.hostname }} (v{{ (backendInfo$ | async )?.version }}) [<a href="https://github.com/mempool/mempool/commit/{{ (backendInfo$ | async )?.gitCommit | slice:0:8 }}">{{ (backendInfo$ | async )?.gitCommit | slice:0:8 }}</a>]
</div> </div>
<br>
</div> </div>
<ng-template #loadingSponsors> <ng-template #loadingSponsors>
<br> <br>
<div class="spinner-border text-light"></div> <div class="spinner-border text-light"></div>
</ng-template> </ng-template>

View file

@ -82,6 +82,10 @@
<br /> <br />
<router-outlet></router-outlet> <main>
<router-outlet></router-outlet>
</main>
<app-global-footer *ngIf="footerVisible"></app-global-footer>
<br> <br>

View file

@ -17,6 +17,12 @@ li.nav-item {
padding-right: 10px; padding-right: 10px;
} }
@media (max-width: 992px) {
footer > .container-fluid {
padding-bottom: 35px;
}
}
@media (min-width: 992px) { @media (min-width: 992px) {
.navbar { .navbar {
padding: 0rem 2rem; padding: 0rem 2rem;

View file

@ -17,6 +17,7 @@ export class BisqMasterPageComponent implements OnInit {
isMobile = window.innerWidth <= 767.98; isMobile = window.innerWidth <= 767.98;
urlLanguage: string; urlLanguage: string;
networkPaths: { [network: string]: string }; networkPaths: { [network: string]: string };
footerVisible = true;
constructor( constructor(
private stateService: StateService, private stateService: StateService,
@ -31,6 +32,11 @@ export class BisqMasterPageComponent implements OnInit {
this.urlLanguage = this.languageService.getLanguageForUrl(); this.urlLanguage = this.languageService.getLanguageForUrl();
this.navigationService.subnetPaths.subscribe((paths) => { this.navigationService.subnetPaths.subscribe((paths) => {
this.networkPaths = paths; this.networkPaths = paths;
if (paths.mainnet.indexOf('docs') > -1) {
this.footerVisible = false;
} else {
this.footerVisible = true;
}
}); });
} }

View file

@ -4,6 +4,9 @@
@media (min-width: 465px) { @media (min-width: 465px) {
font-size: 20px; font-size: 20px;
} }
@media (min-width: 992px) {
height: 40px;
}
} }
.main-title { .main-title {
@ -18,17 +21,19 @@
} }
.full-container { .full-container {
display: flex;
flex-direction: column;
padding: 0px 15px; padding: 0px 15px;
width: 100%; width: 100%;
min-height: 500px; height: calc(100vh - 250px);
height: calc(100% - 150px); @media (min-width: 992px) {
@media (max-width: 992px) { height: calc(100vh - 150px);
padding-bottom: 100px; }
};
} }
.chart { .chart {
width: 100%; display: flex;
flex: 1;
height: 100%; height: 100%;
padding-bottom: 20px; padding-bottom: 20px;
padding-right: 10px; padding-right: 10px;

View file

@ -4,6 +4,9 @@
@media (min-width: 465px) { @media (min-width: 465px) {
font-size: 20px; font-size: 20px;
} }
@media (min-width: 992px) {
height: 40px;
}
} }
.main-title { .main-title {
@ -18,18 +21,20 @@
} }
.full-container { .full-container {
display: flex;
flex-direction: column;
padding: 0px 15px; padding: 0px 15px;
width: 100%; width: 100%;
min-height: 500px; height: calc(100vh - 250px);
height: calc(100% - 150px); @media (min-width: 992px) {
@media (max-width: 992px) { height: calc(100vh - 150px);
padding-bottom: 100px; }
};
} }
.chart { .chart {
display: flex;
flex: 1;
width: 100%; width: 100%;
height: 100%;
padding-bottom: 20px; padding-bottom: 20px;
padding-right: 10px; padding-right: 10px;
@media (max-width: 992px) { @media (max-width: 992px) {

View file

@ -23,6 +23,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
@Input() unavailable: boolean = false; @Input() unavailable: boolean = false;
@Input() auditHighlighting: boolean = false; @Input() auditHighlighting: boolean = false;
@Input() blockConversion: Price; @Input() blockConversion: Price;
@Input() pixelAlign: boolean = false;
@Output() txClickEvent = new EventEmitter<{ tx: TransactionStripped, keyModifier: boolean}>(); @Output() txClickEvent = new EventEmitter<{ tx: TransactionStripped, keyModifier: boolean}>();
@Output() txHoverEvent = new EventEmitter<string>(); @Output() txHoverEvent = new EventEmitter<string>();
@Output() readyEvent = new EventEmitter(); @Output() readyEvent = new EventEmitter();
@ -132,9 +133,9 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
} }
} }
update(add: TransactionStripped[], remove: string[], direction: string = 'left', resetLayout: boolean = false): void { update(add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined }[], direction: string = 'left', resetLayout: boolean = false): void {
if (this.scene) { if (this.scene) {
this.scene.update(add, remove, direction, resetLayout); this.scene.update(add, remove, change, direction, resetLayout);
this.start(); this.start();
} }
} }
@ -201,7 +202,8 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
this.start(); this.start();
} else { } else {
this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: this.resolution, this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: this.resolution,
blockLimit: this.blockLimit, orientation: this.orientation, flip: this.flip, vertexArray: this.vertexArray, highlighting: this.auditHighlighting }); blockLimit: this.blockLimit, orientation: this.orientation, flip: this.flip, vertexArray: this.vertexArray,
highlighting: this.auditHighlighting, pixelAlign: this.pixelAlign });
this.start(); this.start();
} }
} }

View file

@ -15,6 +15,7 @@ export default class BlockScene {
gridWidth: number; gridWidth: number;
gridHeight: number; gridHeight: number;
gridSize: number; gridSize: number;
pixelAlign: boolean;
vbytesPerUnit: number; vbytesPerUnit: number;
unitPadding: number; unitPadding: number;
unitWidth: number; unitWidth: number;
@ -23,19 +24,24 @@ export default class BlockScene {
animateUntil = 0; animateUntil = 0;
dirty: boolean; dirty: boolean;
constructor({ width, height, resolution, blockLimit, orientation, flip, vertexArray, highlighting }: constructor({ width, height, resolution, blockLimit, orientation, flip, vertexArray, highlighting, pixelAlign }:
{ width: number, height: number, resolution: number, blockLimit: number, { width: number, height: number, resolution: number, blockLimit: number,
orientation: string, flip: boolean, vertexArray: FastVertexArray, highlighting: boolean } orientation: string, flip: boolean, vertexArray: FastVertexArray, highlighting: boolean, pixelAlign: boolean }
) { ) {
this.init({ width, height, resolution, blockLimit, orientation, flip, vertexArray, highlighting }); this.init({ width, height, resolution, blockLimit, orientation, flip, vertexArray, highlighting, pixelAlign });
} }
resize({ width = this.width, height = this.height, animate = true }: { width?: number, height?: number, animate: boolean }): void { resize({ width = this.width, height = this.height, animate = true }: { width?: number, height?: number, animate: boolean }): void {
this.width = width; this.width = width;
this.height = height; this.height = height;
this.gridSize = this.width / this.gridWidth; this.gridSize = this.width / this.gridWidth;
this.unitPadding = width / 500; if (this.pixelAlign) {
this.unitWidth = this.gridSize - (this.unitPadding * 2); this.unitPadding = Math.max(1, Math.floor(this.gridSize / 2.5));
this.unitWidth = this.gridSize - (this.unitPadding);
} else {
this.unitPadding = width / 500;
this.unitWidth = this.gridSize - (this.unitPadding * 2);
}
this.dirty = true; this.dirty = true;
if (this.initialised && this.scene) { if (this.initialised && this.scene) {
@ -150,7 +156,7 @@ export default class BlockScene {
this.updateAll(startTime, 200, direction); this.updateAll(startTime, 200, direction);
} }
update(add: TransactionStripped[], remove: string[], direction: string = 'left', resetLayout: boolean = false): void { update(add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined }[], direction: string = 'left', resetLayout: boolean = false): void {
const startTime = performance.now(); const startTime = performance.now();
const removed = this.removeBatch(remove, startTime, direction); const removed = this.removeBatch(remove, startTime, direction);
@ -172,6 +178,15 @@ export default class BlockScene {
this.place(tx); this.place(tx);
}); });
} else { } else {
// update effective rates
change.forEach(tx => {
if (this.txs[tx.txid]) {
this.txs[tx.txid].feerate = tx.rate || (this.txs[tx.txid].fee / this.txs[tx.txid].vsize);
this.txs[tx.txid].rate = tx.rate;
this.txs[tx.txid].dirty = true;
}
});
// try to insert new txs directly // try to insert new txs directly
const remaining = []; const remaining = [];
add.map(tx => new TxView(tx, this)).sort(feeRateDescending).forEach(tx => { add.map(tx => new TxView(tx, this)).sort(feeRateDescending).forEach(tx => {
@ -200,14 +215,15 @@ export default class BlockScene {
this.animateUntil = Math.max(this.animateUntil, tx.setHover(value)); this.animateUntil = Math.max(this.animateUntil, tx.setHover(value));
} }
private init({ width, height, resolution, blockLimit, orientation, flip, vertexArray, highlighting }: private init({ width, height, resolution, blockLimit, orientation, flip, vertexArray, highlighting, pixelAlign }:
{ width: number, height: number, resolution: number, blockLimit: number, { width: number, height: number, resolution: number, blockLimit: number,
orientation: string, flip: boolean, vertexArray: FastVertexArray, highlighting: boolean } orientation: string, flip: boolean, vertexArray: FastVertexArray, highlighting: boolean, pixelAlign: boolean }
): void { ): void {
this.orientation = orientation; this.orientation = orientation;
this.flip = flip; this.flip = flip;
this.vertexArray = vertexArray; this.vertexArray = vertexArray;
this.highlightingEnabled = highlighting; this.highlightingEnabled = highlighting;
this.pixelAlign = pixelAlign;
this.scene = { this.scene = {
count: 0, count: 0,
@ -333,7 +349,12 @@ export default class BlockScene {
private gridToScreen(position: Square | void): Square { private gridToScreen(position: Square | void): Square {
if (position) { if (position) {
const slotSize = (position.s * this.gridSize); const slotSize = (position.s * this.gridSize);
const squareSize = slotSize - (this.unitPadding * 2); let squareSize;
if (this.pixelAlign) {
squareSize = slotSize - (this.unitPadding);
} else {
squareSize = slotSize - (this.unitPadding * 2);
}
// The grid is laid out notionally left-to-right, bottom-to-top, // The grid is laid out notionally left-to-right, bottom-to-top,
// so we rotate and/or flip the y axis to match the target configuration. // so we rotate and/or flip the y axis to match the target configuration.

View file

@ -36,6 +36,7 @@ export default class TxView implements TransactionStripped {
vsize: number; vsize: number;
value: number; value: number;
feerate: number; feerate: number;
rate?: number;
status?: 'found' | 'missing' | 'fresh' | 'added' | 'censored' | 'selected'; status?: 'found' | 'missing' | 'fresh' | 'added' | 'censored' | 'selected';
context?: 'projected' | 'actual'; context?: 'projected' | 'actual';
scene?: BlockScene; scene?: BlockScene;
@ -58,7 +59,8 @@ export default class TxView implements TransactionStripped {
this.fee = tx.fee; this.fee = tx.fee;
this.vsize = tx.vsize; this.vsize = tx.vsize;
this.value = tx.value; this.value = tx.value;
this.feerate = tx.fee / tx.vsize; this.feerate = tx.rate || (tx.fee / tx.vsize); // sort by effective fee rate where available
this.rate = tx.rate;
this.status = tx.status; this.status = tx.status;
this.initialised = false; this.initialised = false;
this.vertexArray = scene.vertexArray; this.vertexArray = scene.vertexArray;
@ -157,7 +159,8 @@ export default class TxView implements TransactionStripped {
} }
getColor(): Color { getColor(): Color {
const feeLevelIndex = feeLevels.findIndex((feeLvl) => Math.max(1, this.feerate) < feeLvl) - 1; const rate = this.fee / this.vsize; // color by simple single-tx fee rate
const feeLevelIndex = feeLevels.findIndex((feeLvl) => Math.max(1, rate) < feeLvl) - 1;
const feeLevelColor = feeColors[feeLevelIndex] || feeColors[mempoolFeeColors.length - 1]; const feeLevelColor = feeColors[feeLevelIndex] || feeColors[mempoolFeeColors.length - 1];
// Normal mode // Normal mode
if (!this.scene?.highlightingEnabled) { if (!this.scene?.highlightingEnabled) {

View file

@ -28,6 +28,12 @@
{{ feeRate | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span> {{ feeRate | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span>
</td> </td>
</tr> </tr>
<tr *ngIf="effectiveRate && effectiveRate !== feeRate">
<td class="td-width" i18n="transaction.effective-fee-rate|Effective transaction fee rate">Effective fee rate</td>
<td>
{{ effectiveRate | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span>
</td>
</tr>
<tr> <tr>
<td class="td-width" i18n="transaction.vsize|Transaction Virtual Size">Virtual size</td> <td class="td-width" i18n="transaction.vsize|Transaction Virtual Size">Virtual size</td>
<td [innerHTML]="'&lrm;' + (vsize | vbytes: 2)"></td> <td [innerHTML]="'&lrm;' + (vsize | vbytes: 2)"></td>

View file

@ -20,6 +20,7 @@ export class BlockOverviewTooltipComponent implements OnChanges {
value = 0; value = 0;
vsize = 1; vsize = 1;
feeRate = 0; feeRate = 0;
effectiveRate;
tooltipPosition: Position = { x: 0, y: 0 }; tooltipPosition: Position = { x: 0, y: 0 };
@ -51,6 +52,7 @@ export class BlockOverviewTooltipComponent implements OnChanges {
this.value = tx.value || 0; this.value = tx.value || 0;
this.vsize = tx.vsize || 1; this.vsize = tx.vsize || 1;
this.feeRate = this.fee / this.vsize; this.feeRate = this.fee / this.vsize;
this.effectiveRate = tx.rate;
} }
} }
} }

View file

@ -4,6 +4,9 @@
@media (min-width: 465px) { @media (min-width: 465px) {
font-size: 20px; font-size: 20px;
} }
@media (min-width: 992px) {
height: 40px;
}
} }
.main-title { .main-title {
@ -18,17 +21,19 @@
} }
.full-container { .full-container {
display: flex;
flex-direction: column;
padding: 0px 15px; padding: 0px 15px;
width: 100%; width: 100%;
min-height: 500px; height: calc(100vh - 250px);
height: calc(100% - 150px); @media (min-width: 992px) {
@media (max-width: 992px) { height: calc(100vh - 150px);
padding-bottom: 100px; }
};
} }
.chart { .chart {
width: 100%; display: flex;
flex: 1;
height: 100%; height: 100%;
padding-bottom: 20px; padding-bottom: 20px;
padding-right: 10px; padding-right: 10px;

View file

@ -4,6 +4,9 @@
@media (min-width: 465px) { @media (min-width: 465px) {
font-size: 20px; font-size: 20px;
} }
@media (min-width: 992px) {
height: 40px;
}
} }
.main-title { .main-title {
@ -18,17 +21,19 @@
} }
.full-container { .full-container {
display: flex;
flex-direction: column;
padding: 0px 15px; padding: 0px 15px;
width: 100%; width: 100%;
min-height: 500px; height: calc(100vh - 250px);
height: calc(100% - 150px); @media (min-width: 992px) {
@media (max-width: 992px) { height: calc(100vh - 150px);
padding-bottom: 100px; }
};
} }
.chart { .chart {
width: 100%; display: flex;
flex: 1;
height: 100%; height: 100%;
padding-bottom: 20px; padding-bottom: 20px;
padding-right: 10px; padding-right: 10px;

View file

@ -4,6 +4,9 @@
@media (min-width: 465px) { @media (min-width: 465px) {
font-size: 20px; font-size: 20px;
} }
@media (min-width: 992px) {
height: 40px;
}
} }
.main-title { .main-title {
@ -18,17 +21,19 @@
} }
.full-container { .full-container {
display: flex;
flex-direction: column;
padding: 0px 15px; padding: 0px 15px;
width: 100%; width: 100%;
min-height: 500px; height: calc(100vh - 250px);
height: calc(100% - 150px); @media (min-width: 992px) {
@media (max-width: 992px) { height: calc(100vh - 150px);
padding-bottom: 100px; }
};
} }
.chart { .chart {
width: 100%; display: flex;
flex: 1;
height: 100%; height: 100%;
padding-bottom: 20px; padding-bottom: 20px;
padding-right: 10px; padding-right: 10px;

View file

@ -1,53 +1,61 @@
<div class="blocks-container blockchain-blocks-container" [class.time-ltr]="timeLtr" <div class="blocks-container blockchain-blocks-container" [class.time-ltr]="timeLtr" [class.minimal]="minimal"
[style.left]="static ? (offset || 0) + 'px' : null" [style.left]="static ? (offset || 0) + 'px' : null" [style.--block-size]="blockWidth+'px'"
*ngIf="static || (loadingBlocks$ | async) === false; else loadingBlocksTemplate"> *ngIf="static || (loadingBlocks$ | async) === false; else loadingBlocksTemplate">
<div *ngFor="let block of blocks; let i = index; trackBy: trackByBlocksFn"> <div *ngFor="let block of blocks; let i = index; trackBy: trackByBlocksFn">
<ng-container *ngIf="connected && block && !block.loading && !block.placeholder; else placeholderBlock"> <ng-container *ngIf="connected && block && !block.loading && !block.placeholder; else placeholderBlock">
<div
*ngIf="minimal && spotlight < 0 && chainTip + spotlight + 1 === block.height"
class="spotlight-bottom"
[style.left]="blockStyles[i].left"
></div>
<div [attr.data-cy]="'bitcoin-block-offset-' + offset + '-index-' + i" <div [attr.data-cy]="'bitcoin-block-offset-' + offset + '-index-' + i"
class="text-center bitcoin-block mined-block blockchain-blocks-offset-{{ offset }}-index-{{ i }}" class="text-center bitcoin-block mined-block blockchain-blocks-offset-{{ offset }}-index-{{ i }}"
[class.offscreen]="!static && count && i >= count"
id="bitcoin-block-{{ block.height }}" [ngStyle]="blockStyles[i]" id="bitcoin-block-{{ block.height }}" [ngStyle]="blockStyles[i]"
[class.blink-bg]="isSpecial(block.height)"> [class.blink-bg]="isSpecial(block.height)">
<a draggable="false" [routerLink]="['/block/' | relativeUrl, block.id]" [state]="{ data: { block: block } }" <a draggable="false" [routerLink]="['/block/' | relativeUrl, block.id]" [state]="{ data: { block: block } }"
class="blockLink" [ngClass]="{'disabled': (this.stateService.blockScrolling$ | async)}">&nbsp;</a> class="blockLink" [ngClass]="{'disabled': (this.stateService.blockScrolling$ | async)}">&nbsp;</a>
<div [attr.data-cy]="'bitcoin-block-' + i + '-height'" class="block-height"> <div *ngIf="!minimal" [attr.data-cy]="'bitcoin-block-' + i + '-height'" class="block-height">
<a [routerLink]="['/block/' | relativeUrl, block.id]" [state]="{ data: { block: block } }">{{ block.height <a [routerLink]="['/block/' | relativeUrl, block.id]" [state]="{ data: { block: block } }">{{ block.height
}}</a> }}</a>
</div> </div>
<div class="block-body"> <div class="block-body">
<div *ngIf="block?.extras; else emptyfees" [attr.data-cy]="'bitcoin-block-offset=' + offset + '-index-' + i + '-fees'" class="fees"> <ng-container *ngIf="!minimal">
~{{ block?.extras?.medianFee | number:feeRounding }} <ng-container <div *ngIf="block?.extras; else emptyfees" [attr.data-cy]="'bitcoin-block-offset=' + offset + '-index-' + i + '-fees'" class="fees">
i18n="shared.sat-vbyte|sat/vB">sat/vB</ng-container> ~{{ block?.extras?.medianFee | number:feeRounding }} <ng-container
</div> i18n="shared.sat-vbyte|sat/vB">sat/vB</ng-container>
<ng-template #emptyfees>
<div [attr.data-cy]="'bitcoin-block-offset=' + offset + '-index-' + i + '-fees'" class="fees">
&nbsp;
</div> </div>
</ng-template> <ng-template #emptyfees>
<div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-fee-span'" class="fee-span" <div [attr.data-cy]="'bitcoin-block-offset=' + offset + '-index-' + i + '-fees'" class="fees">
*ngIf="block?.extras?.feeRange; else emptyfeespan"> &nbsp;
{{ block?.extras?.feeRange?.[0] | number:feeRounding }} - {{ </div>
block?.extras?.feeRange[block?.extras?.feeRange?.length - 1] | number:feeRounding }} <ng-container </ng-template>
i18n="shared.sat-vbyte|sat/vB">sat/vB</ng-container> <div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-fee-span'" class="fee-span"
</div> *ngIf="block?.extras?.feeRange; else emptyfeespan">
<ng-template #emptyfeespan> {{ block?.extras?.feeRange?.[0] | number:feeRounding }} - {{
<div [attr.data-cy]="'bitcoin-block-offset=' + offset + '-index-' + i + '-fees'" class="fee-span"> block?.extras?.feeRange[block?.extras?.feeRange?.length - 1] | number:feeRounding }} <ng-container
&nbsp; i18n="shared.sat-vbyte|sat/vB">sat/vB</ng-container>
</div> </div>
</ng-template> <ng-template #emptyfeespan>
<div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-total-fees'" *ngIf="showMiningInfo" <div [attr.data-cy]="'bitcoin-block-offset=' + offset + '-index-' + i + '-fees'" class="fee-span">
class="block-size"> &nbsp;
<app-amount [satoshis]="block.extras?.totalFees ?? 0" digitsInfo="1.2-3" [noFiat]="true"></app-amount> </div>
</div> </ng-template>
<div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + 'block-size'" *ngIf="!showMiningInfo" <div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-total-fees'" *ngIf="showMiningInfo"
class="block-size" [innerHTML]="'&lrm;' + (block.size | bytes: 2)"></div> class="block-size">
<div [attr.data-cy]="'bitcoin-block-' + i + '-transactions'" class="transaction-count"> <app-amount [satoshis]="block.extras?.totalFees ?? 0" digitsInfo="1.2-3" [noFiat]="true"></app-amount>
<ng-container </div>
*ngTemplateOutlet="block.tx_count === 1 ? transactionsSingular : transactionsPlural; context: {$implicit: block.tx_count | number}"></ng-container> <div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + 'block-size'" *ngIf="!showMiningInfo"
<ng-template #transactionsSingular let-i i18n="shared.transaction-count.singular">{{ i }} transaction</ng-template> class="block-size" [innerHTML]="'&lrm;' + (block.size | bytes: 2)"></div>
<ng-template #transactionsPlural let-i i18n="shared.transaction-count.plural">{{ i }} transactions</ng-template> <div [attr.data-cy]="'bitcoin-block-' + i + '-transactions'" class="transaction-count">
</div> <ng-container
<div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-time'" class="time-difference"> *ngTemplateOutlet="block.tx_count === 1 ? transactionsSingular : transactionsPlural; context: {$implicit: block.tx_count | number}"></ng-container>
<app-time kind="since" [time]="block.timestamp" [fastRender]="true"></app-time></div> <ng-template #transactionsSingular let-i i18n="shared.transaction-count.singular">{{ i }} transaction</ng-template>
<ng-template #transactionsPlural let-i i18n="shared.transaction-count.plural">{{ i }} transactions</ng-template>
</div>
<div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-time'" class="time-difference">
<app-time kind="since" [time]="block.timestamp" [fastRender]="true"></app-time></div>
</ng-container>
</div> </div>
<div class="animated" [class]="showMiningInfo ? 'show' : 'hide'" *ngIf="block.extras?.pool != undefined"> <div class="animated" [class]="showMiningInfo ? 'show' : 'hide'" *ngIf="block.extras?.pool != undefined">
<a [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-pool'" class="badge badge-primary" <a [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-pool'" class="badge badge-primary"
@ -79,11 +87,11 @@
</div> </div>
<ng-template #loadingBlocksTemplate> <ng-template #loadingBlocksTemplate>
<div class="blocks-container" [class.time-ltr]="timeLtr"> <div class="blocks-container" [class.time-ltr]="timeLtr" [style.--block-size]="blockWidth+'px'">
<div class="flashing"> <div class="flashing">
<div *ngFor="let block of emptyBlocks; let i = index; trackBy: trackByBlocksFn"> <div *ngFor="let block of emptyBlocks; let i = index; trackBy: trackByBlocksFn">
<div class="text-center bitcoin-block mined-block" id="bitcoin-block-{{ block.height }}" <div class="text-center bitcoin-block mined-block" id="bitcoin-block-{{ block.height }}"
[ngStyle]="emptyBlockStyles[i]"></div> [ngStyle]="emptyBlockStyles[i]" [class.offscreen]="!static && count && i >= count"></div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,6 +1,6 @@
.bitcoin-block { .bitcoin-block {
width: 125px; width: var(--block-size);
height: 125px; height: var(--block-size);
} }
.blockLink { .blockLink {
@ -22,7 +22,11 @@
.mined-block { .mined-block {
position: absolute; position: absolute;
top: 0px; top: 0px;
transition: background 2s, left 2s, transform 1s; transition: background 2s, left 2s, transform 1s, opacity 1s;
}
.mined-block.offscreen {
opacity: 0;
} }
.mined-block.placeholder-block { .mined-block.placeholder-block {
@ -35,9 +39,11 @@
} }
.blocks-container { .blocks-container {
--block-size: 125px;
--block-offset: calc(0.32 * var(--block-size));
position: absolute; position: absolute;
top: 0px; top: 0px;
left: 40px; left: var(--block-offset);
} }
.block-body { .block-body {
@ -77,11 +83,11 @@
.bitcoin-block::after { .bitcoin-block::after {
content: ''; content: '';
width: 125px; width: var(--block-size);
height: 24px; height: calc(0.192 * var(--block-size));
position:absolute; position:absolute;
top: -24px; top: calc(-0.192 * var(--block-size));
left: -20px; left: calc(-0.16 * var(--block-size));
background-color: #232838; background-color: #232838;
transform:skew(40deg); transform:skew(40deg);
transform-origin:top; transform-origin:top;
@ -89,11 +95,11 @@
.bitcoin-block::before { .bitcoin-block::before {
content: ''; content: '';
width: 20px; width: calc(0.16 * var(--block-size));
height: 125px; height: var(--block-size);
position: absolute; position: absolute;
top: -12px; top: calc(-0.096 * var(--block-size));
left: -20px; left: calc(-0.16 * var(--block-size));
background-color: #191c27; background-color: #191c27;
transform: skewY(50deg); transform: skewY(50deg);
@ -168,4 +174,16 @@
.bitcoin-block { .bitcoin-block {
transform: scaleX(-1); transform: scaleX(-1);
} }
}
.spotlight-bottom {
position: absolute;
width: calc(0.6 * var(--block-size));
height: calc(0.25 * var(--block-size));
border-left: solid calc(0.3 * var(--block-size)) transparent;
border-bottom: solid calc(0.3 * var(--block-size)) white;
border-right: solid calc(0.3 * var(--block-size)) transparent;
transform: translate(calc(0.2 * var(--block-size)), calc(1.1 * var(--block-size)));
border-radius: 2px;
z-index: -1;
} }

View file

@ -24,6 +24,9 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
@Input() count: number = 8; // number of blocks in this chunk (dynamic blocks only) @Input() count: number = 8; // number of blocks in this chunk (dynamic blocks only)
@Input() loadingTip: boolean = false; @Input() loadingTip: boolean = false;
@Input() connected: boolean = true; @Input() connected: boolean = true;
@Input() minimal: boolean = false;
@Input() blockWidth: number = 125;
@Input() spotlight: number = 0;
specialBlocks = specialBlocks; specialBlocks = specialBlocks;
network = ''; network = '';
@ -51,6 +54,10 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
timeLtrSubscription: Subscription; timeLtrSubscription: Subscription;
timeLtr: boolean; timeLtr: boolean;
blockOffset: number = 155;
dividerBlockOffset: number = 205;
blockPadding: number = 30;
gradientColors = { gradientColors = {
'': ['#9339f4', '#105fb0'], '': ['#9339f4', '#105fb0'],
bisq: ['#9339f4', '#105fb0'], bisq: ['#9339f4', '#105fb0'],
@ -118,7 +125,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
this.blockStyles = []; this.blockStyles = [];
if (this.blocksFilled && block.height > this.chainTip) { if (this.blocksFilled && block.height > this.chainTip) {
this.blocks.forEach((b, i) => this.blockStyles.push(this.getStyleForBlock(b, i, i ? -155 : -205))); this.blocks.forEach((b, i) => this.blockStyles.push(this.getStyleForBlock(b, i, i ? -this.blockOffset : -this.dividerBlockOffset)));
setTimeout(() => { setTimeout(() => {
this.blockStyles = []; this.blockStyles = [];
this.blocks.forEach((b, i) => this.blockStyles.push(this.getStyleForBlock(b, i))); this.blocks.forEach((b, i) => this.blockStyles.push(this.getStyleForBlock(b, i)));
@ -159,6 +166,13 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
} }
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
if (changes.blockWidth && this.blockWidth) {
this.blockPadding = 0.24 * this.blockWidth;
this.blockOffset = this.blockWidth + this.blockPadding;
this.dividerBlockOffset = this.blockOffset + (0.4 * this.blockWidth);
this.blockStyles = [];
this.blocks.forEach((b, i) => this.blockStyles.push(this.getStyleForBlock(b, i)));
}
if (this.static) { if (this.static) {
const animateSlide = changes.height && (changes.height.currentValue === changes.height.previousValue + 1); const animateSlide = changes.height && (changes.height.currentValue === changes.height.previousValue + 1);
this.updateStaticBlocks(animateSlide); this.updateStaticBlocks(animateSlide);
@ -191,14 +205,14 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
} }
this.arrowVisible = true; this.arrowVisible = true;
if (newBlockFromLeft) { if (newBlockFromLeft) {
this.arrowLeftPx = blockindex * 155 + 30 - 205; this.arrowLeftPx = blockindex * this.blockOffset + this.blockPadding - this.dividerBlockOffset;
setTimeout(() => { setTimeout(() => {
this.arrowTransition = '2s'; this.arrowTransition = '2s';
this.arrowLeftPx = blockindex * 155 + 30; this.arrowLeftPx = blockindex * this.blockOffset + this.blockPadding;
this.cd.markForCheck(); this.cd.markForCheck();
}, 50); }, 50);
} else { } else {
this.arrowLeftPx = blockindex * 155 + 30; this.arrowLeftPx = blockindex * this.blockOffset + this.blockPadding;
if (!animate) { if (!animate) {
setTimeout(() => { setTimeout(() => {
this.arrowTransition = '2s'; this.arrowTransition = '2s';
@ -245,7 +259,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
} }
this.blocks = this.blocks.slice(0, this.count); this.blocks = this.blocks.slice(0, this.count);
this.blockStyles = []; this.blockStyles = [];
this.blocks.forEach((b, i) => this.blockStyles.push(this.getStyleForBlock(b, i, animateSlide ? -155 : 0))); this.blocks.forEach((b, i) => this.blockStyles.push(this.getStyleForBlock(b, i, animateSlide ? -this.blockOffset : 0)));
this.cd.markForCheck(); this.cd.markForCheck();
if (animateSlide) { if (animateSlide) {
// animate blocks slide right // animate blocks slide right
@ -287,7 +301,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
} }
return { return {
left: addLeft + 155 * index + 'px', left: addLeft + this.blockOffset * index + 'px',
background: `repeating-linear-gradient( background: `repeating-linear-gradient(
#2d3348, #2d3348,
#2d3348 ${greenBackgroundHeight}%, #2d3348 ${greenBackgroundHeight}%,
@ -309,7 +323,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
const addLeft = animateEnterFrom || 0; const addLeft = animateEnterFrom || 0;
return { return {
left: addLeft + (155 * index) + 'px', left: addLeft + (this.blockOffset * index) + 'px',
background: "#2d3348", background: "#2d3348",
}; };
} }
@ -317,7 +331,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
getStyleForPlaceholderBlock(index: number, animateEnterFrom: number = 0) { getStyleForPlaceholderBlock(index: number, animateEnterFrom: number = 0) {
const addLeft = animateEnterFrom || 0; const addLeft = animateEnterFrom || 0;
return { return {
left: addLeft + (155 * index) + 'px', left: addLeft + (this.blockOffset * index) + 'px',
}; };
} }
@ -325,7 +339,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
const addLeft = animateEnterFrom || 0; const addLeft = animateEnterFrom || 0;
return { return {
left: addLeft + 155 * this.emptyBlocks.indexOf(block) + 'px', left: addLeft + this.blockOffset * this.emptyBlocks.indexOf(block) + 'px',
background: "#2d3348", background: "#2d3348",
}; };
} }

View file

@ -0,0 +1,42 @@
<div class="clock-face" [style]="faceStyle">
<ng-content></ng-content>
<svg
class="cut-out"
width="384"
height="384"
viewBox="0 0 384 384"
>
<g>
<path
class="face"
d="M 0,0 V 384 H 384 V 0 Z M 192,15 A 177,177 0 0 1 369,192 177,177 0 0 1 192,369 177,177 0 0 1 15,192 177,177 0 0 1 192,15 Z"
/>
</g>
</svg>
<svg
class="demo-dial"
width="384"
height="384"
viewBox="0 0 384 384"
>
<defs>
<pattern id="dial-gradient" patternUnits="userSpaceOnUse" width="384" height="384">
<image class="dial-gradient-img" href="/resources/clock/gradient.png" x="0" y="0" width="384" height="384" [style.transform]="'rotate(' + (minutes * 6) + 'deg)'" />
</pattern>
</defs>
<path *ngFor="let angle of minorTicks" class="tick minor" d="M 192,27 v 10" [style.transform]="'rotate(' + angle + 'deg)'"/>
<path *ngFor="let angle of majorTicks" class="tick major" d="M 192,27 v 18" [style.transform]="'rotate(' + angle + 'deg)'"/>
<ng-container *ngFor="let segment of segments; trackBy: trackBySegment">
<path class="block-segment" [attr.d]="segment.path" />
<!-- <circle class="segment-mark start" [attr.cx]="segment.start.x" [attr.cy]="segment.start.y" r="2" style="fill:green;stroke:white;stroke-width:1px;" />
<circle class="segment-mark end" [attr.cx]="segment.end.x" [attr.cy]="segment.end.y" r="2" style="fill:red;stroke:white;stroke-width:1px;" /> -->
</ng-container>
<!-- <polyline points="468.750,82.031 468.750,35 " id="polyline322" style="fill:none;stroke:#ffffff;stroke-width:4.84839;stroke-dasharray:none;stroke-opacity:1" transform="matrix(0.41250847,0,0,0.93092534,-1.3627708,-32.692008)" /> -->
<path class="tick very major" d="M 192,0 v 45" />
<path id="hour" class="gnomon hour" d="M 178,3 206,3 192,40 Z" [style.transform]="'rotate(' + (hours * 30) + 'deg)'" />
<path id="minute" class="gnomon minute" d="M 180,4 204,4 192,38 Z" [style.transform]="'rotate(' + (minutes * 6) + 'deg)'" />
</svg>
</div>

View file

@ -0,0 +1,69 @@
.clock-face {
position: relative;
height: 84.375%;
margin: auto;
overflow: hidden;
.cut-out, .demo-dial {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
width: 100%;
height: 100%;
.face {
fill: #11131f;
}
}
.gnomon {
transform-origin: center;
stroke-linejoin: round;
&.minute {
fill:#80C2E1;
stroke:#80C2E1;
stroke-width: 2px;
}
&.hour {
fill: #105fb0;
stroke: #105fb0;
stroke-width: 6px;
}
}
.tick {
transform-origin: center;
fill: none;
stroke: white;
stroke-width: 2px;
stroke-linecap: butt;
&.minor {
stroke-opacity: 0.5;
}
&.very.major {
stroke-width: 4px;
}
}
.block-segment {
fill: none;
stroke: url(#dial-gradient);
stroke-width: 18px;
}
.dial-segment {
fill: none;
stroke: white;
stroke-width: 2px;
}
.dial-gradient-img {
transform-origin: center;
}
}

View file

@ -0,0 +1,147 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, OnInit } from '@angular/core';
import { Subscription, tap, timer } from 'rxjs';
import { WebsocketService } from '../../services/websocket.service';
import { StateService } from '../../services/state.service';
@Component({
selector: 'app-clock-face',
templateUrl: './clock-face.component.html',
styleUrls: ['./clock-face.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ClockFaceComponent implements OnInit, OnChanges, OnDestroy {
@Input() size: number = 300;
blocksSubscription: Subscription;
timeSubscription: Subscription;
faceStyle;
dialPath;
blockTimes = [];
segments = [];
hours: number = 0;
minutes: number = 0;
minorTicks: number[] = [];
majorTicks: number[] = [];
constructor(
public stateService: StateService,
private cd: ChangeDetectorRef
) {
this.updateTime();
this.makeTicks();
}
ngOnInit(): void {
this.timeSubscription = timer(0, 250).pipe(
tap(() => {
this.updateTime();
})
).subscribe();
this.blocksSubscription = this.stateService.blocks$
.subscribe(([block]) => {
if (block) {
this.blockTimes.push([block.height, new Date(block.timestamp * 1000)]);
// using block-reported times, so ensure they are sorted chronologically
this.blockTimes = this.blockTimes.sort((a, b) => a[1].getTime() - b[1].getTime());
this.updateSegments();
}
});
}
ngOnChanges(): void {
this.faceStyle = {
width: `${this.size}px`,
height: `${this.size}px`,
};
}
ngOnDestroy(): void {
this.timeSubscription.unsubscribe();
}
updateTime(): void {
const now = new Date();
const seconds = now.getSeconds() + (now.getMilliseconds() / 1000);
this.minutes = (now.getMinutes() + (seconds / 60)) % 60;
this.hours = now.getHours() + (this.minutes / 60);
this.updateSegments();
}
updateSegments(): void {
const now = new Date();
this.blockTimes = this.blockTimes.filter(time => (now.getTime() - time[1].getTime()) <= 3600000);
const tail = new Date(now.getTime() - 3600000);
const hourStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours());
const times = [
['start', tail],
...this.blockTimes,
['end', now],
];
const minuteTimes = times.map(time => {
return [time[0], (time[1].getTime() - hourStart.getTime()) / 60000];
});
this.segments = [];
const r = 174;
const cx = 192;
const cy = cx;
for (let i = 1; i < minuteTimes.length; i++) {
const arc = this.getArc(minuteTimes[i-1][1], minuteTimes[i][1], r, cx, cy);
if (arc) {
arc.id = minuteTimes[i][0];
this.segments.push(arc);
}
}
const arc = this.getArc(minuteTimes[0][1], minuteTimes[1][1], r, cx, cy);
if (arc) {
this.dialPath = arc.path;
}
this.cd.markForCheck();
}
getArc(startTime, endTime, r, cx, cy): any {
const startDegrees = (startTime + 0.2) * 6;
const endDegrees = (endTime - 0.2) * 6;
const start = this.getPointOnCircle(startDegrees, r, cx, cy);
const end = this.getPointOnCircle(endDegrees, r, cx, cy);
const arcLength = endDegrees - startDegrees;
// merge gaps and omit lines shorter than 1 degree
if (arcLength >= 1) {
const path = `M ${start.x} ${start.y} A ${r} ${r} 0 ${arcLength > 180 ? 1 : 0} 1 ${end.x} ${end.y}`;
return {
path,
start,
end
};
} else {
return null;
}
}
getPointOnCircle(deg, r, cx, cy) {
const modDeg = ((deg % 360) + 360) % 360;
const rad = (modDeg * Math.PI) / 180;
return {
x: cx + (r * Math.sin(rad)),
y: cy - (r * Math.cos(rad)),
};
}
makeTicks() {
this.minorTicks = [];
this.majorTicks = [];
for (let i = 1; i < 60; i++) {
if (i % 5 === 0) {
this.majorTicks.push(i * 6);
} else {
this.minorTicks.push(i * 6);
}
}
}
trackBySegment(index: number, segment) {
return segment.id;
}
}

View file

@ -0,0 +1 @@
<app-clock mode="mempool"></app-clock>

View file

@ -0,0 +1,7 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-clock-mempool',
templateUrl: './clock-mempool.component.html',
})
export class ClockMempoolComponent {}

View file

@ -0,0 +1 @@
<app-clock mode="block"></app-clock>

View file

@ -0,0 +1,7 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-clock-mined',
templateUrl: './clock-mined.component.html',
})
export class ClockMinedComponent {}

View file

@ -0,0 +1,67 @@
<div class="clock-wrapper" [style]="wrapperStyle">
<div class="clockchain-bar" [style.height]="chainHeight + 'px'">
<div class="clockchain">
<app-clockchain [width]="chainWidth" [height]="chainHeight" [mode]="mode"></app-clockchain>
</div>
</div>
<div class="clock-face">
<app-clock-face [size]="clockSize">
<div class="block-wrapper">
<ng-container *ngIf="block && block.height >= 0">
<ng-container *ngIf="mode === 'block'; else mempoolMode;">
<div class="block-cube">
<div class="side top"></div>
<div class="side bottom"></div>
<div class="side right" [style]="blockStyle"></div>
<div class="side left" [style]="blockStyle"></div>
<div class="side front" [style]="blockStyle"></div>
<div class="side back" [style]="blockStyle"></div>
</div>
</ng-container>
<ng-template #mempoolMode>
<div class="block-sizer" [style]="blockSizerStyle">
<app-mempool-block-overview [index]="0" [pixelAlign]="true"></app-mempool-block-overview>
</div>
</ng-template>
<div class="fader"></div>
<div class="title-wrapper">
<h1 class="block-height">{{ block.height }}</h1>
</div>
</ng-container>
</div>
</app-clock-face>
</div>
<ng-container *ngIf="!hideStats">
<div class="stats top left">
<p class="label" i18n="clock.fiat-price">fiat price</p>
<p>
<app-fiat [value]="100000000" digitsInfo="1.2-2" colorClass="white-color"></app-fiat>
</p>
</div>
<div class="stats top right">
<p class="label" i18n="clock.priority-rate|priority fee rate">priority rate</p>
<p *ngIf="recommendedFees$ | async as recommendedFees;" i18n="shared.sat-vbyte|sat/vB">{{ recommendedFees.fastestFee }} sat/vB</p>
</div>
<div *ngIf="mode !== 'mempool' && block" class="stats bottom left">
<p [innerHTML]="block.size | bytes: 2"></p>
<p class="label" i18n="clock.block-size">block size</p>
</div>
<div *ngIf="mode !== 'mempool' && block" class="stats bottom right">
<p class="force-wrap">
<ng-container *ngTemplateOutlet="block.tx_count === 1 ? transactionsSingular : transactionsPlural; context: {$implicit: block.tx_count | number}"></ng-container>
<ng-template #transactionsSingular let-i i18n="shared.transaction-count.singular">{{ i }} <span class="label">transaction</span></ng-template>
<ng-template #transactionsPlural let-i i18n="shared.transaction-count.plural">{{ i }} <span class="label">transactions</span></ng-template>
</p>
</div>
<ng-container *ngIf="mempoolInfo$ | async as mempoolInfo;">
<div *ngIf="mode === 'mempool'" class="stats bottom left">
<p [innerHTML]="mempoolInfo.usage | bytes: 0"></p>
<p class="label" i18n="dashboard.memory-usage|Memory usage">memory usage</p>
</div>
<div *ngIf="mode === 'mempool'" class="stats bottom right">
<p>{{ mempoolInfo.size | number }}</p>
<p class="label" i18n="dashboard.unconfirmed|Unconfirmed count">unconfirmed</p>
</div>
</ng-container>
</ng-container>
</div>

View file

@ -0,0 +1,190 @@
.clock-wrapper {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-start;
--chain-height: 60px;
--clock-width: 300px;
.clockchain-bar, .clock-face {
flex-shrink: 0;
flex-grow: 0;
}
.clockchain-bar {
position: relative;
width: 100%;
height: 15.625%;
z-index: 2;
// overflow: hidden;
// background: #1d1f31;
// box-shadow: 0 0 15px #000;
}
.clock-face {
position: relative;
height: 84.375%;
margin: auto;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
z-index: 1;
}
.stats {
position: absolute;
z-index: 3;
p {
margin: 0;
font-size: calc(0.055 * var(--clock-width));
line-height: calc(0.05 * var(--clock-width));
opacity: 0.8;
&.force-wrap {
word-spacing: 10000px;
}
::ng-deep .symbol {
font-size: inherit;
color: white;
}
}
.label {
font-size: calc(0.04 * var(--clock-width));
line-height: calc(0.05 * var(--clock-width));
}
&.top {
top: calc(var(--chain-height) + 2%);
}
&.bottom {
bottom: 2%;
}
&.left {
left: 5%;
}
&.right {
right: 5%;
text-align: end;
text-align: right;
}
}
}
.title-wrapper {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
.block-height {
font-size: calc(0.2 * var(--clock-width));
padding: 0;
margin: 0;
background: radial-gradient(rgba(0,0,0,0.5), transparent 67%);
padding: calc(0.05 * var(--clock-width)) calc(0.15 * var(--clock-width));
}
}
.block-wrapper {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
width: 100%;
height: 100%;
.block-sizer {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
.fader {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: radial-gradient(transparent 0%, transparent 44%, #11131f 58%, #11131f 100%);
}
.block-cube {
--side-width: calc(0.4 * var(--clock-width));
--half-side: calc(0.2 * var(--clock-width));
--neg-half-side: calc(-0.2 * var(--clock-width));
transform-style: preserve-3d;
animation: block-spin 60s infinite linear;
position: absolute;
z-index: -1;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: var(--side-width);
height: var(--side-width);
.side {
width: var(--side-width);
height: var(--side-width);
line-height: 100px;
text-align: center;
background: #232838;
display: block;
position: absolute;
}
.side.top {
transform: rotateX(90deg);
margin-top: var(--neg-half-side);
}
.side.bottom {
background: #105fb0;
transform: rotateX(-90deg);
margin-top: var(--half-side);
}
.side.right {
transform: rotateY(90deg);
margin-left: var(--half-side);
}
.side.left {
transform: rotateY(-90deg);
margin-left: var(--neg-half-side);
}
.side.front {
transform: translateZ(var(--half-side));
}
.side.back {
transform: translateZ(var(--neg-half-side));
}
}
}
@keyframes block-spin {
0% {transform: translate(-50%, -50%) rotateX(-20deg) rotateY(0deg);}
100% {transform: translate(-50%, -50%) rotateX(-20deg) rotateY(-360deg);}
}

View file

@ -0,0 +1,105 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostListener, Input, OnInit } from '@angular/core';
import { Observable, Subscription } from 'rxjs';
import { StateService } from '../../services/state.service';
import { BlockExtended } from '../../interfaces/node-api.interface';
import { WebsocketService } from '../../services/websocket.service';
import { MempoolInfo, Recommendedfees } from '../../interfaces/websocket.interface';
import { ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-clock',
templateUrl: './clock.component.html',
styleUrls: ['./clock.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ClockComponent implements OnInit {
@Input() mode: 'block' | 'mempool' = 'block';
hideStats: boolean = false;
blocksSubscription: Subscription;
recommendedFees$: Observable<Recommendedfees>;
mempoolInfo$: Observable<MempoolInfo>;
block: BlockExtended;
clockSize: number = 300;
chainWidth: number = 384;
chainHeight: number = 60;
blockStyle;
blockSizerStyle;
wrapperStyle;
limitWidth: number;
limitHeight: number;
gradientColors = {
'': ['#9339f4', '#105fb0'],
bisq: ['#9339f4', '#105fb0'],
liquid: ['#116761', '#183550'],
'liquidtestnet': ['#494a4a', '#272e46'],
testnet: ['#1d486f', '#183550'],
signet: ['#6f1d5d', '#471850'],
};
constructor(
public stateService: StateService,
private websocketService: WebsocketService,
private route: ActivatedRoute,
private cd: ChangeDetectorRef,
) {
this.route.queryParams.subscribe((params) => {
this.hideStats = params && params.stats === 'false';
this.limitWidth = Number.parseInt(params.width) || null;
this.limitHeight = Number.parseInt(params.height) || null;
});
}
ngOnInit(): void {
this.resizeCanvas();
this.websocketService.want(['blocks', 'stats', 'mempool-blocks']);
this.blocksSubscription = this.stateService.blocks$
.subscribe(([block]) => {
if (block) {
this.block = block;
this.blockStyle = this.getStyleForBlock(this.block);
this.cd.markForCheck();
}
});
this.recommendedFees$ = this.stateService.recommendedFees$;
this.mempoolInfo$ = this.stateService.mempoolInfo$;
}
getStyleForBlock(block: BlockExtended) {
const greenBackgroundHeight = 100 - (block.weight / this.stateService.env.BLOCK_WEIGHT_UNITS) * 100;
return {
background: `repeating-linear-gradient(
#2d3348,
#2d3348 ${greenBackgroundHeight}%,
${this.gradientColors[''][0]} ${Math.max(greenBackgroundHeight, 0)}%,
${this.gradientColors[''][1]} 100%
)`,
};
}
@HostListener('window:resize', ['$event'])
resizeCanvas(): void {
const windowWidth = this.limitWidth || window.innerWidth;
const windowHeight = this.limitHeight || window.innerHeight;
this.chainWidth = windowWidth;
this.chainHeight = Math.max(60, windowHeight / 8);
this.clockSize = Math.min(800, windowWidth, windowHeight - (1.4 * this.chainHeight));
const size = Math.ceil(this.clockSize / 75) * 75;
const margin = (this.clockSize - size) / 2;
this.blockSizerStyle = {
transform: `translate(${margin}px, ${margin}px)`,
width: `${size}px`,
height: `${size}px`,
};
this.wrapperStyle = {
'--clock-width': `${this.clockSize}px`,
'--chain-height': `${this.chainHeight}px`,
'width': this.limitWidth ? `${this.limitWidth}px` : undefined,
'height': this.limitHeight ? `${this.limitHeight}px` : undefined,
};
this.cd.markForCheck();
}
}

View file

@ -0,0 +1,28 @@
<div
class="text-center" class="blockchain-wrapper" [class.time-ltr]="timeLtr" #container
[class.ltr-transition]="ltrTransitionEnabled" [style.width]="width + 'px'" [style.height]="height + 'px'"
>
<div class="position-container" [ngClass]="network ? network : ''" [style.top]="(height / 3) + 'px'">
<span>
<div class="blocks-wrapper">
<app-mempool-blocks [minimal]="true" [count]="mempoolBlocks" [blockWidth]="blockWidth" [spotlight]="mode === 'mempool' ? 1 : 0"></app-mempool-blocks>
<app-blockchain-blocks [minimal]="true" [count]="blockchainBlocks" [blockWidth]="blockWidth" [spotlight]="mode === 'block' ? -1 : 0"></app-blockchain-blocks>
</div>
<div class="divider" [style.top]="-(height / 6) + 'px'">
<svg
viewBox="0 0 2 175"
[style.width]="'2px'"
[style.height]="(5 * height / 6) + 'px'"
>
<line
class="divider-line"
x0="0"
x1="0"
y0="0"
y1="175px"
></line>
</svg>
</div>
</span>
</div>
</div>

View file

@ -0,0 +1,94 @@
.divider {
position: absolute;
left: -0.5px;
top: 0;
.divider-line {
stroke: white;
stroke-width: 4px;
stroke-linecap: butt;
stroke-dasharray: 25px 25px;
}
}
.blockchain-wrapper {
height: 100%;
-webkit-user-select: none; /* Safari */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* IE10+/Edge */
user-select: none; /* Standard */
}
.position-container {
position: absolute;
left: 50%;
top: 0;
}
.black-background {
background-color: #11131f;
z-index: 100;
position: relative;
}
.scroll-spacer {
position: absolute;
top: 0;
left: 0;
width: 1px;
height: 1px;
pointer-events: none;
}
.loading-block {
position: absolute;
text-align: center;
margin: auto;
width: 300px;
left: -150px;
top: 0px;
}
.time-toggle {
color: white;
font-size: 0.8rem;
position: absolute;
bottom: -1.8em;
left: 1px;
transform: translateX(-50%);
background: none;
border: none;
outline: none;
margin: 0;
padding: 0;
}
.blockchain-wrapper.ltr-transition .blocks-wrapper,
.blockchain-wrapper.ltr-transition .position-container,
.blockchain-wrapper.ltr-transition .time-toggle {
transition: transform 1s;
}
.blockchain-wrapper.time-ltr {
.blocks-wrapper {
transform: scaleX(-1);
}
.time-toggle {
transform: translateX(-50%) scaleX(-1);
}
}
:host-context(.ltr-layout) {
.blockchain-wrapper.time-ltr .blocks-wrapper,
.blockchain-wrapper .blocks-wrapper {
direction: ltr;
}
}
:host-context(.rtl-layout) {
.blockchain-wrapper.time-ltr .blocks-wrapper,
.blockchain-wrapper .blocks-wrapper {
direction: rtl;
}
}

View file

@ -0,0 +1,73 @@
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, Input, OnChanges, ChangeDetectorRef } from '@angular/core';
import { firstValueFrom, Subscription } from 'rxjs';
import { StateService } from '../../services/state.service';
@Component({
selector: 'app-clockchain',
templateUrl: './clockchain.component.html',
styleUrls: ['./clockchain.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ClockchainComponent implements OnInit, OnChanges, OnDestroy {
@Input() width: number = 300;
@Input() height: number = 60;
@Input() mode: 'mempool' | 'block';
mempoolBlocks: number = 3;
blockchainBlocks: number = 6;
blockWidth: number = 50;
dividerStyle;
network: string;
timeLtrSubscription: Subscription;
timeLtr: boolean = this.stateService.timeLtr.value;
ltrTransitionEnabled = false;
connectionStateSubscription: Subscription;
loadingTip: boolean = true;
connected: boolean = true;
constructor(
public stateService: StateService,
private cd: ChangeDetectorRef,
) {}
ngOnInit() {
this.ngOnChanges();
this.network = this.stateService.network;
this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => {
this.timeLtr = !!ltr;
});
this.connectionStateSubscription = this.stateService.connectionState$.subscribe(state => {
this.connected = (state === 2);
});
firstValueFrom(this.stateService.chainTip$).then(() => {
this.loadingTip = false;
});
}
ngOnChanges() {
this.blockWidth = Math.floor(7 * this.height / 12);
this.mempoolBlocks = Math.floor(((this.width / 2) - (this.blockWidth * 0.32)) / (1.24 * this.blockWidth));
this.blockchainBlocks = this.mempoolBlocks;
this.dividerStyle = {
width: '2px',
height: `${this.height}px`,
};
this.cd.markForCheck();
}
ngOnDestroy() {
this.timeLtrSubscription.unsubscribe();
this.connectionStateSubscription.unsubscribe();
}
trackByPageFn(index: number, item: { index: number }) {
return item.index;
}
toggleTimeDirection() {
this.ltrTransitionEnabled = true;
this.stateService.timeLtr.next(!this.timeLtr);
}
}

View file

@ -14,7 +14,7 @@
<a [routerLink]="['/block' | relativeUrl, diffChange.height]">{{ diffChange.height }}</a> <a [routerLink]="['/block' | relativeUrl, diffChange.height]">{{ diffChange.height }}</a>
</td> </td>
<td class="date text-left"> <td class="date text-left">
<app-time kind="since" [time]="diffChange.timestamp" [fastRender]="true"></app-time> <app-time kind="since" [time]="diffChange.timestamp" [fastRender]="true" [precision]="1"></app-time>
</td> </td>
<td class="text-right">{{ diffChange.difficultyShorten }}</td> <td class="text-right">{{ diffChange.difficultyShorten }}</td>
<td class="text-right" [style]="diffChange.change >= 0 ? 'color: #42B747' : 'color: #B74242'"> <td class="text-right" [style]="diffChange.change >= 0 ? 'color: #42B747' : 'color: #B74242'">

View file

@ -10,7 +10,7 @@
<ng-template #blocksPlural let-i i18n="shared.blocks">{{ i }} <span class="shared-block">blocks</span></ng-template> <ng-template #blocksPlural let-i i18n="shared.blocks">{{ i }} <span class="shared-block">blocks</span></ng-template>
<ng-template #blocksSingular let-i i18n="shared.block">{{ i }} <span class="shared-block">block</span></ng-template> <ng-template #blocksSingular let-i i18n="shared.block">{{ i }} <span class="shared-block">block</span></ng-template>
</div> </div>
<div class="symbol"><app-time kind="until" [time]="epochData.estimatedRetargetDate" [fastRender]="true"></app-time></div> <div class="symbol"><app-time kind="until" [time]="epochData.estimatedRetargetDate" [fastRender]="true" [precision]="1"></app-time></div>
</div> </div>
<div class="item"> <div class="item">
<h5 class="card-title" i18n="difficulty-box.estimate">Estimate</h5> <h5 class="card-title" i18n="difficulty-box.estimate">Estimate</h5>
@ -54,7 +54,7 @@
<ng-template #blocksPlural let-i i18n="shared.blocks">{{ i }} <span class="shared-block">blocks</span></ng-template> <ng-template #blocksPlural let-i i18n="shared.blocks">{{ i }} <span class="shared-block">blocks</span></ng-template>
<ng-template #blocksSingular let-i i18n="shared.block">{{ i }} <span class="shared-block">block</span></ng-template> <ng-template #blocksSingular let-i i18n="shared.block">{{ i }} <span class="shared-block">block</span></ng-template>
</div> </div>
<div class="symbol"><app-time kind="until" [time]="epochData.timeUntilHalving" [fastRender]="true"></app-time></div> <div class="symbol"><app-time kind="until" [time]="epochData.timeUntilHalving" [fastRender]="true" [precision]="1"></app-time></div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -37,7 +37,7 @@
<div class="difficulty-stats"> <div class="difficulty-stats">
<div class="item"> <div class="item">
<div class="card-text"> <div class="card-text">
~<app-time [time]="epochData.timeAvg / 1000" [forceFloorOnTimeIntervals]="['minute']" [fractionDigits]="1"></app-time> ~<app-time [time]="epochData.timeAvg / 1000" [fractionDigits]="1"></app-time>
</div> </div>
<div class="symbol" i18n="difficulty-box.average-block-time">Average block time</div> <div class="symbol" i18n="difficulty-box.average-block-time">Average block time</div>
</div> </div>
@ -68,7 +68,7 @@
</div> </div>
</div> </div>
<div class="item"> <div class="item">
<div class="card-text"><app-time kind="until" [time]="epochData.estimatedRetargetDate" [fastRender]="true"></app-time></div> <div class="card-text"><app-time kind="until" [time]="epochData.estimatedRetargetDate" [fastRender]="true" [precision]="1"></app-time></div>
<div class="symbol"> <div class="symbol">
{{ epochData.retargetDateString }} {{ epochData.retargetDateString }}
</div> </div>

View file

@ -1,4 +1,4 @@
<div class="fee-estimation-wrapper" *ngIf="(isLoadingWebSocket$ | async) === false && (recommendedFees$ | async) as recommendedFees; else loadingFees"> <div class="fee-estimation-wrapper" *ngIf="(isLoading$ | async) === false && (recommendedFees$ | async) as recommendedFees; else loadingFees">
<div class="d-flex"> <div class="d-flex">
<div class="fee-progress-bar" [style.background]="noPriority"> <div class="fee-progress-bar" [style.background]="noPriority">
<span class="fee-label" i18n="fees-box.no-priority" i18n-ngbTooltip="Transaction feerate tooltip (economy)" ngbTooltip="Either 2x the minimum, or the Low Priority rate (whichever is lower)" placement="top">No Priority</span> <span class="fee-label" i18n="fees-box.no-priority" i18n-ngbTooltip="Transaction feerate tooltip (economy)" ngbTooltip="Either 2x the minimum, or the Low Priority rate (whichever is lower)" placement="top">No Priority</span>

View file

@ -1,9 +1,9 @@
import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core'; import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { StateService } from '../../services/state.service'; import { StateService } from '../../services/state.service';
import { Observable } from 'rxjs'; import { Observable, combineLatest } from 'rxjs';
import { Recommendedfees } from '../../interfaces/websocket.interface'; import { Recommendedfees } from '../../interfaces/websocket.interface';
import { feeLevels, mempoolFeeColors } from '../../app.constants'; import { feeLevels, mempoolFeeColors } from '../../app.constants';
import { tap } from 'rxjs/operators'; import { map, startWith, tap } from 'rxjs/operators';
@Component({ @Component({
selector: 'app-fees-box', selector: 'app-fees-box',
@ -12,7 +12,7 @@ import { tap } from 'rxjs/operators';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class FeesBoxComponent implements OnInit { export class FeesBoxComponent implements OnInit {
isLoadingWebSocket$: Observable<boolean>; isLoading$: Observable<boolean>;
recommendedFees$: Observable<Recommendedfees>; recommendedFees$: Observable<Recommendedfees>;
gradient = 'linear-gradient(to right, #2e324e, #2e324e)'; gradient = 'linear-gradient(to right, #2e324e, #2e324e)';
noPriority = '#2e324e'; noPriority = '#2e324e';
@ -22,7 +22,12 @@ export class FeesBoxComponent implements OnInit {
) { } ) { }
ngOnInit(): void { ngOnInit(): void {
this.isLoadingWebSocket$ = this.stateService.isLoadingWebSocket$; this.isLoading$ = combineLatest(
this.stateService.isLoadingWebSocket$.pipe(startWith(false)),
this.stateService.loadingIndicators$.pipe(startWith({ mempool: 0 })),
).pipe(map(([socket, indicators]) => {
return socket || (indicators.mempool != null && indicators.mempool !== 100);
}));
this.recommendedFees$ = this.stateService.recommendedFees$ this.recommendedFees$ = this.stateService.recommendedFees$
.pipe( .pipe(
tap((fees) => { tap((fees) => {

View file

@ -4,6 +4,9 @@
@media (min-width: 465px) { @media (min-width: 465px) {
font-size: 20px; font-size: 20px;
} }
@media (min-width: 992px) {
height: 40px;
}
} }
.main-title { .main-title {
@ -18,16 +21,19 @@
} }
.full-container { .full-container {
display: flex;
flex-direction: column;
padding: 0px 15px; padding: 0px 15px;
width: 100%; width: 100%;
min-height: 500px; height: calc(100vh - 250px);
height: calc(100% - 150px); @media (min-width: 992px) {
@media (max-width: 992px) { height: calc(100vh - 150px);
padding-bottom: 100px; }
};
} }
.chart { .chart {
display: flex;
flex: 1;
width: 100%; width: 100%;
height: 100%; height: 100%;
padding-bottom: 20px; padding-bottom: 20px;

View file

@ -4,6 +4,9 @@
@media (min-width: 465px) { @media (min-width: 465px) {
font-size: 20px; font-size: 20px;
} }
@media (min-width: 992px) {
height: 40px;
}
} }
.main-title { .main-title {
@ -18,18 +21,20 @@
} }
.full-container { .full-container {
display: flex;
flex-direction: column;
padding: 0px 15px; padding: 0px 15px;
width: 100%; width: 100%;
min-height: 500px; height: calc(100vh - 250px);
height: calc(100% - 150px); @media (min-width: 992px) {
@media (max-width: 992px) { height: calc(100vh - 150px);
padding-bottom: 100px; }
};
} }
.chart { .chart {
display: flex;
flex: 1;
width: 100%; width: 100%;
height: 100%;
padding-bottom: 20px; padding-bottom: 20px;
padding-right: 10px; padding-right: 10px;
@media (max-width: 992px) { @media (max-width: 992px) {

View file

@ -44,7 +44,7 @@
</ng-container> </ng-container>
</a> </a>
<div ngbDropdown (window:resize)="onResize($event)" class="dropdown-container" *ngIf="env.TESTNET_ENABLED || env.SIGNET_ENABLED || env.LIQUID_ENABLED || env.BISQ_ENABLED || env.LIQUID_TESTNET_ENABLED"> <div ngbDropdown (window:resize)="onResize()" class="dropdown-container" *ngIf="env.TESTNET_ENABLED || env.SIGNET_ENABLED || env.LIQUID_ENABLED || env.BISQ_ENABLED || env.LIQUID_TESTNET_ENABLED">
<button ngbDropdownToggle type="button" class="btn btn-secondary dropdown-toggle-split" aria-haspopup="true"> <button ngbDropdownToggle type="button" class="btn btn-secondary dropdown-toggle-split" aria-haspopup="true">
<app-svg-images [name]="network.val === '' ? 'liquid' : network.val" width="22" height="22" viewBox="0 0 125 125" style="width: 30px; height: 30px; margin-right: 5px;"></app-svg-images> <app-svg-images [name]="network.val === '' ? 'liquid' : network.val" width="22" height="22" viewBox="0 0 125 125" style="width: 30px; height: 30px; margin-right: 5px;"></app-svg-images>
</button> </button>
@ -92,9 +92,10 @@
<app-testnet-alert *ngIf="network.val === 'liquidtestnet'"></app-testnet-alert> <app-testnet-alert *ngIf="network.val === 'liquidtestnet'"></app-testnet-alert>
<br /> <main>
<router-outlet></router-outlet>
</main>
<router-outlet></router-outlet> <app-global-footer *ngIf="footerVisible"></app-global-footer>
<br>
</ng-container> </ng-container>

View file

@ -19,6 +19,7 @@ export class LiquidMasterPageComponent implements OnInit {
network$: Observable<string>; network$: Observable<string>;
urlLanguage: string; urlLanguage: string;
networkPaths: { [network: string]: string }; networkPaths: { [network: string]: string };
footerVisible = true;
constructor( constructor(
private stateService: StateService, private stateService: StateService,
@ -27,13 +28,18 @@ export class LiquidMasterPageComponent implements OnInit {
private navigationService: NavigationService, private navigationService: NavigationService,
) { } ) { }
ngOnInit() { ngOnInit(): void {
this.env = this.stateService.env; this.env = this.stateService.env;
this.connectionState$ = this.stateService.connectionState$; this.connectionState$ = this.stateService.connectionState$;
this.network$ = merge(of(''), this.stateService.networkChanged$); this.network$ = merge(of(''), this.stateService.networkChanged$);
this.urlLanguage = this.languageService.getLanguageForUrl(); this.urlLanguage = this.languageService.getLanguageForUrl();
this.navigationService.subnetPaths.subscribe((paths) => { this.navigationService.subnetPaths.subscribe((paths) => {
this.networkPaths = paths; this.networkPaths = paths;
if (paths.liquid.indexOf('docs') > -1) {
this.footerVisible = false;
} else {
this.footerVisible = true;
}
}); });
} }
@ -41,7 +47,7 @@ export class LiquidMasterPageComponent implements OnInit {
this.navCollapsed = !this.navCollapsed; this.navCollapsed = !this.navCollapsed;
} }
onResize(event: any) { onResize(): void {
this.isMobile = window.innerWidth <= 767.98; this.isMobile = window.innerWidth <= 767.98;
} }
} }

View file

@ -17,7 +17,7 @@
</ng-container> </ng-container>
</a> </a>
<div (window:resize)="onResize($event)" ngbDropdown class="dropdown-container" *ngIf="env.TESTNET_ENABLED || env.SIGNET_ENABLED || env.LIQUID_ENABLED || env.BISQ_ENABLED || env.LIQUID_TESTNET_ENABLED"> <div (window:resize)="onResize()" ngbDropdown class="dropdown-container" *ngIf="env.TESTNET_ENABLED || env.SIGNET_ENABLED || env.LIQUID_ENABLED || env.BISQ_ENABLED || env.LIQUID_TESTNET_ENABLED">
<button ngbDropdownToggle type="button" class="btn btn-secondary dropdown-toggle-split" aria-haspopup="true"> <button ngbDropdownToggle type="button" class="btn btn-secondary dropdown-toggle-split" aria-haspopup="true">
<app-svg-images [name]="network.val === '' ? 'bitcoin' : network.val" width="20" height="20" viewBox="0 0 65 65" style="width: 30px; height: 30px; margin-right: 5px;"></app-svg-images> <app-svg-images [name]="network.val === '' ? 'bitcoin' : network.val" width="20" height="20" viewBox="0 0 65 65" style="width: 30px; height: 30px; margin-right: 5px;"></app-svg-images>
</button> </button>
@ -64,10 +64,9 @@
<app-testnet-alert *ngIf="network.val === 'testnet' || network.val === 'signet'"></app-testnet-alert> <app-testnet-alert *ngIf="network.val === 'testnet' || network.val === 'signet'"></app-testnet-alert>
<br /> <main>
<router-outlet></router-outlet>
<router-outlet></router-outlet> </main>
<br>
<app-global-footer *ngIf="footerVisible"></app-global-footer>
</ng-container> </ng-container>

View file

@ -20,6 +20,8 @@ export class MasterPageComponent implements OnInit {
urlLanguage: string; urlLanguage: string;
subdomain = ''; subdomain = '';
networkPaths: { [network: string]: string }; networkPaths: { [network: string]: string };
networkPaths$: Observable<Record<string, string>>;
footerVisible = true;
constructor( constructor(
public stateService: StateService, public stateService: StateService,
@ -28,7 +30,7 @@ export class MasterPageComponent implements OnInit {
private navigationService: NavigationService, private navigationService: NavigationService,
) { } ) { }
ngOnInit() { ngOnInit(): void {
this.env = this.stateService.env; this.env = this.stateService.env;
this.connectionState$ = this.stateService.connectionState$; this.connectionState$ = this.stateService.connectionState$;
this.network$ = merge(of(''), this.stateService.networkChanged$); this.network$ = merge(of(''), this.stateService.networkChanged$);
@ -36,6 +38,11 @@ export class MasterPageComponent implements OnInit {
this.subdomain = this.enterpriseService.getSubdomain(); this.subdomain = this.enterpriseService.getSubdomain();
this.navigationService.subnetPaths.subscribe((paths) => { this.navigationService.subnetPaths.subscribe((paths) => {
this.networkPaths = paths; this.networkPaths = paths;
if (paths.mainnet.indexOf('docs') > -1) {
this.footerVisible = false;
} else {
this.footerVisible = true;
}
}); });
} }
@ -43,7 +50,7 @@ export class MasterPageComponent implements OnInit {
this.navCollapsed = !this.navCollapsed; this.navCollapsed = !this.navCollapsed;
} }
onResize(event: any) { onResize(): void {
this.isMobile = window.innerWidth <= 767.98; this.isMobile = window.innerWidth <= 767.98;
} }
} }

View file

@ -5,5 +5,6 @@
[blockLimit]="stateService.blockVSize" [blockLimit]="stateService.blockVSize"
[orientation]="timeLtr ? 'right' : 'left'" [orientation]="timeLtr ? 'right' : 'left'"
[flip]="true" [flip]="true"
[pixelAlign]="pixelAlign"
(txClickEvent)="onTxClick($event)" (txClickEvent)="onTxClick($event)"
></app-block-overview-graph> ></app-block-overview-graph>

View file

@ -16,6 +16,7 @@ import { Router } from '@angular/router';
}) })
export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChanges, AfterViewInit { export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChanges, AfterViewInit {
@Input() index: number; @Input() index: number;
@Input() pixelAlign: boolean = false;
@Output() txPreviewEvent = new EventEmitter<TransactionStripped | void>(); @Output() txPreviewEvent = new EventEmitter<TransactionStripped | void>();
@ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent; @ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent;
@ -99,7 +100,7 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang
const direction = (this.blockIndex == null || this.index < this.blockIndex) ? this.poolDirection : this.chainDirection; const direction = (this.blockIndex == null || this.index < this.blockIndex) ? this.poolDirection : this.chainDirection;
this.blockGraph.replace(delta.added, direction); this.blockGraph.replace(delta.added, direction);
} else { } else {
this.blockGraph.update(delta.added, delta.removed, blockMined ? this.chainDirection : this.poolDirection, blockMined); this.blockGraph.update(delta.added, delta.removed, delta.changed || [], blockMined ? this.chainDirection : this.poolDirection, blockMined);
} }
this.lastBlockHeight = this.stateService.latestBlockHeight; this.lastBlockHeight = this.stateService.latestBlockHeight;

View file

@ -1,40 +1,47 @@
<ng-container *ngIf="(loadingBlocks$ | async) === false; else loadingBlocks"> <ng-container *ngIf="(loadingBlocks$ | async) === false; else loadingBlocks" [class.minimal]="minimal">
<div class="mempool-blocks-container" [class.time-ltr]="timeLtr" *ngIf="(difficultyAdjustments$ | async) as da;"> <div class="mempool-blocks-container" [class.time-ltr]="timeLtr" [style.--block-size]="blockWidth+'px'" *ngIf="(difficultyAdjustments$ | async) as da;">
<div class="flashing"> <div class="flashing">
<ng-template ngFor let-projectedBlock [ngForOf]="mempoolBlocks$ | async" let-i="index" [ngForTrackBy]="trackByFn"> <ng-template ngFor let-projectedBlock [ngForOf]="mempoolBlocks$ | async" let-i="index" [ngForTrackBy]="trackByFn">
<div @blockEntryTrigger [@.disabled]="i > 0 || !animateEntry" [attr.data-cy]="'mempool-block-' + i" class="bitcoin-block text-center mempool-block" id="mempool-block-{{ i }}" [ngStyle]="mempoolBlockStyles[i]" [class.blink-bg]="projectedBlock.blink"> <div
*ngIf="minimal && spotlight > 0 && spotlight === i + 1"
class="spotlight-bottom"
[style.right]="mempoolBlockStyles[i].right"
></div>
<div @blockEntryTrigger [@.disabled]="i > 0 || !animateEntry" [attr.data-cy]="'mempool-block-' + i" class="bitcoin-block text-center mempool-block" [class.hide-block]="count && i >= count" id="mempool-block-{{ i }}" [ngStyle]="mempoolBlockStyles[i]" [class.blink-bg]="projectedBlock.blink">
<a draggable="false" [routerLink]="['/mempool-block/' | relativeUrl, i]" <a draggable="false" [routerLink]="['/mempool-block/' | relativeUrl, i]"
class="blockLink" [ngClass]="{'disabled': (this.stateService.blockScrolling$ | async)}">&nbsp;</a> class="blockLink" [ngClass]="{'disabled': (this.stateService.blockScrolling$ | async)}">&nbsp;</a>
<div class="block-body"> <div class="block-body">
<div [attr.data-cy]="'mempool-block-' + i + '-fees'" class="fees"> <ng-container *ngIf="!minimal">
~{{ projectedBlock.medianFee | number:feeRounding }} <span i18n="shared.sat-vbyte|sat/vB">sat/vB</span> <div [attr.data-cy]="'mempool-block-' + i + '-fees'" class="fees">
</div> ~{{ projectedBlock.medianFee | number:feeRounding }} <span i18n="shared.sat-vbyte|sat/vB">sat/vB</span>
<div [attr.data-cy]="'mempool-block-' + i + '-fee-span'" class="fee-span">
{{ projectedBlock.feeRange[0] | number:feeRounding }} - {{ projectedBlock.feeRange[projectedBlock.feeRange.length - 1] | number:feeRounding }} <span i18n="shared.sat-vbyte|sat/vB">sat/vB</span>
</div>
<div *ngIf="showMiningInfo" class="block-size">
<app-amount [attr.data-cy]="'mempool-block-' + i + '-total-fees'" [satoshis]="projectedBlock.totalFees" digitsInfo="1.2-3" [noFiat]="true"></app-amount>
</div>
<div *ngIf="!showMiningInfo" class="block-size" [innerHTML]="'&lrm;' + (projectedBlock.blockSize | bytes: 2)"></div>
<div [attr.data-cy]="'mempool-block-' + i + '-transaction-count'" class="transaction-count">
<ng-container *ngTemplateOutlet="projectedBlock.nTx === 1 ? transactionsSingular : transactionsPlural; context: {$implicit: projectedBlock.nTx | number}"></ng-container>
<ng-template #transactionsSingular let-i i18n="shared.transaction-count.singular">{{ i }} transaction</ng-template>
<ng-template #transactionsPlural let-i i18n="shared.transaction-count.plural">{{ i }} transactions</ng-template>
</div>
<div [attr.data-cy]="'mempool-block-' + i + '-time'" class="time-difference" *ngIf="projectedBlock.blockVSize <= stateService.blockVSize; else mergedBlock">
<ng-template [ngIf]="network === 'liquid' || network === 'liquidtestnet'" [ngIfElse]="timeDiffMainnet">
<app-time kind="until" [time]="(1 * i) + now + 61000" [fastRender]="false" [fixedRender]="true"></app-time>
</ng-template>
<ng-template #timeDiffMainnet>
<app-time kind="until" [time]="da.timeAvg * (i + 1) + now + da.timeOffset" [fastRender]="false" [fixedRender]="true" [forceFloorOnTimeIntervals]="['hour']"></app-time>
</ng-template>
</div>
<ng-template #mergedBlock>
<div [attr.data-cy]="'mempool-block-' + i + '-blocks'" class="time-difference">
<b>(<ng-container *ngTemplateOutlet="blocksPlural; context: {$implicit: projectedBlock.blockVSize / stateService.blockVSize | ceil }"></ng-container>)</b>
<ng-template #blocksPlural let-i i18n="shared.blocks">{{ i }} <span class="shared-block">blocks</span></ng-template>
</div> </div>
</ng-template> <div [attr.data-cy]="'mempool-block-' + i + '-fee-span'" class="fee-span">
{{ projectedBlock.feeRange[0] | number:feeRounding }} - {{ projectedBlock.feeRange[projectedBlock.feeRange.length - 1] | number:feeRounding }} <span i18n="shared.sat-vbyte|sat/vB">sat/vB</span>
</div>
<div *ngIf="showMiningInfo" class="block-size">
<app-amount [attr.data-cy]="'mempool-block-' + i + '-total-fees'" [satoshis]="projectedBlock.totalFees" digitsInfo="1.2-3" [noFiat]="true"></app-amount>
</div>
<div *ngIf="!showMiningInfo" class="block-size" [innerHTML]="'&lrm;' + (projectedBlock.blockSize | bytes: 2)"></div>
<div [attr.data-cy]="'mempool-block-' + i + '-transaction-count'" class="transaction-count">
<ng-container *ngTemplateOutlet="projectedBlock.nTx === 1 ? transactionsSingular : transactionsPlural; context: {$implicit: projectedBlock.nTx | number}"></ng-container>
<ng-template #transactionsSingular let-i i18n="shared.transaction-count.singular">{{ i }} transaction</ng-template>
<ng-template #transactionsPlural let-i i18n="shared.transaction-count.plural">{{ i }} transactions</ng-template>
</div>
<div [attr.data-cy]="'mempool-block-' + i + '-time'" class="time-difference" *ngIf="projectedBlock.blockVSize <= stateService.blockVSize; else mergedBlock">
<ng-template [ngIf]="network === 'liquid' || network === 'liquidtestnet'" [ngIfElse]="timeDiffMainnet">
<app-time kind="until" [time]="(1 * i) + now + 61000" [fastRender]="false" [fixedRender]="true"></app-time>
</ng-template>
<ng-template #timeDiffMainnet>
<app-time kind="until" [time]="da.timeAvg * (i + 1) + now + da.timeOffset" [fastRender]="false" [fixedRender]="true"></app-time>
</ng-template>
</div>
<ng-template #mergedBlock>
<div [attr.data-cy]="'mempool-block-' + i + '-blocks'" class="time-difference">
<b>(<ng-container *ngTemplateOutlet="blocksPlural; context: {$implicit: projectedBlock.blockVSize / stateService.blockVSize | ceil }"></ng-container>)</b>
<ng-template #blocksPlural let-i i18n="shared.blocks">{{ i }} <span class="shared-block">blocks</span></ng-template>
</div>
</ng-template>
</ng-container>
</div> </div>
<span class="animated-border"></span> <span class="animated-border"></span>
</div> </div>
@ -45,10 +52,10 @@
</ng-container> </ng-container>
<ng-template #loadingBlocks> <ng-template #loadingBlocks>
<div class="mempool-blocks-container" [class.time-ltr]="timeLtr"> <div class="mempool-blocks-container" [class.time-ltr]="timeLtr" [style.--block-size]="blockWidth+'px'">
<div class="flashing"> <div class="flashing">
<ng-template ngFor let-projectedBlock [ngForOf]="mempoolEmptyBlocks" let-i="index" [ngForTrackBy]="trackByFn"> <ng-template ngFor let-projectedBlock [ngForOf]="mempoolEmptyBlocks" let-i="index" [ngForTrackBy]="trackByFn">
<div class="bitcoin-block text-center mempool-block" id="mempool-block-{{ i }}" [ngStyle]="mempoolEmptyBlockStyles[i]"></div> <div class="bitcoin-block text-center mempool-block" [class.hide-block]="count && i >= count" id="mempool-block-{{ i }}" [ngStyle]="mempoolEmptyBlockStyles[i]"></div>
</ng-template> </ng-template>
</div> </div>
</div> </div>

View file

@ -1,7 +1,7 @@
.bitcoin-block { .bitcoin-block {
width: 125px; width: var(--block-size);
height: 125px; height: var(--block-size);
transition: background 2s, right 2s, transform 1s; transition: background 2s, right 2s, transform 1s, opacity 1s;
} }
.block-size { .block-size {
@ -14,6 +14,7 @@
top: 0px; top: 0px;
right: 0px; right: 0px;
left: 0px; left: 0px;
--block-size: 125px;
} }
.flashing { .flashing {
@ -66,11 +67,11 @@
.bitcoin-block::after { .bitcoin-block::after {
content: ''; content: '';
width: 125px; width: var(--block-size);
height: 24px; height: calc(0.192 * var(--block-size));
position:absolute; position:absolute;
top: -24px; top: calc(-0.192 * var(--block-size));
left: -20px; left: calc(-0.16 * var(--block-size));
background-color: #232838; background-color: #232838;
transform:skew(40deg); transform:skew(40deg);
transform-origin:top; transform-origin:top;
@ -79,11 +80,11 @@
.bitcoin-block::before { .bitcoin-block::before {
content: ''; content: '';
width: 20px; width: calc(0.16 * var(--block-size));
height: 125px; height: var(--block-size);
position: absolute; position: absolute;
top: -12px; top: calc(-0.096 * var(--block-size));
left: -20px; left: calc(-0.16 * var(--block-size));
background-color: #191c27; background-color: #191c27;
z-index: -1; z-index: -1;
@ -100,6 +101,10 @@
background-color: #2d2825; background-color: #2d2825;
} }
.mempool-block.hide-block {
opacity: 0;
}
.black-background { .black-background {
background-color: #11131f; background-color: #11131f;
z-index: 100; z-index: 100;
@ -141,7 +146,7 @@
.bitcoin-block::before { .bitcoin-block::before {
transform: skewY(-50deg); transform: skewY(-50deg);
left: 125px; left: var(--block-size);
} }
.block-body { .block-body {
transform: scaleX(-1); transform: scaleX(-1);
@ -152,4 +157,16 @@
#arrow-up { #arrow-up {
transform: translateX(70px); transform: translateX(70px);
} }
}
.spotlight-bottom {
position: absolute;
width: calc(0.6 * var(--block-size));
height: calc(0.25 * var(--block-size));
border-left: solid calc(0.3 * var(--block-size)) transparent;
border-bottom: solid calc(0.3 * var(--block-size)) white;
border-right: solid calc(0.3 * var(--block-size)) transparent;
transform: translate(calc(-0.2 * var(--block-size)), calc(1.1 * var(--block-size)));
border-radius: 2px;
z-index: -1;
} }

View file

@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef, HostListener } from '@angular/core'; import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef, HostListener, Input, OnChanges, SimpleChanges } from '@angular/core';
import { Subscription, Observable, fromEvent, merge, of, combineLatest } from 'rxjs'; import { Subscription, Observable, fromEvent, merge, of, combineLatest } from 'rxjs';
import { MempoolBlock } from '../../interfaces/websocket.interface'; import { MempoolBlock } from '../../interfaces/websocket.interface';
import { StateService } from '../../services/state.service'; import { StateService } from '../../services/state.service';
@ -8,7 +8,7 @@ import { feeLevels, mempoolFeeColors } from '../../app.constants';
import { specialBlocks } from '../../app.constants'; import { specialBlocks } from '../../app.constants';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
import { Location } from '@angular/common'; import { Location } from '@angular/common';
import { DifficultyAdjustment } from '../../interfaces/node-api.interface'; import { DifficultyAdjustment, MempoolPosition } from '../../interfaces/node-api.interface';
import { animate, style, transition, trigger } from '@angular/animations'; import { animate, style, transition, trigger } from '@angular/animations';
@Component({ @Component({
@ -23,7 +23,12 @@ import { animate, style, transition, trigger } from '@angular/animations';
])], ])],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class MempoolBlocksComponent implements OnInit, OnDestroy { export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy {
@Input() minimal: boolean = false;
@Input() blockWidth: number = 125;
@Input() count: number = null;
@Input() spotlight: number = 0;
specialBlocks = specialBlocks; specialBlocks = specialBlocks;
mempoolBlocks: MempoolBlock[] = []; mempoolBlocks: MempoolBlock[] = [];
mempoolEmptyBlocks: MempoolBlock[] = this.mountEmptyBlocks(); mempoolEmptyBlocks: MempoolBlock[] = this.mountEmptyBlocks();
@ -48,8 +53,9 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
timeLtr: boolean; timeLtr: boolean;
animateEntry: boolean = false; animateEntry: boolean = false;
blockWidth = 125; blockOffset: number = 155;
blockPadding = 30; blockPadding: number = 30;
containerOffset: number = 40;
arrowVisible = false; arrowVisible = false;
tabHidden = false; tabHidden = false;
feeRounding = '1.0-0'; feeRounding = '1.0-0';
@ -58,6 +64,7 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
transition = 'background 2s, right 2s, transform 1s'; transition = 'background 2s, right 2s, transform 1s';
markIndex: number; markIndex: number;
txPosition: MempoolPosition;
txFeePerVSize: number; txFeePerVSize: number;
resetTransitionTimeout: number; resetTransitionTimeout: number;
@ -152,10 +159,14 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
this.markBlocksSubscription = this.stateService.markBlock$ this.markBlocksSubscription = this.stateService.markBlock$
.subscribe((state) => { .subscribe((state) => {
this.markIndex = undefined; this.markIndex = undefined;
this.txPosition = undefined;
this.txFeePerVSize = undefined; this.txFeePerVSize = undefined;
if (state.mempoolBlockIndex !== undefined) { if (state.mempoolBlockIndex !== undefined) {
this.markIndex = state.mempoolBlockIndex; this.markIndex = state.mempoolBlockIndex;
} }
if (state.mempoolPosition) {
this.txPosition = state.mempoolPosition;
}
if (state.txFeePerVSize) { if (state.txFeePerVSize) {
this.txFeePerVSize = state.txFeePerVSize; this.txFeePerVSize = state.txFeePerVSize;
} }
@ -213,6 +224,14 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
}); });
} }
ngOnChanges(changes: SimpleChanges): void {
if (changes.blockWidth && this.blockWidth) {
this.blockPadding = 0.24 * this.blockWidth;
this.containerOffset = 0.32 * this.blockWidth;
this.blockOffset = this.blockWidth + this.blockPadding;
}
}
ngOnDestroy() { ngOnDestroy() {
this.markBlocksSubscription.unsubscribe(); this.markBlocksSubscription.unsubscribe();
this.blockSubscription.unsubscribe(); this.blockSubscription.unsubscribe();
@ -254,17 +273,24 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
reduceMempoolBlocksToFitScreen(blocks: MempoolBlock[]): MempoolBlock[] { reduceMempoolBlocksToFitScreen(blocks: MempoolBlock[]): MempoolBlock[] {
const innerWidth = this.stateService.env.BASE_MODULE !== 'liquid' && window.innerWidth <= 767.98 ? window.innerWidth : window.innerWidth / 2; const innerWidth = this.stateService.env.BASE_MODULE !== 'liquid' && window.innerWidth <= 767.98 ? window.innerWidth : window.innerWidth / 2;
const blocksAmount = Math.min(this.stateService.env.MEMPOOL_BLOCKS_AMOUNT, Math.floor(innerWidth / (this.blockWidth + this.blockPadding))); let blocksAmount;
if (this.count) {
blocksAmount = 8;
} else {
blocksAmount = Math.min(this.stateService.env.MEMPOOL_BLOCKS_AMOUNT, Math.floor(innerWidth / (this.blockWidth + this.blockPadding)));
}
while (blocks.length > blocksAmount) { while (blocks.length > blocksAmount) {
const block = blocks.pop(); const block = blocks.pop();
const lastBlock = blocks[blocks.length - 1]; if (!this.count) {
lastBlock.blockSize += block.blockSize; const lastBlock = blocks[blocks.length - 1];
lastBlock.blockVSize += block.blockVSize; lastBlock.blockSize += block.blockSize;
lastBlock.nTx += block.nTx; lastBlock.blockVSize += block.blockVSize;
lastBlock.feeRange = lastBlock.feeRange.concat(block.feeRange); lastBlock.nTx += block.nTx;
lastBlock.feeRange.sort((a, b) => a - b); lastBlock.feeRange = lastBlock.feeRange.concat(block.feeRange);
lastBlock.medianFee = this.median(lastBlock.feeRange); lastBlock.feeRange.sort((a, b) => a - b);
lastBlock.totalFees += block.totalFees; lastBlock.medianFee = this.median(lastBlock.feeRange);
lastBlock.totalFees += block.totalFees;
}
} }
if (blocks.length) { if (blocks.length) {
blocks[blocks.length - 1].isStack = blocks[blocks.length - 1].blockVSize > this.stateService.blockVSize; blocks[blocks.length - 1].isStack = blocks[blocks.length - 1].blockVSize > this.stateService.blockVSize;
@ -310,20 +336,20 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
}); });
return { return {
'right': 40 + index * 155 + 'px', 'right': this.containerOffset + index * this.blockOffset + 'px',
'background': backgroundGradients.join(',') + ')' 'background': backgroundGradients.join(',') + ')'
}; };
} }
getStyleForMempoolEmptyBlock(index: number) { getStyleForMempoolEmptyBlock(index: number) {
return { return {
'right': 40 + index * 155 + 'px', 'right': this.containerOffset + index * this.blockOffset + 'px',
'background': '#554b45', 'background': '#554b45',
}; };
} }
calculateTransactionPosition() { calculateTransactionPosition() {
if ((!this.txFeePerVSize && (this.markIndex === undefined || this.markIndex === -1)) || !this.mempoolBlocks) { if ((!this.txPosition && !this.txFeePerVSize && (this.markIndex === undefined || this.markIndex === -1)) || !this.mempoolBlocks) {
this.arrowVisible = false; this.arrowVisible = false;
return; return;
} else if (this.markIndex > -1) { } else if (this.markIndex > -1) {
@ -341,33 +367,43 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
this.arrowVisible = true; this.arrowVisible = true;
let found = false; if (this.txPosition) {
for (let txInBlockIndex = 0; txInBlockIndex < this.mempoolBlocks.length && !found; txInBlockIndex++) { if (this.txPosition.block >= this.mempoolBlocks.length) {
const block = this.mempoolBlocks[txInBlockIndex]; this.rightPosition = ((this.mempoolBlocks.length - 1) * (this.blockWidth + this.blockPadding)) + this.blockWidth;
for (let i = 0; i < block.feeRange.length - 1 && !found; i++) { } else {
if (this.txFeePerVSize < block.feeRange[i + 1] && this.txFeePerVSize >= block.feeRange[i]) { const positionInBlock = Math.min(1, this.txPosition.vsize / this.stateService.blockVSize) * this.blockWidth;
const feeRangeIndex = i; const positionOfBlock = this.txPosition.block * (this.blockWidth + this.blockPadding);
const feeRangeChunkSize = 1 / (block.feeRange.length - 1); this.rightPosition = positionOfBlock + positionInBlock;
}
} else {
let found = false;
for (let txInBlockIndex = 0; txInBlockIndex < this.mempoolBlocks.length && !found; txInBlockIndex++) {
const block = this.mempoolBlocks[txInBlockIndex];
for (let i = 0; i < block.feeRange.length - 1 && !found; i++) {
if (this.txFeePerVSize < block.feeRange[i + 1] && this.txFeePerVSize >= block.feeRange[i]) {
const feeRangeIndex = i;
const feeRangeChunkSize = 1 / (block.feeRange.length - 1);
const txFee = this.txFeePerVSize - block.feeRange[i]; const txFee = this.txFeePerVSize - block.feeRange[i];
const max = block.feeRange[i + 1] - block.feeRange[i]; const max = block.feeRange[i + 1] - block.feeRange[i];
const blockLocation = txFee / max; const blockLocation = txFee / max;
const chunkPositionOffset = blockLocation * feeRangeChunkSize; const chunkPositionOffset = blockLocation * feeRangeChunkSize;
const feePosition = feeRangeChunkSize * feeRangeIndex + chunkPositionOffset; const feePosition = feeRangeChunkSize * feeRangeIndex + chunkPositionOffset;
const blockedFilledPercentage = (block.blockVSize > this.stateService.blockVSize ? this.stateService.blockVSize : block.blockVSize) / this.stateService.blockVSize; const blockedFilledPercentage = (block.blockVSize > this.stateService.blockVSize ? this.stateService.blockVSize : block.blockVSize) / this.stateService.blockVSize;
const arrowRightPosition = txInBlockIndex * (this.blockWidth + this.blockPadding) const arrowRightPosition = txInBlockIndex * (this.blockWidth + this.blockPadding)
+ ((1 - feePosition) * blockedFilledPercentage * this.blockWidth); + ((1 - feePosition) * blockedFilledPercentage * this.blockWidth);
this.rightPosition = arrowRightPosition; this.rightPosition = arrowRightPosition;
found = true;
}
}
if (this.txFeePerVSize >= block.feeRange[block.feeRange.length - 1]) {
this.rightPosition = txInBlockIndex * (this.blockWidth + this.blockPadding);
found = true; found = true;
} }
} }
if (this.txFeePerVSize >= block.feeRange[block.feeRange.length - 1]) {
this.rightPosition = txInBlockIndex * (this.blockWidth + this.blockPadding);
found = true;
}
} }
} }

View file

@ -23,8 +23,7 @@ import { download, formatterXAxis, formatterXAxisLabel } from '../../shared/grap
}) })
export class MempoolGraphComponent implements OnInit, OnChanges { export class MempoolGraphComponent implements OnInit, OnChanges {
@Input() data: any[]; @Input() data: any[];
@Input() limitFee = 350; @Input() filterSize = 100000;
@Input() limitFilterFee = 1;
@Input() height: number | string = 200; @Input() height: number | string = 200;
@Input() top: number | string = 20; @Input() top: number | string = 20;
@Input() right: number | string = 10; @Input() right: number | string = 10;
@ -99,16 +98,20 @@ export class MempoolGraphComponent implements OnInit, OnChanges {
} }
generateArray(mempoolStats: OptimizedMempoolStats[]) { generateArray(mempoolStats: OptimizedMempoolStats[]) {
const finalArray: number[][][] = []; let finalArray: number[][][] = [];
let feesArray: number[][] = []; let feesArray: number[][] = [];
const limitFeesTemplate = this.template === 'advanced' ? 26 : 20; let maxTier = 0;
for (let index = limitFeesTemplate; index > -1; index--) { for (let index = 37; index > -1; index--) {
feesArray = []; feesArray = [];
mempoolStats.forEach((stats) => { mempoolStats.forEach((stats) => {
if (stats.vsizes[index] >= this.filterSize) {
maxTier = Math.max(maxTier, index);
}
feesArray.push([stats.added * 1000, stats.vsizes[index] ? stats.vsizes[index] : 0]); feesArray.push([stats.added * 1000, stats.vsizes[index] ? stats.vsizes[index] : 0]);
}); });
finalArray.push(feesArray); finalArray.push(feesArray);
} }
this.feeLimitIndex = maxTier;
finalArray.reverse(); finalArray.reverse();
return finalArray; return finalArray;
} }
@ -121,7 +124,7 @@ export class MempoolGraphComponent implements OnInit, OnChanges {
const newColors = []; const newColors = [];
for (let index = 0; index < series.length; index++) { for (let index = 0; index < series.length; index++) {
const value = series[index]; const value = series[index];
if (index >= this.feeLimitIndex) { if (index < this.feeLimitIndex) {
newColors.push(this.chartColorsOrdered[index]); newColors.push(this.chartColorsOrdered[index]);
seriesGraph.push({ seriesGraph.push({
zlevel: 0, zlevel: 0,
@ -178,7 +181,7 @@ export class MempoolGraphComponent implements OnInit, OnChanges {
alwaysShowContent: false, alwaysShowContent: false,
position: (pos, params, el, elRect, size) => { position: (pos, params, el, elRect, size) => {
const positions = { top: (this.template === 'advanced') ? 0 : -30 }; const positions = { top: (this.template === 'advanced') ? 0 : -30 };
positions[['left', 'right'][+(pos[0] < size.viewSize[0] / 2)]] = 60; positions[['left', 'right'][+(pos[0] < size.viewSize[0] / 2)]] = 100;
return positions; return positions;
}, },
extraCssText: `width: ${(this.template === 'advanced') ? '275px' : '200px'}; extraCssText: `width: ${(this.template === 'advanced') ? '275px' : '200px'};
@ -186,10 +189,19 @@ export class MempoolGraphComponent implements OnInit, OnChanges {
border: none; border: none;
box-shadow: none;`, box-shadow: none;`,
axisPointer: { axisPointer: {
type: 'line', type: 'cross',
label: {
formatter: (params: any) => {
if (params.axisDimension === 'y') {
return this.vbytesPipe.transform(params.value, 2, 'vB', 'MvB', true)
} else {
return formatterXAxis(this.locale, this.windowPreference, params.value);
}
}
}
}, },
formatter: (params: any) => { formatter: (params: any) => {
const axisValueLabel: string = formatterXAxis(this.locale, this.windowPreference, params[0].axisValue); const axisValueLabel: string = formatterXAxis(this.locale, this.windowPreference, params[0].axisValue);
const { totalValue, totalValueArray } = this.getTotalValues(params); const { totalValue, totalValueArray } = this.getTotalValues(params);
const itemFormatted = []; const itemFormatted = [];
let totalParcial = 0; let totalParcial = 0;
@ -371,17 +383,21 @@ export class MempoolGraphComponent implements OnInit, OnChanges {
orderLevels() { orderLevels() {
this.feeLevelsOrdered = []; this.feeLevelsOrdered = [];
for (let i = 0; i < feeLevels.length; i++) { let maxIndex = Math.min(feeLevels.length, this.feeLimitIndex);
if (feeLevels[i] === this.limitFilterFee) { for (let i = 0; i < maxIndex; i++) {
this.feeLimitIndex = i;
}
if (feeLevels[i] <= this.limitFee) {
if (this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet') { if (this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet') {
this.feeLevelsOrdered.push(`${(feeLevels[i] / 10).toFixed(1)} - ${(feeLevels[i + 1] / 10).toFixed(1)}`); if (i === maxIndex - 1) {
this.feeLevelsOrdered.push(`${(feeLevels[i] / 10).toFixed(1)}+`);
} else {
this.feeLevelsOrdered.push(`${(feeLevels[i] / 10).toFixed(1)} - ${(feeLevels[i + 1] / 10).toFixed(1)}`);
}
} else { } else {
this.feeLevelsOrdered.push(`${feeLevels[i]} - ${feeLevels[i + 1]}`); if (i === maxIndex - 1) {
this.feeLevelsOrdered.push(`${feeLevels[i]}+`);
} else {
this.feeLevelsOrdered.push(`${feeLevels[i]} - ${feeLevels[i + 1]}`);
}
} }
}
} }
this.chartColorsOrdered = chartColors.slice(0, this.feeLevelsOrdered.length); this.chartColorsOrdered = chartColors.slice(0, this.feeLevelsOrdered.length);
} }

View file

@ -73,24 +73,4 @@
</div> </div>
</div> </div>
<div class="pref-selectors">
<div class="selector">
<app-language-selector></app-language-selector>
</div>
<div class="selector">
<app-fiat-selector></app-fiat-selector>
</div>
</div>
<div class="terms-of-service">
<a [routerLink]="['/terms-of-service']" i18n="shared.terms-of-service|Terms of Service">Terms of Service</a>
|
<a [routerLink]="['/privacy-policy']" i18n="shared.privacy-policy|Privacy Policy">Privacy Policy</a>
|
<a [routerLink]="['/tx/push' | relativeUrl]" i18n="shared.broadcast-transaction|Broadcast Transaction">Broadcast Transaction</a>
</div>
<br>
</div> </div>

View file

@ -1,10 +1,6 @@
.dashboard-container { .dashboard-container {
padding-bottom: 60px;
text-align: center; text-align: center;
margin-top: 0.5rem; margin-top: 0.5rem;
@media (min-width: 992px) {
padding-bottom: 0px;
}
.col { .col {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
@ -104,22 +100,3 @@
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
} }
.terms-of-service {
margin-top: 1rem;
}
.pref-selectors {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
.selector {
margin-left: .5em;
margin-bottom: .5em;
&:first {
margin-left: 0;
}
}
}

View file

@ -75,7 +75,7 @@
</form> </form>
</div> </div>
<div [class]="!widget ? 'bottom-padding' : 'pb-0'" class="container pb-lg-0"> <div [class]="!widget ? '' : 'pb-0'" class="container pb-lg-0">
<div [class]="widget ? 'chart-widget' : 'chart'" echarts [initOpts]="chartInitOptions" [options]="chartOptions" <div [class]="widget ? 'chart-widget' : 'chart'" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
(chartInit)="onChartInit($event)"> (chartInit)="onChartInit($event)">
</div> </div>
@ -136,7 +136,6 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>

View file

@ -10,6 +10,7 @@
padding: 0px 15px; padding: 0px 15px;
width: 100%; width: 100%;
height: calc(100% - 140px); height: calc(100% - 140px);
padding-bottom: 20px;
@media (max-width: 992px) { @media (max-width: 992px) {
height: calc(100% - 190px); height: calc(100% - 190px);
}; };
@ -33,15 +34,6 @@
} }
} }
.bottom-padding {
@media (max-width: 992px) {
padding-bottom: 65px
};
@media (max-width: 576px) {
padding-bottom: 65px
};
}
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.pools-table th, .pools-table th,
.pools-table td { .pools-table td {

View file

@ -0,0 +1,46 @@
<div class="container-xl" style="min-height: 335px">
<h1 class="float-left" i18n="page.rbf-replacements">RBF Replacements</h1>
<div *ngIf="isLoading" class="spinner-border ml-3" role="status"></div>
<div class="mode-toggle float-right" *ngIf="fullRbfEnabled">
<form class="formRadioGroup">
<div class="btn-group btn-group-toggle" name="radioBasic">
<label class="btn btn-primary btn-sm" [class.active]="!fullRbf">
<input type="radio" [value]="'All'" fragment="" [routerLink]="[]"> All
</label>
<label class="btn btn-primary btn-sm" [class.active]="fullRbf">
<input type="radio" [value]="'Full RBF'" fragment="fullrbf" [routerLink]="[]"> Full RBF
</label>
</div>
</form>
</div>
<div class="clearfix"></div>
<div class="rbf-trees" style="min-height: 295px">
<ng-container *ngIf="rbfTrees$ | async as trees">
<div *ngFor="let tree of trees" class="tree">
<p class="info">
<span class="type">
<span *ngIf="isMined(tree)" class="badge badge-success" i18n="transaction.rbf.mined">Mined</span>
<span *ngIf="isFullRbf(tree)" class="badge badge-info" i18n="transaction.full-rbf">Full RBF</span>
</span>
<app-time kind="since" [time]="tree.time"></app-time>
</p>
<div class="timeline-wrapper" [class.mined]="isMined(tree)">
<app-rbf-timeline [replacements]="tree"></app-rbf-timeline>
</div>
</div>
<div class="no-replacements" *ngIf="!trees?.length">
<p i18n="rbf.no-replacements-yet">there are no replacements in the mempool yet!</p>
</div>
</ng-container>
<!-- <ngb-pagination class="pagination-container float-right mt-2" [class]="isLoading ? 'disabled' : ''"
[collectionSize]="blocksCount" [rotate]="true" [maxSize]="maxSize" [pageSize]="15" [(page)]="page"
(pageChange)="pageChange(page)" [boundaryLinks]="true" [ellipses]="false">
</ngb-pagination> -->
</div>
</div>

View file

@ -0,0 +1,35 @@
.spinner-border {
height: 25px;
width: 25px;
margin-top: 13px;
}
.rbf-trees {
.info {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: baseline;
margin: 0;
margin-bottom: 0.5em;
.type {
.badge {
margin-left: .5em;
}
}
}
.tree {
margin-bottom: 1em;
}
.timeline-wrapper.mined {
border: solid 4px #1a9436;
}
.no-replacements {
margin: 1em;
text-align: center;
}
}

View file

@ -0,0 +1,81 @@
import { Component, OnInit, ChangeDetectionStrategy, OnDestroy } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { BehaviorSubject, EMPTY, merge, Observable, Subscription } from 'rxjs';
import { catchError, switchMap, tap } from 'rxjs/operators';
import { WebsocketService } from '../../services/websocket.service';
import { RbfTree } from '../../interfaces/node-api.interface';
import { ApiService } from '../../services/api.service';
import { StateService } from '../../services/state.service';
@Component({
selector: 'app-rbf-list',
templateUrl: './rbf-list.component.html',
styleUrls: ['./rbf-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RbfList implements OnInit, OnDestroy {
rbfTrees$: Observable<RbfTree[]>;
nextRbfSubject = new BehaviorSubject(null);
urlFragmentSubscription: Subscription;
fullRbfEnabled: boolean;
fullRbf: boolean;
isLoading = true;
constructor(
private route: ActivatedRoute,
private router: Router,
private apiService: ApiService,
public stateService: StateService,
private websocketService: WebsocketService,
) {
this.fullRbfEnabled = stateService.env.FULL_RBF_ENABLED;
}
ngOnInit(): void {
this.urlFragmentSubscription = this.route.fragment.subscribe((fragment) => {
this.fullRbf = (fragment === 'fullrbf');
this.websocketService.startTrackRbf(this.fullRbf ? 'fullRbf' : 'all');
this.nextRbfSubject.next(null);
});
this.rbfTrees$ = merge(
this.nextRbfSubject.pipe(
switchMap(() => {
return this.apiService.getRbfList$(this.fullRbf);
}),
catchError((e) => {
return EMPTY;
})
),
this.stateService.rbfLatest$
)
.pipe(
tap(() => {
this.isLoading = false;
})
);
}
toggleFullRbf(event) {
this.router.navigate([], {
relativeTo: this.route,
fragment: this.fullRbf ? null : 'fullrbf'
});
}
isFullRbf(tree: RbfTree): boolean {
return tree.fullRbf;
}
isMined(tree: RbfTree): boolean {
return tree.mined;
}
// pageChange(page: number) {
// this.fromTreeSubject.next(this.lastTreeId);
// }
ngOnDestroy(): void {
this.websocketService.stopTrackRbf();
}
}

View file

@ -0,0 +1,38 @@
<div
#tooltip
*ngIf="rbfInfo && tooltipPosition !== null"
class="rbf-tooltip"
[style.left]="tooltipPosition.x + 'px'"
[style.top]="tooltipPosition.y + 'px'"
>
<table>
<tbody>
<tr>
<td class="td-width" i18n="shared.transaction">Transaction</td>
<td>
<a [routerLink]="['/tx/' | relativeUrl, rbfInfo.tx.txid]">{{ rbfInfo.tx.txid | shortenString : 16}}</a>
</td>
</tr>
<tr>
<td class="td-width" i18n="transaction.first-seen|Transaction first seen">First seen</td>
<td><i><app-time kind="since" [time]="rbfInfo.time" [fastRender]="true"></app-time></i></td>
</tr>
<tr>
<td class="td-width" i18n="transaction.fee|Transaction fee">Fee</td>
<td>{{ rbfInfo.tx.fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span></td>
</tr>
<tr>
<td class="td-width" i18n="transaction.vsize|Transaction Virtual Size">Virtual size</td>
<td [innerHTML]="'&lrm;' + (rbfInfo.tx.vsize | vbytes: 2)"></td>
</tr>
<tr>
<td class="td-width" i18n="transaction.status|Transaction Status">Status</td>
<td>
<span *ngIf="rbfInfo.tx.rbf; else rbfDisabled" class="badge badge-success" i18n="rbfInfo-features.tag.rbf|RBF">RBF</span>
<ng-template #rbfDisabled><span class="badge badge-danger mr-1"><del i18n="rbfInfo-features.tag.rbf|RBF">RBF</del></span></ng-template>
<span *ngIf="rbfInfo.tx.mined" class="badge badge-success" i18n="transaction.rbf.mined">Mined</span>
</td>
</tr>
</tbody>
</table>
</div>

View file

@ -0,0 +1,25 @@
.rbf-tooltip {
position: fixed;
z-index: 3;
background: rgba(#11131f, 0.95);
border-radius: 4px;
box-shadow: 1px 1px 10px rgba(0,0,0,0.5);
color: #b1b1b1;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 10px 15px;
text-align: left;
pointer-events: none;
.badge {
margin-right: 1em;
&:last-child {
margin-right: 0;
}
}
}
.td-width {
padding-right: 10px;
}

View file

@ -0,0 +1,35 @@
import { Component, ElementRef, ViewChild, Input, OnChanges } from '@angular/core';
import { RbfInfo } from '../../interfaces/node-api.interface';
@Component({
selector: 'app-rbf-timeline-tooltip',
templateUrl: './rbf-timeline-tooltip.component.html',
styleUrls: ['./rbf-timeline-tooltip.component.scss'],
})
export class RbfTimelineTooltipComponent implements OnChanges {
@Input() rbfInfo: RbfInfo | void;
@Input() cursorPosition: { x: number, y: number };
tooltipPosition = null;
@ViewChild('tooltip') tooltipElement: ElementRef<HTMLCanvasElement>;
constructor() {}
ngOnChanges(changes): void {
if (changes.cursorPosition && changes.cursorPosition.currentValue) {
let x = Math.max(10, changes.cursorPosition.currentValue.x - 50);
let y = changes.cursorPosition.currentValue.y + 20;
if (this.tooltipElement) {
const elementBounds = this.tooltipElement.nativeElement.getBoundingClientRect();
if ((x + elementBounds.width) > (window.innerWidth - 10)) {
x = Math.max(0, window.innerWidth - elementBounds.width - 10);
}
if (y + elementBounds.height > (window.innerHeight - 20)) {
y = y - elementBounds.height - 20;
}
}
this.tooltipPosition = { x, y };
}
}
}

View file

@ -0,0 +1,73 @@
<div class="rbf-timeline box" [class.mined]="replacements.mined">
<div class="timeline-wrapper">
<div class="timeline" *ngFor="let timeline of rows">
<div class="intervals">
<ng-container *ngFor="let cell of timeline; let i = index;">
<div class="node-spacer"></div>
<ng-container *ngIf="i < timeline.length - 1">
<div class="interval" *ngIf="cell.replacement?.interval != null; else intervalSpacer">
<div class="interval-time">
<app-time [time]="cell.replacement.interval" [relative]="false"></app-time>
</div>
</div>
</ng-container>
</ng-container>
</div>
<div class="nodes">
<ng-container *ngFor="let cell of timeline; let i = index;">
<ng-container *ngIf="cell.replacement; else nonNode">
<div class="node"
[id]="'node-'+cell.replacement.tx.txid"
[class.selected]="txid === cell.replacement.tx.txid"
[class.mined]="cell.replacement.tx.mined"
[class.first-node]="cell.first"
>
<div class="track"></div>
<a class="shape-border"
[class.rbf]="cell.replacement.tx.rbf"
[routerLink]="['/tx/' | relativeUrl, cell.replacement.tx.txid]"
(pointerover)="onHover($event, cell.replacement);"
(pointerout)="onBlur($event);"
>
<div class="shape"></div>
</a>
<span class="fee-rate">{{ cell.replacement.tx.fee / (cell.replacement.tx.vsize) | feeRounding }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></span>
</div>
</ng-container>
<ng-template #nonNode>
<ng-container [ngSwitch]="cell.connector">
<div class="connector" *ngSwitchCase="'pipe'"><div class="pipe"></div></div>
<div class="connector" *ngSwitchCase="'corner'"><div class="corner"></div></div>
<div class="node-spacer" *ngSwitchDefault></div>
</ng-container>
</ng-template>
<ng-container *ngIf="i < timeline.length - 1">
<div class="interval-spacer" *ngIf="cell.replacement?.interval != null; else intervalSpacer">
<div class="track"></div>
</div>
</ng-container>
</ng-container>
</div>
</div>
</div>
<ng-template #nodeSpacer>
<div class="node-spacer"></div>
</ng-template>
<ng-template #intervalSpacer>
<div class="interval-spacer"></div>
</ng-template>
<app-rbf-timeline-tooltip
[rbfInfo]="hoverInfo"
[cursorPosition]="tooltipPosition"
></app-rbf-timeline-tooltip>
<!-- <app-rbf-timeline-tooltip
*ngIf=[tooltip]
[line]="hoverLine"
[cursorPosition]="tooltipPosition"
[isConnector]="hoverConnector"
></app-rbf-timeline-tooltip> -->
</div>

View file

@ -0,0 +1,193 @@
.rbf-timeline {
position: relative;
width: 100%;
padding: 1em 0;
&::after, &::before {
content: '';
display: block;
position: absolute;
top: 0;
bottom: 0;
width: 2em;
z-index: 2;
}
&::before {
left: 0;
background: linear-gradient(to right, #24273e, #24273e, transparent);
}
&::after {
right: 0;
background: linear-gradient(to left, #24273e, #24273e, transparent);
}
.timeline-wrapper {
position: relative;
width: calc(100% - 2em);
margin: auto;
overflow-x: auto;
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.intervals, .nodes {
min-width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
text-align: center;
.node, .node-spacer, .connector {
width: 6em;
min-width: 6em;
flex-grow: 1;
}
.interval, .interval-spacer {
width: 8em;
min-width: 5em;
max-width: 8em;
height: 32px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: flex-end;
}
.interval {
overflow: visible;
}
.interval-time {
font-size: 12px;
line-height: 16px;
white-space: nowrap;
}
}
.node, .interval-spacer {
position: relative;
.track {
position: absolute;
height: 10px;
left: -5px;
right: -5px;
top: 0;
transform: translateY(-50%);
background: #105fb0;
border-radius: 5px;
}
&.first-node {
.track {
left: 50%;
}
}
&:last-child {
.track {
right: 50%;
}
}
}
.nodes {
position: relative;
margin-top: 1em;
.node {
.shape-border {
display: block;
margin: auto;
height: calc(1em + 8px);
width: calc(1em + 8px);
margin-bottom: -8px;
transform: translateY(-50%);
border-radius: 10%;
cursor: pointer;
padding: 4px;
background: transparent;
transition: background-color 300ms, padding 300ms;
.shape {
width: 100%;
height: 100%;
border-radius: 10%;
background: white;
transition: background-color 300ms, border 300ms;
}
&.rbf, &.rbf .shape {
border-radius: 50%;
}
}
.symbol::ng-deep {
display: block;
margin-top: -0.5em;
}
&.selected {
.shape-border {
background: #9339f4;
}
}
&.mined {
.shape-border {
background: #1a9436;
}
}
.shape-border:hover {
padding: 0px;
.shape {
background: #1bd8f4;
}
}
&.selected.mined {
.shape-border {
background: #1a9436;
height: calc(1em + 16px);
width: calc(1em + 16px);
.shape {
border: solid 4px #9339f4;
}
&:hover {
padding: 4px;
.shape {
border-width: 1px;
border-color: #1bd8f4
}
}
}
}
}
.connector {
position: relative;
height: 10px;
.corner, .pipe {
position: absolute;
left: -10px;
width: 20px;
height: 108px;
bottom: 50%;
border-right: solid 10px #105fb0;
}
.corner {
border-bottom: solid 10px #105fb0;
border-bottom-right-radius: 10px;
}
}
}
}

View file

@ -0,0 +1,191 @@
import { Component, Input, OnInit, OnChanges, Inject, LOCALE_ID, HostListener } from '@angular/core';
import { Router } from '@angular/router';
import { RbfInfo, RbfTree } from '../../interfaces/node-api.interface';
import { StateService } from '../../services/state.service';
import { ApiService } from '../../services/api.service';
type Connector = 'pipe' | 'corner';
interface TimelineCell {
replacement?: RbfInfo,
connector?: Connector,
first?: boolean,
}
@Component({
selector: 'app-rbf-timeline',
templateUrl: './rbf-timeline.component.html',
styleUrls: ['./rbf-timeline.component.scss'],
})
export class RbfTimelineComponent implements OnInit, OnChanges {
@Input() replacements: RbfTree;
@Input() txid: string;
rows: TimelineCell[][] = [];
hoverInfo: RbfInfo | void = null;
tooltipPosition = null;
dir: 'rtl' | 'ltr' = 'ltr';
constructor(
private router: Router,
private stateService: StateService,
private apiService: ApiService,
@Inject(LOCALE_ID) private locale: string,
) {
if (this.locale.startsWith('ar') || this.locale.startsWith('fa') || this.locale.startsWith('he')) {
this.dir = 'rtl';
}
}
ngOnInit(): void {
this.rows = this.buildTimelines(this.replacements);
}
ngOnChanges(changes): void {
this.rows = this.buildTimelines(this.replacements);
if (changes.txid) {
setTimeout(() => { this.scrollToSelected(); });
}
}
// converts a tree of RBF events into a format that can be more easily rendered in HTML
buildTimelines(tree: RbfTree): TimelineCell[][] {
if (!tree) return [];
const split = this.splitTimelines(tree);
const timelines = this.prepareTimelines(split);
return this.connectTimelines(timelines);
}
// splits a tree into N leaf-to-root paths
splitTimelines(tree: RbfTree, tail: RbfInfo[] = []): RbfInfo[][] {
const replacements = [...tail, tree];
if (tree.replaces.length) {
return [].concat(...tree.replaces.map(subtree => this.splitTimelines(subtree, replacements)));
} else {
return [[...replacements]];
}
}
// merges separate leaf-to-root paths into a coherent forking timeline
// represented as a 2D array of Rbf events
prepareTimelines(lines: RbfInfo[][]): RbfInfo[][] {
lines.sort((a, b) => b.length - a.length);
const rows = lines.map(() => []);
let lineGroups = [lines];
let done = false;
let column = 0; // sanity check for while loop stopping condition
while (!done && column < 100) {
// iterate over timelines element-by-element
// at each step, group lines which share a common transaction at their head
// (i.e. lines terminating in the same replacement event)
let index = 0;
let emptyCount = 0;
const nextGroups = [];
for (const group of lineGroups) {
const toMerge: { [txid: string]: RbfInfo[][] } = {};
let emptyInGroup = 0;
let first = true;
for (const line of group) {
const head = line.shift() || null;
if (first) {
// only insert the first instance of the replacement node
rows[index].unshift(head);
first = false;
} else {
// substitute duplicates with empty cells
// (we'll fill these in with connecting lines later)
rows[index].unshift(null);
}
// group the tails of the remaining lines for the next iteration
if (line.length) {
const nextId = line[0].tx.txid;
if (!toMerge[nextId]) {
toMerge[nextId] = [];
}
toMerge[nextId].push(line);
} else {
emptyInGroup++;
}
index++;
}
for (const merged of Object.values(toMerge).sort((a, b) => b.length - a.length)) {
nextGroups.push(merged);
}
for (let i = 0; i < emptyInGroup; i++) {
nextGroups.push([[]]);
}
emptyCount += emptyInGroup;
lineGroups = nextGroups;
done = (emptyCount >= rows.length);
}
column++;
}
return rows;
}
// annotates a 2D timeline array with info needed to draw connecting lines for multi-replacements
connectTimelines(timelines: RbfInfo[][]): TimelineCell[][] {
const rows: TimelineCell[][] = [];
timelines.forEach((lines, row) => {
rows.push([]);
let started = false;
let finished = false;
lines.forEach((replacement, column) => {
const cell: TimelineCell = {};
if (replacement) {
cell.replacement = replacement;
}
rows[row].push(cell);
if (replacement) {
if (!started) {
cell.first = true;
started = true;
}
} else if (started && !finished) {
if (column < timelines[row].length) {
let matched = false;
for (let i = row; i >= 0 && !matched; i--) {
const nextCell = rows[i][column];
if (nextCell.replacement) {
matched = true;
} else if (i === row) {
rows[i][column] = {
connector: 'corner'
};
} else if (nextCell.connector !== 'corner') {
rows[i][column] = {
connector: 'pipe'
};
}
}
}
finished = true;
}
});
});
return rows;
}
scrollToSelected() {
const node = document.getElementById('node-' + this.txid);
if (node) {
node.scrollIntoView({ block: 'nearest', inline: 'center' });
}
}
@HostListener('pointermove', ['$event'])
onPointerMove(event) {
this.tooltipPosition = { x: event.clientX, y: event.clientY };
}
onHover(event, replacement): void {
this.hoverInfo = replacement;
}
onBlur(event): void {
this.hoverInfo = null;
}
}

View file

@ -153,7 +153,7 @@ export class SearchFormComponent implements OnInit {
const matchesBlockHeight = this.regexBlockheight.test(searchText); const matchesBlockHeight = this.regexBlockheight.test(searchText);
const matchesTxId = this.regexTransaction.test(searchText) && !this.regexBlockhash.test(searchText); const matchesTxId = this.regexTransaction.test(searchText) && !this.regexBlockhash.test(searchText);
const matchesBlockHash = this.regexBlockhash.test(searchText); const matchesBlockHash = this.regexBlockhash.test(searchText);
const matchesAddress = this.regexAddress.test(searchText); const matchesAddress = !matchesTxId && this.regexAddress.test(searchText);
if (matchesAddress && this.network === 'bisq') { if (matchesAddress && this.network === 'bisq') {
searchText = 'B' + searchText; searchText = 'B' + searchText;
@ -198,7 +198,7 @@ export class SearchFormComponent implements OnInit {
const searchText = result || this.searchForm.value.searchText.trim(); const searchText = result || this.searchForm.value.searchText.trim();
if (searchText) { if (searchText) {
this.isSearching = true; this.isSearching = true;
if (this.regexAddress.test(searchText)) { if (!this.regexTransaction.test(searchText) && this.regexAddress.test(searchText)) {
this.navigate('/address/', searchText); this.navigate('/address/', searchText);
} else if (this.regexBlockhash.test(searchText) || this.regexBlockheight.test(searchText)) { } else if (this.regexBlockhash.test(searchText) || this.regexBlockheight.test(searchText)) {
this.navigate('/block/', searchText); this.navigate('/block/', searchText);

View file

@ -84,8 +84,7 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="incoming-transactions-graph"> <div class="incoming-transactions-graph">
<app-mempool-graph #mempoolgraph dir="ltr" [template]="'advanced'" [limitFee]="500" <app-mempool-graph #mempoolgraph dir="ltr" [template]="'advanced'" [height]="500" [left]="65" [right]="10"
[limitFilterFee]="filterFeeIndex" [height]="500" [left]="65" [right]="10"
[data]="mempoolStats && mempoolStats.length ? mempoolStats : null"></app-mempool-graph> [data]="mempoolStats && mempoolStats.length ? mempoolStats : null"></app-mempool-graph>
</div> </div>
</div> </div>

View file

@ -1,5 +1,5 @@
.container-graph { .container-graph {
padding: 0px 15px 60px; padding: 0px 15px 10px;
} }
.card-header { .card-header {

View file

@ -3,7 +3,6 @@
<div class="chart-holder"> <div class="chart-holder">
<app-mempool-graph <app-mempool-graph
[template]="'advanced'" [template]="'advanced'"
[limitFee]="500"
[height]="600" [height]="600"
[left]="60" [left]="60"
[right]="10" [right]="10"

View file

@ -10,6 +10,16 @@ import { dates } from '../../shared/i18n/dates';
export class TimeComponent implements OnInit, OnChanges, OnDestroy { export class TimeComponent implements OnInit, OnChanges, OnDestroy {
interval: number; interval: number;
text: string; text: string;
units: string[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'];
precisionThresholds = {
year: 100,
month: 18,
week: 12,
day: 31,
hour: 48,
minute: 90,
second: 90
};
intervals = {}; intervals = {};
@Input() time: number; @Input() time: number;
@ -18,7 +28,7 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy {
@Input() fastRender = false; @Input() fastRender = false;
@Input() fixedRender = false; @Input() fixedRender = false;
@Input() relative = false; @Input() relative = false;
@Input() forceFloorOnTimeIntervals: string[]; @Input() precision: number = 0;
@Input() fractionDigits: number = 0; @Input() fractionDigits: number = 0;
constructor( constructor(
@ -83,23 +93,24 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy {
} }
let counter: number; let counter: number;
for (const i in this.intervals) { for (const [index, unit] of this.units.entries()) {
if (this.kind !== 'until' || this.forceFloorOnTimeIntervals && this.forceFloorOnTimeIntervals.indexOf(i) > -1) { let precisionUnit = this.units[Math.min(this.units.length - 1, index + this.precision)];
counter = Math.floor(seconds / this.intervals[i]); counter = Math.floor(seconds / this.intervals[unit]);
} else { const precisionCounter = Math.floor(seconds / this.intervals[precisionUnit]);
counter = Math.round(seconds / this.intervals[i]); if (precisionCounter > this.precisionThresholds[precisionUnit]) {
precisionUnit = unit;
} }
let rounded = counter;
if (this.fractionDigits) {
const roundFactor = Math.pow(10,this.fractionDigits);
rounded = Math.round((seconds / this.intervals[i]) * roundFactor) / roundFactor;
}
const dateStrings = dates(rounded);
if (counter > 0) { if (counter > 0) {
let rounded = Math.round(seconds / this.intervals[precisionUnit]);
if (this.fractionDigits) {
const roundFactor = Math.pow(10,this.fractionDigits);
rounded = Math.round((seconds / this.intervals[precisionUnit]) * roundFactor) / roundFactor;
}
const dateStrings = dates(rounded);
switch (this.kind) { switch (this.kind) {
case 'since': case 'since':
if (counter === 1) { if (rounded === 1) {
switch (i) { // singular (1 day) switch (precisionUnit) { // singular (1 day)
case 'year': return $localize`:@@time-since:${dateStrings.i18nYear}:DATE: ago`; break; case 'year': return $localize`:@@time-since:${dateStrings.i18nYear}:DATE: ago`; break;
case 'month': return $localize`:@@time-since:${dateStrings.i18nMonth}:DATE: ago`; break; case 'month': return $localize`:@@time-since:${dateStrings.i18nMonth}:DATE: ago`; break;
case 'week': return $localize`:@@time-since:${dateStrings.i18nWeek}:DATE: ago`; break; case 'week': return $localize`:@@time-since:${dateStrings.i18nWeek}:DATE: ago`; break;
@ -109,7 +120,7 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy {
case 'second': return $localize`:@@time-since:${dateStrings.i18nSecond}:DATE: ago`; break; case 'second': return $localize`:@@time-since:${dateStrings.i18nSecond}:DATE: ago`; break;
} }
} else { } else {
switch (i) { // plural (2 days) switch (precisionUnit) { // plural (2 days)
case 'year': return $localize`:@@time-since:${dateStrings.i18nYears}:DATE: ago`; break; case 'year': return $localize`:@@time-since:${dateStrings.i18nYears}:DATE: ago`; break;
case 'month': return $localize`:@@time-since:${dateStrings.i18nMonths}:DATE: ago`; break; case 'month': return $localize`:@@time-since:${dateStrings.i18nMonths}:DATE: ago`; break;
case 'week': return $localize`:@@time-since:${dateStrings.i18nWeeks}:DATE: ago`; break; case 'week': return $localize`:@@time-since:${dateStrings.i18nWeeks}:DATE: ago`; break;
@ -121,8 +132,8 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy {
} }
break; break;
case 'until': case 'until':
if (counter === 1) { if (rounded === 1) {
switch (i) { // singular (In ~1 day) switch (precisionUnit) { // singular (In ~1 day)
case 'year': return $localize`:@@time-until:In ~${dateStrings.i18nYear}:DATE:`; break; case 'year': return $localize`:@@time-until:In ~${dateStrings.i18nYear}:DATE:`; break;
case 'month': return $localize`:@@time-until:In ~${dateStrings.i18nMonth}:DATE:`; break; case 'month': return $localize`:@@time-until:In ~${dateStrings.i18nMonth}:DATE:`; break;
case 'week': return $localize`:@@time-until:In ~${dateStrings.i18nWeek}:DATE:`; break; case 'week': return $localize`:@@time-until:In ~${dateStrings.i18nWeek}:DATE:`; break;
@ -132,7 +143,7 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy {
case 'second': return $localize`:@@time-until:In ~${dateStrings.i18nSecond}:DATE:`; case 'second': return $localize`:@@time-until:In ~${dateStrings.i18nSecond}:DATE:`;
} }
} else { } else {
switch (i) { // plural (In ~2 days) switch (precisionUnit) { // plural (In ~2 days)
case 'year': return $localize`:@@time-until:In ~${dateStrings.i18nYears}:DATE:`; break; case 'year': return $localize`:@@time-until:In ~${dateStrings.i18nYears}:DATE:`; break;
case 'month': return $localize`:@@time-until:In ~${dateStrings.i18nMonths}:DATE:`; break; case 'month': return $localize`:@@time-until:In ~${dateStrings.i18nMonths}:DATE:`; break;
case 'week': return $localize`:@@time-until:In ~${dateStrings.i18nWeeks}:DATE:`; break; case 'week': return $localize`:@@time-until:In ~${dateStrings.i18nWeeks}:DATE:`; break;
@ -144,8 +155,8 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy {
} }
break; break;
case 'span': case 'span':
if (counter === 1) { if (rounded === 1) {
switch (i) { // singular (1 day) switch (precisionUnit) { // singular (1 day)
case 'year': return $localize`:@@time-span:After ${dateStrings.i18nYear}:DATE:`; break; case 'year': return $localize`:@@time-span:After ${dateStrings.i18nYear}:DATE:`; break;
case 'month': return $localize`:@@time-span:After ${dateStrings.i18nMonth}:DATE:`; break; case 'month': return $localize`:@@time-span:After ${dateStrings.i18nMonth}:DATE:`; break;
case 'week': return $localize`:@@time-span:After ${dateStrings.i18nWeek}:DATE:`; break; case 'week': return $localize`:@@time-span:After ${dateStrings.i18nWeek}:DATE:`; break;
@ -155,7 +166,7 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy {
case 'second': return $localize`:@@time-span:After ${dateStrings.i18nSecond}:DATE:`; break; case 'second': return $localize`:@@time-span:After ${dateStrings.i18nSecond}:DATE:`; break;
} }
} else { } else {
switch (i) { // plural (2 days) switch (precisionUnit) { // plural (2 days)
case 'year': return $localize`:@@time-span:After ${dateStrings.i18nYears}:DATE:`; break; case 'year': return $localize`:@@time-span:After ${dateStrings.i18nYears}:DATE:`; break;
case 'month': return $localize`:@@time-span:After ${dateStrings.i18nMonths}:DATE:`; break; case 'month': return $localize`:@@time-span:After ${dateStrings.i18nMonths}:DATE:`; break;
case 'week': return $localize`:@@time-span:After ${dateStrings.i18nWeeks}:DATE:`; break; case 'week': return $localize`:@@time-span:After ${dateStrings.i18nWeeks}:DATE:`; break;
@ -167,8 +178,8 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy {
} }
break; break;
default: default:
if (counter === 1) { if (rounded === 1) {
switch (i) { // singular (1 day) switch (precisionUnit) { // singular (1 day)
case 'year': return dateStrings.i18nYear; break; case 'year': return dateStrings.i18nYear; break;
case 'month': return dateStrings.i18nMonth; break; case 'month': return dateStrings.i18nMonth; break;
case 'week': return dateStrings.i18nWeek; break; case 'week': return dateStrings.i18nWeek; break;
@ -178,7 +189,7 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy {
case 'second': return dateStrings.i18nSecond; break; case 'second': return dateStrings.i18nSecond; break;
} }
} else { } else {
switch (i) { // plural (2 days) switch (precisionUnit) { // plural (2 days)
case 'year': return dateStrings.i18nYears; break; case 'year': return dateStrings.i18nYears; break;
case 'month': return dateStrings.i18nMonths; break; case 'month': return dateStrings.i18nMonths; break;
case 'week': return dateStrings.i18nWeeks; break; case 'week': return dateStrings.i18nWeeks; break;

Some files were not shown because too many files have changed in this diff Show more