From e1f0d60c768ba0caf544133995079bbfe50aeba0 Mon Sep 17 00:00:00 2001 From: Randall Naar Date: Wed, 29 Jul 2026 18:19:51 -0400 Subject: [PATCH] Added pending block component and new block details pane. --- client/src/app.js | 91 +- client/src/components/block-details-card.js | 169 ++- client/src/components/block-grid.js | 88 +- client/src/components/expected-block-time.js | 43 + client/src/components/icons.js | 5 + client/src/components/info-card.js | 9 +- client/src/components/mempool-congestion.js | 44 + client/src/components/metric-bar.js | 28 + client/src/components/reference-line-chart.js | 4 +- client/src/components/status-badge.js | 8 + .../src/components/transaction-block-grid.js | 1298 +++++++++++++++++ client/src/const.js | 2 + client/src/lib/block-template.js | 137 ++ client/src/lib/fees.js | 20 + client/src/lib/math.js | 2 + client/src/lib/mempool.js | 36 + client/src/lib/pending-block-details.js | 88 ++ client/src/views/asset.js | 8 +- client/src/views/block.js | 50 +- client/src/views/blocks.js | 60 +- client/src/views/difficulty-adjustment.js | 10 +- client/src/views/home.js | 2 +- client/src/views/overview.js | 90 +- client/src/views/peg-info.js | 10 +- .../src/views/pending-block-details-card.js | 760 ++++++++++ client/src/views/transactions.js | 11 +- client/src/views/tx.js | 9 +- lang/strings.txt | 24 + test/app.test.js | 134 ++ test/block-details-card.test.js | 329 +++++ test/block-template.test.js | 114 ++ test/fees.test.js | 21 + test/mempool.test.js | 31 + test/pending-block-details.test.js | 84 ++ test/transaction-block-grid.test.js | 23 + www/style.css | 698 ++++++++- 36 files changed, 4323 insertions(+), 217 deletions(-) create mode 100644 client/src/components/expected-block-time.js create mode 100644 client/src/components/mempool-congestion.js create mode 100644 client/src/components/metric-bar.js create mode 100644 client/src/components/transaction-block-grid.js create mode 100644 client/src/lib/block-template.js create mode 100644 client/src/lib/math.js create mode 100644 client/src/lib/mempool.js create mode 100644 client/src/lib/pending-block-details.js create mode 100644 client/src/views/pending-block-details-card.js create mode 100644 test/app.test.js create mode 100644 test/block-details-card.test.js create mode 100644 test/block-template.test.js create mode 100644 test/mempool.test.js create mode 100644 test/pending-block-details.test.js create mode 100644 test/transaction-block-grid.test.js diff --git a/client/src/app.js b/client/src/app.js index a6e071e29..bc2627c10 100644 --- a/client/src/app.js +++ b/client/src/app.js @@ -3,9 +3,10 @@ import { Observable as O } from './rxjs' import {setAdapt} from '@cycle/run/lib/adapt'; import { getMempoolDepth, getConfEstimate, calcSegwitFeeGains } from './lib/fees' +import { summarizeBlockTemplate } from './lib/block-template' import { isBitcoinNetwork } from './lib/network' import getPrivacyAnalysis from './lib/privacy-analysis' -import { nativeAssetId, blockTxsPerPage, blocksPerPage, difficultyPeriod, showPegData } from './const' +import { nativeAssetId, blockTxsPerPage, blocksPerPage, difficultyPeriod, showPegData, blockGridTransactionSelectEvent } from './const' import { dbg, combine, @@ -72,6 +73,50 @@ const trackNewEntries = (items$, getId, getNewIds=defaultNewIds) => { .scan((current, mod) => mod(current), {}) } +const trackPendingBlockTemplateUpdate = (previous, template) => { + if (!template || !Array.isArray(template.transactions)) { + return { template: null, key: null, transactionCount: null, delta: null } + } + + const key = template.previousblockhash != null + ? template.previousblockhash + : template.height + , transactionCount = template.transactions.length + 1 + , isSamePendingBlock = previous.key != null && key != null && previous.key == key + , delta = isSamePendingBlock && previous.transactionCount != null + ? transactionCount - previous.transactionCount + : null + + return { template, key, transactionCount, delta: delta || null } +} + +export const trackPendingBlockTemplateEvent = (previous, event) => { + if (event.tipId != null) { + return previous.template && previous.template.previousblockhash == event.tipId + ? { ...previous, tipId: event.tipId } + : { + template: null, + key: null, + transactionCount: null, + delta: null, + tipId: event.tipId + } + } + + const template = event.template + if ( + previous.tipId != null && + (!template || template.previousblockhash != previous.tipId) + ) { + return previous + } + + return { + ...trackPendingBlockTemplateUpdate(previous, template), + tipId: previous.tipId + } +} + export default function main({ DOM, HTTP, route, storage, scanner: scan$, search: searchResult$, blinding: unblinded$ }) { const @@ -137,6 +182,10 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search , togTx$ = click('[data-toggle-tx]').map(d => d.toggleTx).merge(page$.mapTo(null), expandTx$) , togBlock$ = click('[data-toggle-block]').map(d => d.toggleBlock).merge(page$.mapTo(null), expandBl$) + , togPendingBlockDetails$ = click('[data-toggle-pending-block-details]') + , selectBlockGridTx$ = on('.block-grid__canvas', blockGridTransactionSelectEvent) + .map(e => e.detail && e.detail.txid) + .filter(isHash256) , copy$ = click('[data-clipboard-copy]').map(d => d.clipboardCopy) , pushtx$ = (process.browser @@ -186,7 +235,7 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search , latestBlock$ = blocks$ .map(blocks => blocks && blocks[0]) .filter(Boolean) - .distinctUntilChanged((a, b) => a.height == b.height) + .distinctUntilChanged((a, b) => a.id == b.id) , newBlockEntries$ = trackNewEntries( blocks$, @@ -237,6 +286,11 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search // Currently collapsed tx/block ("details") , openTx$ = togTx$.startWith(null).scan((prev, txid) => prev == txid ? null : txid) , openBlock$ = togBlock$.startWith(null).scan((prev, blockhash) => prev == blockhash ? null : blockhash) + , pendingBlockDetailsOpen$ = togPendingBlockDetails$ + .mapTo(open => !open) + .merge(page$.mapTo(_ => false)) + .startWith(false) + .scan((open, mod) => mod(open)) // Spending txs map (reset on every page nav) , spends$ = O.merge( @@ -251,6 +305,18 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search , mempool$ = reply('mempool').startWith(null) , mempoolRecent$ = reply('recent') , newTxEntries$ = trackNewEntries(mempoolRecent$, tx => tx.txid) + , trackedBlockTemplateState$ = O.merge( + reply('block-template').map(template => ({ template })) + , latestBlock$.map(block => ({ tipId: block.id })) + ) + .startWith({ template: null }) + .scan(trackPendingBlockTemplateEvent, { + template: null, + key: null, + transactionCount: null, + delta: null, + tipId: null + }) // dashboard , dashboardPegAsset$ = !showPegData @@ -286,6 +352,13 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search // Fee estimates , feeEst$ = reply('fee-est').startWith(null) + , blockTemplateState$ = O.combineLatest( + trackedBlockTemplateState$ + , feeEst$ + , (state, feeEst) => ({ + ...state + , metrics: summarizeBlockTemplate(state.template, feeEst) + })) // Bitcoin price chart data , bitcoinMarketChart$ = reply('bitcoin-market-chart').startWith(null) @@ -373,6 +446,7 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search // App state , state$ = combine({ t$, error$, tipHeight$, spends$ , goBlocks$, blocks$, nextBlocks$, prevBlocks$, dashboardState$ + , pendingBlockDetailsOpen$, blockTemplateState$ , dashboardEpochStartBlock$, dashboardPreviousDifficultyBlock$ , newBlockEntries$, newTxEntries$ , goBlock$, block$, blockStatus$, blockTxs$, nextBlockTxs$, prevBlockTxs$, openBlock$ @@ -489,6 +563,16 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search , { category: 'dashboard-peg-chain-txs', method: 'GET', path: `/asset/${nativeAssetId}/txs/chain`, bg: true } , { category: 'dashboard-peg-mempool-txs', method: 'GET', path: `/asset/${nativeAssetId}/txs/mempool`, bg: true }])) + // refresh the pending block template while the dashboard remains open + , O.merge( + O.merge(goHome$, tickWhileViewing(30000, 'dashBoard', view$)) + .throttleTime(1000) + , latestBlock$.skip(1) + .withLatestFrom(view$) + .filter(([ _, view ]) => view == 'dashBoard') + ) + .mapTo({ category: 'block-template', method: 'GET', path: '/block-template', bg: true }) + , goHome$.flatMap(_ => [{ category: 'blocks', method: 'GET', path: '/blocks' } , { category: 'recent', method: 'GET', path: '/mempool/recent' } , { category: 'fee-est', method: 'GET', path: '/fee-estimates' } @@ -536,6 +620,7 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search searchResult$.filter(Boolean).map(result => ({ type: 'replace', ...result })) , byHeight$.map(hash => ({ type: 'replace', pathname: `/block/${hash}` })) , pushedtx$.map(txid => ({ type: 'push', pathname: `/tx/${txid}` })) + , selectBlockGridTx$.map(txid => ({ type: 'push', pathname: `/tx/${txid}` })) , updateQuery$.map(([ pathname, hash, qs ]) => ({ type: 'replace', pathname, hash, search: qs, state: { noRouting: true } })) , searchQuery$.map(q => ({ type: 'push', pathname: '/search', search: `q=${encodeURIComponent(q)}` })) ) @@ -544,7 +629,7 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search , openTx$, openBlock$, updateQuery$ , state$, view$, block$, blockTxs$, blocks$, tx$, txBlock$, txAnalysis$, spends$, addr$ , tipHeight$, error$, loading$ - , goSearch$, searchResult$, copy$, store$, navto$, scanning$, scan$ + , goSearch$, searchResult$, copy$, store$, navto$, scanning$, scan$, selectBlockGridTx$ , assetMap$, goAssetList$, assetList$ , req$, reply$: dropErrors(HTTP.select()).map(r => [ r.request.category, r.req.method, r.req.url, r.body||r.text, r ]) }) diff --git a/client/src/components/block-details-card.js b/client/src/components/block-details-card.js index e7e08a4bd..0994b5743 100644 --- a/client/src/components/block-details-card.js +++ b/client/src/components/block-details-card.js @@ -1,22 +1,133 @@ import { BlockGrid } from "./block-grid"; +import { InfoCard } from "./info-card"; import { InfoStat } from "./info-stat"; +import { MinusIcon, PlusIcon } from "./icons"; +import { MetricBar } from "./metric-bar"; import { StatusBadge } from "./status-badge"; import { ElapsedTime } from "./elapsed-time"; import { Tooltip } from "./tooltip"; +import { maxBlockWeight } from "../const"; import { + formatHex, formatTime, formatVMB, getBlockPercentageUsed, } from "../views/util"; +// Require behind env conditional so it gets removed by `envify` on non-elements builds +const BlockSignatures = + process.env.IS_ELEMENTS && + require("./block-signatures").default; + const staticRoot = process.env.STATIC_ROOT || ""; const formatInteger = (value) => Number.isFinite(value) ? value.toLocaleString() : "N/A"; +const formatScaledValue = (value, divisor, suffix) => { + if (!Number.isFinite(value)) return "N/A"; + + return `${(value / divisor).toFixed(2).replace(/\.00$/, "")} ${suffix}`; +}; + +const formatVirtualSize = (weight) => { + if (!Number.isFinite(weight)) return "N/A"; + + const virtualSize = Math.ceil(weight / 4); + if (virtualSize < 1_000) return `${virtualSize.toLocaleString()} vB`; + if (virtualSize < 1_000_000) { + return formatScaledValue(virtualSize, 1_000, "vKB"); + } + + return formatScaledValue(virtualSize, 1_000_000, "vMB"); +}; + +const ExpandedBlockDetails = ({ block, t }) => { + const weightPercentage = getBlockPercentageUsed(block.weight); + + return ( +
+ } + footer={t`Block #${block.height.toLocaleString()}`} + /> + + + +
+ } + /> + + + + {BlockSignatures ? ( + } + /> + ) : ( + + )} + + + ); +}; + const BlockDetailsCard = ({ className, block, + detailsOpen = false, statusText, statusVariant = "success", t, @@ -26,9 +137,21 @@ const BlockDetailsCard = ({ : 0; return ( -
+
- + t`Block is ${percentage}% full`} + loading={!block} + loadingLabel={t`Loading block utilization`} + unavailableLabel={t`Block utilization unavailable`} + />
@@ -49,41 +172,55 @@ const BlockDetailsCard = ({ {block ? ( ) : ( - "Loading block..." + t`Loading block...` )}

{statusText ? ( {statusText} ) : null} + +
+ ) : ( + "N/A" + ) + } />
-

BLOCK FILLING

+

{t`Block filling`}

@@ -105,6 +242,8 @@ const BlockDetailsCard = ({

+ + {detailsOpen && block ? : null}
); }; diff --git a/client/src/components/block-grid.js b/client/src/components/block-grid.js index dc6ee8b90..4e606e59c 100644 --- a/client/src/components/block-grid.js +++ b/client/src/components/block-grid.js @@ -1,8 +1,10 @@ -import { maxBlockWeight } from "../const"; +import { blockGridLoadingDelayMs, maxBlockWeight } from "../const"; +import { clamp } from "../lib/math"; const GRID_LENGTH = 15; +const LOADING_GRID_LENGTH = 5; -const drawBlockGrid = (canvas, blockWeight) => { +const drawBlockGrid = (canvas, blockWeight, weightLimit) => { if ( typeof HTMLCanvasElement === "undefined" || !(canvas instanceof HTMLCanvasElement) @@ -18,8 +20,12 @@ const drawBlockGrid = (canvas, blockWeight) => { const gap = 2; const cellWidth = (width - gap * (GRID_LENGTH - 1)) / GRID_LENGTH; const cellHeight = (height - gap * (GRID_LENGTH - 1)) / GRID_LENGTH; - const fillRatio = Number.isFinite(blockWeight) - ? Math.min(Math.max(blockWeight / maxBlockWeight, 0), 1) + const hasUtilization = + Number.isFinite(blockWeight) && + Number.isFinite(weightLimit) && + weightLimit > 0; + const fillRatio = hasUtilization + ? clamp(blockWeight / weightLimit, 0, 1) : 0; const filledCells = Math.round(fillRatio * GRID_LENGTH * GRID_LENGTH); const styles = window.getComputedStyle(canvas); @@ -48,8 +54,8 @@ const drawBlockGrid = (canvas, blockWeight) => { } }; -const drawGrid = (vnode, blockWeight) => { - const draw = () => drawBlockGrid(vnode.elm, blockWeight); +const drawGrid = (vnode, blockWeight, weightLimit) => { + const draw = () => drawBlockGrid(vnode.elm, blockWeight, weightLimit); if (typeof window !== "undefined" && window.requestAnimationFrame) { window.requestAnimationFrame(draw); @@ -59,14 +65,47 @@ const drawGrid = (vnode, blockWeight) => { draw(); }; -export const BlockGrid = ({ blockWeight } = {}) => { - const hasBlockWeight = Number.isFinite(blockWeight); +const BlockGridLoading = ({ label, loadingDelayMs }) => ( +
+ +
+); + +export const BlockGrid = ({ + formatAriaLabel = (percentage) => + `Block is ${percentage}% full`, + blockWeight, + loading = true, + loadingLabel = "Loading block utilization", + loadingDelayMs = blockGridLoadingDelayMs, + unavailableLabel = "Block utilization unavailable", + weightLimit = maxBlockWeight, +} = {}) => { + const hasBlockWeightLimit = Number.isFinite(weightLimit) && weightLimit > 0; + const hasBlockWeight = Number.isFinite(blockWeight) && hasBlockWeightLimit; const percentage = hasBlockWeight - ? Math.min( - Math.max( - Math.round((blockWeight / maxBlockWeight) * 10_000) / 100, - 0, - ), + ? clamp( + Math.round((blockWeight / weightLimit) * 10_000) / 100, + 0, 100, ) : 0; @@ -76,13 +115,28 @@ export const BlockGrid = ({ blockWeight } = {}) => { drawGrid(vnode, blockWeight)} - hook-postpatch={(_, vnode) => drawGrid(vnode, blockWeight)} + hook-insert={(vnode) => drawGrid(vnode, blockWeight, weightLimit)} + hook-postpatch={(_, vnode) => + drawGrid(vnode, blockWeight, weightLimit) + } > + {!hasBlockWeight && loading ? ( + + ) : null} + {!hasBlockWeight && !loading ? ( +
+ {unavailableLabel} +
+ ) : null}
); }; diff --git a/client/src/components/expected-block-time.js b/client/src/components/expected-block-time.js new file mode 100644 index 000000000..0a836b11d --- /dev/null +++ b/client/src/components/expected-block-time.js @@ -0,0 +1,43 @@ +import { formatEstimatedBlockTime } from "../lib/pending-block-details"; + +const UPDATE_INTERVAL_MS = 60 * 1000; + +const updateExpectedBlockTime = (element) => { + element.textContent = formatEstimatedBlockTime( + element.expectedBlockTimestamp, + element.expectedBlockTranslator, + ); +}; + +const startExpectedBlockTime = (vnode, timestamp, t) => { + vnode.elm.expectedBlockTimestamp = timestamp; + vnode.elm.expectedBlockTranslator = t; + updateExpectedBlockTime(vnode.elm); + vnode.elm.expectedBlockTimeInterval = window.setInterval( + () => updateExpectedBlockTime(vnode.elm), + UPDATE_INTERVAL_MS, + ); +}; + +const patchExpectedBlockTime = (_, vnode, timestamp, t) => { + vnode.elm.expectedBlockTimestamp = timestamp; + vnode.elm.expectedBlockTranslator = t; + updateExpectedBlockTime(vnode.elm); +}; + +const stopExpectedBlockTime = (vnode) => { + window.clearInterval(vnode.elm.expectedBlockTimeInterval); +}; + +export const ExpectedBlockTime = ({ timestamp, t } = {}) => ( + startExpectedBlockTime(vnode, timestamp, t)} + hook-postpatch={(oldVnode, vnode) => + patchExpectedBlockTime(oldVnode, vnode, timestamp, t) + } + hook-destroy={stopExpectedBlockTime} + > + {formatEstimatedBlockTime(timestamp, t)} + +); diff --git a/client/src/components/icons.js b/client/src/components/icons.js index 8220c9971..22883416f 100644 --- a/client/src/components/icons.js +++ b/client/src/components/icons.js @@ -48,6 +48,11 @@ export const MinusIcon = ({ className } = {}) => +export const LightningBoltIcon = ({ className } = {}) => + + export const ClockIcon = ({ className } = {}) =>
{iconSrc ? : null}

{title}

- {tooltip ? : null} + {tooltip ? ( + + ) : null} {headerValue !== undefined ? (

{headerValue}

) : null} diff --git a/client/src/components/mempool-congestion.js b/client/src/components/mempool-congestion.js new file mode 100644 index 000000000..d82c7562b --- /dev/null +++ b/client/src/components/mempool-congestion.js @@ -0,0 +1,44 @@ +import { getMempoolCongestion } from "../lib/mempool"; + +export const MempoolCongestion = ({ + mempool, + fallback = "", + t, +} = {}) => { + const congestion = getMempoolCongestion(mempool); + const fillPercentage = Math.min( + Math.max(congestion.percentage, 0), + 100, + ); + const localizedLevel = { + Low: t`Low`, + Moderate: t`Moderate`, + High: t`High`, + }[congestion.level]; + + return ( +
+
+ {localizedLevel || fallback} +
+
+
+
+
+

{t`LOW`}

+

{t`HIGH`}

+
+
+ ); +}; diff --git a/client/src/components/metric-bar.js b/client/src/components/metric-bar.js new file mode 100644 index 000000000..11edf788e --- /dev/null +++ b/client/src/components/metric-bar.js @@ -0,0 +1,28 @@ +import { clamp } from "../lib/math"; + +const clampPercentage = (value) => + Number.isFinite(value) ? clamp(value, 0, 100) : 0; + +export const MetricBar = ({ + title, + headerValue, + fillPercentage, + fillClass, +}) => ( +
+
+
{title}
+
{headerValue}
+
+
+
+
+
+); diff --git a/client/src/components/reference-line-chart.js b/client/src/components/reference-line-chart.js index 4fe59d893..833e0cd74 100644 --- a/client/src/components/reference-line-chart.js +++ b/client/src/components/reference-line-chart.js @@ -1,3 +1,5 @@ +import { clamp } from "../lib/math"; + const REQUIRED_POINT_COUNT = 24; const drawReferenceLine = (canvas, numbers) => { @@ -20,8 +22,6 @@ const drawReferenceLine = (canvas, numbers) => { const width = rect.width; const height = rect.height; const shortestSide = Math.min(width, height); - const clamp = (value, minValue, maxValue) => - Math.min(Math.max(value, minValue), maxValue); const lineWidth = Math.min( clamp(shortestSide * 0.012, 1.5, 4), Math.max(1, shortestSide * 0.05), diff --git a/client/src/components/status-badge.js b/client/src/components/status-badge.js index f7d9f84c1..f9918a3d0 100644 --- a/client/src/components/status-badge.js +++ b/client/src/components/status-badge.js @@ -11,6 +11,14 @@ export const StatusBadge = ( ); +export const StatusDot = () => ( + +); + export const ConfidentialBadge = ({ t }) => ( {t`Confidential`} ); diff --git a/client/src/components/transaction-block-grid.js b/client/src/components/transaction-block-grid.js new file mode 100644 index 000000000..dbcc62955 --- /dev/null +++ b/client/src/components/transaction-block-grid.js @@ -0,0 +1,1298 @@ +import { + blockGridTransactionSelectEvent, + maxBlockWeight, +} from "../const"; +import { clamp } from "../lib/math"; + +const DEFAULT_CELLS_PER_SIDE = 75; +const DEFAULT_BACKGROUND_COLOR = "#1c1c1c"; +const DEFAULT_PLACEHOLDER_COLOR = "#262626"; +const TILE_GUTTER = 1; +const EXIT_DURATION = 320; +const REENTRY_GAP = 25; +const ENTER_DURATION = 420; +const ENTER_START = EXIT_DURATION + REENTRY_GAP; +const TRANSITION_DURATION = ENTER_START + ENTER_DURATION; +const ENTER_SCALE = 0.28; +const MOVING_OPACITY = 0.72; +const TOOLTIP_HIDE_DELAY = 120; +const mountedInstances = new WeakMap(); +const staticRoot = process.env.STATIC_ROOT || ""; +const DEFAULT_LABELS = { + allTransactionsShown: "All selected transactions are shown individually.", + fee: "fee", + feeRate: "fee rate", + individuallyRendered: "Individually rendered transactions", + keyboardInstructions: + "Use the arrow keys to inspect transactions and Enter to open one.", + omittedTransactions: + "Lower-fee transactions are summarized in the metrics because they do not fit at this resolution.", + transaction: "transaction", + transactionGrid: "Pending block transaction grid", + virtualSize: "virtual size" +}; + +function assertOpaqueColor(color, colorLabel) { + if ( + typeof color !== "string" || + !color.trim() || + (window.CSS && !window.CSS.supports("color", color)) + ) { + throw new TypeError(`${colorLabel} must be a valid CSS color.`); + } + + const probe = document.createElement("canvas"); + probe.width = 1; + probe.height = 1; + const probeContext = probe.getContext("2d", { willReadFrequently: true }); + probeContext.clearRect(0, 0, 1, 1); + probeContext.fillStyle = color; + probeContext.fillRect(0, 0, 1, 1); + + if (probeContext.getImageData(0, 0, 1, 1).data[3] !== 255) { + throw new TypeError(`${colorLabel} must be opaque.`); + } +} + +function normalizeFeeTiers(feeTiers) { + if (!feeTiers || typeof feeTiers !== "object") { + throw new TypeError("Fee tiers must define low, medium, and high tiers."); + } + + const normalized = {}; + + ["low", "medium", "high"].forEach(tierName => { + const tier = feeTiers[tierName]; + if (!tier || typeof tier !== "object") { + throw new TypeError(`Fee tier "${tierName}" is required.`); + } + + const threshold = Number(tier.threshold); + if (!Number.isFinite(threshold)) { + throw new TypeError(`Fee tier "${tierName}" threshold must be finite.`); + } + + assertOpaqueColor(tier.color, `Fee tier "${tierName}" color`); + normalized[tierName] = { + threshold, + color: tier.color.trim() + }; + }); + + if ( + normalized.low.threshold >= normalized.medium.threshold || + normalized.medium.threshold >= normalized.high.threshold + ) { + throw new RangeError( + "Fee tier thresholds must be strictly ascending from low to medium to high." + ); + } + + return normalized; +} + +function normalizeOptions(options) { + if ( + options !== undefined && + (!options || typeof options !== "object" || Array.isArray(options)) + ) { + throw new TypeError("Block grid options must be an object."); + } + + const cellsPerSide = options?.cellsPerSide ?? DEFAULT_CELLS_PER_SIDE; + const backgroundColor = options?.backgroundColor ?? DEFAULT_BACKGROUND_COLOR; + const placeholderColor = options?.placeholderColor ?? DEFAULT_PLACEHOLDER_COLOR; + const weightLimit = options?.weightLimit ?? maxBlockWeight; + const labels = { ...DEFAULT_LABELS, ...(options?.labels || {}) }; + + if (!Number.isInteger(cellsPerSide) || cellsPerSide <= 0) { + throw new RangeError("options.cellsPerSide must be a positive integer."); + } + + if (!Number.isFinite(weightLimit) || weightLimit <= 0) { + throw new RangeError("options.weightLimit must be a positive finite number."); + } + + Object.entries(labels).forEach(([name, value]) => { + if (typeof value !== "string" || !value.trim()) { + throw new TypeError(`options.labels.${name} must be a non-empty string.`); + } + }); + + assertOpaqueColor(backgroundColor, "options.backgroundColor"); + assertOpaqueColor(placeholderColor, "options.placeholderColor"); + + return { + cellsPerSide, + backgroundColor: backgroundColor.trim(), + placeholderColor: placeholderColor.trim(), + weightLimit, + labels + }; +} + +function normalizeTransactions(transactions) { + if (!Array.isArray(transactions)) { + throw new TypeError("Block grid transactions must be an array."); + } + + return transactions.map((transaction, index) => { + if (!transaction || typeof transaction !== "object") { + throw new TypeError(`Transaction at index ${index} must be an object.`); + } + + if (typeof transaction.txid !== "string" || !transaction.txid.trim()) { + throw new TypeError(`Transaction at index ${index} must have a non-empty txid.`); + } + + const fee = transaction.fee; + const weight = transaction.weight; + + if (!Number.isFinite(fee) || fee < 0) { + throw new RangeError( + `Transaction "${transaction.txid}" must have a non-negative finite fee.` + ); + } + + if (!Number.isFinite(weight) || weight <= 0) { + throw new RangeError( + `Transaction "${transaction.txid}" must have a positive finite weight.` + ); + } + + const virtualSize = Math.ceil(weight / 4); + return { + txid: transaction.txid, + fee, + weight, + virtualSize, + feeRate: fee / virtualSize, + inputIndex: index + }; + }); +} + +function intersects(first, second) { + return ( + first.x < second.x + second.width && + first.x + first.width > second.x && + first.y < second.y + second.height && + first.y + first.height > second.y + ); +} + +function contains(outer, inner) { + return ( + inner.x >= outer.x && + inner.y >= outer.y && + inner.x + inner.width <= outer.x + outer.width && + inner.y + inner.height <= outer.y + outer.height + ); +} + +function splitFreeRect(freeRect, usedRect) { + if (!intersects(freeRect, usedRect)) return [freeRect]; + + const nextRects = []; + const freeRight = freeRect.x + freeRect.width; + const freeBottom = freeRect.y + freeRect.height; + const usedRight = usedRect.x + usedRect.width; + const usedBottom = usedRect.y + usedRect.height; + + if (usedRect.y > freeRect.y) { + nextRects.push({ + x: freeRect.x, + y: freeRect.y, + width: freeRect.width, + height: usedRect.y - freeRect.y + }); + } + + if (usedBottom < freeBottom) { + nextRects.push({ + x: freeRect.x, + y: usedBottom, + width: freeRect.width, + height: freeBottom - usedBottom + }); + } + + if (usedRect.x > freeRect.x) { + nextRects.push({ + x: freeRect.x, + y: freeRect.y, + width: usedRect.x - freeRect.x, + height: freeRect.height + }); + } + + if (usedRight < freeRight) { + nextRects.push({ + x: usedRight, + y: freeRect.y, + width: freeRight - usedRight, + height: freeRect.height + }); + } + + return nextRects.filter(rect => rect.width > 0 && rect.height > 0); +} + +function pruneFreeRects(freeRects) { + return freeRects.filter((rect, index) => { + return !freeRects.some((other, otherIndex) => { + return otherIndex !== index && contains(other, rect); + }); + }); +} + +function scorePlacement(freeRect, sideCells) { + return { + x: freeRect.x + freeRect.width - sideCells, + y: freeRect.y, + areaFit: freeRect.width * freeRect.height - sideCells * sideCells, + shortSideFit: Math.min( + freeRect.width - sideCells, + freeRect.height - sideCells + ), + longSideFit: Math.max( + freeRect.width - sideCells, + freeRect.height - sideCells + ) + }; +} + +function findPlacement(item, freeRects) { + let best = null; + + freeRects.forEach(freeRect => { + if (item.sideCells > freeRect.width || item.sideCells > freeRect.height) return; + + const placement = { + ...scorePlacement(freeRect, item.sideCells), + width: item.sideCells, + height: item.sideCells + }; + + if ( + !best || + placement.x > best.x || + (placement.x === best.x && placement.y < best.y) || + ( + placement.x === best.x && + placement.y === best.y && + placement.shortSideFit < best.shortSideFit + ) || + ( + placement.x === best.x && + placement.y === best.y && + placement.shortSideFit === best.shortSideFit && + placement.areaFit < best.areaFit + ) || + ( + placement.x === best.x && + placement.y === best.y && + placement.shortSideFit === best.shortSideFit && + placement.areaFit === best.areaFit && + placement.longSideFit < best.longSideFit + ) + ) { + best = placement; + } + }); + + return best; +} + +function packItems(items, gridDimensions) { + // Approximate the square packing problem with a MaxRects-style free-space + // search: https://en.wikipedia.org/wiki/Square_packing + let freeRects = [{ + x: 0, + y: 0, + width: gridDimensions.columns, + height: gridDimensions.rows + }]; + const rects = []; + const sortedItems = [...items].sort((first, second) => { + return compareTransactionsByFeeRate(first.tx, second.tx); + }); + + for (const item of sortedItems) { + const placement = findPlacement(item, freeRects); + if (!placement) return null; + + rects.push({ + tx: item.tx, + x: placement.x, + y: placement.y, + width: placement.width, + height: placement.height + }); + + freeRects = pruneFreeRects( + freeRects.flatMap(freeRect => splitFreeRect(freeRect, placement)) + ); + } + + return rects; +} + +function quantizeTransactions(transactions, gridDimensions, weightLimit) { + const cellWeight = weightLimit / ( + gridDimensions.columns * gridDimensions.rows + ); + return transactions.map(transaction => ({ + tx: transaction, + sideCells: Math.max( + 1, + Math.round(Math.sqrt(transaction.weight / cellWeight)) + ) + })); +} + +function compareTransactionsByFeeRate(first, second) { + if (second.feeRate !== first.feeRate) return second.feeRate - first.feeRate; + if (second.fee !== first.fee) return second.fee - first.fee; + return first.inputIndex - second.inputIndex; +} + +function rankTransactions(transactions) { + return [...transactions].sort(compareTransactionsByFeeRate); +} + +function createPlaceholderCells(rects, gridDimensions) { + const occupiedCells = new Uint8Array( + gridDimensions.columns * gridDimensions.rows + ); + + rects.forEach(rect => { + for (let y = rect.y; y < rect.y + rect.height; y += 1) { + for (let x = rect.x; x < rect.x + rect.width; x += 1) { + occupiedCells[y * gridDimensions.columns + x] = 1; + } + } + }); + + const placeholderCells = []; + occupiedCells.forEach((isOccupied, index) => { + if (isOccupied) return; + placeholderCells.push({ + x: index % gridDimensions.columns, + y: Math.floor(index / gridDimensions.columns), + width: 1, + height: 1 + }); + }); + return placeholderCells; +} + +function buildScene(rects, transactions, renderedCount, gridDimensions) { + return { + rects, + placeholderCells: createPlaceholderCells(rects, gridDimensions), + transactions, + inputCount: transactions.length, + renderedCount, + gridDimensions + }; +} + +function createScene(transactions, gridDimensions, weightLimit) { + const rankedTransactions = rankTransactions(transactions); + const completeRects = packItems( + quantizeTransactions(rankedTransactions, gridDimensions, weightLimit), + gridDimensions + ); + + if (completeRects) { + return buildScene( + completeRects, + transactions, + transactions.length, + gridDimensions + ); + } + + let smallestCandidate = 0; + let largestCandidate = Math.max(0, rankedTransactions.length - 1); + let bestRects = []; + let bestCount = 0; + + while (smallestCandidate <= largestCandidate) { + const candidateCount = Math.floor( + (smallestCandidate + largestCandidate) / 2 + ); + const candidateRects = packItems( + quantizeTransactions( + rankedTransactions.slice(0, candidateCount), + gridDimensions, + weightLimit + ), + gridDimensions + ); + + if (candidateRects) { + bestRects = candidateRects; + bestCount = candidateCount; + smallestCandidate = candidateCount + 1; + } else { + largestCandidate = candidateCount - 1; + } + } + + return buildScene( + bestRects, + transactions, + bestCount, + gridDimensions + ); +} + +function feeTierFor(transaction, feeTiers) { + if (transaction.feeRate >= feeTiers.high.threshold) return feeTiers.high; + if (transaction.feeRate >= feeTiers.medium.threshold) return feeTiers.medium; + return feeTiers.low; +} + +function easeOutCubic(progress) { + return 1 - Math.pow(1 - progress, 3); +} + +function easeInOutCubic(progress) { + return progress < 0.5 + ? 4 * progress * progress * progress + : 1 - Math.pow(-2 * progress + 2, 3) / 2; +} + +function clampProgress(elapsed, duration) { + return clamp(elapsed / duration, 0, 1); +} + +function outgoingOpacity(progress) { + if (progress < 0.2) { + return 1 - (1 - MOVING_OPACITY) * easeOutCubic(progress / 0.2); + } + if (progress < 0.65) return MOVING_OPACITY; + return MOVING_OPACITY * ( + 1 - easeInOutCubic((progress - 0.65) / 0.35) + ); +} + +function incomingOpacity(progress) { + if (progress < 0.75) { + return MOVING_OPACITY * easeOutCubic(progress / 0.75); + } + return MOVING_OPACITY + ( + 1 - MOVING_OPACITY + ) * easeOutCubic((progress - 0.75) / 0.25); +} + +function outgoingRectState(elapsed) { + const progress = clampProgress(elapsed, EXIT_DURATION); + return { + scale: 1 - easeInOutCubic(progress), + opacity: outgoingOpacity(progress) + }; +} + +function incomingRectState(elapsed) { + const progress = clampProgress(elapsed, ENTER_DURATION); + return { + scale: ENTER_SCALE + (1 - ENTER_SCALE) * easeOutCubic(progress), + opacity: incomingOpacity(progress) + }; +} + +function formatNumber(value, fractionDigits = 0) { + return new Intl.NumberFormat("en-US", { + maximumFractionDigits: fractionDigits, + minimumFractionDigits: fractionDigits + }).format(value); +} + +function shortenTxid(txid) { + return `${txid.slice(0, 8)}...${txid.slice(-8)}`; +} + +function gridDimensionsForSize(width, height, cellsPerSide) { + const safeWidth = Math.max(1, width); + const safeHeight = Math.max(1, height); + const aspectRatio = safeWidth / safeHeight; + const aspectScale = Math.sqrt(aspectRatio); + + return { + columns: Math.max(1, Math.round(cellsPerSide * aspectScale)), + rows: Math.max(1, Math.round(cellsPerSide / aspectScale)) + }; +} + +function sameGridDimensions(first, second) { + return ( + first.columns === second.columns && + first.rows === second.rows + ); +} + +export function findHorizontalNeighborIndex(rects, currentIndex, direction) { + if (!Array.isArray(rects) || !rects.length || ![-1, 1].includes(direction)) { + return currentIndex; + } + if (currentIndex < 0 || currentIndex >= rects.length) return 0; + + const current = rects[currentIndex]; + const currentBottom = current.y + current.height; + const currentEdge = direction < 0 + ? current.x + : current.x + current.width; + const candidates = rects + .map((rect, index) => { + if (index === currentIndex) return null; + + const candidateEdge = direction < 0 + ? rect.x + rect.width + : rect.x; + const horizontalGap = direction * (candidateEdge - currentEdge); + if (horizontalGap < 0) return null; + + const candidateBottom = rect.y + rect.height; + const verticalGap = Math.max( + current.y - candidateBottom, + rect.y - currentBottom, + 0 + ); + const verticalCenterDistance = Math.abs( + rect.y + rect.height / 2 - (current.y + current.height / 2) + ); + + return { index, horizontalGap, verticalGap, verticalCenterDistance }; + }) + .filter(Boolean) + .sort((first, second) => + first.verticalGap - second.verticalGap || + first.horizontalGap - second.horizontalGap || + first.verticalCenterDistance - second.verticalCenterDistance || + first.index - second.index + ); + + return candidates.length ? candidates[0].index : currentIndex; +} + +export function renderBlockGrid( + elementId, + transactions, + baseUrl = "", + feeTiers, + options +) { + if (typeof elementId !== "string" || !elementId.trim()) { + throw new TypeError("renderBlockGrid requires a non-empty element ID."); + } + + const container = document.getElementById(elementId); + if (!container) { + throw new Error(`Block grid container "#${elementId}" was not found.`); + } + + if (baseUrl !== undefined && baseUrl !== null && typeof baseUrl !== "string") { + throw new TypeError("Block grid baseUrl must be a string."); + } + + const initialTransactions = normalizeTransactions(transactions); + const normalizedFeeTiers = normalizeFeeTiers(feeTiers); + const normalizedOptions = normalizeOptions(options); + const normalizedBaseUrl = (baseUrl || "").trim().replace(/\/+$/, ""); + const previousInstance = mountedInstances.get(container); + if (previousInstance) previousInstance.destroy(); + + const canvas = document.createElement("canvas"); + canvas.className = "block-grid__canvas"; + canvas.tabIndex = 0; + canvas.setAttribute("role", "application"); + canvas.setAttribute( + "aria-keyshortcuts", + "ArrowLeft ArrowRight ArrowUp ArrowDown Home End Enter Space" + ); + canvas.style.borderColor = normalizedOptions.backgroundColor; + + const tooltip = document.createElement("div"); + tooltip.className = "block-grid__tooltip"; + tooltip.id = `${elementId}-tooltip`; + tooltip.setAttribute("aria-live", "polite"); + tooltip.setAttribute("role", "tooltip"); + tooltip.hidden = true; + + const summary = document.createElement("button"); + const summaryIcon = document.createElement("img"); + const summaryDialogue = document.createElement("span"); + summary.type = "button"; + summary.className = "tooltip block-grid__summary"; + summary.id = `${elementId}-summary`; + summary.hidden = true; + summaryIcon.alt = ""; + summaryIcon.src = `${staticRoot}img/icons/tooltip.svg`; + summaryDialogue.className = "tooltip-dialogue"; + summaryDialogue.setAttribute("role", "tooltip"); + summary.append(summaryIcon, summaryDialogue); + canvas.setAttribute("aria-describedby", tooltip.id); + + const hadComponentClass = container.classList.contains("block-grid"); + container.classList.add("block-grid"); + container.replaceChildren(canvas, tooltip, summary); + + const context = canvas.getContext("2d"); + if (!context) { + container.replaceChildren(); + if (!hadComponentClass) container.classList.remove("block-grid"); + throw new Error("This browser does not support the 2D canvas API."); + } + + const initialBounds = canvas.getBoundingClientRect(); + let canvasCssWidth = Math.max( + 1, + canvas.clientWidth || initialBounds.width || container.clientWidth + ); + let canvasCssHeight = Math.max( + 1, + canvas.clientHeight || + initialBounds.height || + container.clientHeight || + canvasCssWidth + ); + let gridDimensions = gridDimensionsForSize( + canvasCssWidth, + canvasCssHeight, + normalizedOptions.cellsPerSide + ); + let settledScene = createScene( + initialTransactions, + gridDimensions, + normalizedOptions.weightLimit + ); + let hoveredTransaction = null; + let activeTransition = null; + let transitionFrameId = null; + let queuedTransactions = null; + let tooltipHideTimer = null; + let destroyed = false; + + function updateSceneSummary() { + const { labels } = normalizedOptions; + const countSummary = [ + `${labels.individuallyRendered}:`, + settledScene.renderedCount.toLocaleString(), + "/", + `${settledScene.inputCount.toLocaleString()}.` + ].join(" "); + const detail = settledScene.renderedCount === settledScene.inputCount + ? labels.allTransactionsShown + : labels.omittedTransactions; + + const summaryText = `${countSummary} ${detail}`; + summary.setAttribute("aria-label", summaryText); + summaryDialogue.textContent = summaryText; + summary.hidden = settledScene.renderedCount >= settledScene.inputCount; + canvas.setAttribute( + "aria-label", + `${labels.transactionGrid}. ${countSummary} ${labels.keyboardInstructions}` + ); + } + + function gridMetrics() { + const cellSize = Math.min( + canvasCssWidth / gridDimensions.columns, + canvasCssHeight / gridDimensions.rows + ); + return { + cellSize, + offsetX: ( + canvasCssWidth - gridDimensions.columns * cellSize + ) / 2, + offsetY: ( + canvasCssHeight - gridDimensions.rows * cellSize + ) / 2 + }; + } + + function gridFace(rect, metrics = gridMetrics()) { + const bounds = { + x: metrics.offsetX + rect.x * metrics.cellSize, + y: metrics.offsetY + rect.y * metrics.cellSize, + width: rect.width * metrics.cellSize, + height: rect.height * metrics.cellSize + }; + const inset = Math.min( + TILE_GUTTER / 2, + bounds.width / 4, + bounds.height / 4 + ); + return { + x: bounds.x + inset, + y: bounds.y + inset, + width: Math.max(0, bounds.width - inset * 2), + height: Math.max(0, bounds.height - inset * 2) + }; + } + + function scaledBounds(bounds, scale) { + const width = bounds.width * scale; + const height = bounds.height * scale; + return { + x: bounds.x + (bounds.width - width) / 2, + y: bounds.y + (bounds.height - height) / 2, + width, + height + }; + } + + function clearCanvas() { + context.clearRect(0, 0, canvasCssWidth, canvasCssHeight); + context.fillStyle = normalizedOptions.backgroundColor; + context.fillRect(0, 0, canvasCssWidth, canvasCssHeight); + } + + function drawPlaceholderFaces(scene) { + context.save(); + context.fillStyle = normalizedOptions.placeholderColor; + const metrics = gridMetrics(); + + scene.placeholderCells.forEach(cell => { + const bounds = gridFace(cell, metrics); + context.fillRect(bounds.x, bounds.y, bounds.width, bounds.height); + }); + + context.restore(); + } + + function drawSceneFaces(scene, rectState, allowHover = false) { + context.save(); + const metrics = gridMetrics(); + + scene.rects.forEach(rect => { + const state = rectState(rect); + if (state.scale <= 0 || state.opacity <= 0) return; + + const bounds = scaledBounds(gridFace(rect, metrics), state.scale); + if (bounds.width <= 0 || bounds.height <= 0) return; + + const isHovered = allowHover && rect.tx === hoveredTransaction; + context.globalAlpha = state.opacity * (isHovered ? 1 : 0.5); + context.fillStyle = feeTierFor(rect.tx, normalizedFeeTiers).color; + context.fillRect(bounds.x, bounds.y, bounds.width, bounds.height); + }); + + context.restore(); + } + + function drawSettledScene() { + clearCanvas(); + drawPlaceholderFaces(settledScene); + drawSceneFaces( + settledScene, + () => ({ scale: 1, opacity: 1 }), + true + ); + } + + function clearTooltipHideTimer() { + if (tooltipHideTimer !== null) window.clearTimeout(tooltipHideTimer); + tooltipHideTimer = null; + } + + function hideTooltip() { + clearTooltipHideTimer(); + tooltip.hidden = true; + if (hoveredTransaction) { + hoveredTransaction = null; + if (!activeTransition) drawSettledScene(); + updateSceneSummary(); + } + } + + function scheduleTooltipHide() { + clearTooltipHideTimer(); + tooltipHideTimer = window.setTimeout(hideTooltip, TOOLTIP_HIDE_DELAY); + } + + function moveTooltip(event) { + const offset = 14; + const bounds = tooltip.getBoundingClientRect(); + const left = Math.min( + event.clientX + offset, + window.innerWidth - bounds.width - 8 + ); + const top = Math.min( + event.clientY + offset, + window.innerHeight - bounds.height - 8 + ); + tooltip.style.left = `${Math.max(8, left)}px`; + tooltip.style.top = `${Math.max(8, top)}px`; + } + + function showTooltip(event, transaction) { + clearTooltipHideTimer(); + tooltip.replaceChildren(); + + const { labels } = normalizedOptions; + const definitionList = document.createElement("dl"); + const fields = [ + [labels.fee, `${formatNumber(transaction.fee)} sats`], + [labels.feeRate, `${formatNumber(transaction.feeRate, 2)} sat/vB`], + [labels.virtualSize, `${formatNumber(transaction.virtualSize)} vB`] + ]; + const txidTerm = document.createElement("dt"); + const txidDefinition = document.createElement("dd"); + txidTerm.textContent = labels.transaction; + + if (normalizedBaseUrl) { + const link = document.createElement("a"); + link.href = `${normalizedBaseUrl}/${transaction.txid}`; + link.textContent = shortenTxid(transaction.txid); + txidDefinition.append(link); + } else { + txidDefinition.textContent = shortenTxid(transaction.txid); + } + + definitionList.append(txidTerm, txidDefinition); + + fields.forEach(([label, value]) => { + const term = document.createElement("dt"); + const definition = document.createElement("dd"); + term.textContent = label; + definition.textContent = value; + definitionList.append(term, definition); + }); + + tooltip.append(definitionList); + tooltip.hidden = false; + moveTooltip(event); + } + + function showKeyboardTooltip(transaction) { + const rect = settledScene.rects.find(item => item.tx === transaction); + if (!rect) return; + + const metrics = gridMetrics(); + const face = gridFace(rect, metrics); + const canvasBounds = canvas.getBoundingClientRect(); + showTooltip({ + clientX: canvasBounds.left + face.x + face.width / 2, + clientY: canvasBounds.top + face.y + face.height / 2 + }, transaction); + } + + function focusTransaction(index) { + if (!settledScene.rects.length) return; + + const normalizedIndex = ( + index + settledScene.rects.length + ) % settledScene.rects.length; + hoveredTransaction = settledScene.rects[normalizedIndex].tx; + drawSettledScene(); + showKeyboardTooltip(hoveredTransaction); + canvas.setAttribute( + "aria-label", + [ + `${normalizedOptions.labels.transactionGrid}.`, + `${normalizedOptions.labels.transaction}:`, + `${shortenTxid(hoveredTransaction.txid)}.`, + normalizedOptions.labels.keyboardInstructions + ].join(" ") + ); + } + + function canvasPointFromEvent(event) { + const bounds = canvas.getBoundingClientRect(); + const metrics = gridMetrics(); + const localX = ( + (event.clientX - bounds.left) * + canvasCssWidth / + bounds.width + ); + const localY = ( + (event.clientY - bounds.top) * + canvasCssHeight / + bounds.height + ); + return { + x: (localX - metrics.offsetX) / metrics.cellSize, + y: (localY - metrics.offsetY) / metrics.cellSize + }; + } + + function hitTest(x, y) { + for (let index = settledScene.rects.length - 1; index >= 0; index -= 1) { + const rect = settledScene.rects[index]; + if ( + x >= rect.x && + x <= rect.x + rect.width && + y >= rect.y && + y <= rect.y + rect.height + ) { + return rect.tx; + } + } + return null; + } + + function handleCanvasPointerMove(event) { + if (activeTransition) { + canvas.style.cursor = "default"; + hideTooltip(); + return; + } + + const point = canvasPointFromEvent(event); + const transaction = hitTest(point.x, point.y); + canvas.style.cursor = transaction ? "pointer" : "default"; + + if (transaction !== hoveredTransaction) { + hoveredTransaction = transaction; + drawSettledScene(); + } + + if (transaction) { + showTooltip(event, transaction); + } else { + hideTooltip(); + } + } + + function handleCanvasPointerLeave(event) { + canvas.style.cursor = "default"; + if (event.relatedTarget && tooltip.contains(event.relatedTarget)) return; + scheduleTooltipHide(); + } + + function selectTransaction(transaction, event) { + if (!transaction || !normalizedBaseUrl) return; + if (event.metaKey || event.ctrlKey || event.shiftKey) { + window.open( + `${normalizedBaseUrl}/${transaction.txid}`, + "_blank", + "noopener,noreferrer" + ); + return; + } + + canvas.dispatchEvent(new CustomEvent(blockGridTransactionSelectEvent, { + bubbles: true, + detail: { txid: transaction.txid } + })); + } + + function handleCanvasClick(event) { + if (activeTransition) return; + + const point = canvasPointFromEvent(event); + selectTransaction(hitTest(point.x, point.y), event); + } + + function handleCanvasFocus() { + if (!activeTransition && !hoveredTransaction) focusTransaction(0); + } + + function handleCanvasBlur(event) { + if (event.relatedTarget && tooltip.contains(event.relatedTarget)) return; + hideTooltip(); + updateSceneSummary(); + } + + function handleCanvasKeyDown(event) { + if (activeTransition || !settledScene.rects.length) return; + + const selectedIndex = settledScene.rects.findIndex( + rect => rect.tx === hoveredTransaction + ); + const forwardIndex = selectedIndex < 0 ? 0 : selectedIndex + 1; + const backwardIndex = selectedIndex < 0 + ? settledScene.rects.length - 1 + : selectedIndex - 1; + const nextIndexByKey = { + ArrowDown: forwardIndex, + ArrowLeft: findHorizontalNeighborIndex( + settledScene.rects, + selectedIndex, + -1 + ), + ArrowRight: findHorizontalNeighborIndex( + settledScene.rects, + selectedIndex, + 1 + ), + ArrowUp: backwardIndex, + End: settledScene.rects.length - 1, + Home: 0 + }; + + if (Object.prototype.hasOwnProperty.call(nextIndexByKey, event.key)) { + event.preventDefault(); + focusTransaction(nextIndexByKey[event.key]); + return; + } + + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + selectTransaction( + hoveredTransaction || settledScene.rects[0].tx, + event + ); + } + } + + function handleTooltipPointerEnter() { + clearTooltipHideTimer(); + } + + function handleTooltipPointerLeave(event) { + if (event.relatedTarget === canvas) return; + hideTooltip(); + } + + function handleTooltipFocusOut(event) { + if (event.relatedTarget === canvas) return; + if (event.relatedTarget && tooltip.contains(event.relatedTarget)) return; + hideTooltip(); + updateSceneSummary(); + } + + function resizeCanvas() { + const bounds = canvas.getBoundingClientRect(); + const nextCssWidth = Math.max( + 1, + canvas.clientWidth || bounds.width || container.clientWidth + ); + const nextCssHeight = Math.max( + 1, + canvas.clientHeight || + bounds.height || + container.clientHeight || + nextCssWidth + ); + const pixelRatio = Math.max(1, window.devicePixelRatio || 1); + const pixelWidth = Math.max(1, Math.round(nextCssWidth * pixelRatio)); + const pixelHeight = Math.max(1, Math.round(nextCssHeight * pixelRatio)); + const nextGridDimensions = gridDimensionsForSize( + nextCssWidth, + nextCssHeight, + normalizedOptions.cellsPerSide + ); + const gridDimensionsChanged = !sameGridDimensions( + gridDimensions, + nextGridDimensions + ); + + canvasCssWidth = nextCssWidth; + canvasCssHeight = nextCssHeight; + gridDimensions = nextGridDimensions; + if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) { + canvas.width = pixelWidth; + canvas.height = pixelHeight; + } + context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); + return gridDimensionsChanged; + } + + function repackForGridDimensions() { + clearTooltipHideTimer(); + tooltip.hidden = true; + hoveredTransaction = null; + canvas.style.cursor = "default"; + + if (activeTransition) { + activeTransition.from = createScene( + activeTransition.from.transactions, + gridDimensions, + normalizedOptions.weightLimit + ); + activeTransition.to = createScene( + activeTransition.to.transactions, + gridDimensions, + normalizedOptions.weightLimit + ); + settledScene = activeTransition.from; + updateSceneSummary(); + return; + } + + settledScene = createScene( + settledScene.transactions, + gridDimensions, + normalizedOptions.weightLimit + ); + updateSceneSummary(); + } + + function drawTransition(timestamp) { + if (!activeTransition) return false; + if (activeTransition.startedAt === null) { + activeTransition.startedAt = timestamp; + } + + const elapsed = Math.min( + timestamp - activeTransition.startedAt, + TRANSITION_DURATION + ); + clearCanvas(); + + if (elapsed < EXIT_DURATION) { + drawPlaceholderFaces(activeTransition.from); + drawSceneFaces( + activeTransition.from, + () => outgoingRectState(elapsed) + ); + } else { + drawPlaceholderFaces(activeTransition.to); + } + + if (elapsed >= ENTER_START) { + drawSceneFaces( + activeTransition.to, + () => incomingRectState(elapsed - ENTER_START) + ); + } + + return elapsed >= TRANSITION_DURATION; + } + + function cancelTransition() { + if (transitionFrameId !== null) { + window.cancelAnimationFrame(transitionFrameId); + } + transitionFrameId = null; + activeTransition = null; + queuedTransactions = null; + } + + function startTransition(nextScene) { + hideTooltip(); + canvas.style.cursor = "default"; + activeTransition = { + from: settledScene, + to: nextScene, + startedAt: null + }; + transitionFrameId = window.requestAnimationFrame(stepTransition); + } + + function finishTransition() { + settledScene = activeTransition.to; + activeTransition = null; + transitionFrameId = null; + updateSceneSummary(); + drawSettledScene(); + + if (queuedTransactions) { + const nextTransactions = queuedTransactions; + queuedTransactions = null; + startTransition( + createScene( + nextTransactions, + gridDimensions, + normalizedOptions.weightLimit + ) + ); + } + } + + function stepTransition(timestamp) { + if (!activeTransition) return; + if (drawTransition(timestamp)) { + finishTransition(); + return; + } + transitionFrameId = window.requestAnimationFrame(stepTransition); + } + + function handleResize() { + if (destroyed) return; + const gridDimensionsChanged = resizeCanvas(); + if (gridDimensionsChanged) repackForGridDimensions(); + if (activeTransition) { + drawTransition(window.performance.now()); + } else { + drawSettledScene(); + } + } + + canvas.addEventListener("pointermove", handleCanvasPointerMove); + canvas.addEventListener("pointerleave", handleCanvasPointerLeave); + canvas.addEventListener("click", handleCanvasClick); + canvas.addEventListener("focus", handleCanvasFocus); + canvas.addEventListener("blur", handleCanvasBlur); + canvas.addEventListener("keydown", handleCanvasKeyDown); + tooltip.addEventListener("pointerenter", handleTooltipPointerEnter); + tooltip.addEventListener("pointerleave", handleTooltipPointerLeave); + tooltip.addEventListener("focusout", handleTooltipFocusOut); + window.addEventListener("resize", handleResize); + + const resizeObserver = typeof ResizeObserver === "function" + ? new ResizeObserver(handleResize) + : null; + if (resizeObserver) resizeObserver.observe(container); + + const handle = { + update(nextTransactions) { + if (destroyed) { + throw new Error("Cannot update a destroyed block grid."); + } + + const normalizedTransactions = normalizeTransactions(nextTransactions); + + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + cancelTransition(); + hideTooltip(); + settledScene = createScene( + normalizedTransactions, + gridDimensions, + normalizedOptions.weightLimit + ); + updateSceneSummary(); + drawSettledScene(); + return; + } + + if (activeTransition) { + queuedTransactions = normalizedTransactions; + return; + } + + startTransition( + createScene( + normalizedTransactions, + gridDimensions, + normalizedOptions.weightLimit + ) + ); + }, + + destroy() { + if (destroyed) return; + destroyed = true; + cancelTransition(); + clearTooltipHideTimer(); + resizeObserver?.disconnect(); + window.removeEventListener("resize", handleResize); + canvas.removeEventListener("pointermove", handleCanvasPointerMove); + canvas.removeEventListener("pointerleave", handleCanvasPointerLeave); + canvas.removeEventListener("click", handleCanvasClick); + canvas.removeEventListener("focus", handleCanvasFocus); + canvas.removeEventListener("blur", handleCanvasBlur); + canvas.removeEventListener("keydown", handleCanvasKeyDown); + tooltip.removeEventListener("pointerenter", handleTooltipPointerEnter); + tooltip.removeEventListener("pointerleave", handleTooltipPointerLeave); + tooltip.removeEventListener("focusout", handleTooltipFocusOut); + + if (mountedInstances.get(container) === handle) { + mountedInstances.delete(container); + container.replaceChildren(); + if (!hadComponentClass) container.classList.remove("block-grid"); + } + } + }; + + mountedInstances.set(container, handle); + updateSceneSummary(); + handleResize(); + return handle; +} diff --git a/client/src/const.js b/client/src/const.js index b173552bb..fe1d2adec 100644 --- a/client/src/const.js +++ b/client/src/const.js @@ -6,6 +6,8 @@ export const maxMempoolTxs = 50 export const satoshisPerBitcoin = 100000000 export const averageNativeSegwitTransactionSize = 140 export const maxBlockWeight = 4000000 +export const blockGridLoadingDelayMs = 100 +export const blockGridTransactionSelectEvent = 'block-grid-transaction-select' const configuredTargetBlockIntervalSeconds = Number(process.env.TARGET_BLOCK_INTERVAL_SECONDS) export const targetBlockIntervalSeconds = configuredTargetBlockIntervalSeconds > 0 diff --git a/client/src/lib/block-template.js b/client/src/lib/block-template.js new file mode 100644 index 000000000..55e12c662 --- /dev/null +++ b/client/src/lib/block-template.js @@ -0,0 +1,137 @@ +import { maxBlockWeight } from "../const"; +import { feeRateClass } from "./fees"; +import { clamp } from "./math"; + +const SEGWIT_MARKER_BYTE = "00"; +const EMPTY_SEGWIT_FLAG_BYTE = "00"; + +const percentage = (value, limit) => + Number.isFinite(value) && Number.isFinite(limit) && limit > 0 + ? clamp((value / limit) * 100, 0, 100) + : null; + +const transactionVsize = (tx) => + tx && Number.isFinite(tx.weight) && tx.weight > 0 + ? Math.ceil(tx.weight / 4) + : null; + +const transactionHexSize = (txHex) => + typeof txHex === "string" && txHex.length % 2 === 0 + ? txHex.length / 2 + : null; + +const isSegwitTransaction = (txHex) => + typeof txHex === "string" && + txHex.length >= 12 && + txHex.slice(8, 10) === SEGWIT_MARKER_BYTE && + txHex.slice(10, 12) !== EMPTY_SEGWIT_FLAG_BYTE; + +const sumCompleteValues = (values) => + values.every(Number.isFinite) + ? values.reduce((sum, value) => sum + value, 0) + : null; + +const summarizeFeeBucket = (transactions) => { + if (!transactions.length) { + return { count: 0, averageFeeRate: null, averageFee: null }; + } + + const totalFees = transactions.reduce((sum, tx) => sum + tx.fee, 0); + const totalVsize = transactions.reduce((sum, tx) => sum + tx.vsize, 0); + + return { + count: transactions.length, + averageFeeRate: totalVsize > 0 ? totalFees / totalVsize : null, + averageFee: totalFees / transactions.length, + }; +}; + +const summarizeFeeBuckets = (transactions, feeEst) => { + if (!feeEst || feeEst[3] == null || feeEst[12] == null) { + return { low: null, medium: null, high: null }; + } + + if ( + !transactions.every( + (tx) => Number.isFinite(tx.fee) && Number.isFinite(tx.vsize), + ) + ) { + return { low: null, medium: null, high: null }; + } + + const buckets = { success: [], warning: [], danger: [] }; + + transactions.forEach((tx) => { + const className = feeRateClass(tx.fee / tx.vsize, feeEst); + if (buckets[className]) buckets[className].push(tx); + }); + + return { + low: summarizeFeeBucket(buckets.success), + medium: summarizeFeeBucket(buckets.warning), + high: summarizeFeeBucket(buckets.danger), + }; +}; + +export const summarizeBlockTemplate = (template, feeEst) => { + if (!template || !Array.isArray(template.transactions)) return null; + + const transactions = template.transactions + .filter(Boolean) + .map((tx) => ({ + ...tx, + vsize: transactionVsize(tx), + size: transactionHexSize(tx.data), + })); + const fees = transactions.map((tx) => tx.fee); + const weights = transactions.map((tx) => tx.weight); + const sizes = transactions.map((tx) => tx.size); + const vsizes = transactions.map((tx) => tx.vsize); + const totalFees = sumCompleteValues(fees); + const totalWeight = sumCompleteValues(weights); + const totalSize = sumCompleteValues(sizes); + const totalVsize = sumCompleteValues(vsizes); + const weightLimit = Number.isFinite(template.weightlimit) + ? template.weightlimit + : maxBlockWeight; + const sizeLimit = Number.isFinite(template.sizelimit) + ? template.sizelimit + : null; + const hasCompleteTransactionData = sizes.every(Number.isFinite); + const segwitCount = hasCompleteTransactionData + ? transactions.filter((tx) => isSegwitTransaction(tx.data)).length + : null; + const legacyCount = hasCompleteTransactionData + ? transactions.length - segwitCount + : null; + + return { + height: Number.isFinite(template.height) ? template.height : null, + updatedAt: Number.isFinite(template.curtime) ? template.curtime : null, + templateTransactionCount: transactions.length, + transactionCount: transactions.length + 1, + totalFees, + totalWeight, + totalSize, + weightLimit, + sizeLimit, + weightPercentage: percentage(totalWeight, weightLimit), + sizePercentage: percentage(totalSize, sizeLimit), + averageFeeRate: + Number.isFinite(totalFees) && Number.isFinite(totalVsize) && totalVsize > 0 + ? totalFees / totalVsize + : null, + feeBuckets: summarizeFeeBuckets(transactions, feeEst), + segwitCount, + segwitPercentage: hasCompleteTransactionData + ? transactions.length + ? percentage(segwitCount, transactions.length) + : 0 + : null, + legacyPercentage: hasCompleteTransactionData + ? transactions.length + ? percentage(legacyCount, transactions.length) + : 0 + : null, + }; +}; diff --git a/client/src/lib/fees.js b/client/src/lib/fees.js index 6ea966d07..ba41c6891 100644 --- a/client/src/lib/fees.js +++ b/client/src/lib/fees.js @@ -1,5 +1,25 @@ const MAX_BLOCK_VSIZE = 1000000 +export const getFeeTierBoundaries = feeEst => { + const low = feeEst && feeEst[12] + , high = feeEst && feeEst[3] + + return Number.isFinite(low) && Number.isFinite(high) && low >= 0 && high >= 0 + ? { low, high: Math.max(low, high) } + : null +} + +export const feeRateClass = (feerate, feeEst) => { + const boundaries = getFeeTierBoundaries(feeEst) + if (!Number.isFinite(feerate) || !boundaries) return "" + + return feerate <= boundaries.low + ? "success" + : feerate <= boundaries.high + ? "warning" + : "danger" +} + // Squash fee buckets into fixed fee-rates ranges, with steps of 50% (1, 1.5, 2.25, ..) const SQUASH_BUCKETS = Array.from(Array(20)).map((_,i) => 1*Math.pow(1.5, i)).reverse().concat(0) diff --git a/client/src/lib/math.js b/client/src/lib/math.js new file mode 100644 index 000000000..8d61c50f0 --- /dev/null +++ b/client/src/lib/math.js @@ -0,0 +1,2 @@ +export const clamp = (value, minValue, maxValue) => + Math.min(Math.max(value, minValue), maxValue); diff --git a/client/src/lib/mempool.js b/client/src/lib/mempool.js new file mode 100644 index 000000000..2543abc1b --- /dev/null +++ b/client/src/lib/mempool.js @@ -0,0 +1,36 @@ +// Bitcoin Core's default -maxmempool allocation is 300 MB: +// https://github.com/bitcoin/bitcoin/blob/b2c45888fde06429e86913fab5e7b7a075f091c3/src/kernel/mempool_options.h#L18-L19 +const DEFAULT_MEMPOOL_LIMIT_BYTES = 300 * 1000 * 1000; + +// This is an approximation: Esplora exposes total transaction vsize, not +// Bitcoin Core's dynamic in-memory usage or a node's configured -maxmempool. +export const getMempoolUsage = (mempool) => + mempool && Number.isFinite(mempool.vsize) + ? Math.max(0, Math.min(1, mempool.vsize / DEFAULT_MEMPOOL_LIMIT_BYTES)) + : 0; + +export const getMempoolCongestionLevel = (usage) => { + if (usage < 1 / 3) return "Low"; + if (usage < 2 / 3) return "Moderate"; + return "High"; +}; + +const congestionClassByLevel = { + Low: "success", + Moderate: "warning", + High: "danger", +}; + +export const getMempoolCongestionClass = (level) => + congestionClassByLevel[level] || ""; + +export const getMempoolCongestion = (mempool) => { + const usage = getMempoolUsage(mempool); + const level = mempool ? getMempoolCongestionLevel(usage) : ""; + + return { + className: getMempoolCongestionClass(level), + level, + percentage: usage * 100, + }; +}; diff --git a/client/src/lib/pending-block-details.js b/client/src/lib/pending-block-details.js new file mode 100644 index 000000000..8b2c8c605 --- /dev/null +++ b/client/src/lib/pending-block-details.js @@ -0,0 +1,88 @@ +import { targetBlockIntervalSeconds } from "../const"; + +export const formatEstimatedBlockTime = ( + timestamp, + t, + now = Date.now(), +) => { + // This is a simple reference against the configured nominal block interval, + // not an exact estimate of the remaining wait. + if (!Number.isFinite(timestamp)) return t`N/A`; + + const timestampMs = timestamp < 1e12 ? timestamp * 1000 : timestamp; + const elapsedSeconds = Math.max(0, (now - timestampMs) / 1000); + const remainingSeconds = targetBlockIntervalSeconds - elapsedSeconds; + + if (remainingSeconds > 0) { + if (remainingSeconds < 60) return t`EXPECTED IN < 1 MINUTE`; + + const remainingMinutes = Math.ceil(remainingSeconds / 60); + return remainingMinutes === 1 + ? t`EXPECTED IN ~${remainingMinutes} MINUTE` + : t`EXPECTED IN ~${remainingMinutes} MINUTES`; + } + + const elapsedPastIntervalSeconds = Math.abs(remainingSeconds); + if (elapsedPastIntervalSeconds === 0) { + return t`EXPECTED INTERVAL REACHED`; + } + if (elapsedPastIntervalSeconds < 60) { + return t`< 1 MINUTE PAST EXPECTED INTERVAL`; + } + + const elapsedPastIntervalMinutes = Math.floor( + elapsedPastIntervalSeconds / 60, + ); + return elapsedPastIntervalMinutes === 1 + ? t`${elapsedPastIntervalMinutes} MINUTE PAST EXPECTED INTERVAL` + : t`${elapsedPastIntervalMinutes} MINUTES PAST EXPECTED INTERVAL`; +}; + +export const formatPercentage = (value, fallback = "N/A") => + Number.isFinite(value) ? `${value.toFixed(2)}%` : fallback; + +const formatTrimmedDecimal = (value) => + value.toFixed(2).replace(/\.?0+$/, ""); + +export const formatPanelPercentage = (value, fallback = "N/A") => + Number.isFinite(value) ? `${formatTrimmedDecimal(value)}%` : fallback; + +export const formatFeeRate = (value, fallback = "N/A") => + Number.isFinite(value) ? `${value.toFixed(2)} sat/vB` : fallback; + +export const formatFeeBoundary = (value, fallback = "N/A") => + Number.isFinite(value) ? value.toFixed(2) : fallback; + +export const formatWeight = (value, fallback = "N/A") => + Number.isFinite(value) + ? `${formatTrimmedDecimal(value / 1_000_000)} MWU` + : fallback; + +export const formatMegabytes = (value, fallback = "N/A") => + Number.isFinite(value) + ? `${formatTrimmedDecimal(value / 1_000_000)} MB` + : fallback; + +export const formatCount = (value, fallback = "N/A") => + Number.isFinite(value) ? value.toLocaleString() : fallback; + +export const getLatestBitcoinPrice = (marketChart) => { + const prices = ((marketChart && marketChart.prices) || []) + .map((price) => price && price[1]) + .filter(Number.isFinite); + + return prices.length ? prices[prices.length - 1] : null; +}; + +export const formatUsd = (value, fallback = "N/A") => + Number.isFinite(value) + ? `$${value.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} USD` + : fallback; + +export const formatTransactionDelta = (delta) => + delta > 0 + ? `+ ${formatCount(delta)}` + : `− ${formatCount(Math.abs(delta))}`; diff --git a/client/src/views/asset.js b/client/src/views/asset.js index c91363db1..e99bdf569 100644 --- a/client/src/views/asset.js +++ b/client/src/views/asset.js @@ -4,7 +4,7 @@ import layout from './layout' import { txBox } from './tx' import { maxMempoolTxs, assetTxsPerPage as perPage, nativeAssetName } from '../const' import loader from '../components/loading' -import { ConfidentialBadge, StatusBadge } from '../components/status-badge' +import { ConfidentialBadge, StatusBadge, StatusDot } from '../components/status-badge' import { InfoStat } from '../components/info-stat' import { AssetContractIcon, @@ -119,11 +119,7 @@ export default ({ t, asset, assetTxs, goAsset, openTx, spends, tipHeight, loadin : null} {is_unregistered ? ( - + {t`Unregistered`} ) : null} diff --git a/client/src/views/block.js b/client/src/views/block.js index 5e5888958..7fd84da79 100644 --- a/client/src/views/block.js +++ b/client/src/views/block.js @@ -1,6 +1,6 @@ import layout from "./layout"; import { txBox } from "./tx"; -import { formatHex, formatNumber } from "./util"; +import { formatNumber } from "./util"; import loader from "../components/loading"; import { BlockIcon, @@ -9,11 +9,6 @@ import { } from "../components/icons"; import BlockDetailsCard from "../components/block-details-card"; -// Require behind env conditional so it gets removed by `envify` on non-elements builds -const BlockSignatures = - process.env.IS_ELEMENTS && - require("../components/block-signatures").default; - const staticRoot = process.env.STATIC_ROOT || ""; const makeStatus = (b) => @@ -66,6 +61,7 @@ export default ({
-
-
-
-
-

{t`Version`}

-
-

{formatHex(b.version)}

-
-
- -
-

BLOCK HASH

-
-

{b.id}

-
-
-
- -
-
-

{t`Merkle root`}

-
-

{b.merkle_root}

-
-
- -
-

- {process.env.IS_ELEMENTS ? "BLOCK SIGNATURES" : t`Nonce`} -

-
- {process.env.IS_ELEMENTS ? ( - - ) : ( -

{formatHex(b.nonce)}

- )} -
-
-
-
-
-
{blockTxs ? blockTxs.map((tx, index) => diff --git a/client/src/views/blocks.js b/client/src/views/blocks.js index 9054d5dfc..176cdb8ba 100644 --- a/client/src/views/blocks.js +++ b/client/src/views/blocks.js @@ -8,11 +8,26 @@ import loader from "../components/loading"; import { BlockIcon, ClockIcon, CopyIcon } from "../components/icons"; import { InfoStat } from "../components/info-stat"; import { Tooltip } from "../components/tooltip"; +import PendingBlockDetailsCard from "./pending-block-details-card"; const staticRoot = process.env.STATIC_ROOT || ""; -export const blks = (blocks, viewMore, { t, ...S }) => ( -
+export const blockTemplateStateForTip = (block, state) => + block && + state && + state.template && + state.template.previousblockhash === block.id + ? state + : null; + +export const blks = (blocks, viewMore, { t, ...S }) => { + const blockTemplateState = blockTemplateStateForTip( + blocks && blocks[0], + S.blockTemplateState, + ); + + return ( +
{!blocks ? ( loader() ) : !blocks.length ? ( @@ -23,8 +38,42 @@ export const blks = (blocks, viewMore, { t, ...S }) => (
-

Latest Blocks

+

{t`Latest Blocks`}

+ + {viewMore ? ( + + ) : ( + "" + )} + + {viewMore ? ( + + ) : ( + "" + )} + {viewMore ? ( +

{t`Blocks History`}

+ ) : ""} +
{blocks && blocks.map((b, index) => ( @@ -117,5 +166,6 @@ export const blks = (blocks, viewMore, { t, ...S }) => ( )}
)} -
-); +
+ ); +}; diff --git a/client/src/views/difficulty-adjustment.js b/client/src/views/difficulty-adjustment.js index b26be218b..0fa6413d3 100644 --- a/client/src/views/difficulty-adjustment.js +++ b/client/src/views/difficulty-adjustment.js @@ -273,10 +273,7 @@ export default ({ title="Hashrate" value={hashrate.value} footer={hashrate.footer} - tooltip={{ - iconSrc: `${staticRoot}img/icons/tooltip.svg`, - text: "Estimated computing power securing the network.", - }} + tooltip="Estimated computing power securing the network." />
diff --git a/client/src/views/home.js b/client/src/views/home.js index 877a94c52..01124cf4b 100644 --- a/client/src/views/home.js +++ b/client/src/views/home.js @@ -17,7 +17,7 @@ export const dashBoard = ({ t, blocks, dashboardState, loading, ...S }) => { return homeLayout(
- {overview({ blocks: dashblocks, ...S })} + {overview({ blocks: dashblocks, t, ...S })} {blks(dashblocks, true, { t, ...S })}
{transactions(dashTxs, true, { t, ...S })} diff --git a/client/src/views/overview.js b/client/src/views/overview.js index a20fe305a..1b0e59765 100644 --- a/client/src/views/overview.js +++ b/client/src/views/overview.js @@ -4,10 +4,10 @@ import { } from "../const"; import { ElapsedTime } from "../components/elapsed-time"; import { InfoCard } from "../components/info-card"; +import { MempoolCongestion } from "../components/mempool-congestion"; import { ReferenceLineChart } from "../components/reference-line-chart"; const staticRoot = process.env.STATIC_ROOT || ""; -const DEFAULT_MEMPOOL_LIMIT_BYTES = 300 * 1000 * 1000; const getBitcoinPrices = (marketChart) => ((marketChart && marketChart.prices) || []) @@ -37,38 +37,6 @@ const estimateNativeSegwitFeeUsd = (bitcoinPrice, feeEst) => ) : ""; -const getMempoolUsage = (mempool) => - mempool && Number.isFinite(mempool.vsize) - ? Math.max(0, Math.min(1, mempool.vsize / DEFAULT_MEMPOOL_LIMIT_BYTES)) - : 0; - -const MEMPOOL_CONGESTION_LEVEL = { - LOW: "Low", - MODERATE: "Moderate", - HIGH: "High", -}; - -const getMempoolCongestionLevel = (usage) => { - if (usage < 1 / 3) { - return MEMPOOL_CONGESTION_LEVEL.LOW; - } - - if (usage < 2 / 3) { - return MEMPOOL_CONGESTION_LEVEL.MODERATE; - } - - return MEMPOOL_CONGESTION_LEVEL.HIGH; -}; - -const CONGESTION_CLASS_BY_LEVEL = { - [MEMPOOL_CONGESTION_LEVEL.LOW]: "success", - [MEMPOOL_CONGESTION_LEVEL.MODERATE]: "warning", - [MEMPOOL_CONGESTION_LEVEL.HIGH]: "danger", -}; - -const getMempoolCongestionClass = (level) => - CONGESTION_CLASS_BY_LEVEL[level] || ""; - const getLatestPrice = (marketChart) => { const prices = getBitcoinPrices(marketChart); return prices.length ? prices[prices.length - 1] : null; @@ -79,6 +47,7 @@ export const overview = ({ feeEst, mempool, bitcoinMarketChart, + t, } = {}) => { const latestBlock = blocks && blocks[0]; const chartPrices = getChartPrices(bitcoinMarketChart); @@ -87,25 +56,13 @@ export const overview = ({ currentBitcoinPrice, feeEst, ); - const mempoolUsage = getMempoolUsage(mempool); - const mempoolUsagePercent = Math.round(mempoolUsage * 10000) / 100; - const mempoolCongestionLevel = mempool - ? getMempoolCongestionLevel(mempoolUsage) - : ""; - const mempoolCongestionClass = getMempoolCongestionClass( - mempoolCongestionLevel, - ); - return (
-

Overview

+

{t`Overview`}

@@ -119,44 +76,25 @@ export const overview = ({ /> -
- {mempoolCongestionLevel} -
-
-
-
-
-

LOW

-

HIGH

-
-
+ } /> { {
diff --git a/client/src/views/pending-block-details-card.js b/client/src/views/pending-block-details-card.js new file mode 100644 index 000000000..1e0d7f76f --- /dev/null +++ b/client/src/views/pending-block-details-card.js @@ -0,0 +1,760 @@ +import { BlockGrid } from "../components/block-grid"; +import { ElapsedTime } from "../components/elapsed-time"; +import { ExpectedBlockTime } from "../components/expected-block-time"; +import { + LightningBoltIcon, + MinusIcon, + PlusIcon, +} from "../components/icons"; +import { InfoCard } from "../components/info-card"; +import { MempoolCongestion } from "../components/mempool-congestion"; +import { MetricBar } from "../components/metric-bar"; +import { StatusBadge, StatusDot } from "../components/status-badge"; +import { renderBlockGrid } from "../components/transaction-block-grid"; +import { + blockGridLoadingDelayMs, + satoshisPerBitcoin, +} from "../const"; +import { getFeeTierBoundaries } from "../lib/fees"; +import { + formatCount, + formatFeeBoundary, + formatFeeRate, + formatMegabytes, + formatPanelPercentage, + formatPercentage, + formatTransactionDelta, + formatUsd, + formatWeight, + getLatestBitcoinPrice, +} from "../lib/pending-block-details"; +import { formatSat, formatVMB } from "./util"; + +// Fee estimates are inclusive upper bounds, while the grid expects inclusive +// lower thresholds. Move just beyond a boundary so an equal rate stays in the +// lower tier. +const exclusiveFeeTierMinimum = (value) => + value + Math.max(1, Math.abs(value)) * Number.EPSILON; + +const getBlockGridConfig = ( + element, + boundaries, + weightLimit, + t, +) => { + if (typeof window === "undefined") return null; + + const styles = window.getComputedStyle(element); + const cssValue = (property) => styles.getPropertyValue(property).trim(); + const rgbColor = (property) => `rgb(${cssValue(property)})`; + const neutralColor = rgbColor("--accent-color-rgb"); + const feeTiers = boundaries + ? { + low: { + threshold: 0, + color: rgbColor("--success-color-rgb"), + }, + medium: { + threshold: exclusiveFeeTierMinimum(boundaries.low), + color: rgbColor("--warning-color-rgb"), + }, + high: { + threshold: exclusiveFeeTierMinimum( + Math.max( + boundaries.high, + exclusiveFeeTierMinimum(boundaries.low), + ), + ), + color: rgbColor("--danger-color-rgb"), + }, + } + : { + low: { threshold: 0, color: neutralColor }, + medium: { threshold: Number.MAX_VALUE / 2, color: neutralColor }, + high: { threshold: Number.MAX_VALUE, color: neutralColor }, + }; + const options = { + backgroundColor: cssValue("--surface-primary-color"), + placeholderColor: cssValue("--surface-secondary-color"), + weightLimit, + labels: { + allTransactionsShown: + t`All selected transactions are shown individually.`, + fee: t`Fee`, + feeRate: t`Fee rate`, + individuallyRendered: t`Individually rendered transactions`, + keyboardInstructions: + t`Use the arrow keys to inspect transactions and Enter to open one.`, + omittedTransactions: + t`Lower-fee transactions are summarized in the metrics because they do not fit at this resolution.`, + transaction: t`Transaction`, + transactionGrid: t`Pending block transaction grid`, + virtualSize: t`Virtual size`, + }, + }; + + return { + feeTiers, + options, + key: JSON.stringify({ feeTiers, options }), + }; +}; + +const showPendingBlockGridLoading = (element, label) => { + const loading = document.createElement("div"); + const wave = document.createElement("div"); + + loading.className = "pending-block-grid-loading"; + loading.setAttribute("role", "status"); + loading.setAttribute("aria-label", label); + wave.className = "pending-block-grid-loading-wave"; + wave.setAttribute("aria-hidden", "true"); + + for (let index = 0; index < 25; index += 1) { + const cell = document.createElement("span"); + const row = Math.floor(index / 5); + const column = index % 5; + cell.style.animationDelay = `${(row + column) * 70}ms`; + wave.append(cell); + } + + loading.append(wave); + element.classList.add("block-grid"); + element.replaceChildren(loading); +}; + +const clearPendingBlockGridLoadingTimer = (gridState) => { + if (gridState.loadingTimer !== null) { + window.clearTimeout(gridState.loadingTimer); + gridState.loadingTimer = null; + } +}; + +const schedulePendingBlockGridLoading = ( + element, + gridState, + loadingDelayMs, + loadingLabel, +) => { + clearPendingBlockGridLoadingTimer(gridState); + gridState.loadingTimer = window.setTimeout(() => { + gridState.loadingTimer = null; + if (!gridState.destroyed && !gridState.handle) { + showPendingBlockGridLoading(element, loadingLabel); + } + }, loadingDelayMs); +}; + +const cancelPendingBlockGridWork = (gridState) => { + if (gridState.paintFrame !== null) { + window.cancelAnimationFrame(gridState.paintFrame); + } + if (gridState.workFrame !== null) { + window.cancelAnimationFrame(gridState.workFrame); + } + gridState.paintFrame = null; + gridState.workFrame = null; +}; + +const sameGridTransactions = (first, second) => + first === second || + (Array.isArray(first) && + Array.isArray(second) && + first.length === second.length && + first.every( + (transaction, index) => + transaction.txid === second[index].txid && + transaction.fee === second[index].fee && + transaction.weight === second[index].weight, + )); + +const queuePendingBlockGridWork = ( + element, + gridState, + transactions, + config, +) => { + gridState.targetTransactions = transactions; + gridState.targetConfig = config; + if (gridState.paintFrame !== null || gridState.workFrame !== null) return; + + gridState.paintFrame = window.requestAnimationFrame(() => { + gridState.paintFrame = null; + gridState.workFrame = window.requestAnimationFrame(() => { + gridState.workFrame = null; + if (gridState.destroyed) return; + + const nextConfig = gridState.targetConfig; + const nextTransactions = gridState.targetTransactions; + if (!nextConfig) return; + + if (!gridState.handle || gridState.configKey !== nextConfig.key) { + const transactionBaseUrl = new URL("tx", document.baseURI).href; + gridState.handle = renderBlockGrid( + "pending-block", + nextTransactions, + transactionBaseUrl, + nextConfig.feeTiers, + nextConfig.options, + ); + gridState.configKey = nextConfig.key; + gridState.transactions = nextTransactions; + } else if ( + !sameGridTransactions(gridState.transactions, nextTransactions) + ) { + gridState.handle.update(nextTransactions); + gridState.transactions = nextTransactions; + } + + clearPendingBlockGridLoadingTimer(gridState); + }); + }); +}; + +const mountPendingBlockGrid = ( + vnode, + transactions, + boundaries, + weightLimit, + t, + loadingDelayMs = blockGridLoadingDelayMs, +) => { + const gridState = { + configKey: null, + destroyed: false, + handle: null, + loadingTimer: null, + paintFrame: null, + targetConfig: null, + targetTransactions: transactions, + transactions: null, + workFrame: null, + }; + const loadingLabel = t`Loading pending block transactions`; + + vnode.elm.pendingBlockGrid = gridState; + vnode.elm.classList.add("block-grid"); + schedulePendingBlockGridLoading( + vnode.elm, + gridState, + loadingDelayMs, + loadingLabel, + ); + + const config = getBlockGridConfig( + vnode.elm, + boundaries, + weightLimit, + t, + ); + if (config && Array.isArray(transactions)) { + queuePendingBlockGridWork(vnode.elm, gridState, transactions, config); + } +}; + +const patchPendingBlockGrid = ( + _, + vnode, + transactions, + boundaries, + weightLimit, + t, +) => { + const gridState = vnode.elm.pendingBlockGrid; + const config = getBlockGridConfig( + vnode.elm, + boundaries, + weightLimit, + t, + ); + + if (!gridState || !config) return; + if (!Array.isArray(transactions)) { + cancelPendingBlockGridWork(gridState); + if (gridState.handle) gridState.handle.destroy(); + gridState.handle = null; + gridState.configKey = null; + gridState.transactions = null; + gridState.targetTransactions = null; + schedulePendingBlockGridLoading( + vnode.elm, + gridState, + blockGridLoadingDelayMs, + t`Loading pending block transactions`, + ); + return; + } + if ( + gridState.targetConfig && + gridState.targetConfig.key === config.key && + sameGridTransactions(gridState.targetTransactions, transactions) + ) { + return; + } + + queuePendingBlockGridWork(vnode.elm, gridState, transactions, config); +}; + +const destroyPendingBlockGrid = (vnode) => { + const gridState = vnode.elm.pendingBlockGrid; + if (!gridState) return; + + gridState.destroyed = true; + clearPendingBlockGridLoadingTimer(gridState); + cancelPendingBlockGridWork(gridState); + if (gridState.handle) gridState.handle.destroy(); + vnode.elm.pendingBlockGrid = null; +}; + +const formatFeeCost = (sats, bitcoinPrice, fallback = "N/A") => { + if (!Number.isFinite(sats) || !Number.isFinite(bitcoinPrice)) { + return fallback; + } + + const usdValue = (sats / satoshisPerBitcoin) * bitcoinPrice; + return `${formatSat(Math.round(sats))} / ${formatUsd(usdValue, fallback)}`; +}; + +const blockStat = (title, value) => ( +
+

{title}

+

{value}

+
+); + +const detailPanel = (className, title, value, footer, tooltipText) => ( + +); + +const feeBucketPanel = ( + className, + title, + bucket, + bitcoinPrice, + valueFallback, + costFallback, + tooltipText, +) => + detailPanel( + className, + title, + bucket + ? formatFeeRate(bucket.averageFeeRate, valueFallback) + : valueFallback, + bucket + ? formatFeeCost(bucket.averageFee, bitcoinPrice, costFallback) + : costFallback, + tooltipText, + ); + +const PendingBlockDetailsCard = ({ + bitcoinMarketChart, + block, + blockTemplate, + detailsOpen, + feeEst, + mempool, + metrics, + t, + transactionDelta, +}) => { + const unavailable = t`N/A`; + const templateFallback = blockTemplate ? unavailable : "-"; + const feeEstimateFallback = feeEst ? unavailable : "-"; + const mempoolFallback = mempool ? unavailable : "-"; + const feeBucketFallback = + blockTemplate && feeEst ? unavailable : "-"; + const feeCostFallback = + blockTemplate && feeEst && bitcoinMarketChart ? unavailable : "-"; + const totalFeesUsdFallback = + blockTemplate && bitcoinMarketChart ? unavailable : "-"; + const gridTransactions = + blockTemplate && Array.isArray(blockTemplate.transactions) + ? blockTemplate.transactions + : null; + const feeTierBoundaries = getFeeTierBoundaries(feeEst); + const bitcoinPrice = getLatestBitcoinPrice(bitcoinMarketChart); + const weightPercentage = metrics && metrics.weightPercentage; + const weightLimit = metrics && metrics.weightLimit; + return ( +
+
+ t`Block is ${percentage}% full`} + loading={!blockTemplate} + loadingLabel={t`Loading block utilization`} + unavailableLabel={t`Block utilization unavailable`} + weightLimit={weightLimit} + /> + +
+
+

{t`Next Block`}

+ +

+ {block ? ( + + ) : ( + "-" + )} + + {blockTemplate ? : null} + + {blockTemplate + ? process.env.IS_ELEMENTS + ? t`Building...` + : t`Mining...` + : t`Updating...`} + + +

+ +
+ +
+ {blockStat( + t`AVG FEE`, + formatFeeRate( + metrics && metrics.averageFeeRate, + templateFallback, + ), + )} + {blockStat( + t`TRANSACTIONS`, + formatCount( + metrics && metrics.transactionCount, + templateFallback, + ), + )} + {blockStat( + t`SIZE`, + metrics && Number.isFinite(metrics.totalSize) + ? formatVMB(metrics.totalSize, "MB") + : templateFallback, + )} + {blockStat( + t`TOTAL FEE COLLECTED`, + metrics && Number.isFinite(metrics.totalFees) + ? formatSat(metrics.totalFees) + : templateFallback, + )} +
+ +
+
+

{t`Block filling`}

+

+ {formatPercentage( + weightPercentage, + templateFallback, + )} +

+
+ +
+
+
+
+
+
+ + {detailsOpen ? ( +
+
+
+
+ mountPendingBlockGrid( + vnode, + gridTransactions, + feeTierBoundaries, + weightLimit, + t, + ) + } + hook-postpatch={(oldVnode, vnode) => + patchPendingBlockGrid( + oldVnode, + vnode, + gridTransactions, + feeTierBoundaries, + weightLimit, + t, + ) + } + hook-destroy={destroyPendingBlockGrid} + >
+
+ {feeTierBoundaries ? ( +
+
+
+

+ {t`Low`} ( + {`≤${formatFeeBoundary(feeTierBoundaries.low)} sat/vB`} + ) +

+
+
+
+

+ {t`Medium`} ( + {`>${formatFeeBoundary(feeTierBoundaries.low)}–≤${formatFeeBoundary(feeTierBoundaries.high)} sat/vB`} + ) +

+
+
+
+

+ {t`High`} ( + {`>${formatFeeBoundary(feeTierBoundaries.high)} sat/vB`} + ) +

+
+
+ ) : ( +

+ {blockTemplate + ? t`Fee-rate estimates are unavailable; transactions use a neutral color.` + : feeEstimateFallback} +

+ )} +
+
+
+ {detailPanel( + "time-since-last-block", + t`Time Since Last Block`, + block ? ( + + ) : ( + "-" + ), + block ? t`Block #${block.height.toLocaleString()}` : "-", + t`Elapsed time since the last block confirmed.`, + )} + + + {t`Live`} + + ) : undefined + } + value={ + + + {formatCount( + metrics && metrics.transactionCount, + templateFallback, + )} + + {Number.isFinite(transactionDelta) && + transactionDelta !== 0 ? ( + 0 + ? t`${Math.abs(transactionDelta)} added since the last update` + : t`${Math.abs(transactionDelta)} removed since the last update` + } + > + {formatTransactionDelta(transactionDelta)} + + + ) : null} + + } + footer={ + metrics + ? t`${formatCount(metrics.templateTransactionCount)} SELECTED + COINBASE` + : templateFallback + } + /> +
+
+ {feeBucketPanel( + "low-fee", + t`Low`, + metrics && metrics.feeBuckets.low, + bitcoinPrice, + feeBucketFallback, + feeCostFallback, + t`Average fee rate and transaction fee in the low-fee portion of this template.`, + )} + {feeBucketPanel( + "avg-fee", + t`Average`, + metrics && metrics.feeBuckets.medium, + bitcoinPrice, + feeBucketFallback, + feeCostFallback, + t`Average fee rate and transaction fee in the middle-fee portion of this template.`, + )} + {feeBucketPanel( + "high-fee", + t`High`, + metrics && metrics.feeBuckets.high, + bitcoinPrice, + feeBucketFallback, + feeCostFallback, + t`Average fee rate and transaction fee in the high-fee portion of this template.`, + )} +
+
+ {detailPanel( + "total-fees-collected", + t`Total Fees Collected`, + metrics && Number.isFinite(metrics.totalFees) + ? formatSat(metrics.totalFees) + : templateFallback, + metrics && + Number.isFinite(metrics.totalFees) && + Number.isFinite(bitcoinPrice) + ? formatUsd( + (metrics.totalFees / satoshisPerBitcoin) * bitcoinPrice, + ) + : totalFeesUsdFallback, + t`Total transaction fees a miner would collect from the current template, shown in bitcoin and US dollars.`, + )} +
+
+ + + +
+ } + /> + + + + +
+ } + /> +
+
+ {detailPanel( + "pending-transactions", + t`Pending Transactions`, + formatCount(mempool && mempool.count, mempoolFallback), + mempool ? t`IN MEMPOOL` : mempoolFallback, + t`Transactions currently waiting in the node's mempool.`, + )} + + + } + /> +
+
+
+ ) : ( + "" + )} +
+ ); +}; + +export default PendingBlockDetailsCard; diff --git a/client/src/views/transactions.js b/client/src/views/transactions.js index eebf48e78..3d4f90ea4 100644 --- a/client/src/views/transactions.js +++ b/client/src/views/transactions.js @@ -2,19 +2,10 @@ import { formatSat, formatNumber, truncateTxid } from "./util"; import loader from "../components/loading"; import { CopyIcon, TxArrowsIcon } from "../components/icons"; import { ConfidentialBadge } from "../components/status-badge"; +import { feeRateClass } from "../lib/fees"; const staticRoot = process.env.STATIC_ROOT || ""; -const feeRateClass = (feerate, feeEst) => { - if (!feeEst || feeEst[3] == null || feeEst[12] == null) return ""; - - return feerate <= feeEst[12] - ? "success" - : feerate <= feeEst[3] - ? "warning" - : "danger"; -} - export const transactions = (txs, viewMore, { t, ...S }) => (
{!txs ? ( diff --git a/client/src/views/tx.js b/client/src/views/tx.js index a3f98a516..fd6cf6ac3 100644 --- a/client/src/views/tx.js +++ b/client/src/views/tx.js @@ -21,7 +21,7 @@ import { TxArrowsIcon, } from "../components/icons"; import { InfoStat } from "../components/info-stat"; -import { StatusBadge } from "../components/status-badge"; +import { StatusBadge, StatusDot } from "../components/status-badge"; import { Tooltip } from "../components/tooltip"; import BlockDetailsCard from "../components/block-details-card"; import { targetBlockIntervalSeconds } from "../const"; @@ -94,6 +94,7 @@ export default ({ className="transaction-block-details" block={block} t={t} + detailsOpen={block && S.openBlock === block.id} statusText={t`Confirmed`} />
@@ -276,11 +277,7 @@ const txHeader = ( {!isConfirmed ? ( - + ) : null} {confirmationText(tx.status, tipHeight, t)} diff --git a/lang/strings.txt b/lang/strings.txt index a1f47cff9..40e1b8d6c 100644 --- a/lang/strings.txt +++ b/lang/strings.txt @@ -16,15 +16,25 @@ Block Challenge Block height Block not found Block %s +Block #%s Block Solution Block #%s: %s +Block Signatures Block timestamp +Block Weight +Block filling +Block weight divided by four. Broadcast raw transaction (hex) Broadcast transaction Broadcast tx Cancel Coinbase CoinJoin transactions hide the link between inputs and outputs and improves Bitcoin's overall privacy and fungibility for everyone. +Commitment to all transactions included in the block. +EXPECTED IN < 1 MINUTE +EXPECTED IN ~%s MINUTE +EXPECTED IN ~%s MINUTES +EXPECTED INTERVAL REACHED compared to bitcoind's suggested fee of %s sat/vB for confirmation within 2 blocks Confidential Confirmed @@ -48,6 +58,8 @@ Fee Federation BTC Holdings Go Height +How full the block is. +In block In best chain In best chain (%s confirmations) Included in Block @@ -66,11 +78,13 @@ Last change N/A Last change on %s Likely self-transfer Linked domain +Loading block... Loading... Load more Lock time ltr Mempool +Mining... Merkle root N/A New asset @@ -78,6 +92,7 @@ Newer Next No Nonce +Nonce recorded in the block header. None Nonstandard No outputs @@ -86,6 +101,7 @@ No reissuance No results found nSequence Number of issuances +Number of transactions included in this block. Older OP_RETURN data Opted in @@ -137,6 +153,7 @@ Search for block height, hash, transaction, or address SegWit fee savings Sending exact amounts (with no change) is an indication the bitcoins likely didn't change hands. Sending to a different script type +Signatures authorizing this Elements block. %s from tip Size Size (KB) @@ -156,6 +173,10 @@ This transaction re-uses addresses. This makes it trivial to track your transact This transaction saved %s on fees by upgrading to native SegWit-Bech32 This transaction saved %s on fees by upgrading to SegWit and could save %s more by fully upgrading to native SegWit-Bech32 Ticker +Time Since Last Block +Time elapsed since this block was mined. +%s MINUTE PAST EXPECTED INTERVAL +%s MINUTES PAST EXPECTED INTERVAL Timestamp Total burned amount Total fees @@ -187,13 +208,16 @@ Using round payment amounts gives an indication of which output is the payment a Value Value commitment Version +Version bits recorded in the block header. Virtual size VOLUME IN VOLUME OUT We encountered an error. Please try again later. +Weight Weight (KWU) Weight units Witness Yes +< 1 MINUTE PAST EXPECTED INTERVAL < 1 min ~%s min diff --git a/test/app.test.js b/test/app.test.js new file mode 100644 index 000000000..b19941ed3 --- /dev/null +++ b/test/app.test.js @@ -0,0 +1,134 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +process.env.IS_ELEMENTS = "1"; +process.env.MENU_ACTIVE = "Liquid"; + +const { Observable: O } = require("../client/src/rxjs"); +const { + blockGridTransactionSelectEvent, +} = require("../client/src/const"); +const { + default: main, + trackPendingBlockTemplateEvent, +} = require("../client/src/app"); + +const empty$ = O.empty(); + +const makeRoute = () => { + const location = { + hash: "", + key: "home", + pathname: "/", + query: {}, + }; + const home$ = O.of(location); + const route = (pattern) => + pattern === undefined || pattern === "/" ? home$ : empty$; + + route.all$ = home$; + return route; +}; + +const makeSources = ({ + blockGridEvent$ = empty$, + selectedCategories = [], +} = {}) => ({ + DOM: { + select: (selector) => ({ + elements: () => empty$, + events: (eventName) => + selector === ".block-grid__canvas" && + eventName === blockGridTransactionSelectEvent + ? blockGridEvent$ + : empty$, + }), + }, + HTTP: { + select: (category) => { + selectedCategories.push(category); + return empty$; + }, + }, + blinding: empty$, + route: makeRoute(), + scanner: empty$, + search: empty$, + storage: { + local: { + getItem: () => O.of(null), + }, + }, +}); + +test("requests and consumes block templates on an Elements dashboard", () => { + const selectedCategories = []; + const sources = makeSources({ selectedCategories }); + const requests = []; + + main(sources).HTTP + .filter(({ category }) => category === "block-template") + .subscribe((request) => requests.push(request)); + + assert.ok(selectedCategories.includes("block-template")); + assert.deepEqual(requests, [ + { + bg: true, + category: "block-template", + method: "GET", + url: "/api/block-template", + }, + ]); +}); + +test("navigates a selected pending-block transaction in app history", () => { + const txid = "a".repeat(64); + const routeUpdates = []; + const sources = makeSources({ + blockGridEvent$: O.of({ detail: { txid } }), + }); + + main(sources).route.subscribe((update) => routeUpdates.push(update)); + + assert.deepEqual(routeUpdates, [ + { + type: "push", + pathname: `/tx/${txid}`, + }, + ]); +}); + +test("ignores block template responses for an older tip", () => { + const firstTip = "a".repeat(64); + const secondTip = "b".repeat(64); + const initialState = { + template: null, + key: null, + transactionCount: null, + delta: null, + tipId: null, + }; + const atFirstTip = trackPendingBlockTemplateEvent(initialState, { + tipId: firstTip, + }); + const firstTemplate = trackPendingBlockTemplateEvent(atFirstTip, { + template: { previousblockhash: firstTip, transactions: [] }, + }); + const atSecondTip = trackPendingBlockTemplateEvent(firstTemplate, { + tipId: secondTip, + }); + const secondTemplate = trackPendingBlockTemplateEvent(atSecondTip, { + template: { + previousblockhash: secondTip, + transactions: [{ txid: "c".repeat(64) }], + }, + }); + const afterLateFirstResponse = trackPendingBlockTemplateEvent( + secondTemplate, + { template: { previousblockhash: firstTip, transactions: [] } }, + ); + + assert.equal(atSecondTip.template, null); + assert.equal(secondTemplate.template.previousblockhash, secondTip); + assert.equal(afterLateFirstResponse, secondTemplate); +}); diff --git a/test/block-details-card.test.js b/test/block-details-card.test.js new file mode 100644 index 000000000..4a09b1bef --- /dev/null +++ b/test/block-details-card.test.js @@ -0,0 +1,329 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const render = require("snabbdom-to-html"); + +const BlockDetailsCard = + require("../client/src/components/block-details-card").default; +const PendingBlockDetailsCard = + require("../client/src/views/pending-block-details-card").default; +const { + blockTemplateStateForTip, +} = require("../client/src/views/blocks"); +const { + nativeAssetLabel, + targetBlockIntervalSeconds, +} = require("../client/src/const"); + +const t = (parts, ...values) => parts.reduce( + (result, part, index) => + result + part + (index < values.length ? values[index] : ""), + "", +); + +const block = { + id: "a".repeat(64), + height: 100, + timestamp: 1_700_000_000, + tx_count: 2, + size: 1_000_000, + weight: 4_000_000, + version: 1, + nonce: 2, + merkle_root: "b".repeat(64), +}; + +const emptyTemplateMetrics = { + averageFeeRate: null, + feeBuckets: { low: null, medium: null, high: null }, + legacyPercentage: 0, + segwitPercentage: 0, + sizeLimit: 4_000_000, + sizePercentage: 0, + templateTransactionCount: 0, + totalFees: 0, + totalSize: 0, + totalWeight: 0, + transactionCount: 1, + weightLimit: 4_000_000, + weightPercentage: 0, +}; + +test("passes block detail copy through localization", () => { + const localizedStrings = new Set(); + const t = (parts, ...values) => { + localizedStrings.add(parts.join("%s")); + return parts.reduce( + (result, part, index) => + result + part + (index < values.length ? values[index] : ""), + "", + ); + }; + + render(BlockDetailsCard({ block, detailsOpen: true, t })); + + const networkStrings = process.env.IS_ELEMENTS + ? [ + "Block Signatures", + "Signatures authorizing this Elements block.", + ] + : ["Nonce", "Nonce recorded in the block header."]; + + [ + "Block #%s", + "Block hash", + "Block Weight", + "Block is %s% full", + "Block filling", + "Block utilization unavailable", + "Block weight divided by four.", + "Commitment to all transactions included in the block.", + "Details", + "How full the block is.", + "In block", + "Loading block utilization", + "Merkle root", + "Number of transactions included in this block.", + "Size", + "Time Since Last Block", + "Time elapsed since this block was mined.", + "Transactions", + "Version", + "Version bits recorded in the block header.", + "Virtual size", + "Weight", + ...networkStrings, + ].forEach((message) => assert.ok( + localizedStrings.has(message), + `Missing localized string: ${message}`, + )); +}); + +test("compares confirmed blocks only against the weight limit", () => { + const html = render(BlockDetailsCard({ + block, + detailsOpen: true, + t: (parts, ...values) => parts.reduce( + (result, part, index) => + result + part + (index < values.length ? values[index] : ""), + "", + ), + })); + + assert.equal((html.match(/class="metric-bar-container"/g) || []).length, 1); + assert.doesNotMatch(html, /class="metric-bar-title">Size { + const html = render(BlockDetailsCard({ block, detailsOpen: true, t })); + const networkField = process.env.IS_ELEMENTS + ? "Block Signatures" + : "Nonce"; + + assert.ok(html.includes(block.id)); + assert.ok(html.indexOf("Block hash") < html.indexOf(networkField)); +}); + +test("disables block details until block metadata is available", () => { + const html = render(BlockDetailsCard({ block: null, t })); + + assert.match( + html, + /class="block-details-card-details-button"[^>]*disabled="disabled"/, + ); +}); + +test("shows the pending transaction live badge only with template data", () => { + const props = { + bitcoinMarketChart: null, + block, + detailsOpen: true, + feeEst: null, + mempool: null, + metrics: null, + t, + transactionDelta: null, + }; + + const unavailableHtml = render(PendingBlockDetailsCard({ + ...props, + blockTemplate: null, + })); + const liveHtml = render(PendingBlockDetailsCard({ + ...props, + blockTemplate: { + transactions: [], + weightlimit: 4_000_000, + sizelimit: 4_000_000, + }, + metrics: emptyTemplateMetrics, + })); + + assert.doesNotMatch(unavailableHtml, />LiveLiveBuilding\.\.\.Mining\.\.\. { + const state = { + template: { previousblockhash: block.id, transactions: [] }, + }; + + assert.equal(blockTemplateStateForTip(block, state), state); + assert.equal( + blockTemplateStateForTip({ ...block, id: "c".repeat(64) }, state), + null, + ); +}); + +test("uses the template limit for pending block utilization", () => { + const html = render(PendingBlockDetailsCard({ + bitcoinMarketChart: null, + block, + blockTemplate: { transactions: [] }, + detailsOpen: false, + feeEst: null, + mempool: null, + metrics: { + ...emptyTemplateMetrics, + totalWeight: 1_200, + weightPercentage: 50, + weightLimit: 2_400, + }, + t, + transactionDelta: null, + })); + + assert.match( + html, + /aria-label="Block is 50% full"/, + ); + assert.match(html, />Block filling { + const timestamp = Math.floor(Date.now() / 1000); + const html = render(PendingBlockDetailsCard({ + bitcoinMarketChart: null, + block: { ...block, timestamp }, + blockTemplate: { transactions: [] }, + detailsOpen: false, + feeEst: null, + mempool: null, + metrics: emptyTemplateMetrics, + t, + transactionDelta: null, + })); + const expectedMinutes = Math.ceil(targetBlockIntervalSeconds / 60); + const expectedIntervalPattern = targetBlockIntervalSeconds <= 60 + ? /EXPECTED IN < 1 MINUTE/ + : new RegExp(`EXPECTED IN ~${expectedMinutes} MINUTE`); + + assert.match(html, expectedIntervalPattern); +}); + +test("passes pending block copy through localization", () => { + const localizedStrings = new Set(); + const localizedT = (parts, ...values) => { + localizedStrings.add(parts.join("%s")); + return t(parts, ...values); + }; + + render(PendingBlockDetailsCard({ + bitcoinMarketChart: null, + block, + blockTemplate: { transactions: [] }, + detailsOpen: true, + feeEst: { 3: 10, 12: 2 }, + mempool: { count: 1, vsize: 1_000 }, + metrics: emptyTemplateMetrics, + t: localizedT, + transactionDelta: null, + })); + + [ + "Next Block", + process.env.IS_ELEMENTS ? "Building..." : "Mining...", + "Details", + "SIZE", + "Block filling", + "Transactions", + "Block Weight", + "Pending Transactions", + "Mempool Congestion", + ].forEach((message) => assert.ok( + localizedStrings.has(message), + `Missing localized string: ${message}`, + )); +}); + +test("renders precomputed pending-block metrics", () => { + const html = render(PendingBlockDetailsCard({ + bitcoinMarketChart: null, + block, + blockTemplate: { transactions: [] }, + detailsOpen: false, + feeEst: null, + mempool: null, + metrics: { ...emptyTemplateMetrics, transactionCount: 7 }, + t, + transactionDelta: null, + })); + + assert.match( + html, + /TRANSACTIONS<\/p>

7<\/p>/, + ); +}); + +test("keeps fee cost loading separate from fee-rate availability", () => { + const html = render(PendingBlockDetailsCard({ + bitcoinMarketChart: null, + block, + blockTemplate: { transactions: [] }, + detailsOpen: true, + feeEst: {}, + mempool: null, + metrics: emptyTemplateMetrics, + t, + transactionDelta: null, + })); + + assert.match( + html, + /class="info-card-container low-fee"[\s\S]*?

N\/A<\/p>