diff --git a/crates/brk_query/src/impl/block/info.rs b/crates/brk_query/src/impl/block/info.rs index ef5d335f9..6abe6c9e7 100644 --- a/crates/brk_query/src/impl/block/info.rs +++ b/crates/brk_query/src/impl/block/info.rs @@ -208,6 +208,7 @@ impl Query { let computer = self.computer(); let reader = self.reader(); let all_pools = pools(); + let pool_heights = computer.pools.pool_heights.read(); // Bulk read all indexed data let blockhashes = indexer.vecs.blocks.blockhash.collect_range_at(begin, end); @@ -398,6 +399,11 @@ impl Query { let pool_slug = pool_slugs[i]; let pool = all_pools.get(pool_slug); + let height = begin + i; + let block_number = pool_heights + .get(&pool_slug) + .map(|heights| heights.partition_point(|h| h.to_usize() <= height) as u64) + .unwrap_or(0); let miner_names = if pool_slug == PoolSlug::Ocean { Self::parse_datum_miner_names(&scriptsig_bytes) @@ -410,7 +416,7 @@ impl Query { let info = BlockInfo { id: blockhashes[i], - height: Height::from(begin + i), + height: Height::from(height), version: header.version, timestamp: timestamps[i], bits: header.bits, @@ -444,6 +450,7 @@ impl Query { id: pool.mempool_unique_id(), name: pool.name.to_string(), slug: pool_slug, + block_number, miner_names, }, avg_fee: Sats::from(total_fees_u64.checked_div(non_coinbase).unwrap_or(0)), diff --git a/crates/brk_types/src/block_pool.rs b/crates/brk_types/src/block_pool.rs index a34973ae1..1f0845389 100644 --- a/crates/brk_types/src/block_pool.rs +++ b/crates/brk_types/src/block_pool.rs @@ -18,6 +18,10 @@ pub struct BlockPool { /// URL-friendly pool identifier pub slug: PoolSlug, + /// This block's ordinal among blocks attributed to this pool + #[schemars(example = 215_000)] + pub block_number: u64, + /// Miner name tags found in coinbase scriptsig pub miner_names: Option>, } diff --git a/modules/brk-client/index.js b/modules/brk-client/index.js index 07a9201ef..8dbae77b1 100644 --- a/modules/brk-client/index.js +++ b/modules/brk-client/index.js @@ -259,6 +259,7 @@ Matches mempool.space/bitcoin-cli behavior. * @property {number} id - Unique pool identifier * @property {string} name - Pool name * @property {PoolSlug} slug - URL-friendly pool identifier + * @property {number} blockNumber - This block's ordinal among blocks attributed to this pool * @property {?string[]=} minerNames - Miner name tags found in coinbase scriptsig */ /** diff --git a/website_next/explore/block/index.js b/website_next/explore/block/index.js index 9ee612ff6..920fd1e47 100644 --- a/website_next/explore/block/index.js +++ b/website_next/explore/block/index.js @@ -12,6 +12,8 @@ import { createFeeChart } from "./fee-chart.js"; /** @typedef {Awaited>[number]} Block */ const MAX_BLOCK_WEIGHT = 4_000_000; +const DIFFICULTY_EPOCH_BLOCKS = 2_016; +const HALVING_EPOCH_BLOCKS = 210_000; /** @param {number} bytes */ function formatBytes(bytes) { @@ -100,54 +102,11 @@ function createRow(term, value) { return row; } -/** - * @param {string} label - * @param {string | Node} value - */ -function createStat(label, value) { - const stat = document.createElement("div"); - const name = document.createElement("span"); - const amount = document.createElement("strong"); - - stat.dataset.stat = ""; - name.textContent = label; - amount.append(value); - stat.append(name, amount); - - return stat; -} - -/** @param {[string, string | Node][]} items */ -function createStats(items) { - const stats = document.createElement("div"); - - stats.dataset.stats = ""; - stats.append(...items.map(([label, value]) => createStat(label, value))); - - return stats; -} - /** @param {string} title */ function groupName(title) { return title.toLowerCase().replace(/[^a-z0-9]+/g, "-"); } -/** - * @param {string} title - * @param {[string, string | Node][]} stats - * @param {Node[]} [children] - */ -function createStatBox(title, stats, children = []) { - const box = document.createElement("div"); - const heading = document.createElement("h3"); - - box.dataset.statBox = groupName(title); - heading.textContent = title; - box.append(heading, createStats(stats), ...children); - - return box; -} - /** * @param {string} label * @param {(string | Node)[]} values @@ -168,11 +127,12 @@ function createInlineRow(label, values) { /** * @param {string} label * @param {string | Node} value + * @param {string} [type] */ -function createInlineBox(label, value) { +function createInlineBox(label, value, type = "inline") { const box = document.createElement("div"); - box.dataset.blockBox = "inline"; + box.dataset.blockBox = type; box.append(createInlineRow(label, [value])); return box; @@ -183,21 +143,16 @@ function formatBlockFill(block) { return `${((block.weight / MAX_BLOCK_WEIGHT) * 100).toFixed(1)}%`; } -/** @param {number | string} bits */ -function formatBits(bits) { - return typeof bits === "number" ? `0x${bits.toString(16)}` : bits; -} - /** * @param {string} label * @param {string} value */ -function createMinerStat(label, value) { +function createMetricStat(label, value) { const stat = document.createElement("div"); const name = document.createElement("span"); const amount = document.createElement("strong"); - stat.dataset.minerStat = ""; + stat.dataset.metricStat = ""; name.textContent = label; amount.textContent = value; stat.append(name, amount); @@ -205,34 +160,114 @@ function createMinerStat(label, value) { return stat; } +/** @param {string} raw */ +function getCoinbaseMessage(raw) { + return (raw.match(/[\x20-\x7e]{2,}/g) ?? []) + .map((value) => value.trim()) + .filter((value) => /[A-Za-z0-9]/.test(value)) + .join(" ยท "); +} + +/** @param {string} raw */ +function createCoinbaseMessage(raw) { + const message = getCoinbaseMessage(raw); + + if (!message) return null; + + const element = document.createElement("p"); + + element.dataset.coinbaseMessage = ""; + element.textContent = message; + + return element; +} + +/** + * @param {string} label + * @param {number} height + * @param {number} length + * @param {string} color + */ +function createEpochProgress(label, height, length, color) { + const progress = (height % length) + 1; + const row = document.createElement("div"); + const head = document.createElement("div"); + const name = document.createElement("span"); + const value = document.createElement("strong"); + const bar = document.createElement("div"); + const done = document.createElement("span"); + const remaining = document.createElement("span"); + + row.dataset.epoch = ""; + head.dataset.epochHead = ""; + bar.dataset.epochBar = ""; + done.dataset.epochSegment = "done"; + remaining.dataset.epochSegment = "remaining"; + row.style.setProperty("--epoch-color", color); + done.style.setProperty("--share", `${(progress / length) * 100}%`); + remaining.style.setProperty("--share", `${((length - progress) / length) * 100}%`); + + name.textContent = label; + value.textContent = `${((progress / length) * 100).toFixed(1)}%`; + head.append(name, value); + bar.append(done, remaining); + row.append(head, bar); + + return row; +} + /** @param {Block} block */ function createMinerSummary(block) { const { pool } = block.extras; const pane = document.createElement("div"); const head = document.createElement("div"); const identity = document.createElement("div"); + const title = document.createElement("div"); const name = document.createElement("strong"); + const blockNumber = document.createElement("span"); const slug = document.createElement("span"); - const stats = document.createElement("div"); const logo = createPoolLogo(pool); + const coinbaseMessage = createCoinbaseMessage(block.extras.coinbaseSignatureAscii); pane.dataset.minerPane = ""; head.dataset.minerHead = ""; identity.dataset.minerIdentity = ""; + title.dataset.minerTitle = ""; slug.dataset.minerSlug = ""; - stats.dataset.minerStats = ""; logo.dataset.minerLogo = ""; name.textContent = pool.name; - slug.textContent = `#${pool.slug}`; - identity.append(name, slug); + // TODO: remove fallback after the server includes pool.blockNumber everywhere. + blockNumber.textContent = `#${(pool.blockNumber || 0).toLocaleString()}`; + slug.textContent = pool.slug; + title.append(name, blockNumber); + identity.append(title, slug); head.append(identity, logo); - stats.append( - createMinerStat("Difficulty", block.difficulty.toLocaleString()), - createMinerStat("Bits", formatBits(block.bits)), - createMinerStat("Nonce", block.nonce.toLocaleString()), + pane.append(head, ...(coinbaseMessage ? [coinbaseMessage] : [])); + + return pane; +} + +/** @param {Block} block */ +function createDifficultySummary(block) { + const pane = document.createElement("div"); + + pane.dataset.metricList = ""; + pane.append( + createMetricStat("Difficulty", block.difficulty.toLocaleString()), + createEpochProgress( + "Difficulty epoch", + block.height, + DIFFICULTY_EPOCH_BLOCKS, + "var(--orange)", + ), + createEpochProgress( + "Halving epoch", + block.height, + HALVING_EPOCH_BLOCKS, + "var(--red)", + ), ); - pane.append(head, stats); return pane; } @@ -320,16 +355,19 @@ function createRewardPart(type, label, sats, total, price) { } /** + * @param {string} label * @param {number} sats * @param {number} price */ -function createRewardTotal(sats, price) { +function createRewardTotal(label, sats, price) { const total = document.createElement("div"); + const name = document.createElement("span"); const amount = createBtcAmount("strong", sats); const usd = createSatsUsdAmount(sats, price); total.dataset.rewardTotal = ""; - total.append(amount, usd); + name.textContent = label; + total.append(name, amount, usd); return total; } @@ -365,18 +403,11 @@ function setRewardPreview(rewards, activeKey) { /** @param {Block["extras"]} extras */ function createRewardSummary(extras) { const subsidy = extras.reward - extras.totalFees; + const rewards = document.createElement("div"); const bar = document.createElement("div"); const split = createLegendList({ fill: true }); - const rewards = createStatBox( - "Rewards", - [], - [ - createRewardTotal(extras.reward, extras.price), - bar, - split, - ], - ); + rewards.dataset.statBox = "rewards"; appendLegendListItem( split, createRewardPart("subsidy", "Subsidy", subsidy, extras.reward, extras.price), @@ -390,6 +421,7 @@ function createRewardSummary(extras) { createRewardSegment("subsidy", subsidy, extras.reward), createRewardSegment("fees", extras.totalFees, extras.reward), ); + rewards.append(createRewardTotal("Rewards", extras.reward, extras.price), bar, split); rewards.addEventListener("pointerenter", (event) => { setRewardPreview(rewards, getRewardKey(event.target)); @@ -415,8 +447,8 @@ function createTransactionSummary(block) { transactions.dataset.blockBox = "tx"; io.dataset.blockIo = ""; io.append( - createInlineBox("Input", extras.totalInputs.toLocaleString()), - createInlineBox("Output", extras.totalOutputs.toLocaleString()), + createInlineBox("Input", extras.totalInputs.toLocaleString(), "input"), + createInlineBox("Output", extras.totalOutputs.toLocaleString(), "output"), ); transactions.append( createInlineRow("Tx", [block.txCount.toLocaleString()]), @@ -466,9 +498,10 @@ export function createBlockDetails() { const header = document.createElement("header"); const titleRow = document.createElement("div"); const title = document.createElement("h1"); - const summary = document.createElement("p"); + const date = document.createElement("time"); + const meta = document.createElement("div"); + const hash = document.createElement("p"); const price = createUsdAmount("output", 0, { - size: "title", tone: "positive", }); const content = document.createElement("div"); @@ -476,8 +509,12 @@ export function createBlockDetails() { element.id = "block-details"; element.hidden = true; titleRow.dataset.blockTitle = ""; - titleRow.append(title, price); - header.append(titleRow, summary); + date.dataset.blockDate = ""; + meta.dataset.blockMeta = ""; + hash.dataset.blockHashLine = ""; + titleRow.append(title, date); + meta.append(hash, price); + header.append(titleRow, meta); element.append(header, content); /** @param {Block} block */ @@ -486,13 +523,10 @@ export function createBlockDetails() { element.hidden = false; title.replaceChildren(...createTitle(block.height)); - summary.replaceChildren( - createHashElement(block.id), - document.createElement("br"), - formatDateTime(block.timestamp), - ); + date.dateTime = new Date(block.timestamp * 1_000).toISOString(); + date.textContent = formatDateTime(block.timestamp); + hash.replaceChildren(createHashElement(block.id)); renderUsdAmount(price, extras.price, { - size: "title", tone: "positive", }); @@ -503,13 +537,15 @@ export function createBlockDetails() { appendGroup(content, "Mining", [], [createMinerSummary(block)], false); + appendGroup(content, "Difficulty", [], [createDifficultySummary(block)], false); + appendGroup(content, "Rewards", [], [createRewardSummary(extras)], false); appendGroup(content, "Block", [], [createTransactionSummary(block)], false); appendGroup(content, "Fees", [], [ createFeeChart(extras.feeRange, extras.avgFeeRate, formatFeeRate), - ]); + ], false); } return /** @type {const} */ ({ diff --git a/website_next/explore/block/style.css b/website_next/explore/block/style.css index d22438ffa..93eef0353 100644 --- a/website_next/explore/block/style.css +++ b/website_next/explore/block/style.css @@ -22,12 +22,12 @@ display: grid; padding-bottom: 1.25rem; - [data-block-title] { - display: flex; - flex-wrap: wrap; - align-items: baseline; - justify-content: space-between; + :is([data-block-title], [data-block-meta]) { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; gap: 0.35rem 1rem; + align-items: baseline; + min-width: 0; } h1 { @@ -56,15 +56,28 @@ line-height: var(--line-height-lg); } - p { + [data-block-date], + [data-block-hash-line] { color: var(--gray); } + + [data-block-date] { + white-space: nowrap; + text-align: right; + } + + [data-block-hash-line] { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } } > div { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 1rem; + gap: 2rem; } section { @@ -82,7 +95,7 @@ [data-miner-pane] { display: grid; - gap: 0.75rem; + gap: 1rem; min-width: 0; } @@ -90,12 +103,21 @@ display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 1rem; - align-items: start; + align-items: center; min-width: 0; } [data-miner-identity] { display: grid; + gap: 0.125rem; + min-width: 0; + } + + [data-miner-title] { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: baseline; min-width: 0; > strong { @@ -106,29 +128,49 @@ font-weight: 450; line-height: var(--line-height-sm); } + + > span { + color: var(--gray); + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); + } } [data-miner-slug] { min-width: 0; overflow-wrap: anywhere; color: var(--gray); - font-size: var(--font-size-xs); - line-height: var(--line-height-xs); + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); } [data-miner-logo] { - width: 2rem; - height: 2rem; + width: 1.25rem; + height: 1.25rem; object-fit: contain; } - [data-miner-stats] { + [data-coinbase-message] { + min-width: 0; + overflow: hidden; + color: var(--white); + font-size: var(--font-size-sm); + font-style: italic; + line-height: var(--line-height-sm); + text-overflow: ellipsis; + white-space: nowrap; + } + + } + + &:is([data-group="mining"], [data-group="difficulty"]) { + [data-metric-list] { display: grid; - gap: 0.35rem; + gap: 0.5rem; min-width: 0; } - [data-miner-stat] { + [data-metric-stat] { display: grid; grid-template-columns: minmax(5.5rem, auto) minmax(0, 1fr); gap: 0.75rem; @@ -152,6 +194,61 @@ text-align: right; } } + + [data-epoch] { + display: grid; + gap: 0.25rem; + min-width: 0; + } + + [data-epoch-head] { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.75rem; + align-items: center; + min-width: 0; + + > span { + min-width: 0; + overflow-wrap: anywhere; + color: var(--epoch-color); + font-size: var(--font-size-xs); + line-height: var(--line-height-xs); + text-transform: uppercase; + } + + strong { + color: var(--white); + font-size: var(--font-size-sm); + font-weight: 450; + line-height: var(--line-height-sm); + text-align: right; + } + } + + [data-epoch-bar] { + display: flex; + gap: 0.125rem; + height: 0.5rem; + min-width: 0; + } + + [data-epoch-segment] { + width: var(--share); + border-radius: 0.125rem; + + &[data-epoch-segment="done"] { + background: var(--epoch-color); + } + + &[data-epoch-segment="remaining"] { + background: color-mix(in oklch, var(--gray) 35%, transparent); + } + } + } + + &[data-group="difficulty"] { + --section-color: var(--orange); } &[data-group="block"] { @@ -169,59 +266,9 @@ &[data-group="rewards"] { [data-stat-box] { display: grid; - gap: 0.75rem; - min-width: 0; - border: 1px solid - color-mix(in oklch, var(--section-color) 28%, transparent); - border-radius: 0.25rem; - padding: 0.75rem; - - h3 { - color: var(--section-color); - font-family: var(--font-mono); - font-size: var(--font-size-xs); - font-weight: 450; - line-height: var(--line-height-xs); - text-transform: uppercase; - } - - [data-stat-box] { - border-color: color-mix( - in oklch, - var(--section-color) 18%, - transparent - ); - } - } - - [data-stats] { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.5rem; min-width: 0; } - - [data-stat] { - display: grid; - gap: 0.25rem; - min-width: 0; - - > span { - color: var(--section-color); - font-size: var(--font-size-xs); - line-height: var(--line-height-xs); - text-transform: uppercase; - } - - strong { - min-width: 0; - overflow-wrap: anywhere; - color: var(--white); - font-size: var(--font-size-sm); - font-weight: 450; - line-height: var(--line-height-sm); - } - } } &[data-group="block"] { @@ -230,16 +277,20 @@ gap: 0.5rem; min-width: 0; border: 1px solid - color-mix(in oklch, var(--section-color) 28%, transparent); + color-mix(in oklch, var(--section-color) 35%, transparent); border-radius: 0.25rem; padding: 0.75rem; - [data-block-box] { - border-color: color-mix( - in oklch, - var(--section-color) 18%, - transparent - ); + &[data-block-box="tx"] { + border-color: color-mix(in oklch, var(--orange) 35%, transparent); + } + + &[data-block-box="input"] { + border-color: color-mix(in oklch, var(--yellow) 55%, transparent); + } + + &[data-block-box="output"] { + border-color: color-mix(in oklch, var(--red) 55%, transparent); } } @@ -263,7 +314,7 @@ align-items: center; justify-content: flex-end; min-width: 0; - color: var(--white); + color: var(--section-color); font-size: var(--font-size-sm); font-weight: 450; line-height: var(--line-height-sm); @@ -274,38 +325,55 @@ [data-block-io] { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - gap: 0.5rem; + gap: 0.75rem; min-width: 0; [data-block-box] { - aspect-ratio: 1 / 1; - place-content: center; + align-content: center; padding: 0.5rem; } [data-inline-row] { - grid-template-columns: minmax(0, 1fr); - justify-items: center; - gap: 0.25rem; - - strong { - justify-content: center; - text-align: center; - } + grid-template-columns: auto auto; + justify-content: space-between; + gap: 0.5rem; + width: 100%; } } + + [data-block-box="tx"] > [data-inline-row] { + > span, + strong { + color: var(--orange); + } + } + + :is([data-block-box="input"], [data-block-box="output"]) > [data-inline-row] { + > span, + strong { + color: var(--block-box-color); + } + } + + [data-block-box="input"] { + --block-box-color: var(--yellow); + } + + [data-block-box="output"] { + --block-box-color: var(--red); + } } &[data-group="rewards"] { [data-reward-total] { display: grid; - gap: 0.25rem; - justify-items: center; + gap: 0.125rem; + justify-items: start; min-width: 0; color: var(--gray); font-size: var(--font-size-xs); line-height: var(--line-height-xs); - text-align: center; + text-align: left; strong { min-width: 0; @@ -315,6 +383,11 @@ font-weight: 450; line-height: var(--line-height-sm); } + + > span:first-child { + color: var(--section-color); + text-transform: uppercase; + } } [data-reward-bar] { @@ -416,12 +489,6 @@ grid-column: auto; } - section[data-group="rewards"] { - [data-stats] { - grid-template-columns: minmax(0, 1fr); - } - } - dl > div { grid-template-columns: minmax(0, 1fr); gap: 0.15rem;