From 04aad093056fd8876b5373373b20c118cc8c73eb Mon Sep 17 00:00:00 2001 From: rodribp Date: Tue, 21 Jul 2026 04:22:13 -0600 Subject: [PATCH] feat: coinbase bip 54 badge in block details --- backend/src/api/blocks.ts | 61 +++++++++++++++++++ backend/src/api/database-migration.ts | 7 ++- backend/src/indexer.ts | 1 + backend/src/mempool.interfaces.ts | 1 + backend/src/repositories/BlocksRepository.ts | 50 +++++++++++++-- .../app/components/block/block.component.html | 10 +++ .../src/app/interfaces/node-api.interface.ts | 1 + 7 files changed, 126 insertions(+), 5 deletions(-) diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index be247f571..df500c920 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -51,6 +51,7 @@ class Blocks { private quarterEpochBlockTime: number | null = null; private newBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void)[] = []; private classifyingBlocks: boolean = false; + private updatingBlocksMissingCoinbaseBip54: boolean = false; private oldestCoreLogTimestamp: number | undefined | null = undefined; private mainLoopTimeout: number = 120000; @@ -319,6 +320,12 @@ class Blocks { extras.coinbaseSignatureAscii = null; } + const seq = transactions[0].vin[0].sequence; + + if (block.timestamp >= 1771507776) { // First block to include coinbase bip54 on mainnet + extras.coinbaseBip54 = (block.height > 0 && transactions[0].locktime === block.height - 1 && typeof seq === 'number' && seq !== 0xffffffff); + } + const header = await bitcoinClient.getBlockHeader(block.id, false); extras.header = header; @@ -931,6 +938,60 @@ class Blocks { this.classifyingBlocks = false; } + /** @asyncSafe */ + public async $updateBlocksMissingCoinbaseBip54(): Promise { + if (this.updatingBlocksMissingCoinbaseBip54) { + return; + } + + this.updatingBlocksMissingCoinbaseBip54 = true; + + if (!Common.indexingEnabled()) { + return; + } + + const blocksMissingCoinbaseBip54 = await BlocksRepository.$getBlocksMissingCoinbaseBip54(); + + if (!blocksMissingCoinbaseBip54.length) { + this.updatingBlocksMissingCoinbaseBip54 = false; + return; + } + + let timer = Date.now(); + let updatedThisRun = 0; + let updatedInTotal = 0; + const numToUpdate = blocksMissingCoinbaseBip54.length; + + logger.debug(`Updating blocks missing coinbase bip54 from #${blocksMissingCoinbaseBip54[0].height} to #${blocksMissingCoinbaseBip54[blocksMissingCoinbaseBip54.length - 1].height}`, logger.tags.mining); + for (const block of blocksMissingCoinbaseBip54) { + try { + const coinbaseTx = await bitcoinApi.$getCoinbaseTx(block.id); + const seq = coinbaseTx.vin[0].sequence; + const coinbaseBip54 = block.height > 0 && coinbaseTx.locktime === block.height - 1 && typeof seq === 'number' && seq !== 0xffffffff; + await BlocksRepository.$updateCoinbaseBip54(coinbaseBip54, block.id); + updatedInTotal++; + updatedThisRun++; + + } catch (e) { + logger.warn(`Failed to update bip54 field in block #${block.height}`, logger.tags.mining); + } + + const elapsedSeconds = (Date.now() - timer) / 1000; + if (elapsedSeconds > 5) { + const perSecond = updatedThisRun / elapsedSeconds; + const progress = (updatedInTotal / numToUpdate) * 100; + logger.debug(`Updated #${block.height}: ${updatedInTotal} / ${numToUpdate} (${progress.toFixed(2)}%) | ~${perSecond.toFixed(1)} blocks/s`, logger.tags.mining); + timer = Date.now(); + updatedThisRun = 0; + } + + await Common.sleep$(250); //Don't DoS the DB + } + logger.debug(`Update of blocks missing coinbase bip54 completed`, logger.tags.mining); + + this.updatingBlocksMissingCoinbaseBip54 = false; + } + /** * [INDEXING] Index missing coinbase addresses for all blocks */ diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts index 3932179ed..992e8dead 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('ALTER TABLE `blocks` ADD coinbase_bip_54 TINYINT(1) NULL DEFAULT NULL'); + await this.updateToSchemaVersion(112); + } } /** diff --git a/backend/src/indexer.ts b/backend/src/indexer.ts index ff10d701e..7ef459236 100644 --- a/backend/src/indexer.ts +++ b/backend/src/indexer.ts @@ -228,6 +228,7 @@ class Indexer { await BlocksRepository.$migrateBlocks(); // do not wait for classify blocks to finish void blocks.$classifyBlocks(); + void blocks.$updateBlocksMissingCoinbaseBip54(); runSuccessful = true; } catch (e) { nextRunDelay = retryDelay; diff --git a/backend/src/mempool.interfaces.ts b/backend/src/mempool.interfaces.ts index 3f1403518..ab2f75144 100644 --- a/backend/src/mempool.interfaces.ts +++ b/backend/src/mempool.interfaces.ts @@ -314,6 +314,7 @@ export interface BlockExtension { avgFee: number; avgFeeRate: number; coinbaseRaw: string; + coinbaseBip54?: boolean; orphans: OrphanedBlock[] | null; coinbaseAddress: string | null; coinbaseAddresses: string[] | null; diff --git a/backend/src/repositories/BlocksRepository.ts b/backend/src/repositories/BlocksRepository.ts index 20d0a6e46..f68219819 100644 --- a/backend/src/repositories/BlocksRepository.ts +++ b/backend/src/repositories/BlocksRepository.ts @@ -61,6 +61,7 @@ interface DatabaseBlock { totalInputAmt: number; firstSeen: string; // UNIX_TIMESTAMP() returns a string when applied to datetime(6) stale: boolean; + coinbaseBip54: number | null; } const BLOCK_DB_FIELDS = ` @@ -106,7 +107,8 @@ const BLOCK_DB_FIELDS = ` blocks.utxoset_size AS utxoSetSize, blocks.total_input_amt AS totalInputAmt, UNIX_TIMESTAMP(blocks.first_seen) AS firstSeen, - blocks.stale + blocks.stale, + blocks.coinbase_bip_54 AS coinbaseBip54 `; class BlocksRepository { @@ -132,7 +134,7 @@ class BlocksRepository { total_inputs, total_outputs, total_input_amt, total_output_amt, fee_percentiles, segwit_total_txs, segwit_total_size, segwit_total_weight, median_fee_amt, coinbase_signature_ascii, definition_hash, index_version, - stale, first_seen + stale, first_seen, coinbase_bip_54 ) VALUE ( ?, ?, FROM_UNIXTIME(?), ?, ?, ?, ?, ?, @@ -144,7 +146,7 @@ class BlocksRepository { ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, FROM_UNIXTIME(?) + ?, FROM_UNIXTIME(?), ? )`; const poolDbId = await PoolsRepository.$getPoolByUniqueId(block.extras.pool.id); @@ -194,7 +196,8 @@ class BlocksRepository { poolsUpdater.currentSha, BlocksRepository.version, (block.stale ? 1 : 0), - block.extras.firstSeen === null ? 1 : block.extras.firstSeen // Sentinel value 1 indicates that we could not find first seen time + block.extras.firstSeen === null ? 1 : block.extras.firstSeen, // Sentinel value 1 indicates that we could not find first seen time + block.extras.coinbaseBip54 ?? null ]; await DB.query(query, params); @@ -1294,6 +1297,7 @@ class BlocksRepository { extras.utxoSetSize = dbBlk.utxoSetSize; extras.totalInputAmt = dbBlk.totalInputAmt; extras.virtualSize = dbBlk.weight / 4.0; + extras.coinbaseBip54 = dbBlk.coinbaseBip54 === null ? undefined : !!dbBlk.coinbaseBip54; extras.firstSeen = null; if (config.CORE_RPC.DEBUG_LOG_PATH) { @@ -1426,6 +1430,44 @@ class BlocksRepository { } return blocksMigrated; } + + /** @asyncSafe */ + public async $getBlocksMissingCoinbaseBip54(): Promise { + const query = `SELECT ${BLOCK_DB_FIELDS} FROM blocks + JOIN pools ON blocks.pool_id = pools.id + where blocks.coinbase_bip_54 IS NULL AND + blocks.height > 0 AND + blocks.blockTimestamp >= FROM_UNIXTIME(1771507776) AND + blocks.stale = 0 + ORDER BY blocks.height DESC`; + + try { + const [rows]: any[] = await DB.query(query); + + const blocks: BlockExtended[] = []; + for (const dbBlock of rows) { + blocks.push(await this.formatDbBlockIntoExtendedBlock(dbBlock as DatabaseBlock)); + } + + return blocks; + } catch (e) { + logger.err(`Cannot get blocks with missing bip54 flag. Reason: ` + (e instanceof Error ? e.message : e)); + } + + return []; + } + + public async $updateCoinbaseBip54(result: boolean, hash: string): Promise { + const query = `UPDATE blocks SET coinbase_bip_54 = ? WHERE hash = ?`; + const params = [result, hash]; + + try { + await DB.query(query, params); + } catch (e) { + logger.err(`Couldn't update coinbaseBip54 field for block ${hash}. Reason: ` + (e instanceof Error ? e.message : e)); + throw e; + } + } } export default new BlocksRepository(); diff --git a/frontend/src/app/components/block/block.component.html b/frontend/src/app/components/block/block.component.html index 2864cb4a5..62fae9925 100644 --- a/frontend/src/app/components/block/block.component.html +++ b/frontend/src/app/components/block/block.component.html @@ -212,6 +212,16 @@ + @if (network !== 'liquid' && network !== 'liquidtestnet' && block.extras.coinbaseBip54) { + + Features + + + BIP-54 coinbase + + + + } diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index 92450246a..7a2939602 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -215,6 +215,7 @@ export interface BlockExtension { feeRange?: number[]; reward?: number; coinbaseRaw?: string; + coinbaseBip54?: boolean; matchRate?: number; expectedFees?: number; expectedWeight?: number;