From 7c4f236cb07d263f81676876a1ba0f51fcaeaa47 Mon Sep 17 00:00:00 2001 From: rodribp Date: Mon, 10 Aug 2026 01:43:46 -0600 Subject: [PATCH 1/5] add: flag_values table and repository --- backend/src/api/database-migration.ts | 19 ++- .../src/repositories/FlagValueRepository.ts | 133 ++++++++++++++++++ 2 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 backend/src/repositories/FlagValueRepository.ts diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts index 3932179ed..39bd7787f 100644 --- a/backend/src/api/database-migration.ts +++ b/backend/src/api/database-migration.ts @@ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository'; import { RowDataPacket } from 'mysql2'; class DatabaseMigration { - private static currentVersion = 111; + private static currentVersion = 112; private queryTimeout = 3600_000; private statisticsAddedIndexed = false; private uniqueLogs: string[] = []; @@ -1255,6 +1255,11 @@ class DatabaseMigration { await this.$executeQuery('ALTER TABLE `compact_cpfp_clusters` ADD template_algo TINYINT UNSIGNED NOT NULL DEFAULT 0'); await this.updateToSchemaVersion(111); } + + if (databaseSchemaVersion < 112) { + await this.$executeQuery(this.getCreateFlagsValuesTableQuery(), await this.$checkIfTableExists('flag_values')); + await this.updateToSchemaVersion(112); + } } /** @@ -1841,6 +1846,18 @@ class DatabaseMigration { ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`; } + private getCreateFlagsValuesTableQuery(): string { + return `CREATE TABLE IF NOT EXISTS flag_values ( + bucket_size enum('1', '1008', '4032') NOT NULL, + start_height int unsigned NOT NULL, + avg_timestamp timestamp NOT NULL, + flag_value bigint unsigned NOT NULL, + tx_count int unsigned NOT NULL, + vsize_total int unsigned NOT NULL, + PRIMARY KEY (bucket_size, start_height, flag_value) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8`; + } + /** @asyncUnsafe */ public async $blocksReindexingTruncate(): Promise { logger.warn(`Truncating pools, blocks, hashrates and difficulty_adjustments tables for re-indexing (using '--reindex-blocks'). You can cancel this command within 5 seconds`); diff --git a/backend/src/repositories/FlagValueRepository.ts b/backend/src/repositories/FlagValueRepository.ts new file mode 100644 index 000000000..66f8495c1 --- /dev/null +++ b/backend/src/repositories/FlagValueRepository.ts @@ -0,0 +1,133 @@ +import DB from '../database'; +import logger from '../logger'; + +export const INDEXING_PRESETS = [ + {name: 'per block', bucketSize: 1, retentionSpan: 144}, // block span of ~1 day + {name: 'per week', bucketSize: 1008, retentionSpan: -1}, // all + {name: 'per month', bucketSize: 4032, retentionSpan: -1}, // all +]; + +export const INTERVAL_PRESETS = { + '24h': {retentionSpan: 144, bucketSizes: [1]}, + '6m': {retentionSpan: 24192, bucketSizes: [1008, 4032]}, + '1y': {retentionSpan: 48384, bucketSizes: [1008, 4032]}, + '2y': {retentionSpan: 96768, bucketSizes: [1008, 4032]}, + '3y': {retentionSpan: 145152, bucketSizes: [1008, 4032]}, + 'all': {retentionSpan: -1, bucketSizes: [1008, 4032]}, +}; + +class FlagValuesRepository { + /** + * Get the latest indexed day from the database + * + * @asyncSafe */ + public async $getTipAndTailIndexedByBucketSize(bucketSize: number): Promise<{tip: number, tail: number} | null> { + try { + const [rows]: any[] = await DB.query(`SELECT (MAX(start_height) + ?) as tip, MIN(start_height) as tail FROM flag_values WHERE bucket_size = ?`, [bucketSize, bucketSize.toString()]); + if (rows !== null && rows.length > 0 && rows[0].tip !== null && rows[0].tail !== null) { + return rows[0]; + } + } catch (e) { + logger.err(`Cannot get tip and tail indexed from flag_values. Reason: ` + (e instanceof Error ? e.message : e)); + } + return null; + } + + /** + * Get the set of bucket that area already indexed between heights by bucketSize + * + * @asyncSafe */ + public async $getIndexedStartHeights(bucketSize: number, startHeight: number, latestHeight: number): Promise { + try { + const [rows]: any[] = await DB.query( + `SELECT DISTINCT start_height FROM flag_values WHERE bucket_size = ? AND start_height <= ? AND start_height >= ?`, + [bucketSize.toString(), startHeight, latestHeight] + ); + return rows.map(row => row.start_height); + } catch (e) { + logger.err(`Cannot get indexed start heights from flag_values. Reason: ` + (e instanceof Error ? e.message : e)); + } + return []; + } + + public async $saveBatchFlagValues(bucketSize: number, startHeight: number, dataPerFlag: Record>, avgTimestamp: number): Promise { + const params: any[] = []; + const distinctFlags = Object.keys(dataPerFlag); + const avgDate = new Date(Math.round(avgTimestamp) * 1000); + for (const flag of distinctFlags) { + params.push([bucketSize.toString(), startHeight, avgDate, BigInt(flag), dataPerFlag[flag].txCount, dataPerFlag[flag].vSizeTotal]); + } + try { + await DB.query(` + INSERT INTO flag_values (bucket_size, start_height, avg_timestamp, flag_value, tx_count, vsize_total) VALUES ? + ON DUPLICATE KEY UPDATE + avg_timestamp = VALUES(avg_timestamp), tx_count = VALUES(tx_count), vsize_total = VALUES(vsize_total) + `, [params]); + } catch (e) { + logger.debug(`Cannot save flag batched values. Reason: ${e instanceof Error ? e.message : e}`); + throw e; + } + } + + public async $queryTxCountBasedOnMask(mask: bigint, bucketSize: number, op: 'and' | 'or' | 'nor' | undefined, startHeight: number): Promise<{bucketSize: string, startHeight: number, avgTimestamp: number, txCount: number, vSizeTotal: number}[]> { + let flagPredicate = ''; + let params: any[]= []; + switch (op) { + case 'and': { + flagPredicate = 'AND (flag_value & ?) = ?'; + params = [bucketSize.toString(), startHeight, mask, mask]; + } break; + case 'or': { + flagPredicate = 'AND (flag_value & ?) > 0'; + params = [bucketSize.toString(), startHeight, mask]; + } break; + case 'nor': { + flagPredicate = 'AND (flag_value & ?) = 0'; + params = [bucketSize.toString(), startHeight, mask]; + } break; + case undefined: { // op not passed, no boolean operations + params = [bucketSize.toString(), startHeight]; + break; + } + default: throw new Error(`Invalid op '${op}', expected 'and' | 'or' | 'nor' | undefined`); + } + try { + const [rows]: any[] = await DB.query(` + SELECT bucket_size as bucketSize, start_height as startHeight, UNIX_TIMESTAMP(avg_timestamp) as avgTimestamp, + SUM(tx_count) as txCount, SUM(vsize_total) as vSizeTotal + FROM flag_values + WHERE bucket_size = ? AND start_height >= ? ${flagPredicate} + GROUP BY start_height ORDER BY start_height DESC + `, params); + if (rows !== null && rows.length > 0) { + return rows; + } + } catch (e) { + logger.debug(`Cannot get tx counts. Reason: ${e instanceof Error ? e.message : e}`); + } + return []; + } + + /** @asyncSafe */ + public async $deleteFlagValuesBelowHeight(height: number, bucketSize: number): Promise { + try { + await DB.query(`DELETE FROM flag_values WHERE start_height < ? AND bucket_size = ?`, [height, bucketSize.toString()]); + } catch(e) { + logger.err(`Cannot delete flag values below block #${height}. Reason: ` + (e instanceof Error ? e.message : e)); + } + } + + /** @asyncSafe */ + public async $deleteFlagValuesFromHeight(height: number): Promise { + try { + for (const preset of INDEXING_PRESETS) { + const startHeight = Math.floor(height / preset.bucketSize) * preset.bucketSize; + await DB.query(`DELETE FROM flag_values WHERE start_height >= ? AND bucket_size = ?`, [startHeight, preset.bucketSize.toString()]); + } + } catch (e) { + logger.err(`Cannot delete flag values above ${height}. Reason: ` + (e instanceof Error ? e.message : e)); + } + } +} + +export default new FlagValuesRepository(); From 25ae5109b917d1d5f74fa3d5b728feb0b9254c55 Mon Sep 17 00:00:00 2001 From: rodribp Date: Mon, 10 Aug 2026 01:45:04 -0600 Subject: [PATCH 2/5] add: backend indexing + reorg handling --- backend/src/api/blocks.ts | 155 +++++++++++++++++- backend/src/indexer.ts | 2 + .../repositories/BlocksSummariesRepository.ts | 28 ++++ 3 files changed, 184 insertions(+), 1 deletion(-) diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index be247f571..97276a922 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -40,6 +40,7 @@ import CpfpRepository from '../repositories/CpfpRepository'; import { parseDATUMTemplateCreator, parseDMNDTemplateCreator } from '../utils/bitcoin-script'; import database from '../database'; import { getBlockFirstSeenFromLogs, getOldestLogTimestampFromLogs, scanLogsForBlocksFirstSeen } from '../utils/file-read'; +import FlagValueRepository, { INDEXING_PRESETS } from '../repositories/FlagValueRepository'; class Blocks { private blocks: BlockExtended[] = []; @@ -54,6 +55,8 @@ class Blocks { private oldestCoreLogTimestamp: number | undefined | null = undefined; private mainLoopTimeout: number = 120000; + private indexingFlagValues: boolean = false; + private flagValuesDeleteQueue: number[]= []; constructor() { } @@ -689,6 +692,155 @@ class Blocks { } } + /** + * [INDEXING] Index all blocks flag values for the goggles graph rendering + * + * @asyncSafe + */ + public async $generateFlagValuesDatabase(): Promise { + const MAX_BLOCKS_PERQUERY = 144; + if (this.indexingFlagValues) { + return; + } + + if (Common.blocksSummariesIndexingEnabled() === false || Common.isLiquid()) { + return; + } + + this.indexingFlagValues = true; + + const tipOfSummaries = await BlocksSummariesRepository.$getTipIndexed(); + if (!tipOfSummaries) { + this.indexingFlagValues = false; + return; + } + + let newlyIndexedBuckets = 0; + + while (this.flagValuesDeleteQueue.length > 0) { // Deletion of in-queue heights due to reorg + const deletionHeight = this.flagValuesDeleteQueue.shift(); + if (deletionHeight === undefined) { + continue; + } + await FlagValueRepository.$deleteFlagValuesFromHeight(deletionHeight); + } + + for (const preset of INDEXING_PRESETS) { + let seedHeight = preset.retentionSpan > -1 ? tipOfSummaries - preset.retentionSpan : 0; + if (config.MEMPOOL.INDEXING_BLOCKS_AMOUNT > 0) { + seedHeight = Math.max(seedHeight, tipOfSummaries - config.MEMPOOL.INDEXING_BLOCKS_AMOUNT + 1); + } + const firstBucket = Math.floor((tipOfSummaries + 1) / preset.bucketSize) * preset.bucketSize - preset.bucketSize; + const lastBucket = Math.max(0, Math.floor(seedHeight / preset.bucketSize) * preset.bucketSize); + + // Deletion of flag values out of retention span + const tipAndTailOfFlagValues = await FlagValueRepository.$getTipAndTailIndexedByBucketSize(preset.bucketSize); + if (tipAndTailOfFlagValues && lastBucket > tipAndTailOfFlagValues.tail) { // Drop buckets that fell out of block span + logger.debug(`Deleting all the flag values ${preset.name} below height #${lastBucket}`, logger.tags.goggles); + await FlagValueRepository.$deleteFlagValuesBelowHeight(lastBucket, preset.bucketSize); + } + + if (firstBucket < lastBucket) { + continue; // no complete bucket in range + } + + const indexedBuckets = await FlagValueRepository.$getIndexedStartHeights(preset.bucketSize, firstBucket, lastBucket); + const isBucketIndexed = {}; + // We map the buckets that are already indexed to skip them + for (const startHeight of indexedBuckets) { + isBucketIndexed[startHeight] = true; + } + + logger.debug(`Processing and indexing flag values from #${firstBucket} to #${lastBucket} ${preset.name}`, logger.tags.goggles); + + let timer = Date.now() / 1000; + const startedAt = Date.now() / 1000; + let blocksComputedInTotal = 0; + let blocksComputedThisRun = 0; + const blocksToCompute = firstBucket + preset.bucketSize - lastBucket - (indexedBuckets.length * preset.bucketSize); + for (let bucketStart = firstBucket; bucketStart >= lastBucket; bucketStart -= preset.bucketSize) { + if (isBucketIndexed[bucketStart]) { + continue; // already indexed + } + try { + const bucketFirstHeight = bucketStart + preset.bucketSize - 1; + const bucketLastHeight = bucketStart - 1; + + let step = bucketFirstHeight; + + const dataPerFlag: Record> = {}; + let sumTimestamps = 0; + let nBlocks = 0; + let incomplete = false; + + // Incrementalized logic capped by max blocks per query, not bucket size + while (step > bucketLastHeight) { + const blocksPerQuery = Math.min(step - bucketLastHeight, MAX_BLOCKS_PERQUERY); + const cappedLastHeight = step - blocksPerQuery; + + const blocks = await BlocksSummariesRepository.$getSummariesBetweenHeights(step, cappedLastHeight); + await Common.sleep$(250); // Don't query/index flag values too fast + + if (!blocks || blocks.length < blocksPerQuery) { + incomplete = true; + break; // Incomplete bucket + } + + // Flag values processing + for (const block of blocks) { + const txData = JSON.parse(block.transactions).map((tx) => ({flags: tx.flags, vsize: tx.vsize})); + for (const data of txData) { + if (dataPerFlag[data.flags] === undefined || Object.keys(dataPerFlag[data.flags]).length === 0) { + dataPerFlag[data.flags] = { + txCount: 0, + vSizeTotal: 0 + }; + } + dataPerFlag[data.flags].txCount = dataPerFlag[data.flags].txCount + 1; + dataPerFlag[data.flags].vSizeTotal = dataPerFlag[data.flags].vSizeTotal + data.vsize; + } + sumTimestamps += block.timestamp; + blocksComputedInTotal++; + blocksComputedThisRun++; + nBlocks++; + } + + // Logging + const elapsedSeconds = (Date.now() / 1000) - timer; + if (elapsedSeconds > 5) { + const runningFor = (Date.now() / 1000) - startedAt; + const blocksPerSecond = blocksComputedThisRun / elapsedSeconds; + const completion = (blocksComputedInTotal / blocksToCompute) * 100; + logger.debug(`Indexing flag values ${preset.name} | ${blocksComputedInTotal}/${blocksToCompute} (${completion.toFixed(2)}%) | ~${blocksPerSecond.toFixed(2)} blocks/sec | elapsed: ${runningFor.toFixed(2)} seconds`,logger.tags.goggles); + timer = Date.now() / 1000; + blocksComputedThisRun = 0; + } + + step -= blocksPerQuery; + } + + if (incomplete) { + continue; + } + + const avgTimestamp = sumTimestamps / nBlocks; + await FlagValueRepository.$saveBatchFlagValues(preset.bucketSize, bucketStart, dataPerFlag, avgTimestamp); + nBlocks = 0; + newlyIndexedBuckets++; + } catch (e) { + logger.err(`Failed to index flag values between #${bucketStart} and #${bucketStart + preset.bucketSize - 1}. Reason: ${(e instanceof Error ? e.message : e)}`, logger.tags.goggles); + } + } + logger.debug(`Successfully indexed #${blocksComputedInTotal} blocks ${preset.name} in ${((Date.now() / 1000) - startedAt).toFixed(2)} seconds`, logger.tags.goggles); + } + if (newlyIndexedBuckets > 0) { + logger.notice(`Flag values indexing completed: indexed ${newlyIndexedBuckets} buckets`, logger.tags.goggles); + } else { + logger.debug(`Flag values indexing completed: indexed ${newlyIndexedBuckets} buckets`, logger.tags.goggles); + } + this.indexingFlagValues = false; + } + /** @asyncUnsafe */ public async $indexBlockSummary(hash: string, height: number, stale?: boolean): Promise { if (config.MEMPOOL.BACKEND === 'esplora') { @@ -1409,6 +1561,7 @@ class Blocks { await DifficultyAdjustmentsRepository.$deleteAdjustementsFromHeight(forkTail.height); await cpfpRepository.$deleteClustersFrom(forkTail.height); await AccelerationRepository.$deleteAccelerationsFrom(forkTail.height); + this.flagValuesDeleteQueue.push(forkTail.height); chainTips.clearOrphanCacheAboveHeight(forkTail.height); this.updateTimerProgress(timer, `deleted stale block data`); @@ -1769,7 +1922,7 @@ class Blocks { if (transactions?.length != null) { const { cpfpSummary } = await detectTemplateAlgorithm(height, transactions, [], true); - if (!stale) { + if (!stale && Common.cpfpIndexingEnabled() === true) { await this.$saveCpfp(hash, height, cpfpSummary); } diff --git a/backend/src/indexer.ts b/backend/src/indexer.ts index ff10d701e..9d47de2f2 100644 --- a/backend/src/indexer.ts +++ b/backend/src/indexer.ts @@ -226,6 +226,8 @@ class Indexer { await AccelerationRepository.$indexPastAccelerations(); await BlocksAuditsRepository.$migrateAuditsV0toV1(); await BlocksRepository.$migrateBlocks(); + + void blocks.$generateFlagValuesDatabase(); // do not wait for classify blocks to finish void blocks.$classifyBlocks(); runSuccessful = true; diff --git a/backend/src/repositories/BlocksSummariesRepository.ts b/backend/src/repositories/BlocksSummariesRepository.ts index fc2771ce8..2239fa258 100644 --- a/backend/src/repositories/BlocksSummariesRepository.ts +++ b/backend/src/repositories/BlocksSummariesRepository.ts @@ -216,6 +216,34 @@ class BlocksSummariesRepository { } return false; } + + /** @asyncSafe */ + public async $getTipIndexed(): Promise { + if (!Common.blocksSummariesIndexingEnabled()) { + return null; + } + try { + const [row]: any[] = await DB.query('SELECT MAX(height) as tip FROM blocks_summaries WHERE version >= 1'); + + if (row !== null && row.length > 0) { + return row[0].tip; + } + } catch (e) { + logger.err(`Cannot get latest block summary. Reason: ` + (e instanceof Error ? e.message : e)); + } + return null; + } + + public async $getSummariesBetweenHeights(startHeight: number, latestHeight: number): Promise<{height: number, transactions: string, timestamp: number}[]> { + try { + const [rows]: any[] = await DB.query(`SELECT bs.height, bs.transactions, UNIX_TIMESTAMP(b.blockTimestamp) as timestamp FROM blocks_summaries bs JOIN blocks b ON bs.id = b.hash WHERE bs.height <= ? AND bs.height > ? AND b.stale = 0 AND bs.version >= 1 ORDER BY height DESC`, [startHeight, latestHeight]); + + return rows; + } catch (e) { + logger.err(`Cannot get blocks between ${startHeight} and ${latestHeight}. Reason: ` + (e instanceof Error ? e.message : e)); + throw e; + } + } } export default new BlocksSummariesRepository(); From 1af9dfd4571981b8904fcd5420dae2e3a42119ea Mon Sep 17 00:00:00 2001 From: rodribp Date: Mon, 10 Aug 2026 01:45:29 -0600 Subject: [PATCH 3/5] add: flag values endpoints --- backend/src/api/bitcoin/bitcoin.routes.ts | 61 +++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index e10157b1a..73741efbf 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -23,12 +23,14 @@ import { calculateMempoolTxCpfp } from '../cpfp'; import { handleError } from '../../utils/api'; import poolsUpdater from '../../tasks/pools-updater'; import chainTips from '../chain-tips'; +import FlagValueRepository, { INTERVAL_PRESETS } from '../../repositories/FlagValueRepository'; const TXID_REGEX = /^[a-f0-9]{64}$/i; const BLOCK_HASH_REGEX = /^[a-f0-9]{64}$/i; const ADDRESS_REGEX = /^[a-z0-9]{2,120}$/i; const SCRIPT_HASH_REGEX = /^([a-f0-9]{2})+$/i; const MAX_TRANSACTION_TIMES = 100; +const JUST_NUMBERS_REGEX = /^[1-9]\d*$/; class BitcoinRoutes { public initRoutes(app: Application) { @@ -70,6 +72,10 @@ class BitcoinRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'internal/blocks/definition/list', this.getBlockDefinitionHashes) .get(config.MEMPOOL.API_URL_PREFIX + 'internal/blocks/definition/current', this.getCurrentBlockDefinitionHash) .get(config.MEMPOOL.API_URL_PREFIX + 'internal/blocks/:definitionHash', this.getBlocksByDefinitionHash) + + .get(config.MEMPOOL.API_URL_PREFIX + 'goggles/:interval/', this.getTxCountPerFlagValue) + .get(config.MEMPOOL.API_URL_PREFIX + 'goggles/:interval/:bucketSize', this.getTxCountPerFlagValue) + .get(config.MEMPOOL.API_URL_PREFIX + 'goggles/:interval/:bucketSize/:op/:mask', this.getTxCountPerFlagValue) ; if (config.MEMPOOL.BACKEND !== 'esplora') { @@ -1132,6 +1138,61 @@ class BitcoinRoutes { } } + private async getTxCountPerFlagValue(req: Request, res: Response) { + try { + if (!Common.blocksSummariesIndexingEnabled()) { + handleError(req, res, 404, `Block summaries indexing is required for this API`); + return; + } + + const presets = INTERVAL_PRESETS; + const operations = ['and', 'or', 'nor', undefined]; + const intervals = Object.keys(presets); + const interval = req.params.interval; + + if (!intervals.includes(interval)) { + handleError(req, res, 400, `Invalid interval, must be one of ${intervals.toString()}`); + return; + } + + const validBucketSizes = presets[interval].bucketSizes; + const rawBucketSize = req.params.bucketSize; + const bucketSize: number = rawBucketSize === undefined ? validBucketSizes[0] : Number(rawBucketSize); + if (!Number.isInteger(bucketSize) || !validBucketSizes.includes(bucketSize)) { + handleError(req, res, 400, `Invalid bucket size, must be ${validBucketSizes.toString()}`); + return; + } + + if (!operations.includes(req.params.op)) { + handleError(req, res, 400, `Invalid operation, must be 'and', 'or', 'nor' or undefined.`); + return; + } + + if (req.params.mask && !JUST_NUMBERS_REGEX.test(req.params.mask)) { + handleError(req, res, 400, `Invalid mask value, must be a positive integer`); + return; + } + + const op = (req.params.op) as 'and' | 'or' | 'nor' | undefined; + const mask = BigInt(req.params.mask ?? 0n); + + const { tip } = await FlagValueRepository.$getTipAndTailIndexedByBucketSize(bucketSize) || { tip: undefined }; + + if (!tip) { + handleError(req, res, 400, `Failed to get latest indexed flag values for ${interval}`); + return; + } + + const startHeight = presets[interval].retentionSpan !== -1 ? (tip - presets[interval].retentionSpan) : -1; + const txsCount = await FlagValueRepository.$queryTxCountBasedOnMask(mask, bucketSize, op, startHeight); + res.header('X-total-count', tip.toString()); + res.header('Expires', new Date(Date.now() + 1000 * 3600 * 24 * (presets[interval].bucketSizes[0] / 144)).toUTCString()); + res.send(txsCount); + } catch (e: any) { + handleError(req, res, 400, e instanceof Error ? e.message : 'Failed to get flag values'); + } + } + private async $postTransaction(req: Request, res: Response) { res.setHeader('content-type', 'text/plain'); try { From abd8defcbb794e29851e19a33fead0175f8d4a75 Mon Sep 17 00:00:00 2001 From: rodribp Date: Mon, 10 Aug 2026 01:46:21 -0600 Subject: [PATCH 4/5] add: mempool goggles graph to the frontend --- .../block-filters.component.html | 2 +- .../block-filters/block-filters.component.ts | 1 + .../block-goggles-graph.component.html | 77 ++ .../block-goggles-graph.component.scss | 195 +++++ .../block-goggles-graph.component.ts | 667 ++++++++++++++++++ .../components/graphs/graphs.component.html | 3 + frontend/src/app/graphs/graphs.module.ts | 2 + .../src/app/graphs/graphs.routing.module.ts | 6 + frontend/src/app/services/api.service.ts | 7 + 9 files changed, 959 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/components/block-goggles-graph/block-goggles-graph.component.html create mode 100644 frontend/src/app/components/block-goggles-graph/block-goggles-graph.component.scss create mode 100644 frontend/src/app/components/block-goggles-graph/block-goggles-graph.component.ts diff --git a/frontend/src/app/components/block-filters/block-filters.component.html b/frontend/src/app/components/block-filters/block-filters.component.html index 33dfb621d..7c8585fb3 100644 --- a/frontend/src/app/components/block-filters/block-filters.component.html +++ b/frontend/src/app/components/block-filters/block-filters.component.html @@ -25,7 +25,7 @@ -
+
Tint
diff --git a/frontend/src/app/components/block-filters/block-filters.component.ts b/frontend/src/app/components/block-filters/block-filters.component.ts index d9e8691d2..2dc78c782 100644 --- a/frontend/src/app/components/block-filters/block-filters.component.ts +++ b/frontend/src/app/components/block-filters/block-filters.component.ts @@ -13,6 +13,7 @@ import { Subscription } from 'rxjs'; export class BlockFiltersComponent implements OnInit, OnChanges, OnDestroy { @Input() cssWidth: number = 800; @Input() excludeFilters: string[] = []; + @Input() showTint: boolean = true; @Output() onFilterChanged: EventEmitter = new EventEmitter(); filterSubscription: Subscription; diff --git a/frontend/src/app/components/block-goggles-graph/block-goggles-graph.component.html b/frontend/src/app/components/block-goggles-graph/block-goggles-graph.component.html new file mode 100644 index 000000000..2b94caf31 --- /dev/null +++ b/frontend/src/app/components/block-goggles-graph/block-goggles-graph.component.html @@ -0,0 +1,77 @@ + + +
+
+
+ Mempool goggles + +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ + + + +
+
+
+
+ + + + +
+
+
+
+ + + + +
+
+
+
+ +
+ +
+
+
+
+
+
+ +
\ No newline at end of file diff --git a/frontend/src/app/components/block-goggles-graph/block-goggles-graph.component.scss b/frontend/src/app/components/block-goggles-graph/block-goggles-graph.component.scss new file mode 100644 index 000000000..4dd2d3c3d --- /dev/null +++ b/frontend/src/app/components/block-goggles-graph/block-goggles-graph.component.scss @@ -0,0 +1,195 @@ +.card-header { + border-bottom: 0; + font-size: 18px; + @media (min-width: 465px) { + font-size: 20px; + } + @media (min-width: 1340px) { + height: 40px; + } +} + +.graph-toolbar { + @media (min-width: 1340px) { + display: contents; + } + @media (max-width: 1339px) { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; + } +} + +.full-container .card-header .graph-toolbar .formRadioGroup { + @media (max-width: 1339px) { + position: static; + float: none; + flex-direction: row; + margin: 0; + } +} + +.full-container .card-header .graph-toolbar .interval-group { + @media (max-width: 829px) { + width: 100%; + .btn-group { + display: flex; + width: 100%; + .btn { + flex: 1; + } + } + } +} + +.main-title { + position: relative; + color: var(--fg); + opacity: var(--opacity); + margin-top: -13px; + font-size: 10px; + text-transform: uppercase; + font-weight: 500; + text-align: center; + padding-bottom: 3px; +} + +.full-container { + display: flex; + flex-direction: column; + padding: 0px 15px; + width: 100%; + height: calc(100vh - 225px); + min-height: 400px; + @media (min-width: 992px) { + height: calc(100vh - 150px); + } +} + +.chart { + display: flex; + flex: 1; + height: 100%; + padding-bottom: 20px; + padding-right: 10px; + @media (max-width: 992px) { + padding-bottom: 25px; + } + @media (max-width: 829px) { + padding-bottom: 50px; + } + @media (max-width: 767px) { + padding-bottom: 25px; + } + @media (max-width: 629px) { + padding-bottom: 55px; + } + @media (max-width: 567px) { + padding-bottom: 55px; + } +} + +.goggles-chart-wrapper { + position: relative; + + .echarts-container { + flex: 1; + width: 100%; + height: 100%; + } + + // the goggles toggle is hidden by default (reveals on canvas hover); surface it for the graph + ::ng-deep .block-filters .menu-toggle { + opacity: 0.5; + } + &:hover ::ng-deep .block-filters .menu-toggle { + opacity: 1; + } +} +.chart-widget { + width: 100%; + height: 100%; + max-height: 238px; +} + +.block-fee-rates { + min-height: 56px; + display: block; + @media (min-width: 485px) { + display: flex; + flex-direction: row; + } + h5 { + margin-bottom: 10px; + } + .item { + width: 50%; + display: inline-block; + margin: 0px auto 20px; + &:nth-child(2) { + order: 2; + @media (min-width: 485px) { + order: 3; + } + } + &:nth-child(3) { + order: 3; + @media (min-width: 485px) { + order: 2; + display: block; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: block; + } + } + .card-title { + font-size: 1rem; + color: var(--title-fg); + } + .card-text { + font-size: 18px; + span { + color: var(--transparent-fg); + font-size: 12px; + } + } + } +} + +.formRadioGroup { + margin-top: 6px; + display: flex; + flex-direction: column; + @media (min-width: 991px) { + position: relative; + top: -100px; + } + @media (min-width: 830px) and (max-width: 991px) { + position: relative; + top: 0px; + } + @media (min-width: 830px) { + flex-direction: row; + float: right; + margin-top: 0px; + margin-left: 2px; + margin-right: 2px; + } + .btn-sm { + font-size: 9px; + @media (min-width: 830px) { + font-size: 14px; + } + } +} + +.skeleton-loader { + width: 100%; + display: block; + max-width: 80px; + margin: 15px auto 3px; +} diff --git a/frontend/src/app/components/block-goggles-graph/block-goggles-graph.component.ts b/frontend/src/app/components/block-goggles-graph/block-goggles-graph.component.ts new file mode 100644 index 000000000..08a276d8a --- /dev/null +++ b/frontend/src/app/components/block-goggles-graph/block-goggles-graph.component.ts @@ -0,0 +1,667 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, NgZone, OnInit } from '@angular/core'; +import { EChartsOption } from '@app/graphs/echarts'; +import { BehaviorSubject, combineLatest, forkJoin, Observable, of } from 'rxjs'; +import { catchError, debounceTime, distinctUntilChanged, filter, map, share, startWith, switchMap, tap } from 'rxjs/operators'; +import { ActiveFilter, FilterMode, toFilters, toFlags } from '@app/shared/filters.utils'; +import { ApiService } from '@app/services/api.service'; +import { formatNumber } from '@angular/common'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { download, formatterXAxis, formatterXAxisLabel, formatterXAxisTimeCategory } from '@app/shared/graphs.utils'; +import { StorageService } from '@app/services/storage.service'; +import { MiningService } from '@app/services/mining.service'; +import { selectPowerOfTen } from '@app/bitcoin.utils'; +import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe'; +import { StateService } from '@app/services/state.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { HttpResponse } from '@angular/common/http'; +import { VbytesPipe } from '@app/shared/pipes/bytes-pipe/vbytes.pipe'; +import { SeoService } from '@app/services/seo.service'; + +interface GogglesRollup { + bucketSize: string; + startHeight: number; + avgTimestamp: number; + txCount: number; + vSizeTotal: number; +} + +interface GogglesDatum { + value: number; + startHeight: number; + bucketSize: number; + txCount: number; + vSizeTotal: number; + timestampMs: number; + baseTxCount?: number; + baseVSize?: number; +} + +const INTERVAL_PRESETS: Record = { + '24h': [1], + '6m': [1008, 4032], + '1y': [1008, 4032], + '2y': [1008, 4032], + '3y': [1008, 4032], + 'all': [1008, 4032], +}; + +@Component({ + selector: 'app-block-goggles-graph', + templateUrl: './block-goggles-graph.component.html', + styleUrls: ['./block-goggles-graph.component.scss'], + styles: [` + .loadingGraphs { + position: absolute; + top: 50%; + left: calc(50% - 15px); + z-index: 99; + } + `], + standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BlockGogglesGraphComponent implements OnInit { + @Input() widget = false; + @Input() right: number | string = 45; + @Input() left: number | string = 75; + + miningWindowPreference: string; + radioGroupForm: UntypedFormGroup; + unitGroupForm: UntypedFormGroup; + bucketGroupForm: UntypedFormGroup; + modeGroupForm: UntypedFormGroup; + count = $localize`:@@8177873832400820695:Count`; + allLabel = $localize`All transactions`; + transactionsLabel = $localize`Transactions`; + matchedLabel = $localize`Matched`; + + chartOptions: EChartsOption = {}; + chartInitOptions = { + renderer: 'svg', + }; + + statsObservable$: Observable; + isLoading = true; + formatNumber = formatNumber; + timespan = ''; + chartInstance: any = undefined; + + // active goggles filter; empty op/mask means no filter, so the backend returns total tx counts + goggle$ = new BehaviorSubject<{ op?: FilterMode, mask?: bigint }>({}); + + private intervals = Object.keys(INTERVAL_PRESETS); + + private bucketTimestampByHeight = new Map(); + + private totalsCache: Record = {}; + + private relativeMode = false; + + private prefs: { unit: string, bucket: number, mode: string } = { unit: 'txCount', bucket: 1008, mode: 'abs' }; + + constructor( + @Inject(LOCALE_ID) public locale: string, + private apiService: ApiService, + private formBuilder: UntypedFormBuilder, + private storageService: StorageService, + private miningService: MiningService, + public stateService: StateService, + private router: Router, + private zone: NgZone, + private route: ActivatedRoute, + private cd: ChangeDetectorRef, + private seoService: SeoService, + private vbytesPipe: VbytesPipe, + ) { + this.radioGroupForm = this.formBuilder.group({ dateSpan: '1y' }); + this.radioGroupForm.controls.dateSpan.setValue('1y'); + this.unitGroupForm = this.formBuilder.group({ unitType: 'txCount'}); + this.unitGroupForm.controls.unitType.setValue('txCount'); + this.bucketGroupForm = this.formBuilder.group({ bucketSize: 1008}); + this.bucketGroupForm.controls.bucketSize.setValue(1008); + this.modeGroupForm = this.formBuilder.group({ mode: 'abs' }); + this.modeGroupForm.controls.mode.setValue('abs'); + } + + ngOnInit(): void { + let firstRun = true; + if (this.widget) { + this.miningWindowPreference = '6m'; + } else { + this.seoService.setTitle($localize`Mempool Goggles`); + this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.goggles:See Bitcoin transactions matching Mempool Goggles filters visualized over time.`); + this.miningWindowPreference = this.miningService.getDefaultTimespan('24h'); + } + if (!this.intervals.includes(this.miningWindowPreference)) { + this.miningWindowPreference = '1y'; + } + + this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); + this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); + let storedPrefs: any = {}; + try { + storedPrefs = JSON.parse(this.storageService.getValue('goggles_prefs')) ?? {}; + } catch { + storedPrefs = {}; + } + if (['vb', 'txCount'].includes(storedPrefs.unit)) { + this.prefs.unit = storedPrefs.unit; + } + if ([1008, 4032].includes(storedPrefs.bucket)) { + this.prefs.bucket = storedPrefs.bucket; + } + if (['abs', 'rel'].includes(storedPrefs.mode)) { + this.prefs.mode = storedPrefs.mode; + } + this.unitGroupForm = this.formBuilder.group({ unitType: this.prefs.unit }); + this.unitGroupForm.controls.unitType.setValue(this.prefs.unit); + this.bucketGroupForm.controls.bucketSize.setValue(this.prefs.bucket, { emitEvent: false }); + this.modeGroupForm.controls.mode.setValue(this.prefs.mode, { emitEvent: false }); + + if (!this.widget) { + this.route + .fragment + .subscribe((fragment) => { + this.parseFragment(fragment); + }); + } + + this.statsObservable$ = combineLatest([ + this.radioGroupForm.get('dateSpan').valueChanges.pipe( + startWith(this.radioGroupForm.controls.dateSpan.value), + distinctUntilChanged(), + ), + // debounce so toggling several flags fires one request; startWith keeps the first paint immediate + this.goggle$.pipe( + debounceTime(250), + startWith(this.goggle$.value), + distinctUntilChanged((a, b) => a.op === b.op && a.mask === b.mask), + ), + this.unitGroupForm.get('unitType').valueChanges.pipe( + startWith(this.unitGroupForm.controls.unitType.value), + distinctUntilChanged(), + ), + this.bucketGroupForm.get('bucketSize').valueChanges.pipe( + startWith(this.bucketGroupForm.controls.bucketSize.value), + distinctUntilChanged(), + ), + this.modeGroupForm.get('mode').valueChanges.pipe( + startWith(this.modeGroupForm.controls.mode.value), + distinctUntilChanged(), + ), + ]).pipe( + switchMap(([timespan, goggle, unitType, bucketSize, mode]) => { + if (!this.widget && !firstRun && timespan !== this.timespan) { + this.storageService.setValue('miningWindowPreference', timespan); + } + firstRun = false; + this.timespan = timespan; + this.isLoading = true; + // reconcile the bucket size with the interval (e.g. 24h is per-block only) and keep the UI radio in sync + const allowedBuckets = this.bucketSizesForInterval(timespan); + const effectiveBucket = allowedBuckets.includes(bucketSize) ? bucketSize : allowedBuckets[0]; + if (effectiveBucket !== this.bucketGroupForm.controls.bucketSize.value) { + this.bucketGroupForm.controls.bucketSize.setValue(effectiveBucket, { emitEvent: false }); + } + const effectiveMode = goggle.mask ? mode : 'abs'; + if (effectiveMode !== this.modeGroupForm.controls.mode.value) { + this.modeGroupForm.controls.mode.setValue(effectiveMode, { emitEvent: false }); + } + this.prefs.unit = unitType; + if (allowedBuckets.length > 1) { + this.prefs.bucket = effectiveBucket; + } + if (goggle.mask) { + this.prefs.mode = effectiveMode; + } + this.storageService.setValue('goggles_prefs', JSON.stringify(this.prefs)); + const cacheKey = `${timespan}:${effectiveBucket}`; + const filtered$ = this.apiService.getHistoricalTxCountByFlags$(timespan, effectiveBucket.toString(), goggle.op, goggle.mask?.toString()); + const totals$ = goggle.mask + ? (this.totalsCache[cacheKey] + ? of(this.totalsCache[cacheKey]) + : this.apiService.getHistoricalTxCountByFlags$(timespan, effectiveBucket.toString()).pipe( + map((res) => res.body || []), + tap((body) => { this.totalsCache[cacheKey] = body; }), + )) + : of(null); + const unit = of(unitType); + return forkJoin<[HttpResponse, GogglesRollup[], string]>([filtered$, totals$, unit]).pipe( + tap(([response, totalsBody, unit]) => { + const body: GogglesRollup[] = response.body || []; + const filtered = !!this.goggle$.value.mask; + // when filtering, body is the matched rows and totalsBody the unfiltered totals; otherwise body itself is the totals + const totalRows: GogglesRollup[] = filtered ? (totalsBody || []) : body; + const matchedRows: GogglesRollup[] = filtered ? body : []; + const unitIsTx = unit === 'txCount'; + this.relativeMode = filtered && effectiveMode === 'rel'; + const matchedByHeight = new Map(); + for (const row of matchedRows) { + matchedByHeight.set(row.startHeight, row); + } + const sorted = [...totalRows].sort((a, b) => a.startHeight - b.startHeight); + const categories = sorted.map((row) => row.startHeight); + this.bucketTimestampByHeight = new Map(sorted.map((row) => [row.startHeight, Number(row.avgTimestamp) * 1000])); + const toSeries = (matched = false): GogglesDatum[] => sorted.map((row) => { + const bucketSize = parseInt(row.bucketSize, 10) || 1; + const source = matched ? matchedByHeight.get(row.startHeight) : row; + const txCount = source ? Number(source.txCount) : 0; + const vSizeTotal = source ? Number(source.vSizeTotal) : 0; + const selected = unitIsTx ? txCount : vSizeTotal; + const plotted = bucketSize > 1 ? selected / bucketSize : selected; + const datum: GogglesDatum = { value: plotted, startHeight: row.startHeight, bucketSize, txCount, vSizeTotal, timestampMs: Number(row.avgTimestamp) * 1000 }; + if (matched) { + datum.baseTxCount = Number(row.txCount); + datum.baseVSize = Number(row.vSizeTotal); + if (this.relativeMode) { + const base = unitIsTx ? datum.baseTxCount : datum.baseVSize; + datum.value = base > 0 ? selected / base * 100 : 0; + } + } else if (this.relativeMode) { + datum.value = 100; + } + return datum; + }); + this.prepareChartOptions(categories, toSeries(), filtered ? toSeries(true) : []); + this.isLoading = false; + this.cd.markForCheck(); + }), + map(([response]) => { + const body: GogglesRollup[] = response.body || []; + const headerCount = parseInt(response.headers.get('x-total-count'), 10); + return { + blockCount: Number.isFinite(headerCount) ? headerCount : Number.MAX_SAFE_INTEGER, + txCount: body.reduce((acc, row) => acc + row.txCount, 0), + }; + }), + catchError(err => { + this.prepareChartOptions([], [], [], err); + this.isLoading = false; + this.cd.markForCheck(); + return of({ blockCount: Number.MAX_SAFE_INTEGER, txCount: 0 }); + }), + ); + }), + share(), + ); + } + + onFilterChanged(activeFilter: ActiveFilter | null): void { + const mask = activeFilter ? toFlags(activeFilter.filters) : 0n; + this.goggle$.next(mask > 0n + ? { op: activeFilter.mode, mask } + : {} + ); + if (!this.widget) { + this.router.navigate([], { relativeTo: this.route, fragment: this.getFragment(), replaceUrl: true }); + } + } + + // builds the URL fragment: just the interval when no filter is active ("1y"), or "interval=1y&op=and&mask=5" when filtering + getFragment(interval?: string): string { + const timespan = interval ?? this.radioGroupForm.controls.dateSpan.value; + const { op, mask } = this.goggle$.value; + return mask ? `interval=${timespan}&op=${op}&mask=${mask.toString()}` : timespan; + } + + // restores state from a fragment in either form, letting block-filters pick up restored filters via activeGoggles$ + private parseFragment(fragment: string): void { + if (!fragment) { + return; + } + if (this.intervals.includes(fragment)) { + this.radioGroupForm.controls.dateSpan.setValue(fragment, { emitEvent: false }); + return; + } + const params = new URLSearchParams(fragment); + const rawInterval = params.get('interval') ?? ''; + const interval = this.intervals.includes(rawInterval) ? rawInterval : this.radioGroupForm.controls.dateSpan.value; + const maskParam = params.get('mask') ?? ''; + const mask = maskParam && /^\d+$/.test(maskParam) ? BigInt(maskParam) : 0n; + const op = (['and', 'or', 'nor'].includes(params.get('op')) ? params.get('op') : 'and') as FilterMode; + + this.radioGroupForm.controls.dateSpan.setValue(interval, { emitEvent: false }); + // skip if already applied, otherwise the navigation in onFilterChanged would loop back here + if ((mask > 0n && (this.goggle$.value.mask ?? 0n) !== mask) || this.goggle$.value.op !== op) { + this.stateService.activeGoggles$.next({ mode: op, filters: toFilters(mask).map(f => f.key), gradient: 'fee' }); + } + } + + prepareChartOptions(categories: number[], totalData: GogglesDatum[], matchedData: GogglesDatum[], error?): void { + const filtered = !!this.goggle$.value.mask; + const perBlock = totalData.length > 0 && totalData[0].bucketSize === 1; + let title: object; + if (totalData.length === 0 ) { + title = { + textStyle: { + color: 'grey', + fontSize: 15 + }, + text: $localize`:@@23555386d8af1ff73f297e89dd4af3f4689fb9dd:Indexing blocks`, + left: 'center', + top: 'center' + }; + } + if (error && error.status === 404) { + title = { + textStyle: { + color: 'grey', + fontSize: 15 + }, + text: $localize`Block summaries indexing is required for this graph`, + left: 'center', + top: 'center' + }; + } + + const unitIsVb = this.unitGroupForm.controls.unitType.value === 'vb'; + const yAxisName = this.relativeMode + ? (unitIsVb ? $localize`Share of vsize (%)` : $localize`Share of txs (%)`) + : (unitIsVb ? $localize`Total vsize (vB)` : $localize`Total txs (Count)`); + this.chartOptions = { + title, + color: ['#1E88E5'], + animation: false, + grid: { + right: this.right, + left: this.left, + bottom: this.widget ? 30 : 80, + top: this.widget ? 20 : (this.isMobile() ? 10 : 50), + }, + tooltip: { + show: !this.isMobile(), + trigger: 'axis', + axisPointer: { + type: 'line' + }, + backgroundColor: 'rgba(17, 19, 31, 1)', + borderRadius: 4, + shadowColor: 'rgba(0, 0, 0, 0.5)', + textStyle: { + color: 'var(--tooltip-grey)', + align: 'left', + }, + borderColor: '#000', + formatter: function(params): string { + if (!params || params.length <= 0) { + return ''; + } + const baseline = params.find(p => p.seriesId === 'total'); + const matched = params.find(p => p.seriesId === 'matched'); + const anchor = baseline || matched; + if (!anchor) { + return ''; + } + const startHeight = anchor.data.startHeight; + const bucketSize = anchor.data.bucketSize || 1; + const timestampMs = anchor.data.timestampMs; + const baseTxCount = baseline ? baseline.data.txCount : (matched ? matched.data.baseTxCount : 0); + const baseVSize = baseline ? baseline.data.vSizeTotal : (matched ? matched.data.baseVSize : 0); + const filtered = !!this.goggle$.value.mask; + const unitIsTxCount = this.unitGroupForm.controls.unitType.value === 'txCount'; + const rolledUp = bucketSize > 1; + + const fmtCount = (v): string => formatNumber(v, this.locale, '1.0-0'); + const fmtAvg = (v): string => formatNumber(v, this.locale, '1.0-2'); + const fmtVSize = (v): string => this.vbytesPipe.transform(v, 2, 'vB', undefined, true); + const fmtPct = (v): string => formatNumber(v, this.locale, '1.0-2') + '%'; + + let tooltip = ''; + tooltip += `${formatterXAxis(this.locale, this.timespan, timestampMs)}
`; + + const fmtVal = (v): string => unitIsTxCount + ? (rolledUp ? fmtAvg(v / bucketSize) : fmtCount(v)) + : fmtVSize(rolledUp ? v / bucketSize : v); + + if (baseline) { + tooltip += `${baseline.marker} ${baseline.seriesName}: ${fmtVal(unitIsTxCount ? baseTxCount : baseVSize)}
`; + } + + if (filtered && matched) { + const matchedVal = unitIsTxCount ? matched.data.txCount : matched.data.vSizeTotal; + const base = unitIsTxCount ? baseTxCount : baseVSize; + tooltip += `${matched.marker} ${matched.seriesName}: ${fmtVal(matchedVal)}
`; + if (base > 0) { + tooltip += `${matched.marker} ` + $localize`Share` + `: ${fmtPct(matchedVal / base * 100)}
`; + } + } + + if (rolledUp) { + tooltip += `` + $localize`*On average between blocks ${startHeight} - ${startHeight + bucketSize - 1}` + ``; + } else { + tooltip += `` + $localize`At block: ${startHeight}` + ``; + } + return tooltip; + }.bind(this) + }, + xAxis: totalData.length === 0 ? undefined : { + name: this.widget ? undefined : formatterXAxisLabel(this.locale, this.timespan), + nameLocation: 'middle', + nameTextStyle: { + padding: [10, 0, 0, 0], + }, + type: 'category', + data: categories, + axisLine: { onZero: false }, + splitLine: { show: false }, + axisLabel: { + formatter: (value): string => { + const ts = this.bucketTimestampByHeight.get(Number(value)); + return ts !== undefined ? formatterXAxisTimeCategory(this.locale, this.timespan, ts) : ''; + }, + align: 'center', + fontSize: 11, + lineHeight: 12, + hideOverlap: true, + padding: [0, 5], + }, + }, + yAxis: totalData.length === 0 ? undefined : { + position: 'left', + name: this.widget ? undefined : yAxisName, + nameLocation: 'middle', + nameRotate: 90, + nameGap: 55, + nameTextStyle: { + fontSize: 11, + color: 'rgb(110, 112, 121)' + }, + axisLabel: { + color: 'rgb(110, 112, 121)', + formatter: (val): string => { + if (this.relativeMode) { + return `${val}%`; + } + if (this.unitGroupForm.controls.unitType.value === 'vb') { + return this.vbytesPipe.transform(val, 0, 'vB', undefined, true); + } + const selectedPowerOfTen: any = selectPowerOfTen(val); + const newVal = Math.round(val / selectedPowerOfTen.divider); + return `${newVal}${selectedPowerOfTen.unit}`; + }, + }, + splitLine: { + lineStyle: { + type: 'dotted', + color: 'var(--transparent-fg)', + opacity: 0.25, + } + }, + type: 'value', + }, + legend: (this.widget || totalData.length === 0 || !filtered) ? undefined : { + top: 'top', + data: [ + { + name: this.allLabel, + inactiveColor: 'rgb(110, 112, 121)', + textStyle: { color: 'var(--fg)' }, + icon: 'roundRect', + }, + { + name: this.matchedLabel, + inactiveColor: 'rgb(110, 112, 121)', + textStyle: { color: 'var(--fg)' }, + icon: 'roundRect', + }, + ], + selected: JSON.parse(this.storageService.getValue('goggles_legend') || 'null') ?? { + [this.allLabel]: true, + [this.matchedLabel]: true, + }, + }, + series: totalData.length === 0 ? undefined : [ + { + id: 'total', + zlevel: 0, + name: filtered ? this.allLabel : this.transactionsLabel, + data: totalData, + type: 'bar', + barWidth: '100%', + cursor: perBlock ? 'pointer' : 'default', + itemStyle: { color: '#1E88E5' }, // blue: total tx count + }, + ...(filtered && matchedData.length > 0 ? [{ + id: 'matched', + zlevel: 1, + z: 3, + name: this.matchedLabel, + data: matchedData, + type: 'bar', + barWidth: '100%', + barGap: '-100%', // overlay directly on top of the total bars + cursor: perBlock ? 'pointer' : 'default', + itemStyle: { color: '#8E24AA' }, + }] : []), + ], + dataZoom: this.widget ? null : [{ + type: 'inside', + realtime: true, + zoomLock: true, + maxSpan: 100, + minSpan: 5, + moveOnMouseMove: false, + }, { + showDetail: false, + show: true, + type: 'slider', + brushSelect: false, + realtime: true, + left: 20, + right: 15, + selectedDataBackground: { + lineStyle: { + color: '#fff', + opacity: 0.45, + }, + areaStyle: { + opacity: 0, + } + }, + }], + }; + } + + onChartInit(ec): void { + if (this.chartInstance !== undefined) { + return; + } + + this.chartInstance = ec; + + this.chartInstance.on('click', (e) => { + if (e.data.bucketSize > 1) { + return; + } + this.zone.run(() => { + const url = new RelativeUrlPipe(this.stateService).transform(`/block/${e.data.startHeight}`); + this.router.navigate([url]); + }); + }); + + this.chartInstance.on('legendselectchanged', (e) => { + this.storageService.setValue('goggles_legend', JSON.stringify(e.selected)); + }); + } + + isMobile(): boolean { + return (window.innerWidth <= 767.98); + } + + // the bucket sizes a given interval can be viewed at (24h is per-block only; longer ranges are week/month) + bucketSizesForInterval(interval: string): number[] { + return INTERVAL_PRESETS[interval] ?? [1008, 4032]; + } + + // for the template: the bucket options for the currently selected interval (used to show/hide the selector) + get availableBucketSizes(): number[] { + return this.bucketSizesForInterval(this.radioGroupForm.controls.dateSpan.value); + } + + get isFiltered(): boolean { + return !!this.goggle$.value.mask; + } + + onSaveChart(): void { + // @ts-ignore + const prevBottom = this.chartOptions.grid.bottom; + // @ts-ignore + const prevTitle = { ...this.chartOptions.title ?? {text: ''}}; + // @ts-ignore + const prevYAxisNameStyle: any = { ...this.chartOptions.yAxis.nameTextStyle}; + const now = new Date(); + const { op, mask } = this.goggle$.value; + const filters = mask ? toFilters(mask).map(f => f.label) : []; + if (this.chartOptions.legend) { + const currentLegend = this.chartInstance.getOption().legend; + if (currentLegend?.[0]?.selected) { + // @ts-ignore + this.chartOptions.legend.selected = currentLegend[0].selected; + } + } + // @ts-ignore + this.chartOptions.grid.bottom = 90; + this.chartOptions.backgroundColor = 'var(--active-bg)'; + const bucket = this.bucketGroupForm.controls.bucketSize.value; + const bucketSuffix = bucket === 1008 + ? $localize`Weekly average` + : bucket === 4032 + ? $localize`Monthly average` + : ''; + let expression = ''; + if (filters.length && this.chartOptions.xAxis) { + if (op === 'nor') { + expression += $localize`matching none of: `; + } else if (op === 'or') { + expression += $localize`matching any of: `; + } else { + expression += $localize`matching all of: `; + } + expression += filters.length > 1 ? filters.join(' - ') : filters[0]; + } + const text = `${bucketSuffix} ${$localize`of transactions`} ${expression}`; + this.chartOptions.title = { + text, + textStyle: { color: 'white', fontSize: 15, fontWeight: 'normal' }, + left: 'center', + bottom: 15, + }; + // @ts-ignore + this.chartOptions.yAxis.nameTextStyle = { + fontSize: 14, + color: 'white', + }; + this.chartInstance.setOption(this.chartOptions); + download(this.chartInstance.getDataURL({ + pixelRatio: 2, + excludeComponents: ['dataZoom'], + }), `block-goggles-${this.timespan}${mask ? `-${op}-${mask.toString()}` : ''}-${Math.round(now.getTime() / 1000)}.svg`); + // @ts-ignore + this.chartOptions.grid.bottom = prevBottom; + this.chartOptions.backgroundColor = 'none'; + this.chartOptions.title = prevTitle; + // @ts-ignore + this.chartOptions.yAxis.nameTextStyle = prevYAxisNameStyle; + this.chartInstance.setOption(this.chartOptions); + } +} diff --git a/frontend/src/app/components/graphs/graphs.component.html b/frontend/src/app/components/graphs/graphs.component.html index 74e37d298..4490d964b 100644 --- a/frontend/src/app/components/graphs/graphs.component.html +++ b/frontend/src/app/components/graphs/graphs.component.html @@ -3,6 +3,9 @@ Mempool + Goggles +
diff --git a/frontend/src/app/graphs/graphs.module.ts b/frontend/src/app/graphs/graphs.module.ts index 5994a1775..737d3a5fa 100644 --- a/frontend/src/app/graphs/graphs.module.ts +++ b/frontend/src/app/graphs/graphs.module.ts @@ -9,6 +9,7 @@ import { BlockFeesSubsidyGraphComponent } from '@components/block-fees-subsidy-g import { PriceChartComponent } from '@components/price-chart/price-chart.component'; import { BlockRewardsGraphComponent } from '@components/block-rewards-graph/block-rewards-graph.component'; import { BlockFeeRatesGraphComponent } from '@components/block-fee-rates-graph/block-fee-rates-graph.component'; +import { BlockGogglesGraphComponent } from '@components/block-goggles-graph/block-goggles-graph.component'; import { BlockSizesWeightsGraphComponent } from '@components/block-sizes-weights-graph/block-sizes-weights-graph.component'; import { FeeDistributionGraphComponent } from '@components/fee-distribution-graph/fee-distribution-graph.component'; import { IncomingTransactionsGraphComponent } from '@components/incoming-transactions-graph/incoming-transactions-graph.component'; @@ -70,6 +71,7 @@ import { CommonModule } from '@angular/common'; PriceChartComponent, BlockRewardsGraphComponent, BlockFeeRatesGraphComponent, + BlockGogglesGraphComponent, BlockSizesWeightsGraphComponent, FeeDistributionGraphComponent, IncomingTransactionsGraphComponent, diff --git a/frontend/src/app/graphs/graphs.routing.module.ts b/frontend/src/app/graphs/graphs.routing.module.ts index f0399f410..91c43ecdb 100644 --- a/frontend/src/app/graphs/graphs.routing.module.ts +++ b/frontend/src/app/graphs/graphs.routing.module.ts @@ -25,6 +25,7 @@ import { AccelerationsListComponent } from '@components/acceleration/acceleratio import { AddressComponent } from '@components/address/address.component'; import { WalletComponent } from '@components/wallet/wallet.component'; import { CalculatorComponent } from '@components/calculator/calculator.component'; +import { BlockGogglesGraphComponent } from '@components/block-goggles-graph/block-goggles-graph.component'; const browserWindow = window || {}; // @ts-ignore @@ -114,6 +115,11 @@ const routes: Routes = [ data: { networks: ['bitcoin', 'liquid'] }, component: StatisticsComponent, }, + { + path: 'goggles', + data: { networks: [ 'bitcoin' ]}, + component: BlockGogglesGraphComponent, + }, { path: 'mining/hashrate-difficulty', data: { networks: ['bitcoin'] }, diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index d05b02c66..11be60f22 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -407,6 +407,13 @@ export class ApiService { ); } + getHistoricalTxCountByFlags$(interval: string, bucketSize: string, op?: string, mask?: string) : Observable> { + return this.httpClient.get( + this.apiBaseUrl + this.apiBasePath + `/api/v1/goggles/${interval}/${bucketSize}` + + (op !== undefined && mask !== undefined ? `/${op}/${mask}` : ''), { observe: 'response' } + ); + } + getBlockAudit$(hash: string) : Observable { this.setBlockAuditLoaded(hash); return this.httpClient.get( From a0e74fcf9e8d8a3b05a4f0639d881aa63b59609e Mon Sep 17 00:00:00 2001 From: rodribp Date: Tue, 11 Aug 2026 16:46:24 -0600 Subject: [PATCH 5/5] fix: use actual indexed block count for X-total-count header --- backend/src/api/bitcoin/bitcoin.routes.ts | 8 +++++--- backend/src/repositories/FlagValueRepository.ts | 12 ++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/backend/src/api/bitcoin/bitcoin.routes.ts b/backend/src/api/bitcoin/bitcoin.routes.ts index 73741efbf..2388a5618 100644 --- a/backend/src/api/bitcoin/bitcoin.routes.ts +++ b/backend/src/api/bitcoin/bitcoin.routes.ts @@ -1176,16 +1176,18 @@ class BitcoinRoutes { const op = (req.params.op) as 'and' | 'or' | 'nor' | undefined; const mask = BigInt(req.params.mask ?? 0n); - const { tip } = await FlagValueRepository.$getTipAndTailIndexedByBucketSize(bucketSize) || { tip: undefined }; + const { tip, tail } = await FlagValueRepository.$getTipAndTailIndexedByBucketSize(bucketSize) || { tip: undefined, tail: undefined }; - if (!tip) { + if (tip === undefined || tail === undefined) { handleError(req, res, 400, `Failed to get latest indexed flag values for ${interval}`); return; } + const totalCount = await FlagValueRepository.$getTotalBlocksIndexedByBucketSize(bucketSize === 1 ? 1008 : bucketSize) ?? tip - tail; + const startHeight = presets[interval].retentionSpan !== -1 ? (tip - presets[interval].retentionSpan) : -1; const txsCount = await FlagValueRepository.$queryTxCountBasedOnMask(mask, bucketSize, op, startHeight); - res.header('X-total-count', tip.toString()); + res.header('X-total-count', totalCount.toString()); res.header('Expires', new Date(Date.now() + 1000 * 3600 * 24 * (presets[interval].bucketSizes[0] / 144)).toUTCString()); res.send(txsCount); } catch (e: any) { diff --git a/backend/src/repositories/FlagValueRepository.ts b/backend/src/repositories/FlagValueRepository.ts index 66f8495c1..9bedf7913 100644 --- a/backend/src/repositories/FlagValueRepository.ts +++ b/backend/src/repositories/FlagValueRepository.ts @@ -128,6 +128,18 @@ class FlagValuesRepository { logger.err(`Cannot delete flag values above ${height}. Reason: ` + (e instanceof Error ? e.message : e)); } } + + public async $getTotalBlocksIndexedByBucketSize(bucketSize: number): Promise { + try { + const [rows]: any[] = await DB.query(`SELECT (count(distinct start_height) * ?) as total FROM flag_values WHERE bucket_size = ?`, [bucketSize, bucketSize.toString()]); + if (rows !== null && rows.length > 0) { + return rows[0].total; + } + } catch (e) { + logger.err(`Cannot get total blocks indexed in flag_values. Reason: ` + (e instanceof Error ? e.message : e)); + } + return null; + } } export default new FlagValuesRepository();