diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index c50c38107..23814a87e 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -529,13 +529,14 @@ class Blocks { return await BlocksRepository.$validateChain(); } - public async $updateBlocks() { + public async $updateBlocks(): Promise { // warn if this run stalls the main loop for more than 2 minutes const timer = this.startTimer(); diskCache.lock(); let fastForwarded = false; + let handledBlocks = 0; const blockHeightTip = await bitcoinApi.$getBlockHeightTip(); this.updateTimerProgress(timer, 'got block height tip'); @@ -697,11 +698,15 @@ class Blocks { this.updateTimerProgress(timer, `waiting for async callbacks to complete for ${this.currentBlockHeight}`); await Promise.all(callbackPromises); this.updateTimerProgress(timer, `async callbacks completed for ${this.currentBlockHeight}`); + + handledBlocks++; } diskCache.unlock(); this.clearTimer(timer); + + return handledBlocks; } private startTimer() { diff --git a/backend/src/api/disk-cache.ts b/backend/src/api/disk-cache.ts index 220d22b8e..0264fe1a3 100644 --- a/backend/src/api/disk-cache.ts +++ b/backend/src/api/disk-cache.ts @@ -52,7 +52,7 @@ class DiskCache { const mempool = memPool.getMempool(); const mempoolArray: TransactionExtended[] = []; for (const tx in mempool) { - if (mempool[tx] && !mempool[tx].deleteAfter) { + if (mempool[tx]) { mempoolArray.push(mempool[tx]); } } diff --git a/backend/src/api/mempool-blocks.ts b/backend/src/api/mempool-blocks.ts index 49fda543b..62717ed7e 100644 --- a/backend/src/api/mempool-blocks.ts +++ b/backend/src/api/mempool-blocks.ts @@ -1,5 +1,5 @@ import logger from '../logger'; -import { MempoolBlock, TransactionExtended, ThreadTransaction, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor } from '../mempool.interfaces'; +import { MempoolBlock, TransactionExtended, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor, CompactThreadTransaction } from '../mempool.interfaces'; import { Common } from './common'; import config from '../config'; import { Worker } from 'worker_threads'; @@ -10,6 +10,9 @@ class MempoolBlocks { private mempoolBlockDeltas: MempoolBlockDelta[] = []; private txSelectionWorker: Worker | null = null; + private nextUid: number = 1; + private uidMap: Map = new Map(); // map short numerical uids to full txids + constructor() {} public getMempoolBlocks(): MempoolBlock[] { @@ -101,8 +104,12 @@ class MempoolBlocks { private calculateMempoolBlocks(transactionsSorted: TransactionExtended[]): MempoolBlockWithTransactions[] { const mempoolBlocks: MempoolBlockWithTransactions[] = []; + let blockSize = 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[] = []; transactionsSorted.forEach((tx) => { if (blockWeight + tx.weight <= config.MEMPOOL.BLOCK_WEIGHT_UNITS @@ -113,9 +120,14 @@ class MempoolBlocks { }; blockWeight += tx.weight; blockVsize += tx.vsize; - transactions.push(tx); + blockSize += tx.size; + blockFees += tx.fee; + if (blockVsize <= sizeLimit) { + transactions.push(tx); + } + transactionIds.push(tx.txid); } else { - mempoolBlocks.push(this.dataToMempoolBlocks(transactions)); + mempoolBlocks.push(this.dataToMempoolBlocks(transactionIds, transactions, blockSize, blockWeight, blockFees)); blockVsize = 0; tx.position = { block: mempoolBlocks.length, @@ -123,11 +135,14 @@ class MempoolBlocks { }; blockVsize += tx.vsize; blockWeight = tx.weight; + blockSize = tx.size; + blockFees = tx.fee; + transactionIds = [tx.txid]; transactions = [tx]; } }); if (transactions.length) { - mempoolBlocks.push(this.dataToMempoolBlocks(transactions)); + mempoolBlocks.push(this.dataToMempoolBlocks(transactionIds, transactions, blockSize, blockWeight, blockFees)); } return mempoolBlocks; @@ -175,18 +190,28 @@ class MempoolBlocks { } public async $makeBlockTemplates(newMempool: { [txid: string]: TransactionExtended }, saveResults: boolean = false): Promise { + 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 // to reduce the overhead of passing this data to the worker thread - const strippedMempool: { [txid: string]: ThreadTransaction } = {}; - Object.values(newMempool).filter(tx => !tx.deleteAfter).forEach(entry => { - strippedMempool[entry.txid] = { - txid: entry.txid, - fee: entry.fee, - weight: entry.weight, - feePerVsize: entry.fee / (entry.weight / 4), - effectiveFeePerVsize: entry.fee / (entry.weight / 4), - vin: entry.vin.map(v => v.txid), - }; + const strippedMempool: Map = new Map(); + Object.values(newMempool).forEach(entry => { + if (entry.uid != null) { + strippedMempool.set(entry.uid, { + uid: entry.uid, + fee: entry.fee, + weight: entry.weight, + feePerVsize: entry.fee / (entry.weight / 4), + 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 @@ -205,7 +230,7 @@ class MempoolBlocks { // run the block construction algorithm in a separate thread, and wait for a result let threadErrorListener; try { - const workerResultPromise = new Promise<{ blocks: ThreadTransaction[][], clusters: { [root: string]: string[] } }>((resolve, reject) => { + const workerResultPromise = new Promise<{ blocks: number[][], rates: Map, clusters: Map }>((resolve, reject) => { threadErrorListener = reject; this.txSelectionWorker?.once('message', (result): void => { resolve(result); @@ -213,131 +238,151 @@ class MempoolBlocks { this.txSelectionWorker?.once('error', reject); }); this.txSelectionWorker.postMessage({ type: 'set', mempool: strippedMempool }); - let { blocks, clusters } = 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`); - } + const { blocks, rates, clusters } = this.convertResultTxids(await workerResultPromise); // clean up thread error listener 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) { logger.err('makeBlockTemplates failed. ' + (e instanceof Error ? e.message : e)); } return this.mempoolBlocks; } - public async $updateBlockTemplates(newMempool: { [txid: string]: TransactionExtended }, added: TransactionExtended[], removed: string[], saveResults: boolean = false): Promise { + public async $updateBlockTemplates(newMempool: { [txid: string]: TransactionExtended }, added: TransactionExtended[], removed: TransactionExtended[], saveResults: boolean = false): Promise { if (!this.txSelectionWorker) { // need to reset the worker await this.$makeBlockTemplates(newMempool, saveResults); 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 // 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 { - txid: entry.txid, + uid: entry.uid || 0, fee: entry.fee, weight: entry.weight, feePerVsize: entry.fee / (entry.weight / 4), - effectiveFeePerVsize: 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[], }; }); // run the block construction algorithm in a separate thread, and wait for a result let threadErrorListener; try { - const workerResultPromise = new Promise<{ blocks: ThreadTransaction[][], clusters: { [root: string]: string[] } }>((resolve, reject) => { + const workerResultPromise = new Promise<{ blocks: number[][], rates: Map, clusters: Map }>((resolve, reject) => { threadErrorListener = reject; this.txSelectionWorker?.once('message', (result): void => { resolve(result); }); this.txSelectionWorker?.once('error', reject); }); - this.txSelectionWorker.postMessage({ type: 'update', added: addedStripped, removed }); - let { blocks, clusters } = 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 updateBlockTemplates`); - } + this.txSelectionWorker.postMessage({ type: 'update', added: addedStripped, removed: removedUids }); + const { blocks, rates, clusters } = this.convertResultTxids(await workerResultPromise); + + this.removeUids(removedUids); // clean up thread error listener 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) { 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[] { + for (const txid of Object.keys(rates)) { + if (txid in mempool) { + mempool[txid].effectiveFeePerVsize = rates[txid]; + } + } + + const readyBlocks: { transactionIds, transactions, totalSize, totalWeight, totalFees }[] = []; + const sizeLimit = (config.MEMPOOL.BLOCK_WEIGHT_UNITS / 4) * 1.2; // update this thread's mempool with the results - blocks.forEach((block, blockIndex) => { - let runningVsize = 0; - block.forEach(tx => { - if (tx.txid && tx.txid in mempool) { + 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 - mempool[tx.txid].position = { + mempoolTx.position = { block: blockIndex, - vsize: runningVsize + (mempool[tx.txid].vsize / 2), + vsize: totalVsize + (mempoolTx.vsize / 2), }; - runningVsize += mempool[tx.txid].vsize; + mempoolTx.cpfpChecked = true; - if (tx.effectiveFeePerVsize != null) { - mempool[tx.txid].effectiveFeePerVsize = tx.effectiveFeePerVsize; + totalSize += mempoolTx.size; + totalVsize += mempoolTx.vsize; + totalWeight += mempoolTx.weight; + totalFees += mempoolTx.fee; + + if (totalVsize <= sizeLimit) { + transactions.push(mempoolTx); } - 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'); } + } + readyBlocks.push({ + transactionIds: block, + transactions, + totalSize, + totalWeight, + totalFees }); - }); + } - // unpack the condensed blocks into proper mempool blocks - const mempoolBlocks = blocks.map((transactions) => { - return this.dataToMempoolBlocks(transactions.map(tx => { - return mempool[tx.txid] || null; - }).filter(tx => !!tx)); - }); + 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 => this.dataToMempoolBlocks(b.transactionIds, b.transactions, b.totalSize, b.totalWeight, b.totalFees)); if (saveResults) { const deltas = this.calculateMempoolDeltas(this.mempoolBlocks, mempoolBlocks); @@ -348,29 +393,69 @@ class MempoolBlocks { return mempoolBlocks; } - private dataToMempoolBlocks(transactions: TransactionExtended[]): MempoolBlockWithTransactions { - let totalSize = 0; - let totalWeight = 0; - 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); - } - }); + private dataToMempoolBlocks(transactionIds: string[], transactions: TransactionExtended[], totalSize: number, totalWeight: number, totalFees: number): MempoolBlockWithTransactions { const feeStats = Common.calcEffectiveFeeStatistics(transactions); return { blockSize: totalSize, - blockVSize: totalWeight / 4, - nTx: transactions.length, - totalFees: transactions.reduce((acc, cur) => acc + cur.fee, 0), + blockVSize: (totalWeight / 4), // fractional vsize to avoid rounding errors + nTx: transactionIds.length, + totalFees: totalFees, medianFee: feeStats.medianFee, // Common.percentile(transactions.map((tx) => tx.effectiveFeePerVsize), config.MEMPOOL.RECOMMENDED_FEE_PERCENTILE), feeRange: feeStats.feeRange, //Common.getFeesInRange(transactions, rangeLength), - transactionIds: transactions.map((tx) => tx.txid), - transactions: fitTransactions.map((tx) => Common.stripTransaction(tx)), + transactionIds: transactionIds, + 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, clusters: Map}) + : { 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(); diff --git a/backend/src/api/mempool.ts b/backend/src/api/mempool.ts index d476d6bca..5746ca6d4 100644 --- a/backend/src/api/mempool.ts +++ b/backend/src/api/mempool.ts @@ -11,8 +11,6 @@ import bitcoinSecondClient from './bitcoin/bitcoin-second-client'; import rbfCache from './rbf-cache'; class Mempool { - private static WEBSOCKET_REFRESH_RATE_MS = 10000; - private static LAZY_DELETE_AFTER_SECONDS = 30; private inSync: boolean = false; private mempoolCacheDelta: number = -1; private mempoolCache: { [txId: string]: TransactionExtended } = {}; @@ -35,7 +33,6 @@ class Mempool { private SAMPLE_TIME = 10000; // In ms private timer = new Date().getTime(); private missingTxCount = 0; - private mainLoopTimeout: number = 120000; constructor() { @@ -119,7 +116,7 @@ class Mempool { return txTimes; } - public async $updateMempool(): Promise { + public async $updateMempool(transactions: string[]): Promise { logger.debug(`Updating mempool...`); // warn if this run stalls the main loop for more than 2 minutes @@ -128,7 +125,6 @@ class Mempool { const start = new Date().getTime(); let hasChange: boolean = false; const currentMempoolSize = Object.keys(this.mempoolCache).length; - const transactions = await bitcoinApi.$getRawMempool(); this.updateTimerProgress(timer, 'got raw mempool'); const diff = transactions.length - currentMempoolSize; const newTransactions: TransactionExtended[] = []; @@ -136,7 +132,7 @@ class Mempool { this.mempoolCacheDelta = Math.abs(diff); 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 @@ -149,6 +145,7 @@ class Mempool { } }; + let loggerTimer = new Date().getTime() / 1000; for (const txid of transactions) { if (!this.mempoolCache[txid]) { try { @@ -171,9 +168,12 @@ class Mempool { logger.debug(`Error finding transaction '${txid}' in the mempool: ` + (e instanceof Error ? e.message : e)); } } - - if ((new Date().getTime()) - start > Mempool.WEBSOCKET_REFRESH_RATE_MS) { - break; + const elapsedSeconds = Math.round((new Date().getTime() / 1000) - loggerTimer); + if (elapsedSeconds > 4) { + 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; } } @@ -207,13 +207,15 @@ class Mempool { const transactionsObject = {}; transactions.forEach((txId) => transactionsObject[txId] = true); - // Flag transactions for lazy deletion + // Delete evicted transactions from mempool for (const tx in this.mempoolCache) { - if (!transactionsObject[tx] && !this.mempoolCache[tx].deleteAfter) { + if (!transactionsObject[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)); @@ -270,10 +272,6 @@ class Mempool { if (this.mempoolCache[rbfTransaction] && rbfTransactions[rbfTransaction]?.length) { // Store replaced transactions rbfCache.add(rbfTransactions[rbfTransaction], this.mempoolCache[rbfTransaction]); - // Erase the replaced transactions from the local mempool - for (const replaced of rbfTransactions[rbfTransaction]) { - delete this.mempoolCache[replaced.txid]; - } } } } @@ -291,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() { if (config.MEMPOOL.USE_SECOND_NODE_FOR_MINFEE) { return Promise.all([ diff --git a/backend/src/api/rbf-cache.ts b/backend/src/api/rbf-cache.ts index d75fb0ba8..6c5afc146 100644 --- a/backend/src/api/rbf-cache.ts +++ b/backend/src/api/rbf-cache.ts @@ -163,7 +163,7 @@ class RbfCache { } // flag a transaction as removed from the mempool - public evict(txid, fast: boolean = false): void { + public evict(txid: string, fast: boolean = false): void { 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 } diff --git a/backend/src/api/tx-selection-worker.ts b/backend/src/api/tx-selection-worker.ts index c035099a3..b22f42823 100644 --- a/backend/src/api/tx-selection-worker.ts +++ b/backend/src/api/tx-selection-worker.ts @@ -1,10 +1,10 @@ import config from '../config'; import logger from '../logger'; -import { ThreadTransaction, MempoolBlockWithTransactions, AuditTransaction } from '../mempool.interfaces'; +import { CompactThreadTransaction, AuditTransaction } from '../mempool.interfaces'; import { PairingHeap } from '../utils/pairing-heap'; import { parentPort } from 'worker_threads'; -let mempool: { [txid: string]: ThreadTransaction } = {}; +let mempool: Map = new Map(); if (parentPort) { parentPort.on('message', (params) => { @@ -12,18 +12,18 @@ if (parentPort) { mempool = params.mempool; } else if (params.type === 'update') { params.added.forEach(tx => { - mempool[tx.txid] = tx; + mempool.set(tx.uid, tx); }); - params.removed.forEach(txid => { - delete mempool[txid]; + params.removed.forEach(uid => { + mempool.delete(uid); }); } - const { blocks, clusters } = makeBlockTemplates(mempool); + const { blocks, rates, clusters } = makeBlockTemplates(mempool); // return the result to main thread. if (parentPort) { - parentPort.postMessage({ blocks, clusters }); + parentPort.postMessage({ blocks, rates, clusters }); } }); } @@ -32,26 +32,25 @@ if (parentPort) { * 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) */ -function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction }) - : { blocks: ThreadTransaction[][], clusters: { [root: string]: string[] } } { +function makeBlockTemplates(mempool: Map) + : { blocks: number[][], rates: Map, clusters: Map } { const start = Date.now(); - const auditPool: { [txid: string]: AuditTransaction } = {}; + const auditPool: Map = new Map(); const mempoolArray: AuditTransaction[] = []; - const restOfArray: ThreadTransaction[] = []; - const cpfpClusters: { [root: string]: string[] } = {}; + const cpfpClusters: Map = new Map(); - // grab the top feerate txs up to maxWeight - Object.values(mempool).sort((a, b) => b.feePerVsize - a.feePerVsize).forEach(tx => { + mempool.forEach(tx => { + tx.dirty = false; // initializing everything up front helps V8 optimize property access later - auditPool[tx.txid] = { - txid: tx.txid, + auditPool.set(tx.uid, { + uid: tx.uid, fee: tx.fee, weight: tx.weight, feePerVsize: tx.feePerVsize, effectiveFeePerVsize: tx.feePerVsize, - vin: tx.vin, + inputs: tx.inputs || [], relativesSet: false, - ancestorMap: new Map(), + ancestorMap: new Map(), children: new Set(), ancestorFee: 0, ancestorWeight: 0, @@ -59,8 +58,8 @@ function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction }) used: false, modified: false, modifiedNode: null, - }; - mempoolArray.push(auditPool[tx.txid]); + }); + mempoolArray.push(auditPool.get(tx.uid) as AuditTransaction); }); // Build relatives graph & calculate ancestor scores @@ -73,8 +72,8 @@ function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction }) // Sort by descending ancestor score mempoolArray.sort((a, b) => { if (b.score === a.score) { - // tie-break by lexicographic txid order for stability - return a.txid < b.txid ? -1 : 1; + // tie-break by uid for stability + return a.uid < b.uid ? -1 : 1; } else { return (b.score || 0) - (a.score || 0); } @@ -82,14 +81,13 @@ function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction }) // Build blocks by greedily choosing the highest feerate package // (i.e. the package rooted in the transaction with the best ancestor score) - const blocks: ThreadTransaction[][] = []; + const blocks: number[][] = []; let blockWeight = 4000; - let blockSize = 0; let transactions: AuditTransaction[] = []; const modified: PairingHeap = new PairingHeap((a, b): boolean => { if (a.score === b.score) { - // tie-break by lexicographic txid order for stability - return a.txid > b.txid; + // tie-break by uid for stability + return a.uid > b.uid; } else { return (a.score || 0) > (b.score || 0); } @@ -126,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]; let isCluster = false; if (sortedTxSet.length > 1) { - cpfpClusters[nextTx.txid] = sortedTxSet.map(tx => tx.txid); + cpfpClusters.set(nextTx.uid, sortedTxSet.map(tx => tx.uid)); isCluster = true; } const effectiveFeeRate = nextTx.ancestorFee / (nextTx.ancestorWeight / 4); const used: AuditTransaction[] = []; while (sortedTxSet.length) { const ancestor = sortedTxSet.pop(); - const mempoolTx = mempool[ancestor.txid]; + const mempoolTx = mempool.get(ancestor.uid); + if (!mempoolTx) { + continue; + } ancestor.used = true; - ancestor.usedBy = nextTx.txid; + ancestor.usedBy = nextTx.uid; // update original copy of this tx with effective fee rate & relatives data - mempoolTx.effectiveFeePerVsize = effectiveFeeRate; - if (isCluster) { - mempoolTx.cpfpRoot = nextTx.txid; + if (mempoolTx.effectiveFeePerVsize !== effectiveFeeRate) { + mempoolTx.effectiveFeePerVsize = effectiveFeeRate; + mempoolTx.dirty = true; + } + if (mempoolTx.cpfpRoot !== nextTx.uid) { + mempoolTx.cpfpRoot = isCluster ? nextTx.uid : null; + mempoolTx.dirty; } mempoolTx.cpfpChecked = true; transactions.push(ancestor); - blockSize += ancestor.size; blockWeight += ancestor.weight; used.push(ancestor); } @@ -169,11 +173,10 @@ function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction }) if ((exceededPackageTries || queueEmpty) && blocks.length < 7) { // construct this block if (transactions.length) { - blocks.push(transactions.map(t => mempool[t.txid])); + blocks.push(transactions.map(t => t.uid)); } // reset for the next block transactions = []; - blockSize = 0; blockWeight = 4000; // 'overflow' packages didn't fit in this block, but are valid candidates for the next @@ -194,24 +197,32 @@ function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction }) } // add the final unbounded block if it contains any transactions 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(); + for (const tx of mempool.values()) { + if (tx?.dirty) { + rates.set(tx.uid, tx.effectiveFeePerVsize || tx.feePerVsize); + } } const end = Date.now(); const time = end - start; logger.debug('Mempool templates calculated in ' + time / 1000 + ' seconds'); - return { blocks, clusters: cpfpClusters }; + return { blocks, rates, clusters: cpfpClusters }; } // traverse in-mempool ancestors // recursion unavoidable, but should be limited to depth < 25 by mempool policy function setRelatives( tx: AuditTransaction, - mempool: { [txid: string]: AuditTransaction }, + mempool: Map, ): void { - for (const parent of tx.vin) { - const parentTx = mempool[parent]; + for (const parent of tx.inputs) { + const parentTx = mempool.get(parent); if (parentTx && !tx.ancestorMap?.has(parent)) { tx.ancestorMap.set(parent, parentTx); parentTx.children.add(tx); @@ -220,7 +231,7 @@ function setRelatives( setRelatives(parentTx, mempool); } parentTx.ancestorMap.forEach((ancestor) => { - tx.ancestorMap.set(ancestor.txid, ancestor); + tx.ancestorMap.set(ancestor.uid, ancestor); }); } }; @@ -238,7 +249,7 @@ function setRelatives( // avoids recursion to limit call stack depth function updateDescendants( rootTx: AuditTransaction, - mempool: { [txid: string]: AuditTransaction }, + mempool: Map, modified: PairingHeap, ): void { const descendantSet: Set = new Set(); @@ -254,9 +265,9 @@ function updateDescendants( }); while (descendants.length) { 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 - descendantTx.ancestorMap.delete(rootTx.txid); + descendantTx.ancestorMap.delete(rootTx.uid); descendantTx.ancestorFee -= rootTx.fee; descendantTx.ancestorWeight -= rootTx.weight; tmpScore = descendantTx.score; diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index f28f284c7..bc0fb8ea5 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -282,7 +282,7 @@ class WebsocketHandler { this.printLogs(); 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 { mempoolBlocks.updateMempoolBlocks(newMempool, true); } @@ -301,6 +301,9 @@ class WebsocketHandler { rbfReplacements = rbfCache.getRbfTrees(false); fullRbfReplacements = rbfCache.getRbfTrees(true); } + for (const deletedTx of deletedTransactions) { + rbfCache.evict(deletedTx.txid); + } const recommendedFees = feeApi.getRecommendedFee(); this.wss.clients.forEach(async (client) => { diff --git a/backend/src/index.ts b/backend/src/index.ts index 3887aac2c..9f543d644 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -2,6 +2,7 @@ import express from 'express'; import { Application, Request, Response, NextFunction } from 'express'; import * as http from 'http'; import * as WebSocket from 'ws'; +import bitcoinApi from './api/bitcoin/bitcoin-api-factory'; import cluster from 'cluster'; import DB from './database'; import config from './config'; @@ -179,12 +180,15 @@ class Server { logger.debug(msg); } } - await blocks.$updateBlocks(); - memPool.deleteExpiredTransactions(); - await memPool.$updateMempool(); + const newMempool = await bitcoinApi.$getRawMempool(); + const numHandledBlocks = await blocks.$updateBlocks(); + if (numHandledBlocks === 0) { + await memPool.$updateMempool(newMempool); + } 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; } catch (e: any) { this.backendRetryCount++; diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index 25cd8b95d..ab4c4cd25 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -80,22 +80,22 @@ export interface TransactionExtended extends IEsploraApi.Transaction { descendants?: Ancestor[]; bestDescendant?: BestDescendant | null; cpfpChecked?: boolean; - deleteAfter?: number; position?: { block: number, vsize: number, }; + uid?: number; } export interface AuditTransaction { - txid: string; + uid: number; fee: number; weight: number; feePerVsize: number; effectiveFeePerVsize: number; - vin: string[]; + inputs: number[]; relativesSet: boolean; - ancestorMap: Map; + ancestorMap: Map; children: Set; ancestorFee: number; ancestorWeight: number; @@ -105,13 +105,25 @@ export interface AuditTransaction { modifiedNode: HeapNode; } +export interface CompactThreadTransaction { + uid: number; + fee: number; + weight: number; + feePerVsize: number; + effectiveFeePerVsize?: number; + inputs: number[]; + cpfpRoot?: string; + cpfpChecked?: boolean; + dirty?: boolean; +} + export interface ThreadTransaction { txid: string; fee: number; weight: number; feePerVsize: number; effectiveFeePerVsize?: number; - vin: string[]; + inputs: number[]; cpfpRoot?: string; cpfpChecked?: boolean; } diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 06334c5b5..0fe496d3e 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -4,6 +4,8 @@ import { AppPreloadingStrategy } from './app.preloading-strategy' import { StartComponent } from './components/start/start.component'; import { TransactionComponent } from './components/transaction/transaction.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 { MasterPageComponent } from './components/master-page/master-page.component'; import { AboutComponent } from './components/about/about.component'; @@ -355,6 +357,14 @@ let routes: Routes = [ }, ], }, + { + path: 'clock-mined', + component: ClockMinedComponent, + }, + { + path: 'clock-mempool', + component: ClockMempoolComponent, + }, { path: 'status', data: { networks: ['bitcoin', 'liquid'] }, diff --git a/frontend/src/app/app.constants.ts b/frontend/src/app/app.constants.ts index 8a091706a..f510c6480 100644 --- a/frontend/src/app/app.constants.ts +++ b/frontend/src/app/app.constants.ts @@ -29,6 +29,14 @@ export const mempoolFeeColors = [ 'ba3243', 'b92b48', 'b9254b', + 'b8214d', + 'b71d4f', + 'b61951', + 'b41453', + 'b30e55', + 'b10857', + 'b00259', + 'ae005b', ]; export const chartColors = [ @@ -69,6 +77,7 @@ export const chartColors = [ "#3E2723", "#212121", "#263238", + "#801313", ]; export const poolsColor = { diff --git a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts index 940939470..15e41f1a7 100644 --- a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts +++ b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts @@ -23,6 +23,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On @Input() unavailable: boolean = false; @Input() auditHighlighting: boolean = false; @Input() blockConversion: Price; + @Input() pixelAlign: boolean = false; @Output() txClickEvent = new EventEmitter<{ tx: TransactionStripped, keyModifier: boolean}>(); @Output() txHoverEvent = new EventEmitter(); @Output() readyEvent = new EventEmitter(); @@ -201,7 +202,8 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On this.start(); } else { 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(); } } diff --git a/frontend/src/app/components/block-overview-graph/block-scene.ts b/frontend/src/app/components/block-overview-graph/block-scene.ts index 7fb0a1e99..0cd5c9391 100644 --- a/frontend/src/app/components/block-overview-graph/block-scene.ts +++ b/frontend/src/app/components/block-overview-graph/block-scene.ts @@ -15,6 +15,7 @@ export default class BlockScene { gridWidth: number; gridHeight: number; gridSize: number; + pixelAlign: boolean; vbytesPerUnit: number; unitPadding: number; unitWidth: number; @@ -23,19 +24,24 @@ export default class BlockScene { animateUntil = 0; 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, - 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 { this.width = width; this.height = height; this.gridSize = this.width / this.gridWidth; - this.unitPadding = width / 500; - this.unitWidth = this.gridSize - (this.unitPadding * 2); + if (this.pixelAlign) { + 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; if (this.initialised && this.scene) { @@ -209,14 +215,15 @@ export default class BlockScene { 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, - orientation: string, flip: boolean, vertexArray: FastVertexArray, highlighting: boolean } + orientation: string, flip: boolean, vertexArray: FastVertexArray, highlighting: boolean, pixelAlign: boolean } ): void { this.orientation = orientation; this.flip = flip; this.vertexArray = vertexArray; this.highlightingEnabled = highlighting; + this.pixelAlign = pixelAlign; this.scene = { count: 0, @@ -342,7 +349,12 @@ export default class BlockScene { private gridToScreen(position: Square | void): Square { if (position) { 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, // so we rotate and/or flip the y axis to match the target configuration. diff --git a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html index 373605667..8ea5acef6 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.html @@ -1,53 +1,61 @@ -
+
  -
+
-
- ~{{ block?.extras?.medianFee | number:feeRounding }} sat/vB -
- -
-   + +
+ ~{{ block?.extras?.medianFee | number:feeRounding }} sat/vB
- -
- {{ block?.extras?.feeRange?.[0] | number:feeRounding }} - {{ - block?.extras?.feeRange[block?.extras?.feeRange?.length - 1] | number:feeRounding }} sat/vB -
- -
-   + +
+   +
+
+
+ {{ block?.extras?.feeRange?.[0] | number:feeRounding }} - {{ + block?.extras?.feeRange[block?.extras?.feeRange?.length - 1] | number:feeRounding }} sat/vB
- -
- -
-
-
- - {{ i }} transaction - {{ i }} transactions -
-
-
+ +
+   +
+
+
+ +
+
+
+ + {{ i }} transaction + {{ i }} transactions +
+
+
+
-
+
+ [ngStyle]="emptyBlockStyles[i]" [class.offscreen]="!static && count && i >= count">
diff --git a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss index 5db452470..795e1f4df 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.scss @@ -1,6 +1,6 @@ .bitcoin-block { - width: 125px; - height: 125px; + width: var(--block-size); + height: var(--block-size); } .blockLink { @@ -22,7 +22,11 @@ .mined-block { position: absolute; 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 { @@ -35,9 +39,11 @@ } .blocks-container { + --block-size: 125px; + --block-offset: calc(0.32 * var(--block-size)); position: absolute; top: 0px; - left: 40px; + left: var(--block-offset); } .block-body { @@ -77,11 +83,11 @@ .bitcoin-block::after { content: ''; - width: 125px; - height: 24px; + width: var(--block-size); + height: calc(0.192 * var(--block-size)); position:absolute; - top: -24px; - left: -20px; + top: calc(-0.192 * var(--block-size)); + left: calc(-0.16 * var(--block-size)); background-color: #232838; transform:skew(40deg); transform-origin:top; @@ -89,11 +95,11 @@ .bitcoin-block::before { content: ''; - width: 20px; - height: 125px; + width: calc(0.16 * var(--block-size)); + height: var(--block-size); position: absolute; - top: -12px; - left: -20px; + top: calc(-0.096 * var(--block-size)); + left: calc(-0.16 * var(--block-size)); background-color: #191c27; transform: skewY(50deg); @@ -168,4 +174,16 @@ .bitcoin-block { 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; } \ No newline at end of file diff --git a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts index 9c0049c4d..65c949b4d 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts @@ -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() loadingTip: boolean = false; @Input() connected: boolean = true; + @Input() minimal: boolean = false; + @Input() blockWidth: number = 125; + @Input() spotlight: number = 0; specialBlocks = specialBlocks; network = ''; @@ -51,6 +54,10 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { timeLtrSubscription: Subscription; timeLtr: boolean; + blockOffset: number = 155; + dividerBlockOffset: number = 205; + blockPadding: number = 30; + gradientColors = { '': ['#9339f4', '#105fb0'], bisq: ['#9339f4', '#105fb0'], @@ -118,7 +125,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { this.blockStyles = []; 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(() => { this.blockStyles = []; 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 { + 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) { const animateSlide = changes.height && (changes.height.currentValue === changes.height.previousValue + 1); this.updateStaticBlocks(animateSlide); @@ -191,14 +205,14 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { } this.arrowVisible = true; if (newBlockFromLeft) { - this.arrowLeftPx = blockindex * 155 + 30 - 205; + this.arrowLeftPx = blockindex * this.blockOffset + this.blockPadding - this.dividerBlockOffset; setTimeout(() => { this.arrowTransition = '2s'; - this.arrowLeftPx = blockindex * 155 + 30; + this.arrowLeftPx = blockindex * this.blockOffset + this.blockPadding; this.cd.markForCheck(); }, 50); } else { - this.arrowLeftPx = blockindex * 155 + 30; + this.arrowLeftPx = blockindex * this.blockOffset + this.blockPadding; if (!animate) { setTimeout(() => { this.arrowTransition = '2s'; @@ -245,7 +259,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { } this.blocks = this.blocks.slice(0, this.count); 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(); if (animateSlide) { // animate blocks slide right @@ -287,7 +301,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { } return { - left: addLeft + 155 * index + 'px', + left: addLeft + this.blockOffset * index + 'px', background: `repeating-linear-gradient( #2d3348, #2d3348 ${greenBackgroundHeight}%, @@ -309,7 +323,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { const addLeft = animateEnterFrom || 0; return { - left: addLeft + (155 * index) + 'px', + left: addLeft + (this.blockOffset * index) + 'px', background: "#2d3348", }; } @@ -317,7 +331,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { getStyleForPlaceholderBlock(index: number, animateEnterFrom: number = 0) { const addLeft = animateEnterFrom || 0; 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; return { - left: addLeft + 155 * this.emptyBlocks.indexOf(block) + 'px', + left: addLeft + this.blockOffset * this.emptyBlocks.indexOf(block) + 'px', background: "#2d3348", }; } diff --git a/frontend/src/app/components/clock-face/clock-face.component.html b/frontend/src/app/components/clock-face/clock-face.component.html new file mode 100644 index 000000000..b3d478ebb --- /dev/null +++ b/frontend/src/app/components/clock-face/clock-face.component.html @@ -0,0 +1,42 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/frontend/src/app/components/clock-face/clock-face.component.scss b/frontend/src/app/components/clock-face/clock-face.component.scss new file mode 100644 index 000000000..1ca2ce914 --- /dev/null +++ b/frontend/src/app/components/clock-face/clock-face.component.scss @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/clock-face/clock-face.component.ts b/frontend/src/app/components/clock-face/clock-face.component.ts new file mode 100644 index 000000000..01e439e8e --- /dev/null +++ b/frontend/src/app/components/clock-face/clock-face.component.ts @@ -0,0 +1,148 @@ +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 websocketService: WebsocketService, + 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; + } +} diff --git a/frontend/src/app/components/clock/clock-mempool.component.html b/frontend/src/app/components/clock/clock-mempool.component.html new file mode 100644 index 000000000..a8620a212 --- /dev/null +++ b/frontend/src/app/components/clock/clock-mempool.component.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/app/components/clock/clock-mempool.component.ts b/frontend/src/app/components/clock/clock-mempool.component.ts new file mode 100644 index 000000000..7e99cc08b --- /dev/null +++ b/frontend/src/app/components/clock/clock-mempool.component.ts @@ -0,0 +1,7 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-clock-mempool', + templateUrl: './clock-mempool.component.html', +}) +export class ClockMempoolComponent {} diff --git a/frontend/src/app/components/clock/clock-mined.component.html b/frontend/src/app/components/clock/clock-mined.component.html new file mode 100644 index 000000000..a3bebd4bd --- /dev/null +++ b/frontend/src/app/components/clock/clock-mined.component.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/app/components/clock/clock-mined.component.ts b/frontend/src/app/components/clock/clock-mined.component.ts new file mode 100644 index 000000000..b26815ac6 --- /dev/null +++ b/frontend/src/app/components/clock/clock-mined.component.ts @@ -0,0 +1,7 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-clock-mined', + templateUrl: './clock-mined.component.html', +}) +export class ClockMinedComponent {} diff --git a/frontend/src/app/components/clock/clock.component.html b/frontend/src/app/components/clock/clock.component.html new file mode 100644 index 000000000..914450a79 --- /dev/null +++ b/frontend/src/app/components/clock/clock.component.html @@ -0,0 +1,67 @@ +
+
+
+ +
+
+
+ +
+ + +
+
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+

{{ block.height }}

+
+
+
+
+
+ +
+

fiat price

+

+ +

+
+
+

priority rate

+

{{ recommendedFees.fastestFee + 300 }} sat/vB

+
+
+

+

block size

+
+
+

+ + {{ i }} transaction + {{ i }} transactions +

+
+ +
+

+

memory usage

+
+
+

{{ mempoolInfo.size | number }}

+

unconfirmed

+
+
+
+
\ No newline at end of file diff --git a/frontend/src/app/components/clock/clock.component.scss b/frontend/src/app/components/clock/clock.component.scss new file mode 100644 index 000000000..20baf02ee --- /dev/null +++ b/frontend/src/app/components/clock/clock.component.scss @@ -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);} +} \ No newline at end of file diff --git a/frontend/src/app/components/clock/clock.component.ts b/frontend/src/app/components/clock/clock.component.ts new file mode 100644 index 000000000..dea2de4c8 --- /dev/null +++ b/frontend/src/app/components/clock/clock.component.ts @@ -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; + mempoolInfo$: Observable; + 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']); + + 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(); + } +} diff --git a/frontend/src/app/components/clockchain/clockchain.component.html b/frontend/src/app/components/clockchain/clockchain.component.html new file mode 100644 index 000000000..169de58d4 --- /dev/null +++ b/frontend/src/app/components/clockchain/clockchain.component.html @@ -0,0 +1,28 @@ +
+
+ +
+ + +
+
+ + + +
+
+
+
diff --git a/frontend/src/app/components/clockchain/clockchain.component.scss b/frontend/src/app/components/clockchain/clockchain.component.scss new file mode 100644 index 000000000..6ffc144e9 --- /dev/null +++ b/frontend/src/app/components/clockchain/clockchain.component.scss @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/clockchain/clockchain.component.ts b/frontend/src/app/components/clockchain/clockchain.component.ts new file mode 100644 index 000000000..303fbf8e2 --- /dev/null +++ b/frontend/src/app/components/clockchain/clockchain.component.ts @@ -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); + } +} diff --git a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.html b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.html index 3cb4ff3e8..37c82afad 100644 --- a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.html +++ b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.html @@ -5,5 +5,6 @@ [blockLimit]="stateService.blockVSize" [orientation]="timeLtr ? 'right' : 'left'" [flip]="true" + [pixelAlign]="pixelAlign" (txClickEvent)="onTxClick($event)" > diff --git a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts index 30632a862..540046e13 100644 --- a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts +++ b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts @@ -16,6 +16,7 @@ import { Router } from '@angular/router'; }) export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChanges, AfterViewInit { @Input() index: number; + @Input() pixelAlign: boolean = false; @Output() txPreviewEvent = new EventEmitter(); @ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent; diff --git a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.html b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.html index 6e397dee7..11dc28ad9 100644 --- a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.html +++ b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.html @@ -1,40 +1,47 @@ - -
+ +
-
+
+
 
-
- ~{{ projectedBlock.medianFee | number:feeRounding }} sat/vB -
-
- {{ projectedBlock.feeRange[0] | number:feeRounding }} - {{ projectedBlock.feeRange[projectedBlock.feeRange.length - 1] | number:feeRounding }} sat/vB -
-
- -
-
-
- - {{ i }} transaction - {{ i }} transactions -
-
- - - - - - -
- -
- () - {{ i }} blocks + +
+ ~{{ projectedBlock.medianFee | number:feeRounding }} sat/vB
- +
+ {{ projectedBlock.feeRange[0] | number:feeRounding }} - {{ projectedBlock.feeRange[projectedBlock.feeRange.length - 1] | number:feeRounding }} sat/vB +
+
+ +
+
+
+ + {{ i }} transaction + {{ i }} transactions +
+
+ + + + + + +
+ +
+ () + {{ i }} blocks +
+
+
@@ -45,10 +52,10 @@ -
+
-
+
diff --git a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.scss b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.scss index 565d4b302..40f43a015 100644 --- a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.scss +++ b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.scss @@ -1,7 +1,7 @@ .bitcoin-block { - width: 125px; - height: 125px; - transition: background 2s, right 2s, transform 1s; + width: var(--block-size); + height: var(--block-size); + transition: background 2s, right 2s, transform 1s, opacity 1s; } .block-size { @@ -14,6 +14,7 @@ top: 0px; right: 0px; left: 0px; + --block-size: 125px; } .flashing { @@ -66,11 +67,11 @@ .bitcoin-block::after { content: ''; - width: 125px; - height: 24px; + width: var(--block-size); + height: calc(0.192 * var(--block-size)); position:absolute; - top: -24px; - left: -20px; + top: calc(-0.192 * var(--block-size)); + left: calc(-0.16 * var(--block-size)); background-color: #232838; transform:skew(40deg); transform-origin:top; @@ -79,11 +80,11 @@ .bitcoin-block::before { content: ''; - width: 20px; - height: 125px; + width: calc(0.16 * var(--block-size)); + height: var(--block-size); position: absolute; - top: -12px; - left: -20px; + top: calc(-0.096 * var(--block-size)); + left: calc(-0.16 * var(--block-size)); background-color: #191c27; z-index: -1; @@ -100,6 +101,10 @@ background-color: #2d2825; } +.mempool-block.hide-block { + opacity: 0; +} + .black-background { background-color: #11131f; z-index: 100; @@ -141,7 +146,7 @@ .bitcoin-block::before { transform: skewY(-50deg); - left: 125px; + left: var(--block-size); } .block-body { transform: scaleX(-1); @@ -152,4 +157,16 @@ #arrow-up { 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; } \ No newline at end of file diff --git a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts index 91273169c..93498d535 100644 --- a/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts +++ b/frontend/src/app/components/mempool-blocks/mempool-blocks.component.ts @@ -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 { MempoolBlock } from '../../interfaces/websocket.interface'; import { StateService } from '../../services/state.service'; @@ -23,7 +23,12 @@ import { animate, style, transition, trigger } from '@angular/animations'; ])], 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; mempoolBlocks: MempoolBlock[] = []; mempoolEmptyBlocks: MempoolBlock[] = this.mountEmptyBlocks(); @@ -48,8 +53,9 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy { timeLtr: boolean; animateEntry: boolean = false; - blockWidth = 125; - blockPadding = 30; + blockOffset: number = 155; + blockPadding: number = 30; + containerOffset: number = 40; arrowVisible = false; tabHidden = false; feeRounding = '1.0-0'; @@ -218,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() { this.markBlocksSubscription.unsubscribe(); this.blockSubscription.unsubscribe(); @@ -238,17 +252,24 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy { reduceMempoolBlocksToFitScreen(blocks: MempoolBlock[]): MempoolBlock[] { 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) { const block = blocks.pop(); - const lastBlock = blocks[blocks.length - 1]; - lastBlock.blockSize += block.blockSize; - lastBlock.blockVSize += block.blockVSize; - lastBlock.nTx += block.nTx; - lastBlock.feeRange = lastBlock.feeRange.concat(block.feeRange); - lastBlock.feeRange.sort((a, b) => a - b); - lastBlock.medianFee = this.median(lastBlock.feeRange); - lastBlock.totalFees += block.totalFees; + if (!this.count) { + const lastBlock = blocks[blocks.length - 1]; + lastBlock.blockSize += block.blockSize; + lastBlock.blockVSize += block.blockVSize; + lastBlock.nTx += block.nTx; + lastBlock.feeRange = lastBlock.feeRange.concat(block.feeRange); + lastBlock.feeRange.sort((a, b) => a - b); + lastBlock.medianFee = this.median(lastBlock.feeRange); + lastBlock.totalFees += block.totalFees; + } } if (blocks.length) { blocks[blocks.length - 1].isStack = blocks[blocks.length - 1].blockVSize > this.stateService.blockVSize; @@ -294,14 +315,14 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy { }); return { - 'right': 40 + index * 155 + 'px', + 'right': this.containerOffset + index * this.blockOffset + 'px', 'background': backgroundGradients.join(',') + ')' }; } getStyleForMempoolEmptyBlock(index: number) { return { - 'right': 40 + index * 155 + 'px', + 'right': this.containerOffset + index * this.blockOffset + 'px', 'background': '#554b45', }; } diff --git a/frontend/src/app/components/mempool-graph/mempool-graph.component.ts b/frontend/src/app/components/mempool-graph/mempool-graph.component.ts index 989fa141e..cc53f425d 100644 --- a/frontend/src/app/components/mempool-graph/mempool-graph.component.ts +++ b/frontend/src/app/components/mempool-graph/mempool-graph.component.ts @@ -23,8 +23,7 @@ import { download, formatterXAxis, formatterXAxisLabel } from '../../shared/grap }) export class MempoolGraphComponent implements OnInit, OnChanges { @Input() data: any[]; - @Input() limitFee = 350; - @Input() limitFilterFee = 1; + @Input() filterSize = 100000; @Input() height: number | string = 200; @Input() top: number | string = 20; @Input() right: number | string = 10; @@ -99,16 +98,20 @@ export class MempoolGraphComponent implements OnInit, OnChanges { } generateArray(mempoolStats: OptimizedMempoolStats[]) { - const finalArray: number[][][] = []; + let finalArray: number[][][] = []; let feesArray: number[][] = []; - const limitFeesTemplate = this.template === 'advanced' ? 26 : 20; - for (let index = limitFeesTemplate; index > -1; index--) { + let maxTier = 0; + for (let index = 37; index > -1; index--) { feesArray = []; 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]); }); finalArray.push(feesArray); } + this.feeLimitIndex = maxTier; finalArray.reverse(); return finalArray; } @@ -121,7 +124,7 @@ export class MempoolGraphComponent implements OnInit, OnChanges { const newColors = []; for (let index = 0; index < series.length; index++) { const value = series[index]; - if (index >= this.feeLimitIndex) { + if (index < this.feeLimitIndex) { newColors.push(this.chartColorsOrdered[index]); seriesGraph.push({ zlevel: 0, @@ -371,17 +374,21 @@ export class MempoolGraphComponent implements OnInit, OnChanges { orderLevels() { this.feeLevelsOrdered = []; - for (let i = 0; i < feeLevels.length; i++) { - if (feeLevels[i] === this.limitFilterFee) { - this.feeLimitIndex = i; - } - if (feeLevels[i] <= this.limitFee) { + let maxIndex = Math.min(feeLevels.length, this.feeLimitIndex); + for (let i = 0; i < maxIndex; i++) { 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 { - 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); } diff --git a/frontend/src/app/components/statistics/statistics.component.html b/frontend/src/app/components/statistics/statistics.component.html index 30738f591..2133b2615 100644 --- a/frontend/src/app/components/statistics/statistics.component.html +++ b/frontend/src/app/components/statistics/statistics.component.html @@ -84,8 +84,7 @@
-
diff --git a/frontend/src/app/components/television/television.component.html b/frontend/src/app/components/television/television.component.html index 89cf8e5bb..23dd18389 100644 --- a/frontend/src/app/components/television/television.component.html +++ b/frontend/src/app/components/television/television.component.html @@ -3,7 +3,6 @@
diff --git a/frontend/src/app/graphs/graphs.module.ts b/frontend/src/app/graphs/graphs.module.ts index 4cb803888..a4e4f5bfc 100644 --- a/frontend/src/app/graphs/graphs.module.ts +++ b/frontend/src/app/graphs/graphs.module.ts @@ -14,7 +14,6 @@ import { LbtcPegsGraphComponent } from '../components/lbtc-pegs-graph/lbtc-pegs- import { GraphsComponent } from '../components/graphs/graphs.component'; import { StatisticsComponent } from '../components/statistics/statistics.component'; import { MempoolBlockComponent } from '../components/mempool-block/mempool-block.component'; -import { MempoolBlockOverviewComponent } from '../components/mempool-block-overview/mempool-block-overview.component'; import { PoolRankingComponent } from '../components/pool-ranking/pool-ranking.component'; import { PoolComponent } from '../components/pool/pool.component'; import { TelevisionComponent } from '../components/television/television.component'; @@ -42,7 +41,6 @@ import { CommonModule } from '@angular/common'; BlockFeeRatesGraphComponent, BlockSizesWeightsGraphComponent, FeeDistributionGraphComponent, - MempoolBlockOverviewComponent, IncomingTransactionsGraphComponent, MempoolGraphComponent, LbtcPegsGraphComponent, diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 7d3e51d20..6e8d8d0f2 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -90,6 +90,13 @@ import { GeolocationComponent } from '../shared/components/geolocation/geolocati import { TestnetAlertComponent } from './components/testnet-alert/testnet-alert.component'; import { GlobalFooterComponent } from './components/global-footer/global-footer.component'; +import { MempoolBlockOverviewComponent } from '../components/mempool-block-overview/mempool-block-overview.component'; +import { ClockchainComponent } from '../components/clockchain/clockchain.component'; +import { ClockFaceComponent } from '../components/clock-face/clock-face.component'; +import { ClockComponent } from '../components/clock/clock.component'; +import { ClockMinedComponent } from '../components/clock/clock-mined.component'; +import { ClockMempoolComponent } from '../components/clock/clock-mempool.component'; + @NgModule({ declarations: [ ClipboardComponent, @@ -172,6 +179,13 @@ import { GlobalFooterComponent } from './components/global-footer/global-footer. GeolocationComponent, TestnetAlertComponent, GlobalFooterComponent, + + MempoolBlockOverviewComponent, + ClockchainComponent, + ClockComponent, + ClockMinedComponent, + ClockMempoolComponent, + ClockFaceComponent, ], imports: [ CommonModule, @@ -279,6 +293,13 @@ import { GlobalFooterComponent } from './components/global-footer/global-footer. GeolocationComponent, PreviewTitleComponent, GlobalFooterComponent, + + MempoolBlockOverviewComponent, + ClockchainComponent, + ClockComponent, + ClockMinedComponent, + ClockMempoolComponent, + ClockFaceComponent, ] }) export class SharedModule { diff --git a/frontend/src/resources/clock/gradient.png b/frontend/src/resources/clock/gradient.png new file mode 100644 index 000000000..372105fbd Binary files /dev/null and b/frontend/src/resources/clock/gradient.png differ diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index fbaaa5ed2..e58bcdc6a 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -285,6 +285,10 @@ body { color: #fff; } +.white-color { + color: white; +} + .green-color { color: #3bcc49; }