diff --git a/backend/mempool-config.sample.json b/backend/mempool-config.sample.json index fe5f2e213..3b416255a 100644 --- a/backend/mempool-config.sample.json +++ b/backend/mempool-config.sample.json @@ -82,7 +82,8 @@ "BACKEND": "lnd", "STATS_REFRESH_INTERVAL": 600, "GRAPH_REFRESH_INTERVAL": 600, - "LOGGER_UPDATE_INTERVAL": 30 + "LOGGER_UPDATE_INTERVAL": 30, + "FORENSICS_INTERVAL": 43200 }, "LND": { "TLS_CERT_PATH": "tls.cert", diff --git a/backend/src/__fixtures__/mempool-config.template.json b/backend/src/__fixtures__/mempool-config.template.json index d54365cda..ec6be20d8 100644 --- a/backend/src/__fixtures__/mempool-config.template.json +++ b/backend/src/__fixtures__/mempool-config.template.json @@ -98,7 +98,8 @@ "TOPOLOGY_FOLDER": "__LIGHTNING_TOPOLOGY_FOLDER__", "STATS_REFRESH_INTERVAL": 600, "GRAPH_REFRESH_INTERVAL": 600, - "LOGGER_UPDATE_INTERVAL": 30 + "LOGGER_UPDATE_INTERVAL": 30, + "FORENSICS_INTERVAL": 43200 }, "LND": { "TLS_CERT_PATH": "", diff --git a/backend/src/api/audit.ts b/backend/src/api/audit.ts index 1cbfe7a84..6aafc9ded 100644 --- a/backend/src/api/audit.ts +++ b/backend/src/api/audit.ts @@ -10,9 +10,9 @@ const PROPAGATION_MARGIN = 180; // in seconds, time since a transaction is first class Audit { auditBlock(transactions: TransactionExtended[], projectedBlocks: MempoolBlockWithTransactions[], mempool: { [txId: string]: TransactionExtended }) - : { censored: string[], added: string[], score: number } { + : { censored: string[], added: string[], fresh: string[], score: number } { if (!projectedBlocks?.[0]?.transactionIds || !mempool) { - return { censored: [], added: [], score: 0 }; + return { censored: [], added: [], fresh: [], score: 0 }; } const matches: string[] = []; // present in both mined block and template @@ -83,7 +83,17 @@ class Audit { } else { if (!isDisplaced[tx.txid]) { added.push(tx.txid); + } else { } + let blockIndex = -1; + let index = -1; + projectedBlocks.forEach((block, bi) => { + const i = block.transactionIds.indexOf(tx.txid); + if (i >= 0) { + blockIndex = bi; + index = i; + } + }); overflowWeight += tx.weight; } totalWeight += tx.weight; @@ -119,48 +129,10 @@ class Audit { return { censored: Object.keys(isCensored), added, + fresh, score }; } - - public async $getBlockAuditScores(fromHeight?: number, limit: number = 15): Promise { - let currentHeight = fromHeight !== undefined ? fromHeight : await blocksRepository.$mostRecentBlockHeight(); - const returnScores: AuditScore[] = []; - - if (currentHeight < 0) { - return returnScores; - } - - for (let i = 0; i < limit && currentHeight >= 0; i++) { - const block = blocks.getBlocks().find((b) => b.height === currentHeight); - if (block?.extras?.matchRate != null) { - returnScores.push({ - hash: block.id, - matchRate: block.extras.matchRate - }); - } else { - let currentHash; - if (!currentHash && Common.indexingEnabled()) { - const dbBlock = await blocksRepository.$getBlockByHeight(currentHeight); - if (dbBlock && dbBlock['id']) { - currentHash = dbBlock['id']; - } - } - if (!currentHash) { - currentHash = await bitcoinApi.$getBlockHash(currentHeight); - } - if (currentHash) { - const auditScore = await blocksAuditsRepository.$getBlockAuditScore(currentHash); - returnScores.push({ - hash: currentHash, - matchRate: auditScore?.matchRate - }); - } - } - currentHeight--; - } - return returnScores; - } } export default new Audit(); \ No newline at end of file diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index 3740cccd4..cdcc589fd 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -89,6 +89,7 @@ class BitcoinRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'blocks/:height', this.getBlocks.bind(this)) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash', this.getBlock) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/summary', this.getStrippedBlockTransactions) + .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/audit-summary', this.getBlockAuditSummary) .post(config.MEMPOOL.API_URL_PREFIX + 'psbt/addparents', this.postPsbtCompletion) ; @@ -324,6 +325,16 @@ class BitcoinRoutes { } } + private async getStrippedBlockTransactions(req: Request, res: Response) { + try { + const transactions = await blocks.$getStrippedBlockTransactions(req.params.hash); + res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString()); + res.json(transactions); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + private async getBlock(req: Request, res: Response) { try { const block = await blocks.$getBlock(req.params.hash); @@ -356,9 +367,9 @@ class BitcoinRoutes { } } - private async getStrippedBlockTransactions(req: Request, res: Response) { + private async getBlockAuditSummary(req: Request, res: Response) { try { - const transactions = await blocks.$getStrippedBlockTransactions(req.params.hash); + const transactions = await blocks.$getBlockAuditSummary(req.params.hash); res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString()); res.json(transactions); } catch (e) { diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index 562f49de1..111d0fa1e 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -590,7 +590,7 @@ class Blocks { if (skipMemoryCache === false) { // Check the memory cache const cachedSummary = this.getBlockSummaries().find((b) => b.id === hash); - if (cachedSummary) { + if (cachedSummary?.transactions?.length) { return cachedSummary.transactions; } } @@ -598,7 +598,7 @@ class Blocks { // Check if it's indexed in db if (skipDBLookup === false && Common.blocksSummariesIndexingEnabled() === true) { const indexedSummary = await BlocksSummariesRepository.$getByBlockId(hash); - if (indexedSummary !== undefined) { + if (indexedSummary !== undefined && indexedSummary?.transactions?.length) { return indexedSummary.transactions; } } @@ -651,6 +651,19 @@ class Blocks { return returnBlocks; } + public async $getBlockAuditSummary(hash: string): Promise { + let summary = await BlocksAuditsRepository.$getBlockAudit(hash); + + // fallback to non-audited transaction summary + if (!summary?.transactions?.length) { + const strippedTransactions = await this.$getStrippedBlockTransactions(hash); + summary = { + transactions: strippedTransactions + }; + } + return summary; + } + public getLastDifficultyAdjustmentTime(): number { return this.lastDifficultyAdjustmentTime; } diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts index 6dbfab723..d8e96d57d 100644 --- a/backend/src/api/database-migration.ts +++ b/backend/src/api/database-migration.ts @@ -4,7 +4,7 @@ import logger from '../logger'; import { Common } from './common'; class DatabaseMigration { - private static currentVersion = 44; + private static currentVersion = 45; private queryTimeout = 900_000; private statisticsAddedIndexed = false; private uniqueLogs: string[] = []; @@ -365,6 +365,10 @@ class DatabaseMigration { await this.$executeQuery('TRUNCATE TABLE `blocks_audits`'); await this.$executeQuery('UPDATE blocks_summaries SET template = NULL'); } + + if (databaseSchemaVersion < 45 && isBitcoin === true) { + await this.$executeQuery('ALTER TABLE `blocks_audits` ADD fresh_txs JSON DEFAULT "[]"'); + } } /** diff --git a/backend/src/api/mining/mining-routes.ts b/backend/src/api/mining/mining-routes.ts index 73d38d841..81c7b5a99 100644 --- a/backend/src/api/mining/mining-routes.ts +++ b/backend/src/api/mining/mining-routes.ts @@ -283,9 +283,12 @@ class MiningRoutes { private async $getBlockAuditScores(req: Request, res: Response) { try { - const height = req.params.height === undefined ? undefined : parseInt(req.params.height, 10); + let height = req.params.height === undefined ? undefined : parseInt(req.params.height, 10); + if (height == null) { + height = await BlocksRepository.$mostRecentBlockHeight(); + } res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); - res.json(await audits.$getBlockAuditScores(height, 15)); + res.json(await BlocksAuditsRepository.$getBlockAuditScores(height, height - 15)); } catch (e) { res.status(500).send(e instanceof Error ? e.message : e); } diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index 375869902..0499fe842 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -428,7 +428,7 @@ class WebsocketHandler { if (Common.indexingEnabled() && memPool.isInSync()) { const projectedBlocks = mempoolBlocks.getMempoolBlocksWithTransactions(); - const { censored, added, score } = Audit.auditBlock(transactions, projectedBlocks, _memPool); + const { censored, added, fresh, score } = Audit.auditBlock(transactions, projectedBlocks, _memPool); matchRate = Math.round(score * 100 * 100) / 100; const stripped = projectedBlocks[0]?.transactions ? projectedBlocks[0].transactions.map((tx) => { @@ -454,6 +454,7 @@ class WebsocketHandler { hash: block.id, addedTxs: added, missingTxs: censored, + freshTxs: fresh, matchRate: matchRate, }); diff --git a/backend/src/config.ts b/backend/src/config.ts index 4aab7a306..f7d1ee60a 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -41,6 +41,7 @@ interface IConfig { STATS_REFRESH_INTERVAL: number; GRAPH_REFRESH_INTERVAL: number; LOGGER_UPDATE_INTERVAL: number; + FORENSICS_INTERVAL: number; }; LND: { TLS_CERT_PATH: string; @@ -199,6 +200,7 @@ const defaults: IConfig = { 'STATS_REFRESH_INTERVAL': 600, 'GRAPH_REFRESH_INTERVAL': 600, 'LOGGER_UPDATE_INTERVAL': 30, + 'FORENSICS_INTERVAL': 43200, }, 'LND': { 'TLS_CERT_PATH': '', diff --git a/backend/src/index.ts b/backend/src/index.ts index cd81e4994..8371e927f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -35,6 +35,7 @@ import bisqRoutes from './api/bisq/bisq.routes'; import liquidRoutes from './api/liquid/liquid.routes'; import bitcoinRoutes from './api/bitcoin/bitcoin.routes'; import fundingTxFetcher from './tasks/lightning/sync-tasks/funding-tx-fetcher'; +import forensicsService from './tasks/lightning/forensics.service'; class Server { private wss: WebSocket.Server | undefined; @@ -192,6 +193,7 @@ class Server { try { await fundingTxFetcher.$init(); await networkSyncService.$startService(); + await forensicsService.$startService(); await lightningStatsUpdater.$startService(); } catch(e) { logger.err(`Nodejs lightning backend crashed. Restarting in 1 minute. Reason: ${(e instanceof Error ? e.message : e)}`); diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index 24bfa1565..0e68d2ed5 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -28,6 +28,7 @@ export interface BlockAudit { height: number, hash: string, missingTxs: string[], + freshTxs: string[], addedTxs: string[], matchRate: number, } diff --git a/backend/src/repositories/BlocksAuditsRepository.ts b/backend/src/repositories/BlocksAuditsRepository.ts index 2aa1fb260..c6156334b 100644 --- a/backend/src/repositories/BlocksAuditsRepository.ts +++ b/backend/src/repositories/BlocksAuditsRepository.ts @@ -1,3 +1,4 @@ +import blocks from '../api/blocks'; import DB from '../database'; import logger from '../logger'; import { BlockAudit, AuditScore } from '../mempool.interfaces'; @@ -5,9 +6,9 @@ import { BlockAudit, AuditScore } from '../mempool.interfaces'; class BlocksAuditRepositories { public async $saveAudit(audit: BlockAudit): Promise { try { - await DB.query(`INSERT INTO blocks_audits(time, height, hash, missing_txs, added_txs, match_rate) - VALUE (FROM_UNIXTIME(?), ?, ?, ?, ?, ?)`, [audit.time, audit.height, audit.hash, JSON.stringify(audit.missingTxs), - JSON.stringify(audit.addedTxs), audit.matchRate]); + await DB.query(`INSERT INTO blocks_audits(time, height, hash, missing_txs, added_txs, fresh_txs, match_rate) + VALUE (FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?)`, [audit.time, audit.height, audit.hash, JSON.stringify(audit.missingTxs), + JSON.stringify(audit.addedTxs), JSON.stringify(audit.freshTxs), audit.matchRate]); } catch (e: any) { if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart logger.debug(`Cannot save block audit for block ${audit.hash} because it has already been indexed, ignoring`); @@ -51,7 +52,7 @@ class BlocksAuditRepositories { const [rows]: any[] = await DB.query( `SELECT blocks.height, blocks.hash as id, UNIX_TIMESTAMP(blocks.blockTimestamp) as timestamp, blocks.size, blocks.weight, blocks.tx_count, - transactions, template, missing_txs as missingTxs, added_txs as addedTxs, match_rate as matchRate + transactions, template, missing_txs as missingTxs, added_txs as addedTxs, fresh_txs as freshTxs, match_rate as matchRate FROM blocks_audits JOIN blocks ON blocks.hash = blocks_audits.hash JOIN blocks_summaries ON blocks_summaries.id = blocks_audits.hash @@ -61,11 +62,15 @@ class BlocksAuditRepositories { if (rows.length) { rows[0].missingTxs = JSON.parse(rows[0].missingTxs); rows[0].addedTxs = JSON.parse(rows[0].addedTxs); + rows[0].freshTxs = JSON.parse(rows[0].freshTxs); rows[0].transactions = JSON.parse(rows[0].transactions); rows[0].template = JSON.parse(rows[0].template); + + if (rows[0].transactions.length) { + return rows[0]; + } } - - return rows[0]; + return null; } catch (e: any) { logger.err(`Cannot fetch block audit from db. Reason: ` + (e instanceof Error ? e.message : e)); throw e; @@ -85,6 +90,20 @@ class BlocksAuditRepositories { throw e; } } + + public async $getBlockAuditScores(maxHeight: number, minHeight: number): Promise { + try { + const [rows]: any[] = await DB.query( + `SELECT hash, match_rate as matchRate + FROM blocks_audits + WHERE blocks_audits.height BETWEEN ? AND ? + `, [minHeight, maxHeight]); + return rows; + } 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/tasks/lightning/forensics.service.ts b/backend/src/tasks/lightning/forensics.service.ts new file mode 100644 index 000000000..9b999fca1 --- /dev/null +++ b/backend/src/tasks/lightning/forensics.service.ts @@ -0,0 +1,225 @@ +import DB from '../../database'; +import logger from '../../logger'; +import channelsApi from '../../api/explorer/channels.api'; +import bitcoinApi from '../../api/bitcoin/bitcoin-api-factory'; +import config from '../../config'; +import { IEsploraApi } from '../../api/bitcoin/esplora-api.interface'; +import { Common } from '../../api/common'; + +const throttleDelay = 20; //ms + +class ForensicsService { + loggerTimer = 0; + closedChannelsScanBlock = 0; + txCache: { [txid: string]: IEsploraApi.Transaction } = {}; + + constructor() {} + + public async $startService(): Promise { + logger.info('Starting lightning network forensics service'); + + this.loggerTimer = new Date().getTime() / 1000; + + await this.$runTasks(); + } + + private async $runTasks(): Promise { + try { + logger.info(`Running forensics scans`); + + if (config.MEMPOOL.BACKEND === 'esplora') { + await this.$runClosedChannelsForensics(false); + } + + } catch (e) { + logger.err('ForensicsService.$runTasks() error: ' + (e instanceof Error ? e.message : e)); + } + + setTimeout(() => { this.$runTasks(); }, 1000 * config.LIGHTNING.FORENSICS_INTERVAL); + } + + /* + 1. Mutually closed + 2. Forced closed + 3. Forced closed with penalty + + ┌────────────────────────────────────┐ ┌────────────────────────────┐ + │ outputs contain revocation script? ├──yes──► force close w/ penalty = 3 │ + └──────────────┬─────────────────────┘ └────────────────────────────┘ + no + ┌──────────────▼──────────────────────────┐ + │ outputs contain other lightning script? ├──┐ + └──────────────┬──────────────────────────┘ │ + no yes + ┌──────────────▼─────────────┐ │ + │ sequence starts with 0x80 │ ┌────────▼────────┐ + │ and ├──────► force close = 2 │ + │ locktime starts with 0x20? │ └─────────────────┘ + └──────────────┬─────────────┘ + no + ┌─────────▼────────┐ + │ mutual close = 1 │ + └──────────────────┘ + */ + + public async $runClosedChannelsForensics(onlyNewChannels: boolean = false): Promise { + if (config.MEMPOOL.BACKEND !== 'esplora') { + return; + } + + let progress = 0; + + try { + logger.info(`Started running closed channel forensics...`); + let channels; + if (onlyNewChannels) { + channels = await channelsApi.$getClosedChannelsWithoutReason(); + } else { + channels = await channelsApi.$getUnresolvedClosedChannels(); + } + + for (const channel of channels) { + let reason = 0; + let resolvedForceClose = false; + // Only Esplora backend can retrieve spent transaction outputs + const cached: string[] = []; + try { + let outspends: IEsploraApi.Outspend[] | undefined; + try { + outspends = await bitcoinApi.$getOutspends(channel.closing_transaction_id); + await Common.sleep$(throttleDelay); + } catch (e) { + logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + channel.closing_transaction_id + '/outspends'}. Reason ${e instanceof Error ? e.message : e}`); + continue; + } + const lightningScriptReasons: number[] = []; + for (const outspend of outspends) { + if (outspend.spent && outspend.txid) { + let spendingTx: IEsploraApi.Transaction | undefined = this.txCache[outspend.txid]; + if (!spendingTx) { + try { + spendingTx = await bitcoinApi.$getRawTransaction(outspend.txid); + await Common.sleep$(throttleDelay); + this.txCache[outspend.txid] = spendingTx; + } catch (e) { + logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + outspend.txid}. Reason ${e instanceof Error ? e.message : e}`); + continue; + } + } + cached.push(spendingTx.txid); + const lightningScript = this.findLightningScript(spendingTx.vin[outspend.vin || 0]); + lightningScriptReasons.push(lightningScript); + } + } + const filteredReasons = lightningScriptReasons.filter((r) => r !== 1); + if (filteredReasons.length) { + if (filteredReasons.some((r) => r === 2 || r === 4)) { + reason = 3; + } else { + reason = 2; + resolvedForceClose = true; + } + } else { + /* + We can detect a commitment transaction (force close) by reading Sequence and Locktime + https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction + */ + let closingTx: IEsploraApi.Transaction | undefined = this.txCache[channel.closing_transaction_id]; + if (!closingTx) { + try { + closingTx = await bitcoinApi.$getRawTransaction(channel.closing_transaction_id); + await Common.sleep$(throttleDelay); + this.txCache[channel.closing_transaction_id] = closingTx; + } catch (e) { + logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + channel.closing_transaction_id}. Reason ${e instanceof Error ? e.message : e}`); + continue; + } + } + cached.push(closingTx.txid); + const sequenceHex: string = closingTx.vin[0].sequence.toString(16); + const locktimeHex: string = closingTx.locktime.toString(16); + if (sequenceHex.substring(0, 2) === '80' && locktimeHex.substring(0, 2) === '20') { + reason = 2; // Here we can't be sure if it's a penalty or not + } else { + reason = 1; + } + } + if (reason) { + logger.debug('Setting closing reason ' + reason + ' for channel: ' + channel.id + '.'); + await DB.query(`UPDATE channels SET closing_reason = ? WHERE id = ?`, [reason, channel.id]); + if (reason === 2 && resolvedForceClose) { + await DB.query(`UPDATE channels SET closing_resolved = ? WHERE id = ?`, [true, channel.id]); + } + if (reason !== 2 || resolvedForceClose) { + cached.forEach(txid => { + delete this.txCache[txid]; + }); + } + } + } catch (e) { + logger.err(`$runClosedChannelsForensics() failed for channel ${channel.short_id}. Reason: ${e instanceof Error ? e.message : e}`); + } + + ++progress; + const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer); + if (elapsedSeconds > 10) { + logger.info(`Updating channel closed channel forensics ${progress}/${channels.length}`); + this.loggerTimer = new Date().getTime() / 1000; + } + } + logger.info(`Closed channels forensics scan complete.`); + } catch (e) { + logger.err('$runClosedChannelsForensics() error: ' + (e instanceof Error ? e.message : e)); + } + } + + private findLightningScript(vin: IEsploraApi.Vin): number { + const topElement = vin.witness[vin.witness.length - 2]; + if (/^OP_IF OP_PUSHBYTES_33 \w{66} OP_ELSE OP_PUSH(NUM_\d+|BYTES_(1 \w{2}|2 \w{4})) OP_CSV OP_DROP OP_PUSHBYTES_33 \w{66} OP_ENDIF OP_CHECKSIG$/.test(vin.inner_witnessscript_asm)) { + // https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction-outputs + if (topElement === '01') { + // top element is '01' to get in the revocation path + // 'Revoked Lightning Force Close'; + // Penalty force closed + return 2; + } else { + // top element is '', this is a delayed to_local output + // 'Lightning Force Close'; + return 3; + } + } else if ( + /^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_NOTIF OP_DROP OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(vin.inner_witnessscript_asm) || + /^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_IF OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_DROP OP_PUSHBYTES_3 \w{6} OP_CLTV OP_DROP OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(vin.inner_witnessscript_asm) + ) { + // https://github.com/lightning/bolts/blob/master/03-transactions.md#offered-htlc-outputs + // https://github.com/lightning/bolts/blob/master/03-transactions.md#received-htlc-outputs + if (topElement.length === 66) { + // top element is a public key + // 'Revoked Lightning HTLC'; Penalty force closed + return 4; + } else if (topElement) { + // top element is a preimage + // 'Lightning HTLC'; + return 5; + } else { + // top element is '' to get in the expiry of the script + // 'Expired Lightning HTLC'; + return 6; + } + } else if (/^OP_PUSHBYTES_33 \w{66} OP_CHECKSIG OP_IFDUP OP_NOTIF OP_PUSHNUM_16 OP_CSV OP_ENDIF$/.test(vin.inner_witnessscript_asm)) { + // https://github.com/lightning/bolts/blob/master/03-transactions.md#to_local_anchor-and-to_remote_anchor-output-option_anchors + if (topElement) { + // top element is a signature + // 'Lightning Anchor'; + return 7; + } else { + // top element is '', it has been swept after 16 blocks + // 'Swept Lightning Anchor'; + return 8; + } + } + return 1; + } +} + +export default new ForensicsService(); diff --git a/backend/src/tasks/lightning/network-sync.service.ts b/backend/src/tasks/lightning/network-sync.service.ts index 2910f0f9c..9f40a350a 100644 --- a/backend/src/tasks/lightning/network-sync.service.ts +++ b/backend/src/tasks/lightning/network-sync.service.ts @@ -14,6 +14,7 @@ import NodesSocketsRepository from '../../repositories/NodesSocketsRepository'; import { Common } from '../../api/common'; import blocks from '../../api/blocks'; import NodeRecordsRepository from '../../repositories/NodeRecordsRepository'; +import forensicsService from './forensics.service'; class NetworkSyncService { loggerTimer = 0; @@ -46,8 +47,10 @@ class NetworkSyncService { await this.$lookUpCreationDateFromChain(); await this.$updateNodeFirstSeen(); await this.$scanForClosedChannels(); + if (config.MEMPOOL.BACKEND === 'esplora') { - await this.$runClosedChannelsForensics(); + // run forensics on new channels only + await forensicsService.$runClosedChannelsForensics(true); } } catch (e) { @@ -301,174 +304,6 @@ class NetworkSyncService { logger.err('$scanForClosedChannels() error: ' + (e instanceof Error ? e.message : e)); } } - - /* - 1. Mutually closed - 2. Forced closed - 3. Forced closed with penalty - - ┌────────────────────────────────────┐ ┌────────────────────────────┐ - │ outputs contain revocation script? ├──yes──► force close w/ penalty = 3 │ - └──────────────┬─────────────────────┘ └────────────────────────────┘ - no - ┌──────────────▼──────────────────────────┐ - │ outputs contain other lightning script? ├──┐ - └──────────────┬──────────────────────────┘ │ - no yes - ┌──────────────▼─────────────┐ │ - │ sequence starts with 0x80 │ ┌────────▼────────┐ - │ and ├──────► force close = 2 │ - │ locktime starts with 0x20? │ └─────────────────┘ - └──────────────┬─────────────┘ - no - ┌─────────▼────────┐ - │ mutual close = 1 │ - └──────────────────┘ - */ - - private async $runClosedChannelsForensics(skipUnresolved: boolean = false): Promise { - if (!config.ESPLORA.REST_API_URL) { - return; - } - - let progress = 0; - - try { - logger.info(`Started running closed channel forensics...`); - let channels; - const closedChannels = await channelsApi.$getClosedChannelsWithoutReason(); - if (skipUnresolved) { - channels = closedChannels; - } else { - const unresolvedChannels = await channelsApi.$getUnresolvedClosedChannels(); - channels = [...closedChannels, ...unresolvedChannels]; - } - - for (const channel of channels) { - let reason = 0; - let resolvedForceClose = false; - // Only Esplora backend can retrieve spent transaction outputs - try { - let outspends: IEsploraApi.Outspend[] | undefined; - try { - outspends = await bitcoinApi.$getOutspends(channel.closing_transaction_id); - } catch (e) { - logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + channel.closing_transaction_id + '/outspends'}. Reason ${e instanceof Error ? e.message : e}`); - continue; - } - const lightningScriptReasons: number[] = []; - for (const outspend of outspends) { - if (outspend.spent && outspend.txid) { - let spendingTx: IEsploraApi.Transaction | undefined; - try { - spendingTx = await bitcoinApi.$getRawTransaction(outspend.txid); - } catch (e) { - logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + outspend.txid}. Reason ${e instanceof Error ? e.message : e}`); - continue; - } - const lightningScript = this.findLightningScript(spendingTx.vin[outspend.vin || 0]); - lightningScriptReasons.push(lightningScript); - } - } - const filteredReasons = lightningScriptReasons.filter((r) => r !== 1); - if (filteredReasons.length) { - if (filteredReasons.some((r) => r === 2 || r === 4)) { - reason = 3; - } else { - reason = 2; - resolvedForceClose = true; - } - } else { - /* - We can detect a commitment transaction (force close) by reading Sequence and Locktime - https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction - */ - let closingTx: IEsploraApi.Transaction | undefined; - try { - closingTx = await bitcoinApi.$getRawTransaction(channel.closing_transaction_id); - } catch (e) { - logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + channel.closing_transaction_id}. Reason ${e instanceof Error ? e.message : e}`); - continue; - } - const sequenceHex: string = closingTx.vin[0].sequence.toString(16); - const locktimeHex: string = closingTx.locktime.toString(16); - if (sequenceHex.substring(0, 2) === '80' && locktimeHex.substring(0, 2) === '20') { - reason = 2; // Here we can't be sure if it's a penalty or not - } else { - reason = 1; - } - } - if (reason) { - logger.debug('Setting closing reason ' + reason + ' for channel: ' + channel.id + '.'); - await DB.query(`UPDATE channels SET closing_reason = ? WHERE id = ?`, [reason, channel.id]); - if (reason === 2 && resolvedForceClose) { - await DB.query(`UPDATE channels SET closing_resolved = ? WHERE id = ?`, [true, channel.id]); - } - } - } catch (e) { - logger.err(`$runClosedChannelsForensics() failed for channel ${channel.short_id}. Reason: ${e instanceof Error ? e.message : e}`); - } - - ++progress; - const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer); - if (elapsedSeconds > 10) { - logger.info(`Updating channel closed channel forensics ${progress}/${channels.length}`); - this.loggerTimer = new Date().getTime() / 1000; - } - } - logger.info(`Closed channels forensics scan complete.`); - } catch (e) { - logger.err('$runClosedChannelsForensics() error: ' + (e instanceof Error ? e.message : e)); - } - } - - private findLightningScript(vin: IEsploraApi.Vin): number { - const topElement = vin.witness[vin.witness.length - 2]; - if (/^OP_IF OP_PUSHBYTES_33 \w{66} OP_ELSE OP_PUSH(NUM_\d+|BYTES_(1 \w{2}|2 \w{4})) OP_CSV OP_DROP OP_PUSHBYTES_33 \w{66} OP_ENDIF OP_CHECKSIG$/.test(vin.inner_witnessscript_asm)) { - // https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction-outputs - if (topElement === '01') { - // top element is '01' to get in the revocation path - // 'Revoked Lightning Force Close'; - // Penalty force closed - return 2; - } else { - // top element is '', this is a delayed to_local output - // 'Lightning Force Close'; - return 3; - } - } else if ( - /^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_NOTIF OP_DROP OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(vin.inner_witnessscript_asm) || - /^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_IF OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_DROP OP_PUSHBYTES_3 \w{6} OP_CLTV OP_DROP OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(vin.inner_witnessscript_asm) - ) { - // https://github.com/lightning/bolts/blob/master/03-transactions.md#offered-htlc-outputs - // https://github.com/lightning/bolts/blob/master/03-transactions.md#received-htlc-outputs - if (topElement.length === 66) { - // top element is a public key - // 'Revoked Lightning HTLC'; Penalty force closed - return 4; - } else if (topElement) { - // top element is a preimage - // 'Lightning HTLC'; - return 5; - } else { - // top element is '' to get in the expiry of the script - // 'Expired Lightning HTLC'; - return 6; - } - } else if (/^OP_PUSHBYTES_33 \w{66} OP_CHECKSIG OP_IFDUP OP_NOTIF OP_PUSHNUM_16 OP_CSV OP_ENDIF$/.test(vin.inner_witnessscript_asm)) { - // https://github.com/lightning/bolts/blob/master/03-transactions.md#to_local_anchor-and-to_remote_anchor-output-option_anchors - if (topElement) { - // top element is a signature - // 'Lightning Anchor'; - return 7; - } else { - // top element is '', it has been swept after 16 blocks - // 'Swept Lightning Anchor'; - return 8; - } - } - return 1; - } } export default new NetworkSyncService(); diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 69c78fc83..d9c6a93bb 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -4,7 +4,6 @@ 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 { BlockAuditComponent } from './components/block-audit/block-audit.component'; import { AddressComponent } from './components/address/address.component'; import { MasterPageComponent } from './components/master-page/master-page.component'; import { AboutComponent } from './components/about/about.component'; @@ -103,16 +102,6 @@ let routes: Routes = [ }, ], }, - { - path: 'block-audit', - data: { networkSpecific: true }, - children: [ - { - path: ':id', - component: BlockAuditComponent, - }, - ], - }, { path: 'docs', loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule), @@ -219,16 +208,6 @@ let routes: Routes = [ }, ], }, - { - path: 'block-audit', - data: { networkSpecific: true }, - children: [ - { - path: ':id', - component: BlockAuditComponent, - }, - ], - }, { path: 'docs', loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule) @@ -331,16 +310,6 @@ let routes: Routes = [ }, ], }, - { - path: 'block-audit', - data: { networkSpecific: true }, - children: [ - { - path: ':id', - component: BlockAuditComponent - }, - ], - }, { path: 'docs', loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule) diff --git a/frontend/src/app/components/asset-circulation/asset-circulation.component.ts b/frontend/src/app/components/asset-circulation/asset-circulation.component.ts index c828e4c31..cc09c4809 100644 --- a/frontend/src/app/components/asset-circulation/asset-circulation.component.ts +++ b/frontend/src/app/components/asset-circulation/asset-circulation.component.ts @@ -4,7 +4,7 @@ import { map } from 'rxjs/operators'; import { moveDec } from '../../bitcoin.utils'; import { AssetsService } from '../../services/assets.service'; import { ElectrsApiService } from '../../services/electrs-api.service'; -import { environment } from 'src/environments/environment'; +import { environment } from '../../../environments/environment'; @Component({ selector: 'app-asset-circulation', diff --git a/frontend/src/app/components/asset/asset.component.ts b/frontend/src/app/components/asset/asset.component.ts index b08ca88ef..0e642063a 100644 --- a/frontend/src/app/components/asset/asset.component.ts +++ b/frontend/src/app/components/asset/asset.component.ts @@ -9,7 +9,7 @@ import { AudioService } from '../../services/audio.service'; import { ApiService } from '../../services/api.service'; import { of, merge, Subscription, combineLatest } from 'rxjs'; import { SeoService } from '../../services/seo.service'; -import { environment } from 'src/environments/environment'; +import { environment } from '../../../environments/environment'; import { AssetsService } from '../../services/assets.service'; import { moveDec } from '../../bitcoin.utils'; diff --git a/frontend/src/app/components/assets/assets-nav/assets-nav.component.ts b/frontend/src/app/components/assets/assets-nav/assets-nav.component.ts index d3edfc9b1..acac8bafb 100644 --- a/frontend/src/app/components/assets/assets-nav/assets-nav.component.ts +++ b/frontend/src/app/components/assets/assets-nav/assets-nav.component.ts @@ -9,7 +9,7 @@ import { AssetsService } from '../../../services/assets.service'; import { SeoService } from '../../../services/seo.service'; import { StateService } from '../../../services/state.service'; import { RelativeUrlPipe } from '../../../shared/pipes/relative-url/relative-url.pipe'; -import { environment } from 'src/environments/environment'; +import { environment } from '../../../../environments/environment'; @Component({ selector: 'app-assets-nav', diff --git a/frontend/src/app/components/assets/assets.component.ts b/frontend/src/app/components/assets/assets.component.ts index 7368d6c8e..bd046ae7a 100644 --- a/frontend/src/app/components/assets/assets.component.ts +++ b/frontend/src/app/components/assets/assets.component.ts @@ -1,6 +1,6 @@ import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core'; import { AssetsService } from '../../services/assets.service'; -import { environment } from 'src/environments/environment'; +import { environment } from '../../../environments/environment'; import { FormGroup } from '@angular/forms'; import { filter, map, switchMap, take } from 'rxjs/operators'; import { ActivatedRoute, Router } from '@angular/router'; diff --git a/frontend/src/app/components/block-audit/block-audit.component.html b/frontend/src/app/components/block-audit/block-audit.component.html deleted file mode 100644 index a3f2e2ada..000000000 --- a/frontend/src/app/components/block-audit/block-audit.component.html +++ /dev/null @@ -1,172 +0,0 @@ -
- -
-

- - Block Audit -   - {{ blockAudit.height }} -   - -

- -
- - -
- -
- - - -
-
- -
- - - - - - - - - - - - - - - - - - - -
Hash{{ blockHash | shortenString : 13 }} - -
Timestamp - ‎{{ blockAudit.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }} -
- ( - ) -
-
Size
Weight
-
- - -
- - - - - - - - - - - - - - - - - - - -
Transactions{{ blockAudit.tx_count }}
Block health{{ blockAudit.matchRate }}%
Removed txs{{ blockAudit.missingTxs.length }}
Added txs{{ blockAudit.addedTxs.length }}
-
-
-
- - - -
- - - -
-
- -
- - - - - - - -
-
- - -
- - - - - - - -
-
-
-
- - - -
- - -
-
- 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 deleted file mode 100644 index 1e35b7c63..000000000 --- a/frontend/src/app/components/block-audit/block-audit.component.scss +++ /dev/null @@ -1,44 +0,0 @@ -.title-block { - border-top: none; -} - -.table { - tr td { - &:last-child { - text-align: right; - @media (min-width: 768px) { - text-align: left; - } - } - } -} - -.block-tx-title { - display: flex; - justify-content: space-between; - flex-direction: column; - position: relative; - @media (min-width: 550px) { - flex-direction: row; - } - h2 { - line-height: 1; - margin: 0; - position: relative; - padding-bottom: 10px; - @media (min-width: 550px) { - padding-bottom: 0px; - align-self: end; - } - } -} - -.menu-button { - @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 deleted file mode 100644 index 3787796fd..000000000 --- a/frontend/src/app/components/block-audit/block-audit.component.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { Component, OnDestroy, OnInit, AfterViewInit, ViewChildren, QueryList } from '@angular/core'; -import { ActivatedRoute, ParamMap, Router } from '@angular/router'; -import { Subscription, combineLatest, of } from 'rxjs'; -import { map, switchMap, startWith, catchError, filter } from 'rxjs/operators'; -import { BlockAudit, TransactionStripped } from '../../interfaces/node-api.interface'; -import { ApiService } from '../../services/api.service'; -import { ElectrsApiService } from '../../services/electrs-api.service'; -import { StateService } from '../../services/state.service'; -import { detectWebGL } from '../../shared/graphs.utils'; -import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; -import { BlockOverviewGraphComponent } from '../block-overview-graph/block-overview-graph.component'; - -@Component({ - selector: 'app-block-audit', - templateUrl: './block-audit.component.html', - styleUrls: ['./block-audit.component.scss'], - styles: [` - .loadingGraphs { - position: absolute; - top: 50%; - left: calc(50% - 15px); - z-index: 100; - } - `], -}) -export class BlockAuditComponent implements OnInit, AfterViewInit, OnDestroy { - blockAudit: BlockAudit = undefined; - transactions: string[]; - auditSubscription: Subscription; - urlFragmentSubscription: Subscription; - - paginationMaxSize: number; - page = 1; - itemsPerPage: number; - - mode: 'projected' | 'actual' = 'projected'; - error: any; - isLoading = true; - webGlEnabled = true; - isMobile = window.innerWidth <= 767.98; - hoverTx: string; - - childChangeSubscription: Subscription; - - blockHash: string; - numMissing: number = 0; - numUnexpected: number = 0; - - @ViewChildren('blockGraphProjected') blockGraphProjected: QueryList; - @ViewChildren('blockGraphActual') blockGraphActual: QueryList; - - constructor( - private route: ActivatedRoute, - public stateService: StateService, - private router: Router, - private apiService: ApiService, - private electrsApiService: ElectrsApiService, - ) { - this.webGlEnabled = detectWebGL(); - } - - 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.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 = params.get('id') || null; - if (!blockHash) { - return null; - } - - let isBlockHeight = false; - if (/^[0-9]+$/.test(blockHash)) { - isBlockHeight = true; - } else { - this.blockHash = blockHash; - } - - if (isBlockHeight) { - return this.electrsApiService.getBlockHashFromHeight$(parseInt(blockHash, 10)) - .pipe( - switchMap((hash: string) => { - if (hash) { - this.blockHash = hash; - return this.apiService.getBlockAudit$(this.blockHash) - } else { - return null; - } - }), - catchError((err) => { - this.error = err; - return of(null); - }), - ); - } - return this.apiService.getBlockAudit$(this.blockHash) - }), - filter((response) => response != null), - map((response) => { - const blockAudit = response.body; - 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 { - tx.status = 'missing'; - isMissing[tx.txid] = true; - this.numMissing++; - } - } - for (const [index, tx] of blockAudit.transactions.entries()) { - if (index === 0) { - tx.status = null; - } else if (isAdded[tx.txid]) { - tx.status = 'added'; - } else if (inTemplate[tx.txid]) { - tx.status = 'found'; - } else { - tx.status = 'selected'; - isSelected[tx.txid] = true; - this.numUnexpected++; - } - } - for (const tx of blockAudit.transactions) { - inBlock[tx.txid] = true; - } - return blockAudit; - }), - catchError((err) => { - console.log(err); - this.error = err; - this.isLoading = false; - return of(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) { - 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: 'projected' | 'actual') { - this.router.navigate([], { fragment: mode }); - } - - onTxClick(event: TransactionStripped): void { - const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`); - this.router.navigate([url]); - } - - onTxHover(txid: string): void { - if (txid && txid.length) { - this.hoverTx = txid; - } else { - this.hoverTx = null; - } - } -} diff --git a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.html b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.html index 782cbe25e..77ee62cae 100644 --- a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.html +++ b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.html @@ -1,7 +1,8 @@
-
-
+
+
+
not available
(); @Output() txHoverEvent = new EventEmitter(); @Output() readyEvent = new EventEmitter(); 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 f07d96eb0..f73b83fd4 100644 --- a/frontend/src/app/components/block-overview-graph/tx-view.ts +++ b/frontend/src/app/components/block-overview-graph/tx-view.ts @@ -3,12 +3,13 @@ import { FastVertexArray } from './fast-vertex-array'; import { TransactionStripped } from '../../interfaces/websocket.interface'; import { SpriteUpdateParams, Square, Color, ViewUpdateParams } from './sprite-types'; import { feeLevels, mempoolFeeColors } from '../../app.constants'; +import BlockScene from './block-scene'; const hoverTransitionTime = 300; const defaultHoverColor = hexToColor('1bd8f4'); const feeColors = mempoolFeeColors.map(hexToColor); -const auditFeeColors = feeColors.map((color) => desaturate(color, 0.3)); +const auditFeeColors = feeColors.map((color) => darken(desaturate(color, 0.3), 0.9)); const auditColors = { censored: hexToColor('f344df'), missing: darken(desaturate(hexToColor('f344df'), 0.3), 0.7), @@ -34,7 +35,8 @@ export default class TxView implements TransactionStripped { vsize: number; value: number; feerate: number; - status?: 'found' | 'missing' | 'added' | 'censored' | 'selected'; + status?: 'found' | 'missing' | 'fresh' | 'added' | 'censored' | 'selected'; + context?: 'projected' | 'actual'; initialised: boolean; vertexArray: FastVertexArray; @@ -48,6 +50,7 @@ export default class TxView implements TransactionStripped { dirty: boolean; constructor(tx: TransactionStripped, vertexArray: FastVertexArray) { + this.context = tx.context; this.txid = tx.txid; this.fee = tx.fee; this.vsize = tx.vsize; @@ -159,12 +162,18 @@ export default class TxView implements TransactionStripped { return auditColors.censored; case 'missing': return auditColors.missing; + case 'fresh': + return auditColors.missing; case 'added': return auditColors.added; case 'selected': return auditColors.selected; case 'found': - return auditFeeColors[feeLevelIndex] || auditFeeColors[mempoolFeeColors.length - 1]; + if (this.context === 'projected') { + return auditFeeColors[feeLevelIndex] || auditFeeColors[mempoolFeeColors.length - 1]; + } else { + return feeLevelColor; + } default: return feeLevelColor; } 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 8c1002025..71801bfb4 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 @@ -37,9 +37,10 @@ match removed - omitted + marginal fee rate + recently broadcast added - extra + marginal fee rate diff --git a/frontend/src/app/components/block/block.component.html b/frontend/src/app/components/block/block.component.html index be1de7a26..26b58507c 100644 --- a/frontend/src/app/components/block/block.component.html +++ b/frontend/src/app/components/block/block.component.html @@ -54,7 +54,19 @@ Weight - + + Block health + + {{ blockAudit.matchRate }}% + Unknown + + + + + + Fee span + {{ block.extras.feeRange[0] | number:'1.0-0' }} - {{ block.extras.feeRange[block.extras.feeRange.length - 1] | number:'1.0-0' }} sat/vB + Median fee ~{{ block?.extras?.medianFee | number:'1.0-0' }} sat/vB @@ -98,26 +110,19 @@ Miner - {{ block.extras.pool.name }} - {{ block.extras.pool.name }} - - Block health - - {{ block.extras.matchRate }}% - Unknown - - - +
@@ -138,7 +143,11 @@ - + + + + + @@ -148,17 +157,25 @@ - + - + + + +
-
- +
+
+ + + + + @@ -216,8 +233,9 @@
Fee span{{ block.extras.feeRange[0] | number:'1.0-0' }} - {{ block.extras.feeRange[block.extras.feeRange.length - 1] | number:'1.0-0' }} sat/vB
Median fee ~{{ block?.extras?.medianFee | number:'1.0-0' }} sat/vB
- +
+ @@ -230,22 +248,54 @@ + + +
-
-
- +
+ +
+ + + +
+ + +
+ +
+
+

Projected Block

+ +
+
+

Actual Block

+ +
+
+
+

@@ -273,6 +323,7 @@
+ diff --git a/frontend/src/app/components/block/block.component.scss b/frontend/src/app/components/block/block.component.scss index d6c4d65b4..69002de79 100644 --- a/frontend/src/app/components/block/block.component.scss +++ b/frontend/src/app/components/block/block.component.scss @@ -171,3 +171,35 @@ h1 { margin: auto; } } + +.menu-button { + @media (min-width: 768px) { + max-width: 150px; + } +} + +.block-subtitle { + text-align: center; +} + +.nav-tabs { + border-color: white; + border-width: 1px; +} + +.nav-tabs .nav-link { + background: inherit; + border-width: 1px; + border-bottom: none; + border-color: transparent; + margin-bottom: -1px; + cursor: pointer; + + &.active { + background: #24273e; + } + + &.active, &:hover { + border-color: white; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/block/block.component.ts b/frontend/src/app/components/block/block.component.ts index aff07a95e..916904375 100644 --- a/frontend/src/app/components/block/block.component.ts +++ b/frontend/src/app/components/block/block.component.ts @@ -1,15 +1,15 @@ -import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'; +import { Component, OnInit, OnDestroy, ViewChildren, QueryList } from '@angular/core'; import { Location } from '@angular/common'; import { ActivatedRoute, ParamMap, Router } from '@angular/router'; import { ElectrsApiService } from '../../services/electrs-api.service'; -import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith, pairwise } from 'rxjs/operators'; +import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith, pairwise, filter } from 'rxjs/operators'; import { Transaction, Vout } from '../../interfaces/electrs.interface'; -import { Observable, of, Subscription, asyncScheduler, EMPTY, Subject } from 'rxjs'; +import { Observable, of, Subscription, asyncScheduler, EMPTY, combineLatest } from 'rxjs'; import { StateService } from '../../services/state.service'; import { SeoService } from '../../services/seo.service'; import { WebsocketService } from '../../services/websocket.service'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; -import { BlockExtended, TransactionStripped } from '../../interfaces/node-api.interface'; +import { BlockAudit, BlockExtended, TransactionStripped } from '../../interfaces/node-api.interface'; import { ApiService } from '../../services/api.service'; import { BlockOverviewGraphComponent } from '../../components/block-overview-graph/block-overview-graph.component'; import { detectWebGL } from '../../shared/graphs.utils'; @@ -17,11 +17,20 @@ import { detectWebGL } from '../../shared/graphs.utils'; @Component({ selector: 'app-block', templateUrl: './block.component.html', - styleUrls: ['./block.component.scss'] + styleUrls: ['./block.component.scss'], + styles: [` + .loadingGraphs { + position: absolute; + top: 50%; + left: calc(50% - 15px); + z-index: 100; + } + `], }) export class BlockComponent implements OnInit, OnDestroy { network = ''; block: BlockExtended; + blockAudit: BlockAudit = undefined; blockHeight: number; lastBlockHeight: number; nextBlockHeight: number; @@ -48,9 +57,16 @@ export class BlockComponent implements OnInit, OnDestroy { overviewError: any = null; webGlEnabled = true; indexingAvailable = false; + auditEnabled = true; + isMobile = window.innerWidth <= 767.98; + hoverTx: string; + numMissing: number = 0; + numUnexpected: number = 0; + mode: 'projected' | 'actual' = 'projected'; transactionSubscription: Subscription; overviewSubscription: Subscription; + auditSubscription: Subscription; keyNavigationSubscription: Subscription; blocksSubscription: Subscription; networkChangedSubscription: Subscription; @@ -60,10 +76,10 @@ export class BlockComponent implements OnInit, OnDestroy { nextBlockTxListSubscription: Subscription = undefined; timeLtrSubscription: Subscription; timeLtr: boolean; - fetchAuditScore$ = new Subject(); - fetchAuditScoreSubscription: Subscription; + childChangeSubscription: Subscription; - @ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent; + @ViewChildren('blockGraphProjected') blockGraphProjected: QueryList; + @ViewChildren('blockGraphActual') blockGraphActual: QueryList; constructor( private route: ActivatedRoute, @@ -89,8 +105,8 @@ export class BlockComponent implements OnInit, OnDestroy { this.timeLtr = !!ltr; }); - this.indexingAvailable = (this.stateService.env.BASE_MODULE === 'mempool' && - this.stateService.env.MINING_DASHBOARD === true); + this.indexingAvailable = (this.stateService.env.BASE_MODULE === 'mempool' && this.stateService.env.MINING_DASHBOARD === true); + this.auditEnabled = this.indexingAvailable; this.txsLoadingStatus$ = this.route.paramMap .pipe( @@ -107,30 +123,12 @@ export class BlockComponent implements OnInit, OnDestroy { if (block.id === this.blockHash) { this.block = block; - if (this.block.id && this.block?.extras?.matchRate == null) { - this.fetchAuditScore$.next(this.block.id); - } if (block?.extras?.reward != undefined) { this.fees = block.extras.reward / 100000000 - this.blockSubsidy; } } }); - if (this.indexingAvailable) { - this.fetchAuditScoreSubscription = this.fetchAuditScore$ - .pipe( - switchMap((hash) => this.apiService.getBlockAuditScore$(hash)), - catchError(() => EMPTY), - ) - .subscribe((score) => { - if (score && score.hash === this.block.id) { - this.block.extras.matchRate = score.matchRate || null; - } else { - this.block.extras.matchRate = null; - } - }); - } - const block$ = this.route.paramMap.pipe( switchMap((params: ParamMap) => { const blockHash: string = params.get('id') || ''; @@ -212,7 +210,7 @@ export class BlockComponent implements OnInit, OnDestroy { setTimeout(() => { this.nextBlockSubscription = this.apiService.getBlock$(block.previousblockhash).subscribe(); this.nextBlockTxListSubscription = this.electrsApiService.getBlockTransactions$(block.previousblockhash).subscribe(); - this.nextBlockSummarySubscription = this.apiService.getStrippedBlockTransactions$(block.previousblockhash).subscribe(); + this.apiService.getBlockAudit$(block.previousblockhash); }, 100); } @@ -229,9 +227,6 @@ export class BlockComponent implements OnInit, OnDestroy { this.fees = block.extras.reward / 100000000 - this.blockSubsidy; } this.stateService.markBlock$.next({ blockHeight: this.blockHeight }); - if (this.block.id && this.block?.extras?.matchRate == null) { - this.fetchAuditScore$.next(this.block.id); - } this.isLoadingTransactions = true; this.transactions = null; this.transactionsError = null; @@ -263,40 +258,126 @@ export class BlockComponent implements OnInit, OnDestroy { this.isLoadingOverview = false; }); - this.overviewSubscription = block$.pipe( - startWith(null), - pairwise(), - switchMap(([prevBlock, block]) => this.apiService.getStrippedBlockTransactions$(block.id) - .pipe( - catchError((err) => { - this.overviewError = err; - return of([]); - }), - switchMap((transactions) => { - if (prevBlock) { - return of({ transactions, direction: (prevBlock.height < block.height) ? 'right' : 'left' }); - } else { - return of({ transactions, direction: 'down' }); + if (!this.indexingAvailable) { + this.overviewSubscription = block$.pipe( + startWith(null), + pairwise(), + switchMap(([prevBlock, block]) => this.apiService.getStrippedBlockTransactions$(block.id) + .pipe( + catchError((err) => { + this.overviewError = err; + return of([]); + }), + switchMap((transactions) => { + if (prevBlock) { + return of({ transactions, direction: (prevBlock.height < block.height) ? 'right' : 'left' }); + } else { + return of({ transactions, direction: 'down' }); + } + }) + ) + ), + ) + .subscribe(({transactions, direction}: {transactions: TransactionStripped[], direction: string}) => { + this.strippedTransactions = transactions; + this.isLoadingOverview = false; + this.setupBlockGraphs(); + }, + (error) => { + this.error = error; + this.isLoadingOverview = false; + }); + } + + if (this.indexingAvailable) { + this.auditSubscription = block$.pipe( + startWith(null), + pairwise(), + switchMap(([prevBlock, block]) => this.apiService.getBlockAudit$(block.id) + .pipe( + catchError((err) => { + this.overviewError = err; + return of([]); + }) + ) + ), + filter((response) => response != null), + map((response) => { + const blockAudit = response.body; + const inTemplate = {}; + const inBlock = {}; + const isAdded = {}; + const isCensored = {}; + const isMissing = {}; + const isSelected = {}; + const isFresh = {}; + this.numMissing = 0; + this.numUnexpected = 0; + + if (blockAudit?.template) { + for (const tx of blockAudit.template) { + inTemplate[tx.txid] = true; } - }) - ) - ), - ) - .subscribe(({transactions, direction}: {transactions: TransactionStripped[], direction: string}) => { - this.strippedTransactions = transactions; - this.isLoadingOverview = false; - if (this.blockGraph) { - this.blockGraph.destroy(); - this.blockGraph.setup(this.strippedTransactions); - } - }, - (error) => { - this.error = error; - this.isLoadingOverview = false; - if (this.blockGraph) { - this.blockGraph.destroy(); - } - }); + 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; + } + for (const txid of blockAudit.freshTxs || []) { + isFresh[txid] = true; + } + // set transaction statuses + for (const tx of blockAudit.template) { + tx.context = 'projected'; + if (isCensored[tx.txid]) { + tx.status = 'censored'; + } else if (inBlock[tx.txid]) { + tx.status = 'found'; + } else { + tx.status = isFresh[tx.txid] ? 'fresh' : 'missing'; + isMissing[tx.txid] = true; + this.numMissing++; + } + } + for (const [index, tx] of blockAudit.transactions.entries()) { + tx.context = 'actual'; + if (index === 0) { + tx.status = null; + } else if (isAdded[tx.txid]) { + tx.status = 'added'; + } else if (inTemplate[tx.txid]) { + tx.status = 'found'; + } else { + tx.status = 'selected'; + isSelected[tx.txid] = true; + this.numUnexpected++; + } + } + for (const tx of blockAudit.transactions) { + inBlock[tx.txid] = true; + } + this.auditEnabled = true; + } else { + this.auditEnabled = false; + } + return blockAudit; + }), + catchError((err) => { + console.log(err); + this.error = err; + this.isLoadingOverview = false; + return of(null); + }), + ).subscribe((blockAudit) => { + this.blockAudit = blockAudit; + this.setupBlockGraphs(); + this.isLoadingOverview = false; + }); + } this.networkChangedSubscription = this.stateService.networkChanged$ .subscribe((network) => this.network = network); @@ -307,6 +388,12 @@ export class BlockComponent implements OnInit, OnDestroy { } else { this.showDetails = false; } + if (params.view === 'projected') { + this.mode = 'projected'; + } else { + this.mode = 'actual'; + } + this.setupBlockGraphs(); }); this.keyNavigationSubscription = this.stateService.keyNavigation$.subscribe((event) => { @@ -325,17 +412,24 @@ export class BlockComponent implements OnInit, OnDestroy { }); } + ngAfterViewInit(): void { + this.childChangeSubscription = combineLatest([this.blockGraphProjected.changes.pipe(startWith(null)), this.blockGraphActual.changes.pipe(startWith(null))]).subscribe(() => { + this.setupBlockGraphs(); + }); + } + ngOnDestroy() { this.stateService.markBlock$.next({}); this.transactionSubscription.unsubscribe(); - this.overviewSubscription.unsubscribe(); + this.overviewSubscription?.unsubscribe(); + this.auditSubscription?.unsubscribe(); this.keyNavigationSubscription.unsubscribe(); this.blocksSubscription.unsubscribe(); this.networkChangedSubscription.unsubscribe(); this.queryParamsSubscription.unsubscribe(); this.timeLtrSubscription.unsubscribe(); - this.fetchAuditScoreSubscription?.unsubscribe(); this.unsubscribeNextBlockSubscriptions(); + this.childChangeSubscription.unsubscribe(); } unsubscribeNextBlockSubscriptions() { @@ -382,7 +476,7 @@ export class BlockComponent implements OnInit, OnDestroy { this.showDetails = false; this.router.navigate([], { relativeTo: this.route, - queryParams: { showDetails: false }, + queryParams: { showDetails: false, view: this.mode }, queryParamsHandling: 'merge', fragment: 'block' }); @@ -390,7 +484,7 @@ export class BlockComponent implements OnInit, OnDestroy { this.showDetails = true; this.router.navigate([], { relativeTo: this.route, - queryParams: { showDetails: true }, + queryParams: { showDetails: true, view: this.mode }, queryParamsHandling: 'merge', fragment: 'details' }); @@ -409,10 +503,6 @@ export class BlockComponent implements OnInit, OnDestroy { return this.block && this.block.height > 681393 && (new Date().getTime() / 1000) < 1628640000; } - onResize(event: any) { - this.paginationMaxSize = event.target.innerWidth < 670 ? 3 : 5; - } - navigateToPreviousBlock() { if (!this.block) { return; @@ -443,8 +533,53 @@ export class BlockComponent implements OnInit, OnDestroy { } } + setupBlockGraphs(): void { + if (this.blockAudit || this.strippedTransactions) { + this.blockGraphProjected.forEach(graph => { + graph.destroy(); + if (this.isMobile && this.mode === 'actual') { + graph.setup(this.blockAudit?.transactions || this.strippedTransactions || []); + } else { + graph.setup(this.blockAudit?.template || []); + } + }); + this.blockGraphActual.forEach(graph => { + graph.destroy(); + graph.setup(this.blockAudit?.transactions || this.strippedTransactions || []); + }); + } + } + + onResize(event: any): void { + 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: 'projected' | 'actual'): void { + this.router.navigate([], { + relativeTo: this.route, + queryParams: { showDetails: this.showDetails, view: mode }, + queryParamsHandling: 'merge', + fragment: 'overview' + }); + } + onTxClick(event: TransactionStripped): void { const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`); this.router.navigate([url]); } + + onTxHover(txid: string): void { + if (txid && txid.length) { + this.hoverTx = txid; + } else { + this.hoverTx = null; + } + } } \ No newline at end of file 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 69bcf3141..628efb51b 100644 --- a/frontend/src/app/components/blocks-list/blocks-list.component.html +++ b/frontend/src/app/components/blocks-list/blocks-list.component.html @@ -46,14 +46,13 @@
Difficulty {{ block.difficulty }} - +
{{ auditScores[block.id] }}% - - ~ + ~
diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.ts b/frontend/src/app/components/transactions-list/transactions-list.component.ts index b09226f33..072d71311 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.ts +++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts @@ -3,7 +3,7 @@ import { StateService } from '../../services/state.service'; import { Observable, ReplaySubject, BehaviorSubject, merge, Subscription } from 'rxjs'; import { Outspend, Transaction, Vin, Vout } from '../../interfaces/electrs.interface'; import { ElectrsApiService } from '../../services/electrs-api.service'; -import { environment } from 'src/environments/environment'; +import { environment } from '../../../environments/environment'; import { AssetsService } from '../../services/assets.service'; import { filter, map, tap, switchMap } from 'rxjs/operators'; import { BlockExtended } from '../../interfaces/node-api.interface'; diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index 39d0c3d5d..5df095432 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' | 'censored' | 'selected'; + status?: 'found' | 'missing' | 'fresh' | '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 67cc0ffc7..96f7530c9 100644 --- a/frontend/src/app/interfaces/websocket.interface.ts +++ b/frontend/src/app/interfaces/websocket.interface.ts @@ -70,7 +70,8 @@ export interface TransactionStripped { fee: number; vsize: number; value: number; - status?: 'found' | 'missing' | 'added' | 'censored' | 'selected'; + status?: 'found' | 'missing' | 'fresh' | 'added' | 'censored' | 'selected'; + context?: 'projected' | 'actual'; } export interface IBackendInfo { diff --git a/frontend/src/app/lightning/node/node.component.ts b/frontend/src/app/lightning/node/node.component.ts index 411246d2b..7ca79df87 100644 --- a/frontend/src/app/lightning/node/node.component.ts +++ b/frontend/src/app/lightning/node/node.component.ts @@ -7,7 +7,7 @@ import { ApiService } from '../../services/api.service'; import { LightningApiService } from '../lightning-api.service'; import { GeolocationData } from '../../shared/components/geolocation/geolocation.component'; import { ILiquidityAd, parseLiquidityAdHex } from './liquidity-ad'; -import { haversineDistance, kmToMiles } from 'src/app/shared/common.utils'; +import { haversineDistance, kmToMiles } from '../../../app/shared/common.utils'; interface CustomRecord { type: string; diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index dfed35d72..f813959e3 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -230,7 +230,7 @@ export class ApiService { getBlockAudit$(hash: string) : Observable { return this.httpClient.get( - this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/blocks/audit/` + hash, { observe: 'response' } + this.apiBaseUrl + this.apiBasePath + `/api/v1/block/${hash}/audit-summary`, { observe: 'response' } ); } diff --git a/frontend/src/app/services/assets.service.ts b/frontend/src/app/services/assets.service.ts index decc8cbad..9c6b5dba0 100644 --- a/frontend/src/app/services/assets.service.ts +++ b/frontend/src/app/services/assets.service.ts @@ -3,7 +3,7 @@ import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { map, shareReplay, switchMap } from 'rxjs/operators'; import { StateService } from './state.service'; -import { environment } from 'src/environments/environment'; +import { environment } from '../../../src/environments/environment'; import { AssetExtended } from '../interfaces/electrs.interface'; @Injectable({ diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 9e9e2e2a5..ca4501d58 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -45,7 +45,6 @@ import { StartComponent } from '../components/start/start.component'; import { TransactionComponent } from '../components/transaction/transaction.component'; import { TransactionsListComponent } from '../components/transactions-list/transactions-list.component'; import { BlockComponent } from '../components/block/block.component'; -import { BlockAuditComponent } from '../components/block-audit/block-audit.component'; import { BlockOverviewGraphComponent } from '../components/block-overview-graph/block-overview-graph.component'; import { BlockOverviewTooltipComponent } from '../components/block-overview-tooltip/block-overview-tooltip.component'; import { AddressComponent } from '../components/address/address.component'; @@ -120,7 +119,6 @@ import { GeolocationComponent } from '../shared/components/geolocation/geolocati StartComponent, TransactionComponent, BlockComponent, - BlockAuditComponent, BlockOverviewGraphComponent, BlockOverviewTooltipComponent, TransactionsListComponent, @@ -223,7 +221,6 @@ import { GeolocationComponent } from '../shared/components/geolocation/geolocati StartComponent, TransactionComponent, BlockComponent, - BlockAuditComponent, BlockOverviewGraphComponent, BlockOverviewTooltipComponent, TransactionsListComponent,