diff --git a/backend/src/api/audit.ts b/backend/src/api/audit.ts new file mode 100644 index 000000000..77a6e7459 --- /dev/null +++ b/backend/src/api/audit.ts @@ -0,0 +1,118 @@ +import logger from '../logger'; +import { BlockExtended, TransactionExtended, MempoolBlockWithTransactions } from '../mempool.interfaces'; + +const PROPAGATION_MARGIN = 180; // in seconds, time since a transaction is first seen after which it is assumed to have propagated to all miners + +class Audit { + auditBlock(transactions: TransactionExtended[], projectedBlocks: MempoolBlockWithTransactions[], mempool: { [txId: string]: TransactionExtended }) + : { censored: string[], added: string[], score: number } { + if (!projectedBlocks?.[0]?.transactionIds || !mempool) { + return { censored: [], added: [], score: 0 }; + } + + const matches: string[] = []; // present in both mined block and template + const added: string[] = []; // present in mined block, not in template + const fresh: string[] = []; // missing, but firstSeen within PROPAGATION_MARGIN + const isCensored = {}; // missing, without excuse + const isDisplaced = {}; + let displacedWeight = 0; + + const inBlock = {}; + const inTemplate = {}; + + const now = Math.round((Date.now() / 1000)); + for (const tx of transactions) { + inBlock[tx.txid] = tx; + } + // coinbase is always expected + if (transactions[0]) { + inTemplate[transactions[0].txid] = true; + } + // look for transactions that were expected in the template, but missing from the mined block + for (const txid of projectedBlocks[0].transactionIds) { + if (!inBlock[txid]) { + // tx is recent, may have reached the miner too late for inclusion + if (mempool[txid]?.firstSeen != null && (now - (mempool[txid]?.firstSeen || 0)) <= PROPAGATION_MARGIN) { + fresh.push(txid); + } else { + isCensored[txid] = true; + } + displacedWeight += mempool[txid].weight; + } + inTemplate[txid] = true; + } + + displacedWeight += (4000 - transactions[0].weight); + + logger.warn(`${fresh.length} fresh, ${Object.keys(isCensored).length} possibly censored, ${displacedWeight} displaced weight`); + + // we can expect an honest miner to include 'displaced' transactions in place of recent arrivals and censored txs + // these displaced transactions should occupy the first N weight units of the next projected block + let displacedWeightRemaining = displacedWeight; + let index = 0; + let lastFeeRate = Infinity; + let failures = 0; + while (projectedBlocks[1] && index < projectedBlocks[1].transactionIds.length && failures < 500) { + const txid = projectedBlocks[1].transactionIds[index]; + const fits = (mempool[txid].weight - displacedWeightRemaining) < 4000; + const feeMatches = mempool[txid].effectiveFeePerVsize >= lastFeeRate; + if (fits || feeMatches) { + isDisplaced[txid] = true; + if (fits) { + lastFeeRate = Math.min(lastFeeRate, mempool[txid].effectiveFeePerVsize); + } + if (mempool[txid].firstSeen == null || (now - (mempool[txid]?.firstSeen || 0)) > PROPAGATION_MARGIN) { + displacedWeightRemaining -= mempool[txid].weight; + } + failures = 0; + } else { + failures++; + } + index++; + } + + // mark unexpected transactions in the mined block as 'added' + let overflowWeight = 0; + for (const tx of transactions) { + if (inTemplate[tx.txid]) { + matches.push(tx.txid); + } else { + if (!isDisplaced[tx.txid]) { + added.push(tx.txid); + } + overflowWeight += tx.weight; + } + } + + // transactions missing from near the end of our template are probably not being censored + let overflowWeightRemaining = overflowWeight; + let lastOverflowRate = 1.00; + index = projectedBlocks[0].transactionIds.length - 1; + while (index >= 0) { + const txid = projectedBlocks[0].transactionIds[index]; + if (overflowWeightRemaining > 0) { + if (isCensored[txid]) { + delete isCensored[txid]; + } + lastOverflowRate = mempool[txid].effectiveFeePerVsize; + } else if (Math.floor(mempool[txid].effectiveFeePerVsize * 100) <= Math.ceil(lastOverflowRate * 100)) { // tolerance of 0.01 sat/vb + if (isCensored[txid]) { + delete isCensored[txid]; + } + } + overflowWeightRemaining -= (mempool[txid]?.weight || 0); + index--; + } + + const numCensored = Object.keys(isCensored).length; + const score = matches.length > 0 ? (matches.length / (matches.length + numCensored)) : 0; + + return { + censored: Object.keys(isCensored), + added, + score + }; + } +} + +export default new Audit(); \ No newline at end of file diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index d702b4927..f536ce3d5 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -20,6 +20,7 @@ import indexer from '../indexer'; import fiatConversion from './fiat-conversion'; import poolsParser from './pools-parser'; import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository'; +import BlocksAuditsRepository from '../repositories/BlocksAuditsRepository'; import mining from './mining/mining'; import DifficultyAdjustmentsRepository from '../repositories/DifficultyAdjustmentsRepository'; import PricesRepository from '../repositories/PricesRepository'; @@ -186,14 +187,18 @@ class Blocks { if (!pool) { // We should never have this situation in practise logger.warn(`Cannot assign pool to block ${blockExtended.height} and 'unknown' pool does not exist. ` + `Check your "pools" table entries`); - return blockExtended; + } else { + blockExtended.extras.pool = { + id: pool.id, + name: pool.name, + slug: pool.slug, + }; } - blockExtended.extras.pool = { - id: pool.id, - name: pool.name, - slug: pool.slug, - }; + const auditSummary = await BlocksAuditsRepository.$getShortBlockAudit(block.id); + if (auditSummary) { + blockExtended.extras.matchRate = auditSummary.matchRate; + } } return blockExtended; diff --git a/backend/src/api/mempool-blocks.ts b/backend/src/api/mempool-blocks.ts index 5eb5aa9c8..d0c2a4f63 100644 --- a/backend/src/api/mempool-blocks.ts +++ b/backend/src/api/mempool-blocks.ts @@ -1,7 +1,8 @@ import logger from '../logger'; -import { MempoolBlock, TransactionExtended, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta } from '../mempool.interfaces'; +import { MempoolBlock, TransactionExtended, AuditTransaction, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor } from '../mempool.interfaces'; import { Common } from './common'; import config from '../config'; +import { PairingHeap } from '../utils/pairing-heap'; class MempoolBlocks { private mempoolBlocks: MempoolBlockWithTransactions[] = []; @@ -72,6 +73,7 @@ class MempoolBlocks { logger.debug('Mempool blocks calculated in ' + time / 1000 + ' seconds'); const { blocks, deltas } = this.calculateMempoolBlocks(memPoolArray, this.mempoolBlocks); + this.mempoolBlocks = blocks; this.mempoolBlockDeltas = deltas; } @@ -99,6 +101,7 @@ class MempoolBlocks { if (transactions.length) { mempoolBlocks.push(this.dataToMempoolBlocks(transactions, blockSize, blockWeight, mempoolBlocks.length)); } + // Calculate change from previous block states for (let i = 0; i < Math.max(mempoolBlocks.length, prevBlocks.length); i++) { let added: TransactionStripped[] = []; @@ -132,12 +135,286 @@ class MempoolBlocks { removed }); } + return { blocks: mempoolBlocks, deltas: mempoolBlockDeltas }; } + /* + * 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) + * + * blockLimit: number of blocks to build in total. + * weightLimit: maximum weight of transactions to consider using the selection algorithm. + * if weightLimit is significantly lower than the mempool size, results may start to diverge from getBlockTemplate + * condenseRest: whether to ignore excess transactions or append them to the final block. + */ + public makeBlockTemplates(mempool: { [txid: string]: TransactionExtended }, blockLimit: number, weightLimit: number | null = null, condenseRest = false): MempoolBlockWithTransactions[] { + const start = Date.now(); + const auditPool: { [txid: string]: AuditTransaction } = {}; + const mempoolArray: AuditTransaction[] = []; + const restOfArray: TransactionExtended[] = []; + + let weight = 0; + const maxWeight = weightLimit ? Math.max(4_000_000 * blockLimit, weightLimit) : Infinity; + // grab the top feerate txs up to maxWeight + Object.values(mempool).sort((a, b) => b.feePerVsize - a.feePerVsize).forEach(tx => { + weight += tx.weight; + if (weight >= maxWeight) { + restOfArray.push(tx); + return; + } + // initializing everything up front helps V8 optimize property access later + auditPool[tx.txid] = { + txid: tx.txid, + fee: tx.fee, + size: tx.size, + weight: tx.weight, + feePerVsize: tx.feePerVsize, + vin: tx.vin, + relativesSet: false, + ancestorMap: new Map(), + children: new Set(), + ancestorFee: 0, + ancestorWeight: 0, + score: 0, + used: false, + modified: false, + modifiedNode: null, + } + mempoolArray.push(auditPool[tx.txid]); + }) + + // Build relatives graph & calculate ancestor scores + for (const tx of mempoolArray) { + if (!tx.relativesSet) { + this.setRelatives(tx, auditPool); + } + } + + // Sort by descending ancestor score + mempoolArray.sort((a, b) => (b.score || 0) - (a.score || 0)); + + // Build blocks by greedily choosing the highest feerate package + // (i.e. the package rooted in the transaction with the best ancestor score) + const blocks: MempoolBlockWithTransactions[] = []; + let blockWeight = 4000; + let blockSize = 0; + let transactions: AuditTransaction[] = []; + const modified: PairingHeap = new PairingHeap((a, b): boolean => (a.score || 0) > (b.score || 0)); + let overflow: AuditTransaction[] = []; + let failures = 0; + let top = 0; + while ((top < mempoolArray.length || !modified.isEmpty()) && (condenseRest || blocks.length < blockLimit)) { + // skip invalid transactions + while (top < mempoolArray.length && (mempoolArray[top].used || mempoolArray[top].modified)) { + top++; + } + + // Select best next package + let nextTx; + const nextPoolTx = mempoolArray[top]; + const nextModifiedTx = modified.peek(); + if (nextPoolTx && (!nextModifiedTx || (nextPoolTx.score || 0) > (nextModifiedTx.score || 0))) { + nextTx = nextPoolTx; + top++; + } else { + modified.pop(); + if (nextModifiedTx) { + nextTx = nextModifiedTx; + nextTx.modifiedNode = undefined; + } + } + + if (nextTx && !nextTx?.used) { + // Check if the package fits into this block + if (blockWeight + nextTx.ancestorWeight < config.MEMPOOL.BLOCK_WEIGHT_UNITS) { + blockWeight += nextTx.ancestorWeight; + const ancestors: AuditTransaction[] = Array.from(nextTx.ancestorMap.values()); + // sort ancestors by dependency graph (equivalent to sorting by ascending ancestor count) + const sortedTxSet = [...ancestors.sort((a, b) => { return (a.ancestorMap.size || 0) - (b.ancestorMap.size || 0); }), nextTx]; + const effectiveFeeRate = nextTx.ancestorFee / (nextTx.ancestorWeight / 4); + sortedTxSet.forEach((ancestor, i, arr) => { + const mempoolTx = mempool[ancestor.txid]; + if (ancestor && !ancestor?.used) { + ancestor.used = true; + // update original copy of this tx with effective fee rate & relatives data + mempoolTx.effectiveFeePerVsize = effectiveFeeRate; + mempoolTx.ancestors = (Array.from(ancestor.ancestorMap?.values()) as AuditTransaction[]).map((a) => { + return { + txid: a.txid, + fee: a.fee, + weight: a.weight, + } + }) + if (i < arr.length - 1) { + mempoolTx.bestDescendant = { + txid: arr[arr.length - 1].txid, + fee: arr[arr.length - 1].fee, + weight: arr[arr.length - 1].weight, + }; + } + transactions.push(ancestor); + blockSize += ancestor.size; + } + }); + + // remove these as valid package ancestors for any descendants remaining in the mempool + if (sortedTxSet.length) { + sortedTxSet.forEach(tx => { + this.updateDescendants(tx, auditPool, modified); + }); + } + + failures = 0; + } else { + // hold this package in an overflow list while we check for smaller options + overflow.push(nextTx); + failures++; + } + } + + // this block is full + const exceededPackageTries = failures > 1000 && blockWeight > (config.MEMPOOL.BLOCK_WEIGHT_UNITS - 4000); + if (exceededPackageTries && (!condenseRest || blocks.length < blockLimit - 1)) { + // construct this block + if (transactions.length) { + blocks.push(this.dataToMempoolBlocks(transactions.map(t => mempool[t.txid]), blockSize, blockWeight, blocks.length)); + } + // 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 + for (const overflowTx of overflow.reverse()) { + if (overflowTx.modified) { + overflowTx.modifiedNode = modified.add(overflowTx); + } else { + top--; + mempoolArray[top] = overflowTx; + } + } + overflow = []; + } + } + if (condenseRest) { + // pack any leftover transactions into the last block + for (const tx of overflow) { + if (!tx || tx?.used) { + continue; + } + blockWeight += tx.weight; + blockSize += tx.size; + transactions.push(tx); + tx.used = true; + } + const blockTransactions = transactions.map(t => mempool[t.txid]) + restOfArray.forEach(tx => { + blockWeight += tx.weight; + blockSize += tx.size; + blockTransactions.push(tx); + }); + if (blockTransactions.length) { + blocks.push(this.dataToMempoolBlocks(blockTransactions, blockSize, blockWeight, blocks.length)); + } + transactions = []; + } else if (transactions.length) { + blocks.push(this.dataToMempoolBlocks(transactions.map(t => mempool[t.txid]), blockSize, blockWeight, blocks.length)); + } + + const end = Date.now(); + const time = end - start; + logger.debug('Mempool templates calculated in ' + time / 1000 + ' seconds'); + + return blocks; + } + + // traverse in-mempool ancestors + // recursion unavoidable, but should be limited to depth < 25 by mempool policy + public setRelatives( + tx: AuditTransaction, + mempool: { [txid: string]: AuditTransaction }, + ): void { + for (const parent of tx.vin) { + const parentTx = mempool[parent.txid]; + if (parentTx && !tx.ancestorMap!.has(parent.txid)) { + tx.ancestorMap.set(parent.txid, parentTx); + parentTx.children.add(tx); + // visit each node only once + if (!parentTx.relativesSet) { + this.setRelatives(parentTx, mempool); + } + parentTx.ancestorMap.forEach((ancestor) => { + tx.ancestorMap.set(ancestor.txid, ancestor); + }); + } + }; + tx.ancestorFee = tx.fee || 0; + tx.ancestorWeight = tx.weight || 0; + tx.ancestorMap.forEach((ancestor) => { + tx.ancestorFee += ancestor.fee; + tx.ancestorWeight += ancestor.weight; + }); + tx.score = tx.ancestorFee / (tx.ancestorWeight || 1); + tx.relativesSet = true; + } + + // iterate over remaining descendants, removing the root as a valid ancestor & updating the ancestor score + // avoids recursion to limit call stack depth + private updateDescendants( + rootTx: AuditTransaction, + mempool: { [txid: string]: AuditTransaction }, + modified: PairingHeap, + ): void { + const descendantSet: Set = new Set(); + // stack of nodes left to visit + const descendants: AuditTransaction[] = []; + let descendantTx; + let ancestorIndex; + let tmpScore; + rootTx.children.forEach(childTx => { + if (!descendantSet.has(childTx)) { + descendants.push(childTx); + descendantSet.add(childTx); + } + }); + while (descendants.length) { + descendantTx = descendants.pop(); + if (descendantTx && descendantTx.ancestorMap && descendantTx.ancestorMap.has(rootTx.txid)) { + // remove tx as ancestor + descendantTx.ancestorMap.delete(rootTx.txid); + descendantTx.ancestorFee -= rootTx.fee; + descendantTx.ancestorWeight -= rootTx.weight; + tmpScore = descendantTx.score; + descendantTx.score = descendantTx.ancestorFee / descendantTx.ancestorWeight; + + if (!descendantTx.modifiedNode) { + descendantTx.modified = true; + descendantTx.modifiedNode = modified.add(descendantTx); + } else { + // rebalance modified heap if score has changed + if (descendantTx.score < tmpScore) { + modified.decreasePriority(descendantTx.modifiedNode); + } else if (descendantTx.score > tmpScore) { + modified.increasePriority(descendantTx.modifiedNode); + } + } + + // add this node's children to the stack + descendantTx.children.forEach(childTx => { + // visit each node only once + if (!descendantSet.has(childTx)) { + descendants.push(childTx); + descendantSet.add(childTx); + } + }); + } + } + } + private dataToMempoolBlocks(transactions: TransactionExtended[], blockSize: number, blockWeight: number, blocksIndex: number): MempoolBlockWithTransactions { let rangeLength = 4; diff --git a/backend/src/api/mining/mining-routes.ts b/backend/src/api/mining/mining-routes.ts index f52d42d1f..47704f993 100644 --- a/backend/src/api/mining/mining-routes.ts +++ b/backend/src/api/mining/mining-routes.ts @@ -238,6 +238,12 @@ class MiningRoutes { public async $getBlockAudit(req: Request, res: Response) { try { const audit = await BlocksAuditsRepository.$getBlockAudit(req.params.hash); + + if (!audit) { + res.status(404).send(`This block has not been audited.`); + return; + } + res.header('Pragma', 'public'); res.header('Cache-control', 'public'); res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24).toUTCString()); diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index 4896ee058..4bd7cfc8d 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -18,6 +18,7 @@ import difficultyAdjustment from './difficulty-adjustment'; import feeApi from './fee-api'; import BlocksAuditsRepository from '../repositories/BlocksAuditsRepository'; import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository'; +import Audit from './audit'; class WebsocketHandler { private wss: WebSocket.Server | undefined; @@ -405,75 +406,63 @@ class WebsocketHandler { }); } - handleNewBlock(block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) { + handleNewBlock(block: BlockExtended, txIds: string[], transactions: TransactionExtended[]): void { if (!this.wss) { throw new Error('WebSocket.Server is not set'); } let mBlocks: undefined | MempoolBlock[]; let mBlockDeltas: undefined | MempoolBlockDelta[]; - let matchRate = 0; + let matchRate; const _memPool = memPool.getMempool(); - const _mempoolBlocks = mempoolBlocks.getMempoolBlocksWithTransactions(); - if (_mempoolBlocks[0]) { - const matches: string[] = []; - const added: string[] = []; - const missing: string[] = []; + if (Common.indexingEnabled()) { + const mempoolCopy = cloneMempool(_memPool); + const projectedBlocks = mempoolBlocks.makeBlockTemplates(mempoolCopy, 2); - for (const txId of txIds) { - if (_mempoolBlocks[0].transactionIds.indexOf(txId) > -1) { - matches.push(txId); - } else { - added.push(txId); + const { censored, added, score } = Audit.auditBlock(transactions, projectedBlocks, mempoolCopy); + matchRate = Math.round(score * 100 * 100) / 100; + + const stripped = projectedBlocks[0]?.transactions ? projectedBlocks[0].transactions.map((tx) => { + return { + txid: tx.txid, + vsize: tx.vsize, + fee: tx.fee ? Math.round(tx.fee) : 0, + value: tx.value, + }; + }) : []; + + BlocksSummariesRepository.$saveSummary({ + height: block.height, + template: { + id: block.id, + transactions: stripped } - delete _memPool[txId]; - } + }); - for (const txId of _mempoolBlocks[0].transactionIds) { - if (matches.includes(txId) || added.includes(txId)) { - continue; - } - missing.push(txId); - } + BlocksAuditsRepository.$saveAudit({ + time: block.timestamp, + height: block.height, + hash: block.id, + addedTxs: added, + missingTxs: censored, + matchRate: matchRate, + }); - matchRate = Math.round((Math.max(0, matches.length - missing.length - added.length) / txIds.length * 100) * 100) / 100; - mempoolBlocks.updateMempoolBlocks(_memPool); - mBlocks = mempoolBlocks.getMempoolBlocks(); - mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas(); - - if (Common.indexingEnabled()) { - const stripped = _mempoolBlocks[0].transactions.map((tx) => { - return { - txid: tx.txid, - vsize: tx.vsize, - fee: tx.fee ? Math.round(tx.fee) : 0, - value: tx.value, - }; - }); - BlocksSummariesRepository.$saveSummary({ - height: block.height, - template: { - id: block.id, - transactions: stripped - } - }); - - BlocksAuditsRepository.$saveAudit({ - time: block.timestamp, - height: block.height, - hash: block.id, - addedTxs: added, - missingTxs: missing, - matchRate: matchRate, - }); + if (block.extras) { + block.extras.matchRate = matchRate; } } - if (block.extras) { - block.extras.matchRate = matchRate; + // Update mempool to remove transactions included in the new block + for (const txId of txIds) { + delete _memPool[txId]; } + mempoolBlocks.updateMempoolBlocks(_memPool); + mBlocks = mempoolBlocks.getMempoolBlocks(); + mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas(); + const da = difficultyAdjustment.getDifficultyAdjustment(); const fees = feeApi.getRecommendedFee(); @@ -580,4 +569,14 @@ class WebsocketHandler { } } +function cloneMempool(mempool: { [txid: string]: TransactionExtended }): { [txid: string]: TransactionExtended } { + const cloned = {}; + Object.keys(mempool).forEach(id => { + cloned[id] = { + ...mempool[id] + }; + }); + return cloned; +} + export default new WebsocketHandler(); diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index d72b13576..32d87f3dc 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -1,4 +1,5 @@ import { IEsploraApi } from './api/bitcoin/esplora-api.interface'; +import { HeapNode } from "./utils/pairing-heap"; export interface PoolTag { id: number; // mysql row id @@ -70,12 +71,40 @@ export interface TransactionExtended extends IEsploraApi.Transaction { deleteAfter?: number; } -interface Ancestor { +export interface AuditTransaction { + txid: string; + fee: number; + size: number; + weight: number; + feePerVsize: number; + vin: IEsploraApi.Vin[]; + relativesSet: boolean; + ancestorMap: Map; + children: Set; + ancestorFee: number; + ancestorWeight: number; + score: number; + used: boolean; + modified: boolean; + modifiedNode: HeapNode; +} + +export interface Ancestor { txid: string; weight: number; fee: number; } +export interface TransactionSet { + fee: number; + weight: number; + score: number; + children?: Set; + available?: boolean; + modified?: boolean; + modifiedNode?: HeapNode; +} + interface BestDescendant { txid: string; weight: number; diff --git a/backend/src/repositories/BlocksAuditsRepository.ts b/backend/src/repositories/BlocksAuditsRepository.ts index be85b22b9..188cf4c38 100644 --- a/backend/src/repositories/BlocksAuditsRepository.ts +++ b/backend/src/repositories/BlocksAuditsRepository.ts @@ -58,10 +58,12 @@ class BlocksAuditRepositories { WHERE blocks_audits.hash = "${hash}" `); - rows[0].missingTxs = JSON.parse(rows[0].missingTxs); - rows[0].addedTxs = JSON.parse(rows[0].addedTxs); - rows[0].transactions = JSON.parse(rows[0].transactions); - rows[0].template = JSON.parse(rows[0].template); + if (rows.length) { + rows[0].missingTxs = JSON.parse(rows[0].missingTxs); + rows[0].addedTxs = JSON.parse(rows[0].addedTxs); + rows[0].transactions = JSON.parse(rows[0].transactions); + rows[0].template = JSON.parse(rows[0].template); + } return rows[0]; } catch (e: any) { @@ -69,6 +71,20 @@ class BlocksAuditRepositories { throw e; } } + + public async $getShortBlockAudit(hash: string): Promise { + try { + const [rows]: any[] = await DB.query( + `SELECT hash as id, match_rate as matchRate + FROM blocks_audits + WHERE blocks_audits.hash = "${hash}" + `); + return rows[0]; + } catch (e: any) { + logger.err(`Cannot fetch block audit from db. Reason: ` + (e instanceof Error ? e.message : e)); + throw e; + } + } } export default new BlocksAuditRepositories(); diff --git a/backend/src/utils/pairing-heap.ts b/backend/src/utils/pairing-heap.ts new file mode 100644 index 000000000..876e056c4 --- /dev/null +++ b/backend/src/utils/pairing-heap.ts @@ -0,0 +1,174 @@ +export type HeapNode = { + element: T + child?: HeapNode + next?: HeapNode + prev?: HeapNode +} | null | undefined; + +// minimal pairing heap priority queue implementation +export class PairingHeap { + private root: HeapNode = null; + private comparator: (a: T, b: T) => boolean; + + // comparator function should return 'true' if a is higher priority than b + constructor(comparator: (a: T, b: T) => boolean) { + this.comparator = comparator; + } + + isEmpty(): boolean { + return !this.root; + } + + add(element: T): HeapNode { + const node: HeapNode = { + element + }; + + this.root = this.meld(this.root, node); + + return node; + } + + // returns the top priority element without modifying the queue + peek(): T | void { + return this.root?.element; + } + + // removes and returns the top priority element + pop(): T | void { + let element; + if (this.root) { + const node = this.root; + element = node.element; + this.root = this.mergePairs(node.child); + } + return element; + } + + deleteNode(node: HeapNode): void { + if (!node) { + return; + } + + if (node === this.root) { + this.root = this.mergePairs(node.child); + } + else { + if (node.prev) { + if (node.prev.child === node) { + node.prev.child = node.next; + } + else { + node.prev.next = node.next; + } + } + if (node.next) { + node.next.prev = node.prev; + } + this.root = this.meld(this.root, this.mergePairs(node.child)); + } + + node.child = null; + node.prev = null; + node.next = null; + } + + // fix the heap after increasing the priority of a given node + increasePriority(node: HeapNode): void { + // already the top priority element + if (!node || node === this.root) { + return; + } + // extract from siblings + if (node.prev) { + if (node.prev?.child === node) { + if (this.comparator(node.prev.element, node.element)) { + // already in a valid position + return; + } + node.prev.child = node.next; + } + else { + node.prev.next = node.next; + } + } + if (node.next) { + node.next.prev = node.prev; + } + + this.root = this.meld(this.root, node); + } + + decreasePriority(node: HeapNode): void { + this.deleteNode(node); + this.root = this.meld(this.root, node); + } + + meld(a: HeapNode, b: HeapNode): HeapNode { + if (!a) { + return b; + } + if (!b || a === b) { + return a; + } + + let parent: HeapNode = b; + let child: HeapNode = a; + if (this.comparator(a.element, b.element)) { + parent = a; + child = b; + } + + child.next = parent.child; + if (parent.child) { + parent.child.prev = child; + } + child.prev = parent; + parent.child = child; + + parent.next = null; + parent.prev = null; + + return parent; + } + + mergePairs(node: HeapNode): HeapNode { + if (!node) { + return null; + } + + let current: HeapNode = node; + let next: HeapNode; + let nextCurrent: HeapNode; + let pairs: HeapNode; + let melded: HeapNode; + while (current) { + next = current.next; + if (next) { + nextCurrent = next.next; + melded = this.meld(current, next); + if (melded) { + melded.prev = pairs; + } + pairs = melded; + } + else { + nextCurrent = null; + current.prev = pairs; + pairs = current; + break; + } + current = nextCurrent; + } + + melded = null; + let prev: HeapNode; + while (pairs) { + prev = pairs.prev; + melded = this.meld(melded, pairs); + pairs = prev; + } + + return melded; + } +} \ No newline at end of file diff --git a/frontend/proxy.conf.staging.js b/frontend/proxy.conf.staging.js index 098edb619..0cf366ca7 100644 --- a/frontend/proxy.conf.staging.js +++ b/frontend/proxy.conf.staging.js @@ -3,9 +3,9 @@ const fs = require('fs'); let PROXY_CONFIG = require('./proxy.conf'); PROXY_CONFIG.forEach(entry => { - entry.target = entry.target.replace("mempool.space", "mempool.ninja"); - entry.target = entry.target.replace("liquid.network", "liquid.place"); - entry.target = entry.target.replace("bisq.markets", "bisq.ninja"); + entry.target = entry.target.replace("mempool.space", "mempool-staging.fra.mempool.space"); + entry.target = entry.target.replace("liquid.network", "liquid-staging.fra.mempool.space"); + entry.target = entry.target.replace("bisq.markets", "bisq-staging.fra.mempool.space"); }); module.exports = PROXY_CONFIG; diff --git a/frontend/src/app/components/block-audit/block-audit.component.html b/frontend/src/app/components/block-audit/block-audit.component.html index 0ee6bef44..543dbb705 100644 --- a/frontend/src/app/components/block-audit/block-audit.component.html +++ b/frontend/src/app/components/block-audit/block-audit.component.html @@ -1,21 +1,22 @@
-
-
-

- - Block -   - {{ blockAudit.height }} -   - Template vs Mined - -

+
+

+ + Block Audit +   + {{ blockAudit.height }} +   + +

-
+
- -
+ +
+ +
+
@@ -26,8 +27,8 @@ Hash - {{ blockAudit.id | shortenString : 13 }} - + {{ blockHash | shortenString : 13 }} + @@ -40,6 +41,10 @@
+ + Transactions + {{ blockAudit.tx_count }} + Size @@ -57,21 +62,25 @@ - - - - - + - + + + + + + + + +
Transactions{{ blockAudit.tx_count }}
Match rateBlock health {{ blockAudit.matchRate }}%
Missing txsRemoved txs {{ blockAudit.missingTxs.length }}
Omitted txs{{ numMissing }}
Added txs {{ blockAudit.addedTxs.length }}
Included txs{{ numUnexpected }}
@@ -79,33 +88,110 @@
- + +
+

+ + Block Audit +   + {{ blockAudit.height }} +   + +

+ +
+ + +
+ + +
+
+ +
+ + + + + + + + +
+
+ + +
+ + + + + + + + +
+
+
+
+ + + +
+ + +
+
+ audit unavailable +

+ {{ error.error }} +
+
+
+ +
+
+ Error loading data. +

+ {{ error }} +
+
+
+
+
+ -
+
- Projected Block +
- Actual Block +
- -
\ No newline at end of file diff --git a/frontend/src/app/components/block-audit/block-audit.component.scss b/frontend/src/app/components/block-audit/block-audit.component.scss index 7ec503891..1e35b7c63 100644 --- a/frontend/src/app/components/block-audit/block-audit.component.scss +++ b/frontend/src/app/components/block-audit/block-audit.component.scss @@ -37,4 +37,8 @@ @media (min-width: 768px) { max-width: 150px; } +} + +.block-subtitle { + text-align: center; } \ No newline at end of file diff --git a/frontend/src/app/components/block-audit/block-audit.component.ts b/frontend/src/app/components/block-audit/block-audit.component.ts index ff6c0ea7f..f8ce8d9bb 100644 --- a/frontend/src/app/components/block-audit/block-audit.component.ts +++ b/frontend/src/app/components/block-audit/block-audit.component.ts @@ -1,7 +1,7 @@ -import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { Component, OnDestroy, OnInit, AfterViewInit, ViewChildren, QueryList } from '@angular/core'; import { ActivatedRoute, ParamMap, Router } from '@angular/router'; -import { Observable } from 'rxjs'; -import { map, share, switchMap, tap } from 'rxjs/operators'; +import { Subscription, combineLatest } from 'rxjs'; +import { map, switchMap, startWith, catchError } from 'rxjs/operators'; import { BlockAudit, TransactionStripped } from '../../interfaces/node-api.interface'; import { ApiService } from '../../services/api.service'; import { StateService } from '../../services/state.service'; @@ -22,22 +22,30 @@ import { BlockOverviewGraphComponent } from '../block-overview-graph/block-overv } `], }) -export class BlockAuditComponent implements OnInit, OnDestroy { +export class BlockAuditComponent implements OnInit, AfterViewInit, OnDestroy { blockAudit: BlockAudit = undefined; transactions: string[]; - auditObservable$: Observable; + auditSubscription: Subscription; + urlFragmentSubscription: Subscription; paginationMaxSize: number; page = 1; itemsPerPage: number; - mode: 'missing' | 'added' = 'missing'; + mode: 'projected' | 'actual' = 'projected'; + error: any; isLoading = true; webGlEnabled = true; isMobile = window.innerWidth <= 767.98; - @ViewChild('blockGraphTemplate') blockGraphTemplate: BlockOverviewGraphComponent; - @ViewChild('blockGraphMined') blockGraphMined: BlockOverviewGraphComponent; + childChangeSubscription: Subscription; + + blockHash: string; + numMissing: number = 0; + numUnexpected: number = 0; + + @ViewChildren('blockGraphProjected') blockGraphProjected: QueryList; + @ViewChildren('blockGraphActual') blockGraphActual: QueryList; constructor( private route: ActivatedRoute, @@ -48,73 +56,137 @@ export class BlockAuditComponent implements OnInit, OnDestroy { this.webGlEnabled = detectWebGL(); } - ngOnDestroy(): void { + ngOnDestroy() { + this.childChangeSubscription.unsubscribe(); + this.urlFragmentSubscription.unsubscribe(); } ngOnInit(): void { this.paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5; this.itemsPerPage = this.stateService.env.ITEMS_PER_PAGE; - this.auditObservable$ = this.route.paramMap.pipe( + this.urlFragmentSubscription = this.route.fragment.subscribe((fragment) => { + if (fragment === 'actual') { + this.mode = 'actual'; + } else { + this.mode = 'projected' + } + this.setupBlockGraphs(); + }); + + this.auditSubscription = this.route.paramMap.pipe( switchMap((params: ParamMap) => { - const blockHash: string = params.get('id') || ''; - return this.apiService.getBlockAudit$(blockHash) + this.blockHash = params.get('id') || null; + if (!this.blockHash) { + return null; + } + return this.apiService.getBlockAudit$(this.blockHash) .pipe( map((response) => { const blockAudit = response.body; - for (let i = 0; i < blockAudit.template.length; ++i) { - if (blockAudit.missingTxs.includes(blockAudit.template[i].txid)) { - blockAudit.template[i].status = 'missing'; - } else if (blockAudit.addedTxs.includes(blockAudit.template[i].txid)) { - blockAudit.template[i].status = 'added'; + const inTemplate = {}; + const inBlock = {}; + const isAdded = {}; + const isCensored = {}; + const isMissing = {}; + const isSelected = {}; + this.numMissing = 0; + this.numUnexpected = 0; + for (const tx of blockAudit.template) { + inTemplate[tx.txid] = true; + } + for (const tx of blockAudit.transactions) { + inBlock[tx.txid] = true; + } + for (const txid of blockAudit.addedTxs) { + isAdded[txid] = true; + } + for (const txid of blockAudit.missingTxs) { + isCensored[txid] = true; + } + // set transaction statuses + for (const tx of blockAudit.template) { + if (isCensored[tx.txid]) { + tx.status = 'censored'; + } else if (inBlock[tx.txid]) { + tx.status = 'found'; } else { - blockAudit.template[i].status = 'found'; + tx.status = 'missing'; + isMissing[tx.txid] = true; + this.numMissing++; } } - for (let i = 0; i < blockAudit.transactions.length; ++i) { - if (blockAudit.missingTxs.includes(blockAudit.transactions[i].txid)) { - blockAudit.transactions[i].status = 'missing'; - } else if (blockAudit.addedTxs.includes(blockAudit.transactions[i].txid)) { - blockAudit.transactions[i].status = 'added'; + for (const [index, tx] of blockAudit.transactions.entries()) { + if (isAdded[tx.txid]) { + tx.status = 'added'; + } else if (index === 0 || inTemplate[tx.txid]) { + tx.status = 'found'; } else { - blockAudit.transactions[i].status = 'found'; + tx.status = 'selected'; + isSelected[tx.txid] = true; + this.numUnexpected++; } } + for (const tx of blockAudit.transactions) { + inBlock[tx.txid] = true; + } return blockAudit; - }), - tap((blockAudit) => { - this.changeMode(this.mode); - if (this.blockGraphTemplate) { - this.blockGraphTemplate.destroy(); - this.blockGraphTemplate.setup(blockAudit.template); - } - if (this.blockGraphMined) { - this.blockGraphMined.destroy(); - this.blockGraphMined.setup(blockAudit.transactions); - } - this.isLoading = false; - }), + }) ); }), - share() - ); + catchError((err) => { + console.log(err); + this.error = err; + this.isLoading = false; + return null; + }), + ).subscribe((blockAudit) => { + this.blockAudit = blockAudit; + this.setupBlockGraphs(); + this.isLoading = false; + }); + } + + ngAfterViewInit() { + this.childChangeSubscription = combineLatest([this.blockGraphProjected.changes.pipe(startWith(null)), this.blockGraphActual.changes.pipe(startWith(null))]).subscribe(() => { + this.setupBlockGraphs(); + }) + } + + setupBlockGraphs() { + if (this.blockAudit) { + this.blockGraphProjected.forEach(graph => { + graph.destroy(); + if (this.isMobile && this.mode === 'actual') { + graph.setup(this.blockAudit.transactions); + } else { + graph.setup(this.blockAudit.template); + } + }) + this.blockGraphActual.forEach(graph => { + graph.destroy(); + graph.setup(this.blockAudit.transactions); + }) + } } onResize(event: any) { - this.isMobile = event.target.innerWidth <= 767.98; + const isMobile = event.target.innerWidth <= 767.98; + const changed = isMobile !== this.isMobile; + this.isMobile = isMobile; this.paginationMaxSize = event.target.innerWidth < 670 ? 3 : 5; + + if (changed) { + this.changeMode(this.mode); + } } - changeMode(mode: 'missing' | 'added') { + changeMode(mode: 'projected' | 'actual') { this.router.navigate([], { fragment: mode }); - this.mode = mode; } onTxClick(event: TransactionStripped): void { const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`); this.router.navigate([url]); } - - pageChange(page: number, target: HTMLElement) { - } } diff --git a/frontend/src/app/components/block-overview-graph/tx-view.ts b/frontend/src/app/components/block-overview-graph/tx-view.ts index 5f2ebf898..ac2a4655a 100644 --- a/frontend/src/app/components/block-overview-graph/tx-view.ts +++ b/frontend/src/app/components/block-overview-graph/tx-view.ts @@ -7,6 +7,15 @@ import { feeLevels, mempoolFeeColors } from '../../app.constants'; const hoverTransitionTime = 300; const defaultHoverColor = hexToColor('1bd8f4'); +const feeColors = mempoolFeeColors.map(hexToColor); +const auditFeeColors = feeColors.map((color) => desaturate(color, 0.3)); +const auditColors = { + censored: hexToColor('f344df'), + missing: darken(desaturate(hexToColor('f344df'), 0.3), 0.7), + added: hexToColor('03E1E5'), + selected: darken(desaturate(hexToColor('039BE5'), 0.3), 0.7), +} + // convert from this class's update format to TxSprite's update format function toSpriteUpdate(params: ViewUpdateParams): SpriteUpdateParams { return { @@ -25,7 +34,7 @@ export default class TxView implements TransactionStripped { vsize: number; value: number; feerate: number; - status?: 'found' | 'missing' | 'added'; + status?: 'found' | 'missing' | 'added' | 'censored' | 'selected'; initialised: boolean; vertexArray: FastVertexArray; @@ -142,16 +151,23 @@ export default class TxView implements TransactionStripped { } getColor(): Color { - // Block audit - if (this.status === 'missing') { - return hexToColor('039BE5'); - } else if (this.status === 'added') { - return hexToColor('D81B60'); - } - - // Block component const feeLevelIndex = feeLevels.findIndex((feeLvl) => Math.max(1, this.feerate) < feeLvl) - 1; - return hexToColor(mempoolFeeColors[feeLevelIndex] || mempoolFeeColors[mempoolFeeColors.length - 1]); + const feeLevelColor = feeColors[feeLevelIndex] || feeColors[mempoolFeeColors.length - 1]; + // Block audit + switch(this.status) { + case 'censored': + return auditColors.censored; + case 'missing': + return auditColors.missing; + case 'added': + return auditColors.added; + case 'selected': + return auditColors.selected; + case 'found': + return auditFeeColors[feeLevelIndex] || auditFeeColors[mempoolFeeColors.length - 1]; + default: + return feeLevelColor; + } } } @@ -163,3 +179,22 @@ function hexToColor(hex: string): Color { a: 1 }; } + +function desaturate(color: Color, amount: number): Color { + const gray = (color.r + color.g + color.b) / 6; + return { + r: color.r + ((gray - color.r) * amount), + g: color.g + ((gray - color.g) * amount), + b: color.b + ((gray - color.b) * amount), + a: color.a, + }; +} + +function darken(color: Color, amount: number): Color { + return { + r: color.r * amount, + g: color.g * amount, + b: color.b * amount, + a: color.a, + } +} diff --git a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html index 03d7fc1e9..b19b67b06 100644 --- a/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html +++ b/frontend/src/app/components/block-overview-tooltip/block-overview-tooltip.component.html @@ -32,6 +32,16 @@ Virtual size + + Audit status + + match + removed + missing + added + included + +
diff --git a/frontend/src/app/components/block/block.component.html b/frontend/src/app/components/block/block.component.html index a44abe3a0..819b05c81 100644 --- a/frontend/src/app/components/block/block.component.html +++ b/frontend/src/app/components/block/block.component.html @@ -110,6 +110,13 @@ + + Block health + + {{ block.extras.matchRate }}% + Unknown + + diff --git a/frontend/src/app/components/block/block.component.ts b/frontend/src/app/components/block/block.component.ts index 2e6a73c62..8f977b81d 100644 --- a/frontend/src/app/components/block/block.component.ts +++ b/frontend/src/app/components/block/block.component.ts @@ -47,6 +47,7 @@ export class BlockComponent implements OnInit, OnDestroy { transactionsError: any = null; overviewError: any = null; webGlEnabled = true; + indexingAvailable = false; transactionSubscription: Subscription; overviewSubscription: Subscription; @@ -86,6 +87,9 @@ export class BlockComponent implements OnInit, OnDestroy { this.timeLtr = !!ltr; }); + this.indexingAvailable = (this.stateService.env.BASE_MODULE === 'mempool' && + this.stateService.env.MINING_DASHBOARD === true); + this.txsLoadingStatus$ = this.route.paramMap .pipe( switchMap(() => this.stateService.loadingIndicators$), diff --git a/frontend/src/app/components/blocks-list/blocks-list.component.html b/frontend/src/app/components/blocks-list/blocks-list.component.html index 660481ecd..68acf71ea 100644 --- a/frontend/src/app/components/blocks-list/blocks-list.component.html +++ b/frontend/src/app/components/blocks-list/blocks-list.component.html @@ -14,6 +14,8 @@ i18n-ngbTooltip="mining.pool-name" ngbTooltip="Pool" placement="bottom" #miningpool [disableTooltip]="!isEllipsisActive(miningpool)">Pool Timestamp Mined + Health Reward Fees @@ -37,12 +39,30 @@ {{ block.extras.coinbaseRaw | hex2ascii }} - + ‎{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }} + + +
+
+
+ {{ block.extras.matchRate }}% +
+
+
+
+
+
+ ~ +
+
+ @@ -77,6 +97,9 @@ + + + diff --git a/frontend/src/app/components/blocks-list/blocks-list.component.scss b/frontend/src/app/components/blocks-list/blocks-list.component.scss index 5dc265017..6617cec58 100644 --- a/frontend/src/app/components/blocks-list/blocks-list.component.scss +++ b/frontend/src/app/components/blocks-list/blocks-list.component.scss @@ -63,7 +63,7 @@ tr, td, th { } .height { - width: 10%; + width: 8%; } .height.widget { width: 15%; @@ -77,12 +77,18 @@ tr, td, th { .timestamp { width: 18%; - @media (max-width: 900px) { + @media (max-width: 1100px) { display: none; } } .timestamp.legacy { width: 20%; + @media (max-width: 1100px) { + display: table-cell; + } + @media (max-width: 850px) { + display: none; + } } .mined { @@ -93,6 +99,10 @@ tr, td, th { } .mined.legacy { width: 15%; + @media (max-width: 1000px) { + padding-right: 20px; + width: 20%; + } @media (max-width: 576px) { display: table-cell; } @@ -100,6 +110,7 @@ tr, td, th { .txs { padding-right: 40px; + width: 8%; @media (max-width: 1100px) { padding-right: 10px; } @@ -113,17 +124,21 @@ tr, td, th { } .txs.widget { padding-right: 0; + display: none; @media (max-width: 650px) { display: none; } } .txs.legacy { - padding-right: 80px; - width: 10%; + width: 18%; + display: table-cell; + @media (max-width: 1000px) { + padding-right: 20px; + } } .fees { - width: 10%; + width: 8%; @media (max-width: 650px) { display: none; } @@ -133,7 +148,7 @@ tr, td, th { } .reward { - width: 10%; + width: 8%; @media (max-width: 576px) { width: 7%; padding-right: 30px; @@ -152,8 +167,11 @@ tr, td, th { } .size { - width: 12%; + width: 10%; @media (max-width: 1000px) { + width: 13%; + } + @media (max-width: 950px) { width: 15%; } @media (max-width: 650px) { @@ -164,12 +182,34 @@ tr, td, th { } } .size.legacy { - width: 20%; + width: 30%; @media (max-width: 576px) { display: table-cell; } } +.health { + width: 10%; + @media (max-width: 1000px) { + width: 13%; + } + @media (max-width: 950px) { + display: none; + } +} +.health.widget { + width: 25%; + @media (max-width: 1000px) { + display: none; + } + @media (max-width: 767px) { + display: table-cell; + } + @media (max-width: 500px) { + display: none; + } +} + /* Tooltip text */ .tooltip-custom { position: relative; diff --git a/frontend/src/app/components/television/television.component.html b/frontend/src/app/components/television/television.component.html index 7da6e2d38..89cf8e5bb 100644 --- a/frontend/src/app/components/television/television.component.html +++ b/frontend/src/app/components/television/television.component.html @@ -11,11 +11,15 @@ [showZoom]="false" > -
+
- - -
+ +
+ + +
+
+
diff --git a/frontend/src/app/components/television/television.component.scss b/frontend/src/app/components/television/television.component.scss index 50ee7b543..9a6cbcc24 100644 --- a/frontend/src/app/components/television/television.component.scss +++ b/frontend/src/app/components/television/television.component.scss @@ -31,8 +31,9 @@ .position-container { position: absolute; - left: 50%; + left: 0; bottom: 170px; + transform: translateX(50vw); } #divider { @@ -47,9 +48,33 @@ top: -28px; } } + + &.time-ltr { + .blocks-wrapper { + transform: 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; + } +} + .tv-container { display: flex; margin-top: 0px; flex-direction: column; } + + + diff --git a/frontend/src/app/components/television/television.component.ts b/frontend/src/app/components/television/television.component.ts index ab1770972..5e3888aa4 100644 --- a/frontend/src/app/components/television/television.component.ts +++ b/frontend/src/app/components/television/television.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, OnDestroy } from '@angular/core'; import { WebsocketService } from '../../services/websocket.service'; import { OptimizedMempoolStats } from '../../interfaces/node-api.interface'; import { StateService } from '../../services/state.service'; @@ -6,7 +6,7 @@ import { ApiService } from '../../services/api.service'; import { SeoService } from '../../services/seo.service'; import { ActivatedRoute } from '@angular/router'; import { map, scan, startWith, switchMap, tap } from 'rxjs/operators'; -import { interval, merge, Observable } from 'rxjs'; +import { interval, merge, Observable, Subscription } from 'rxjs'; import { ChangeDetectionStrategy } from '@angular/core'; @Component({ @@ -15,11 +15,13 @@ import { ChangeDetectionStrategy } from '@angular/core'; styleUrls: ['./television.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) -export class TelevisionComponent implements OnInit { +export class TelevisionComponent implements OnInit, OnDestroy { mempoolStats: OptimizedMempoolStats[] = []; statsSubscription$: Observable; fragment: string; + timeLtrSubscription: Subscription; + timeLtr: boolean = this.stateService.timeLtr.value; constructor( private websocketService: WebsocketService, @@ -37,6 +39,10 @@ export class TelevisionComponent implements OnInit { this.seoService.setTitle($localize`:@@46ce8155c9ab953edeec97e8950b5a21e67d7c4e:TV view`); this.websocketService.want(['blocks', 'live-2h-chart', 'mempool-blocks']); + this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => { + this.timeLtr = !!ltr; + }); + this.statsSubscription$ = merge( this.stateService.live2Chart$.pipe(map(stats => [stats])), this.route.fragment @@ -70,4 +76,8 @@ export class TelevisionComponent implements OnInit { }) ); } + + ngOnDestroy() { + this.timeLtrSubscription.unsubscribe(); + } } diff --git a/frontend/src/app/docs/api-docs/api-docs.component.html b/frontend/src/app/docs/api-docs/api-docs.component.html index f106c4bc5..e2524a27d 100644 --- a/frontend/src/app/docs/api-docs/api-docs.component.html +++ b/frontend/src/app/docs/api-docs/api-docs.component.html @@ -106,6 +106,20 @@ +
+
+
+

Hostname

+

{{plainHostname}}

+

Port

+

{{electrsPort}}

+

SSL

+

Enabled

+

Electrum RPC interface for Bitcoin Signet is publicly available. Electrum RPC interface for all other networks is available to sponsors only—whitelisting is required.

+
+
+
+ diff --git a/frontend/src/app/docs/api-docs/api-docs.component.scss b/frontend/src/app/docs/api-docs/api-docs.component.scss index 456983657..acfc209e5 100644 --- a/frontend/src/app/docs/api-docs/api-docs.component.scss +++ b/frontend/src/app/docs/api-docs/api-docs.component.scss @@ -1,7 +1,21 @@ +.center { + text-align: center; +} + +.note { + font-style: italic; +} + .text-small { font-size: 12px; } +.container-xl { + display: flex; + min-height: 75vh; + flex-direction: column; +} + code { background-color: #1d1f31; font-family: Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New; @@ -116,6 +130,10 @@ li.nav-item { float: right; } +.doc-content.no-sidebar { + width: 100% +} + h3 { margin: 2rem 0 0 0; } diff --git a/frontend/src/app/docs/api-docs/api-docs.component.ts b/frontend/src/app/docs/api-docs/api-docs.component.ts index ed0ecb0a2..7b78d187b 100644 --- a/frontend/src/app/docs/api-docs/api-docs.component.ts +++ b/frontend/src/app/docs/api-docs/api-docs.component.ts @@ -12,6 +12,8 @@ import { FaqTemplateDirective } from '../faq-template/faq-template.component'; styleUrls: ['./api-docs.component.scss'] }) export class ApiDocsComponent implements OnInit, AfterViewInit { + plainHostname = document.location.hostname; + electrsPort = 0; hostname = document.location.hostname; network$: Observable; active = 0; @@ -82,6 +84,20 @@ export class ApiDocsComponent implements OnInit, AfterViewInit { this.network$.subscribe((network) => { this.active = (network === 'liquid' || network === 'liquidtestnet') ? 2 : 0; + switch( network ) { + case "": + this.electrsPort = 50002; break; + case "mainnet": + this.electrsPort = 50002; break; + case "testnet": + this.electrsPort = 60002; break; + case "signet": + this.electrsPort = 60602; break; + case "liquid": + this.electrsPort = 51002; break; + case "liquidtestnet": + this.electrsPort = 51302; break; + } }); } diff --git a/frontend/src/app/docs/docs/docs.component.html b/frontend/src/app/docs/docs/docs.component.html index 04f9bb8cf..9e7f57c74 100644 --- a/frontend/src/app/docs/docs/docs.component.html +++ b/frontend/src/app/docs/docs/docs.component.html @@ -32,6 +32,15 @@ +
  • + API - Electrum RPC + + + + + +
  • +
    diff --git a/frontend/src/app/docs/docs/docs.component.ts b/frontend/src/app/docs/docs/docs.component.ts index 74cebc88f..3e74ba959 100644 --- a/frontend/src/app/docs/docs/docs.component.ts +++ b/frontend/src/app/docs/docs/docs.component.ts @@ -15,6 +15,7 @@ export class DocsComponent implements OnInit { env: Env; showWebSocketTab = true; showFaqTab = true; + showElectrsTab = true; @HostBinding('attr.dir') dir = 'ltr'; @@ -34,14 +35,18 @@ export class DocsComponent implements OnInit { } else if( url[1].path === "rest" ) { this.activeTab = 1; this.seoService.setTitle($localize`:@@e351b40b3869a5c7d19c3d4918cb1ac7aaab95c4:API`); - } else { + } else if( url[1].path === "websocket" ) { this.activeTab = 2; this.seoService.setTitle($localize`:@@e351b40b3869a5c7d19c3d4918cb1ac7aaab95c4:API`); + } else { + this.activeTab = 3; + this.seoService.setTitle($localize`:@@e351b40b3869a5c7d19c3d4918cb1ac7aaab95c4:API`); } this.env = this.stateService.env; this.showWebSocketTab = ( ! ( ( this.stateService.network === "bisq" ) || ( this.stateService.network === "liquidtestnet" ) ) ); this.showFaqTab = ( this.env.BASE_MODULE === 'mempool' ) ? true : false; + this.showElectrsTab = this.stateService.env.OFFICIAL_MEMPOOL_SPACE && ( this.stateService.network !== "bisq" ); document.querySelector( "html" ).style.scrollBehavior = "smooth"; } diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index d9670936d..8e04c8635 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -141,7 +141,7 @@ export interface TransactionStripped { fee: number; vsize: number; value: number; - status?: 'found' | 'missing' | 'added'; + status?: 'found' | 'missing' | 'added' | 'censored' | 'selected'; } export interface RewardStats { diff --git a/frontend/src/app/interfaces/websocket.interface.ts b/frontend/src/app/interfaces/websocket.interface.ts index e4ceefb44..67cc0ffc7 100644 --- a/frontend/src/app/interfaces/websocket.interface.ts +++ b/frontend/src/app/interfaces/websocket.interface.ts @@ -70,7 +70,7 @@ export interface TransactionStripped { fee: number; vsize: number; value: number; - status?: 'found' | 'missing' | 'added'; + status?: 'found' | 'missing' | 'added' | 'censored' | 'selected'; } export interface IBackendInfo { diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 65293098d..dd9de6aae 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -668,6 +668,15 @@ h1, h2, h3 { background-color: #2e324e; } +.progress.progress-health { + background: repeating-linear-gradient(to right, #2d3348, #2d3348 0%, #105fb0 0%, #1a9436 100%); + justify-content: flex-end; +} + +.progress-bar.progress-bar-health { + background: #2d3348; +} + .mt-2-5, .my-2-5 { margin-top: 0.75rem !important; } diff --git a/production/install b/production/install index 40f89389f..9bab3a418 100755 --- a/production/install +++ b/production/install @@ -401,7 +401,7 @@ FREEBSD_PKG+=(nginx rsync py39-certbot-nginx mariadb105-server keybase) FREEBSD_PKG+=(geoipupdate) FREEBSD_UNFURL_PKG=() -FREEBSD_UNFURL_PKG+=(nvidia-driver-470-470.129.06 chromium xinit xterm twm ja-sourcehansans-otf) +FREEBSD_UNFURL_PKG+=(nvidia-driver-470 chromium xinit xterm twm ja-sourcehansans-otf) FREEBSD_UNFURL_PKG+=(zh-sourcehansans-sc-otf ko-aleefonts-ttf lohit tlwg-ttf) ############################# @@ -1324,9 +1324,9 @@ case $OS in osPackageInstall ${CLN_PKG} echo "[*] Installing Core Lightning mainnet Cronjob" - crontab_cln+='@reboot sleep 60 ; screen -dmS main lightningd --alias `hostname` --bitcoin-datadir /bitcoin\n' - crontab_cln+='@reboot sleep 90 ; screen -dmS tes lightningd --alias `hostname` --bitcoin-datadir /bitcoin --network testnet\n' - crontab_cln+='@reboot sleep 120 ; screen -dmS sig lightningd --alias `hostname` --bitcoin-datadir /bitcoin --network signet\n' + crontab_cln+='@reboot sleep 60 ; screen -dmS main lightningd --alias `hostname` --fee-base 0 --bitcoin-datadir /bitcoin\n' + crontab_cln+='@reboot sleep 90 ; screen -dmS tes lightningd --alias `hostname` --fee-base 0 --bitcoin-datadir /bitcoin --network testnet\n' + crontab_cln+='@reboot sleep 120 ; screen -dmS sig lightningd --alias `hostname` --fee-base 0 --bitcoin-datadir /bitcoin --network signet\n' echo "${crontab_cln}" | crontab -u "${CLN_USER}" - ;; Debian) diff --git a/unfurler/src/language/lang.ts b/unfurler/src/language/lang.ts index 610e68312..9b8181dd6 100644 --- a/unfurler/src/language/lang.ts +++ b/unfurler/src/language/lang.ts @@ -62,15 +62,15 @@ export const languages = languageDict; // expects path to start with a leading '/' export function parseLanguageUrl(path) { - const parts = path.split('/'); + const parts = path.split('/').filter(part => part.length); let lang; let rest; - if (languages[parts[1]]) { - lang = parts[1]; - rest = '/' + parts.slice(2).join('/'); + if (languages[parts[0]]) { + lang = parts[0]; + rest = '/' + parts.slice(1).join('/'); } else { lang = null; - rest = path; + rest = '/' + parts.join('/'); } if (lang === 'en') { lang = null;